@skyhook-io/radar-app 0.2.2 → 0.3.1
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/README.md +7 -1
- package/package.json +33 -25
- package/src/App.tsx +1449 -382
- package/src/RadarApp.tsx +132 -19
- package/src/api/apiResources.ts +1 -1
- package/src/api/client.argoResourceSync.test.ts +69 -0
- package/src/api/client.delta.test.ts +89 -0
- package/src/api/client.deltaSync.test.ts +216 -0
- package/src/api/client.metrics.test.ts +106 -0
- package/src/api/client.rightsizing.test.ts +32 -0
- package/src/api/client.ts +2730 -271
- package/src/api/client.yaml.test.ts +45 -0
- package/src/api/diagnose.ts +289 -0
- package/src/api/quotas.ts +16 -0
- package/src/api/rbac.ts +57 -0
- package/src/api/timelineSource.test.ts +217 -0
- package/src/api/timelineSource.ts +582 -0
- package/src/components/ConnectionErrorView.tsx +186 -70
- package/src/components/ContextSwitcher.tsx +63 -18
- package/src/components/DebugOverlay.tsx +5 -3
- package/src/components/NamespaceSwitcher.tsx +41 -0
- package/src/components/UserMenu.tsx +69 -21
- package/src/components/applications/ApplicationsView.tsx +936 -0
- package/src/components/audit/AuditSettingsDialog.tsx +79 -17
- package/src/components/audit/AuditView.tsx +65 -62
- package/src/components/compare/CompareViewRoute.tsx +124 -0
- package/src/components/compare/useCompareCandidates.ts +27 -0
- package/src/components/compare/useCompareLauncher.tsx +79 -0
- package/src/components/cost/ApplicationCostTab.test.ts +204 -0
- package/src/components/cost/ApplicationCostTab.tsx +571 -0
- package/src/components/cost/CostTrendChart.tsx +106 -75
- package/src/components/cost/CostView.test.ts +12 -0
- package/src/components/cost/CostView.tsx +507 -223
- package/src/components/cost/CostViewTabs.test.tsx +21 -0
- package/src/components/cost/CostViewTabs.tsx +40 -0
- package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
- package/src/components/cost/CurrentAllocationUse.tsx +126 -0
- package/src/components/cost/WorkloadCostTab.test.ts +153 -0
- package/src/components/cost/WorkloadCostTab.tsx +372 -0
- package/src/components/cost/cloud-console.test.ts +39 -0
- package/src/components/cost/cloud-console.ts +81 -0
- package/src/components/cost/errors.ts +8 -0
- package/src/components/cost/format.test.ts +27 -0
- package/src/components/cost/format.ts +46 -0
- package/src/components/cost/kinds.ts +5 -0
- package/src/components/curl/ServiceCurlButton.tsx +445 -0
- package/src/components/diagnose/AISettings.tsx +147 -0
- package/src/components/diagnose/DiagnoseContext.tsx +495 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
- package/src/components/diagnose/Home.tsx +163 -0
- package/src/components/diagnose/InvestigationView.tsx +622 -0
- package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
- package/src/components/diagnose/launch.ts +65 -0
- package/src/components/diagnose/parts.tsx +1756 -0
- package/src/components/dock/BottomDock.tsx +2 -3
- package/src/components/dock/DockContext.tsx +1 -0
- package/src/components/dock/TerminalTab.tsx +1 -1
- package/src/components/dock/WorkloadLogsTab.tsx +21 -5
- package/src/components/dock/index.ts +1 -1
- package/src/components/execution/BatchExecutionView.test.ts +170 -0
- package/src/components/execution/BatchExecutionView.tsx +1329 -0
- package/src/components/execution/batch-run-actions.test.ts +48 -0
- package/src/components/execution/batch-run-actions.ts +24 -0
- package/src/components/execution/batch-timeline.test.ts +57 -0
- package/src/components/execution/batch-timeline.ts +46 -0
- package/src/components/execution/execution-definition.test.ts +208 -0
- package/src/components/execution/execution-definition.ts +245 -0
- package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
- package/src/components/gitops/GitOpsView.tsx +1042 -0
- package/src/components/gitops/RevisionMetaChip.tsx +63 -0
- package/src/components/helm/ChartBrowser.tsx +87 -31
- package/src/components/helm/HelmCompareRoute.tsx +1341 -0
- package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
- package/src/components/helm/HelmView.tsx +237 -96
- package/src/components/helm/InstallWizard.tsx +94 -38
- package/src/components/helm/ManifestDiffViewer.tsx +8 -27
- package/src/components/helm/OwnedResources.tsx +34 -59
- package/src/components/helm/RevisionHistory.tsx +52 -3
- package/src/components/helm/RoleGatedPanel.tsx +3 -3
- package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
- package/src/components/helm/ValuesDiffPreview.tsx +17 -7
- package/src/components/helm/ValuesViewer.tsx +49 -53
- package/src/components/helm/helm-utils.ts +4 -0
- package/src/components/home/ActivitySummary.tsx +4 -1
- package/src/components/home/ClusterHealthCard.tsx +56 -42
- package/src/components/home/CostCard.tsx +21 -36
- package/src/components/home/GitOpsControllersCard.tsx +110 -0
- package/src/components/home/HelmSummary.tsx +3 -1
- package/src/components/home/HomeView.tsx +339 -105
- package/src/components/home/MCPSetupDialog.tsx +29 -87
- package/src/components/home/TrafficSummary.tsx +2 -2
- package/src/components/home/mcpToolCatalog.ts +333 -0
- package/src/components/issues/IssuesPane.tsx +151 -0
- package/src/components/logs/LogsViewer.tsx +4 -1
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
- package/src/components/nav/PrimaryNavRail.tsx +285 -0
- package/src/components/portforward/PortForwardButton.tsx +118 -47
- package/src/components/portforward/PortForwardManager.tsx +253 -131
- package/src/components/resource/HPACharts.tsx +237 -0
- package/src/components/resource/PVCUsageBar.tsx +59 -0
- package/src/components/resource/PrometheusCharts.tsx +160 -584
- package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
- package/src/components/resource/RestartChart.tsx +133 -0
- package/src/components/resource/RightsizingStrip.test.ts +109 -0
- package/src/components/resource/RightsizingStrip.tsx +363 -0
- package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
- package/src/components/resources/CompositeRenderer.tsx +101 -0
- package/src/components/resources/ImageFilesystemModal.tsx +19 -12
- package/src/components/resources/PodFilesystemModal.tsx +6 -5
- package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
- package/src/components/resources/ResourcesView.tsx +194 -17
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
- package/src/components/resources/renderers/HPARenderer.tsx +20 -1
- package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
- package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
- package/src/components/resources/renderers/PodRenderer.tsx +30 -6
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
- package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
- package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
- package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
- package/src/components/resources/renderers/index.ts +2 -0
- package/src/components/resources/resource-utils.ts +2 -1
- package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
- package/src/components/rightsizing/copy.test.ts +56 -0
- package/src/components/rightsizing/model.test.ts +227 -0
- package/src/components/rightsizing/model.ts +158 -0
- package/src/components/rightsizing/presentation.test.ts +104 -0
- package/src/components/rightsizing/presentation.ts +94 -0
- package/src/components/settings/MyPermissionsDialog.tsx +241 -0
- package/src/components/settings/SettingsDialog.tsx +1505 -165
- package/src/components/shared/CreateResourceDialog.tsx +9 -2
- package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
- package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
- package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
- package/src/components/timeline/TimelineList.tsx +86 -13
- package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
- package/src/components/timeline/TimelineView.tsx +873 -24
- package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
- package/src/components/traffic/TrafficFlowList.tsx +29 -15
- package/src/components/traffic/TrafficGraph.tsx +42 -24
- package/src/components/traffic/TrafficView.tsx +32 -19
- package/src/components/ui/CommandPalette.tsx +8 -215
- package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
- package/src/components/ui/Markdown.tsx +3 -3
- package/src/components/ui/Omnibar.tsx +602 -0
- package/src/components/ui/RadarOmnibar.tsx +52 -0
- package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
- package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
- package/src/components/ui/UpdateNotification.tsx +48 -36
- package/src/components/ui/command-items.ts +178 -0
- package/src/components/workload/WorkloadView.tsx +1342 -158
- package/src/context/ConnectionContext.tsx +146 -21
- package/src/context/DiagnoseCustomization.tsx +93 -0
- package/src/context/NavCustomization.tsx +75 -0
- package/src/context/TimelineSource.tsx +50 -0
- package/src/contexts/CapabilitiesContext.tsx +32 -8
- package/src/filter/FilterLocationBridge.tsx +30 -0
- package/src/hooks/useClusterLoadState.ts +73 -0
- package/src/hooks/useDocumentTitle.ts +25 -0
- package/src/hooks/useEventSource.ts +6 -0
- package/src/hooks/useKeyboardShortcuts.tsx +1 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/useNavRailPinned.ts +46 -0
- package/src/hooks/useRecentResources.ts +49 -0
- package/src/index.css +162 -1
- package/src/index.ts +73 -1
- package/src/main.tsx +7 -5
- package/src/types/clusterLoadState.ts +33 -0
- package/src/types.ts +2 -0
- package/src/utils/auditBadges.ts +53 -0
- package/src/utils/navigation.ts +64 -1
- package/src/components/ui/NamespaceSelector.tsx +0 -436
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { useMemo, useState } from 'react'
|
|
2
|
+
import { 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
|
+
useAutoPromConnect,
|
|
15
|
+
type PrometheusMetricCategory,
|
|
16
|
+
type PrometheusTimeRange,
|
|
17
|
+
} from '../../api/client'
|
|
18
|
+
import {
|
|
19
|
+
MetricsSummary,
|
|
20
|
+
TIME_RANGES,
|
|
21
|
+
WORKLOAD_CATEGORIES,
|
|
22
|
+
NODE_CATEGORIES,
|
|
23
|
+
computeRequestLimitLines,
|
|
24
|
+
type CategoryDef,
|
|
25
|
+
} from './PrometheusCharts'
|
|
26
|
+
import { RestartEventLane } from './RestartChart'
|
|
27
|
+
import { Tooltip } from '../ui/Tooltip'
|
|
28
|
+
|
|
29
|
+
// Used when MetricsTabContent is in expanded (full-screen) mode. Drawer mode
|
|
30
|
+
// uses the single-chart tabbed `PrometheusCharts` instead — drawer width
|
|
31
|
+
// can't fit the grid cleanly.
|
|
32
|
+
export interface PrometheusChartsGridProps {
|
|
33
|
+
kind: string
|
|
34
|
+
namespace: string
|
|
35
|
+
name: string
|
|
36
|
+
/** Optional full K8s resource for request/limit overlay derivation. */
|
|
37
|
+
resource?: any
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const SUPPORTED_KINDS = new Set([
|
|
41
|
+
'Pod', 'Deployment', 'StatefulSet', 'DaemonSet', 'ReplicaSet', 'Job', 'CronJob', 'Node',
|
|
42
|
+
])
|
|
43
|
+
|
|
44
|
+
export function PrometheusChartsGrid({
|
|
45
|
+
kind,
|
|
46
|
+
namespace,
|
|
47
|
+
name,
|
|
48
|
+
resource,
|
|
49
|
+
}: PrometheusChartsGridProps) {
|
|
50
|
+
useAutoPromConnect()
|
|
51
|
+
const { data: status, isLoading: statusLoading } = usePrometheusStatus()
|
|
52
|
+
const connectMutation = usePrometheusConnect()
|
|
53
|
+
const isConnected = status?.connected === true
|
|
54
|
+
const isSupported = SUPPORTED_KINDS.has(kind)
|
|
55
|
+
const showRestartLane = isSupported && kind !== 'Node'
|
|
56
|
+
|
|
57
|
+
const [timeRange, setTimeRange] = useState<PrometheusTimeRange>('1h')
|
|
58
|
+
|
|
59
|
+
const categories = kind === 'Node' ? NODE_CATEGORIES : WORKLOAD_CATEGORIES
|
|
60
|
+
|
|
61
|
+
// CPU + memory get reference-line overlays when a resource is provided.
|
|
62
|
+
// Computed once at the parent so each panel can stay otherwise generic.
|
|
63
|
+
const cpuRefLines = useMemo<ReferenceLine[] | undefined>(
|
|
64
|
+
() => (resource ? computeRequestLimitLines(resource, kind, 'cpu') : undefined),
|
|
65
|
+
[resource, kind],
|
|
66
|
+
)
|
|
67
|
+
const memRefLines = useMemo<ReferenceLine[] | undefined>(
|
|
68
|
+
() => (resource ? computeRequestLimitLines(resource, kind, 'memory') : undefined),
|
|
69
|
+
[resource, kind],
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
if (!isSupported) return null
|
|
73
|
+
|
|
74
|
+
if (statusLoading) {
|
|
75
|
+
return (
|
|
76
|
+
<div className="flex items-center justify-center py-12 text-theme-text-tertiary">
|
|
77
|
+
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
|
78
|
+
Checking Prometheus availability...
|
|
79
|
+
</div>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!isConnected) {
|
|
84
|
+
return (
|
|
85
|
+
<div className="flex flex-col items-center justify-center py-12 gap-4">
|
|
86
|
+
<WifiOff className="w-10 h-10 text-theme-text-quaternary" />
|
|
87
|
+
<div className="text-center">
|
|
88
|
+
<p className="text-sm text-theme-text-secondary mb-1">Prometheus not connected</p>
|
|
89
|
+
<p className="text-xs text-theme-text-tertiary mb-4">
|
|
90
|
+
{status?.error || 'Connect to view historical CPU, memory, and network metrics'}
|
|
91
|
+
</p>
|
|
92
|
+
<button
|
|
93
|
+
onClick={() => connectMutation.mutate()}
|
|
94
|
+
disabled={connectMutation.isPending}
|
|
95
|
+
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg btn-brand"
|
|
96
|
+
>
|
|
97
|
+
{connectMutation.isPending ? (
|
|
98
|
+
<Loader2 className="w-4 h-4 animate-spin" />
|
|
99
|
+
) : (
|
|
100
|
+
<Wifi className="w-4 h-4" />
|
|
101
|
+
)}
|
|
102
|
+
Discover Prometheus
|
|
103
|
+
</button>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const findCategory = (key: PrometheusMetricCategory): CategoryDef | undefined =>
|
|
110
|
+
categories.find(c => c.key === key)
|
|
111
|
+
|
|
112
|
+
const primaryCats: { def: CategoryDef; refLines?: ReferenceLine[] }[] = []
|
|
113
|
+
const cpu = findCategory('cpu')
|
|
114
|
+
if (cpu) primaryCats.push({ def: cpu, refLines: cpuRefLines })
|
|
115
|
+
const mem = findCategory('memory')
|
|
116
|
+
if (mem) primaryCats.push({ def: mem, refLines: memRefLines })
|
|
117
|
+
if (kind !== 'Node') {
|
|
118
|
+
const rx = findCategory('network_rx')
|
|
119
|
+
if (rx) primaryCats.push({ def: rx })
|
|
120
|
+
const tx = findCategory('network_tx')
|
|
121
|
+
if (tx) primaryCats.push({ def: tx })
|
|
122
|
+
}
|
|
123
|
+
const disk = findCategory('filesystem')
|
|
124
|
+
const chartPanels = disk ? [...primaryCats, { def: disk }] : primaryCats
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div className="flex flex-col h-full overflow-auto">
|
|
128
|
+
<div className="flex shrink-0 justify-end px-4 pt-3">
|
|
129
|
+
<select
|
|
130
|
+
aria-label="Metrics time range"
|
|
131
|
+
value={timeRange}
|
|
132
|
+
onChange={e => setTimeRange(e.target.value as PrometheusTimeRange)}
|
|
133
|
+
className="rounded-md border border-theme-border bg-theme-elevated px-2 py-1 text-xs text-theme-text-secondary shadow-theme-sm focus:outline-none focus:ring-1 focus:ring-accent/50"
|
|
134
|
+
>
|
|
135
|
+
{TIME_RANGES.map(tr => (
|
|
136
|
+
<option key={tr.value} value={tr.value}>{tr.label}</option>
|
|
137
|
+
))}
|
|
138
|
+
</select>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
{/* Restart lane sits above the grid so its markers visually align with
|
|
142
|
+
the time axis of the charts below. */}
|
|
143
|
+
{showRestartLane && (
|
|
144
|
+
<div className="px-4 pt-3">
|
|
145
|
+
<RestartEventLane kind={kind} namespace={namespace} name={name} range={timeRange} />
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
|
|
149
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 p-4">
|
|
150
|
+
{chartPanels.map(({ def, refLines }) => (
|
|
151
|
+
<MetricsPanel
|
|
152
|
+
key={def.key}
|
|
153
|
+
category={def}
|
|
154
|
+
kind={kind}
|
|
155
|
+
namespace={namespace}
|
|
156
|
+
name={name}
|
|
157
|
+
timeRange={timeRange}
|
|
158
|
+
referenceLines={refLines}
|
|
159
|
+
/>
|
|
160
|
+
))}
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
interface MetricsPanelProps {
|
|
167
|
+
category: CategoryDef
|
|
168
|
+
kind: string
|
|
169
|
+
namespace: string
|
|
170
|
+
name: string
|
|
171
|
+
timeRange: PrometheusTimeRange
|
|
172
|
+
referenceLines?: ReferenceLine[]
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function MetricsPanel({ category, kind, namespace, name, timeRange, referenceLines }: MetricsPanelProps) {
|
|
176
|
+
const { data: metrics, isLoading, error } = usePrometheusResourceMetrics(
|
|
177
|
+
kind, namespace, name, category.key, timeRange, true,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
const series = metrics?.result?.series
|
|
181
|
+
const hasData = (series?.length ?? 0) > 0
|
|
182
|
+
|
|
183
|
+
// "% of limit / request" derived from current peak vs reference lines.
|
|
184
|
+
// Without this, a low-utilization workload with a high limit looks like
|
|
185
|
+
// an empty chart — the user can't tell healthy from starved at a glance.
|
|
186
|
+
const saturation = hasData && series && referenceLines
|
|
187
|
+
? computeSaturation(series, referenceLines)
|
|
188
|
+
: undefined
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<section className="rounded-lg border border-theme-border bg-theme-surface/30 p-3 flex flex-col min-h-[260px]">
|
|
192
|
+
<header className="flex items-center justify-between mb-2 gap-3">
|
|
193
|
+
<div className="flex items-center gap-2">
|
|
194
|
+
<h3 className="text-xs font-medium text-theme-text-secondary uppercase tracking-wide">
|
|
195
|
+
{category.label}
|
|
196
|
+
</h3>
|
|
197
|
+
{saturation && <SaturationChip {...saturation} />}
|
|
198
|
+
</div>
|
|
199
|
+
{hasData && series && (
|
|
200
|
+
<MetricsSummary series={series} category={category} unit={metrics!.unit} />
|
|
201
|
+
)}
|
|
202
|
+
</header>
|
|
203
|
+
|
|
204
|
+
<div className="flex-1 min-h-[200px]">
|
|
205
|
+
{isLoading ? (
|
|
206
|
+
<PanelLoading />
|
|
207
|
+
) : error ? (
|
|
208
|
+
<PanelError message={(error as Error).message} />
|
|
209
|
+
) : hasData && series ? (
|
|
210
|
+
<>
|
|
211
|
+
<AreaChart
|
|
212
|
+
series={series}
|
|
213
|
+
color={category.chartColor}
|
|
214
|
+
fillColor={category.fillColor}
|
|
215
|
+
unit={metrics!.unit}
|
|
216
|
+
referenceLines={referenceLines}
|
|
217
|
+
/>
|
|
218
|
+
{series.length > 1 && (
|
|
219
|
+
<div className="mt-1.5">
|
|
220
|
+
<SeriesLegend series={series} color={category.chartColor} />
|
|
221
|
+
</div>
|
|
222
|
+
)}
|
|
223
|
+
</>
|
|
224
|
+
) : (
|
|
225
|
+
<PanelNoData hint={metrics?.hint} />
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
</section>
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function SaturationChip({ ratio, against }: { ratio: number; against: 'limit' | 'request' }) {
|
|
233
|
+
// Thresholds match the rightsizing tone vocabulary: amber from 75% (start
|
|
234
|
+
// watching), red at 90% (the same OOM-risk boundary the backend uses for
|
|
235
|
+
// memory in classifyRightsizing).
|
|
236
|
+
const tone: Severity = ratio >= 0.9 ? 'error' : ratio >= 0.75 ? 'warning' : ratio < 0.05 ? 'info' : 'neutral'
|
|
237
|
+
const label = `${(ratio * 100).toFixed(ratio < 0.1 ? 1 : 0)}% of ${against}`
|
|
238
|
+
return <span className={`badge badge-sm ${SEVERITY_BADGE[tone]}`}>{label}</span>
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function PanelLoading() {
|
|
242
|
+
return (
|
|
243
|
+
<div className="flex items-center justify-center h-full min-h-[160px] text-theme-text-tertiary text-xs">
|
|
244
|
+
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
|
245
|
+
Loading…
|
|
246
|
+
</div>
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function PanelError({ message }: { message: string }) {
|
|
251
|
+
return (
|
|
252
|
+
<div className={`flex flex-col items-center justify-center h-full min-h-[160px] ${SEVERITY_TEXT.warning} text-xs px-3 text-center`}>
|
|
253
|
+
Query failed
|
|
254
|
+
<Tooltip content={message} wrapperClassName="!block w-full">
|
|
255
|
+
<span className="text-theme-text-quaternary mt-0.5 line-clamp-2">{message}</span>
|
|
256
|
+
</Tooltip>
|
|
257
|
+
</div>
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function PanelNoData({ hint }: { hint?: string }) {
|
|
262
|
+
return (
|
|
263
|
+
<div className="flex flex-col items-center justify-center h-full min-h-[160px] text-theme-text-tertiary text-xs px-3 text-center">
|
|
264
|
+
No data
|
|
265
|
+
{hint && <span className="text-theme-text-quaternary mt-1 max-w-xs">{hint}</span>}
|
|
266
|
+
</div>
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export { isPrometheusSupported } from './PrometheusCharts'
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { useEffect, useMemo } from 'react'
|
|
2
|
+
import { AlertCircle } from 'lucide-react'
|
|
3
|
+
import { usePrometheusResourceMetrics, usePrometheusStatus, type PrometheusSeries, type PrometheusTimeRange } from '../../api/client'
|
|
4
|
+
import { Tooltip } from '../ui/Tooltip'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* RestartEventLane — vertical markers at each restart event, on a dedicated
|
|
8
|
+
* row below the chart. Markers stay readable when they cluster because they
|
|
9
|
+
* don't overlay the chart waveform. KSM-gated (uses kube_pod_container_status_restarts_total)
|
|
10
|
+
* — silently hidden when Prom isn't connected or the series doesn't exist.
|
|
11
|
+
*/
|
|
12
|
+
export function RestartEventLane({ kind, namespace, name, range = '1h' }: {
|
|
13
|
+
kind: string
|
|
14
|
+
namespace: string
|
|
15
|
+
name: string
|
|
16
|
+
range?: PrometheusTimeRange
|
|
17
|
+
}) {
|
|
18
|
+
const { data: status } = usePrometheusStatus()
|
|
19
|
+
const isConnected = status?.connected === true
|
|
20
|
+
const { data: metrics, isLoading, error } = usePrometheusResourceMetrics(kind, namespace, name, 'restarts', range, isConnected)
|
|
21
|
+
|
|
22
|
+
const restarts = useMemo(() => collectRestartEvents(metrics?.result?.series), [metrics])
|
|
23
|
+
|
|
24
|
+
// A real Prom-side failure shouldn't look identical to "no restarts" — log
|
|
25
|
+
// it so an operator investigating a missing lane has a breadcrumb. The lane
|
|
26
|
+
// still hides because we don't want a permanent red banner on every pod.
|
|
27
|
+
// Effect-gated so we log once per error change, not on every re-render.
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (error) {
|
|
30
|
+
console.warn('[RestartEventLane] restart query failed', error)
|
|
31
|
+
}
|
|
32
|
+
}, [error])
|
|
33
|
+
|
|
34
|
+
if (!isConnected || isLoading) return null
|
|
35
|
+
if (restarts.length === 0) return null
|
|
36
|
+
|
|
37
|
+
// Position markers within the chart's full time window — not the
|
|
38
|
+
// min/max of detected events — so a cluster of restarts at the start
|
|
39
|
+
// of the window doesn't visually spread across the whole lane.
|
|
40
|
+
const nowSec = Date.now() / 1000
|
|
41
|
+
const windowStart = nowSec - rangeToSeconds(range)
|
|
42
|
+
const span = nowSec - windowStart
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div className="rounded-md border border-amber-500/20 bg-amber-500/[0.04] px-3 py-2">
|
|
46
|
+
<div className="flex items-center gap-2 mb-1.5">
|
|
47
|
+
<AlertCircle className="w-3.5 h-3.5 text-amber-500/70" />
|
|
48
|
+
<span className="text-xs font-medium text-theme-text-secondary">
|
|
49
|
+
Restarts in last {range}
|
|
50
|
+
</span>
|
|
51
|
+
<span className="text-xs text-theme-text-quaternary tabular-nums">
|
|
52
|
+
{restarts.reduce((n, r) => n + r.value, 0)} total
|
|
53
|
+
</span>
|
|
54
|
+
</div>
|
|
55
|
+
<div className="relative h-5">
|
|
56
|
+
{/* Baseline */}
|
|
57
|
+
<div className="absolute inset-x-0 top-1/2 h-px bg-theme-border/40" />
|
|
58
|
+
{/* Markers */}
|
|
59
|
+
{restarts.map((r, i) => {
|
|
60
|
+
const left = `${Math.max(0, Math.min(100, ((r.timestamp - windowStart) / span) * 100))}%`
|
|
61
|
+
return (
|
|
62
|
+
<Tooltip
|
|
63
|
+
key={i}
|
|
64
|
+
content={`${new Date(r.timestamp * 1000).toLocaleString()} · ${r.label}${r.value > 1 ? ` ×${r.value}` : ''}`}
|
|
65
|
+
wrapperClassName="absolute top-0 h-full w-px bg-amber-500/80"
|
|
66
|
+
wrapperStyle={{ left }}
|
|
67
|
+
>
|
|
68
|
+
<div className="absolute -top-0.5 left-1/2 -translate-x-1/2 w-1.5 h-1.5 rounded-full bg-amber-500" />
|
|
69
|
+
</Tooltip>
|
|
70
|
+
)
|
|
71
|
+
})}
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ============================================================================
|
|
78
|
+
// Internals
|
|
79
|
+
// ============================================================================
|
|
80
|
+
|
|
81
|
+
interface RestartEvent {
|
|
82
|
+
timestamp: number
|
|
83
|
+
value: number
|
|
84
|
+
label: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function collectRestartEvents(series: PrometheusSeries[] | undefined): RestartEvent[] {
|
|
88
|
+
if (!series) return []
|
|
89
|
+
const events: RestartEvent[] = []
|
|
90
|
+
for (const s of series) {
|
|
91
|
+
const pod = s.labels.pod ?? 'pod'
|
|
92
|
+
// `changes(...[1h])` produces a rolling count, so a single restart shows
|
|
93
|
+
// value=1 for ~60 consecutive samples (the whole 1h window). Emit a marker
|
|
94
|
+
// only when the count increases — that's when a *new* restart entered the
|
|
95
|
+
// window — and use the increase as the marker's restart count.
|
|
96
|
+
let prev: number | null = null
|
|
97
|
+
for (const dp of s.dataPoints) {
|
|
98
|
+
// Skip gap samples and drop the previous reference, so the next finite
|
|
99
|
+
// sample isn't diffed across the gap — a delta across a gap is
|
|
100
|
+
// meaningless and could fabricate a restart count. We compare only
|
|
101
|
+
// consecutive finite samples.
|
|
102
|
+
if (dp.value == null) {
|
|
103
|
+
prev = null
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
if (prev !== null) {
|
|
107
|
+
// Only count positive deltas — restarts that entered the rolling 1h
|
|
108
|
+
// window during the chart range. The first sample's value covers
|
|
109
|
+
// [start-1h, start] which is outside the user's chosen window; counting
|
|
110
|
+
// it would inflate the total with pre-window restarts.
|
|
111
|
+
const delta = dp.value - prev
|
|
112
|
+
if (delta > 0) {
|
|
113
|
+
events.push({ timestamp: dp.timestamp, value: delta, label: pod })
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
prev = dp.value
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
events.sort((a, b) => a.timestamp - b.timestamp)
|
|
120
|
+
return events
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function rangeToSeconds(range: PrometheusTimeRange): number {
|
|
124
|
+
const match = range.match(/^(\d+)([mhd])$/)
|
|
125
|
+
if (!match) return 3600 // default to 1h if unrecognized
|
|
126
|
+
const n = parseInt(match[1], 10)
|
|
127
|
+
switch (match[2]) {
|
|
128
|
+
case 'm': return n * 60
|
|
129
|
+
case 'h': return n * 3600
|
|
130
|
+
case 'd': return n * 86400
|
|
131
|
+
default: return 3600
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { RightsizingRow } from '../../api/client'
|
|
3
|
+
import {
|
|
4
|
+
getRightsizingExplanation,
|
|
5
|
+
getRightsizingPresentation,
|
|
6
|
+
RIGHTSIZING_METHODOLOGY,
|
|
7
|
+
RIGHTSIZING_DOCS_URL,
|
|
8
|
+
RIGHTSIZING_SUMMARY,
|
|
9
|
+
} from './RightsizingStrip'
|
|
10
|
+
|
|
11
|
+
const row = (overrides: Partial<RightsizingRow> = {}): RightsizingRow => ({
|
|
12
|
+
container: 'server',
|
|
13
|
+
resource: 'cpu',
|
|
14
|
+
fit: 'balanced',
|
|
15
|
+
confidence: 'high',
|
|
16
|
+
sampleCount: 2016,
|
|
17
|
+
expectedSamples: 2016,
|
|
18
|
+
coverage: 1,
|
|
19
|
+
hpaManaged: false,
|
|
20
|
+
hpaEvidenceAvailable: true,
|
|
21
|
+
oomEvidenceAvailable: true,
|
|
22
|
+
throttleAvailable: true,
|
|
23
|
+
...overrides,
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('rightsizing presentation', () => {
|
|
27
|
+
it('keeps fit, confidence, and runtime risk as separate concepts', () => {
|
|
28
|
+
expect(getRightsizingPresentation('oversized')).toEqual({
|
|
29
|
+
label: 'Oversized',
|
|
30
|
+
severity: 'info',
|
|
31
|
+
})
|
|
32
|
+
expect(getRightsizingPresentation('under_requested')).toEqual({
|
|
33
|
+
label: 'Under-requested',
|
|
34
|
+
severity: 'warning',
|
|
35
|
+
})
|
|
36
|
+
expect(getRightsizingPresentation('insufficient_history')).toEqual({
|
|
37
|
+
label: 'Insufficient history',
|
|
38
|
+
severity: 'neutral',
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('labels query failures independently from insufficient history', () => {
|
|
43
|
+
expect(getRightsizingPresentation('insufficient_history', 'usage query failed')).toEqual({
|
|
44
|
+
label: 'Query failed',
|
|
45
|
+
severity: 'error',
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('explains why recommendations are withheld without inventing a zero-risk result', () => {
|
|
50
|
+
expect(getRightsizingExplanation(row({ recommendationReason: 'hpa_managed' }))).toContain(
|
|
51
|
+
'HPA manages cpu',
|
|
52
|
+
)
|
|
53
|
+
expect(
|
|
54
|
+
getRightsizingExplanation(row({ resource: 'memory', recommendationReason: 'oom_evidence' })),
|
|
55
|
+
).toContain('OOM evidence')
|
|
56
|
+
expect(
|
|
57
|
+
getRightsizingExplanation(row({ recommendationReason: 'hpa_evidence_unavailable' })),
|
|
58
|
+
).toContain('could not verify HPA')
|
|
59
|
+
expect(
|
|
60
|
+
getRightsizingExplanation(
|
|
61
|
+
row({
|
|
62
|
+
resource: 'memory',
|
|
63
|
+
recommendationReason: 'oom_evidence_unavailable',
|
|
64
|
+
}),
|
|
65
|
+
),
|
|
66
|
+
).toContain('could not verify recent OOM')
|
|
67
|
+
expect(getRightsizingExplanation(row({ throttleAvailable: false }))).toContain(
|
|
68
|
+
'throttling metrics are unavailable',
|
|
69
|
+
)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('separates the demand target from a conservative reduction step', () => {
|
|
73
|
+
const explanation = getRightsizingExplanation(
|
|
74
|
+
row({
|
|
75
|
+
fit: 'oversized',
|
|
76
|
+
currentRequest: '1',
|
|
77
|
+
recommendedRequest: '500m',
|
|
78
|
+
calculatedRequest: '10m',
|
|
79
|
+
reductionLimited: true,
|
|
80
|
+
bursty: true,
|
|
81
|
+
peak: { name: 'P99', value: 0.2, formatted: '200m' },
|
|
82
|
+
}),
|
|
83
|
+
)
|
|
84
|
+
expect(explanation).toContain('Demand-based target: 10m')
|
|
85
|
+
expect(explanation).toContain('conservative next step')
|
|
86
|
+
expect(explanation).toContain('CPU P99 reached 200m')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('does not revive the misleading efficiency vocabulary', () => {
|
|
90
|
+
const copy = [
|
|
91
|
+
getRightsizingPresentation('balanced').label,
|
|
92
|
+
getRightsizingPresentation('oversized').label,
|
|
93
|
+
getRightsizingExplanation(row({ recommendationReason: 'request_within_fit_range' })),
|
|
94
|
+
]
|
|
95
|
+
.join(' ')
|
|
96
|
+
.toLowerCase()
|
|
97
|
+
expect(copy).not.toContain('efficien')
|
|
98
|
+
expect(copy).not.toContain('waste')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('sets the workload-level scope and methodology without promising savings or changes', () => {
|
|
102
|
+
expect(RIGHTSIZING_SUMMARY).toContain('this workload')
|
|
103
|
+
expect(RIGHTSIZING_SUMMARY).toContain('not a savings estimate or automatic change')
|
|
104
|
+
expect(RIGHTSIZING_METHODOLOGY).toContain('CPU P95 and memory maximum')
|
|
105
|
+
expect(RIGHTSIZING_METHODOLOGY).toContain('Reductions are staged')
|
|
106
|
+
expect(RIGHTSIZING_METHODOLOGY).toContain('Radar does not change requests')
|
|
107
|
+
expect(RIGHTSIZING_DOCS_URL).toContain('/features/rightsizing')
|
|
108
|
+
})
|
|
109
|
+
})
|