@skyhook-io/radar-app 1.10.0 → 1.12.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.
Files changed (66) hide show
  1. package/package.json +10 -10
  2. package/src/App.tsx +5 -8
  3. package/src/api/client.ts +174 -12
  4. package/src/api/policy.test.ts +38 -0
  5. package/src/api/policy.ts +166 -2
  6. package/src/components/ConnectionErrorView.test.tsx +53 -0
  7. package/src/components/ConnectionErrorView.tsx +15 -12
  8. package/src/components/ContextSwitcher.tsx +10 -13
  9. package/src/components/audit/UpgradeReadinessView.tsx +3 -3
  10. package/src/components/capacity/ClusterSchedulingCard.tsx +8 -9
  11. package/src/components/capacity/schedulingBar.test.ts +10 -0
  12. package/src/components/cost/ApplicationCostTab.test.ts +6 -0
  13. package/src/components/cost/ApplicationCostTab.tsx +28 -16
  14. package/src/components/cost/CostTrendChart.tsx +20 -10
  15. package/src/components/cost/CostView.tsx +79 -28
  16. package/src/components/cost/CurrentAllocationUse.tsx +6 -4
  17. package/src/components/cost/WorkloadCostTab.test.ts +10 -0
  18. package/src/components/cost/WorkloadCostTab.tsx +24 -12
  19. package/src/components/cost/format.test.ts +27 -8
  20. package/src/components/cost/format.ts +78 -27
  21. package/src/components/home/ClusterHealthCard.tsx +3 -0
  22. package/src/components/home/CostCard.tsx +12 -7
  23. package/src/components/home/HomeView.tsx +15 -3
  24. package/src/components/home/NetworkPolicyCoverageCard.test.tsx +81 -0
  25. package/src/components/home/NetworkPolicyCoverageCard.tsx +50 -7
  26. package/src/components/home/TopologyPreview.tsx +57 -11
  27. package/src/components/home/mcpToolCatalog.ts +27 -5
  28. package/src/components/nav/PrimaryNavRail.tsx +2 -2
  29. package/src/components/resources/ResourcesView.tsx +15 -3
  30. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +116 -1
  31. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +227 -0
  32. package/src/components/resources/renderers/CNPGImageCatalogRenderer.tsx +123 -0
  33. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +152 -0
  34. package/src/components/resources/renderers/KyvernoPolicyCoverage.tsx +65 -0
  35. package/src/components/resources/renderers/KyvernoPolicyQueued.render.test.tsx +59 -0
  36. package/src/components/resources/renderers/KyvernoPolicyQueued.test.ts +99 -0
  37. package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +184 -0
  38. package/src/components/resources/renderers/RolloutRenderer.tsx +24 -1
  39. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +44 -1
  40. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +75 -1
  41. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +35 -1
  42. package/src/components/resources/renderers/index.ts +1 -0
  43. package/src/components/rightsizing/RightsizingScanView.tsx +2 -2
  44. package/src/components/settings/SettingsDialog.tsx +160 -36
  45. package/src/components/settings/currency-options.test.ts +49 -0
  46. package/src/components/settings/currency-options.ts +38 -0
  47. package/src/components/traffic/TrafficFilterSidebar.tsx +37 -20
  48. package/src/components/traffic/TrafficFlowList.tsx +16 -2
  49. package/src/components/traffic/TrafficGraph.tsx +150 -58
  50. package/src/components/traffic/TrafficView.tsx +168 -52
  51. package/src/components/traffic/TrafficWizard.tsx +13 -1
  52. package/src/components/traffic/trafficFilters.test.ts +103 -0
  53. package/src/components/traffic/trafficFilters.ts +117 -0
  54. package/src/components/ui/DiagnosticsOverlay.test.ts +115 -0
  55. package/src/components/ui/DiagnosticsOverlay.tsx +65 -8
  56. package/src/components/ui/command-items.ts +4 -14
  57. package/src/components/workload/WorkloadView.tsx +57 -8
  58. package/src/main.tsx +4 -114
  59. package/src/utils/context-name.test.ts +63 -0
  60. package/src/utils/context-name.ts +22 -0
  61. package/src/utils/navigation.ts +44 -2
  62. package/src/utils/network-policy-navigation.test.ts +68 -0
  63. package/src/utils/topology-selection.test.ts +40 -0
  64. package/src/utils/topology-selection.ts +39 -0
  65. package/src/utils/wails-clipboard.test.ts +109 -0
  66. package/src/utils/wails-clipboard.ts +127 -0
