@skyhook-io/radar-app 1.1.0 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/App.tsx +81 -18
- package/src/api/client.ts +200 -26
- package/src/api/rbac.ts +57 -0
- package/src/components/compare/CompareViewRoute.tsx +116 -0
- package/src/components/compare/useCompareCandidates.ts +27 -0
- package/src/components/compare/useCompareLauncher.tsx +76 -0
- package/src/components/cost/CostView.tsx +1 -1
- package/src/components/gitops/GitOpsView.tsx +258 -1862
- package/src/components/helm/ChartBrowser.tsx +61 -10
- package/src/components/helm/HelmView.tsx +28 -11
- package/src/components/helm/InstallWizard.tsx +5 -5
- package/src/components/helm/ManifestDiffViewer.tsx +1 -1
- package/src/components/helm/ValuesViewer.tsx +3 -39
- package/src/components/helm/helm-utils.ts +4 -0
- package/src/components/home/HomeView.tsx +18 -2
- package/src/components/resource/HPACharts.tsx +232 -0
- package/src/components/resource/PVCUsageBar.tsx +59 -0
- package/src/components/resource/PrometheusCharts.tsx +151 -434
- package/src/components/resource/PrometheusChartsGrid.tsx +339 -0
- package/src/components/resource/RestartChart.tsx +124 -0
- package/src/components/resource/RightsizingStrip.tsx +167 -0
- package/src/components/resources/CompositeRenderer.tsx +101 -0
- package/src/components/resources/renderers/HPARenderer.tsx +17 -1
- package/src/components/resources/renderers/NamespaceRenderer.tsx +22 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
- package/src/components/resources/renderers/PodRenderer.tsx +13 -0
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +43 -1
- package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
- package/src/components/resources/renderers/WorkloadRenderer.tsx +12 -0
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/settings/MyPermissionsDialog.tsx +231 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +1 -0
- package/src/components/workload/WorkloadView.tsx +107 -3
- package/src/context/NavCustomization.tsx +13 -0
- package/src/contexts/CapabilitiesContext.tsx +8 -3
- package/src/components/gitops/RollbackDialog.tsx +0 -107
- package/src/components/gitops/SyncOptionsDialog.tsx +0 -144
|
@@ -2,12 +2,13 @@ import { useState, useMemo } from 'react'
|
|
|
2
2
|
import { Search, RefreshCw, Package, Database, AlertCircle, ExternalLink, ChevronDown, Star, Shield, BadgeCheck, Building2, Globe, ArrowUpDown, FileJson, PenTool } from 'lucide-react'
|
|
3
3
|
import { PaneLoader } from '@skyhook-io/k8s-ui'
|
|
4
4
|
import { clsx } from 'clsx'
|
|
5
|
-
import { useHelmRepositories, useSearchCharts, useUpdateRepository, useArtifactHubSearch, type ArtifactHubSortOption } from '../../api/client'
|
|
5
|
+
import { useHelmRepositories, useSearchCharts, useUpdateRepository, useUpdateRepositorySilent, useArtifactHubSearch, type ArtifactHubSortOption } from '../../api/client'
|
|
6
6
|
import { useCanHelmWrite } from '../../contexts/CapabilitiesContext'
|
|
7
7
|
import type { ChartInfo, HelmRepository, ArtifactHubChart, ChartSource } from '../../types'
|
|
8
8
|
import { formatAge } from './helm-utils'
|
|
9
9
|
import { SEVERITY_BADGE } from '../../utils/badge-colors'
|
|
10
10
|
import { Tooltip } from '../ui/Tooltip'
|
|
11
|
+
import { useToast } from '../ui/Toast'
|
|
11
12
|
|
|
12
13
|
interface ChartBrowserProps {
|
|
13
14
|
onChartSelect: (repo: string, chart: string, version: string, source: ChartSource) => void
|
|
@@ -66,21 +67,54 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
|
|
|
66
67
|
return groups
|
|
67
68
|
}, [filteredLocalCharts])
|
|
68
69
|
|
|
70
|
+
// Silent variant for the bulk path so the global MutationCache
|
|
71
|
+
// doesn't fire a per-call "Failed to update repository" toast
|
|
72
|
+
// — handleUpdateAllRepos surfaces a single aggregate toast
|
|
73
|
+
// that names the failed repos.
|
|
74
|
+
const updateRepoSilentMutation = useUpdateRepositorySilent()
|
|
75
|
+
const { showError, showSuccess } = useToast()
|
|
76
|
+
// updateRepoSilentMutation.isPending flips to false BETWEEN
|
|
77
|
+
// sequential mutateAsync calls, briefly re-enabling the bulk
|
|
78
|
+
// button mid-loop. Track the whole-batch state explicitly so
|
|
79
|
+
// the user can't kick off a second concurrent batch.
|
|
80
|
+
const [isBatchUpdating, setIsBatchUpdating] = useState(false)
|
|
81
|
+
|
|
69
82
|
const handleUpdateRepo = async (repoName: string) => {
|
|
70
83
|
await updateRepoMutation.mutateAsync(repoName)
|
|
71
84
|
refetchCharts()
|
|
72
85
|
}
|
|
73
86
|
|
|
74
87
|
const handleUpdateAllRepos = async () => {
|
|
75
|
-
if (!repositories) return
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
88
|
+
if (!repositories || repositories.length === 0 || isBatchUpdating) return
|
|
89
|
+
setIsBatchUpdating(true)
|
|
90
|
+
const failed: string[] = []
|
|
91
|
+
try {
|
|
92
|
+
for (const repo of repositories) {
|
|
93
|
+
try {
|
|
94
|
+
await updateRepoSilentMutation.mutateAsync(repo.name)
|
|
95
|
+
} catch (err) {
|
|
96
|
+
failed.push(repo.name)
|
|
97
|
+
console.warn(`helm repo update failed for "${repo.name}":`, err)
|
|
98
|
+
}
|
|
81
99
|
}
|
|
100
|
+
} finally {
|
|
101
|
+
setIsBatchUpdating(false)
|
|
82
102
|
}
|
|
83
103
|
refetchCharts()
|
|
104
|
+
const ok = repositories.length - failed.length
|
|
105
|
+
if (failed.length === 0) {
|
|
106
|
+
showSuccess(`Updated ${ok} ${ok === 1 ? 'repository' : 'repositories'}`)
|
|
107
|
+
} else if (ok === 0) {
|
|
108
|
+
showError(
|
|
109
|
+
`Failed to update ${failed.length} ${failed.length === 1 ? 'repository' : 'repositories'}`,
|
|
110
|
+
`Failed: ${failed.join(', ')}`,
|
|
111
|
+
)
|
|
112
|
+
} else {
|
|
113
|
+
showError(
|
|
114
|
+
`Updated ${ok}/${repositories.length} repositories`,
|
|
115
|
+
`Failed: ${failed.join(', ')}`,
|
|
116
|
+
)
|
|
117
|
+
}
|
|
84
118
|
}
|
|
85
119
|
|
|
86
120
|
const isLoading = chartSource === 'local' ? chartsLoading : artifactHubLoading
|
|
@@ -246,10 +280,10 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
|
|
|
246
280
|
<Tooltip content={canHelmWrite ? "Update all repositories" : helmWriteReason}>
|
|
247
281
|
<button
|
|
248
282
|
onClick={handleUpdateAllRepos}
|
|
249
|
-
disabled={
|
|
283
|
+
disabled={isBatchUpdating || !canHelmWrite}
|
|
250
284
|
className="p-2 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-lg disabled:opacity-50"
|
|
251
285
|
>
|
|
252
|
-
<RefreshCw className={clsx('w-4 h-4',
|
|
286
|
+
<RefreshCw className={clsx('w-4 h-4', isBatchUpdating && 'animate-spin')} />
|
|
253
287
|
</button>
|
|
254
288
|
</Tooltip>
|
|
255
289
|
</>
|
|
@@ -280,7 +314,24 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
|
|
|
280
314
|
</p>
|
|
281
315
|
</div>
|
|
282
316
|
) : (
|
|
283
|
-
<
|
|
317
|
+
<div className="text-sm mt-1 flex flex-col items-center gap-2">
|
|
318
|
+
<p>Your repositories may be out of date.</p>
|
|
319
|
+
<button
|
|
320
|
+
onClick={handleUpdateAllRepos}
|
|
321
|
+
disabled={isBatchUpdating || !canHelmWrite}
|
|
322
|
+
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs btn-brand rounded disabled:opacity-50"
|
|
323
|
+
title={canHelmWrite ? 'Run helm repo update on every configured repository' : helmWriteReason}
|
|
324
|
+
>
|
|
325
|
+
<RefreshCw className={`w-3.5 h-3.5 ${isBatchUpdating ? 'animate-spin' : ''}`} />
|
|
326
|
+
{isBatchUpdating ? 'Updating…' : 'Update all repositories'}
|
|
327
|
+
</button>
|
|
328
|
+
<button
|
|
329
|
+
onClick={() => setChartSource('artifacthub')}
|
|
330
|
+
className="text-xs text-blue-400 hover:underline"
|
|
331
|
+
>
|
|
332
|
+
Or browse ArtifactHub instead
|
|
333
|
+
</button>
|
|
334
|
+
</div>
|
|
284
335
|
)}
|
|
285
336
|
</div>
|
|
286
337
|
</div>
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { useState, useMemo, useRef, useEffect, useCallback, forwardRef } from 'react'
|
|
2
2
|
import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
|
|
3
3
|
import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
|
|
4
|
-
import { Package, Search, RefreshCw, ArrowUpCircle, LayoutGrid, List, Shield, GitBranch } from 'lucide-react'
|
|
4
|
+
import { Package, Search, RefreshCw, ArrowUpCircle, LayoutGrid, List, Shield, GitBranch, ChevronRight } from 'lucide-react'
|
|
5
5
|
import { PaneLoader } from '@skyhook-io/k8s-ui'
|
|
6
6
|
import { clsx } from 'clsx'
|
|
7
7
|
import { useHelmReleases, useHelmBatchUpgradeInfo, isForbiddenError } from '../../api/client'
|
|
8
8
|
import type { HelmRelease, SelectedHelmRelease, UpgradeInfo, ChartSource } from '../../types'
|
|
9
|
-
import { getStatusColor, formatAge, truncate } from './helm-utils'
|
|
9
|
+
import { getStatusColor, formatAge, truncate, isHelmReleaseActionable } from './helm-utils'
|
|
10
10
|
import { SEVERITY_BADGE } from '../../utils/badge-colors'
|
|
11
11
|
import { Tooltip } from '../ui/Tooltip'
|
|
12
12
|
import { ChartBrowser } from './ChartBrowser'
|
|
@@ -481,17 +481,34 @@ const ReleaseRow = forwardRef<HTMLTableRowElement, ReleaseRowProps>(
|
|
|
481
481
|
</Tooltip>
|
|
482
482
|
</td>
|
|
483
483
|
<td className="px-4 py-3 w-24 hidden xl:table-cell">
|
|
484
|
-
|
|
484
|
+
{release.appVersion ? (
|
|
485
|
+
<span className="text-sm text-theme-text-secondary">{release.appVersion}</span>
|
|
486
|
+
) : (
|
|
487
|
+
<Tooltip content="This chart did not declare an appVersion in Chart.yaml.">
|
|
488
|
+
<span className="text-sm text-theme-text-disabled cursor-help">—</span>
|
|
489
|
+
</Tooltip>
|
|
490
|
+
)}
|
|
485
491
|
</td>
|
|
486
492
|
<td className="px-4 py-3 w-28">
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
493
|
+
{isHelmReleaseActionable(release.status) ? (
|
|
494
|
+
<Tooltip content="Click row to view rollback / history / logs and recover">
|
|
495
|
+
<span
|
|
496
|
+
className={clsx('badge inline-flex items-center gap-1', getStatusColor(release.status))}
|
|
497
|
+
>
|
|
498
|
+
{release.status}
|
|
499
|
+
<ChevronRight className="w-3 h-3 opacity-70" />
|
|
500
|
+
</span>
|
|
501
|
+
</Tooltip>
|
|
502
|
+
) : (
|
|
503
|
+
<span
|
|
504
|
+
className={clsx(
|
|
505
|
+
'badge',
|
|
506
|
+
getStatusColor(release.status)
|
|
507
|
+
)}
|
|
508
|
+
>
|
|
509
|
+
{release.status}
|
|
510
|
+
</span>
|
|
511
|
+
)}
|
|
495
512
|
</td>
|
|
496
513
|
<td className="px-4 py-3 w-20">
|
|
497
514
|
<span className="text-sm text-theme-text-secondary">{release.revision}</span>
|
|
@@ -106,11 +106,11 @@ export function InstallWizard({ repo, chartName, version, source, repoUrl, defau
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
if (baseValues && defaultValues) {
|
|
109
|
-
setValuesYaml(yaml.stringify(deepMerge(baseValues, defaultValues)))
|
|
109
|
+
setValuesYaml(yaml.stringify(deepMerge(baseValues, defaultValues), { lineWidth: 0 }))
|
|
110
110
|
} else if (defaultValues && !baseValues) {
|
|
111
|
-
setValuesYaml(yaml.stringify(defaultValues))
|
|
111
|
+
setValuesYaml(yaml.stringify(defaultValues, { lineWidth: 0 }))
|
|
112
112
|
} else if (baseValues) {
|
|
113
|
-
setValuesYaml(yaml.stringify(baseValues))
|
|
113
|
+
setValuesYaml(yaml.stringify(baseValues, { lineWidth: 0 }))
|
|
114
114
|
}
|
|
115
115
|
}, [localChartDetail?.values, artifactHubDetail?.values, isLocal, defaultValues])
|
|
116
116
|
|
|
@@ -349,7 +349,7 @@ export function InstallWizard({ repo, chartName, version, source, repoUrl, defau
|
|
|
349
349
|
valuesYaml={valuesYaml}
|
|
350
350
|
defaultValuesYaml={
|
|
351
351
|
isLocal
|
|
352
|
-
? (localChartDetail?.values ? yaml.stringify(localChartDetail.values) : '')
|
|
352
|
+
? (localChartDetail?.values ? yaml.stringify(localChartDetail.values, { lineWidth: 0 }) : '')
|
|
353
353
|
: (artifactHubDetail?.values || '')
|
|
354
354
|
}
|
|
355
355
|
/>
|
|
@@ -664,7 +664,7 @@ function ValuesStep({ valuesYaml, setValuesYaml, yamlError, setYamlError, chartD
|
|
|
664
664
|
const ahDetail = chartDetail as ArtifactHubChartDetail | undefined
|
|
665
665
|
|
|
666
666
|
const defaultValues = isLocal
|
|
667
|
-
? (localDetail?.values ? yaml.stringify(localDetail.values) : '')
|
|
667
|
+
? (localDetail?.values ? yaml.stringify(localDetail.values, { lineWidth: 0 }) : '')
|
|
668
668
|
: (ahDetail?.values || '')
|
|
669
669
|
|
|
670
670
|
const hasDefaults = Boolean(defaultValues)
|
|
@@ -44,7 +44,7 @@ export function ManifestDiffViewer({ diff, isLoading, revision1, revision2, onCl
|
|
|
44
44
|
</button>
|
|
45
45
|
</div>
|
|
46
46
|
|
|
47
|
-
<div className="rounded-lg
|
|
47
|
+
<div className="rounded-lg max-h-[calc(100vh-300px)] overflow-auto bg-theme-base/50 font-mono text-xs">
|
|
48
48
|
<div className="p-3">
|
|
49
49
|
{diff.split('\n').map((line, index) => (
|
|
50
50
|
<DiffLine key={index} line={line} />
|
|
@@ -320,43 +320,7 @@ function ToggleButton({ showAll, onToggle, disabled }: { showAll: boolean; onTog
|
|
|
320
320
|
)
|
|
321
321
|
}
|
|
322
322
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
let result = ''
|
|
327
|
-
|
|
328
|
-
for (const [key, value] of Object.entries(obj)) {
|
|
329
|
-
if (value === null || value === undefined) {
|
|
330
|
-
result += `${spaces}${key}: null\n`
|
|
331
|
-
} else if (typeof value === 'object' && !Array.isArray(value)) {
|
|
332
|
-
result += `${spaces}${key}:\n`
|
|
333
|
-
result += jsonToYaml(value as Record<string, unknown>, indent + 1)
|
|
334
|
-
} else if (Array.isArray(value)) {
|
|
335
|
-
result += `${spaces}${key}:\n`
|
|
336
|
-
for (const item of value) {
|
|
337
|
-
if (typeof item === 'object' && item !== null) {
|
|
338
|
-
result += `${spaces}- \n`
|
|
339
|
-
const itemYaml = jsonToYaml(item as Record<string, unknown>, indent + 2)
|
|
340
|
-
result += itemYaml
|
|
341
|
-
} else {
|
|
342
|
-
result += `${spaces}- ${formatValue(item)}\n`
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
} else {
|
|
346
|
-
result += `${spaces}${key}: ${formatValue(value)}\n`
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
return result
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
function formatValue(value: unknown): string {
|
|
354
|
-
if (typeof value === 'string') {
|
|
355
|
-
// Quote strings that contain special characters
|
|
356
|
-
if (value.includes(':') || value.includes('#') || value.includes('\n') || value.startsWith(' ') || value.endsWith(' ')) {
|
|
357
|
-
return `"${value.replace(/"/g, '\\"')}"`
|
|
358
|
-
}
|
|
359
|
-
return value
|
|
360
|
-
}
|
|
361
|
-
return String(value)
|
|
323
|
+
function jsonToYaml(obj: Record<string, unknown>): string {
|
|
324
|
+
if (!obj || Object.keys(obj).length === 0) return ''
|
|
325
|
+
return yaml.stringify(obj, { lineWidth: 0 })
|
|
362
326
|
}
|
|
@@ -35,3 +35,7 @@ export function getChartDisplay(chart: string, version: string): string {
|
|
|
35
35
|
|
|
36
36
|
// Re-export kindToPlural from centralized navigation utils
|
|
37
37
|
export { kindToPlural } from '../../utils/navigation'
|
|
38
|
+
|
|
39
|
+
// Re-export from k8s-ui where the predicate lives next to the
|
|
40
|
+
// Helm status color palette and is unit-tested.
|
|
41
|
+
export { isHelmReleaseActionable } from '../../utils/badge-colors'
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
1
2
|
import { useDashboard, useDashboardCRDs, useDashboardHelm } from '../../api/client'
|
|
2
3
|
import type { DashboardResponse } from '../../api/client'
|
|
3
4
|
import type { ExtendedMainView, Topology, SelectedResource } from '../../types'
|
|
@@ -25,6 +26,21 @@ interface HomeViewProps {
|
|
|
25
26
|
|
|
26
27
|
export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToResourceKind, onNavigateToResource }: HomeViewProps) {
|
|
27
28
|
const { data, isLoading, error } = useDashboard(namespaces)
|
|
29
|
+
|
|
30
|
+
// SSE is cluster-wide on small/medium clusters; the picker only narrows the
|
|
31
|
+
// dashboard summary, so re-apply the filter here or the legend disagrees.
|
|
32
|
+
const scopedTopology = useMemo<Topology | null>(() => {
|
|
33
|
+
if (!topology) return null
|
|
34
|
+
if (namespaces.length === 0) return topology
|
|
35
|
+
const nsSet = new Set(namespaces)
|
|
36
|
+
const nodes = topology.nodes.filter(n => {
|
|
37
|
+
const ns = n.data.namespace as string | undefined
|
|
38
|
+
return !ns || nsSet.has(ns)
|
|
39
|
+
})
|
|
40
|
+
const nodeIds = new Set(nodes.map(n => n.id))
|
|
41
|
+
const edges = topology.edges.filter(e => nodeIds.has(e.source) && nodeIds.has(e.target))
|
|
42
|
+
return { nodes, edges }
|
|
43
|
+
}, [topology, namespaces])
|
|
28
44
|
// CRDs and Helm load lazily after main dashboard to keep initial load fast
|
|
29
45
|
const { data: crdsData } = useDashboardCRDs(namespaces)
|
|
30
46
|
const { data: helmData } = useDashboardHelm(namespaces)
|
|
@@ -100,7 +116,7 @@ export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToR
|
|
|
100
116
|
{/* Primary cards — 2-col grid */}
|
|
101
117
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
|
102
118
|
<TopologyPreview
|
|
103
|
-
topology={
|
|
119
|
+
topology={scopedTopology}
|
|
104
120
|
summary={data.topologySummary}
|
|
105
121
|
onNavigate={() => onNavigateToView('topology')}
|
|
106
122
|
/>
|
|
@@ -110,7 +126,7 @@ export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToR
|
|
|
110
126
|
/>
|
|
111
127
|
<ActivitySummary
|
|
112
128
|
namespaces={namespaces}
|
|
113
|
-
topology={
|
|
129
|
+
topology={scopedTopology}
|
|
114
130
|
onNavigate={() => onNavigateToView('timeline')}
|
|
115
131
|
/>
|
|
116
132
|
<TrafficSummary
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { useEffect, useMemo } from 'react'
|
|
2
|
+
import { LineChart } from 'lucide-react'
|
|
3
|
+
import { usePromQLRange, usePrometheusStatus, useAutoPromConnect, type PrometheusSeries } from '../../api/client'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* HPACharts — replicas-over-time chart for an HPA.
|
|
7
|
+
*
|
|
8
|
+
* Sources from KSM `kube_horizontalpodautoscaler_status_{current,desired}_replicas`.
|
|
9
|
+
* Hidden silently when Prom isn't connected or KSM isn't reporting the series.
|
|
10
|
+
*
|
|
11
|
+
* Only the replicas series is plotted — KSM doesn't expose the observed metric
|
|
12
|
+
* the HPA target compares against, so an "observed vs target" chart would need
|
|
13
|
+
* cAdvisor derivation.
|
|
14
|
+
*/
|
|
15
|
+
export function HPACharts({ data }: { data: any }) {
|
|
16
|
+
// HPA detail can be the first Prometheus-backed surface a user opens; without
|
|
17
|
+
// this, the chart silently stays empty until they open a workload metrics tab.
|
|
18
|
+
useAutoPromConnect()
|
|
19
|
+
const { data: status } = usePrometheusStatus()
|
|
20
|
+
const isConnected = status?.connected === true
|
|
21
|
+
|
|
22
|
+
const namespace = data?.metadata?.namespace ?? ''
|
|
23
|
+
const name = data?.metadata?.name ?? ''
|
|
24
|
+
const spec = data?.spec ?? {}
|
|
25
|
+
const min = spec.minReplicas ?? 1
|
|
26
|
+
const max = spec.maxReplicas
|
|
27
|
+
|
|
28
|
+
const currentQuery = useMemo(
|
|
29
|
+
() => `kube_horizontalpodautoscaler_status_current_replicas{namespace="${escapeLabel(namespace)}",horizontalpodautoscaler="${escapeLabel(name)}"}`,
|
|
30
|
+
[namespace, name],
|
|
31
|
+
)
|
|
32
|
+
const desiredQuery = useMemo(
|
|
33
|
+
() => `kube_horizontalpodautoscaler_status_desired_replicas{namespace="${escapeLabel(namespace)}",horizontalpodautoscaler="${escapeLabel(name)}"}`,
|
|
34
|
+
[namespace, name],
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
const enabled = isConnected && Boolean(namespace && name)
|
|
38
|
+
const { data: currentRes, error: currentErr } = usePromQLRange(currentQuery, '1h', enabled)
|
|
39
|
+
const { data: desiredRes, error: desiredErr } = usePromQLRange(desiredQuery, '1h', enabled)
|
|
40
|
+
|
|
41
|
+
const replicasPoints = useMemo(() => combineSeries({
|
|
42
|
+
current: currentRes?.series,
|
|
43
|
+
desired: desiredRes?.series,
|
|
44
|
+
}), [currentRes, desiredRes])
|
|
45
|
+
|
|
46
|
+
// Surface Prom-side failures in the console so an operator debugging a
|
|
47
|
+
// missing HPA chart has a breadcrumb; the chart still hides silently when
|
|
48
|
+
// KSM isn't reporting (the common no-data case). Effect-gated so we log
|
|
49
|
+
// once per error change, not on every re-render.
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (currentErr || desiredErr) {
|
|
52
|
+
console.warn('[HPACharts] PromQL query failed', { currentErr, desiredErr })
|
|
53
|
+
}
|
|
54
|
+
}, [currentErr, desiredErr])
|
|
55
|
+
|
|
56
|
+
if (!isConnected) return null
|
|
57
|
+
if (!replicasPoints) return null
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<section className="mt-4 rounded-lg border border-theme-border bg-theme-surface/30 p-3">
|
|
61
|
+
<div className="flex items-center gap-2 mb-3 text-sm font-medium text-theme-text-secondary">
|
|
62
|
+
<LineChart className="w-4 h-4 text-theme-text-tertiary" />
|
|
63
|
+
Activity (last 1h)
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<DualLineChart
|
|
67
|
+
title="Replicas"
|
|
68
|
+
height={120}
|
|
69
|
+
primary={{ label: 'current', points: replicasPoints.current, color: '#3b82f6' }}
|
|
70
|
+
secondary={{ label: 'desired', points: replicasPoints.desired, color: '#a855f7', dashed: true }}
|
|
71
|
+
referenceLines={[
|
|
72
|
+
{ value: min, label: `min ${min}`, color: '#94a3b8' },
|
|
73
|
+
...(max != null ? [{ value: max, label: `max ${max}`, color: '#94a3b8' }] : []),
|
|
74
|
+
]}
|
|
75
|
+
formatY={(v) => v.toFixed(0)}
|
|
76
|
+
/>
|
|
77
|
+
</section>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ============================================================================
|
|
82
|
+
// Internals
|
|
83
|
+
// ============================================================================
|
|
84
|
+
|
|
85
|
+
interface FlatPoint { timestamp: number; value: number }
|
|
86
|
+
|
|
87
|
+
function extractFirstSeries(series: PrometheusSeries[]): FlatPoint[] | null {
|
|
88
|
+
for (const s of series) {
|
|
89
|
+
if (s.dataPoints.length > 0) {
|
|
90
|
+
return s.dataPoints.map(dp => ({ timestamp: dp.timestamp, value: dp.value }))
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function combineSeries(args: { current?: PrometheusSeries[]; desired?: PrometheusSeries[] }): {
|
|
97
|
+
current: FlatPoint[]
|
|
98
|
+
desired: FlatPoint[]
|
|
99
|
+
} | null {
|
|
100
|
+
const current = args.current ? extractFirstSeries(args.current) : null
|
|
101
|
+
const desired = args.desired ? extractFirstSeries(args.desired) : null
|
|
102
|
+
if (!current && !desired) return null
|
|
103
|
+
return {
|
|
104
|
+
current: current ?? [],
|
|
105
|
+
desired: desired ?? [],
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function escapeLabel(s: string): string {
|
|
110
|
+
return s.replace(/[\\"]/g, '\\$&')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ============================================================================
|
|
114
|
+
// DualLineChart — minimal two-line chart for HPA-style time series.
|
|
115
|
+
// Deliberately separate from PrometheusCharts.AreaChart: the chart shapes are
|
|
116
|
+
// different (line not area, discrete integer Y axis for replicas), and
|
|
117
|
+
// reusing the area chart would require adding more knobs to it.
|
|
118
|
+
// ============================================================================
|
|
119
|
+
|
|
120
|
+
interface LineSpec {
|
|
121
|
+
label: string
|
|
122
|
+
points: FlatPoint[]
|
|
123
|
+
color: string
|
|
124
|
+
dashed?: boolean
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface RefLine { value: number; label: string; color: string }
|
|
128
|
+
|
|
129
|
+
function DualLineChart({ title, height, primary, secondary, referenceLines, formatY }: {
|
|
130
|
+
title: string
|
|
131
|
+
height: number
|
|
132
|
+
primary: LineSpec
|
|
133
|
+
secondary?: LineSpec
|
|
134
|
+
referenceLines?: RefLine[]
|
|
135
|
+
formatY: (v: number) => string
|
|
136
|
+
}) {
|
|
137
|
+
const allPoints = [...primary.points, ...(secondary?.points ?? [])]
|
|
138
|
+
if (allPoints.length === 0) {
|
|
139
|
+
return (
|
|
140
|
+
<div className="text-xs text-theme-text-tertiary">{title} — no data</div>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const minTs = Math.min(...allPoints.map(p => p.timestamp))
|
|
145
|
+
const maxTs = Math.max(...allPoints.map(p => p.timestamp))
|
|
146
|
+
const tsSpan = Math.max(maxTs - minTs, 60)
|
|
147
|
+
|
|
148
|
+
let maxV = Math.max(...allPoints.map(p => p.value), 1)
|
|
149
|
+
if (referenceLines) {
|
|
150
|
+
for (const rl of referenceLines) maxV = Math.max(maxV, rl.value)
|
|
151
|
+
}
|
|
152
|
+
// Add 10% headroom so the top line isn't flush with the top edge.
|
|
153
|
+
maxV = maxV * 1.1
|
|
154
|
+
|
|
155
|
+
const width = 600
|
|
156
|
+
const marginL = 36
|
|
157
|
+
const marginR = 16
|
|
158
|
+
const marginT = 4
|
|
159
|
+
const marginB = 18
|
|
160
|
+
const plotW = width - marginL - marginR
|
|
161
|
+
const plotH = height - marginT - marginB
|
|
162
|
+
|
|
163
|
+
const toX = (ts: number) => marginL + ((ts - minTs) / tsSpan) * plotW
|
|
164
|
+
const toY = (v: number) => marginT + plotH - (v / maxV) * plotH
|
|
165
|
+
|
|
166
|
+
const drawLine = (spec: LineSpec) => {
|
|
167
|
+
if (spec.points.length === 0) return null
|
|
168
|
+
const d = spec.points.map((p, i) => `${i === 0 ? 'M' : 'L'}${toX(p.timestamp).toFixed(1)},${toY(p.value).toFixed(1)}`).join(' ')
|
|
169
|
+
return (
|
|
170
|
+
<path
|
|
171
|
+
d={d}
|
|
172
|
+
fill="none"
|
|
173
|
+
stroke={spec.color}
|
|
174
|
+
strokeWidth="1.75"
|
|
175
|
+
strokeLinejoin="round"
|
|
176
|
+
strokeDasharray={spec.dashed ? '4 3' : undefined}
|
|
177
|
+
/>
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<div>
|
|
183
|
+
<div className="flex items-center justify-between mb-1.5">
|
|
184
|
+
<span className="text-xs text-theme-text-secondary">{title}</span>
|
|
185
|
+
<div className="flex items-center gap-3">
|
|
186
|
+
<Legend color={primary.color} label={primary.label} />
|
|
187
|
+
{secondary && <Legend color={secondary.color} label={secondary.label} dashed />}
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
<svg viewBox={`0 0 ${width} ${height}`} className="w-full" preserveAspectRatio="none">
|
|
191
|
+
{/* Y ticks */}
|
|
192
|
+
{[0, 0.5, 1].map(frac => {
|
|
193
|
+
const v = maxV * frac
|
|
194
|
+
const y = toY(v)
|
|
195
|
+
return (
|
|
196
|
+
<g key={frac}>
|
|
197
|
+
<line x1={marginL} y1={y} x2={width - marginR} y2={y} stroke="currentColor" className="text-theme-border/30" strokeWidth="1" />
|
|
198
|
+
<text x={marginL - 4} y={y + 3} textAnchor="end" fontSize="9" fontFamily="ui-monospace, monospace" className="fill-theme-text-tertiary">
|
|
199
|
+
{formatY(v)}
|
|
200
|
+
</text>
|
|
201
|
+
</g>
|
|
202
|
+
)
|
|
203
|
+
})}
|
|
204
|
+
{/* Reference lines */}
|
|
205
|
+
{referenceLines?.map((rl, i) => {
|
|
206
|
+
const y = toY(rl.value)
|
|
207
|
+
return (
|
|
208
|
+
<g key={`rl-${i}`}>
|
|
209
|
+
<line x1={marginL} y1={y} x2={width - marginR} y2={y} stroke={rl.color} strokeWidth="1" strokeDasharray="3 3" opacity="0.6" />
|
|
210
|
+
<text x={width - marginR - 4} y={y - 2} textAnchor="end" fontSize="9" fontFamily="ui-monospace, monospace" fill={rl.color} opacity="0.85">
|
|
211
|
+
{rl.label}
|
|
212
|
+
</text>
|
|
213
|
+
</g>
|
|
214
|
+
)
|
|
215
|
+
})}
|
|
216
|
+
{drawLine(primary)}
|
|
217
|
+
{secondary && drawLine(secondary)}
|
|
218
|
+
</svg>
|
|
219
|
+
</div>
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function Legend({ color, label, dashed }: { color: string; label: string; dashed?: boolean }) {
|
|
224
|
+
return (
|
|
225
|
+
<span className="flex items-center gap-1 text-[11px] text-theme-text-tertiary">
|
|
226
|
+
<svg width="14" height="6" aria-hidden>
|
|
227
|
+
<line x1="0" y1="3" x2="14" y2="3" stroke={color} strokeWidth="1.75" strokeDasharray={dashed ? '3 2' : undefined} />
|
|
228
|
+
</svg>
|
|
229
|
+
{label}
|
|
230
|
+
</span>
|
|
231
|
+
)
|
|
232
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { formatMemoryBytes } from '@skyhook-io/k8s-ui/utils/format'
|
|
2
|
+
import { useAutoPromConnect, usePrometheusPVCUsage, usePrometheusStatus } from '../../api/client'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* PVCUsageBar — single-line capacity gauge derived from kubelet_volume_stats_*.
|
|
6
|
+
*
|
|
7
|
+
* Hidden silently when:
|
|
8
|
+
* - Prometheus isn't connected
|
|
9
|
+
* - The CSI driver doesn't implement NodeGetVolumeStats
|
|
10
|
+
* - Prometheus isn't scraping kubelet endpoints (notably GMP default config)
|
|
11
|
+
*
|
|
12
|
+
* Operators get nothing rather than a "no data" message that'd look like Radar
|
|
13
|
+
* is broken — the absence is information enough.
|
|
14
|
+
*/
|
|
15
|
+
export function PVCUsageBar({ namespace, name }: { namespace: string; name: string }) {
|
|
16
|
+
// PVC detail can be the first Prometheus-backed surface a user opens; without
|
|
17
|
+
// this, the gauge silently stays hidden until they open a workload metrics tab.
|
|
18
|
+
useAutoPromConnect()
|
|
19
|
+
const { data: status } = usePrometheusStatus()
|
|
20
|
+
const isConnected = status?.connected === true
|
|
21
|
+
const { data: usage } = usePrometheusPVCUsage(namespace, name, isConnected)
|
|
22
|
+
|
|
23
|
+
if (!usage || !usage.hasData) return null
|
|
24
|
+
|
|
25
|
+
const pct = Math.max(0, Math.min(1, usage.ratio))
|
|
26
|
+
const usedLabel = formatMemoryBytes(usage.used)
|
|
27
|
+
const capLabel = formatMemoryBytes(usage.capacity)
|
|
28
|
+
const pctLabel = `${(pct * 100).toFixed(0)}%`
|
|
29
|
+
|
|
30
|
+
// Tone: green well under, amber > 75%, red > 90%. PVCs fill silently — the
|
|
31
|
+
// top tone is justified because the consequence (write failures) is severe.
|
|
32
|
+
const tone = pct >= 0.9 ? 'critical' : pct >= 0.75 ? 'warning' : 'ok'
|
|
33
|
+
const barColor =
|
|
34
|
+
tone === 'critical' ? 'bg-red-500' :
|
|
35
|
+
tone === 'warning' ? 'bg-amber-500' :
|
|
36
|
+
'bg-emerald-500'
|
|
37
|
+
// Light/dark-paired text tones — `text-red-400` alone washes out in light
|
|
38
|
+
// mode (Tailwind's 400 stop is calibrated for dark backgrounds).
|
|
39
|
+
const textColor =
|
|
40
|
+
tone === 'critical' ? 'text-red-700 dark:text-red-400' :
|
|
41
|
+
tone === 'warning' ? 'text-amber-700 dark:text-amber-400' :
|
|
42
|
+
'text-theme-text-secondary'
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<section className="rounded-lg border border-theme-border bg-theme-surface/30 p-3">
|
|
46
|
+
<div className="flex items-center justify-between mb-2">
|
|
47
|
+
<span className="text-xs font-medium text-theme-text-secondary uppercase tracking-wide">Usage</span>
|
|
48
|
+
<span className={`text-sm font-semibold tabular-nums ${textColor}`}>
|
|
49
|
+
{usedLabel} <span className="text-theme-text-quaternary font-normal">/</span> {capLabel}
|
|
50
|
+
<span className="ml-2 text-theme-text-tertiary text-xs font-normal">({pctLabel})</span>
|
|
51
|
+
</span>
|
|
52
|
+
</div>
|
|
53
|
+
<div className="h-2 rounded-full bg-theme-elevated overflow-hidden">
|
|
54
|
+
<div className={`h-full ${barColor} transition-all`} style={{ width: `${pct * 100}%` }} />
|
|
55
|
+
</div>
|
|
56
|
+
</section>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|