@skyhook-io/k8s-ui 1.7.7 → 1.7.8
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 +6 -1
- package/src/components/charts/PrometheusChartsView.tsx +233 -0
- package/src/components/charts/index.ts +13 -0
- package/src/components/checks/ChecksView.tsx +5 -1
- package/src/components/gitops/GitOpsTableView.tsx +1 -2
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +3 -3
- package/src/components/issues/IssuesView.tsx +352 -0
- package/src/components/issues/index.ts +24 -0
- package/src/components/issues/issues.test.ts +100 -0
- package/src/components/issues/severity.ts +125 -0
- package/src/components/issues/types.ts +194 -0
- package/src/components/resources/ResourcesView.tsx +2 -19
- package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +1 -1
- package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +1 -1
- package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +1 -1
- package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +1 -1
- package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
- package/src/components/resources/renderers/CrossplanePackageRenderer.tsx +5 -5
- package/src/components/resources/renderers/CrossplaneProviderConfigRenderer.tsx +1 -1
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +1 -1
- package/src/components/resources/renderers/PodRenderer.test.tsx +53 -0
- package/src/components/resources/renderers/PodRenderer.tsx +7 -1
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +1 -1
- package/src/components/resources/renderers/WorkloadRenderer.test.tsx +41 -0
- package/src/components/resources/renderers/WorkloadRenderer.tsx +49 -7
- package/src/components/resources/renderers/XRDRenderer.tsx +2 -2
- package/src/components/shared/DetailShell.tsx +107 -0
- package/src/components/shared/ManagedByChip.tsx +149 -16
- package/src/components/shared/ResourceRendererDispatch.test.tsx +45 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +13 -1
- package/src/components/shared/index.ts +2 -1
- package/src/components/timeline/TimelineSwimlanes.tsx +1320 -0
- package/src/components/timeline/index.ts +1 -0
- package/src/components/topology/K8sResourceNode.tsx +60 -60
- package/src/components/topology/TopologyFilterSidebar.tsx +25 -3
- package/src/components/topology/TopologyGraph.tsx +168 -52
- package/src/components/topology/TopologySearch.tsx +17 -8
- package/src/components/topology/topology-search-match.test.ts +1 -1
- package/src/components/topology/topology.css +18 -0
- package/src/components/ui/Tooltip.tsx +37 -2
- package/src/components/workload/WorkloadView.tsx +118 -112
- package/src/index.ts +5 -0
- package/src/theme/components.css +16 -0
- package/src/types/core.ts +3 -2
- package/src/utils/env-from.ts +3 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/replica-scalers.ts +9 -0
- package/src/components/resources/resources-search-sidebar-hint.test.ts +0 -85
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/k8s-ui",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.8",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/skyhook-io/radar",
|
|
@@ -31,6 +31,11 @@
|
|
|
31
31
|
"./src/components/gitops/*.tsx",
|
|
32
32
|
"./src/components/gitops/*.ts"
|
|
33
33
|
],
|
|
34
|
+
"./components/issues": "./src/components/issues/index.ts",
|
|
35
|
+
"./components/issues/*": [
|
|
36
|
+
"./src/components/issues/*.tsx",
|
|
37
|
+
"./src/components/issues/*.ts"
|
|
38
|
+
],
|
|
34
39
|
"./components/timeline/*": [
|
|
35
40
|
"./src/components/timeline/*.tsx",
|
|
36
41
|
"./src/components/timeline/*.ts"
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { clsx } from 'clsx'
|
|
2
|
+
import { BarChart3, Wifi, WifiOff, Loader2 } from 'lucide-react'
|
|
3
|
+
import { AreaChart } from './AreaChart'
|
|
4
|
+
import { MetricsSummary } from './MetricsSummary'
|
|
5
|
+
import { SeriesLegend } from './SeriesLegend'
|
|
6
|
+
import type { TimeSeries, ReferenceLine } from './types'
|
|
7
|
+
|
|
8
|
+
// Pure presentational Prometheus charts view, shared between radar's WorkloadView
|
|
9
|
+
// and Radar Hub's Application metrics tab. It owns NO data fetching — the host
|
|
10
|
+
// passes status, the selected category/range, the metrics result, and an
|
|
11
|
+
// onConnect callback. radar/web injects its global-apiBase hooks; the hub injects
|
|
12
|
+
// per-cluster tunnel fetches. Built on the AreaChart/MetricsSummary/SeriesLegend
|
|
13
|
+
// primitives already in this package.
|
|
14
|
+
|
|
15
|
+
export type PrometheusMetricCategory = 'cpu' | 'memory' | 'network_rx' | 'network_tx' | 'filesystem' | 'restarts'
|
|
16
|
+
export type PrometheusTimeRange = '10m' | '30m' | '1h' | '3h' | '6h' | '12h' | '24h' | '48h' | '7d' | '14d'
|
|
17
|
+
|
|
18
|
+
export interface MetricCategoryDef {
|
|
19
|
+
key: PrometheusMetricCategory
|
|
20
|
+
label: string
|
|
21
|
+
color: string // tailwind text class for the summary's current value
|
|
22
|
+
chartColor: string // hex for the SVG line
|
|
23
|
+
fillColor: string // hex + alpha for the SVG fill
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const WORKLOAD_METRIC_CATEGORIES: MetricCategoryDef[] = [
|
|
27
|
+
{ key: 'cpu', label: 'CPU', color: 'text-blue-400', chartColor: '#60a5fa', fillColor: '#60a5fa22' },
|
|
28
|
+
{ key: 'memory', label: 'Memory', color: 'text-purple-400', chartColor: '#c084fc', fillColor: '#c084fc22' },
|
|
29
|
+
{ key: 'network_rx', label: 'Net RX', color: 'text-emerald-400', chartColor: '#34d399', fillColor: '#34d39922' },
|
|
30
|
+
{ key: 'network_tx', label: 'Net TX', color: 'text-orange-400', chartColor: '#fb923c', fillColor: '#fb923c22' },
|
|
31
|
+
{ key: 'filesystem', label: 'Disk I/O', color: 'text-amber-400', chartColor: '#fbbf24', fillColor: '#fbbf2422' },
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
export const NODE_METRIC_CATEGORIES: MetricCategoryDef[] = [
|
|
35
|
+
{ key: 'cpu', label: 'CPU', color: 'text-blue-400', chartColor: '#60a5fa', fillColor: '#60a5fa22' },
|
|
36
|
+
{ key: 'memory', label: 'Memory', color: 'text-purple-400', chartColor: '#c084fc', fillColor: '#c084fc22' },
|
|
37
|
+
{ key: 'filesystem', label: 'Disk', color: 'text-amber-400', chartColor: '#fbbf24', fillColor: '#fbbf2422' },
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
export const METRIC_TIME_RANGES: { value: PrometheusTimeRange; label: string }[] = [
|
|
41
|
+
{ value: '10m', label: '10m' },
|
|
42
|
+
{ value: '30m', label: '30m' },
|
|
43
|
+
{ value: '1h', label: '1h' },
|
|
44
|
+
{ value: '3h', label: '3h' },
|
|
45
|
+
{ value: '6h', label: '6h' },
|
|
46
|
+
{ value: '12h', label: '12h' },
|
|
47
|
+
{ value: '24h', label: '24h' },
|
|
48
|
+
{ value: '7d', label: '7d' },
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
const SUPPORTED_KINDS = new Set([
|
|
52
|
+
'Pod', 'Deployment', 'StatefulSet', 'DaemonSet', 'ReplicaSet', 'Job', 'CronJob', 'Node',
|
|
53
|
+
])
|
|
54
|
+
|
|
55
|
+
export interface PrometheusResourceMetricsResult {
|
|
56
|
+
unit: string
|
|
57
|
+
result?: { series?: TimeSeries[] }
|
|
58
|
+
query?: string
|
|
59
|
+
hint?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface PrometheusChartsViewProps {
|
|
63
|
+
kind: string
|
|
64
|
+
/** When false (embedded in an Overview), hide entirely when not connected or no data. */
|
|
65
|
+
showEmptyState?: boolean
|
|
66
|
+
// Connection status (host-provided)
|
|
67
|
+
statusLoading: boolean
|
|
68
|
+
isConnected: boolean
|
|
69
|
+
statusError?: string
|
|
70
|
+
onConnect: () => void
|
|
71
|
+
connecting: boolean
|
|
72
|
+
// Selection (controlled by host)
|
|
73
|
+
category: PrometheusMetricCategory
|
|
74
|
+
onCategoryChange: (c: PrometheusMetricCategory) => void
|
|
75
|
+
range: PrometheusTimeRange
|
|
76
|
+
onRangeChange: (r: PrometheusTimeRange) => void
|
|
77
|
+
// Metrics (host-fetched)
|
|
78
|
+
metrics?: PrometheusResourceMetricsResult
|
|
79
|
+
metricsLoading: boolean
|
|
80
|
+
metricsError?: Error | null
|
|
81
|
+
referenceLines?: ReferenceLine[]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function PrometheusChartsView({
|
|
85
|
+
kind,
|
|
86
|
+
showEmptyState = false,
|
|
87
|
+
statusLoading,
|
|
88
|
+
isConnected,
|
|
89
|
+
statusError,
|
|
90
|
+
onConnect,
|
|
91
|
+
connecting,
|
|
92
|
+
category,
|
|
93
|
+
onCategoryChange,
|
|
94
|
+
range,
|
|
95
|
+
onRangeChange,
|
|
96
|
+
metrics,
|
|
97
|
+
metricsLoading,
|
|
98
|
+
metricsError,
|
|
99
|
+
referenceLines,
|
|
100
|
+
}: PrometheusChartsViewProps) {
|
|
101
|
+
const categories = kind === 'Node' ? NODE_METRIC_CATEGORIES : WORKLOAD_METRIC_CATEGORIES
|
|
102
|
+
const isSupported = SUPPORTED_KINDS.has(kind)
|
|
103
|
+
const activeCategoryDef = categories.find((c) => c.key === category) || categories[0]
|
|
104
|
+
const series = metrics?.result?.series ?? []
|
|
105
|
+
|
|
106
|
+
if (!isSupported) return null
|
|
107
|
+
|
|
108
|
+
if (statusLoading) {
|
|
109
|
+
if (!showEmptyState) return null
|
|
110
|
+
return (
|
|
111
|
+
<div className="flex items-center justify-center py-12 text-theme-text-tertiary">
|
|
112
|
+
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
|
113
|
+
Checking Prometheus availability...
|
|
114
|
+
</div>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!showEmptyState) {
|
|
119
|
+
if (!isConnected) return null
|
|
120
|
+
if (!metricsLoading && !metricsError && !series.length) return null
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!isConnected) {
|
|
124
|
+
return (
|
|
125
|
+
<div className="flex flex-col items-center justify-center gap-4 py-12">
|
|
126
|
+
<WifiOff className="h-10 w-10 text-theme-text-tertiary" />
|
|
127
|
+
<div className="text-center">
|
|
128
|
+
<p className="mb-1 text-sm text-theme-text-secondary">Prometheus not connected</p>
|
|
129
|
+
<p className="mb-4 text-xs text-theme-text-tertiary">
|
|
130
|
+
{statusError || 'Connect to view historical CPU, memory, and network metrics'}
|
|
131
|
+
</p>
|
|
132
|
+
<button
|
|
133
|
+
type="button"
|
|
134
|
+
onClick={onConnect}
|
|
135
|
+
disabled={connecting}
|
|
136
|
+
className="btn-brand inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium"
|
|
137
|
+
>
|
|
138
|
+
{connecting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Wifi className="h-4 w-4" />}
|
|
139
|
+
Discover Prometheus
|
|
140
|
+
</button>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<div className="flex h-full flex-col">
|
|
148
|
+
{/* Toolbar */}
|
|
149
|
+
<div className="flex shrink-0 items-center justify-between border-b border-theme-border bg-theme-surface/50 px-4 py-2.5">
|
|
150
|
+
<div className="flex items-center gap-1">
|
|
151
|
+
<BarChart3 className="mr-2 h-4 w-4 text-theme-text-tertiary" />
|
|
152
|
+
{categories.map((cat) => (
|
|
153
|
+
<button
|
|
154
|
+
key={cat.key}
|
|
155
|
+
type="button"
|
|
156
|
+
onClick={() => onCategoryChange(cat.key)}
|
|
157
|
+
className={clsx(
|
|
158
|
+
'rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
|
|
159
|
+
category === cat.key
|
|
160
|
+
? 'bg-theme-elevated text-theme-text-primary shadow-sm'
|
|
161
|
+
: 'text-theme-text-tertiary hover:bg-theme-elevated/50 hover:text-theme-text-secondary',
|
|
162
|
+
)}
|
|
163
|
+
>
|
|
164
|
+
{cat.label}
|
|
165
|
+
</button>
|
|
166
|
+
))}
|
|
167
|
+
</div>
|
|
168
|
+
<select
|
|
169
|
+
value={range}
|
|
170
|
+
onChange={(e) => onRangeChange(e.target.value as PrometheusTimeRange)}
|
|
171
|
+
className="rounded-md border border-theme-border bg-theme-elevated px-2 py-1 text-xs text-theme-text-secondary focus:outline-none focus:ring-1 focus:ring-blue-500/50"
|
|
172
|
+
>
|
|
173
|
+
{METRIC_TIME_RANGES.map((tr) => (
|
|
174
|
+
<option key={tr.value} value={tr.value}>
|
|
175
|
+
{tr.label}
|
|
176
|
+
</option>
|
|
177
|
+
))}
|
|
178
|
+
</select>
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
{/* Chart area */}
|
|
182
|
+
<div className="min-h-[280px] flex-1 p-4">
|
|
183
|
+
{metricsLoading ? (
|
|
184
|
+
<div className="flex min-h-[240px] items-center justify-center text-theme-text-tertiary">
|
|
185
|
+
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
|
186
|
+
Loading metrics...
|
|
187
|
+
</div>
|
|
188
|
+
) : metricsError ? (
|
|
189
|
+
<div className="flex h-full items-center justify-center text-sm text-red-400">
|
|
190
|
+
Failed to load metrics: {(metricsError as Error).message}
|
|
191
|
+
</div>
|
|
192
|
+
) : series.length ? (
|
|
193
|
+
<div className="flex h-full flex-col gap-4">
|
|
194
|
+
<MetricsSummary series={series} unit={metrics!.unit} currentColorClass={activeCategoryDef.color} />
|
|
195
|
+
<div className="min-h-0 flex-1">
|
|
196
|
+
<AreaChart
|
|
197
|
+
series={series}
|
|
198
|
+
color={activeCategoryDef.chartColor}
|
|
199
|
+
fillColor={activeCategoryDef.fillColor}
|
|
200
|
+
unit={metrics!.unit}
|
|
201
|
+
referenceLines={referenceLines}
|
|
202
|
+
/>
|
|
203
|
+
</div>
|
|
204
|
+
{series.length > 1 && <SeriesLegend series={series} color={activeCategoryDef.chartColor} />}
|
|
205
|
+
</div>
|
|
206
|
+
) : (
|
|
207
|
+
<div className="flex h-full flex-col items-center justify-center text-theme-text-tertiary">
|
|
208
|
+
<BarChart3 className="mb-2 h-8 w-8 opacity-40" />
|
|
209
|
+
<p className="text-sm">No data for this time range</p>
|
|
210
|
+
<p className="mt-1 text-xs text-theme-text-quaternary">
|
|
211
|
+
Try a different time range or check that metrics are being collected
|
|
212
|
+
</p>
|
|
213
|
+
{metrics?.hint && (
|
|
214
|
+
<p className="mt-3 w-full max-w-lg rounded border border-yellow-500/30 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-700 dark:text-yellow-400">
|
|
215
|
+
{metrics.hint}
|
|
216
|
+
</p>
|
|
217
|
+
)}
|
|
218
|
+
{metrics?.query && (
|
|
219
|
+
<details className="mt-3 w-full max-w-lg text-left">
|
|
220
|
+
<summary className="cursor-pointer text-xs text-theme-text-quaternary hover:text-theme-text-tertiary">
|
|
221
|
+
Diagnostics: show PromQL query
|
|
222
|
+
</summary>
|
|
223
|
+
<div className="mt-2 break-all rounded border border-theme-border bg-theme-base p-2 font-mono text-xs text-theme-text-secondary">
|
|
224
|
+
{metrics.query}
|
|
225
|
+
</div>
|
|
226
|
+
</details>
|
|
227
|
+
)}
|
|
228
|
+
</div>
|
|
229
|
+
)}
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
)
|
|
233
|
+
}
|
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
export { AreaChart } from './AreaChart'
|
|
2
2
|
export { MetricsSummary } from './MetricsSummary'
|
|
3
3
|
export { SeriesLegend } from './SeriesLegend'
|
|
4
|
+
export {
|
|
5
|
+
PrometheusChartsView,
|
|
6
|
+
WORKLOAD_METRIC_CATEGORIES,
|
|
7
|
+
NODE_METRIC_CATEGORIES,
|
|
8
|
+
METRIC_TIME_RANGES,
|
|
9
|
+
} from './PrometheusChartsView'
|
|
10
|
+
export type {
|
|
11
|
+
PrometheusChartsViewProps,
|
|
12
|
+
PrometheusMetricCategory,
|
|
13
|
+
PrometheusTimeRange,
|
|
14
|
+
MetricCategoryDef,
|
|
15
|
+
PrometheusResourceMetricsResult,
|
|
16
|
+
} from './PrometheusChartsView'
|
|
4
17
|
export { SERIES_COLORS, seriesColor, seriesFill, computeShortLabels } from './colors'
|
|
5
18
|
export { formatMetricValue, formatTimestamp } from './format'
|
|
6
19
|
export { computeSaturation } from './saturation'
|
|
@@ -703,7 +703,11 @@ function FindingLine({
|
|
|
703
703
|
{body}
|
|
704
704
|
</button>
|
|
705
705
|
) : resourceHref ? (
|
|
706
|
-
|
|
706
|
+
// Opens in a new tab to match the ExternalLink glyph in `body`. For the
|
|
707
|
+
// Hub fleet view this href crosses the Hub→Cluster SPA boundary (a full
|
|
708
|
+
// document nav); a new tab keeps the Checks queue intact and avoids the
|
|
709
|
+
// cross-tree browser-Back dead-end (SKY-931).
|
|
710
|
+
<a href={resourceHref(r)} target="_blank" rel="noreferrer" className={cls}>
|
|
707
711
|
{body}
|
|
708
712
|
</a>
|
|
709
713
|
) : (
|
|
@@ -552,7 +552,7 @@ export function GitOpsTableView({
|
|
|
552
552
|
// understand why some destinations show as unmatched.
|
|
553
553
|
<div className="shrink-0 border-b border-amber-500/30 bg-amber-500/10 px-4 py-2 text-xs text-amber-700 dark:text-amber-300">
|
|
554
554
|
<span className="font-medium">Cross-cluster mapping limited for {forbiddenSecretsClusters.join(', ')}.</span>{' '}
|
|
555
|
-
Owner needs <code>get secrets</code> in the <code>argocd</code> namespace there; destination
|
|
555
|
+
Owner needs <code className="inline-code">get secrets</code> in the <code className="inline-code">argocd</code> namespace there; destination
|
|
556
556
|
correlation falls back to name-only resolution.
|
|
557
557
|
</div>
|
|
558
558
|
)}
|
|
@@ -1915,4 +1915,3 @@ function newestConditionTime(resource: any): string {
|
|
|
1915
1915
|
.sort()
|
|
1916
1916
|
return times[times.length - 1] ?? ''
|
|
1917
1917
|
}
|
|
1918
|
-
|
|
@@ -818,12 +818,12 @@ export function GitOpsChangesView({ insight, error, onOpenResource, focusKey, tr
|
|
|
818
818
|
<div className="border-b border-theme-border bg-theme-base/40 px-4 py-2 text-[11px] text-theme-text-tertiary">
|
|
819
819
|
Radar reads each resource's drift status from the controller. For a line-by-line diff, {insight.summary.tool === 'fluxcd' ? (
|
|
820
820
|
insight.summary.kind === 'HelmRelease' ? (
|
|
821
|
-
<>run <code className="
|
|
821
|
+
<>run <code className="inline-code text-[10px]">helm diff upgrade {insight.summary.name} <chart></code> (requires the helm-diff plugin).</>
|
|
822
822
|
) : (
|
|
823
|
-
<>run <code className="
|
|
823
|
+
<>run <code className="inline-code text-[10px]">flux diff kustomization {insight.summary.name} --path <local-manifests></code>.</>
|
|
824
824
|
)
|
|
825
825
|
) : (
|
|
826
|
-
<>use the Argo CD UI or run <code className="
|
|
826
|
+
<>use the Argo CD UI or run <code className="inline-code text-[10px]">argocd app diff {insight.summary.name}</code>.</>
|
|
827
827
|
)}
|
|
828
828
|
</div>
|
|
829
829
|
)}
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { useMemo, useState, type ReactNode } from 'react';
|
|
2
|
+
import { ChevronRight, CircleCheck, Clock, ExternalLink } from 'lucide-react';
|
|
3
|
+
import { ClusterName, EmptyState } from '../ui';
|
|
4
|
+
import { formatCompactAge, formatRelativeAgeTime } from '../../utils/format';
|
|
5
|
+
import {
|
|
6
|
+
ISSUE_SEVERITY_BADGE_CLASS,
|
|
7
|
+
ISSUE_SEVERITY_LABEL,
|
|
8
|
+
ISSUE_SEVERITY_RAIL_CLASS,
|
|
9
|
+
categoryLabel,
|
|
10
|
+
groupBadgeClass,
|
|
11
|
+
groupLabel,
|
|
12
|
+
} from './severity';
|
|
13
|
+
import {
|
|
14
|
+
compareIssues,
|
|
15
|
+
issueMessageParts,
|
|
16
|
+
memberRef,
|
|
17
|
+
subjectRef,
|
|
18
|
+
type Issue,
|
|
19
|
+
type IssueAffected,
|
|
20
|
+
type IssueResourceRef,
|
|
21
|
+
} from './types';
|
|
22
|
+
|
|
23
|
+
export interface IssuesViewProps {
|
|
24
|
+
/** Grouped live issues — one row per subject+category. Typically flattened
|
|
25
|
+
* across the fleet by the host (the hub) or a single cluster (OSS). */
|
|
26
|
+
issues: Issue[];
|
|
27
|
+
/** True when at least one source returned issue data — distinguishes "clean"
|
|
28
|
+
* from "nothing connected / everything errored". */
|
|
29
|
+
anyData: boolean;
|
|
30
|
+
/** Resolve a deep-link href for a resource (host-specific routing). Omit to
|
|
31
|
+
* render non-link text. */
|
|
32
|
+
resourceHref?: (ref: IssueResourceRef) => string;
|
|
33
|
+
/** In-app resource navigation. When set, resource lines call this (no reload)
|
|
34
|
+
* instead of following resourceHref — OSS opens its own drawer this way.
|
|
35
|
+
* Takes precedence over resourceHref. */
|
|
36
|
+
onResourceClick?: (ref: IssueResourceRef) => void;
|
|
37
|
+
/** Display label for an issue's source cluster. Omit (or return falsy) to
|
|
38
|
+
* hide the cluster line — e.g. single-cluster OSS. */
|
|
39
|
+
clusterLabel?: (issue: Issue) => string | undefined;
|
|
40
|
+
/** Empty-state CTA shown when there's no data. */
|
|
41
|
+
emptyAction?: ReactNode;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The queue list. Filtering/faceting is the host page's job (FleetPageShell on
|
|
45
|
+
// the hub, a thin wrapper in OSS) — this renders the rows + the healthy /
|
|
46
|
+
// no-data terminal states only.
|
|
47
|
+
export function IssuesView({ issues, anyData, resourceHref, onResourceClick, clusterLabel, emptyAction }: IssuesViewProps) {
|
|
48
|
+
// Single-open accordion: opening a row collapses the previous one, so the
|
|
49
|
+
// queue stays scannable and you never lose your place to a wall of expansions.
|
|
50
|
+
const [openId, setOpenId] = useState<string | null>(null);
|
|
51
|
+
|
|
52
|
+
// Stable order keyed on severity → onset → identity (see compareIssues), so
|
|
53
|
+
// the queue doesn't reshuffle under the host's auto-refresh.
|
|
54
|
+
const sorted = useMemo(() => [...issues].sort(compareIssues), [issues]);
|
|
55
|
+
|
|
56
|
+
if (sorted.length === 0) {
|
|
57
|
+
return anyData ? (
|
|
58
|
+
<EmptyState
|
|
59
|
+
tone="healthy"
|
|
60
|
+
variant="card"
|
|
61
|
+
icon={CircleCheck}
|
|
62
|
+
headline="Nothing broken right now"
|
|
63
|
+
body="No active issues across the selected scope."
|
|
64
|
+
/>
|
|
65
|
+
) : (
|
|
66
|
+
<EmptyState headline="No issue data yet" body="Connect a cluster to populate the issue queue." action={emptyAction} />
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<ol className="flex flex-col gap-1.5">
|
|
72
|
+
{sorted.map((issue) => {
|
|
73
|
+
// Stable identity for the React key + open-accordion state, so a row
|
|
74
|
+
// survives auto-refresh in place. cluster_id scopes the id across the
|
|
75
|
+
// fleet (the hub renders issues from many clusters in one list).
|
|
76
|
+
const rowKey = `${issue.cluster_id ?? ''}:${issue.id}`;
|
|
77
|
+
return (
|
|
78
|
+
<IssueRow
|
|
79
|
+
key={rowKey}
|
|
80
|
+
issue={issue}
|
|
81
|
+
clusterLabel={clusterLabel}
|
|
82
|
+
open={openId === rowKey}
|
|
83
|
+
onToggle={() => setOpenId((cur) => (cur === rowKey ? null : rowKey))}
|
|
84
|
+
resourceHref={resourceHref}
|
|
85
|
+
onResourceClick={onResourceClick}
|
|
86
|
+
/>
|
|
87
|
+
);
|
|
88
|
+
})}
|
|
89
|
+
</ol>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function IssueRow({
|
|
94
|
+
issue,
|
|
95
|
+
clusterLabel,
|
|
96
|
+
open,
|
|
97
|
+
onToggle,
|
|
98
|
+
resourceHref,
|
|
99
|
+
onResourceClick,
|
|
100
|
+
}: {
|
|
101
|
+
issue: Issue;
|
|
102
|
+
clusterLabel?: (issue: Issue) => string | undefined;
|
|
103
|
+
open: boolean;
|
|
104
|
+
onToggle: () => void;
|
|
105
|
+
resourceHref?: (ref: IssueResourceRef) => string;
|
|
106
|
+
onResourceClick?: (ref: IssueResourceRef) => void;
|
|
107
|
+
}) {
|
|
108
|
+
const cluster = clusterLabel?.(issue);
|
|
109
|
+
const affected = affectedSummary(issue.affected);
|
|
110
|
+
const { headline } = issueMessageParts(issue);
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<li className="overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm">
|
|
114
|
+
{/* The whole header is the single toggle target — chevron is just the
|
|
115
|
+
open/closed indicator, not a separate action. Deep-links live in the
|
|
116
|
+
expanded body (a link nested in a button would be invalid). */}
|
|
117
|
+
<div
|
|
118
|
+
role="button"
|
|
119
|
+
tabIndex={0}
|
|
120
|
+
aria-expanded={open}
|
|
121
|
+
onClick={onToggle}
|
|
122
|
+
onKeyDown={(e) => {
|
|
123
|
+
if (e.target !== e.currentTarget) return;
|
|
124
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
125
|
+
e.preventDefault();
|
|
126
|
+
onToggle();
|
|
127
|
+
}
|
|
128
|
+
}}
|
|
129
|
+
className={`group flex cursor-pointer items-center gap-3 border-l-2 py-3 pl-3 pr-4 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-radar-accent)]/40 ${ISSUE_SEVERITY_RAIL_CLASS[issue.severity]}`}
|
|
130
|
+
>
|
|
131
|
+
<ChevronRight className={`h-4 w-4 shrink-0 text-theme-text-tertiary transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
|
|
132
|
+
|
|
133
|
+
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
134
|
+
<div className="flex min-w-0 items-baseline gap-2">
|
|
135
|
+
<span className="shrink-0 text-sm font-medium text-theme-text-primary">{categoryLabel(issue.category)}</span>
|
|
136
|
+
<span className={`badge-sm shrink-0 self-center text-[10px] ${groupBadgeClass(issue.category_group)}`}>{groupLabel(issue.category_group)}</span>
|
|
137
|
+
{/* The detector reason/message rides the title row so the most
|
|
138
|
+
useful triage signal is visible without expanding — it fills
|
|
139
|
+
the otherwise-empty band between the title and the severity
|
|
140
|
+
badge. Full text (plus crash context) stays in the body. */}
|
|
141
|
+
{issue.reason ? (
|
|
142
|
+
<span className="min-w-0 flex-1 truncate text-xs text-theme-text-tertiary">
|
|
143
|
+
<span className="font-medium text-theme-text-secondary">{issue.reason}</span>
|
|
144
|
+
{headline ? <span> — {headline}</span> : null}
|
|
145
|
+
</span>
|
|
146
|
+
) : null}
|
|
147
|
+
</div>
|
|
148
|
+
<div className="flex min-w-0 items-center gap-1.5 text-xs text-theme-text-tertiary">
|
|
149
|
+
<span className="shrink-0 font-mono uppercase tracking-wide">{issue.kind}</span>
|
|
150
|
+
<span className="min-w-0 truncate font-medium text-theme-text-secondary">
|
|
151
|
+
{issue.namespace ? `${issue.namespace} / ` : ''}
|
|
152
|
+
{issue.name}
|
|
153
|
+
</span>
|
|
154
|
+
{cluster ? (
|
|
155
|
+
<>
|
|
156
|
+
<span aria-hidden>·</span>
|
|
157
|
+
<span className="max-w-[160px] shrink-0 truncate">
|
|
158
|
+
<ClusterName name={cluster} />
|
|
159
|
+
</span>
|
|
160
|
+
</>
|
|
161
|
+
) : null}
|
|
162
|
+
{affected ? (
|
|
163
|
+
<>
|
|
164
|
+
<span aria-hidden>·</span>
|
|
165
|
+
<span className="shrink-0 tabular-nums">{affected}</span>
|
|
166
|
+
</>
|
|
167
|
+
) : null}
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
{/* Onset age (first_seen, fixed for the issue's life) is the chronic-vs-
|
|
172
|
+
acute signal — "broken 2m" reads very differently from "broken 5d".
|
|
173
|
+
Keyed on first_seen, not last_seen, so it doesn't churn on refresh. */}
|
|
174
|
+
{issue.first_seen ? (
|
|
175
|
+
<time
|
|
176
|
+
dateTime={issue.first_seen}
|
|
177
|
+
title={ageTitle(issue)}
|
|
178
|
+
className="flex shrink-0 items-center gap-1 text-xs tabular-nums text-theme-text-tertiary"
|
|
179
|
+
>
|
|
180
|
+
<Clock className="h-3 w-3" aria-hidden />
|
|
181
|
+
{formatCompactAge(issue.first_seen)}
|
|
182
|
+
</time>
|
|
183
|
+
) : null}
|
|
184
|
+
|
|
185
|
+
<span className={`badge-sm shrink-0 text-[10px] font-semibold ${ISSUE_SEVERITY_BADGE_CLASS[issue.severity]}`}>
|
|
186
|
+
{ISSUE_SEVERITY_LABEL[issue.severity]}
|
|
187
|
+
</span>
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
<div className="grid transition-[grid-template-rows] duration-200 ease-out" style={{ gridTemplateRows: open ? '1fr' : '0fr' }}>
|
|
191
|
+
{/* Kept mounted (not `open &&`) so the grid-rows transition animates the
|
|
192
|
+
collapse too; inert when closed so SR + tab skip the clipped content. */}
|
|
193
|
+
<div className="overflow-hidden" inert={!open || undefined}>
|
|
194
|
+
<div className="border-t border-theme-border bg-theme-base/40 px-4 py-4 pl-11">
|
|
195
|
+
<div className="flex flex-col gap-4">
|
|
196
|
+
<Diagnosis issue={issue} />
|
|
197
|
+
<div className="border-t border-theme-border/70 pt-3">
|
|
198
|
+
<AffectedResources issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} />
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
</li>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// What's-wrong block: the specific detector reason + message, plus pod crash
|
|
209
|
+
// context when present (the "chronic vs acute" signal).
|
|
210
|
+
function Diagnosis({ issue }: { issue: Issue }) {
|
|
211
|
+
const crash =
|
|
212
|
+
issue.restart_count || issue.last_terminated_reason
|
|
213
|
+
? [issue.restart_count ? `${issue.restart_count} restart${issue.restart_count === 1 ? '' : 's'}` : null, issue.last_terminated_reason ? `last exit: ${issue.last_terminated_reason}` : null]
|
|
214
|
+
.filter(Boolean)
|
|
215
|
+
.join(' · ')
|
|
216
|
+
: null;
|
|
217
|
+
const { headline, detail } = issueMessageParts(issue);
|
|
218
|
+
return (
|
|
219
|
+
<section className="flex flex-col gap-1">
|
|
220
|
+
<h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">What's wrong</h4>
|
|
221
|
+
<p className="text-sm leading-relaxed text-theme-text-primary">
|
|
222
|
+
<span className="font-medium">{issue.reason}</span>
|
|
223
|
+
{headline ? <span className="text-theme-text-secondary"> — {headline}</span> : null}
|
|
224
|
+
</p>
|
|
225
|
+
{/* Raw source string kept as secondary detail only when we showed a
|
|
226
|
+
normalized headline above (e.g. the verbose containerd image-pull
|
|
227
|
+
error) — so the precise message is never lost, just de-emphasized. */}
|
|
228
|
+
{detail ? <p className="break-words font-mono text-xs leading-relaxed text-theme-text-tertiary">{detail}</p> : null}
|
|
229
|
+
{crash ? <p className="text-xs text-theme-text-tertiary tabular-nums">{crash}</p> : null}
|
|
230
|
+
{issue.first_seen ? (
|
|
231
|
+
<p className="text-xs text-theme-text-tertiary tabular-nums">
|
|
232
|
+
Started {formatRelativeAgeTime(issue.first_seen)}
|
|
233
|
+
{issue.last_seen ? ` · last seen ${formatRelativeAgeTime(issue.last_seen)}` : ''}
|
|
234
|
+
</p>
|
|
235
|
+
) : null}
|
|
236
|
+
</section>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Native-tooltip detail for the collapsed-row age chip: absolute onset + last-seen
|
|
241
|
+
// freshness, the two facts the compact "2h" hides.
|
|
242
|
+
function ageTitle(issue: Issue): string {
|
|
243
|
+
const parts: string[] = [];
|
|
244
|
+
if (issue.first_seen) parts.push(`Started ${new Date(issue.first_seen).toLocaleString()}`);
|
|
245
|
+
if (issue.last_seen) parts.push(`Last seen ${formatRelativeAgeTime(issue.last_seen)}`);
|
|
246
|
+
return parts.join('\n');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function AffectedResources({
|
|
250
|
+
issue,
|
|
251
|
+
resourceHref,
|
|
252
|
+
onResourceClick,
|
|
253
|
+
}: {
|
|
254
|
+
issue: Issue;
|
|
255
|
+
resourceHref?: (ref: IssueResourceRef) => string;
|
|
256
|
+
onResourceClick?: (ref: IssueResourceRef) => void;
|
|
257
|
+
}) {
|
|
258
|
+
const members = issue.members ?? [];
|
|
259
|
+
// count is the backend fan-out size (members, subject excluded — see
|
|
260
|
+
// grouping.go); fall back to the inline member count, not +1.
|
|
261
|
+
const total = issue.count ?? members.length;
|
|
262
|
+
return (
|
|
263
|
+
<section className="flex flex-col gap-1.5">
|
|
264
|
+
{/* The subject (the grouped thing — e.g. the Deployment) is always the
|
|
265
|
+
first deep-link; members (the folded pods) follow. ResourceLine emits
|
|
266
|
+
an <li>, so it needs a list parent of its own. */}
|
|
267
|
+
<ul className="flex flex-col gap-px">
|
|
268
|
+
<ResourceLine label="Subject" refForLink={subjectRef(issue)} resourceHref={resourceHref} onResourceClick={onResourceClick} />
|
|
269
|
+
</ul>
|
|
270
|
+
{members.length > 0 && (
|
|
271
|
+
<>
|
|
272
|
+
<h4 className="mt-1.5 text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
|
|
273
|
+
Affected resources <span className="tabular-nums">({total})</span>
|
|
274
|
+
</h4>
|
|
275
|
+
<ul className="flex flex-col gap-px">
|
|
276
|
+
{members.map((m, i) => (
|
|
277
|
+
<ResourceLine
|
|
278
|
+
key={`${m.group}/${m.kind}/${m.namespace}/${m.name}#${i}`}
|
|
279
|
+
refForLink={memberRef(issue, m)}
|
|
280
|
+
resourceHref={resourceHref}
|
|
281
|
+
onResourceClick={onResourceClick}
|
|
282
|
+
/>
|
|
283
|
+
))}
|
|
284
|
+
</ul>
|
|
285
|
+
{issue.members_truncated && (
|
|
286
|
+
<p className="mt-0.5 text-xs text-theme-text-tertiary">
|
|
287
|
+
Showing {members.length} of {total} — open the subject to see the rest.
|
|
288
|
+
</p>
|
|
289
|
+
)}
|
|
290
|
+
</>
|
|
291
|
+
)}
|
|
292
|
+
</section>
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function ResourceLine({
|
|
297
|
+
label,
|
|
298
|
+
refForLink,
|
|
299
|
+
resourceHref,
|
|
300
|
+
onResourceClick,
|
|
301
|
+
}: {
|
|
302
|
+
label?: string;
|
|
303
|
+
refForLink: IssueResourceRef;
|
|
304
|
+
resourceHref?: (ref: IssueResourceRef) => string;
|
|
305
|
+
onResourceClick?: (ref: IssueResourceRef) => void;
|
|
306
|
+
}) {
|
|
307
|
+
const r = refForLink;
|
|
308
|
+
const linkable = !!(onResourceClick || resourceHref);
|
|
309
|
+
const body = (
|
|
310
|
+
<>
|
|
311
|
+
{label ? <span className="shrink-0 text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">{label}</span> : null}
|
|
312
|
+
<span className="shrink-0 font-mono text-[11px] uppercase tracking-wide text-theme-text-tertiary">{r.kind}</span>
|
|
313
|
+
<span className={`min-w-0 truncate font-medium ${linkable ? 'text-[var(--color-radar-accent)]' : 'text-theme-text-primary'}`}>
|
|
314
|
+
{r.namespace ? `${r.namespace} / ` : ''}
|
|
315
|
+
{r.name}
|
|
316
|
+
</span>
|
|
317
|
+
{linkable && <ExternalLink className="h-3 w-3 shrink-0 text-theme-text-tertiary opacity-0 transition-opacity group-hover/r:opacity-100" />}
|
|
318
|
+
</>
|
|
319
|
+
);
|
|
320
|
+
const cls = 'group/r flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-sm transition-colors hover:bg-theme-hover/60';
|
|
321
|
+
return (
|
|
322
|
+
<li>
|
|
323
|
+
{onResourceClick ? (
|
|
324
|
+
<button type="button" onClick={() => onResourceClick(r)} className={cls}>
|
|
325
|
+
{body}
|
|
326
|
+
</button>
|
|
327
|
+
) : resourceHref ? (
|
|
328
|
+
<a href={resourceHref(r)} className={cls}>
|
|
329
|
+
{body}
|
|
330
|
+
</a>
|
|
331
|
+
) : (
|
|
332
|
+
<span className="flex items-center gap-2 rounded-md px-2 py-1 text-sm">{body}</span>
|
|
333
|
+
)}
|
|
334
|
+
</li>
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// "3 pods · 1 service" from the affected rollup; null when there's no fan-out
|
|
339
|
+
// (single-resource issue — the subject line already says everything).
|
|
340
|
+
function affectedSummary(a?: IssueAffected): string | null {
|
|
341
|
+
if (!a) return null;
|
|
342
|
+
const parts: string[] = [];
|
|
343
|
+
const add = (n: number | undefined, singular: string, plural: string) => {
|
|
344
|
+
if (n && n > 0) parts.push(`${n} ${n === 1 ? singular : plural}`);
|
|
345
|
+
};
|
|
346
|
+
add(a.pods, 'pod', 'pods');
|
|
347
|
+
add(a.workloads, 'workload', 'workloads');
|
|
348
|
+
add(a.services, 'service', 'services');
|
|
349
|
+
add(a.pvcs, 'PVC', 'PVCs');
|
|
350
|
+
add(a.nodes, 'node', 'nodes');
|
|
351
|
+
return parts.length > 0 ? parts.join(' · ') : null;
|
|
352
|
+
}
|