@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,571 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
2
|
+
import { AlertCircle, DollarSign, HelpCircle, Loader2, TrendingUp } from 'lucide-react'
|
|
3
|
+
import type { AppRow, AppWorkload } from '@skyhook-io/k8s-ui'
|
|
4
|
+
import {
|
|
5
|
+
COST_DISCOVERY_GRACE_MS,
|
|
6
|
+
useOpenCostApplicationCost,
|
|
7
|
+
useOpenCostApplicationCostTrend,
|
|
8
|
+
type CostTimeRange,
|
|
9
|
+
type CostUnavailableReason,
|
|
10
|
+
type OpenCostApplicationCostResponse,
|
|
11
|
+
type OpenCostApplicationCostTrendResponse,
|
|
12
|
+
type OpenCostApplicationWorkloadCost,
|
|
13
|
+
type OpenCostTrendSeries,
|
|
14
|
+
} from '../../api/client'
|
|
15
|
+
import { Tooltip } from '../ui/Tooltip'
|
|
16
|
+
import { ChartLegend, CostTimeRangeSelector, StackedAreaChart } from './CostTrendChart'
|
|
17
|
+
import {
|
|
18
|
+
formatCostPerHour,
|
|
19
|
+
formatHistoricalSpend,
|
|
20
|
+
formatProjectedDailyRate,
|
|
21
|
+
formatProjectedMonthlyCost,
|
|
22
|
+
formatProjectedMonthlyRate,
|
|
23
|
+
} from './format'
|
|
24
|
+
import { isOpenCostWorkloadKind } from './kinds'
|
|
25
|
+
import { CurrentAllocationUse } from './CurrentAllocationUse'
|
|
26
|
+
import { costUnavailableReasonFromError } from './errors'
|
|
27
|
+
|
|
28
|
+
type ApplicationCostState =
|
|
29
|
+
| 'loading'
|
|
30
|
+
| 'data'
|
|
31
|
+
| 'partial_missing_history'
|
|
32
|
+
| 'partial_missing_current'
|
|
33
|
+
| 'zero'
|
|
34
|
+
| 'load_error'
|
|
35
|
+
| CostUnavailableReason
|
|
36
|
+
|
|
37
|
+
interface ApplicationCostQueryStatus {
|
|
38
|
+
currentLoading?: boolean
|
|
39
|
+
trendLoading?: boolean
|
|
40
|
+
currentError?: unknown
|
|
41
|
+
trendError?: unknown
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ApplicationCostTabProps {
|
|
45
|
+
app: AppRow
|
|
46
|
+
workloads: AppWorkload[]
|
|
47
|
+
onSelectWorkloadCost?: (workload: AppWorkload) => void
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function ApplicationCostTab({
|
|
51
|
+
app,
|
|
52
|
+
workloads,
|
|
53
|
+
onSelectWorkloadCost,
|
|
54
|
+
}: ApplicationCostTabProps) {
|
|
55
|
+
const [range, setRange] = useState<CostTimeRange>('24h')
|
|
56
|
+
const [noPrometheusSince, setNoPrometheusSince] = useState<number | null>(null)
|
|
57
|
+
const supportedWorkloads = useMemo(() => applicationCostWorkloads(workloads), [workloads])
|
|
58
|
+
const unsupportedCount = workloads.length - supportedWorkloads.length
|
|
59
|
+
const queriesEnabled = supportedWorkloads.length > 0
|
|
60
|
+
const currentQuery = useOpenCostApplicationCost(supportedWorkloads, {
|
|
61
|
+
enabled: queriesEnabled,
|
|
62
|
+
})
|
|
63
|
+
const trendQuery = useOpenCostApplicationCostTrend(supportedWorkloads, range, {
|
|
64
|
+
enabled: queriesEnabled,
|
|
65
|
+
})
|
|
66
|
+
const trendMatchesRange = trendQuery.data?.range === range
|
|
67
|
+
const trendData = trendMatchesRange ? trendQuery.data : undefined
|
|
68
|
+
const trendLoading =
|
|
69
|
+
trendQuery.isLoading ||
|
|
70
|
+
(trendQuery.isFetching && Boolean(trendQuery.data) && !trendMatchesRange)
|
|
71
|
+
const state = getApplicationCostState(currentQuery.data, trendData, {
|
|
72
|
+
currentLoading: currentQuery.isLoading,
|
|
73
|
+
trendLoading,
|
|
74
|
+
currentError: currentQuery.error,
|
|
75
|
+
trendError: trendQuery.error,
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (state === 'no_prometheus') {
|
|
80
|
+
setNoPrometheusSince((prev) => prev ?? Date.now())
|
|
81
|
+
} else {
|
|
82
|
+
setNoPrometheusSince(null)
|
|
83
|
+
}
|
|
84
|
+
}, [state])
|
|
85
|
+
|
|
86
|
+
const chartSeries = useMemo(() => applicationChartSeries(trendData), [trendData])
|
|
87
|
+
const workloadByKey = useMemo(() => {
|
|
88
|
+
const map = new Map<string, AppWorkload>()
|
|
89
|
+
for (const workload of workloads) map.set(applicationCostKey(workload), workload)
|
|
90
|
+
return map
|
|
91
|
+
}, [workloads])
|
|
92
|
+
|
|
93
|
+
if (supportedWorkloads.length === 0) {
|
|
94
|
+
return (
|
|
95
|
+
<ApplicationCostUnavailable
|
|
96
|
+
state="no_metrics"
|
|
97
|
+
message="No steady-state workloads in this app are currently cost-attributed."
|
|
98
|
+
/>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (state === 'loading') {
|
|
103
|
+
return (
|
|
104
|
+
<div className="flex h-full min-h-[320px] items-center justify-center text-theme-text-tertiary">
|
|
105
|
+
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
|
106
|
+
Loading application cost…
|
|
107
|
+
</div>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (
|
|
112
|
+
state === 'no_prometheus' ||
|
|
113
|
+
state === 'no_metrics' ||
|
|
114
|
+
state === 'query_error' ||
|
|
115
|
+
state === 'access_denied' ||
|
|
116
|
+
state === 'not_found' ||
|
|
117
|
+
state === 'load_error'
|
|
118
|
+
) {
|
|
119
|
+
const discoveryAgeMs = noPrometheusSince == null ? 0 : Date.now() - noPrometheusSince
|
|
120
|
+
if (state === 'no_prometheus' && discoveryAgeMs < COST_DISCOVERY_GRACE_MS) {
|
|
121
|
+
return (
|
|
122
|
+
<ApplicationCostDiscovering
|
|
123
|
+
isFetching={currentQuery.isFetching || trendQuery.isFetching}
|
|
124
|
+
onRetry={() => {
|
|
125
|
+
setNoPrometheusSince(Date.now())
|
|
126
|
+
currentQuery.refetch()
|
|
127
|
+
trendQuery.refetch()
|
|
128
|
+
}}
|
|
129
|
+
/>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
return <ApplicationCostUnavailable state={state} />
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const current = currentQuery.data
|
|
136
|
+
const trend = trendData
|
|
137
|
+
const hasCurrent = current?.available === true && (current.coverage?.included ?? 0) > 0
|
|
138
|
+
const totals = hasCurrent ? current?.totals : undefined
|
|
139
|
+
const coverage =
|
|
140
|
+
state === 'partial_missing_current'
|
|
141
|
+
? (trend?.coverage ?? current?.coverage)
|
|
142
|
+
: (current?.coverage ?? trend?.coverage)
|
|
143
|
+
const included = coverage?.included ?? 0
|
|
144
|
+
const total = (coverage?.total ?? supportedWorkloads.length) + unsupportedCount
|
|
145
|
+
const unavailableCount = coverage?.unavailable?.length ?? 0
|
|
146
|
+
const hourly = totals?.hourlyCost ?? 0
|
|
147
|
+
const points = trend?.available ? (trend.dataPoints ?? []) : []
|
|
148
|
+
const hasTrend = points.length >= 2 && points.some((p) => p.value > 0)
|
|
149
|
+
const rows = current?.workloads ?? []
|
|
150
|
+
const maxCost = Math.max(...rows.map((row) => row.current?.hourlyCost ?? 0), 0)
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<div className="mx-auto w-full max-w-[1600px] space-y-4">
|
|
154
|
+
{(current?.partial ||
|
|
155
|
+
trend?.partial ||
|
|
156
|
+
state === 'partial_missing_history' ||
|
|
157
|
+
state === 'partial_missing_current' ||
|
|
158
|
+
unsupportedCount > 0) && (
|
|
159
|
+
<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">
|
|
160
|
+
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-theme-text-tertiary" />
|
|
161
|
+
<span>
|
|
162
|
+
Showing tracked steady-state compute for {included} of {total} workloads.
|
|
163
|
+
{unavailableCount > 0
|
|
164
|
+
? ` ${unavailableCount} workload${unavailableCount === 1 ? '' : 's'} could not be included for this window.`
|
|
165
|
+
: ''}
|
|
166
|
+
{unsupportedCount > 0
|
|
167
|
+
? ` ${unsupportedCount} batch or unsupported workload${unsupportedCount === 1 ? ' is' : 's are'} excluded from this compute view.`
|
|
168
|
+
: ''}
|
|
169
|
+
</span>
|
|
170
|
+
</div>
|
|
171
|
+
)}
|
|
172
|
+
|
|
173
|
+
<section
|
|
174
|
+
className="rounded-lg border border-theme-border bg-theme-surface/50"
|
|
175
|
+
aria-label={`${app.name} application cost`}
|
|
176
|
+
>
|
|
177
|
+
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-theme-border px-4 py-3">
|
|
178
|
+
<div className="flex items-center gap-2">
|
|
179
|
+
<TrendingUp className="h-4 w-4 text-theme-text-tertiary" />
|
|
180
|
+
<div>
|
|
181
|
+
<div className="flex items-center gap-1.5">
|
|
182
|
+
<div className="text-sm font-semibold text-theme-text-primary">
|
|
183
|
+
Application compute cost
|
|
184
|
+
</div>
|
|
185
|
+
<CostInfoTooltip content="Dollars are based on OpenCost CPU and memory allocation over time, grouped by the workloads in this application. OpenCost allocation uses the greater of requested or observed resources." />
|
|
186
|
+
</div>
|
|
187
|
+
<div className="text-xs text-theme-text-tertiary">
|
|
188
|
+
OpenCost CPU and memory allocation rate ($/hr) for Deployment, StatefulSet, and
|
|
189
|
+
DaemonSet workloads
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
<CostTimeRangeSelector value={range} onChange={setRange} />
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
<div className="grid gap-4 p-4 lg:grid-cols-[240px_minmax(0,1fr)]">
|
|
197
|
+
<div className="space-y-4">
|
|
198
|
+
<CostMetricBlock
|
|
199
|
+
label={`Spend over ${range}`}
|
|
200
|
+
value={formatHistoricalSpend(
|
|
201
|
+
points.length,
|
|
202
|
+
trend?.windowTotalCost ?? 0,
|
|
203
|
+
trendLoading || state === 'partial_missing_history',
|
|
204
|
+
)}
|
|
205
|
+
subvalue={
|
|
206
|
+
state === 'partial_missing_history'
|
|
207
|
+
? 'Historical data incomplete'
|
|
208
|
+
: `${included} of ${total} workloads included`
|
|
209
|
+
}
|
|
210
|
+
/>
|
|
211
|
+
<CostMetricBlock
|
|
212
|
+
label="Projected monthly"
|
|
213
|
+
value={totals ? formatProjectedMonthlyCost(hourly) : '—'}
|
|
214
|
+
subvalue={
|
|
215
|
+
totals
|
|
216
|
+
? `${formatCostPerHour(hourly)} current rate`
|
|
217
|
+
: 'Current allocation unavailable'
|
|
218
|
+
}
|
|
219
|
+
/>
|
|
220
|
+
</div>
|
|
221
|
+
<div className="min-w-0">
|
|
222
|
+
{trendLoading ? (
|
|
223
|
+
<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">
|
|
224
|
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
225
|
+
Loading historical cost…
|
|
226
|
+
</div>
|
|
227
|
+
) : hasTrend && chartSeries.length > 0 ? (
|
|
228
|
+
<div className="min-w-0">
|
|
229
|
+
<StackedAreaChart series={chartSeries} />
|
|
230
|
+
<ChartLegend series={chartSeries} />
|
|
231
|
+
</div>
|
|
232
|
+
) : (
|
|
233
|
+
<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">
|
|
234
|
+
No historical workload owner cost points for this range.
|
|
235
|
+
</div>
|
|
236
|
+
)}
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
</section>
|
|
240
|
+
|
|
241
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
242
|
+
<CostMetricTile
|
|
243
|
+
label="Tracked workloads"
|
|
244
|
+
value={`${included}/${total}`}
|
|
245
|
+
subvalue={
|
|
246
|
+
unsupportedCount > 0
|
|
247
|
+
? `${unsupportedCount} unsupported`
|
|
248
|
+
: 'All supported workloads included'
|
|
249
|
+
}
|
|
250
|
+
/>
|
|
251
|
+
<CostMetricTile
|
|
252
|
+
label="Projected daily"
|
|
253
|
+
value={totals ? formatProjectedDailyRate(hourly) : '—'}
|
|
254
|
+
subvalue={
|
|
255
|
+
totals
|
|
256
|
+
? `${formatCostPerHour(hourly)} current hourly rate`
|
|
257
|
+
: 'Current allocation unavailable'
|
|
258
|
+
}
|
|
259
|
+
/>
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
<CurrentAllocationUse
|
|
263
|
+
dataAvailable={Boolean(totals)}
|
|
264
|
+
cpuCost={totals?.cpuCost ?? 0}
|
|
265
|
+
memoryCost={totals?.memoryCost ?? 0}
|
|
266
|
+
hourlyCost={hourly}
|
|
267
|
+
cpuAllocationUse={totals?.cpuAllocationUse ?? 0}
|
|
268
|
+
memoryAllocationUse={totals?.memoryAllocationUse ?? 0}
|
|
269
|
+
cpuUsageAvailable={totals?.cpuUsageAvailable ?? false}
|
|
270
|
+
memoryUsageAvailable={totals?.memoryUsageAvailable ?? false}
|
|
271
|
+
scopeNote="Included workloads only"
|
|
272
|
+
/>
|
|
273
|
+
|
|
274
|
+
<section className="rounded-lg border border-theme-border bg-theme-surface/50">
|
|
275
|
+
<div className="flex items-center justify-between border-b border-theme-border px-4 py-3">
|
|
276
|
+
<div>
|
|
277
|
+
<div className="text-sm font-semibold text-theme-text-primary">
|
|
278
|
+
Workload contributors
|
|
279
|
+
</div>
|
|
280
|
+
<div className="text-xs text-theme-text-tertiary">
|
|
281
|
+
Projected monthly from current allocation, sorted by spend
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
<div className="text-xs text-theme-text-tertiary">{rows.length} tracked</div>
|
|
285
|
+
</div>
|
|
286
|
+
{rows.length === 0 ? (
|
|
287
|
+
<div className="px-4 py-6 text-sm text-theme-text-tertiary">
|
|
288
|
+
No workload cost rows available for this application.
|
|
289
|
+
</div>
|
|
290
|
+
) : (
|
|
291
|
+
<div className="divide-y divide-theme-border/60">
|
|
292
|
+
{rows.map((row) => {
|
|
293
|
+
const appWorkload = workloadByKey.get(applicationCostKey(row))
|
|
294
|
+
return (
|
|
295
|
+
<ApplicationWorkloadCostRow
|
|
296
|
+
key={applicationCostKey(row)}
|
|
297
|
+
row={row}
|
|
298
|
+
maxCost={maxCost}
|
|
299
|
+
onOpen={
|
|
300
|
+
appWorkload && onSelectWorkloadCost
|
|
301
|
+
? () => onSelectWorkloadCost(appWorkload)
|
|
302
|
+
: undefined
|
|
303
|
+
}
|
|
304
|
+
/>
|
|
305
|
+
)
|
|
306
|
+
})}
|
|
307
|
+
</div>
|
|
308
|
+
)}
|
|
309
|
+
</section>
|
|
310
|
+
|
|
311
|
+
<div className="text-xs text-theme-text-tertiary">
|
|
312
|
+
Powered by OpenCost via Prometheus. Historical spend uses the selected range; projected
|
|
313
|
+
monthly values multiply current hourly allocation. Batch/job cost is separate; storage/PVC
|
|
314
|
+
and network costs remain at namespace and cluster level.
|
|
315
|
+
</div>
|
|
316
|
+
</div>
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function getApplicationCostState(
|
|
321
|
+
current: OpenCostApplicationCostResponse | undefined,
|
|
322
|
+
trend: OpenCostApplicationCostTrendResponse | undefined,
|
|
323
|
+
status: ApplicationCostQueryStatus,
|
|
324
|
+
): ApplicationCostState {
|
|
325
|
+
const loading = Boolean(status.currentLoading || status.trendLoading)
|
|
326
|
+
const queryError = Boolean(status.currentError || status.trendError)
|
|
327
|
+
const currentHasData = current?.available === true && (current.coverage?.included ?? 0) > 0
|
|
328
|
+
const trendHasData =
|
|
329
|
+
trend?.available === true && (trend.dataPoints ?? []).some((p) => p.value > 0)
|
|
330
|
+
if (currentHasData) {
|
|
331
|
+
if (status.trendLoading && !trend) return 'data'
|
|
332
|
+
if (status.trendError || (trend?.available === false && trend.reason !== 'no_metrics'))
|
|
333
|
+
return 'partial_missing_history'
|
|
334
|
+
if ((current?.totals?.hourlyCost ?? 0) === 0 && !trendHasData) return 'zero'
|
|
335
|
+
if (!trend?.available) return 'partial_missing_history'
|
|
336
|
+
return 'data'
|
|
337
|
+
}
|
|
338
|
+
if (trendHasData) return 'partial_missing_current'
|
|
339
|
+
const reason =
|
|
340
|
+
current?.reason ??
|
|
341
|
+
trend?.reason ??
|
|
342
|
+
costUnavailableReasonFromError(status.currentError) ??
|
|
343
|
+
costUnavailableReasonFromError(status.trendError)
|
|
344
|
+
if (
|
|
345
|
+
reason === 'no_prometheus' ||
|
|
346
|
+
reason === 'query_error' ||
|
|
347
|
+
reason === 'access_denied' ||
|
|
348
|
+
reason === 'not_found'
|
|
349
|
+
)
|
|
350
|
+
return reason
|
|
351
|
+
if (queryError) return 'load_error'
|
|
352
|
+
if (loading) return 'loading'
|
|
353
|
+
|
|
354
|
+
return 'no_metrics'
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function applicationCostWorkloads(workloads: AppWorkload[]): AppWorkload[] {
|
|
358
|
+
return workloads.filter((workload) => isOpenCostWorkloadKind(workload.kind))
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function ApplicationWorkloadCostRow({
|
|
362
|
+
row,
|
|
363
|
+
maxCost,
|
|
364
|
+
onOpen,
|
|
365
|
+
}: {
|
|
366
|
+
row: OpenCostApplicationWorkloadCost
|
|
367
|
+
maxCost: number
|
|
368
|
+
onOpen?: () => void
|
|
369
|
+
}) {
|
|
370
|
+
const current = row.current
|
|
371
|
+
const hourly = current?.hourlyCost ?? 0
|
|
372
|
+
const cpuPct = hourly > 0 ? ((current?.cpuCost ?? 0) / hourly) * 100 : 0
|
|
373
|
+
const barWidth = maxCost > 0 ? Math.max((hourly / maxCost) * 100, 3) : 0
|
|
374
|
+
const content = (
|
|
375
|
+
<>
|
|
376
|
+
<div className="min-w-0">
|
|
377
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
378
|
+
<span className="shrink-0 rounded bg-theme-base px-1.5 py-0.5 text-[10px] uppercase text-theme-text-tertiary">
|
|
379
|
+
{row.kind}
|
|
380
|
+
</span>
|
|
381
|
+
<Tooltip content={`${row.kind} ${row.namespace}/${row.name}`} wrapperClassName="min-w-0">
|
|
382
|
+
<span className="block truncate text-sm font-medium text-theme-text-primary">
|
|
383
|
+
{row.name}
|
|
384
|
+
</span>
|
|
385
|
+
</Tooltip>
|
|
386
|
+
<Tooltip content={row.namespace} wrapperClassName="shrink-0">
|
|
387
|
+
<span className="text-xs text-theme-text-tertiary">{row.namespace}</span>
|
|
388
|
+
</Tooltip>
|
|
389
|
+
</div>
|
|
390
|
+
{!row.available && (
|
|
391
|
+
<div className="mt-0.5 text-xs text-theme-text-tertiary">{reasonLabel(row.reason)}</div>
|
|
392
|
+
)}
|
|
393
|
+
</div>
|
|
394
|
+
<div className="text-right text-sm font-medium tabular-nums text-theme-text-primary">
|
|
395
|
+
{current ? formatProjectedMonthlyRate(hourly) : '—'}
|
|
396
|
+
</div>
|
|
397
|
+
<div className="hidden text-right text-xs tabular-nums text-theme-text-tertiary sm:block">
|
|
398
|
+
{current ? formatCostPerHour(hourly) : '—'}
|
|
399
|
+
</div>
|
|
400
|
+
<div className="hidden min-w-0 items-center gap-2 md:flex">
|
|
401
|
+
<div
|
|
402
|
+
className="h-1.5 flex-1 overflow-hidden rounded-full bg-theme-hover"
|
|
403
|
+
style={{ maxWidth: `${barWidth}%` }}
|
|
404
|
+
>
|
|
405
|
+
<div className="flex h-full">
|
|
406
|
+
<div className="h-full bg-accent" style={{ width: `${cpuPct}%` }} />
|
|
407
|
+
<div className="h-full bg-amber-500" style={{ width: `${100 - cpuPct}%` }} />
|
|
408
|
+
</div>
|
|
409
|
+
</div>
|
|
410
|
+
</div>
|
|
411
|
+
<div className="hidden text-right text-xs tabular-nums text-theme-text-tertiary lg:block">
|
|
412
|
+
{current
|
|
413
|
+
? `${formatProjectedMonthlyCost(current.cpuCost)} / ${formatProjectedMonthlyCost(current.memoryCost)}`
|
|
414
|
+
: '—'}
|
|
415
|
+
</div>
|
|
416
|
+
</>
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
if (!onOpen) {
|
|
420
|
+
return (
|
|
421
|
+
<div className="grid grid-cols-[minmax(180px,1fr)_100px] gap-3 px-4 py-3 sm:grid-cols-[minmax(180px,1fr)_100px_100px] md:grid-cols-[minmax(220px,1fr)_100px_100px_minmax(160px,1fr)] lg:grid-cols-[minmax(220px,1fr)_110px_110px_minmax(180px,1fr)_130px]">
|
|
422
|
+
{content}
|
|
423
|
+
</div>
|
|
424
|
+
)
|
|
425
|
+
}
|
|
426
|
+
return (
|
|
427
|
+
<button
|
|
428
|
+
type="button"
|
|
429
|
+
onClick={onOpen}
|
|
430
|
+
className="grid w-full grid-cols-[minmax(180px,1fr)_100px] gap-3 px-4 py-3 text-left transition-colors hover:bg-theme-hover sm:grid-cols-[minmax(180px,1fr)_100px_100px] md:grid-cols-[minmax(220px,1fr)_100px_100px_minmax(160px,1fr)] lg:grid-cols-[minmax(220px,1fr)_110px_110px_minmax(180px,1fr)_130px]"
|
|
431
|
+
>
|
|
432
|
+
{content}
|
|
433
|
+
</button>
|
|
434
|
+
)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function applicationChartSeries(
|
|
438
|
+
trend: OpenCostApplicationCostTrendResponse | undefined,
|
|
439
|
+
): OpenCostTrendSeries[] {
|
|
440
|
+
return (trend?.series ?? [])
|
|
441
|
+
.filter((series) => (series.dataPoints ?? []).length >= 2)
|
|
442
|
+
.map((series) => ({
|
|
443
|
+
namespace: `${series.name} (${series.kind})`,
|
|
444
|
+
dataPoints: series.dataPoints ?? [],
|
|
445
|
+
}))
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function applicationCostKey(ref: { kind: string; namespace: string; name: string }) {
|
|
449
|
+
return `${ref.kind}/${ref.namespace}/${ref.name}`
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function reasonLabel(reason?: CostUnavailableReason) {
|
|
453
|
+
if (reason === 'no_prometheus') return 'Prometheus not found'
|
|
454
|
+
if (reason === 'query_error') return 'Cost query failed'
|
|
455
|
+
if (reason === 'access_denied') return 'No access to this workload'
|
|
456
|
+
if (reason === 'not_found') return 'Workload not found'
|
|
457
|
+
return 'No workload cost metrics'
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function ApplicationCostDiscovering({
|
|
461
|
+
isFetching,
|
|
462
|
+
onRetry,
|
|
463
|
+
}: {
|
|
464
|
+
isFetching: boolean
|
|
465
|
+
onRetry: () => void
|
|
466
|
+
}) {
|
|
467
|
+
return (
|
|
468
|
+
<div className="flex h-full min-h-[320px] items-center justify-center">
|
|
469
|
+
<div className="flex max-w-md flex-col items-center gap-3 text-center text-theme-text-secondary">
|
|
470
|
+
<Loader2 className="h-8 w-8 animate-spin text-theme-text-tertiary/60" />
|
|
471
|
+
<div>
|
|
472
|
+
<p className="text-sm font-medium text-theme-text-primary">
|
|
473
|
+
Looking for Prometheus cost data…
|
|
474
|
+
</p>
|
|
475
|
+
<p className="mt-1 text-xs text-theme-text-tertiary">
|
|
476
|
+
First discovery can take a few seconds while Radar checks cluster services and opens a
|
|
477
|
+
local port-forward.
|
|
478
|
+
</p>
|
|
479
|
+
</div>
|
|
480
|
+
<button
|
|
481
|
+
onClick={onRetry}
|
|
482
|
+
disabled={isFetching}
|
|
483
|
+
className="text-xs text-accent-text transition-colors hover:text-theme-text-primary disabled:cursor-not-allowed disabled:text-theme-text-disabled"
|
|
484
|
+
>
|
|
485
|
+
{isFetching ? 'Checking…' : 'Check again'}
|
|
486
|
+
</button>
|
|
487
|
+
</div>
|
|
488
|
+
</div>
|
|
489
|
+
)
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function ApplicationCostUnavailable({
|
|
493
|
+
state,
|
|
494
|
+
message,
|
|
495
|
+
}: {
|
|
496
|
+
state: CostUnavailableReason | 'load_error'
|
|
497
|
+
message?: string
|
|
498
|
+
}) {
|
|
499
|
+
const text =
|
|
500
|
+
message ??
|
|
501
|
+
(state === 'no_prometheus'
|
|
502
|
+
? 'Prometheus not found. OpenCost application cost requires Prometheus or VictoriaMetrics.'
|
|
503
|
+
: state === 'query_error'
|
|
504
|
+
? 'Cost data is temporarily unavailable. Prometheus was found, but application cost queries failed.'
|
|
505
|
+
: state === 'access_denied'
|
|
506
|
+
? 'Cost data is unavailable because these workloads are not accessible with your current permissions.'
|
|
507
|
+
: state === 'not_found'
|
|
508
|
+
? 'Cost data is unavailable because the referenced workloads no longer exist.'
|
|
509
|
+
: state === 'load_error'
|
|
510
|
+
? 'Could not load application cost data. Check access to these workloads and try again.'
|
|
511
|
+
: 'OpenCost workload metrics were not found for this application.')
|
|
512
|
+
return (
|
|
513
|
+
<div className="flex h-full min-h-[320px] items-center justify-center">
|
|
514
|
+
<div className="flex max-w-md flex-col items-center gap-3 text-center text-theme-text-secondary">
|
|
515
|
+
<DollarSign className="h-8 w-8 text-theme-text-tertiary/50" />
|
|
516
|
+
<div className="text-sm">{text}</div>
|
|
517
|
+
</div>
|
|
518
|
+
</div>
|
|
519
|
+
)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function CostMetricBlock({
|
|
523
|
+
label,
|
|
524
|
+
value,
|
|
525
|
+
subvalue,
|
|
526
|
+
}: {
|
|
527
|
+
label: string
|
|
528
|
+
value: string
|
|
529
|
+
subvalue?: string
|
|
530
|
+
}) {
|
|
531
|
+
return (
|
|
532
|
+
<div>
|
|
533
|
+
<div className="text-xs font-medium uppercase text-theme-text-tertiary">{label}</div>
|
|
534
|
+
<div className="mt-1 text-2xl font-semibold text-theme-text-primary tabular-nums">
|
|
535
|
+
{value}
|
|
536
|
+
</div>
|
|
537
|
+
{subvalue && <div className="mt-1 text-xs text-theme-text-tertiary">{subvalue}</div>}
|
|
538
|
+
</div>
|
|
539
|
+
)
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function CostMetricTile({
|
|
543
|
+
label,
|
|
544
|
+
value,
|
|
545
|
+
subvalue,
|
|
546
|
+
tooltip,
|
|
547
|
+
}: {
|
|
548
|
+
label: string
|
|
549
|
+
value: string
|
|
550
|
+
subvalue?: string
|
|
551
|
+
tooltip?: string
|
|
552
|
+
}) {
|
|
553
|
+
return (
|
|
554
|
+
<div className="rounded-lg border border-theme-border bg-theme-surface/50 p-4">
|
|
555
|
+
<div className="flex items-center gap-1.5">
|
|
556
|
+
<div className="text-xs font-medium uppercase text-theme-text-tertiary">{label}</div>
|
|
557
|
+
{tooltip && <CostInfoTooltip content={tooltip} />}
|
|
558
|
+
</div>
|
|
559
|
+
<div className="mt-1 text-lg font-semibold text-theme-text-primary tabular-nums">{value}</div>
|
|
560
|
+
{subvalue && <div className="mt-1 text-xs text-theme-text-tertiary">{subvalue}</div>}
|
|
561
|
+
</div>
|
|
562
|
+
)
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function CostInfoTooltip({ content }: { content: string }) {
|
|
566
|
+
return (
|
|
567
|
+
<Tooltip content={content} className="max-w-[280px] whitespace-normal text-left" delay={150}>
|
|
568
|
+
<HelpCircle className="h-3.5 w-3.5 cursor-help text-theme-text-tertiary transition-colors hover:text-theme-text-secondary" />
|
|
569
|
+
</Tooltip>
|
|
570
|
+
)
|
|
571
|
+
}
|