@skyhook-io/radar-app 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,7 @@ import {
4
4
  ApplicationsList,
5
5
  ApplicationDetail,
6
6
  CenteredEmpty,
7
+ PageHeader,
7
8
  useToast,
8
9
  orderEnvs,
9
10
  matchWorkloadAcrossInstances,
@@ -65,26 +66,33 @@ export function ApplicationsView({ namespaces, onOpenResource }: ApplicationsVie
65
66
  return <AppDetailRoute app={selected} apps={apps} onBack={() => selectApp(null)} onOpenResource={onOpenResource} />
66
67
  }
67
68
 
68
- return (
69
- <div className="flex-1 overflow-auto px-4 py-4 sm:px-6">
70
- <header className="mb-4 flex flex-col gap-1">
71
- <h1 className="text-xl font-semibold text-theme-text-primary">Applications</h1>
72
- <p className="max-w-3xl text-sm text-theme-text-secondary">Deployable software in this cluster — your services, workers, and jobs, grouped by app/release evidence.</p>
73
- </header>
69
+ // The header + status + filters + table chassis lives inside ApplicationsList
70
+ // (mirroring GitOpsTableView), which renders only on the data path. To keep
71
+ // the page header from vanishing while loading / on error, the wrapper shows
72
+ // the same header bar above those states. (Keep title + description in sync
73
+ // with ApplicationsList's PageHeader.)
74
+ if (query.isLoading || query.error) {
75
+ return (
76
+ <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
77
+ <div className="shrink-0 border-b border-theme-border px-4 py-4">
78
+ <PageHeader
79
+ icon={Boxes}
80
+ title="Applications"
81
+ description="Deployable software in this cluster — your services, workers, and jobs, grouped by app/release evidence."
82
+ />
83
+ </div>
84
+ {query.isLoading ? (
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
+ )}
89
+ </div>
90
+ )
91
+ }
74
92
 
75
- {query.isLoading ? (
76
- <CenteredEmpty icon={Boxes} headline="Loading applications…" />
77
- ) : query.error ? (
78
- <CenteredEmpty tone="filtered" icon={Boxes} headline="Failed to load applications" body={(query.error as Error).message} />
79
- ) : apps.length === 0 ? (
80
- <CenteredEmpty
81
- icon={Boxes}
82
- headline="No applications detected yet"
83
- body="Deploy services, workers, or jobs to this cluster to see them grouped by app."
84
- />
85
- ) : (
86
- <ApplicationsList apps={apps} onSelect={selectApp} />
87
- )}
93
+ return (
94
+ <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
95
+ <ApplicationsList apps={apps} onSelect={selectApp} />
88
96
  </div>
89
97
  )
90
98
  }
@@ -73,7 +73,7 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
73
73
  <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
74
74
  <div className="bg-theme-surface rounded-xl shadow-xl w-full max-w-lg mx-4 max-h-[80vh] flex flex-col" onClick={e => e.stopPropagation()}>
75
75
  <div className="flex items-center justify-between px-5 py-4 border-b border-theme-border shrink-0">
76
- <h2 className="text-sm font-semibold text-theme-text-primary">Audit Settings</h2>
76
+ <h2 className="text-sm font-semibold text-theme-text-primary">Checks Settings</h2>
77
77
  <button onClick={onClose} className="p-1 rounded-lg hover:bg-theme-hover transition-colors">
78
78
  <X className="w-4 h-4 text-theme-text-tertiary" />
79
79
  </button>
@@ -1,13 +1,12 @@
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, type CheckResourceRef } from '@skyhook-io/k8s-ui'
5
- import { ArrowLeft, ClipboardCheck, Settings } from 'lucide-react'
4
+ import { ChecksView, PaneLoader, PageHeader, type CheckResourceRef } from '@skyhook-io/k8s-ui'
5
+ import { ShieldCheck, Settings } from 'lucide-react'
6
6
  import { AuditSettingsDialog } from './AuditSettingsDialog'
7
7
 
