@skyhook-io/radar-app 1.1.0 → 1.1.2
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 +2 -2
- package/src/App.tsx +81 -18
- package/src/api/client.ts +200 -26
- package/src/api/rbac.ts +57 -0
- package/src/components/compare/CompareViewRoute.tsx +116 -0
- package/src/components/compare/useCompareCandidates.ts +27 -0
- package/src/components/compare/useCompareLauncher.tsx +76 -0
- package/src/components/cost/CostView.tsx +1 -1
- package/src/components/gitops/GitOpsView.tsx +258 -1862
- package/src/components/helm/ChartBrowser.tsx +61 -10
- package/src/components/helm/HelmView.tsx +28 -11
- package/src/components/helm/InstallWizard.tsx +5 -5
- package/src/components/helm/ManifestDiffViewer.tsx +1 -1
- package/src/components/helm/ValuesViewer.tsx +3 -39
- package/src/components/helm/helm-utils.ts +4 -0
- package/src/components/home/HomeView.tsx +18 -2
- package/src/components/resource/HPACharts.tsx +232 -0
- package/src/components/resource/PVCUsageBar.tsx +59 -0
- package/src/components/resource/PrometheusCharts.tsx +151 -434
- package/src/components/resource/PrometheusChartsGrid.tsx +339 -0
- package/src/components/resource/RestartChart.tsx +124 -0
- package/src/components/resource/RightsizingStrip.tsx +167 -0
- package/src/components/resources/CompositeRenderer.tsx +101 -0
- package/src/components/resources/renderers/HPARenderer.tsx +17 -1
- package/src/components/resources/renderers/NamespaceRenderer.tsx +22 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
- package/src/components/resources/renderers/PodRenderer.tsx +13 -0
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +43 -1
- package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
- package/src/components/resources/renderers/WorkloadRenderer.tsx +12 -0
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/settings/MyPermissionsDialog.tsx +231 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +1 -0
- package/src/components/workload/WorkloadView.tsx +107 -3
- package/src/context/NavCustomization.tsx +13 -0
- package/src/contexts/CapabilitiesContext.tsx +8 -3
- package/src/components/gitops/RollbackDialog.tsx +0 -107
- package/src/components/gitops/SyncOptionsDialog.tsx +0 -144
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { useMemo, useState } from 'react'
|
|
2
|
+
import { BarChart3, ChevronDown, ChevronRight, Loader2, Wifi, WifiOff } from 'lucide-react'
|
|
3
|
+
import {
|
|
4
|
+
AreaChart,
|
|
5
|
+
SeriesLegend,
|
|
6
|
+
computeSaturation,
|
|
7
|
+
type ReferenceLine,
|
|
8
|
+
} from '@skyhook-io/k8s-ui/components/charts'
|
|
9
|
+
import { SEVERITY_BADGE, SEVERITY_TEXT, type Severity } from '@skyhook-io/k8s-ui/utils/badge-colors'
|
|
10
|
+
import {
|
|
11
|
+
usePrometheusStatus,
|
|
12
|
+
usePrometheusConnect,
|
|
13
|
+
usePrometheusResourceMetrics,
|
|
14
|
+
usePrometheusRightsizing,
|
|
15
|
+
useAutoPromConnect,
|
|
16
|
+
type PrometheusMetricCategory,
|
|
17
|
+
type PrometheusTimeRange,
|
|
18
|
+
type RightsizingTone,
|
|
19
|
+
} from '../../api/client'
|
|
20
|
+
import {
|
|
21
|
+
MetricsSummary,
|
|
22
|
+
TIME_RANGES,
|
|
23
|
+
WORKLOAD_CATEGORIES,
|
|
24
|
+
NODE_CATEGORIES,
|
|
25
|
+
computeRequestLimitLines,
|
|
26
|
+
type CategoryDef,
|
|
27
|
+
} from './PrometheusCharts'
|
|
28
|
+
import { RestartEventLane } from './RestartChart'
|
|
29
|
+
|
|
30
|
+
// Used when MetricsTabContent is in expanded (full-screen) mode. Drawer mode
|
|
31
|
+
// uses the single-chart tabbed `PrometheusCharts` instead — drawer width
|
|
32
|
+
// can't fit the grid cleanly.
|
|
33
|
+
export interface PrometheusChartsGridProps {
|
|
34
|
+
kind: string
|
|
35
|
+
namespace: string
|
|
36
|
+
name: string
|
|
37
|
+
/** Optional full K8s resource for request/limit overlay derivation. */
|
|
38
|
+
resource?: any
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const SUPPORTED_KINDS = new Set([
|
|
42
|
+
'Pod', 'Deployment', 'StatefulSet', 'DaemonSet', 'ReplicaSet', 'Job', 'CronJob', 'Node',
|
|
43
|
+
])
|
|
44
|
+
|
|
45
|
+
export function PrometheusChartsGrid({
|
|
46
|
+
kind,
|
|
47
|
+
namespace,
|
|
48
|
+
name,
|
|
49
|
+
resource,
|
|
50
|
+
}: PrometheusChartsGridProps) {
|
|
51
|
+
// Node-kind workloads don't have container restart semantics — KSM would
|
|
52
|
+
// return empty series anyway, but suppressing the lane spares the query.
|
|
53
|
+
const showRestartLane = kind !== 'Node'
|
|
54
|
+
useAutoPromConnect()
|
|
55
|
+
const { data: status, isLoading: statusLoading } = usePrometheusStatus()
|
|
56
|
+
const connectMutation = usePrometheusConnect()
|
|
57
|
+
const isConnected = status?.connected === true
|
|
58
|
+
const isSupported = SUPPORTED_KINDS.has(kind)
|
|
59
|
+
|
|
60
|
+
const [timeRange, setTimeRange] = useState<PrometheusTimeRange>('1h')
|
|
61
|
+
const [diskExpanded, setDiskExpanded] = useState(false)
|
|
62
|
+
|
|
63
|
+
const categories = kind === 'Node' ? NODE_CATEGORIES : WORKLOAD_CATEGORIES
|
|
64
|
+
|
|
65
|
+
// CPU + memory get reference-line overlays when a resource is provided.
|
|
66
|
+
// Computed once at the parent so each panel can stay otherwise generic.
|
|
67
|
+
const cpuRefLines = useMemo<ReferenceLine[] | undefined>(
|
|
68
|
+
() => (resource ? computeRequestLimitLines(resource, kind, 'cpu') : undefined),
|
|
69
|
+
[resource, kind],
|
|
70
|
+
)
|
|
71
|
+
const memRefLines = useMemo<ReferenceLine[] | undefined>(
|
|
72
|
+
() => (resource ? computeRequestLimitLines(resource, kind, 'memory') : undefined),
|
|
73
|
+
[resource, kind],
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
if (!isSupported) return null
|
|
77
|
+
|
|
78
|
+
if (statusLoading) {
|
|
79
|
+
return (
|
|
80
|
+
<div className="flex items-center justify-center py-12 text-theme-text-tertiary">
|
|
81
|
+
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
|
82
|
+
Checking Prometheus availability...
|
|
83
|
+
</div>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!isConnected) {
|
|
88
|
+
return (
|
|
89
|
+
<div className="flex flex-col items-center justify-center py-12 gap-4">
|
|
90
|
+
<WifiOff className="w-10 h-10 text-theme-text-quaternary" />
|
|
91
|
+
<div className="text-center">
|
|
92
|
+
<p className="text-sm text-theme-text-secondary mb-1">Prometheus not connected</p>
|
|
93
|
+
<p className="text-xs text-theme-text-tertiary mb-4">
|
|
94
|
+
{status?.error || 'Connect to view historical CPU, memory, and network metrics'}
|
|
95
|
+
</p>
|
|
96
|
+
<button
|
|
97
|
+
onClick={() => connectMutation.mutate()}
|
|
98
|
+
disabled={connectMutation.isPending}
|
|
99
|
+
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg btn-brand"
|
|
100
|
+
>
|
|
101
|
+
{connectMutation.isPending ? (
|
|
102
|
+
<Loader2 className="w-4 h-4 animate-spin" />
|
|
103
|
+
) : (
|
|
104
|
+
<Wifi className="w-4 h-4" />
|
|
105
|
+
)}
|
|
106
|
+
Discover Prometheus
|
|
107
|
+
</button>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const findCategory = (key: PrometheusMetricCategory): CategoryDef | undefined =>
|
|
114
|
+
categories.find(c => c.key === key)
|
|
115
|
+
|
|
116
|
+
// Disk I/O is collapsed by default — niche metric.
|
|
117
|
+
const primaryCats: { def: CategoryDef; refLines?: ReferenceLine[] }[] = []
|
|
118
|
+
const cpu = findCategory('cpu')
|
|
119
|
+
if (cpu) primaryCats.push({ def: cpu, refLines: cpuRefLines })
|
|
120
|
+
const mem = findCategory('memory')
|
|
121
|
+
if (mem) primaryCats.push({ def: mem, refLines: memRefLines })
|
|
122
|
+
if (kind !== 'Node') {
|
|
123
|
+
const rx = findCategory('network_rx')
|
|
124
|
+
if (rx) primaryCats.push({ def: rx })
|
|
125
|
+
const tx = findCategory('network_tx')
|
|
126
|
+
if (tx) primaryCats.push({ def: tx })
|
|
127
|
+
}
|
|
128
|
+
const disk = findCategory('filesystem')
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<div className="flex flex-col h-full overflow-auto">
|
|
132
|
+
<div className="shrink-0 flex items-center justify-between px-4 py-2.5 border-b border-theme-border bg-theme-surface/50">
|
|
133
|
+
<div className="flex items-center gap-2 text-sm font-medium text-theme-text-secondary">
|
|
134
|
+
<BarChart3 className="w-4 h-4 text-theme-text-tertiary" />
|
|
135
|
+
Metrics
|
|
136
|
+
<WorkloadHealthBadge kind={kind} namespace={namespace} name={name} />
|
|
137
|
+
</div>
|
|
138
|
+
<select
|
|
139
|
+
value={timeRange}
|
|
140
|
+
onChange={e => setTimeRange(e.target.value as PrometheusTimeRange)}
|
|
141
|
+
className="px-2 py-1 text-xs rounded-md bg-theme-elevated border border-theme-border text-theme-text-secondary focus:outline-none focus:ring-1 focus:ring-blue-500/50"
|
|
142
|
+
>
|
|
143
|
+
{TIME_RANGES.map(tr => (
|
|
144
|
+
<option key={tr.value} value={tr.value}>{tr.label}</option>
|
|
145
|
+
))}
|
|
146
|
+
</select>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
{/* Restart lane sits above the grid so its markers visually align with
|
|
150
|
+
the time axis of the charts below. */}
|
|
151
|
+
{showRestartLane && (
|
|
152
|
+
<div className="px-4 pt-3">
|
|
153
|
+
<RestartEventLane kind={kind} namespace={namespace} name={name} range={timeRange} />
|
|
154
|
+
</div>
|
|
155
|
+
)}
|
|
156
|
+
|
|
157
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 p-4">
|
|
158
|
+
{primaryCats.map(({ def, refLines }) => (
|
|
159
|
+
<MetricsPanel
|
|
160
|
+
key={def.key}
|
|
161
|
+
category={def}
|
|
162
|
+
kind={kind}
|
|
163
|
+
namespace={namespace}
|
|
164
|
+
name={name}
|
|
165
|
+
timeRange={timeRange}
|
|
166
|
+
referenceLines={refLines}
|
|
167
|
+
/>
|
|
168
|
+
))}
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
{disk && (
|
|
172
|
+
<div className="px-4 pb-4">
|
|
173
|
+
<button
|
|
174
|
+
type="button"
|
|
175
|
+
onClick={() => setDiskExpanded(v => !v)}
|
|
176
|
+
className="flex items-center gap-1.5 text-xs font-medium text-theme-text-tertiary hover:text-theme-text-secondary py-1"
|
|
177
|
+
>
|
|
178
|
+
{diskExpanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
|
|
179
|
+
Disk I/O
|
|
180
|
+
</button>
|
|
181
|
+
{diskExpanded && (
|
|
182
|
+
<div className="mt-2">
|
|
183
|
+
<MetricsPanel
|
|
184
|
+
category={disk}
|
|
185
|
+
kind={kind}
|
|
186
|
+
namespace={namespace}
|
|
187
|
+
name={name}
|
|
188
|
+
timeRange={timeRange}
|
|
189
|
+
/>
|
|
190
|
+
</div>
|
|
191
|
+
)}
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
</div>
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
interface MetricsPanelProps {
|
|
199
|
+
category: CategoryDef
|
|
200
|
+
kind: string
|
|
201
|
+
namespace: string
|
|
202
|
+
name: string
|
|
203
|
+
timeRange: PrometheusTimeRange
|
|
204
|
+
referenceLines?: ReferenceLine[]
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function MetricsPanel({ category, kind, namespace, name, timeRange, referenceLines }: MetricsPanelProps) {
|
|
208
|
+
const { data: metrics, isLoading, error } = usePrometheusResourceMetrics(
|
|
209
|
+
kind, namespace, name, category.key, timeRange, true,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
const series = metrics?.result?.series
|
|
213
|
+
const hasData = (series?.length ?? 0) > 0
|
|
214
|
+
|
|
215
|
+
// "% of limit / request" derived from current peak vs reference lines.
|
|
216
|
+
// Without this, a low-utilization workload with a high limit looks like
|
|
217
|
+
// an empty chart — the user can't tell healthy from starved at a glance.
|
|
218
|
+
const saturation = hasData && series && referenceLines
|
|
219
|
+
? computeSaturation(series, referenceLines)
|
|
220
|
+
: undefined
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<section className="rounded-lg border border-theme-border bg-theme-surface/30 p-3 flex flex-col min-h-[260px]">
|
|
224
|
+
<header className="flex items-center justify-between mb-2 gap-3">
|
|
225
|
+
<div className="flex items-center gap-2">
|
|
226
|
+
<h3 className="text-xs font-medium text-theme-text-secondary uppercase tracking-wide">
|
|
227
|
+
{category.label}
|
|
228
|
+
</h3>
|
|
229
|
+
{saturation && <SaturationChip {...saturation} />}
|
|
230
|
+
</div>
|
|
231
|
+
{hasData && series && (
|
|
232
|
+
<MetricsSummary series={series} category={category} unit={metrics!.unit} />
|
|
233
|
+
)}
|
|
234
|
+
</header>
|
|
235
|
+
|
|
236
|
+
<div className="flex-1 min-h-[200px]">
|
|
237
|
+
{isLoading ? (
|
|
238
|
+
<PanelLoading />
|
|
239
|
+
) : error ? (
|
|
240
|
+
<PanelError message={(error as Error).message} />
|
|
241
|
+
) : hasData && series ? (
|
|
242
|
+
<>
|
|
243
|
+
<AreaChart
|
|
244
|
+
series={series}
|
|
245
|
+
color={category.chartColor}
|
|
246
|
+
fillColor={category.fillColor}
|
|
247
|
+
unit={metrics!.unit}
|
|
248
|
+
referenceLines={referenceLines}
|
|
249
|
+
/>
|
|
250
|
+
{series.length > 1 && (
|
|
251
|
+
<div className="mt-1.5">
|
|
252
|
+
<SeriesLegend series={series} color={category.chartColor} />
|
|
253
|
+
</div>
|
|
254
|
+
)}
|
|
255
|
+
</>
|
|
256
|
+
) : (
|
|
257
|
+
<PanelNoData hint={metrics?.hint} />
|
|
258
|
+
)}
|
|
259
|
+
</div>
|
|
260
|
+
</section>
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function SaturationChip({ ratio, against }: { ratio: number; against: 'limit' | 'request' }) {
|
|
265
|
+
// Thresholds match the rightsizing tone vocabulary: amber from 75% (start
|
|
266
|
+
// watching), red at 90% (the same OOM-risk boundary the backend uses for
|
|
267
|
+
// memory in classifyRightsizing).
|
|
268
|
+
const tone: Severity = ratio >= 0.9 ? 'error' : ratio >= 0.75 ? 'warning' : ratio < 0.05 ? 'info' : 'neutral'
|
|
269
|
+
const label = `${(ratio * 100).toFixed(ratio < 0.1 ? 1 : 0)}% of ${against}`
|
|
270
|
+
return <span className={`badge badge-sm ${SEVERITY_BADGE[tone]}`}>{label}</span>
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Severity ordering for rightsizing tones. Aligned with `Tone` constants in
|
|
274
|
+
// `internal/prometheus/rightsizing.go` — adding a new tone there triggers a
|
|
275
|
+
// TypeScript exhaustiveness error on the Record key set here.
|
|
276
|
+
const TONE_RANK: Record<RightsizingTone, number> = {
|
|
277
|
+
ok: 0,
|
|
278
|
+
info: 1,
|
|
279
|
+
warning: 2,
|
|
280
|
+
alert: 3,
|
|
281
|
+
critical: 4,
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function WorkloadHealthBadge({ kind, namespace, name }: { kind: string; namespace: string; name: string }) {
|
|
285
|
+
const supported = kind === 'Deployment' || kind === 'StatefulSet' || kind === 'DaemonSet'
|
|
286
|
+
const { data, error } = usePrometheusRightsizing(kind, namespace, name, supported)
|
|
287
|
+
if (!supported) return null
|
|
288
|
+
// Surface a neutral "Health unknown" pill when the rightsizing endpoint
|
|
289
|
+
// errors — otherwise an actually-throttled or OOM-risk workload would
|
|
290
|
+
// silently render as fine while we have no signal to display.
|
|
291
|
+
if (error && !data) {
|
|
292
|
+
const msg = error instanceof Error ? error.message : String(error)
|
|
293
|
+
return <span className={`badge badge-sm ${SEVERITY_BADGE.neutral}`} title={`Health check failed: ${msg}`}>Health unknown</span>
|
|
294
|
+
}
|
|
295
|
+
if (!data?.sampleAvailable || data.rows.length === 0) return null
|
|
296
|
+
|
|
297
|
+
const worst = data.rows.reduce<RightsizingTone>(
|
|
298
|
+
(acc, r) => (TONE_RANK[r.tone] > TONE_RANK[acc] ? r.tone : acc),
|
|
299
|
+
'ok',
|
|
300
|
+
)
|
|
301
|
+
// Skip the chip for the steady-state tones to avoid badge-blindness — we
|
|
302
|
+
// only want to draw the eye when there's something to address.
|
|
303
|
+
if (worst === 'ok' || worst === 'info') return null
|
|
304
|
+
|
|
305
|
+
const { label, severity }: { label: string; severity: Severity } =
|
|
306
|
+
worst === 'critical' ? { label: 'OOM risk', severity: 'error' } :
|
|
307
|
+
worst === 'alert' ? { label: 'CPU throttling', severity: 'alert' } :
|
|
308
|
+
/* warning */ { label: 'Needs review', severity: 'warning' }
|
|
309
|
+
return <span className={`badge badge-sm ${SEVERITY_BADGE[severity]}`}>{label}</span>
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function PanelLoading() {
|
|
313
|
+
return (
|
|
314
|
+
<div className="flex items-center justify-center h-full min-h-[160px] text-theme-text-tertiary text-xs">
|
|
315
|
+
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
|
316
|
+
Loading...
|
|
317
|
+
</div>
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function PanelError({ message }: { message: string }) {
|
|
322
|
+
return (
|
|
323
|
+
<div className={`flex flex-col items-center justify-center h-full min-h-[160px] ${SEVERITY_TEXT.warning} text-xs px-3 text-center`}>
|
|
324
|
+
Query failed
|
|
325
|
+
<span className="text-theme-text-quaternary mt-0.5 line-clamp-2" title={message}>{message}</span>
|
|
326
|
+
</div>
|
|
327
|
+
)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function PanelNoData({ hint }: { hint?: string }) {
|
|
331
|
+
return (
|
|
332
|
+
<div className="flex flex-col items-center justify-center h-full min-h-[160px] text-theme-text-tertiary text-xs px-3 text-center">
|
|
333
|
+
No data
|
|
334
|
+
{hint && <span className="text-theme-text-quaternary mt-1 max-w-xs">{hint}</span>}
|
|
335
|
+
</div>
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export { isPrometheusSupported } from './PrometheusCharts'
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { useEffect, useMemo } from 'react'
|
|
2
|
+
import { AlertCircle } from 'lucide-react'
|
|
3
|
+
import { usePrometheusResourceMetrics, usePrometheusStatus, type PrometheusSeries, type PrometheusTimeRange } from '../../api/client'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* RestartEventLane — vertical markers at each restart event, on a dedicated
|
|
7
|
+
* row below the chart. Markers stay readable when they cluster because they
|
|
8
|
+
* don't overlay the chart waveform. KSM-gated (uses kube_pod_container_status_restarts_total)
|
|
9
|
+
* — silently hidden when Prom isn't connected or the series doesn't exist.
|
|
10
|
+
*/
|
|
11
|
+
export function RestartEventLane({ kind, namespace, name, range = '1h' }: {
|
|
12
|
+
kind: string
|
|
13
|
+
namespace: string
|
|
14
|
+
name: string
|
|
15
|
+
range?: PrometheusTimeRange
|
|
16
|
+
}) {
|
|
17
|
+
const { data: status } = usePrometheusStatus()
|
|
18
|
+
const isConnected = status?.connected === true
|
|
19
|
+
const { data: metrics, isLoading, error } = usePrometheusResourceMetrics(kind, namespace, name, 'restarts', range, isConnected)
|
|
20
|
+
|
|
21
|
+
const restarts = useMemo(() => collectRestartEvents(metrics?.result?.series), [metrics])
|
|
22
|
+
|
|
23
|
+
// A real Prom-side failure shouldn't look identical to "no restarts" — log
|
|
24
|
+
// it so an operator investigating a missing lane has a breadcrumb. The lane
|
|
25
|
+
// still hides because we don't want a permanent red banner on every pod.
|
|
26
|
+
// Effect-gated so we log once per error change, not on every re-render.
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (error) {
|
|
29
|
+
console.warn('[RestartEventLane] restart query failed', error)
|
|
30
|
+
}
|
|
31
|
+
}, [error])
|
|
32
|
+
|
|
33
|
+
if (!isConnected || isLoading) return null
|
|
34
|
+
if (restarts.length === 0) return null
|
|
35
|
+
|
|
36
|
+
// Position markers within the chart's full time window — not the
|
|
37
|
+
// min/max of detected events — so a cluster of restarts at the start
|
|
38
|
+
// of the window doesn't visually spread across the whole lane.
|
|
39
|
+
const nowSec = Date.now() / 1000
|
|
40
|
+
const windowStart = nowSec - rangeToSeconds(range)
|
|
41
|
+
const span = nowSec - windowStart
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className="rounded-md border border-amber-500/20 bg-amber-500/[0.04] px-3 py-2">
|
|
45
|
+
<div className="flex items-center gap-2 mb-1.5">
|
|
46
|
+
<AlertCircle className="w-3.5 h-3.5 text-amber-500/70" />
|
|
47
|
+
<span className="text-xs font-medium text-theme-text-secondary">
|
|
48
|
+
Restarts in last {range}
|
|
49
|
+
</span>
|
|
50
|
+
<span className="text-xs text-theme-text-quaternary tabular-nums">
|
|
51
|
+
{restarts.reduce((n, r) => n + r.value, 0)} total
|
|
52
|
+
</span>
|
|
53
|
+
</div>
|
|
54
|
+
<div className="relative h-5">
|
|
55
|
+
{/* Baseline */}
|
|
56
|
+
<div className="absolute inset-x-0 top-1/2 h-px bg-theme-border/40" />
|
|
57
|
+
{/* Markers */}
|
|
58
|
+
{restarts.map((r, i) => {
|
|
59
|
+
const left = `${Math.max(0, Math.min(100, ((r.timestamp - windowStart) / span) * 100))}%`
|
|
60
|
+
return (
|
|
61
|
+
<div
|
|
62
|
+
key={i}
|
|
63
|
+
className="absolute top-0 h-full w-px bg-amber-500/80"
|
|
64
|
+
style={{ left }}
|
|
65
|
+
title={`${new Date(r.timestamp * 1000).toLocaleString()} · ${r.label}${r.value > 1 ? ` ×${r.value}` : ''}`}
|
|
66
|
+
>
|
|
67
|
+
<div className="absolute -top-0.5 left-1/2 -translate-x-1/2 w-1.5 h-1.5 rounded-full bg-amber-500" />
|
|
68
|
+
</div>
|
|
69
|
+
)
|
|
70
|
+
})}
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ============================================================================
|
|
77
|
+
// Internals
|
|
78
|
+
// ============================================================================
|
|
79
|
+
|
|
80
|
+
interface RestartEvent {
|
|
81
|
+
timestamp: number
|
|
82
|
+
value: number
|
|
83
|
+
label: string
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function collectRestartEvents(series: PrometheusSeries[] | undefined): RestartEvent[] {
|
|
87
|
+
if (!series) return []
|
|
88
|
+
const events: RestartEvent[] = []
|
|
89
|
+
for (const s of series) {
|
|
90
|
+
const pod = s.labels.pod ?? 'pod'
|
|
91
|
+
// `changes(...[1h])` produces a rolling count, so a single restart shows
|
|
92
|
+
// value=1 for ~60 consecutive samples (the whole 1h window). Emit a marker
|
|
93
|
+
// only when the count increases — that's when a *new* restart entered the
|
|
94
|
+
// window — and use the increase as the marker's restart count.
|
|
95
|
+
let prev: number | null = null
|
|
96
|
+
for (const dp of s.dataPoints) {
|
|
97
|
+
if (prev !== null) {
|
|
98
|
+
// Only count positive deltas — restarts that entered the rolling 1h
|
|
99
|
+
// window during the chart range. The first sample's value covers
|
|
100
|
+
// [start-1h, start] which is outside the user's chosen window; counting
|
|
101
|
+
// it would inflate the total with pre-window restarts.
|
|
102
|
+
const delta = dp.value - prev
|
|
103
|
+
if (delta > 0) {
|
|
104
|
+
events.push({ timestamp: dp.timestamp, value: delta, label: pod })
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
prev = dp.value
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
events.sort((a, b) => a.timestamp - b.timestamp)
|
|
111
|
+
return events
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function rangeToSeconds(range: PrometheusTimeRange): number {
|
|
115
|
+
const match = range.match(/^(\d+)([mhd])$/)
|
|
116
|
+
if (!match) return 3600 // default to 1h if unrecognized
|
|
117
|
+
const n = parseInt(match[1], 10)
|
|
118
|
+
switch (match[2]) {
|
|
119
|
+
case 'm': return n * 60
|
|
120
|
+
case 'h': return n * 3600
|
|
121
|
+
case 'd': return n * 86400
|
|
122
|
+
default: return 3600
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { ArrowRight, Check, Info, AlertTriangle } from 'lucide-react'
|
|
2
|
+
import { SEVERITY_TEXT, SEVERITY_BADGE, type Severity } from '@skyhook-io/k8s-ui/utils/badge-colors'
|
|
3
|
+
import { usePrometheusRightsizing, usePrometheusStatus, type RightsizingTone, type RightsizingRow } from '../../api/client'
|
|
4
|
+
|
|
5
|
+
const RIGHTSIZING_KINDS = new Set(['Deployment', 'StatefulSet', 'DaemonSet'])
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* RightsizingStrip — compact "current → recommended" table per container.
|
|
9
|
+
*
|
|
10
|
+
* Tone policy is deliberately mild:
|
|
11
|
+
* - "Well-sized" or "Nx headroom" → neutral, no badge
|
|
12
|
+
* - 5×+ over-provisioning → info ("could reduce"), not a problem
|
|
13
|
+
* - P95 exceeds request but within limit → warning
|
|
14
|
+
* - P95 exceeds CPU limit → alert (throttling)
|
|
15
|
+
* - Memory P95 near limit → critical (active OOM risk)
|
|
16
|
+
*
|
|
17
|
+
* Anything below severe over-provisioning displays without flagging it as
|
|
18
|
+
* an issue. 2-3× headroom is the common, sensible default and should not nag.
|
|
19
|
+
*/
|
|
20
|
+
export function RightsizingStrip({ kind, namespace, name }: {
|
|
21
|
+
kind: string
|
|
22
|
+
namespace: string
|
|
23
|
+
name: string
|
|
24
|
+
}) {
|
|
25
|
+
const { data: status } = usePrometheusStatus()
|
|
26
|
+
const isConnected = status?.connected === true
|
|
27
|
+
const supported = RIGHTSIZING_KINDS.has(kind)
|
|
28
|
+
const { data, error, isLoading } = usePrometheusRightsizing(kind, namespace, name, isConnected && supported)
|
|
29
|
+
|
|
30
|
+
if (!supported || !isConnected || isLoading) return null
|
|
31
|
+
// Stay consistent with WorkloadHealthBadge — when the rightsizing query
|
|
32
|
+
// fails, surface a small inline note so the absence of recommendations
|
|
33
|
+
// doesn't read as "everything is fine."
|
|
34
|
+
if (error && !data) {
|
|
35
|
+
const msg = error instanceof Error ? error.message : String(error)
|
|
36
|
+
return (
|
|
37
|
+
<section className="rounded-lg border border-theme-border bg-theme-surface/40 p-3 mb-3">
|
|
38
|
+
<header className="flex items-center justify-between mb-1">
|
|
39
|
+
<h3 className="text-sm font-medium text-theme-text-primary">Right-sizing</h3>
|
|
40
|
+
</header>
|
|
41
|
+
<p className="text-xs text-theme-text-tertiary" title={msg}>Right-sizing unavailable — Prometheus query failed.</p>
|
|
42
|
+
</section>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
if (!data) return null
|
|
46
|
+
if (!data.sampleAvailable || data.rows.length === 0) {
|
|
47
|
+
// Backend distinguishes "workload too new / retention short" from "Prometheus
|
|
48
|
+
// query failed" — show the reason inline so operators have an actionable signal
|
|
49
|
+
// instead of an empty section.
|
|
50
|
+
if (!data.reason) return null
|
|
51
|
+
return (
|
|
52
|
+
<section className="rounded-lg border border-theme-border bg-theme-surface/40 p-3 mb-3">
|
|
53
|
+
<header className="flex items-center justify-between mb-1">
|
|
54
|
+
<h3 className="text-sm font-medium text-theme-text-primary">Right-sizing</h3>
|
|
55
|
+
</header>
|
|
56
|
+
<p className="text-xs text-theme-text-tertiary">{data.reason}</p>
|
|
57
|
+
</section>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Group rows by container so each container is a compact two-row block (cpu+mem).
|
|
62
|
+
const byContainer = new Map<string, RightsizingRow[]>()
|
|
63
|
+
for (const row of data.rows) {
|
|
64
|
+
if (!byContainer.has(row.container)) byContainer.set(row.container, [])
|
|
65
|
+
byContainer.get(row.container)!.push(row)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<section className="rounded-lg border border-theme-border bg-theme-surface/40 p-3 mb-3">
|
|
70
|
+
<header className="flex items-center justify-between mb-2">
|
|
71
|
+
<h3 className="text-sm font-medium text-theme-text-primary">Right-sizing</h3>
|
|
72
|
+
<span className="text-[11px] text-theme-text-tertiary">
|
|
73
|
+
based on last {data.window} · P95
|
|
74
|
+
</span>
|
|
75
|
+
</header>
|
|
76
|
+
<div className="space-y-1.5">
|
|
77
|
+
{Array.from(byContainer.entries()).map(([container, rows]) => (
|
|
78
|
+
<div key={container} className="text-xs">
|
|
79
|
+
<div className="text-theme-text-secondary font-medium mb-0.5">{container}</div>
|
|
80
|
+
<div className="space-y-0.5 pl-2">
|
|
81
|
+
{rows.map(row => (
|
|
82
|
+
<RightsizingLine key={row.resource} row={row} />
|
|
83
|
+
))}
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
))}
|
|
87
|
+
</div>
|
|
88
|
+
</section>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function RightsizingLine({ row }: { row: RightsizingRow }) {
|
|
93
|
+
const showRec = row.recommendedRequest && row.recommendedRequest !== row.currentRequest
|
|
94
|
+
const toneClass = toneClasses(row.tone)
|
|
95
|
+
const Icon = toneIcon(row.tone)
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div className="flex items-center gap-2 text-theme-text-tertiary tabular-nums">
|
|
99
|
+
<span className="w-12 text-theme-text-quaternary uppercase tracking-wide text-[10px]">
|
|
100
|
+
{row.resource}
|
|
101
|
+
</span>
|
|
102
|
+
|
|
103
|
+
<span className="text-theme-text-secondary min-w-[3.5rem]">
|
|
104
|
+
{row.currentRequest ?? <span className="text-theme-text-quaternary italic">unset</span>}
|
|
105
|
+
</span>
|
|
106
|
+
|
|
107
|
+
{showRec && (
|
|
108
|
+
<>
|
|
109
|
+
<ArrowRight className="w-3 h-3 text-theme-text-quaternary shrink-0" />
|
|
110
|
+
<span className={`min-w-[3.5rem] ${toneClass.value}`}>
|
|
111
|
+
{row.recommendedRequest}
|
|
112
|
+
</span>
|
|
113
|
+
</>
|
|
114
|
+
)}
|
|
115
|
+
|
|
116
|
+
{row.p95 && (
|
|
117
|
+
<span className="text-theme-text-quaternary text-[10px]">
|
|
118
|
+
(P95 {row.p95})
|
|
119
|
+
</span>
|
|
120
|
+
)}
|
|
121
|
+
|
|
122
|
+
{row.tone !== 'ok' && Icon && (
|
|
123
|
+
<span className={`ml-auto inline-flex items-center gap-1 ${toneClass.badge} px-1.5 py-0.5 rounded text-[10px]`}>
|
|
124
|
+
<Icon className="w-3 h-3" />
|
|
125
|
+
<span>{row.message}</span>
|
|
126
|
+
</span>
|
|
127
|
+
)}
|
|
128
|
+
|
|
129
|
+
{row.tone === 'ok' && row.message && (
|
|
130
|
+
<span className="ml-auto text-theme-text-quaternary text-[10px]">{row.message}</span>
|
|
131
|
+
)}
|
|
132
|
+
</div>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const TONE_TO_SEVERITY: Record<RightsizingTone, Severity> = {
|
|
137
|
+
critical: 'error',
|
|
138
|
+
alert: 'alert',
|
|
139
|
+
warning: 'warning',
|
|
140
|
+
info: 'info',
|
|
141
|
+
ok: 'neutral',
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function toneClasses(tone: RightsizingTone): { value: string; badge: string } {
|
|
145
|
+
if (tone === 'ok') return { value: 'text-theme-text-secondary', badge: '' }
|
|
146
|
+
if (tone === 'info') {
|
|
147
|
+
// "Could reduce" is a suggestion, not a problem — mute the badge.
|
|
148
|
+
return { value: SEVERITY_TEXT.info, badge: 'text-theme-text-tertiary bg-theme-elevated/60' }
|
|
149
|
+
}
|
|
150
|
+
const sev = TONE_TO_SEVERITY[tone]
|
|
151
|
+
return { value: SEVERITY_TEXT[sev], badge: SEVERITY_BADGE[sev] }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function toneIcon(tone: RightsizingTone) {
|
|
155
|
+
switch (tone) {
|
|
156
|
+
case 'critical':
|
|
157
|
+
case 'alert':
|
|
158
|
+
case 'warning':
|
|
159
|
+
return AlertTriangle
|
|
160
|
+
case 'info':
|
|
161
|
+
return Info
|
|
162
|
+
case 'ok':
|
|
163
|
+
return Check
|
|
164
|
+
default:
|
|
165
|
+
return null
|
|
166
|
+
}
|
|
167
|
+
}
|