@skyhook-io/k8s-ui 1.15.0 → 1.16.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/package.json +3 -3
- package/src/components/audit/AuditFindingsTable.tsx +1 -1
- package/src/components/checks/ChecksView.tsx +8 -8
- package/src/components/gitops/GitOpsDetailLayout.tsx +3 -1
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +12 -2
- package/src/components/gitops/index.ts +1 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +4 -1
- package/src/components/issues/IssuesView.tsx +2 -2
- package/src/components/logs/LogCore.tsx +131 -97
- package/src/components/logs/StructuredLogLine.tsx +18 -28
- package/src/components/logs/log-palette.ts +4 -0
- package/src/components/logs/useLogBuffer.ts +8 -39
- package/src/components/logs/useLogSearch.ts +52 -24
- package/src/components/namespace-switcher/NamespacePicker.test.tsx +140 -0
- package/src/components/namespace-switcher/NamespacePicker.tsx +26 -20
- package/src/components/resources/renderers/PodRenderer.test.tsx +15 -0
- package/src/components/resources/renderers/PodRenderer.tsx +10 -2
- package/src/components/resources/renderers/PolicySection.test.tsx +42 -0
- package/src/components/resources/renderers/PolicySection.tsx +10 -1
- package/src/components/shared/DrainPlanDialog.test.tsx +10 -0
- package/src/components/shared/DrainPlanDialog.tsx +14 -3
- package/src/components/shared/ResourceActionsBar.tsx +14 -9
- package/src/components/ui/FilterPill.tsx +1 -1
- package/src/components/ui/RadarUpgradeNote.click.test.tsx +24 -0
- package/src/components/ui/RadarUpgradeNote.test.tsx +66 -0
- package/src/components/ui/RadarUpgradeNote.tsx +129 -0
- package/src/components/ui/ResourceBar.tsx +12 -6
- package/src/components/ui/SelectMenu.tsx +62 -19
- package/src/components/ui/index.ts +8 -0
- package/src/components/workload/WorkloadView.tsx +109 -3
- package/src/types/core.ts +25 -4
- package/src/types/fetch-error.ts +33 -0
- package/src/utils/log-format.ts +1 -1
- package/src/utils/log-level.test.ts +269 -0
- package/src/utils/log-level.ts +301 -0
- package/src/utils/resource-hierarchy.test.ts +69 -0
- package/src/utils/resource-hierarchy.ts +87 -52
|
@@ -23,6 +23,8 @@ import type {
|
|
|
23
23
|
RBACPolicyRule,
|
|
24
24
|
} from '../../../types'
|
|
25
25
|
import { Tooltip } from '../../ui/Tooltip'
|
|
26
|
+
import { RadarUpgradeNote } from '../../ui/RadarUpgradeNote'
|
|
27
|
+
import { getRadarUpgradeRequirement, type RadarUpgradeRequirement } from '../../../types/fetch-error'
|
|
26
28
|
import { MetricsChart } from '../../ui/MetricsChart'
|
|
27
29
|
import { MetricsUnavailableNotice } from './MetricsUnavailableNotice'
|
|
28
30
|
import { ContainerEnvironmentSection } from './ContainerEnvironmentSection'
|
|
@@ -191,10 +193,12 @@ function EnvVarsSection({
|
|
|
191
193
|
initContainers,
|
|
192
194
|
containers,
|
|
193
195
|
resolvedEnvFrom,
|
|
196
|
+
upgrade,
|
|
194
197
|
}: {
|
|
195
198
|
initContainers: any[]
|
|
196
199
|
containers: any[]
|
|
197
200
|
resolvedEnvFrom?: ResolvedEnvFrom
|
|
201
|
+
upgrade?: RadarUpgradeRequirement | null
|
|
198
202
|
}) {
|
|
199
203
|
const allContainers = [...initContainers, ...containers]
|
|
200
204
|
const containersWithEnv = allContainers.filter((c: any) => c.env?.length > 0 || c.envFrom?.length > 0)
|
|
@@ -205,6 +209,7 @@ function EnvVarsSection({
|
|
|
205
209
|
return (
|
|
206
210
|
<Section title={`Environment Variables · ${subtitle}`} icon={List} defaultExpanded={false}>
|
|
207
211
|
<div className="space-y-4">
|
|
212
|
+
{upgrade && <RadarUpgradeNote requirement={upgrade} />}
|
|
208
213
|
{allContainers.map((container: any) => {
|
|
209
214
|
const envVars: any[] = container.env || []
|
|
210
215
|
const envFrom: any[] = container.envFrom || []
|
|
@@ -312,6 +317,8 @@ export function PodRenderer({
|
|
|
312
317
|
const hasResolvedEnvironmentRows = environment?.containers.some(
|
|
313
318
|
container => container.rows.length > 0 || container.truncated,
|
|
314
319
|
) ?? false
|
|
320
|
+
// An older Radar can't resolve variable sources; fall back to the spec view.
|
|
321
|
+
const environmentUpgrade = getRadarUpgradeRequirement(environmentError)
|
|
315
322
|
|
|
316
323
|
const namespace = data.metadata?.namespace
|
|
317
324
|
const podName = data.metadata?.name
|
|
@@ -792,7 +799,7 @@ export function PodRenderer({
|
|
|
792
799
|
<div className="text-xs text-theme-text-tertiary">Loading variable sources…</div>
|
|
793
800
|
</Section>
|
|
794
801
|
)}
|
|
795
|
-
{hasEnvironmentDeclarations && environmentError && !environment && (
|
|
802
|
+
{hasEnvironmentDeclarations && environmentError && !environment && !environmentUpgrade && (
|
|
796
803
|
<Section title="Environment Variables" icon={List} defaultExpanded={false}>
|
|
797
804
|
<div className="space-y-2 text-xs">
|
|
798
805
|
<p className="text-theme-text-secondary">Variable sources could not be loaded.</p>
|
|
@@ -800,11 +807,12 @@ export function PodRenderer({
|
|
|
800
807
|
</div>
|
|
801
808
|
</Section>
|
|
802
809
|
)}
|
|
803
|
-
{(!environment || !hasResolvedEnvironmentRows) && !environmentLoading && !environmentError && hasEnvironmentDeclarations && (
|
|
810
|
+
{(!environment || !hasResolvedEnvironmentRows) && !environmentLoading && (!environmentError || environmentUpgrade) && hasEnvironmentDeclarations && (
|
|
804
811
|
<EnvVarsSection
|
|
805
812
|
initContainers={initContainers}
|
|
806
813
|
containers={containers}
|
|
807
814
|
resolvedEnvFrom={resolvedEnvFrom}
|
|
815
|
+
upgrade={environmentUpgrade}
|
|
808
816
|
/>
|
|
809
817
|
)}
|
|
810
818
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import { renderToString } from 'react-dom/server'
|
|
3
3
|
import { PolicySection } from './PolicySection'
|
|
4
|
+
import { RadarUpgradeContext } from '../../ui/RadarUpgradeNote'
|
|
4
5
|
import type { PolicyResourceResponse } from '../../../types/policy'
|
|
5
6
|
|
|
6
7
|
const base: PolicyResourceResponse = {
|
|
@@ -157,3 +158,44 @@ describe('when results were withheld from this caller', () => {
|
|
|
157
158
|
expect(html).not.toContain('not shown here')
|
|
158
159
|
})
|
|
159
160
|
})
|
|
161
|
+
|
|
162
|
+
// An older in-cluster Radar behind a newer frontend (Radar Hub) has no policy
|
|
163
|
+
// endpoint. That is an expected state, so it gets an upgrade prompt, not red.
|
|
164
|
+
describe('PolicySection — connected Radar predates policy results', () => {
|
|
165
|
+
const unsupported = Object.assign(new Error('Policy results need Radar v1.10.0 or newer'), {
|
|
166
|
+
radarUpgrade: { feature: 'Policy results', minimumVersion: 'v1.10.0', currentVersion: 'v1.7.2' },
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('prompts an upgrade instead of reporting a failure', () => {
|
|
170
|
+
const html = renderToString(<PolicySection data={null} error={unsupported} />)
|
|
171
|
+
expect(html).toContain('Policy results')
|
|
172
|
+
expect(html).toContain('need Radar v1.10 or newer on this cluster. It runs v1.7.2.')
|
|
173
|
+
expect(html).not.toContain('Could not load policy results')
|
|
174
|
+
expect(html).not.toContain('text-red-400')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('hands the upgrade to the host when it offers a flow', () => {
|
|
178
|
+
const html = renderToString(
|
|
179
|
+
<RadarUpgradeContext.Provider value={{ onRequestUpgrade: () => undefined }}>
|
|
180
|
+
<PolicySection data={null} error={unsupported} />
|
|
181
|
+
</RadarUpgradeContext.Provider>,
|
|
182
|
+
)
|
|
183
|
+
expect(html).toContain('<button')
|
|
184
|
+
expect(html).toContain('Upgrade Radar')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('links to instructions when the host has no flow', () => {
|
|
188
|
+
const html = renderToString(
|
|
189
|
+
<RadarUpgradeContext.Provider value={{ upgradeHref: 'https://radarhq.io/docs/configuration/in-cluster' }}>
|
|
190
|
+
<PolicySection data={null} error={unsupported} />
|
|
191
|
+
</RadarUpgradeContext.Provider>,
|
|
192
|
+
)
|
|
193
|
+
expect(html).toContain('href="https://radarhq.io/docs/configuration/in-cluster"')
|
|
194
|
+
expect(html).toContain('How to upgrade')
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('keeps a genuine failure red', () => {
|
|
198
|
+
const html = renderToString(<PolicySection data={null} error={new Error('HTTP 502 (Bad Gateway)')} />)
|
|
199
|
+
expect(html).toContain('Could not load policy results: HTTP 502 (Bad Gateway)')
|
|
200
|
+
})
|
|
201
|
+
})
|
|
@@ -2,7 +2,8 @@ import { ShieldCheck, ShieldAlert, ShieldQuestion } from 'lucide-react'
|
|
|
2
2
|
import { clsx } from 'clsx'
|
|
3
3
|
import { Section } from '../../ui/drawer-components'
|
|
4
4
|
import { SEVERITY_BADGE } from '../../../utils/badge-colors'
|
|
5
|
-
import {
|
|
5
|
+
import { RadarUpgradeNote } from '../../ui/RadarUpgradeNote'
|
|
6
|
+
import { getRadarUpgradeRequirement, isForbiddenError } from '../../../types/fetch-error'
|
|
6
7
|
import type { PolicyResourceResponse, PolicyResourceFinding } from '../../../types/policy'
|
|
7
8
|
|
|
8
9
|
interface PolicySectionProps {
|
|
@@ -61,6 +62,14 @@ export function PolicySection({ data, loading, error }: PolicySectionProps) {
|
|
|
61
62
|
</Section>
|
|
62
63
|
)
|
|
63
64
|
}
|
|
65
|
+
const upgrade = getRadarUpgradeRequirement(error)
|
|
66
|
+
if (upgrade) {
|
|
67
|
+
return (
|
|
68
|
+
<Section title={TITLE} icon={ShieldQuestion}>
|
|
69
|
+
<RadarUpgradeNote requirement={upgrade} />
|
|
70
|
+
</Section>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
64
73
|
return (
|
|
65
74
|
<Section title={TITLE} icon={ShieldAlert}>
|
|
66
75
|
<div className="text-sm text-red-400">{`Could not load policy results: ${error.message}`}</div>
|
|
@@ -88,6 +88,16 @@ describe('DrainPlanContent', () => {
|
|
|
88
88
|
)
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
it('says a drain plan needs a newer Radar when the cluster predates it', () => {
|
|
92
|
+
const html = render({
|
|
93
|
+
planSupported: false,
|
|
94
|
+
planUpgrade: { feature: 'Drain plans', minimumVersion: 'v1.14.0', currentVersion: 'v1.13.1' },
|
|
95
|
+
})
|
|
96
|
+
expect(html).toContain('Drain plans')
|
|
97
|
+
expect(html).toContain('need Radar v1.14 or newer on this cluster.')
|
|
98
|
+
expect(html).not.toContain('Computing the plan')
|
|
99
|
+
})
|
|
100
|
+
|
|
91
101
|
it('shows per-pod outcomes with reasons and calls the result an estimate', () => {
|
|
92
102
|
const html = render({ plan: plan([pod('web', 'may-block', { pdb: 'shop/web', reason: 'PodDisruptionBudget shop/web currently allows no disruptions' }), pod('agent', 'skip')]) })
|
|
93
103
|
expect(html).toContain('may block')
|
|
@@ -3,6 +3,8 @@ import { ConfirmDialog } from '../ui/ConfirmDialog'
|
|
|
3
3
|
import { Badge, type BadgeSeverity } from '../ui/Badge'
|
|
4
4
|
import { AlertBanner } from '../ui/drawer-components'
|
|
5
5
|
import { Tooltip } from '../ui/Tooltip'
|
|
6
|
+
import { RadarUpgradeNote } from '../ui/RadarUpgradeNote'
|
|
7
|
+
import type { RadarUpgradeRequirement } from '../../types/fetch-error'
|
|
6
8
|
import { pluralize } from '../../utils/pluralize'
|
|
7
9
|
|
|
8
10
|
// Shapes returned by POST /api/nodes/{name}/drain-plan.
|
|
@@ -102,6 +104,8 @@ interface DrainPlanContentProps {
|
|
|
102
104
|
options: DrainDialogOptions
|
|
103
105
|
onOptionsChange: (options: DrainDialogOptions) => void
|
|
104
106
|
planSupported: boolean
|
|
107
|
+
/** Why there is no plan, when the connected Radar predates drain plans. */
|
|
108
|
+
planUpgrade?: RadarUpgradeRequirement | null
|
|
105
109
|
/** Recompute the plan with the current options; also the retry after a failed plan. */
|
|
106
110
|
onRefreshPlan?: () => void
|
|
107
111
|
}
|
|
@@ -112,7 +116,7 @@ interface DrainPlanContentProps {
|
|
|
112
116
|
* react-dom/server); the enclosing dialog owns the confirm gating.
|
|
113
117
|
*/
|
|
114
118
|
export function DrainPlanContent({
|
|
115
|
-
nodeName, plan, loading, error, options, onOptionsChange, planSupported, onRefreshPlan,
|
|
119
|
+
nodeName, plan, loading, error, options, onOptionsChange, planSupported, planUpgrade, onRefreshPlan,
|
|
116
120
|
}: DrainPlanContentProps) {
|
|
117
121
|
const current = planMatches(plan, nodeName, options) ? plan : null
|
|
118
122
|
const atRisk = current ? emptyDirPodsAtRisk(current) : []
|
|
@@ -140,6 +144,8 @@ export function DrainPlanContent({
|
|
|
140
144
|
</label>
|
|
141
145
|
</div>
|
|
142
146
|
|
|
147
|
+
{!planSupported && planUpgrade && <RadarUpgradeNote requirement={planUpgrade} />}
|
|
148
|
+
|
|
143
149
|
{planSupported && loading && (
|
|
144
150
|
<div className="flex items-center gap-2 text-theme-text-secondary">
|
|
145
151
|
<Loader2 className="w-4 h-4 animate-spin" /> Computing the plan…
|
|
@@ -259,12 +265,14 @@ interface DrainPlanDialogProps {
|
|
|
259
265
|
isDraining: boolean
|
|
260
266
|
/** Whether the host can compute plans. Without it the dialog confirms without one. */
|
|
261
267
|
planSupported: boolean
|
|
268
|
+
/** Why there is no plan, when the connected Radar predates drain plans. */
|
|
269
|
+
planUpgrade?: RadarUpgradeRequirement | null
|
|
262
270
|
/** Recompute the plan with the current options; also the retry after a failed plan. */
|
|
263
271
|
onRefreshPlan?: () => void
|
|
264
272
|
}
|
|
265
273
|
|
|
266
274
|
export function DrainPlanDialog({
|
|
267
|
-
open, nodeName, plan, loading, error, options, onOptionsChange, onConfirm, onClose, isDraining, planSupported, onRefreshPlan,
|
|
275
|
+
open, nodeName, plan, loading, error, options, onOptionsChange, onConfirm, onClose, isDraining, planSupported, planUpgrade, onRefreshPlan,
|
|
268
276
|
}: DrainPlanDialogProps) {
|
|
269
277
|
const confirmEnabled = canConfirmDrain({ plan, nodeName, options, loading, error, planSupported })
|
|
270
278
|
|
|
@@ -274,7 +282,9 @@ export function DrainPlanDialog({
|
|
|
274
282
|
onClose={onClose}
|
|
275
283
|
onConfirm={() => onConfirm(options)}
|
|
276
284
|
title="Drain Node"
|
|
277
|
-
message={
|
|
285
|
+
message={planSupported
|
|
286
|
+
? `Cordon "${nodeName}" and evict the pods listed below. The list is an estimate: the drain re-lists live state when it runs.`
|
|
287
|
+
: `Cordon "${nodeName}" and evict its pods.`}
|
|
278
288
|
confirmLabel={isDraining ? 'Draining...' : 'Drain'}
|
|
279
289
|
variant="danger"
|
|
280
290
|
isLoading={isDraining}
|
|
@@ -290,6 +300,7 @@ export function DrainPlanDialog({
|
|
|
290
300
|
options={options}
|
|
291
301
|
onOptionsChange={onOptionsChange}
|
|
292
302
|
planSupported={planSupported}
|
|
303
|
+
planUpgrade={planUpgrade}
|
|
293
304
|
onRefreshPlan={onRefreshPlan}
|
|
294
305
|
/>
|
|
295
306
|
</ConfirmDialog>
|
|
@@ -25,6 +25,7 @@ import { ConfirmDialog } from '../ui/ConfirmDialog'
|
|
|
25
25
|
import { AlertBanner } from '../ui/drawer-components'
|
|
26
26
|
import { DialogPortal } from '../ui/DialogPortal'
|
|
27
27
|
import type { SelectedResource, WorkloadRevision } from '../../types'
|
|
28
|
+
import type { RadarUpgradeRequirement } from '../../types/fetch-error'
|
|
28
29
|
import { displayKindName } from '../ui/drawer-components'
|
|
29
30
|
import { getDefaultContainerName } from '../resources/resource-utils'
|
|
30
31
|
import { SetImageDialog, type ManagedImageSource } from './SetImageDialog'
|
|
@@ -143,10 +144,12 @@ interface ResourceActionsBarProps {
|
|
|
143
144
|
drainPlan?: DrainPlan | null
|
|
144
145
|
isPlanningDrain?: boolean
|
|
145
146
|
drainPlanError?: string | null
|
|
146
|
-
// The connected
|
|
147
|
-
//
|
|
148
|
-
// acknowledgement-only mode instead of keeping Drain disabled on a dead
|
|
149
|
-
|
|
147
|
+
// The connected Radar predates the drain-plan endpoint (a newer frontend
|
|
148
|
+
// against an older Radar). The dialog falls back to the plan-less
|
|
149
|
+
// acknowledgement-only mode instead of keeping Drain disabled on a dead
|
|
150
|
+
// request, and names the Radar version that serves plans so the user knows a
|
|
151
|
+
// preview exists.
|
|
152
|
+
drainPlanUpgrade?: RadarUpgradeRequirement | null
|
|
150
153
|
}
|
|
151
154
|
|
|
152
155
|
export function ResourceActionsBar({
|
|
@@ -177,7 +180,7 @@ export function ResourceActionsBar({
|
|
|
177
180
|
onCordonNode, isCordoningNode,
|
|
178
181
|
onUncordonNode, isUncordoningNode,
|
|
179
182
|
onDrainNode, isDrainingNode,
|
|
180
|
-
onPlanDrain, onPlanDrainReset, drainPlan, isPlanningDrain, drainPlanError,
|
|
183
|
+
onPlanDrain, onPlanDrainReset, drainPlan, isPlanningDrain, drainPlanError, drainPlanUpgrade,
|
|
181
184
|
}: ResourceActionsBarProps) {
|
|
182
185
|
const kind = resource.kind.toLowerCase()
|
|
183
186
|
const coreBatchJob = isCoreBatchJob(kind, resource.group)
|
|
@@ -202,13 +205,14 @@ export function ResourceActionsBar({
|
|
|
202
205
|
// open: the host reports it through a mutation error, which the next request clears,
|
|
203
206
|
// so without latching every option change would refire a request known to 404 and
|
|
204
207
|
// bounce the dialog out of its fallback mode.
|
|
205
|
-
const [
|
|
208
|
+
const [planUpgrade, setPlanUpgrade] = useState<RadarUpgradeRequirement | null>(null)
|
|
206
209
|
useEffect(() => {
|
|
207
|
-
if (
|
|
208
|
-
}, [
|
|
210
|
+
if (drainPlanUpgrade) setPlanUpgrade(drainPlanUpgrade)
|
|
211
|
+
}, [drainPlanUpgrade])
|
|
209
212
|
useEffect(() => {
|
|
210
|
-
if (!showDrainConfirm)
|
|
213
|
+
if (!showDrainConfirm) setPlanUpgrade(null)
|
|
211
214
|
}, [showDrainConfirm])
|
|
215
|
+
const planUnsupported = planUpgrade !== null
|
|
212
216
|
const planSupported = Boolean(onPlanDrain) && !planUnsupported
|
|
213
217
|
|
|
214
218
|
// Fetch (and refetch on option changes) the read-only plan while the drain dialog is open.
|
|
@@ -719,6 +723,7 @@ export function ResourceActionsBar({
|
|
|
719
723
|
options={drainOptions}
|
|
720
724
|
onOptionsChange={setDrainOptions}
|
|
721
725
|
planSupported={planSupported}
|
|
726
|
+
planUpgrade={planUpgrade}
|
|
722
727
|
onRefreshPlan={planSupported ? () => onPlanDrain?.({ name: resource.name, options: drainOptions }) : undefined}
|
|
723
728
|
isDraining={Boolean(isDrainingNode)}
|
|
724
729
|
onClose={() => {
|
|
@@ -52,7 +52,7 @@ const TONE_ACTIVE: Record<FilterPillTone, string> = {
|
|
|
52
52
|
high: 'bg-orange-500/15 border-orange-500/40 text-orange-800 dark:text-orange-300',
|
|
53
53
|
medium: 'bg-yellow-500/15 border-yellow-500/40 text-yellow-800 dark:text-yellow-300',
|
|
54
54
|
ok: 'bg-emerald-500/15 border-emerald-500/40 text-emerald-700 dark:text-emerald-300',
|
|
55
|
-
brand: 'bg-[var(--color-brand-50)] border-[var(--color-radar-accent)] text-theme-text-primary dark:bg-[var(--color-brand-950)]',
|
|
55
|
+
brand: 'bg-[var(--color-brand-50,var(--accent-muted))] border-[var(--color-radar-accent,var(--accent))] text-theme-text-primary dark:bg-[var(--color-brand-950,var(--accent-muted))]',
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
const INACTIVE = 'border-theme-border-light text-theme-text-secondary hover:border-theme-border hover:text-theme-text-primary hover:bg-theme-hover/50'
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot } from 'react-dom/client'
|
|
4
|
+
import { expect, it, vi } from 'vitest'
|
|
5
|
+
import { RadarUpgradeContext, RadarUpgradeNote } from './RadarUpgradeNote'
|
|
6
|
+
|
|
7
|
+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
|
8
|
+
|
|
9
|
+
it('hands the whole requirement to the host when the user asks to upgrade', async () => {
|
|
10
|
+
const onRequestUpgrade = vi.fn()
|
|
11
|
+
const requirement = { feature: 'Policy results', minimumVersion: 'v1.10.0', currentVersion: 'v1.7.2', latestVersion: 'v1.16.0' }
|
|
12
|
+
const element = document.createElement('div')
|
|
13
|
+
const root = createRoot(element)
|
|
14
|
+
await act(async () => {
|
|
15
|
+
root.render(
|
|
16
|
+
<RadarUpgradeContext.Provider value={{ onRequestUpgrade }}>
|
|
17
|
+
<RadarUpgradeNote requirement={requirement} />
|
|
18
|
+
</RadarUpgradeContext.Provider>,
|
|
19
|
+
)
|
|
20
|
+
})
|
|
21
|
+
await act(async () => { element.querySelector('button')!.click() })
|
|
22
|
+
expect(onRequestUpgrade).toHaveBeenCalledWith(requirement)
|
|
23
|
+
await act(async () => root.unmount())
|
|
24
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { formatRadarVersion, radarUpgradeDetail, radarUpgradeHeadline, RadarUpgradeNote } from './RadarUpgradeNote'
|
|
4
|
+
import { getRadarUpgradeRequirement } from '../../types/fetch-error'
|
|
5
|
+
|
|
6
|
+
describe('radarUpgradeHeadline', () => {
|
|
7
|
+
it('says the cluster is behind, not the UI', () => {
|
|
8
|
+
expect(radarUpgradeHeadline('Capacity')).toBe('Capacity needs a newer Radar on this cluster')
|
|
9
|
+
})
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
describe('radarUpgradeDetail', () => {
|
|
13
|
+
it('quotes the minimum and a real current version', () => {
|
|
14
|
+
expect(radarUpgradeDetail({ feature: 'Policy results', minimumVersion: 'v1.10.0', currentVersion: '1.7.2' }))
|
|
15
|
+
.toBe('Available from Radar v1.10. This cluster runs v1.7.2.')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('leaves out a current version that is not a version', () => {
|
|
19
|
+
expect(radarUpgradeDetail({ feature: 'Policy results', minimumVersion: 'v1.10.0', currentVersion: 'dev' }))
|
|
20
|
+
.toBe('Available from Radar v1.10.')
|
|
21
|
+
expect(radarUpgradeDetail({ feature: 'Policy results', minimumVersion: 'v1.10.0' }))
|
|
22
|
+
.toBe('Available from Radar v1.10.')
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('radarUpgradeDetail without a known first release', () => {
|
|
27
|
+
const unknownSince = { feature: 'Drift alerts', currentVersion: 'v1.7.2' }
|
|
28
|
+
|
|
29
|
+
it('asks for the latest release when it is newer than the running one', () => {
|
|
30
|
+
expect(radarUpgradeDetail({ ...unknownSince, latestVersion: '1.16.0' }))
|
|
31
|
+
.toBe('Available in the latest Radar, v1.16. This cluster runs v1.7.2.')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('only names the running version when no target release is known', () => {
|
|
35
|
+
expect(radarUpgradeDetail(unknownSince)).toBe('This cluster runs v1.7.2.')
|
|
36
|
+
expect(radarUpgradeDetail({ ...unknownSince, currentVersion: 'v1.16.0', latestVersion: 'v1.16.0' }))
|
|
37
|
+
.toBe('This cluster runs v1.16.')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('reads the same way inline', () => {
|
|
41
|
+
const html = renderToString(<RadarUpgradeNote requirement={{ ...unknownSince, latestVersion: 'v1.16.0' }} />)
|
|
42
|
+
expect(html).toContain('need the latest Radar (v1.16) on this cluster. It runs v1.7.2.')
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('formatRadarVersion', () => {
|
|
47
|
+
it('drops a zero patch and adds the v', () => {
|
|
48
|
+
expect(formatRadarVersion('v1.14.0')).toBe('v1.14')
|
|
49
|
+
expect(formatRadarVersion('1.7.2')).toBe('v1.7.2')
|
|
50
|
+
expect(formatRadarVersion('v1.7.2-dirty')).toBe('v1.7.2-dirty')
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('getRadarUpgradeRequirement', () => {
|
|
55
|
+
it('reads the requirement off any error that carries one', () => {
|
|
56
|
+
const error = Object.assign(new Error('x'), { radarUpgrade: { feature: 'Drain plans', minimumVersion: 'v1.14.0' } })
|
|
57
|
+
expect(getRadarUpgradeRequirement(error)).toEqual({ feature: 'Drain plans', minimumVersion: 'v1.14.0' })
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('ignores plain errors and malformed requirements', () => {
|
|
61
|
+
expect(getRadarUpgradeRequirement(new Error('HTTP 404 (Not Found)'))).toBeNull()
|
|
62
|
+
expect(getRadarUpgradeRequirement({ radarUpgrade: { minimumVersion: 'v1.9.0' } })).toBeNull()
|
|
63
|
+
expect(getRadarUpgradeRequirement({ radarUpgrade: { feature: 'x', minimumVersion: 9 } })).toBeNull()
|
|
64
|
+
expect(getRadarUpgradeRequirement(null)).toBeNull()
|
|
65
|
+
})
|
|
66
|
+
})
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react'
|
|
2
|
+
import { clsx } from 'clsx'
|
|
3
|
+
import { ArrowUpCircle, ExternalLink } from 'lucide-react'
|
|
4
|
+
import type { RadarUpgradeRequirement } from '../../types/fetch-error'
|
|
5
|
+
|
|
6
|
+
export interface RadarUpgradeActions {
|
|
7
|
+
/** Hands the upgrade to the host, e.g. Radar Cloud's role-aware upgrade flow. */
|
|
8
|
+
onRequestUpgrade?: (requirement: RadarUpgradeRequirement) => void
|
|
9
|
+
/** Used when there is no handler: a page with upgrade instructions. */
|
|
10
|
+
upgradeHref?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const RadarUpgradeContext = createContext<RadarUpgradeActions>({})
|
|
14
|
+
|
|
15
|
+
/** "v1.10.0" → "v1.10", "1.7.2" → "v1.7.2". Anything else is shown as given. */
|
|
16
|
+
export function formatRadarVersion(version: string): string {
|
|
17
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(version.trim())
|
|
18
|
+
if (!match) return version
|
|
19
|
+
const [, major, minor, patch] = match
|
|
20
|
+
return patch === '0' ? `v${major}.${minor}` : `v${major}.${minor}.${patch}`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseVersion(version: string | undefined): number[] | null {
|
|
24
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(version?.trim() ?? '')
|
|
25
|
+
return match ? match.slice(1).map(Number) : null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isNewer(a: number[], b: number[]): boolean {
|
|
29
|
+
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i]
|
|
30
|
+
return false
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Which Radar to ask for. The release that introduced the feature when it is
|
|
35
|
+
* known; otherwise the latest release, which serves everything this UI calls;
|
|
36
|
+
* otherwise just "a newer Radar".
|
|
37
|
+
*/
|
|
38
|
+
function target(requirement: RadarUpgradeRequirement): { since?: string; latest?: string } {
|
|
39
|
+
if (requirement.minimumVersion) return { since: formatRadarVersion(requirement.minimumVersion) }
|
|
40
|
+
const { latestVersion } = requirement
|
|
41
|
+
const latest = parseVersion(latestVersion)
|
|
42
|
+
const current = parseVersion(requirement.currentVersion)
|
|
43
|
+
if (latestVersion && latest && (!current || isNewer(latest, current))) {
|
|
44
|
+
return { latest: formatRadarVersion(latestVersion) }
|
|
45
|
+
}
|
|
46
|
+
return {}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The Radar that is behind is the cluster's, not the UI the user is looking
|
|
50
|
+
// at, so the copy names the cluster. "It runs dev." says nothing useful, so
|
|
51
|
+
// only a real version is quoted.
|
|
52
|
+
function clusterVersion(requirement: RadarUpgradeRequirement): string | undefined {
|
|
53
|
+
const current = requirement.currentVersion?.trim()
|
|
54
|
+
return current && /^v?\d+\.\d+/.test(current) ? formatRadarVersion(current) : undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Headline for a full-page state: "Capacity needs a newer Radar on this cluster". */
|
|
58
|
+
export function radarUpgradeHeadline(feature: string): string {
|
|
59
|
+
return `${feature} needs a newer Radar on this cluster`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Detail under that headline: "Available from Radar v1.9. This cluster runs v1.7.2." */
|
|
63
|
+
export function radarUpgradeDetail(requirement: RadarUpgradeRequirement): string {
|
|
64
|
+
const { since, latest } = target(requirement)
|
|
65
|
+
const current = clusterVersion(requirement)
|
|
66
|
+
return [
|
|
67
|
+
since ? `Available from Radar ${since}.` : latest ? `Available in the latest Radar, ${latest}.` : '',
|
|
68
|
+
current ? `This cluster runs ${current}.` : '',
|
|
69
|
+
].filter(Boolean).join(' ')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Follows the feature name in an inline note: "need Radar v1.10 or newer on this cluster. It runs v1.7.2." */
|
|
73
|
+
function radarUpgradeNeed(requirement: RadarUpgradeRequirement): string {
|
|
74
|
+
const { since, latest } = target(requirement)
|
|
75
|
+
const current = clusterVersion(requirement)
|
|
76
|
+
const need = since
|
|
77
|
+
? `need Radar ${since} or newer on this cluster.`
|
|
78
|
+
: latest
|
|
79
|
+
? `need the latest Radar (${latest}) on this cluster.`
|
|
80
|
+
: 'need a newer Radar on this cluster.'
|
|
81
|
+
return current ? `${need} It runs ${current}.` : need
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The upgrade call to action, or nothing when the host offers no way to upgrade. */
|
|
85
|
+
export function RadarUpgradeAction({ requirement }: { requirement: RadarUpgradeRequirement }) {
|
|
86
|
+
const { onRequestUpgrade, upgradeHref } = useContext(RadarUpgradeContext)
|
|
87
|
+
const className = 'inline-flex items-center gap-1 font-medium text-accent-text hover:underline'
|
|
88
|
+
if (onRequestUpgrade) {
|
|
89
|
+
return (
|
|
90
|
+
<button type="button" className={className} onClick={() => onRequestUpgrade(requirement)}>
|
|
91
|
+
Upgrade Radar
|
|
92
|
+
</button>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
if (upgradeHref) {
|
|
96
|
+
return (
|
|
97
|
+
<a href={upgradeHref} target="_blank" rel="noopener noreferrer" className={className}>
|
|
98
|
+
How to upgrade
|
|
99
|
+
<ExternalLink className="h-3 w-3" aria-hidden />
|
|
100
|
+
</a>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Inline note for a feature the connected Radar is too old to serve. Calm on
|
|
108
|
+
* purpose: nothing failed, the cluster just runs an older Radar than this UI.
|
|
109
|
+
*/
|
|
110
|
+
export function RadarUpgradeNote({
|
|
111
|
+
requirement,
|
|
112
|
+
className,
|
|
113
|
+
}: {
|
|
114
|
+
requirement: RadarUpgradeRequirement
|
|
115
|
+
className?: string
|
|
116
|
+
}) {
|
|
117
|
+
return (
|
|
118
|
+
<div className={clsx('flex items-start gap-2 text-xs text-theme-text-secondary', className)}>
|
|
119
|
+
<ArrowUpCircle className="mt-px h-3.5 w-3.5 shrink-0 text-accent-text" aria-hidden />
|
|
120
|
+
<p className="min-w-0">
|
|
121
|
+
<span className="font-medium text-theme-text-primary">{requirement.feature}</span>
|
|
122
|
+
{' '}
|
|
123
|
+
{radarUpgradeNeed(requirement)}
|
|
124
|
+
{' '}
|
|
125
|
+
<RadarUpgradeAction requirement={requirement} />
|
|
126
|
+
</p>
|
|
127
|
+
</div>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
@@ -60,7 +60,7 @@ export function ResourceBar({
|
|
|
60
60
|
label,
|
|
61
61
|
}: ResourceBarProps) {
|
|
62
62
|
const track = (
|
|
63
|
-
<div className={clsx('relative', layout === 'inline' && 'min-w-
|
|
63
|
+
<div className={clsx('relative', layout === 'inline' && 'min-w-16 flex-1')}>
|
|
64
64
|
<div className={clsx('rounded-full border border-theme-border bg-theme-elevated overflow-hidden', layout === 'inline' ? 'h-1' : 'h-1.5')}>
|
|
65
65
|
<div
|
|
66
66
|
className={clsx('h-full rounded-full transition-[width] duration-300 ease-out', getBarColor(percent, colorScheme))}
|
|
@@ -78,7 +78,7 @@ export function ResourceBar({
|
|
|
78
78
|
|
|
79
79
|
const bar =
|
|
80
80
|
layout === 'inline' ? (
|
|
81
|
-
<div className=
|
|
81
|
+
<div className={clsx('flex items-center gap-1.5 min-w-0', tooltip && 'w-full')}>
|
|
82
82
|
<span className="w-7 shrink-0 text-[10px] font-medium uppercase tracking-wide text-theme-text-tertiary">{label}</span>
|
|
83
83
|
{track}
|
|
84
84
|
<span className="w-8 shrink-0 text-right text-[10.5px] font-mono tabular-nums text-theme-text-secondary">
|
|
@@ -86,7 +86,7 @@ export function ResourceBar({
|
|
|
86
86
|
</span>
|
|
87
87
|
</div>
|
|
88
88
|
) : (
|
|
89
|
-
<div className=
|
|
89
|
+
<div className={clsx('flex flex-col gap-0.5 min-w-0', tooltip && 'w-full')}>
|
|
90
90
|
<div className="flex items-baseline justify-between gap-1">
|
|
91
91
|
<span className="text-xs font-mono text-theme-text-secondary truncate">
|
|
92
92
|
{used} / {total}
|
|
@@ -99,11 +99,17 @@ export function ResourceBar({
|
|
|
99
99
|
</div>
|
|
100
100
|
)
|
|
101
101
|
|
|
102
|
+
// Tooltip's wrapper is inline-flex: it sizes the row to its content (hence
|
|
103
|
+
// w-full on the row, or the track collapses to the labels' width) and it is
|
|
104
|
+
// inline-level, so the block div keeps sibling bars stacked instead of
|
|
105
|
+
// sharing one line.
|
|
102
106
|
if (tooltip) {
|
|
103
107
|
return (
|
|
104
|
-
<
|
|
105
|
-
{
|
|
106
|
-
|
|
108
|
+
<div className="min-w-0">
|
|
109
|
+
<Tooltip content={tooltip} delay={200} position="top" wrapperClassName="w-full min-w-0">
|
|
110
|
+
{bar}
|
|
111
|
+
</Tooltip>
|
|
112
|
+
</div>
|
|
107
113
|
)
|
|
108
114
|
}
|
|
109
115
|
|