@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,372 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { AlertCircle, DollarSign, HelpCircle, Loader2, TrendingUp } from 'lucide-react'
|
|
3
|
+
import {
|
|
4
|
+
useOpenCostWorkload,
|
|
5
|
+
useOpenCostWorkloadTrend,
|
|
6
|
+
COST_DISCOVERY_GRACE_MS,
|
|
7
|
+
type CostTimeRange,
|
|
8
|
+
type CostUnavailableReason,
|
|
9
|
+
type OpenCostWorkloadDetailResponse,
|
|
10
|
+
type OpenCostWorkloadTrendResponse,
|
|
11
|
+
} from '../../api/client'
|
|
12
|
+
import { Tooltip } from '../ui/Tooltip'
|
|
13
|
+
import { CostTimeRangeSelector, StackedAreaChart } from './CostTrendChart'
|
|
14
|
+
import {
|
|
15
|
+
formatCostPerHour,
|
|
16
|
+
formatHistoricalSpend,
|
|
17
|
+
formatProjectedDailyRate,
|
|
18
|
+
formatProjectedMonthlyCost,
|
|
19
|
+
} from './format'
|
|
20
|
+
import { CurrentAllocationUse } from './CurrentAllocationUse'
|
|
21
|
+
import { costUnavailableReasonFromError } from './errors'
|
|
22
|
+
|
|
23
|
+
type WorkloadCostState =
|
|
24
|
+
| 'loading'
|
|
25
|
+
| 'data'
|
|
26
|
+
| 'partial_missing_history'
|
|
27
|
+
| 'partial_missing_current'
|
|
28
|
+
| 'zero'
|
|
29
|
+
| 'load_error'
|
|
30
|
+
| CostUnavailableReason
|
|
31
|
+
|
|
32
|
+
interface WorkloadCostQueryStatus {
|
|
33
|
+
currentLoading?: boolean
|
|
34
|
+
trendLoading?: boolean
|
|
35
|
+
currentError?: unknown
|
|
36
|
+
trendError?: unknown
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface WorkloadCostTabProps {
|
|
40
|
+
kind: string
|
|
41
|
+
namespace: string
|
|
42
|
+
name: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps) {
|
|
46
|
+
const [range, setRange] = useState<CostTimeRange>('24h')
|
|
47
|
+
const [noPrometheusSince, setNoPrometheusSince] = useState<number | null>(null)
|
|
48
|
+
const currentQuery = useOpenCostWorkload(kind, namespace, name)
|
|
49
|
+
const trendQuery = useOpenCostWorkloadTrend(kind, namespace, name, range)
|
|
50
|
+
const trendMatchesRange = trendQuery.data?.range === range
|
|
51
|
+
const trendData = trendMatchesRange ? trendQuery.data : undefined
|
|
52
|
+
const trendLoading =
|
|
53
|
+
trendQuery.isLoading ||
|
|
54
|
+
(trendQuery.isFetching && Boolean(trendQuery.data) && !trendMatchesRange)
|
|
55
|
+
|
|
56
|
+
const state = getWorkloadCostState(currentQuery.data, trendData, {
|
|
57
|
+
currentLoading: currentQuery.isLoading,
|
|
58
|
+
trendLoading,
|
|
59
|
+
currentError: currentQuery.error,
|
|
60
|
+
trendError: trendQuery.error,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (state === 'no_prometheus') {
|
|
65
|
+
setNoPrometheusSince((prev) => prev ?? Date.now())
|
|
66
|
+
} else {
|
|
67
|
+
setNoPrometheusSince(null)
|
|
68
|
+
}
|
|
69
|
+
}, [state])
|
|
70
|
+
|
|
71
|
+
if (state === 'loading') {
|
|
72
|
+
return (
|
|
73
|
+
<div className="flex h-full min-h-[320px] items-center justify-center text-theme-text-tertiary">
|
|
74
|
+
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
|
75
|
+
Loading workload cost…
|
|
76
|
+
</div>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (
|
|
81
|
+
state === 'no_prometheus' ||
|
|
82
|
+
state === 'no_metrics' ||
|
|
83
|
+
state === 'query_error' ||
|
|
84
|
+
state === 'access_denied' ||
|
|
85
|
+
state === 'not_found' ||
|
|
86
|
+
state === 'load_error'
|
|
87
|
+
) {
|
|
88
|
+
const discoveryAgeMs = noPrometheusSince == null ? 0 : Date.now() - noPrometheusSince
|
|
89
|
+
if (state === 'no_prometheus' && discoveryAgeMs < COST_DISCOVERY_GRACE_MS) {
|
|
90
|
+
return (
|
|
91
|
+
<WorkloadCostDiscovering
|
|
92
|
+
isFetching={currentQuery.isFetching || trendQuery.isFetching}
|
|
93
|
+
onRetry={() => {
|
|
94
|
+
setNoPrometheusSince(Date.now())
|
|
95
|
+
currentQuery.refetch()
|
|
96
|
+
trendQuery.refetch()
|
|
97
|
+
}}
|
|
98
|
+
/>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
return <WorkloadCostUnavailable state={state} />
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const current = currentQuery.data?.current
|
|
105
|
+
const trend = trendData
|
|
106
|
+
const points = trend?.available ? (trend.dataPoints ?? []) : []
|
|
107
|
+
const hasTrend = points.length >= 2 && points.some((p) => p.value > 0)
|
|
108
|
+
const hasCurrent = Boolean(current)
|
|
109
|
+
const hourly = current?.hourlyCost ?? 0
|
|
110
|
+
const windowTotal = trend?.available ? (trend.windowTotalCost ?? 0) : 0
|
|
111
|
+
const cpuCost = current?.cpuCost ?? 0
|
|
112
|
+
const memoryCost = current?.memoryCost ?? 0
|
|
113
|
+
const windowSpendValue = formatHistoricalSpend(
|
|
114
|
+
points.length,
|
|
115
|
+
windowTotal,
|
|
116
|
+
trendLoading || state === 'partial_missing_history',
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<div className="mx-auto w-full max-w-[1600px] space-y-4">
|
|
121
|
+
<section className="rounded-lg border border-theme-border bg-theme-surface/50">
|
|
122
|
+
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-theme-border px-4 py-3">
|
|
123
|
+
<div className="flex items-center gap-2">
|
|
124
|
+
<TrendingUp className="h-4 w-4 text-theme-text-tertiary" />
|
|
125
|
+
<div>
|
|
126
|
+
<div className="flex items-center gap-1.5">
|
|
127
|
+
<div className="text-sm font-semibold text-theme-text-primary">
|
|
128
|
+
Historical compute cost
|
|
129
|
+
</div>
|
|
130
|
+
<MetricInfoTooltip content="Dollars are based on OpenCost CPU and memory allocation over time, not raw utilization. OpenCost allocation uses the greater of requested or observed resources." />
|
|
131
|
+
</div>
|
|
132
|
+
<div className="text-xs text-theme-text-tertiary">
|
|
133
|
+
OpenCost CPU and memory allocation rate ($/hr) attributed by workload ownership
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
<CostTimeRangeSelector value={range} onChange={setRange} />
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
<div className="grid gap-4 p-4 lg:grid-cols-[220px_minmax(0,1fr)]">
|
|
141
|
+
<div className="space-y-4">
|
|
142
|
+
<MetricBlock
|
|
143
|
+
label={`Spend over ${range}`}
|
|
144
|
+
value={windowSpendValue}
|
|
145
|
+
subvalue={
|
|
146
|
+
state === 'partial_missing_history' ? 'Historical data unavailable' : undefined
|
|
147
|
+
}
|
|
148
|
+
/>
|
|
149
|
+
<MetricBlock
|
|
150
|
+
label="Projected monthly"
|
|
151
|
+
value={hasCurrent ? formatProjectedMonthlyCost(hourly) : '—'}
|
|
152
|
+
subvalue={
|
|
153
|
+
hasCurrent
|
|
154
|
+
? `${formatCostPerHour(hourly)} current rate`
|
|
155
|
+
: 'Current allocation unavailable'
|
|
156
|
+
}
|
|
157
|
+
/>
|
|
158
|
+
</div>
|
|
159
|
+
<div className="min-w-0">
|
|
160
|
+
{trendLoading ? (
|
|
161
|
+
<div className="flex h-[240px] items-center justify-center rounded-md border border-dashed border-theme-border bg-theme-base/60 text-sm text-theme-text-tertiary">
|
|
162
|
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
163
|
+
Loading historical cost…
|
|
164
|
+
</div>
|
|
165
|
+
) : hasTrend ? (
|
|
166
|
+
<StackedAreaChart series={[{ namespace: 'Allocation rate', dataPoints: points }]} />
|
|
167
|
+
) : (
|
|
168
|
+
<div className="flex h-[240px] items-center justify-center rounded-md border border-dashed border-theme-border bg-theme-base/60 text-sm text-theme-text-tertiary">
|
|
169
|
+
No historical workload owner cost points for this range.
|
|
170
|
+
</div>
|
|
171
|
+
)}
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
</section>
|
|
175
|
+
|
|
176
|
+
{state === 'partial_missing_history' && (
|
|
177
|
+
<div className="flex items-start gap-2 rounded-lg border border-theme-border bg-theme-base px-3 py-2 text-sm text-theme-text-secondary">
|
|
178
|
+
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-theme-text-tertiary" />
|
|
179
|
+
<span>
|
|
180
|
+
Current cost is available, but historical workload owner metrics are not available for
|
|
181
|
+
this range.
|
|
182
|
+
</span>
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
185
|
+
{state === 'partial_missing_current' && (
|
|
186
|
+
<div className="flex items-start gap-2 rounded-lg border border-theme-border bg-theme-base px-3 py-2 text-sm text-theme-text-secondary">
|
|
187
|
+
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-theme-text-tertiary" />
|
|
188
|
+
<span>
|
|
189
|
+
Historical cost is available, but current workload allocation metrics are not available.
|
|
190
|
+
</span>
|
|
191
|
+
</div>
|
|
192
|
+
)}
|
|
193
|
+
|
|
194
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
195
|
+
<MetricTile label="Replicas" value={hasCurrent ? String(current?.replicas ?? 0) : '—'} />
|
|
196
|
+
<MetricTile
|
|
197
|
+
label="Projected daily"
|
|
198
|
+
value={hasCurrent ? formatProjectedDailyRate(hourly) : '—'}
|
|
199
|
+
subvalue={
|
|
200
|
+
hasCurrent
|
|
201
|
+
? `${formatCostPerHour(hourly)} current hourly rate`
|
|
202
|
+
: 'Current allocation unavailable'
|
|
203
|
+
}
|
|
204
|
+
/>
|
|
205
|
+
</div>
|
|
206
|
+
|
|
207
|
+
<CurrentAllocationUse
|
|
208
|
+
dataAvailable={hasCurrent}
|
|
209
|
+
cpuCost={cpuCost}
|
|
210
|
+
memoryCost={memoryCost}
|
|
211
|
+
hourlyCost={hourly}
|
|
212
|
+
cpuAllocationUse={current?.cpuAllocationUse ?? 0}
|
|
213
|
+
memoryAllocationUse={current?.memoryAllocationUse ?? 0}
|
|
214
|
+
cpuUsageAvailable={current?.cpuUsageAvailable ?? false}
|
|
215
|
+
memoryUsageAvailable={current?.memoryUsageAvailable ?? false}
|
|
216
|
+
/>
|
|
217
|
+
|
|
218
|
+
<div className="text-xs text-theme-text-tertiary">
|
|
219
|
+
Powered by OpenCost via Prometheus. Historical spend uses the selected range; projected
|
|
220
|
+
monthly values multiply the current hourly allocation. Storage/PVC attribution remains at
|
|
221
|
+
namespace and cluster level.
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function getWorkloadCostState(
|
|
228
|
+
current: OpenCostWorkloadDetailResponse | undefined,
|
|
229
|
+
trend: OpenCostWorkloadTrendResponse | undefined,
|
|
230
|
+
status: boolean | WorkloadCostQueryStatus,
|
|
231
|
+
): WorkloadCostState {
|
|
232
|
+
const queryStatus: WorkloadCostQueryStatus =
|
|
233
|
+
typeof status === 'boolean' ? { currentLoading: status, trendLoading: status } : status
|
|
234
|
+
const loading = Boolean(queryStatus.currentLoading || queryStatus.trendLoading)
|
|
235
|
+
const queryError = Boolean(queryStatus.currentError || queryStatus.trendError)
|
|
236
|
+
|
|
237
|
+
const currentRow = current?.available ? current.current : undefined
|
|
238
|
+
const trendHasData =
|
|
239
|
+
trend?.available === true && (trend.dataPoints ?? []).some((p) => p.value > 0)
|
|
240
|
+
if (currentRow) {
|
|
241
|
+
if (queryStatus.trendLoading && !trend) return 'data'
|
|
242
|
+
if (queryStatus.trendError || (trend?.available === false && trend.reason !== 'no_metrics'))
|
|
243
|
+
return 'partial_missing_history'
|
|
244
|
+
if (currentRow.hourlyCost === 0 && currentRow.replicas === 0 && !trendHasData) return 'zero'
|
|
245
|
+
if (!trend?.available) return 'partial_missing_history'
|
|
246
|
+
return 'data'
|
|
247
|
+
}
|
|
248
|
+
if (trendHasData) return 'partial_missing_current'
|
|
249
|
+
const reason =
|
|
250
|
+
current?.reason ??
|
|
251
|
+
trend?.reason ??
|
|
252
|
+
costUnavailableReasonFromError(queryStatus.currentError) ??
|
|
253
|
+
costUnavailableReasonFromError(queryStatus.trendError)
|
|
254
|
+
if (
|
|
255
|
+
reason === 'no_prometheus' ||
|
|
256
|
+
reason === 'query_error' ||
|
|
257
|
+
reason === 'access_denied' ||
|
|
258
|
+
reason === 'not_found'
|
|
259
|
+
)
|
|
260
|
+
return reason
|
|
261
|
+
if (queryError) return 'load_error'
|
|
262
|
+
if (loading) return 'loading'
|
|
263
|
+
|
|
264
|
+
return 'no_metrics'
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function WorkloadCostDiscovering({
|
|
268
|
+
isFetching,
|
|
269
|
+
onRetry,
|
|
270
|
+
}: {
|
|
271
|
+
isFetching: boolean
|
|
272
|
+
onRetry: () => void
|
|
273
|
+
}) {
|
|
274
|
+
return (
|
|
275
|
+
<div className="flex h-full min-h-[320px] items-center justify-center">
|
|
276
|
+
<div className="flex max-w-md flex-col items-center gap-3 text-center text-theme-text-secondary">
|
|
277
|
+
<Loader2 className="h-8 w-8 animate-spin text-theme-text-tertiary/60" />
|
|
278
|
+
<div>
|
|
279
|
+
<p className="text-sm font-medium text-theme-text-primary">
|
|
280
|
+
Looking for Prometheus cost data…
|
|
281
|
+
</p>
|
|
282
|
+
<p className="mt-1 text-xs text-theme-text-tertiary">
|
|
283
|
+
First discovery can take a few seconds while Radar checks cluster services and opens a
|
|
284
|
+
local port-forward.
|
|
285
|
+
</p>
|
|
286
|
+
</div>
|
|
287
|
+
<button
|
|
288
|
+
onClick={onRetry}
|
|
289
|
+
disabled={isFetching}
|
|
290
|
+
className="text-xs text-accent-text transition-colors hover:text-theme-text-primary disabled:cursor-not-allowed disabled:text-theme-text-disabled"
|
|
291
|
+
>
|
|
292
|
+
{isFetching ? 'Checking…' : 'Check again'}
|
|
293
|
+
</button>
|
|
294
|
+
</div>
|
|
295
|
+
</div>
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function WorkloadCostUnavailable({ state }: { state: CostUnavailableReason | 'load_error' }) {
|
|
300
|
+
const message =
|
|
301
|
+
state === 'no_prometheus'
|
|
302
|
+
? 'Prometheus not found. OpenCost workload cost requires Prometheus or VictoriaMetrics.'
|
|
303
|
+
: state === 'query_error'
|
|
304
|
+
? 'Cost data is temporarily unavailable. Prometheus was found, but workload cost queries failed.'
|
|
305
|
+
: state === 'access_denied'
|
|
306
|
+
? 'You do not have access to view cost for this workload.'
|
|
307
|
+
: state === 'not_found'
|
|
308
|
+
? 'This workload no longer exists.'
|
|
309
|
+
: state === 'load_error'
|
|
310
|
+
? 'Could not load workload cost data. Check access to this workload and try again.'
|
|
311
|
+
: 'OpenCost workload metrics were not found for this workload.'
|
|
312
|
+
|
|
313
|
+
return (
|
|
314
|
+
<div className="flex h-full min-h-[320px] items-center justify-center">
|
|
315
|
+
<div className="flex max-w-md flex-col items-center gap-3 text-center text-theme-text-secondary">
|
|
316
|
+
<DollarSign className="h-8 w-8 text-theme-text-tertiary/50" />
|
|
317
|
+
<div className="text-sm">{message}</div>
|
|
318
|
+
</div>
|
|
319
|
+
</div>
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function MetricBlock({
|
|
324
|
+
label,
|
|
325
|
+
value,
|
|
326
|
+
subvalue,
|
|
327
|
+
}: {
|
|
328
|
+
label: string
|
|
329
|
+
value: string
|
|
330
|
+
subvalue?: string
|
|
331
|
+
}) {
|
|
332
|
+
return (
|
|
333
|
+
<div>
|
|
334
|
+
<div className="text-xs font-medium uppercase text-theme-text-tertiary">{label}</div>
|
|
335
|
+
<div className="mt-1 text-2xl font-semibold text-theme-text-primary tabular-nums">
|
|
336
|
+
{value}
|
|
337
|
+
</div>
|
|
338
|
+
{subvalue && <div className="mt-1 text-xs text-theme-text-tertiary">{subvalue}</div>}
|
|
339
|
+
</div>
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function MetricTile({
|
|
344
|
+
label,
|
|
345
|
+
value,
|
|
346
|
+
subvalue,
|
|
347
|
+
tooltip,
|
|
348
|
+
}: {
|
|
349
|
+
label: string
|
|
350
|
+
value: string
|
|
351
|
+
subvalue?: string
|
|
352
|
+
tooltip?: string
|
|
353
|
+
}) {
|
|
354
|
+
return (
|
|
355
|
+
<div className="rounded-lg border border-theme-border bg-theme-surface/50 p-4">
|
|
356
|
+
<div className="flex items-center gap-1.5">
|
|
357
|
+
<div className="text-xs font-medium uppercase text-theme-text-tertiary">{label}</div>
|
|
358
|
+
{tooltip && <MetricInfoTooltip content={tooltip} />}
|
|
359
|
+
</div>
|
|
360
|
+
<div className="mt-1 text-lg font-semibold text-theme-text-primary tabular-nums">{value}</div>
|
|
361
|
+
{subvalue && <div className="mt-1 text-xs text-theme-text-tertiary">{subvalue}</div>}
|
|
362
|
+
</div>
|
|
363
|
+
)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function MetricInfoTooltip({ content }: { content: string }) {
|
|
367
|
+
return (
|
|
368
|
+
<Tooltip content={content} className="max-w-[280px] whitespace-normal text-left" delay={150}>
|
|
369
|
+
<HelpCircle className="h-3.5 w-3.5 cursor-help text-theme-text-tertiary transition-colors hover:text-theme-text-secondary" />
|
|
370
|
+
</Tooltip>
|
|
371
|
+
)
|
|
372
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { clusterCloudConsoleLink, nodeCloudConsoleLink } from './cloud-console'
|
|
3
|
+
|
|
4
|
+
describe('cloud console links', () => {
|
|
5
|
+
it('builds Google Compute Engine node links from GCE provider IDs', () => {
|
|
6
|
+
expect(nodeCloudConsoleLink('gce://proj-1/us-east1-b/gke-node-1')).toEqual({
|
|
7
|
+
label: 'Open in Google Cloud Console',
|
|
8
|
+
url: 'https://console.cloud.google.com/compute/instancesDetail/zones/us-east1-b/instances/gke-node-1?project=proj-1',
|
|
9
|
+
})
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('builds AWS EC2 node links from AWS provider IDs', () => {
|
|
13
|
+
expect(nodeCloudConsoleLink('aws:///us-east-1a/i-0123456789abcdef0')).toEqual({
|
|
14
|
+
label: 'Open in AWS Console',
|
|
15
|
+
url: 'https://us-east-1.console.aws.amazon.com/ec2/home?region=us-east-1#InstanceDetails:instanceId=i-0123456789abcdef0',
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('omits ambiguous node links', () => {
|
|
20
|
+
expect(nodeCloudConsoleLink('aws:///us-east-1-wl1-bos-wlz-1/i-0123456789abcdef0')).toBeNull()
|
|
21
|
+
expect(nodeCloudConsoleLink('azure:///subscriptions/123/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/vm')).toBeNull()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('builds high-confidence cluster links from canonical contexts', () => {
|
|
25
|
+
expect(clusterCloudConsoleLink('gke_proj-1_us-east1_nonprod')).toEqual({
|
|
26
|
+
label: 'Open cluster in Google Cloud Console',
|
|
27
|
+
url: 'https://console.cloud.google.com/kubernetes/clusters/details/us-east1/nonprod/details?project=proj-1',
|
|
28
|
+
})
|
|
29
|
+
expect(clusterCloudConsoleLink('arn:aws:eks:us-west-2:123456789012:cluster/prod')).toEqual({
|
|
30
|
+
label: 'Open cluster in AWS Console',
|
|
31
|
+
url: 'https://us-west-2.console.aws.amazon.com/eks/home?region=us-west-2#/clusters/prod',
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('omits renamed or non-commercial-partition cluster contexts', () => {
|
|
36
|
+
expect(clusterCloudConsoleLink('nonprod-cluster-us-east1')).toBeNull()
|
|
37
|
+
expect(clusterCloudConsoleLink('arn:aws-us-gov:eks:us-gov-west-1:123456789012:cluster/prod')).toBeNull()
|
|
38
|
+
})
|
|
39
|
+
})
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export interface CloudConsoleLink {
|
|
2
|
+
label: string
|
|
3
|
+
url: string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function nodeCloudConsoleLink(providerID?: string): CloudConsoleLink | null {
|
|
7
|
+
if (!providerID) return null
|
|
8
|
+
|
|
9
|
+
const gce = parseGCEProviderID(providerID)
|
|
10
|
+
if (gce) {
|
|
11
|
+
return {
|
|
12
|
+
label: 'Open in Google Cloud Console',
|
|
13
|
+
url: `https://console.cloud.google.com/compute/instancesDetail/zones/${encodeURIComponent(gce.zone)}/instances/${encodeURIComponent(gce.instance)}?project=${encodeURIComponent(gce.project)}`,
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const aws = parseAWSProviderID(providerID)
|
|
18
|
+
if (aws) {
|
|
19
|
+
return {
|
|
20
|
+
label: 'Open in AWS Console',
|
|
21
|
+
url: `https://${aws.region}.console.aws.amazon.com/ec2/home?region=${aws.region}#InstanceDetails:instanceId=${encodeURIComponent(aws.instanceID)}`,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function clusterCloudConsoleLink(context?: string): CloudConsoleLink | null {
|
|
29
|
+
if (!context) return null
|
|
30
|
+
|
|
31
|
+
const gke = parseGKEContext(context)
|
|
32
|
+
if (gke) {
|
|
33
|
+
return {
|
|
34
|
+
label: 'Open cluster in Google Cloud Console',
|
|
35
|
+
url: `https://console.cloud.google.com/kubernetes/clusters/details/${encodeURIComponent(gke.location)}/${encodeURIComponent(gke.cluster)}/details?project=${encodeURIComponent(gke.project)}`,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const eks = parseEKSContext(context)
|
|
40
|
+
if (eks) {
|
|
41
|
+
return {
|
|
42
|
+
label: 'Open cluster in AWS Console',
|
|
43
|
+
url: `https://${eks.region}.console.aws.amazon.com/eks/home?region=${eks.region}#/clusters/${encodeURIComponent(eks.cluster)}`,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseGCEProviderID(providerID: string): { project: string; zone: string; instance: string } | null {
|
|
51
|
+
if (!providerID.startsWith('gce://')) return null
|
|
52
|
+
const parts = providerID.replace(/^gce:\/\/\/?/, '').split('/')
|
|
53
|
+
if (parts.length !== 3 || parts.some((part) => part === '')) return null
|
|
54
|
+
return { project: parts[0], zone: parts[1], instance: parts[2] }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseAWSProviderID(providerID: string): { region: string; instanceID: string } | null {
|
|
58
|
+
if (!providerID.startsWith('aws://')) return null
|
|
59
|
+
const parts = providerID.replace(/^aws:\/\/\/?/, '').split('/')
|
|
60
|
+
if (parts.length !== 2 || parts.some((part) => part === '')) return null
|
|
61
|
+
const region = regionFromAWSZone(parts[0])
|
|
62
|
+
if (!region) return null
|
|
63
|
+
return { region, instanceID: parts[1] }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseGKEContext(context: string): { project: string; location: string; cluster: string } | null {
|
|
67
|
+
const match = /^gke_([^_]+)_([^_]+)_(.+)$/.exec(context)
|
|
68
|
+
if (!match) return null
|
|
69
|
+
return { project: match[1], location: match[2], cluster: match[3] }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseEKSContext(context: string): { region: string; cluster: string } | null {
|
|
73
|
+
const match = /^arn:aws:eks:([^:]+):\d{12}:cluster\/(.+)$/.exec(context)
|
|
74
|
+
if (!match) return null
|
|
75
|
+
return { region: match[1], cluster: match[2] }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function regionFromAWSZone(zone: string): string | null {
|
|
79
|
+
const match = /^([a-z]{2}-[a-z]+-\d)[a-z]$/.exec(zone)
|
|
80
|
+
return match?.[1] ?? null
|
|
81
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { ApiError, type CostUnavailableReason } from '../../api/client'
|
|
2
|
+
|
|
3
|
+
export function costUnavailableReasonFromError(error: unknown): CostUnavailableReason | undefined {
|
|
4
|
+
if (!(error instanceof ApiError)) return undefined
|
|
5
|
+
if (error.status === 403) return 'access_denied'
|
|
6
|
+
if (error.status === 404) return 'not_found'
|
|
7
|
+
return undefined
|
|
8
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
formatCostPerHour,
|
|
4
|
+
formatHistoricalSpend,
|
|
5
|
+
formatProjectedDailyRate,
|
|
6
|
+
formatProjectedMonthlyCost,
|
|
7
|
+
formatProjectedMonthlyRate,
|
|
8
|
+
} from './format'
|
|
9
|
+
|
|
10
|
+
describe('cost formatters', () => {
|
|
11
|
+
it('formats projected run rates from hourly allocation', () => {
|
|
12
|
+
expect(formatProjectedDailyRate(0.1)).toBe('~$2.40/day')
|
|
13
|
+
expect(formatProjectedMonthlyCost(1)).toBe('~$730.00')
|
|
14
|
+
expect(formatProjectedMonthlyRate(0.1)).toBe('~$73.00/mo')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('keeps hourly rates explicit', () => {
|
|
18
|
+
expect(formatCostPerHour(0.1)).toBe('$0.100/hr')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('does not turn insufficient history into zero spend', () => {
|
|
22
|
+
expect(formatHistoricalSpend(1, 0, false)).toBe('—')
|
|
23
|
+
expect(formatHistoricalSpend(2, 0, false)).toBe('$0.00')
|
|
24
|
+
expect(formatHistoricalSpend(2, 1.25, false)).toBe('~$1.25')
|
|
25
|
+
expect(formatHistoricalSpend(2, 1.25, true)).toBe('—')
|
|
26
|
+
})
|
|
27
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export const COST_HOURS_PER_DAY = 24
|
|
2
|
+
export const COST_HOURS_PER_MONTH = 730
|
|
3
|
+
|
|
4
|
+
export function formatCostAxis(value: number): string {
|
|
5
|
+
if (!Number.isFinite(value) || value <= 0) return '$0'
|
|
6
|
+
if (value >= 1000) return `$${(value / 1000).toFixed(0)}k`
|
|
7
|
+
if (value >= 1) return `$${value.toFixed(1)}`
|
|
8
|
+
if (value >= 0.01) return `$${value.toFixed(2)}`
|
|
9
|
+
if (value >= 0.0001) return `$${value.toFixed(4)}`
|
|
10
|
+
if (value >= 0.00001) return `$${value.toFixed(5)}`
|
|
11
|
+
return '<$0.00001'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function formatCost(value: number): string {
|
|
15
|
+
if (!Number.isFinite(value) || value <= 0) return '$0.00'
|
|
16
|
+
if (value >= 1000) return `$${(value / 1000).toFixed(1)}k`
|
|
17
|
+
if (value >= 1) return `$${value.toFixed(2)}`
|
|
18
|
+
if (value >= 0.01) return `$${value.toFixed(3)}`
|
|
19
|
+
if (value >= 0.0001) return `$${value.toFixed(4)}`
|
|
20
|
+
return formatCostAxis(value)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function formatCostPerHour(value: number): string {
|
|
24
|
+
return `${formatCost(value)}/hr`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function formatHistoricalSpend(pointCount: number, windowTotalCost: number, unavailable: boolean): string {
|
|
28
|
+
if (unavailable || pointCount < 2) return '—'
|
|
29
|
+
return windowTotalCost > 0 ? `~${formatCost(windowTotalCost)}` : formatCost(0)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function formatProjectedDailyCost(hourlyCost: number): string {
|
|
33
|
+
return `~${formatCost(hourlyCost * COST_HOURS_PER_DAY)}`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function formatProjectedDailyRate(hourlyCost: number): string {
|
|
37
|
+
return `${formatProjectedDailyCost(hourlyCost)}/day`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function formatProjectedMonthlyCost(hourlyCost: number): string {
|
|
41
|
+
return `~${formatCost(hourlyCost * COST_HOURS_PER_MONTH)}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function formatProjectedMonthlyRate(hourlyCost: number): string {
|
|
45
|
+
return `${formatProjectedMonthlyCost(hourlyCost)}/mo`
|
|
46
|
+
}
|