8
8
  interface AuditViewProps {
9
9
  namespaces: string[]
10
- onBack: () => void
11
10
  onNavigateToResource: (resource: SelectedResource) => void
12
11
  }
13
12
 
@@ -17,7 +16,7 @@ interface AuditViewProps {
17
16
  // come pre-computed from radar's /api/audit (pkg/audit.BuildChecks); local
18
17
  // ~/.radar settings are this cluster's "policy" and the row hide-menu writes to
19
18
  // them.
20
- export function AuditView({ namespaces, onBack, onNavigateToResource }: AuditViewProps) {
19
+ export function AuditView({ namespaces, onNavigateToResource }: AuditViewProps) {
21
20
  const { data, isLoading, error } = useAudit(namespaces)
22
21
  const { data: auditSettings } = useAuditSettings()
23
22
  const updateSettings = useUpdateAuditSettings()
@@ -73,37 +72,26 @@ export function AuditView({ namespaces, onBack, onNavigateToResource }: AuditVie
73
72
  onNavigateToResource({ kind: ref.kind, namespace: ref.namespace, name: ref.name, group: ref.group })
74
73
 
75
74
  return (
76
- <div className="flex-1 flex flex-col min-h-0 p-6 gap-6 overflow-auto">
77
- {/* Header */}
78
- <div className="flex items-center gap-4">
79
- <button
80
- onClick={onBack}
81
- className="p-1.5 rounded-lg hover:bg-theme-hover transition-colors"
82
- >
83
- <ArrowLeft className="w-5 h-5 text-theme-text-secondary" />
84
- </button>
85
- <div className="flex-1">
86
- <div className="flex items-center gap-2">
87
- <ClipboardCheck className="w-5 h-5 text-theme-text-secondary" />
88
- <h1 className="text-lg font-semibold text-theme-text-primary">Checks</h1>
89
- </div>
90
- <p className="text-sm text-theme-text-tertiary mt-1 ml-7">
91
- Security, reliability, and efficiency best practices (NSA/CISA, CIS, Polaris, Kubescape), grouped into a remediation queue.
92
- </p>
93
- </div>
94
- <div className="flex items-center gap-2 shrink-0">
95
- {ignoredCount > 0 && (
96
- <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>
97
- )}
98
- <button
99
- onClick={() => setShowSettings(true)}
100
- className="p-2 rounded-lg hover:bg-theme-hover text-theme-text-tertiary hover:text-theme-text-secondary transition-colors"
101
- title="Checks settings"
102
- >
103
- <Settings className="w-4 h-4" />
104
- </button>
105
- </div>
106
- </div>
75
+ <div className="flex-1 flex flex-col min-h-0 p-4 gap-4 overflow-auto">
76
+ <PageHeader
77
+ icon={ShieldCheck}
78
+ title="Checks"
79
+ description="Security, reliability, and efficiency best practices (NSA/CISA, CIS, Polaris, Kubescape), grouped into a remediation queue."
80
+ actions={
81
+ <>
82
+ {ignoredCount > 0 && (
83
+ <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>
84
+ )}
85
+ <button
86
+ onClick={() => setShowSettings(true)}
87
+ className="p-2 rounded-lg hover:bg-theme-hover text-theme-text-tertiary hover:text-theme-text-secondary transition-colors"
88
+ title="Checks settings"
89
+ >
90
+ <Settings className="w-4 h-4" />
91
+ </button>
92
+ </>
93
+ }
94
+ />
107
95
 
108
96
  <ChecksView
109
97
  checks={data.groupedChecks ?? []}
@@ -1,4 +1,4 @@
1
- import { useEffect, useMemo, useState } from 'react'
1
+ import { useEffect, useMemo, useRef, useState } from 'react'
2
2
  import { useLocation, useNavigate } from 'react-router-dom'
3
3
  import { useQuery } from '@tanstack/react-query'
4
4
  import yaml from 'yaml'
@@ -204,6 +204,28 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
204
204
  window.setTimeout(refetchTable, 1200)
205
205
  }
206
206
 
207
+ // Cold-cache catch-up: right after a cluster/namespace switch (or first open)
208
+ // the GitOps CRD informers can still be warming, so the first list resolves
209
+ // EMPTY even though apps exist — stranding the user on "No applications found"
210
+ // until the 120s poll (which is why a manual Refresh "fixes" it). When the
211
+ // fetch settles empty, briefly retry (bounded) to catch the cache as it syncs.
212
+ // Reset the budget whenever the cluster (apiResources identity) or namespace
213
+ // scope changes so each switch gets a fresh set of retries.
214
+ const coldRetriesRef = useRef(0)
215
+ // While we're still retrying a cold cache, the view shows a spinner (not the
216
+ // false "No applications found") — so the user sees "loading", not "empty".
217
+ const [coldRetrying, setColdRetrying] = useState(false)
218
+ useEffect(() => { coldRetriesRef.current = 0; setColdRetrying(false) }, [apiResources, namespacesParam])
219
+ useEffect(() => {
220
+ if (apiResourcesLoading || rowsQuery.isFetching) return
221
+ if ((rowsQuery.data?.length ?? 0) > 0) { setColdRetrying(false); return }
222
+ if (coldRetriesRef.current >= 4) { setColdRetrying(false); return }
223
+ setColdRetrying(true)
224
+ const t = window.setTimeout(() => { coldRetriesRef.current += 1; refetchTable() }, 2000)
225
+ return () => window.clearTimeout(t)
226
+ // eslint-disable-next-line react-hooks/exhaustive-deps
227
+ }, [rowsQuery.data, rowsQuery.isFetching, apiResourcesLoading])
228
+
207
229
  const handleRowAction = (row: GitOpsRow, action: GitOpsRowAction) => {
208
230
  const { kindName: kind, namespace, name, id } = row
209
231
  const settle = { onSuccess: refetchTableAfterMutation, onSettled: () => markAction(id, action, false) }
@@ -246,7 +268,7 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
246
268
  <>
247
269
  <SharedGitOpsTableView
248
270
  rows={rowsQuery.data ?? []}
249
- loading={apiResourcesLoading || countsQuery.isLoading || rowsQuery.isLoading}
271
+ loading={apiResourcesLoading || countsQuery.isLoading || rowsQuery.isLoading || coldRetrying}
250
272
  error={(rowsQuery.error as Error | null) ?? null}
251
273
  counts={countsQuery.data?.counts ?? {}}
252
274
  onRefresh={() => rowsQuery.refetch()}
@@ -2,7 +2,7 @@ import { useState, useMemo, useRef, useEffect, useCallback, forwardRef } from 'r
2
2
  import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
3
3
  import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
4
4
  import { Package, Search, RefreshCw, ArrowUpCircle, LayoutGrid, List, Shield, GitBranch, ChevronRight } from 'lucide-react'
5
- import { PaneLoader } from '@skyhook-io/k8s-ui'
5
+ import { PaneLoader, PageHeader } from '@skyhook-io/k8s-ui'
6
6
  import { clsx } from 'clsx'
7
7
  import { useHelmReleases, useHelmBatchUpgradeInfo, isForbiddenError } from '../../api/client'
8
8
  import type { HelmRelease, SelectedHelmRelease, UpgradeInfo, ChartSource } from '../../types'
@@ -167,6 +167,14 @@ export function HelmView({ namespace, selectedRelease, onReleaseClick }: HelmVie
167
167
  <div className="flex h-full w-full">
168
168
  {/* Main Content */}
169
169
  <div className="flex-1 flex flex-col overflow-hidden min-w-0 w-full">
170
+ {/* Page header — consistent across views; the tabs + search sit below. */}
171
+ <div className="px-4 pt-4 pb-1">
172
+ <PageHeader
173
+ icon={Package}
174
+ title="Helm"
175
+ description="Installed Helm releases and the chart catalog for this cluster."
176
+ />
177
+ </div>
170
178
  {/* Tab bar */}
171
179
  <div className="flex items-center gap-1 px-4 pt-3 border-b border-theme-border bg-theme-surface/50">
172
180
  <button
@@ -204,13 +212,9 @@ export function HelmView({ namespace, selectedRelease, onReleaseClick }: HelmVie
204
212
  <>
205
213
  {/* Releases Toolbar */}
206
214
  <div className="flex items-center gap-4 px-4 py-3 border-b border-theme-border bg-theme-surface/50 shrink-0">
207
- <div className="flex items-center gap-2 text-theme-text-secondary">
208
- <Package className="w-5 h-5" />
209
- <span className="font-medium">Helm Releases</span>
210
- {!isFullyLoaded && (
211
- <RefreshCw className="w-3.5 h-3.5 animate-spin text-theme-text-tertiary" />
212
- )}
213
- </div>
215
+ {!isFullyLoaded && (
216
+ <RefreshCw className="w-3.5 h-3.5 animate-spin text-theme-text-tertiary shrink-0" />
217
+ )}
214
218
  <div className="flex-1 relative">
215
219
  <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-theme-text-tertiary" />
216
220
  <input
@@ -182,7 +182,7 @@ export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToR
182
182
  <BandItem>
183
183
  <AuditCard
184
184
  data={data.audit}
185
- onNavigate={() => onNavigateToView('audit')}
185
+ onNavigate={() => onNavigateToView('checks')}
186
186
  />
187
187
  </BandItem>
188
188
  )}
@@ -201,6 +201,40 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
201
201
  { arg: 'namespace', desc: 'required for ServiceAccount; omit for User/Group' },
202
202
  ],
203
203
  },
204
+ {
205
+ name: 'query_prometheus',
206
+ desc: "Run PromQL against the cluster's Prometheus (auto-discovered or configured; works with Thanos, VictoriaMetrics, Mimir). Instant queries return current values; range queries return time-series history with automatic step adjustment. Oversized results return a cardinality summary with a suggested topk rewrite instead of raw data.",
207
+ params: [
208
+ { arg: 'query', required: true, desc: 'PromQL query to execute' },
209
+ { arg: 'type', desc: 'instant (default) or range' },
210
+ { arg: 'since', desc: 'range lookback, e.g. 30m, 1h, 24h, 7d (default 1h)' },
211
+ { arg: 'start', desc: 'range RFC3339 start time; overrides since' },
212
+ { arg: 'end', desc: 'range RFC3339 end time (default now)' },
213
+ { arg: 'step', desc: 'range resolution, e.g. 30s, 5m (auto-calculated when omitted)' },
214
+ { arg: 'max_points', desc: 'max data points per series (default 300, max 600)' },
215
+ { arg: 'timeout', desc: 'query timeout in seconds (default 30, max 180)' },
216
+ ],
217
+ },
218
+ {
219
+ name: 'discover_metrics',
220
+ desc: 'Discover exact Prometheus metric names (with type and help text) or values of one label before writing PromQL. Lists active series from the last hour; flags truncation so the selector can be narrowed.',
221
+ params: [
222
+ { arg: 'match', desc: 'PromQL series selector filter, e.g. {__name__=~"node_cpu.*"}; required when label is empty' },
223
+ { arg: 'label', desc: 'discover values of this label instead of metric names, e.g. namespace, pod' },
224
+ { arg: 'limit', desc: 'max values returned (default 100, max 500)' },
225
+ ],
226
+ },
227
+ {
228
+ name: 'get_prometheus_rules',
229
+ desc: 'List Prometheus alerting and recording rules with their PromQL definitions, state (firing/pending/inactive), and active alert instances. The starting point for alert investigation: fetch the rule, then query its expression.',
230
+ params: [
231
+ { arg: 'type', desc: 'alert or record (omit for both)' },
232
+ { arg: 'name', desc: 'substring filter on rule name' },
233
+ { arg: 'group', desc: 'substring filter on rule group name' },
234
+ { arg: 'state', desc: 'alerting rules only: firing, pending, or inactive' },
235
+ { arg: 'limit', desc: 'max rules returned (default 50, max 200)' },
236
+ ],
237
+ },
204
238
  {
205
239
  name: 'get_workload_logs',
206
240
  desc: 'Aggregated, filtered logs across all pods of a workload (Deployment, StatefulSet, or DaemonSet) — collected concurrently, filtered for errors/warnings, and deduplicated.',
@@ -1,11 +1,23 @@
1
+ import { useMemo, useState } from 'react'
1
2
  import { useIssues } from '../../api/client'
2
3
  import type { SelectedResource } from '../../types'
3
- import { IssuesView, PaneLoader, type IssueResourceRef } from '@skyhook-io/k8s-ui'
4
- import { AlertTriangle, ArrowLeft } from 'lucide-react'
4
+ import {
5
+ IssuesView,
6
+ PaneLoader,
7
+ PageHeader,
8
+ SummaryTile,
9
+ ISSUE_SEVERITIES,
10
+ ISSUE_SEVERITY_LABEL,
11
+ type IssueResourceRef,
12
+ type IssueSeverity,
13
+ type SummaryTone,
14
+ } from '@skyhook-io/k8s-ui'
15
+ import { AlertTriangle } from 'lucide-react'
16
+
17
+ const SEVERITY_TONE: Record<IssueSeverity, SummaryTone> = { critical: 'error', warning: 'warning' }
5
18
 
6
19
  interface IssuesPaneProps {
7
20
  namespaces: string[]
8
- onBack: () => void
9
21
  onNavigateToResource: (resource: SelectedResource) => void
10
22
  }
11
23
 
@@ -13,9 +25,28 @@ interface IssuesPaneProps {
13
25
  // (IssuesView) the Hub fleet view uses — single cluster here, so no cluster
14
26
  // label and in-app (client-side) resource navigation. Classification +
15
27
  // owner-grouping come pre-computed from radar's /api/issues
16
- // (internal/issues.Compose → Classify → Group).
17
- export function IssuesPane({ namespaces, onBack, onNavigateToResource }: IssuesPaneProps) {
28
+ // (internal/issues.Compose → Classify → Group). Filtering is the host's job
29
+ // (IssuesView is a pure list); single-cluster gets a light severity filter via
30
+ // the header status tiles (clickable → filter), matching the Applications /
31
+ // GitOps header-tile pattern rather than Hub's fleet facet sidebar.
32
+ export function IssuesPane({ namespaces, onNavigateToResource }: IssuesPaneProps) {
18
33
  const { data, isLoading, error } = useIssues(namespaces)
34
+ const [severityFilter, setSeverityFilter] = useState<Set<IssueSeverity>>(new Set())
35
+
36
+ const allIssues = data?.issues ?? []
37
+ const totals = useMemo(() => {
38
+ const t: Record<IssueSeverity, number> = { critical: 0, warning: 0 }
39
+ for (const i of allIssues) t[i.severity] = (t[i.severity] ?? 0) + 1
40
+ return t
41
+ }, [allIssues])
42
+ const shown = severityFilter.size ? allIssues.filter((i) => severityFilter.has(i.severity)) : allIssues
43
+
44
+ const toggleSeverity = (s: IssueSeverity) =>
45
+ setSeverityFilter((prev) => {
46
+ const next = new Set(prev)
47
+ next.has(s) ? next.delete(s) : next.add(s)
48
+ return next
49
+ })
19
50
 
20
51
  const onResourceClick = (ref: IssueResourceRef) =>
21
52
  onNavigateToResource({ kind: ref.kind, namespace: ref.namespace ?? '', name: ref.name, group: ref.group ?? '' })
@@ -33,30 +64,37 @@ export function IssuesPane({ namespaces, onBack, onNavigateToResource }: IssuesP
33
64
  }
34
65
 
35
66
  return (
36
- <div className="flex-1 flex flex-col min-h-0 p-6 gap-6 overflow-auto">
37
- <div className="flex items-center gap-4">
38
- <button
39
- onClick={onBack}
40
- className="p-1.5 rounded-lg hover:bg-theme-hover transition-colors"
41
- >
42
- <ArrowLeft className="w-5 h-5 text-theme-text-secondary" />
43
- </button>
44
- <div className="flex-1">
45
- <div className="flex items-center gap-2">
46
- <AlertTriangle className="w-5 h-5 text-theme-text-secondary" />
47
- <h1 className="text-lg font-semibold text-theme-text-primary">Issues</h1>
48
- </div>
49
- <p className="text-sm text-theme-text-tertiary mt-1 ml-7">
50
- Live cluster problems — crashes, scheduling failures, bad references — grouped by the resource they affect.
51
- </p>
52
- </div>
53
- </div>
67
+ <div className="flex-1 flex flex-col min-h-0 p-4 gap-4 overflow-auto">
68
+ <PageHeader
69
+ icon={AlertTriangle}
70
+ title="Issues"
71
+ description="Live cluster problems — crashes, scheduling failures, bad references — grouped by the resource they affect."
72
+ 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
90
+ }
91
+ />
54
92
 
55
93
  {/* Visibility honesty: when RBAC reads are incomplete, an empty queue may
56
94
  mean "can't see" rather than "nothing broken" — say so up front so the
57
95
  empty state isn't mistaken for a clean bill of health. */}
58
96
  {data?.visibility?.impact && (
59
- <div className="-mt-3 flex items-start gap-2 rounded-lg border border-theme-border bg-theme-elevated px-3 py-2 text-xs text-theme-text-secondary">
97
+ <div className="flex items-start gap-2 rounded-lg border border-theme-border bg-theme-elevated px-3 py-2 text-xs text-theme-text-secondary">
60
98
  <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
61
99
  <span>Limited visibility — {data.visibility.impact} Results may be incomplete.</span>
62
100
  </div>
@@ -65,14 +103,30 @@ export function IssuesPane({ namespaces, onBack, onNavigateToResource }: IssuesP
65
103
  {/* Truncation honesty: when more issues matched than were returned, say
66
104
  so — don't present a capped list as the complete picture. */}
67
105
  {data?.total_matched != null && data.total_matched > (data.issues?.length ?? 0) && (
68
- <p className="-mt-3 text-xs text-theme-text-tertiary">
106
+ <p className="text-xs text-theme-text-tertiary">
69
107
  Showing {data.issues?.length ?? 0} of {data.total_matched} issues (capped) — narrow by namespace to see the rest.
70
108
  </p>
71
109
  )}
72
110
 
73
- {/* anyData = the query resolved, i.e. the cluster is reachable; an empty
74
- list then means "nothing broken" rather than "not connected". */}
75
- <IssuesView issues={data?.issues ?? []} anyData={!!data} onResourceClick={onResourceClick} />
111
+ {/* Filtered-empty is NOT the healthy empty state: when a severity filter
112
+ hides every row but issues still exist, say "no matches" rather than
113
+ letting IssuesView render its "nothing broken" terminal state. */}
114
+ {severityFilter.size > 0 && allIssues.length > 0 && shown.length === 0 ? (
115
+ <div className="flex flex-col items-center gap-2 py-12 text-center text-sm text-theme-text-secondary">
116
+ <p>No issues match the selected severity.</p>
117
+ <button
118
+ type="button"
119
+ onClick={() => setSeverityFilter(new Set())}
120
+ className="text-xs text-skyhook-600 hover:text-skyhook-500 dark:text-skyhook-400"
121
+ >
122
+ Clear filter
123
+ </button>
124
+ </div>
125
+ ) : (
126
+ /* anyData = the query resolved, i.e. the cluster is reachable; an empty
127
+ list then means "nothing broken" rather than "not connected". */
128
+ <IssuesView issues={shown} anyData={!!data} onResourceClick={onResourceClick} />
129
+ )}
76
130
  </div>
77
131
  )
78
132
  }