@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
|
@@ -1,24 +1,29 @@
|
|
|
1
|
-
import { useState, useMemo
|
|
2
|
-
import {
|
|
3
|
-
|
|
1
|
+
import { useState, useMemo } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
PrometheusChartsView,
|
|
4
|
+
MetricsSummary as BaseMetricsSummary,
|
|
5
|
+
type TimeSeries,
|
|
6
|
+
type ReferenceLine,
|
|
7
|
+
} from '@skyhook-io/k8s-ui/components/charts'
|
|
4
8
|
import {
|
|
5
9
|
usePrometheusStatus,
|
|
6
10
|
usePrometheusConnect,
|
|
7
11
|
usePrometheusResourceMetrics,
|
|
12
|
+
useAutoPromConnect,
|
|
8
13
|
type PrometheusMetricCategory,
|
|
9
14
|
type PrometheusTimeRange,
|
|
10
|
-
type PrometheusSeries,
|
|
11
15
|
} from '../../api/client'
|
|
12
16
|
|
|
13
17
|
// ============================================================================
|
|
14
|
-
//
|
|
18
|
+
// Radar-specific category config (lives here, not in k8s-ui, so consumers
|
|
19
|
+
// reusing AreaChart aren't forced to inherit Radar's category vocabulary).
|
|
15
20
|
// ============================================================================
|
|
16
21
|
|
|
17
22
|
const SUPPORTED_KINDS = new Set([
|
|
18
23
|
'Pod', 'Deployment', 'StatefulSet', 'DaemonSet', 'ReplicaSet', 'Job', 'CronJob', 'Node',
|
|
19
24
|
])
|
|
20
25
|
|
|
21
|
-
interface CategoryDef {
|
|
26
|
+
export interface CategoryDef {
|
|
22
27
|
key: PrometheusMetricCategory
|
|
23
28
|
label: string
|
|
24
29
|
color: string // tailwind text class
|
|
@@ -26,7 +31,7 @@ interface CategoryDef {
|
|
|
26
31
|
fillColor: string // hex with alpha for SVG fill
|
|
27
32
|
}
|
|
28
33
|
|
|
29
|
-
const WORKLOAD_CATEGORIES: CategoryDef[] = [
|
|
34
|
+
export const WORKLOAD_CATEGORIES: CategoryDef[] = [
|
|
30
35
|
{ key: 'cpu', label: 'CPU', color: 'text-blue-400', chartColor: '#60a5fa', fillColor: '#60a5fa22' },
|
|
31
36
|
{ key: 'memory', label: 'Memory', color: 'text-purple-400', chartColor: '#c084fc', fillColor: '#c084fc22' },
|
|
32
37
|
{ key: 'network_rx', label: 'Net RX', color: 'text-emerald-400', chartColor: '#34d399', fillColor: '#34d39922' },
|
|
@@ -34,28 +39,13 @@ const WORKLOAD_CATEGORIES: CategoryDef[] = [
|
|
|
34
39
|
{ key: 'filesystem', label: 'Disk I/O', color: 'text-amber-400', chartColor: '#fbbf24', fillColor: '#fbbf2422' },
|
|
35
40
|
]
|
|
36
41
|
|
|
37
|
-
const NODE_CATEGORIES: CategoryDef[] = [
|
|
42
|
+
export const NODE_CATEGORIES: CategoryDef[] = [
|
|
38
43
|
{ key: 'cpu', label: 'CPU', color: 'text-blue-400', chartColor: '#60a5fa', fillColor: '#60a5fa22' },
|
|
39
44
|
{ key: 'memory', label: 'Memory', color: 'text-purple-400', chartColor: '#c084fc', fillColor: '#c084fc22' },
|
|
40
45
|
{ key: 'filesystem', label: 'Disk', color: 'text-amber-400', chartColor: '#fbbf24', fillColor: '#fbbf2422' },
|
|
41
46
|
]
|
|
42
47
|
|
|
43
|
-
|
|
44
|
-
// Uses 500-level shades for adequate contrast on both dark (#1e293b) and light (#ffffff) surfaces.
|
|
45
|
-
const SERIES_COLORS = [
|
|
46
|
-
'#3b82f6', // blue-500
|
|
47
|
-
'#10b981', // emerald-500
|
|
48
|
-
'#f97316', // orange-500
|
|
49
|
-
'#a855f7', // purple-500
|
|
50
|
-
'#ec4899', // pink-500
|
|
51
|
-
'#eab308', // yellow-500
|
|
52
|
-
'#06b6d4', // cyan-500
|
|
53
|
-
'#84cc16', // lime-500
|
|
54
|
-
'#ef4444', // red-500
|
|
55
|
-
'#6366f1', // indigo-500
|
|
56
|
-
]
|
|
57
|
-
|
|
58
|
-
const TIME_RANGES: { value: PrometheusTimeRange; label: string }[] = [
|
|
48
|
+
export const TIME_RANGES: { value: PrometheusTimeRange; label: string }[] = [
|
|
59
49
|
{ value: '10m', label: '10m' },
|
|
60
50
|
{ value: '30m', label: '30m' },
|
|
61
51
|
{ value: '1h', label: '1h' },
|
|
@@ -66,6 +56,16 @@ const TIME_RANGES: { value: PrometheusTimeRange; label: string }[] = [
|
|
|
66
56
|
{ value: '7d', label: '7d' },
|
|
67
57
|
]
|
|
68
58
|
|
|
59
|
+
// Radar's MetricsSummary thin wrapper — adapts CategoryDef to the slim
|
|
60
|
+
// interface of the shared k8s-ui primitive so callers downstream don't change.
|
|
61
|
+
export function MetricsSummary({ series, category, unit }: {
|
|
62
|
+
series: TimeSeries[]
|
|
63
|
+
category: CategoryDef
|
|
64
|
+
unit: string
|
|
65
|
+
}) {
|
|
66
|
+
return <BaseMetricsSummary series={series} unit={unit} currentColorClass={category.color} />
|
|
67
|
+
}
|
|
68
|
+
|
|
69
69
|
// ============================================================================
|
|
70
70
|
// Main Component
|
|
71
71
|
// ============================================================================
|
|
@@ -76,13 +76,22 @@ interface PrometheusChartsProps {
|
|
|
76
76
|
name: string
|
|
77
77
|
/** When true, show "no data" empty state instead of hiding. Defaults to false (hide when no data). */
|
|
78
78
|
showEmptyState?: boolean
|
|
79
|
+
/**
|
|
80
|
+
* Full K8s resource. When provided, CPU and memory charts overlay the
|
|
81
|
+
* aggregate request/limit (summed across runtime containers including
|
|
82
|
+
* native sidecars, multiplied by readyReplicas for replicated workloads).
|
|
83
|
+
*/
|
|
84
|
+
resource?: any
|
|
85
|
+
/** Notifies the parent when the user changes the time range, so sibling
|
|
86
|
+
* panels (e.g. restart lane) can mirror the selection. */
|
|
87
|
+
onTimeRangeChange?: (range: PrometheusTimeRange) => void
|
|
79
88
|
}
|
|
80
89
|
|
|
81
|
-
export function PrometheusCharts({ kind, namespace, name, showEmptyState = false }: PrometheusChartsProps) {
|
|
90
|
+
export function PrometheusCharts({ kind, namespace, name, showEmptyState = false, resource, onTimeRangeChange }: PrometheusChartsProps) {
|
|
91
|
+
useAutoPromConnect()
|
|
82
92
|
const { data: status, isLoading: statusLoading } = usePrometheusStatus()
|
|
83
93
|
const connectMutation = usePrometheusConnect()
|
|
84
94
|
|
|
85
|
-
const categories = kind === 'Node' ? NODE_CATEGORIES : WORKLOAD_CATEGORIES
|
|
86
95
|
const [activeCategory, setActiveCategory] = useState<PrometheusMetricCategory>('cpu')
|
|
87
96
|
const [timeRange, setTimeRange] = useState<PrometheusTimeRange>('1h')
|
|
88
97
|
|
|
@@ -95,587 +104,154 @@ export function PrometheusCharts({ kind, namespace, name, showEmptyState = false
|
|
|
95
104
|
isConnected && isSupported,
|
|
96
105
|
)
|
|
97
106
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
return (
|
|
106
|
-
<div className="flex items-center justify-center py-12 text-theme-text-tertiary">
|
|
107
|
-
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
|
108
|
-
Checking Prometheus availability...
|
|
109
|
-
</div>
|
|
110
|
-
)
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// When embedded in Overview (showEmptyState=false), hide when not connected or no data
|
|
114
|
-
if (!showEmptyState) {
|
|
115
|
-
if (!isConnected) return null
|
|
116
|
-
if (!metricsLoading && !metricsError && !metrics?.result?.series?.length) return null
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
if (!isConnected) {
|
|
120
|
-
return (
|
|
121
|
-
<div className="flex flex-col items-center justify-center py-12 gap-4">
|
|
122
|
-
<WifiOff className="w-10 h-10 text-theme-text-quaternary" />
|
|
123
|
-
<div className="text-center">
|
|
124
|
-
<p className="text-sm text-theme-text-secondary mb-1">Prometheus not connected</p>
|
|
125
|
-
<p className="text-xs text-theme-text-tertiary mb-4">
|
|
126
|
-
{status?.error || 'Connect to view historical CPU, memory, and network metrics'}
|
|
127
|
-
</p>
|
|
128
|
-
<button
|
|
129
|
-
onClick={() => connectMutation.mutate()}
|
|
130
|
-
disabled={connectMutation.isPending}
|
|
131
|
-
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg btn-brand"
|
|
132
|
-
>
|
|
133
|
-
{connectMutation.isPending ? (
|
|
134
|
-
<Loader2 className="w-4 h-4 animate-spin" />
|
|
135
|
-
) : (
|
|
136
|
-
<Wifi className="w-4 h-4" />
|
|
137
|
-
)}
|
|
138
|
-
Discover Prometheus
|
|
139
|
-
</button>
|
|
140
|
-
</div>
|
|
141
|
-
</div>
|
|
142
|
-
)
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
const activeCategoryDef = categories.find(c => c.key === activeCategory) || categories[0]
|
|
107
|
+
// Reference lines: aggregate request/limit overlaid on CPU and memory charts.
|
|
108
|
+
// Computed unconditionally and placed above early returns to keep hook order
|
|
109
|
+
// stable when connection state transitions (Rules of Hooks).
|
|
110
|
+
const referenceLines = useMemo<ReferenceLine[] | undefined>(() => {
|
|
111
|
+
if (!resource || (activeCategory !== 'cpu' && activeCategory !== 'memory')) return undefined
|
|
112
|
+
return computeRequestLimitLines(resource, kind, activeCategory)
|
|
113
|
+
}, [resource, kind, activeCategory])
|
|
146
114
|
|
|
147
115
|
return (
|
|
148
|
-
<
|
|
149
|
-
{
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
{cat.label}
|
|
166
|
-
</button>
|
|
167
|
-
))}
|
|
168
|
-
</div>
|
|
169
|
-
|
|
170
|
-
{/* Time range selector */}
|
|
171
|
-
<select
|
|
172
|
-
value={timeRange}
|
|
173
|
-
onChange={e => setTimeRange(e.target.value as PrometheusTimeRange)}
|
|
174
|
-
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"
|
|
175
|
-
>
|
|
176
|
-
{TIME_RANGES.map(tr => (
|
|
177
|
-
<option key={tr.value} value={tr.value}>{tr.label}</option>
|
|
178
|
-
))}
|
|
179
|
-
</select>
|
|
180
|
-
</div>
|
|
181
|
-
|
|
182
|
-
{/* Chart area — fixed min-height prevents layout shift while loading */}
|
|
183
|
-
<div className="min-h-[280px] p-4">
|
|
184
|
-
{metricsLoading ? (
|
|
185
|
-
<div className="flex items-center justify-center min-h-[240px] text-theme-text-tertiary">
|
|
186
|
-
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
|
187
|
-
Loading metrics...
|
|
188
|
-
</div>
|
|
189
|
-
) : metricsError ? (
|
|
190
|
-
<div className="flex items-center justify-center h-full text-red-400 text-sm">
|
|
191
|
-
Failed to load metrics: {(metricsError as Error).message}
|
|
192
|
-
</div>
|
|
193
|
-
) : metrics?.result?.series?.length ? (
|
|
194
|
-
<div className="h-full flex flex-col gap-4">
|
|
195
|
-
{/* Summary stats */}
|
|
196
|
-
<MetricsSummary
|
|
197
|
-
series={metrics.result.series}
|
|
198
|
-
category={activeCategoryDef}
|
|
199
|
-
unit={metrics.unit}
|
|
200
|
-
/>
|
|
201
|
-
|
|
202
|
-
{/* Main chart */}
|
|
203
|
-
<div className="flex-1 min-h-0">
|
|
204
|
-
<AreaChart
|
|
205
|
-
series={metrics.result.series}
|
|
206
|
-
color={activeCategoryDef.chartColor}
|
|
207
|
-
fillColor={activeCategoryDef.fillColor}
|
|
208
|
-
unit={metrics.unit}
|
|
209
|
-
/>
|
|
210
|
-
</div>
|
|
211
|
-
|
|
212
|
-
{/* Per-pod legend for workload-level queries */}
|
|
213
|
-
{metrics.result.series.length > 1 && (
|
|
214
|
-
<SeriesLegend series={metrics.result.series} color={activeCategoryDef.chartColor} />
|
|
215
|
-
)}
|
|
216
|
-
</div>
|
|
217
|
-
) : (
|
|
218
|
-
<div className="flex flex-col items-center justify-center h-full text-theme-text-tertiary">
|
|
219
|
-
<BarChart3 className="w-8 h-8 mb-2 opacity-40" />
|
|
220
|
-
<p className="text-sm">No data for this time range</p>
|
|
221
|
-
<p className="text-xs text-theme-text-quaternary mt-1">
|
|
222
|
-
Try a different time range or check that metrics are being collected
|
|
223
|
-
</p>
|
|
224
|
-
{metrics?.hint && (
|
|
225
|
-
<p className="mt-3 px-3 py-2 w-full max-w-lg text-xs text-yellow-700 dark:text-yellow-400 bg-yellow-500/10 border border-yellow-500/30 rounded">
|
|
226
|
-
{metrics.hint}
|
|
227
|
-
</p>
|
|
228
|
-
)}
|
|
229
|
-
{metrics?.query && (
|
|
230
|
-
<details className="mt-3 w-full max-w-lg text-left">
|
|
231
|
-
<summary className="text-xs text-theme-text-quaternary cursor-pointer hover:text-theme-text-tertiary">
|
|
232
|
-
Diagnostics: show PromQL query
|
|
233
|
-
</summary>
|
|
234
|
-
<div className="mt-2 p-2 bg-theme-base border border-theme-border rounded text-xs font-mono text-theme-text-secondary break-all">
|
|
235
|
-
{metrics.query}
|
|
236
|
-
</div>
|
|
237
|
-
<p className="mt-1.5 text-xs text-theme-text-quaternary">
|
|
238
|
-
This query returned no results. Verify in your Prometheus UI that the metric names and labels
|
|
239
|
-
({activeCategoryDef.key === 'cpu' ? 'pod, namespace, container' : 'pod, namespace'}) exist.
|
|
240
|
-
Custom label relabeling in your Prometheus configuration may require adjustments.
|
|
241
|
-
</p>
|
|
242
|
-
</details>
|
|
243
|
-
)}
|
|
244
|
-
</div>
|
|
245
|
-
)}
|
|
246
|
-
</div>
|
|
247
|
-
</div>
|
|
116
|
+
<PrometheusChartsView
|
|
117
|
+
kind={kind}
|
|
118
|
+
showEmptyState={showEmptyState}
|
|
119
|
+
statusLoading={statusLoading}
|
|
120
|
+
isConnected={isConnected}
|
|
121
|
+
statusError={status?.error}
|
|
122
|
+
onConnect={() => connectMutation.mutate()}
|
|
123
|
+
connecting={connectMutation.isPending}
|
|
124
|
+
category={activeCategory}
|
|
125
|
+
onCategoryChange={setActiveCategory}
|
|
126
|
+
range={timeRange}
|
|
127
|
+
onRangeChange={(r) => { setTimeRange(r); onTimeRangeChange?.(r) }}
|
|
128
|
+
metrics={metrics}
|
|
129
|
+
metricsLoading={metricsLoading}
|
|
130
|
+
metricsError={(metricsError as Error) ?? null}
|
|
131
|
+
referenceLines={referenceLines}
|
|
132
|
+
/>
|
|
248
133
|
)
|
|
249
134
|
}
|
|
250
135
|
|
|
136
|
+
|
|
251
137
|
// ============================================================================
|
|
252
|
-
//
|
|
138
|
+
// Request/limit overlay derivation
|
|
253
139
|
// ============================================================================
|
|
254
140
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
141
|
+
/**
|
|
142
|
+
* Compute aggregate request + limit reference lines from a K8s resource spec.
|
|
143
|
+
* Sums across runtime containers (regular + native sidecars), excluding pure
|
|
144
|
+
* init containers. The values are per-pod — workload charts use
|
|
145
|
+
* `sum(...) by (pod, namespace)` (one series per pod, at per-pod scale), so
|
|
146
|
+
* the reference line lives on the same axis without any replica multiplier.
|
|
147
|
+
*
|
|
148
|
+
* Returns undefined when the spec doesn't have enough information to render
|
|
149
|
+
* a meaningful line (no runtime containers, or no values set on any container).
|
|
150
|
+
*/
|
|
151
|
+
export function computeRequestLimitLines(
|
|
152
|
+
resource: any,
|
|
153
|
+
kind: string,
|
|
154
|
+
category: 'cpu' | 'memory',
|
|
155
|
+
): ReferenceLine[] | undefined {
|
|
156
|
+
if (!resource) return undefined
|
|
157
|
+
const podSpec = extractPodSpec(resource, kind)
|
|
158
|
+
if (!podSpec) return undefined
|
|
159
|
+
|
|
160
|
+
const runtimeContainers = collectRuntimeContainers(podSpec)
|
|
161
|
+
if (runtimeContainers.length === 0) return undefined
|
|
162
|
+
|
|
163
|
+
let reqSum = 0, reqAny = false
|
|
164
|
+
let limSum = 0, limAny = false
|
|
165
|
+
for (const c of runtimeContainers) {
|
|
166
|
+
const req = readQuantity(c.resources?.requests?.[category], category)
|
|
167
|
+
const lim = readQuantity(c.resources?.limits?.[category], category)
|
|
168
|
+
if (req != null) { reqSum += req; reqAny = true }
|
|
169
|
+
if (lim != null) { limSum += lim; limAny = true }
|
|
170
|
+
}
|
|
280
171
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
172
|
+
const lines: ReferenceLine[] = []
|
|
173
|
+
if (reqAny) {
|
|
174
|
+
lines.push({
|
|
175
|
+
value: reqSum,
|
|
176
|
+
label: `request ${formatRequestLimitLabel(reqSum, category)}`,
|
|
177
|
+
kind: 'request',
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
if (limAny) {
|
|
181
|
+
lines.push({
|
|
182
|
+
value: limSum,
|
|
183
|
+
label: `limit ${formatRequestLimitLabel(limSum, category)}`,
|
|
184
|
+
kind: 'limit',
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
return lines.length > 0 ? lines : undefined
|
|
288
188
|
}
|
|
289
189
|
|
|
290
|
-
function
|
|
291
|
-
return
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
<span className={clsx('text-sm font-semibold tabular-nums', className)}>{value}</span>
|
|
295
|
-
</div>
|
|
296
|
-
)
|
|
190
|
+
function extractPodSpec(resource: any, kind: string): any | undefined {
|
|
191
|
+
if (kind === 'Pod') return resource?.spec
|
|
192
|
+
if (kind === 'CronJob') return resource?.spec?.jobTemplate?.spec?.template?.spec
|
|
193
|
+
return resource?.spec?.template?.spec
|
|
297
194
|
}
|
|
298
195
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
196
|
+
function collectRuntimeContainers(podSpec: any): any[] {
|
|
197
|
+
const out: any[] = []
|
|
198
|
+
for (const c of (podSpec?.containers || [])) out.push(c)
|
|
199
|
+
// Native sidecars (initContainers with restartPolicy: Always, GA in 1.33)
|
|
200
|
+
// run for the pod's lifetime and contribute to steady-state usage. Pure
|
|
201
|
+
// init containers run to completion and don't.
|
|
202
|
+
for (const c of (podSpec?.initContainers || [])) {
|
|
203
|
+
if (c?.restartPolicy === 'Always') out.push(c)
|
|
204
|
+
}
|
|
205
|
+
return out
|
|
305
206
|
}
|
|
306
207
|
|
|
307
|
-
|
|
308
|
-
|
|
208
|
+
const CPU_SUFFIXES: Record<string, number> = { n: 1e-9, u: 1e-6, m: 1e-3 }
|
|
209
|
+
const MEMORY_SUFFIXES: Record<string, number> = {
|
|
210
|
+
Ki: 1024, Mi: 1024 ** 2, Gi: 1024 ** 3, Ti: 1024 ** 4, Pi: 1024 ** 5, Ei: 1024 ** 6,
|
|
211
|
+
K: 1e3, M: 1e6, G: 1e9, T: 1e12, P: 1e15, E: 1e18,
|
|
309
212
|
}
|
|
310
213
|
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
214
|
+
// NOT a duplicate of @skyhook-io/k8s-ui/utils/format's parseCPUToNanocores /
|
|
215
|
+
// parseMemoryToBytes — those return 0 on invalid input. We need null so that
|
|
216
|
+
// "abcMi" doesn't silently poison the caller's running sum and produce a
|
|
217
|
+
// missing/zeroed reference line on the chart (real garbage data must be
|
|
218
|
+
// distinguishable from a legit 0).
|
|
219
|
+
function readQuantity(raw: unknown, category: 'cpu' | 'memory'): number | null {
|
|
220
|
+
if (raw == null) return null
|
|
221
|
+
const s = String(raw).trim()
|
|
222
|
+
if (s === '') return null
|
|
223
|
+
if (category === 'cpu') {
|
|
224
|
+
if (s.endsWith('m')) return scaleOrNull(s, CPU_SUFFIXES.m)
|
|
225
|
+
if (s.endsWith('n')) return scaleOrNull(s, CPU_SUFFIXES.n)
|
|
226
|
+
if (s.endsWith('u')) return scaleOrNull(s, CPU_SUFFIXES.u)
|
|
227
|
+
const v = parseFloat(s)
|
|
228
|
+
return isNaN(v) ? null : v
|
|
321
229
|
}
|
|
322
|
-
//
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
function AreaChart({ series, color, fillColor, unit }: {
|
|
333
|
-
series: PrometheusSeries[]
|
|
334
|
-
color: string
|
|
335
|
-
fillColor: string
|
|
336
|
-
unit: string
|
|
337
|
-
}) {
|
|
338
|
-
const svgRef = useRef<SVGSVGElement>(null)
|
|
339
|
-
const [hoverX, setHoverX] = useState<number | null>(null)
|
|
340
|
-
const multiSeries = series.length > 1
|
|
341
|
-
|
|
342
|
-
const chartData = useMemo(() => {
|
|
343
|
-
if (!series.length) return null
|
|
344
|
-
|
|
345
|
-
// Merge all series into a single timeline for the X axis
|
|
346
|
-
let minTs = Infinity
|
|
347
|
-
let maxTs = -Infinity
|
|
348
|
-
let maxVal = 0
|
|
349
|
-
|
|
350
|
-
for (const s of series) {
|
|
351
|
-
for (const dp of s.dataPoints) {
|
|
352
|
-
if (dp.timestamp < minTs) minTs = dp.timestamp
|
|
353
|
-
if (dp.timestamp > maxTs) maxTs = dp.timestamp
|
|
354
|
-
if (dp.value > maxVal) maxVal = dp.value
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
if (minTs === maxTs) maxTs = minTs + 60
|
|
359
|
-
if (maxVal === 0) {
|
|
360
|
-
// Use a small unit-appropriate default so the Y-axis isn't misleadingly large
|
|
361
|
-
maxVal = unit === 'cores' ? 0.01 : unit === 'bytes' ? 1024 * 1024 : unit === 'bytes/s' ? 1024 : 1
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
const padding = maxVal * 0.1
|
|
365
|
-
const yMax = maxVal + padding
|
|
366
|
-
|
|
367
|
-
return { minTs, maxTs, yMax, series }
|
|
368
|
-
}, [series, unit])
|
|
369
|
-
|
|
370
|
-
if (!chartData) return null
|
|
371
|
-
|
|
372
|
-
const { minTs, maxTs, yMax } = chartData
|
|
373
|
-
const width = 1000
|
|
374
|
-
const height = 300
|
|
375
|
-
const marginLeft = 60
|
|
376
|
-
const marginRight = 20
|
|
377
|
-
const marginTop = 10
|
|
378
|
-
const marginBottom = 30
|
|
379
|
-
const plotWidth = width - marginLeft - marginRight
|
|
380
|
-
const plotHeight = height - marginTop - marginBottom
|
|
381
|
-
|
|
382
|
-
const toX = (ts: number) => marginLeft + ((ts - minTs) / (maxTs - minTs)) * plotWidth
|
|
383
|
-
const toY = (val: number) => marginTop + plotHeight - (val / yMax) * plotHeight
|
|
384
|
-
|
|
385
|
-
// Y axis ticks
|
|
386
|
-
const yTicks = useMemo(() => {
|
|
387
|
-
const count = 4
|
|
388
|
-
return Array.from({ length: count + 1 }, (_, i) => {
|
|
389
|
-
const val = (yMax / count) * i
|
|
390
|
-
return { val, y: toY(val), label: formatMetricValue(val, unit) }
|
|
391
|
-
})
|
|
392
|
-
}, [yMax, unit])
|
|
393
|
-
|
|
394
|
-
// X axis ticks
|
|
395
|
-
const xTicks = useMemo(() => {
|
|
396
|
-
const count = 6
|
|
397
|
-
return Array.from({ length: count + 1 }, (_, i) => {
|
|
398
|
-
const ts = minTs + ((maxTs - minTs) / count) * i
|
|
399
|
-
return { ts, x: toX(ts), label: formatTimestamp(ts) }
|
|
400
|
-
})
|
|
401
|
-
}, [minTs, maxTs])
|
|
402
|
-
|
|
403
|
-
// Build paths for each series
|
|
404
|
-
const paths = useMemo(() => {
|
|
405
|
-
return chartData.series.map((s, seriesIdx) => {
|
|
406
|
-
if (s.dataPoints.length < 2) return null
|
|
407
|
-
const points = s.dataPoints.map(dp => ({ x: toX(dp.timestamp), y: toY(dp.value) }))
|
|
408
|
-
|
|
409
|
-
const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
|
|
410
|
-
|
|
411
|
-
// Area path: line + close to bottom
|
|
412
|
-
const areaPath = linePath +
|
|
413
|
-
` L${points[points.length - 1].x},${marginTop + plotHeight}` +
|
|
414
|
-
` L${points[0].x},${marginTop + plotHeight} Z`
|
|
415
|
-
|
|
416
|
-
return {
|
|
417
|
-
linePath,
|
|
418
|
-
areaPath,
|
|
419
|
-
strokeColor: multiSeries ? seriesColor(seriesIdx, color) : color,
|
|
420
|
-
areaFillColor: multiSeries ? seriesFill(seriesIdx, fillColor) : fillColor,
|
|
421
|
-
key: seriesIdx,
|
|
422
|
-
}
|
|
423
|
-
}).filter(Boolean)
|
|
424
|
-
}, [chartData])
|
|
425
|
-
|
|
426
|
-
// Hover data: find nearest data point per series at the hovered X position
|
|
427
|
-
const hoverData = useMemo(() => {
|
|
428
|
-
if (hoverX === null) return null
|
|
429
|
-
const clampedX = Math.max(marginLeft, Math.min(marginLeft + plotWidth, hoverX))
|
|
430
|
-
const frac = (clampedX - marginLeft) / plotWidth
|
|
431
|
-
const ts = minTs + frac * (maxTs - minTs)
|
|
432
|
-
|
|
433
|
-
const validSeries = chartData.series
|
|
434
|
-
.map((s, i) => ({ s, i }))
|
|
435
|
-
.filter(({ s }) => s.dataPoints.length >= 2)
|
|
436
|
-
|
|
437
|
-
const fullLabels = validSeries.map(({ s, i }) =>
|
|
438
|
-
s.labels.pod || s.labels.instance || s.labels.node || `series-${i}`
|
|
439
|
-
)
|
|
440
|
-
const shortLabels = computeShortLabels(fullLabels)
|
|
441
|
-
|
|
442
|
-
const points = validSeries.map(({ s, i }, vi) => {
|
|
443
|
-
let closest = s.dataPoints[0]
|
|
444
|
-
let closestDist = Infinity
|
|
445
|
-
for (const dp of s.dataPoints) {
|
|
446
|
-
const dist = Math.abs(dp.timestamp - ts)
|
|
447
|
-
if (dist < closestDist) {
|
|
448
|
-
closestDist = dist
|
|
449
|
-
closest = dp
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
return {
|
|
453
|
-
label: shortLabels[vi],
|
|
454
|
-
fullLabel: fullLabels[vi],
|
|
455
|
-
value: closest.value,
|
|
456
|
-
y: toY(closest.value),
|
|
457
|
-
color: multiSeries ? seriesColor(i, color) : color,
|
|
458
|
-
}
|
|
459
|
-
})
|
|
460
|
-
|
|
461
|
-
return { ts, x: clampedX, points }
|
|
462
|
-
}, [hoverX, chartData])
|
|
463
|
-
|
|
464
|
-
// Convert client mouse coordinates to SVG viewBox coordinates
|
|
465
|
-
const handleMouseMove = useCallback((e: React.MouseEvent<SVGRectElement>) => {
|
|
466
|
-
const svg = svgRef.current
|
|
467
|
-
if (!svg) return
|
|
468
|
-
const ctm = svg.getScreenCTM()
|
|
469
|
-
if (!ctm) return
|
|
470
|
-
setHoverX((e.clientX - ctm.e) / ctm.a)
|
|
471
|
-
}, [])
|
|
472
|
-
|
|
473
|
-
return (
|
|
474
|
-
<div className="relative">
|
|
475
|
-
<svg
|
|
476
|
-
ref={svgRef}
|
|
477
|
-
viewBox={`0 0 ${width} ${height}`}
|
|
478
|
-
className="w-full h-full"
|
|
479
|
-
preserveAspectRatio="xMidYMid meet"
|
|
480
|
-
>
|
|
481
|
-
{/* Grid lines */}
|
|
482
|
-
{yTicks.map((tick, i) => (
|
|
483
|
-
<line
|
|
484
|
-
key={`grid-${i}`}
|
|
485
|
-
x1={marginLeft}
|
|
486
|
-
y1={tick.y}
|
|
487
|
-
x2={width - marginRight}
|
|
488
|
-
y2={tick.y}
|
|
489
|
-
stroke="currentColor"
|
|
490
|
-
className="text-theme-border/30"
|
|
491
|
-
strokeWidth="1"
|
|
492
|
-
strokeDasharray={i === 0 ? undefined : '4 4'}
|
|
493
|
-
/>
|
|
494
|
-
))}
|
|
495
|
-
|
|
496
|
-
{/* Y axis labels */}
|
|
497
|
-
{yTicks.map((tick, i) => (
|
|
498
|
-
<text
|
|
499
|
-
key={`ylabel-${i}`}
|
|
500
|
-
x={marginLeft - 8}
|
|
501
|
-
y={tick.y + 4}
|
|
502
|
-
textAnchor="end"
|
|
503
|
-
className="fill-theme-text-secondary"
|
|
504
|
-
fontSize="11"
|
|
505
|
-
fontFamily="ui-monospace, monospace"
|
|
506
|
-
>
|
|
507
|
-
{tick.label}
|
|
508
|
-
</text>
|
|
509
|
-
))}
|
|
510
|
-
|
|
511
|
-
{/* X axis labels */}
|
|
512
|
-
{xTicks.map((tick, i) => (
|
|
513
|
-
<text
|
|
514
|
-
key={`xlabel-${i}`}
|
|
515
|
-
x={tick.x}
|
|
516
|
-
y={height - 4}
|
|
517
|
-
textAnchor="middle"
|
|
518
|
-
className="fill-theme-text-secondary"
|
|
519
|
-
fontSize="11"
|
|
520
|
-
fontFamily="ui-monospace, monospace"
|
|
521
|
-
>
|
|
522
|
-
{tick.label}
|
|
523
|
-
</text>
|
|
524
|
-
))}
|
|
525
|
-
|
|
526
|
-
{/* Area fills */}
|
|
527
|
-
{paths.map(p => p && (
|
|
528
|
-
<path
|
|
529
|
-
key={`area-${p.key}`}
|
|
530
|
-
d={p.areaPath}
|
|
531
|
-
fill={p.areaFillColor}
|
|
532
|
-
/>
|
|
533
|
-
))}
|
|
534
|
-
|
|
535
|
-
{/* Lines */}
|
|
536
|
-
{paths.map(p => p && (
|
|
537
|
-
<path
|
|
538
|
-
key={`line-${p.key}`}
|
|
539
|
-
d={p.linePath}
|
|
540
|
-
fill="none"
|
|
541
|
-
stroke={p.strokeColor}
|
|
542
|
-
strokeWidth="2"
|
|
543
|
-
strokeLinejoin="round"
|
|
544
|
-
/>
|
|
545
|
-
))}
|
|
546
|
-
|
|
547
|
-
{/* Hover crosshair + dots */}
|
|
548
|
-
{hoverData && (
|
|
549
|
-
<>
|
|
550
|
-
<line
|
|
551
|
-
x1={hoverData.x} y1={marginTop}
|
|
552
|
-
x2={hoverData.x} y2={marginTop + plotHeight}
|
|
553
|
-
stroke="currentColor"
|
|
554
|
-
className="text-theme-text-tertiary"
|
|
555
|
-
strokeWidth="1"
|
|
556
|
-
strokeDasharray="4 4"
|
|
557
|
-
/>
|
|
558
|
-
{hoverData.points.map((p, i) => (
|
|
559
|
-
<circle
|
|
560
|
-
key={i}
|
|
561
|
-
cx={hoverData.x} cy={p.y}
|
|
562
|
-
r="4"
|
|
563
|
-
fill={p.color}
|
|
564
|
-
stroke="var(--color-theme-surface, #1a1a2e)"
|
|
565
|
-
strokeWidth="2"
|
|
566
|
-
/>
|
|
567
|
-
))}
|
|
568
|
-
</>
|
|
569
|
-
)}
|
|
570
|
-
|
|
571
|
-
{/* Invisible overlay for mouse events — must be last for event capture */}
|
|
572
|
-
<rect
|
|
573
|
-
x={marginLeft} y={marginTop}
|
|
574
|
-
width={plotWidth} height={plotHeight}
|
|
575
|
-
fill="transparent"
|
|
576
|
-
style={{ cursor: 'crosshair' }}
|
|
577
|
-
onMouseMove={handleMouseMove}
|
|
578
|
-
onMouseLeave={() => setHoverX(null)}
|
|
579
|
-
/>
|
|
580
|
-
</svg>
|
|
581
|
-
|
|
582
|
-
{/* Tooltip positioned outside SVG for proper HTML rendering */}
|
|
583
|
-
{hoverData && (
|
|
584
|
-
<div
|
|
585
|
-
className="absolute top-0 pointer-events-none z-10"
|
|
586
|
-
style={{
|
|
587
|
-
left: `${(hoverData.x / width) * 100}%`,
|
|
588
|
-
transform: hoverData.x > width * 0.65 ? 'translateX(calc(-100% - 12px))' : 'translateX(12px)',
|
|
589
|
-
}}
|
|
590
|
-
>
|
|
591
|
-
<div className="bg-theme-surface border border-theme-border rounded-lg shadow-lg px-3 py-2 text-xs whitespace-nowrap">
|
|
592
|
-
<div className="text-theme-text-tertiary mb-1.5 font-mono">
|
|
593
|
-
{new Date(hoverData.ts * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
|
594
|
-
</div>
|
|
595
|
-
{hoverData.points.map((p, i) => (
|
|
596
|
-
<div key={i} className="flex items-center gap-2 py-0.5">
|
|
597
|
-
<div
|
|
598
|
-
className="w-2 h-2 rounded-full shrink-0"
|
|
599
|
-
style={{ backgroundColor: p.color }}
|
|
600
|
-
/>
|
|
601
|
-
<span className="text-theme-text-secondary font-mono" title={p.fullLabel}>
|
|
602
|
-
{p.label}
|
|
603
|
-
</span>
|
|
604
|
-
<span className="text-theme-text-primary font-semibold ml-auto pl-3 tabular-nums">
|
|
605
|
-
{formatMetricValue(p.value, unit)}
|
|
606
|
-
</span>
|
|
607
|
-
</div>
|
|
608
|
-
))}
|
|
609
|
-
</div>
|
|
610
|
-
</div>
|
|
611
|
-
)}
|
|
612
|
-
</div>
|
|
613
|
-
)
|
|
230
|
+
// Memory: try two-character then one-character suffixes (Mi before M).
|
|
231
|
+
for (const suffix of ['Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei']) {
|
|
232
|
+
if (s.endsWith(suffix)) return scaleOrNull(s, MEMORY_SUFFIXES[suffix])
|
|
233
|
+
}
|
|
234
|
+
for (const suffix of ['K', 'M', 'G', 'T', 'P', 'E']) {
|
|
235
|
+
if (s.endsWith(suffix)) return scaleOrNull(s, MEMORY_SUFFIXES[suffix])
|
|
236
|
+
}
|
|
237
|
+
const v = parseFloat(s)
|
|
238
|
+
return isNaN(v) ? null : v
|
|
614
239
|
}
|
|
615
240
|
|
|
616
|
-
function
|
|
617
|
-
const
|
|
618
|
-
return (
|
|
619
|
-
<div className="flex flex-wrap gap-x-4 gap-y-1 px-1">
|
|
620
|
-
{series.slice(0, 10).map((_, i) => {
|
|
621
|
-
const shortName = labels[i].length > 40 ? '...' + labels[i].slice(-37) : labels[i]
|
|
622
|
-
return (
|
|
623
|
-
<div key={i} className="flex items-center gap-1.5 text-xs text-theme-text-tertiary">
|
|
624
|
-
<div
|
|
625
|
-
className="w-2.5 h-2.5 rounded-full shrink-0"
|
|
626
|
-
style={{ backgroundColor: seriesColor(i, color) }}
|
|
627
|
-
/>
|
|
628
|
-
<span className="truncate" title={labels[i]}>{shortName}</span>
|
|
629
|
-
</div>
|
|
630
|
-
)
|
|
631
|
-
})}
|
|
632
|
-
{series.length > 10 && (
|
|
633
|
-
<span className="text-xs text-theme-text-quaternary">+{series.length - 10} more</span>
|
|
634
|
-
)}
|
|
635
|
-
</div>
|
|
636
|
-
)
|
|
241
|
+
function scaleOrNull(s: string, scale: number): number | null {
|
|
242
|
+
const v = parseFloat(s)
|
|
243
|
+
return isNaN(v) ? null : v * scale
|
|
637
244
|
}
|
|
638
245
|
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
function formatMetricValue(value: number, unit: string): string {
|
|
644
|
-
if (value === 0) return '0'
|
|
645
|
-
|
|
646
|
-
switch (unit) {
|
|
647
|
-
case 'cores': {
|
|
648
|
-
if (value < 0.0001) return '< 0.1m'
|
|
649
|
-
if (value < 0.001) return `${(value * 1000).toFixed(1)}m`
|
|
650
|
-
if (value < 1) return `${(value * 1000).toFixed(0)}m`
|
|
651
|
-
return `${value.toFixed(2)}`
|
|
652
|
-
}
|
|
653
|
-
case 'bytes': {
|
|
654
|
-
if (value < 1) return '< 1 B'
|
|
655
|
-
if (value < 1024) return `${value.toFixed(0)} B`
|
|
656
|
-
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`
|
|
657
|
-
if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)} MiB`
|
|
658
|
-
return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GiB`
|
|
659
|
-
}
|
|
660
|
-
case 'bytes/s': {
|
|
661
|
-
if (value < 1) return '< 1 B/s'
|
|
662
|
-
if (value < 1024) return `${value.toFixed(0)} B/s`
|
|
663
|
-
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB/s`
|
|
664
|
-
if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)} MiB/s`
|
|
665
|
-
return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GiB/s`
|
|
666
|
-
}
|
|
667
|
-
default:
|
|
668
|
-
if (value < 0.01) return value.toExponential(1)
|
|
669
|
-
if (value < 1) return value.toFixed(3)
|
|
670
|
-
if (value < 100) return value.toFixed(2)
|
|
671
|
-
if (value < 10000) return value.toFixed(0)
|
|
672
|
-
return `${(value / 1000).toFixed(1)}k`
|
|
246
|
+
function formatRequestLimitLabel(value: number, category: 'cpu' | 'memory'): string {
|
|
247
|
+
if (category === 'cpu') {
|
|
248
|
+
if (value < 1) return `${Math.round(value * 1000)}m`
|
|
249
|
+
return value.toFixed(2).replace(/\.?0+$/, '')
|
|
673
250
|
}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
251
|
+
// Memory — match formatMetricValue's tier breakpoints.
|
|
252
|
+
if (value < 1024 * 1024) return `${(value / 1024).toFixed(0)}KiB`
|
|
253
|
+
if (value < 1024 ** 3) return `${(value / (1024 ** 2)).toFixed(0)}MiB`
|
|
254
|
+
return `${(value / (1024 ** 3)).toFixed(1)}GiB`
|
|
679
255
|
}
|
|
680
256
|
|
|
681
257
|
// ============================================================================
|