@@ -0,0 +1,65 @@
1
+ import { useState } from 'react'
2
+ import { useLocation } from 'react-router-dom'
3
+ import { PolicyCoverageSection } from '@skyhook-io/k8s-ui/components/resources/renderers/PolicyCoverageSection'
4
+ import type { PolicyCoverageSubject, ResourceRef } from '@skyhook-io/k8s-ui'
5
+ import { usePolicyCoverage } from '../../../api/policy'
6
+
7
+ /** Mirrors maxPolicyCoverageSubjectsHard in internal/server/policy_handlers.go —
8
+ * asking for more than the server will send just returns the same list. */
9
+ const COVERAGE_MAX_SUBJECTS = 5000
10
+
11
+ /**
12
+ * Host wrapper for the policy coverage section — the inverse lookup that answers
13
+ * "which resources does this policy pass and fail".
14
+ *
15
+ * The package renderers take it as a slot rather than fetching it themselves, so
16
+ * a library consumer that does not wire this endpoint keeps the original policy
17
+ * drawer and nothing breaks.
18
+ */
19
+ export function KyvernoPolicyCoverage({
20
+ data,
21
+ onNavigate,
22
+ }: {
23
+ data: any
24
+ onNavigate?: (ref: ResourceRef) => void
25
+ }) {
26
+ const name = data?.metadata?.name ?? ''
27
+ // A namespaced Kyverno Policy reports as "namespace/name"; the server tries
28
+ // both shapes, so the namespace is passed through whenever the policy has one.
29
+ const namespace = data?.metadata?.namespace ?? ''
30
+ // The server bounds each rule's subject list so an ordinary drawer open stays
31
+ // small. Asking for the rest raises that bound once, up to the server's own
32
+ // ceiling — past which the response says what it could not send.
33
+ const [limit, setLimit] = useState<number | undefined>(undefined)
34
+
35
+ // The header's namespace filter is applied SERVER-side from session state, so
36
+ // the same URL returns a different body once it changes and nothing in the
37
+ // request distinguishes the two. Without it in the cache key, a policy opened
38
+ // under "All namespaces" keeps serving that body after the filter narrows —
39
+ // the view then shows other people's namespaces under your scope, and which
40
+ // behaviour you get depends on where you navigated from.
41
+ const viewFilter = useLocation().search
42
+ const query = usePolicyCoverage(name, namespace || undefined, !!name, limit, viewFilter)
43
+
44
+ return (
45
+ <PolicyCoverageSection
46
+ resource={data}
47
+ data={query.data ?? null}
48
+ loading={query.isLoading}
49
+ error={query.error as Error | null}
50
+ onLoadMore={limit ? undefined : () => setLimit(COVERAGE_MAX_SUBJECTS)}
51
+ loadingMore={query.isFetching}
52
+ onSelectSubject={
53
+ onNavigate
54
+ ? (subject: PolicyCoverageSubject) =>
55
+ onNavigate({
56
+ kind: subject.kind,
57
+ namespace: subject.namespace ?? '',
58
+ name: subject.name,
59
+ group: subject.group,
60
+ })
61
+ : undefined
62
+ }
63
+ />
64
+ )
65
+ }
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+
4
+ // The denial is the state under test, so the fetch is stubbed rather than run.
5
+ const forbidden = Object.assign(new Error('forbidden'), { status: 403 })
6
+ let queuedResult: { data?: unknown; error?: unknown } = {}
7
+
8
+ vi.mock('../../../api/policy', () => ({
9
+ usePolicyQueued: () => queuedResult,
10
+ }))
11
+ vi.mock('../../../api/client', () => ({
12
+ isForbiddenError: (e: any) => e?.status === 403,
13
+ }))
14
+
15
+ const { KyvernoPolicyQueued } = await import('./KyvernoPolicyQueued')
16
+
17
+ const generating = {
18
+ kind: 'ClusterPolicy',
19
+ metadata: { name: 'gen-companion' },
20
+ spec: { rules: [{ name: 'g', generate: {} }] },
21
+ }
22
+ const validating = {
23
+ kind: 'ClusterPolicy',
24
+ metadata: { name: 'require-labels' },
25
+ spec: { rules: [{ name: 'v', validate: {} }] },
26
+ }
27
+
28
+ /**
29
+ * A denial here is cluster-static, so disclosing it everywhere would be noise on
30
+ * the majority of policies that only validate. Staying silent about it on a
31
+ * policy that DOES queue is worse: a stuck backlog then looks exactly like a
32
+ * healthy policy with nothing pending, on the page someone checks to find out.
33
+ */
34
+ describe('KyvernoPolicyQueued — a denial you cannot see', () => {
35
+ it('says it cannot check when the policy actually queues work', () => {
36
+ queuedResult = { error: forbidden }
37
+ const html = renderToString(<KyvernoPolicyQueued data={generating} />)
38
+ expect(html).toContain('permission')
39
+ expect(html).toContain('Queued Work')
40
+ })
41
+
42
+ it('stays silent for a policy that never queues anything', () => {
43
+ queuedResult = { error: forbidden }
44
+ expect(renderToString(<KyvernoPolicyQueued data={validating} />)).toBe('')
45
+ })
46
+
47
+ it('stays silent when there is genuinely nothing queued', () => {
48
+ queuedResult = { data: { requests: 0 } }
49
+ expect(renderToString(<KyvernoPolicyQueued data={generating} />)).toBe('')
50
+ })
51
+
52
+ // A fault is not a denial: it is neither permanent nor beyond acting on, so it
53
+ // is loud regardless of what the policy does.
54
+ it('reports a genuine fault on any policy', () => {
55
+ queuedResult = { error: new Error('could not read queued work') }
56
+ const html = renderToString(<KyvernoPolicyQueued data={validating} />)
57
+ expect(html).toContain('could not read queued work')
58
+ })
59
+ })
@@ -0,0 +1,99 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { policyQueuesWork, queueBanner, requestBelongsTo } from './KyvernoPolicyQueued'
3
+
4
+ /**
5
+ * Kyverno permits a namespaced Policy and a ClusterPolicy to share a name, and
6
+ * records the first qualified and the second bare. Accepting the bare form as a
7
+ * fallback for a namespaced policy shows it someone else's backlog — the same
8
+ * collision the coverage lookup refuses, reintroduced here once already.
9
+ */
10
+ describe('requestBelongsTo', () => {
11
+ const req = (policy: string) => ({ spec: { policy } })
12
+
13
+ it('matches a cluster-scoped policy on the bare name only', () => {
14
+ expect(requestBelongsTo(req('require-labels'), 'require-labels', '')).toBe(true)
15
+ expect(requestBelongsTo(req('team-a/require-labels'), 'require-labels', '')).toBe(false)
16
+ })
17
+
18
+ it('matches a namespaced policy on the qualified name only', () => {
19
+ expect(requestBelongsTo(req('team-a/require-labels'), 'require-labels', 'team-a')).toBe(true)
20
+ // The bug: this is a ClusterPolicy's request and must not appear here.
21
+ expect(requestBelongsTo(req('require-labels'), 'require-labels', 'team-a')).toBe(false)
22
+ })
23
+
24
+ it('does not match another namespace', () => {
25
+ expect(requestBelongsTo(req('team-b/require-labels'), 'require-labels', 'team-a')).toBe(false)
26
+ })
27
+
28
+ it('matches nothing when either side is missing', () => {
29
+ expect(requestBelongsTo({}, 'require-labels', '')).toBe(false)
30
+ expect(requestBelongsTo(req('require-labels'), '', '')).toBe(false)
31
+ })
32
+ })
33
+
34
+ /**
35
+ * The banner is the only thing on this page that makes a claim rather than a
36
+ * count, so the claim has to be the one the numbers support.
37
+ */
38
+ describe('queueBanner', () => {
39
+ it('says work stopped only when something has actually sat still', () => {
40
+ const b = queueBanner(3, 12, '12m')
41
+ expect(b?.title).toBe('Queued work has not moved for 12m')
42
+ expect(b?.message).toContain('cannot keep up')
43
+ })
44
+
45
+ // The bug: this branch fires when NOTHING is older than the stall threshold,
46
+ // which is the case most likely to be a burst draining normally. The body has
47
+ // to agree with the headline — diagnosing a stalled controller underneath a
48
+ // headline that only reports a size puts the claim straight back.
49
+ it('does not tell you a moving backlog is not being processed', () => {
50
+ const b = queueBanner(30, 1, '1m')
51
+ expect(b?.title).toBe('30 requests are queued')
52
+ expect(b?.title).not.toContain('not being processed')
53
+ expect(b?.message).toContain('may be a burst still draining')
54
+ expect(b?.message).not.toContain('cannot keep up')
55
+ expect(b?.message).not.toContain('grows rather than drains')
56
+ })
57
+
58
+ it('stays quiet for a queue doing what a queue does', () => {
59
+ expect(queueBanner(3, 1, '1m')).toBeNull()
60
+ expect(queueBanner(0, 0, '')).toBeNull()
61
+ })
62
+
63
+ it('prefers the measured stall over the size when both apply', () => {
64
+ expect(queueBanner(40, 30, '30m')?.title).toContain('has not moved')
65
+ })
66
+ })
67
+
68
+ /**
69
+ * A denial is only worth disclosing where it can cost something. Silence on a
70
+ * policy that queues nothing keeps the page clean; silence on one that DOES
71
+ * queue hides a backlog behind a page that looks healthy.
72
+ */
73
+ describe('policyQueuesWork', () => {
74
+ it('is true for a policy that generates resources', () => {
75
+ expect(policyQueuesWork({ spec: { rules: [{ name: 'g', generate: {} }] } })).toBe(true)
76
+ expect(policyQueuesWork({ kind: 'GeneratingPolicy', spec: {} })).toBe(true)
77
+ })
78
+
79
+ it('is true for a policy that mutates resources which already exist', () => {
80
+ expect(policyQueuesWork({ spec: { rules: [{ mutate: { targets: [{ kind: 'Pod' }] } }] } })).toBe(true)
81
+ expect(policyQueuesWork({ spec: { evaluation: { mutateExisting: { enabled: true } } } })).toBe(true)
82
+ })
83
+
84
+ // The common case, and the reason a blanket note would be noise.
85
+ it('is false for a validate-only policy', () => {
86
+ expect(policyQueuesWork({ spec: { rules: [{ name: 'v', validate: {} }] } })).toBe(false)
87
+ expect(policyQueuesWork({ kind: 'ValidatingPolicy', spec: {} })).toBe(false)
88
+ })
89
+
90
+ // A mutate rule with no targets runs at admission only — nothing is queued.
91
+ it('is false for admission-time mutation', () => {
92
+ expect(policyQueuesWork({ spec: { rules: [{ mutate: { patchStrategicMerge: {} } }] } })).toBe(false)
93
+ })
94
+
95
+ it('handles a policy with no spec at all', () => {
96
+ expect(policyQueuesWork({})).toBe(false)
97
+ expect(policyQueuesWork(undefined)).toBe(false)
98
+ })
99
+ })
@@ -0,0 +1,184 @@
1
+ import { Workflow } from 'lucide-react'
2
+ import { clsx } from 'clsx'
3
+ import { Section, PropertyList, Property, AlertBanner } from '@skyhook-io/k8s-ui'
4
+ import { LookupFailureNote } from '@skyhook-io/k8s-ui/components/resources/renderers/LookupFailureNote'
5
+ import { HEALTH_BADGE_COLORS } from '@skyhook-io/k8s-ui/utils/badge-colors'
6
+ import { formatAge } from '@skyhook-io/k8s-ui/components/resources/resource-utils'
7
+ import { isForbiddenError } from '../../../api/client'
8
+ import { isKyvernoMutateExistingEnabled } from '@skyhook-io/k8s-ui/components/resources/resource-utils-kyverno-modern'
9
+ import { usePolicyQueued } from '../../../api/policy'
10
+
11
+ /** A request sitting this long is not mid-flight. Kyverno retries on every
12
+ * reconcile, so past this point a backlog grows rather than drains. */
13
+ const STALLED_MINUTES = 5
14
+
15
+ /** Enough at once to be worth raising even while it is still moving. */
16
+ const BACKLOG_SIZE = 25
17
+
18
+ /**
19
+ * The whole banner, or null when a queue is doing what a queue does. Headline
20
+ * and body are decided together — split across two places, the body went on
21
+ * diagnosing a stalled controller under a headline that had stopped claiming one.
22
+ *
23
+ * Only the first case has measured that the work stopped. The second says how
24
+ * much is waiting and nothing more: everything in it is younger than the stall
25
+ * threshold, so it is as likely to be a burst draining normally, and diagnosing
26
+ * a controller from that is a claim the evidence argues against.
27
+ */
28
+ export function queueBanner(
29
+ pending: number,
30
+ stalledMinutes: number,
31
+ oldestAge: string,
32
+ ): { title: string; message: string } | null {
33
+ if (stalledMinutes >= STALLED_MINUTES) {
34
+ return {
35
+ title: `Queued work has not moved for ${oldestAge}`,
36
+ message:
37
+ 'Requests build up when the background controller cannot keep up or cannot reach what it needs. They are retried on every reconcile, so a backlog grows rather than drains.',
38
+ }
39
+ }
40
+ if (pending >= BACKLOG_SIZE) {
41
+ return {
42
+ title: `${pending} requests are queued`,
43
+ message: `Nothing here has been waiting longer than ${STALLED_MINUTES} minutes, so this may be a burst still draining. Worth watching: if the count holds or the oldest keeps ageing, the controller is not keeping up.`,
44
+ }
45
+ }
46
+ return null
47
+ }
48
+
49
+ /**
50
+ * Whether this policy can queue background work at all.
51
+ *
52
+ * Decides whether a denial is worth mentioning. A validate-only policy never
53
+ * queues anything, so "you can't see the queue" there is noise on the majority
54
+ * of policy pages. A policy that generates, or mutates resources that already
55
+ * exist, does queue — and for those a denial hides a backlog that looks exactly
56
+ * like a healthy policy with nothing pending.
57
+ */
58
+ export function policyQueuesWork(data: any): boolean {
59
+ if (data?.kind === 'GeneratingPolicy') return true
60
+ if (isKyvernoMutateExistingEnabled(data)) return true
61
+ return (data?.spec?.rules ?? []).some(
62
+ (r: any) => !!r?.generate || Array.isArray(r?.mutate?.targets),
63
+ )
64
+ }
65
+
66
+ /**
67
+ * Whether a queued request belongs to this policy.
68
+ *
69
+ * Exactly one form matches, never both. A namespaced Policy is recorded as
70
+ * `namespace/name` and a cluster-scoped one bare, and Kyverno permits the two
71
+ * to share a name — so accepting the bare form as a fallback hands a namespaced
72
+ * policy the backlog of a ClusterPolicy it has nothing to do with. The coverage
73
+ * lookup refuses the same fallback for the same reason.
74
+ */
75
+ export function requestBelongsTo(request: any, name: string, namespace: string): boolean {
76
+ const policy = request?.spec?.policy
77
+ if (!policy || !name) return false
78
+ return policy === (namespace ? `${namespace}/${name}` : name)
79
+ }
80
+
81
+ /**
82
+ * What this policy has queued and not finished.
83
+ *
84
+ * A generate or mutate-existing rule does its work through queued requests, and
85
+ * the way that fails in practice is a pile-up rather than one bad request:
86
+ * upstream reports describe thousands stuck in Pending, never cleaned up, taken
87
+ * up again on every reconcile. That is a count, and a count belongs next to the
88
+ * policy — the requests have their own page, but nobody watches it.
89
+ *
90
+ * Silent when the policy has queued nothing, so it never adds an empty section
91
+ * to the majority of policies that only validate.
92
+ */
93
+ export function KyvernoPolicyQueued({ data }: { data: any }) {
94
+ const name = data?.metadata?.name ?? ''
95
+ const namespace = data?.metadata?.namespace ?? ''
96
+
97
+ // Server-side, because the answer's scope is not the subject's: Kyverno keeps
98
+ // these in its own namespace, and the generic resource list would have
99
+ // answered for whatever namespaces the reader happens to be viewing.
100
+ const { data: queued, error } = usePolicyQueued(name, namespace, !!name)
101
+
102
+ const total = queued?.requests ?? 0
103
+ if (total === 0) {
104
+ // An empty list and an unreadable one are not the same answer. A denial is
105
+ // cluster-static, so disclosing it on every policy page would be noise on the
106
+ // majority that only validate and never queue anything — but staying silent
107
+ // about it on a policy that DOES queue hides a backlog behind a page that
108
+ // looks like a healthy one with nothing pending. So it is disclosed exactly
109
+ // where it can cost something.
110
+ if (!error) return null
111
+ if (isForbiddenError(error) && !policyQueuesWork(data)) return null
112
+ return (
113
+ <Section title="Queued Work" icon={Workflow}>
114
+ <LookupFailureNote errors={[error]} what="what this policy has queued" />
115
+ </Section>
116
+ )
117
+ }
118
+
119
+ const byState = queued?.byState ?? {}
120
+ const pending = byState['Pending'] ?? 0
121
+ const failed = byState['Failed'] ?? 0
122
+ const messages = queued?.messages ?? []
123
+ const oldestPending = queued?.oldestPending
124
+
125
+ const stalledMinutes = oldestPending
126
+ ? Math.floor((Date.now() - new Date(oldestPending).getTime()) / 60000)
127
+ : 0
128
+ const banner = queueBanner(pending, stalledMinutes, oldestPending ? formatAge(oldestPending) : '')
129
+
130
+ return (
131
+ <>
132
+ {banner && (
133
+ <AlertBanner variant="warning" title={banner.title} message={banner.message} />
134
+ )}
135
+ <Section title="Queued Work" icon={Workflow} defaultExpanded={pending > 0 || failed > 0}>
136
+ <PropertyList>
137
+ <Property label="Requests" value={String(total)} />
138
+ {Object.entries(byState).map(([state, n]) => (
139
+ <Property
140
+ key={state}
141
+ label={state}
142
+ value={
143
+ <span
144
+ className={clsx(
145
+ 'badge',
146
+ HEALTH_BADGE_COLORS[
147
+ (state === 'Failed'
148
+ ? 'unhealthy'
149
+ : state === 'Pending'
150
+ ? 'degraded'
151
+ : state === 'Completed'
152
+ ? 'healthy'
153
+ : 'unknown') as keyof typeof HEALTH_BADGE_COLORS
154
+ ],
155
+ )}
156
+ >
157
+ {n}
158
+ </span>
159
+ }
160
+ />
161
+ ))}
162
+ {oldestPending && (
163
+ <Property label="Oldest Pending" value={`${formatAge(oldestPending)} ago`} />
164
+ )}
165
+ </PropertyList>
166
+ {messages.length > 0 && (
167
+ <div className="mt-2 pt-2 border-t border-theme-border space-y-1">
168
+ {/* Kyverno writes why it could not complete a request into
169
+ status.message, and it is the only diagnosis this object
170
+ carries. A count without it says something is wrong and leaves
171
+ you to go and find out what. */}
172
+ {messages.map((m, i) => (
173
+ <div key={i} className="text-xs text-warning-text">{m}</div>
174
+ ))}
175
+ </div>
176
+ )}
177
+ <div className="mt-2 pt-2 border-t border-theme-border text-xs text-theme-text-secondary">
178
+ These are deleted seconds after they complete, so this counts what is in flight right now
179
+ rather than everything this policy has ever done.
180
+ </div>
181
+ </Section>
182
+ </>
183
+ )
184
+ }
@@ -1 +1,24 @@
1
- export * from '@skyhook-io/k8s-ui/components/resources/renderers/RolloutRenderer'
1
+ import { RolloutRenderer as BaseRolloutRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/RolloutRenderer'
2
+ import { useRolloutAction, useRolloutCapabilities, type RolloutAction } from '../../../api/client'
3
+
4
+ interface RolloutRendererProps {
5
+ data: any
6
+ onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
7
+ }
8
+
9
+ export function RolloutRenderer({ data, onNavigate }: RolloutRendererProps) {
10
+ const namespace = data?.metadata?.namespace ?? ''
11
+ const name = data?.metadata?.name ?? ''
12
+ const { data: capabilities } = useRolloutCapabilities(namespace, name)
13
+ const action = useRolloutAction()
14
+
15
+ return (
16
+ <BaseRolloutRenderer
17
+ data={data}
18
+ onNavigate={onNavigate}
19
+ capabilities={capabilities}
20
+ onAction={(next: RolloutAction) => action.mutate({ action: next, namespace, name })}
21
+ pendingAction={action.isPending ? action.variables?.action ?? null : null}
22
+ />
23
+ )
24
+ }
@@ -1 +1,44 @@
1
- export * from '@skyhook-io/k8s-ui/components/resources/renderers/VeleroBSLRenderer'
1
+ import { VeleroBSLRenderer as BaseVeleroBSLRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/VeleroBSLRenderer'
2
+ import { LookupFailureNote } from '@skyhook-io/k8s-ui/components/resources/renderers/LookupFailureNote'
3
+ import type { ResourceRef } from '@skyhook-io/k8s-ui'
4
+ import { useVeleroStoredBackups } from '../../../api/policy'
5
+
6
+ /**
7
+ * Host wrapper adding the reverse lookup the package renderer cannot do: which
8
+ * Backups this storage location holds.
9
+ *
10
+ * Without it the page states a phase and stops. "Unavailable" is a fact about a
11
+ * bucket; what an operator came to find out is what it costs them, and that is
12
+ * the list of backups they cannot restore from until it recovers — every one of
13
+ * which Velero still reports as Completed.
14
+ */
15
+ export function VeleroBSLRenderer({
16
+ data,
17
+ onNavigate,
18
+ }: {
19
+ data: any
20
+ onNavigate?: (ref: ResourceRef) => void
21
+ }) {
22
+ const namespace = data?.metadata?.namespace ?? ''
23
+ const name = data?.metadata?.name ?? ''
24
+ const stored = useVeleroStoredBackups(namespace, name, !!namespace && !!name)
25
+
26
+ return (
27
+ <BaseVeleroBSLRenderer
28
+ data={data}
29
+ // Undefined while unresolved: an empty list would say this location holds
30
+ // nothing, which is a different answer from not having looked yet.
31
+ storedBackups={stored.isLoading || stored.error ? undefined : (stored.data?.backups ?? [])}
32
+ storedTotal={stored.data?.stored}
33
+ restorableTotal={stored.data?.restorable}
34
+ expiredTotal={stored.data?.expired}
35
+ listTruncated={stored.data?.truncated}
36
+ lookupNote={
37
+ stored.error ? (
38
+ <LookupFailureNote errors={[stored.error]} what="which backups are stored here" />
39
+ ) : undefined
40
+ }
41
+ onNavigate={onNavigate}
42
+ />
43
+ )
44
+ }
@@ -1 +1,75 @@
1
- export * from '@skyhook-io/k8s-ui/components/resources/renderers/VeleroBackupRenderer'
1
+ import { LookupFailureNote } from '@skyhook-io/k8s-ui/components/resources/renderers/LookupFailureNote'
2
+ import { VeleroBackupRenderer as BaseVeleroBackupRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/VeleroBackupRenderer'
3
+ import { resolveBackupStorageLocation } from '@skyhook-io/k8s-ui/components/resources/resource-utils-velero'
4
+ import type { ResourceRef } from '@skyhook-io/k8s-ui'
5
+ import { useResources } from '../../../api/client'
6
+ import { useVeleroRunMessages } from '../../../api/policy'
7
+
8
+ /**
9
+ * Host wrapper adding the two things a Backup cannot answer from its own status:
10
+ * whether the storage location holding it is reachable, and what the errors and
11
+ * warnings it counted actually say.
12
+ *
13
+ * The backup's own status says Completed and goes on saying it after the bucket
14
+ * behind it goes Unavailable. This is the page someone opens to decide whether
15
+ * they can restore to this point, so the answer belongs here and not one screen
16
+ * away.
17
+ *
18
+ * Namespace is explicit — storage locations live alongside the backups that name
19
+ * them, in Velero's own namespace. Omitting it would inherit the reader's
20
+ * namespace view filter, which is a browsing preference and not the scope of
21
+ * this question.
22
+ */
23
+ export function VeleroBackupRenderer({ data, onNavigate }: { data: any; onNavigate?: (ref: ResourceRef) => void }) {
24
+ const messages = useRunMessages('backups', data)
25
+ const namespace = data?.metadata?.namespace ?? ''
26
+
27
+ const locations = useResources<any>('backupstoragelocations', namespace, 'velero.io', {
28
+ enabled: !!namespace,
29
+ })
30
+
31
+ // Everything below is gated on the lookup having answered. A location we have
32
+ // not read is not a healthy one, and neither is a name we have not resolved:
33
+ // an unset spec.storageLocation means whichever location carries spec.default,
34
+ // so resolving it against a list that has not loaded produces the literal
35
+ // "default", which is wrong on any install that renamed it — and wrong
36
+ // permanently when the list errors rather than for a moment.
37
+ const answered = !locations.isLoading && !locations.error && locations.data !== undefined
38
+ const resolved = answered ? resolveBackupStorageLocation(data, locations.data) : undefined
39
+
40
+ const match = answered
41
+ ? (locations.data ?? []).find((l: any) => l?.metadata?.name === resolved)
42
+ : undefined
43
+ const phase = match?.status?.phase
44
+
45
+ // The location the backup names is gone. Velero restores from the location
46
+ // recorded on the backup, so this is not restorable — and it is invisible
47
+ // otherwise, because a location that does not exist has no phase to report.
48
+ const missing = answered && !!resolved && match === undefined
49
+
50
+ return (
51
+ <BaseVeleroBackupRenderer
52
+ data={data}
53
+ storageLocationPhase={phase}
54
+ storageLocationName={resolved}
55
+ storageLocationMissing={missing}
56
+ messages={messages}
57
+ onNavigate={onNavigate}
58
+ />
59
+ )
60
+ }
61
+
62
+ // The messages behind the counts. Nothing is fetched until the operator asks:
63
+ // reading them makes Velero create a DownloadRequest and pulls an object out of
64
+ // storage, which is not work to do on every drawer open.
65
+ function useRunMessages(kind: 'backups' | 'restores', data: any) {
66
+ const namespace = data?.metadata?.namespace ?? ''
67
+ const name = data?.metadata?.name ?? ''
68
+ const fetcher = useVeleroRunMessages(kind, namespace, name)
69
+ return {
70
+ messages: fetcher.data,
71
+ loading: fetcher.isPending,
72
+ lookupNote: fetcher.error ? <LookupFailureNote errors={[fetcher.error]} what="the messages behind these counts" /> : undefined,
73
+ onFetch: namespace && name ? () => fetcher.mutate() : undefined,
74
+ }
75
+ }
@@ -1 +1,35 @@
1
- export * from '@skyhook-io/k8s-ui/components/resources/renderers/VeleroRestoreRenderer'
1
+ import { LookupFailureNote } from '@skyhook-io/k8s-ui/components/resources/renderers/LookupFailureNote'
2
+ import { VeleroRestoreRenderer as BaseVeleroRestoreRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/VeleroRestoreRenderer'
3
+ import type { ResourceRef } from '@skyhook-io/k8s-ui'
4
+ import { useVeleroRunMessages } from '../../../api/policy'
5
+
6
+ /**
7
+ * Host wrapper adding the messages behind the run's error and warning counts.
8
+ *
9
+ * The counts are on the Restore; the text is not. It lives in a results file in
10
+ * object storage that only Velero's controller can hand out a link to — so the
11
+ * page showed a number an operator could worry about but not act on. A stuck
12
+ * restore is the more urgent of the two kinds, because someone is waiting on
13
+ * their data.
14
+ *
15
+ * Fetched on click, not on open: it creates a DownloadRequest and pulls an
16
+ * object out of storage. Same bargain as the network trace's probes.
17
+ */
18
+ export function VeleroRestoreRenderer({ data, onNavigate }: { data: any; onNavigate?: (ref: ResourceRef) => void }) {
19
+ const namespace = data?.metadata?.namespace ?? ''
20
+ const name = data?.metadata?.name ?? ''
21
+ const fetcher = useVeleroRunMessages('restores', namespace, name)
22
+
23
+ return (
24
+ <BaseVeleroRestoreRenderer
25
+ data={data}
26
+ onNavigate={onNavigate}
27
+ messages={{
28
+ messages: fetcher.data,
29
+ loading: fetcher.isPending,
30
+ lookupNote: fetcher.error ? <LookupFailureNote errors={[fetcher.error]} what="the messages behind these counts" /> : undefined,
31
+ onFetch: namespace && name ? () => fetcher.mutate() : undefined,
32
+ }}
33
+ />
34
+ )
35
+ }
@@ -11,6 +11,7 @@ export { CronWorkflowRenderer } from './CronWorkflowRenderer'
11
11
  export { HPARenderer } from './HPARenderer'
12
12
  export { NodeRenderer } from './NodeRenderer'
13
13
  export { PVCRenderer } from './PVCRenderer'
14
+ export { KyvernoPolicyCoverage } from './KyvernoPolicyCoverage'
14
15
  export { RolloutRenderer } from './RolloutRenderer'
15
16
  export { CertificateRenderer } from './CertificateRenderer'
16
17
  export { WorkflowRenderer } from './WorkflowRenderer'
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useLayoutEffect, useMemo, useState } from 'react'
2
2
  import { useNavigate, useSearchParams } from 'react-router-dom'
3
- import { AlertTriangle, DollarSign, ExternalLink, Gauge, Loader2, RefreshCw } from 'lucide-react'
3
+ import { AlertTriangle, Coins, ExternalLink, Gauge, Loader2, RefreshCw } from 'lucide-react'
4
4
  import {
5
5
  Collapse,
6
6
  CollapseChevron,
@@ -194,7 +194,7 @@ export function RightsizingScanView({ namespaces }: RightsizingScanViewProps) {
194
194
  <div className="flex-1 min-h-0 overflow-y-auto">
195
195
  <div className="mx-auto flex w-full max-w-[1920px] flex-col gap-4 px-6 py-6">
196
196
  <PageHeader
197
- icon={DollarSign}
197
+ icon={Coins}
198
198
  title="Cost Insights"
199
199
  description="Understand current allocation and find CPU and memory requests worth tuning."
200
200
  />