@skyhook-io/k8s-ui 1.17.1 → 1.18.0
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 +4 -4
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -2
- package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +27 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +16 -10
- package/src/components/gitops/insights/insights-helpers.test.ts +18 -0
- package/src/components/gitops/insights/insights-helpers.ts +7 -0
- package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +73 -9
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +45 -28
- package/src/components/gitops/tree/tree-helpers.test.ts +6 -0
- package/src/components/gitops/tree/tree-helpers.ts +5 -1
- package/src/components/issues/IssuesView.tsx +4 -1
- package/src/components/issues/ResourceIssuesSection.tsx +5 -2
- package/src/components/issues/diagnostic.ts +2 -0
- package/src/components/issues/index.ts +1 -1
- package/src/components/issues/issues.test.ts +26 -0
- package/src/components/issues/severity.ts +9 -2
- package/src/components/issues/types.ts +32 -4
- package/src/components/logs/LogCore.theme.test.tsx +126 -1
- package/src/components/logs/LogCore.tsx +159 -40
- package/src/components/logs/LogsViewer.test.tsx +80 -2
- package/src/components/logs/LogsViewer.tsx +21 -4
- package/src/components/logs/StructuredLogLine.tsx +1 -1
- package/src/components/logs/WorkloadLogsViewer.tsx +7 -1
- package/src/components/logs/useLogBuffer.reading.test.tsx +149 -0
- package/src/components/logs/useLogBuffer.ts +48 -18
- package/src/components/logs/useLogSearch.ts +3 -1
- package/src/components/resources/ColumnPickerList.test.tsx +46 -0
- package/src/components/resources/ColumnPickerList.tsx +174 -0
- package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -2
- package/src/components/resources/ResourcesView.tsx +93 -75
- package/src/components/resources/column-order.test.ts +104 -0
- package/src/components/resources/column-order.ts +64 -0
- package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +15 -0
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +4 -1
- package/src/components/resources/renderers/CronJobRenderer.test.tsx +31 -0
- package/src/components/resources/renderers/CronJobRenderer.tsx +28 -20
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +22 -9
- package/src/components/resources/renderers/PodRenderer.sidecar-terminal.test.tsx +200 -0
- package/src/components/resources/renderers/PodRenderer.tsx +40 -11
- package/src/components/resources/resource-utils.ts +15 -2
- package/src/components/resources/summarize-scheduler-message.test.ts +1 -0
- package/src/components/shared/DrainPlanDialog.test.tsx +7 -0
- package/src/components/shared/DrainPlanDialog.tsx +6 -2
- package/src/components/shared/ResourceActionsBar.tsx +3 -3
- package/src/components/shared/ResourceRendererDispatch.tsx +3 -1
- package/src/components/timeline/DiffViewer.test.tsx +69 -0
- package/src/components/timeline/DiffViewer.tsx +113 -4
- package/src/components/timeline/shared.test.tsx +48 -0
- package/src/components/timeline/shared.tsx +14 -19
- package/src/components/ui/Toast.tsx +15 -4
- package/src/components/ui/index.ts +1 -1
- package/src/components/workload/WorkloadView.tsx +14 -8
- package/src/types/core.ts +25 -1
- package/src/types/gitops-insights.ts +1 -0
- package/src/types/gitops-tree.ts +2 -0
|
@@ -4,7 +4,7 @@ import { clsx } from 'clsx'
|
|
|
4
4
|
import { PolicySection } from './PolicySection'
|
|
5
5
|
import type { PolicyResourceResponse } from '../../../types/policy'
|
|
6
6
|
import { Section, PropertyList, Property, ConditionsSection, CopyHandler, AlertBanner, ResourceLink, useOperationalIssuesShown } from '../../ui/drawer-components'
|
|
7
|
-
import { formatResources, formatDuration, getPodProblems, getPodPhaseDisplay, healthColors, SEVERITY_DOT_COLOR, getDefaultContainerName } from '../resource-utils'
|
|
7
|
+
import { formatResources, formatDuration, getPodProblems, getPodPhaseDisplay, healthColors, SEVERITY_DOT_COLOR, getDefaultContainerName, getExecContainerNames } from '../resource-utils'
|
|
8
8
|
import { getResourceStatusColor, SEVERITY_BORDER } from '../../../utils/badge-colors'
|
|
9
9
|
import {
|
|
10
10
|
rbacVerbBadgeClass,
|
|
@@ -91,7 +91,7 @@ interface PodRendererProps {
|
|
|
91
91
|
hideMetricsServer?: boolean
|
|
92
92
|
// Filesystem browser render props
|
|
93
93
|
renderImageBrowser?: (props: { image: string; namespace: string; podName: string; pullSecrets: string[]; onClose: () => void; onSwitchToPodFiles?: () => void }) => ReactNode
|
|
94
|
-
renderPodBrowser?: (props: { namespace: string; podName: string; containers: string[]; initialContainer: string; onClose: () => void; onSwitchToImageFiles: () => void }) => ReactNode
|
|
94
|
+
renderPodBrowser?: (props: { namespace: string; podName: string; containers: string[]; initialContainer: string; onClose: () => void; onSwitchToImageFiles: (containerName?: string) => void }) => ReactNode
|
|
95
95
|
/**
|
|
96
96
|
* Resolved content for envFrom references.
|
|
97
97
|
* When provided, expands ConfigMap/Secret keys inline instead of showing "(all keys)".
|
|
@@ -340,6 +340,16 @@ export function PodRenderer({
|
|
|
340
340
|
// Pod filesystem modal state
|
|
341
341
|
const [podFilesContainer, setPodFilesContainer] = useState<string | null>(null)
|
|
342
342
|
|
|
343
|
+
// A sidecar can be running while the pod is still Pending, and a stopped
|
|
344
|
+
// container has no live files, so match the image against running containers.
|
|
345
|
+
const runningImageContainer = selectedImage
|
|
346
|
+
? getExecContainerNames(data).find((name) => {
|
|
347
|
+
const spec = [...containers, ...initContainers].find((c: any) => c.name === name)
|
|
348
|
+
const status = [...containerStatuses, ...initContainerStatuses].find((st: any) => st.name === name)
|
|
349
|
+
return spec?.image === selectedImage && !!status?.state?.running
|
|
350
|
+
})
|
|
351
|
+
: undefined
|
|
352
|
+
|
|
343
353
|
const handleOpenTerminal = (containerName?: string) => {
|
|
344
354
|
const container = containerName || getDefaultContainerName(data)
|
|
345
355
|
if (namespace && podName && container) {
|
|
@@ -347,7 +357,7 @@ export function PodRenderer({
|
|
|
347
357
|
namespace,
|
|
348
358
|
podName,
|
|
349
359
|
containerName: container,
|
|
350
|
-
containers:
|
|
360
|
+
containers: getExecContainerNames(data),
|
|
351
361
|
})
|
|
352
362
|
}
|
|
353
363
|
}
|
|
@@ -512,6 +522,7 @@ export function PodRenderer({
|
|
|
512
522
|
const isFailed = stateKey === 'terminated' && exitCode != null && exitCode !== 0
|
|
513
523
|
const isWaiting = stateKey === 'waiting'
|
|
514
524
|
const isInitRunning = stateKey === 'running'
|
|
525
|
+
const isSidecar = container.restartPolicy === 'Always'
|
|
515
526
|
|
|
516
527
|
// Status label and color
|
|
517
528
|
let statusLabel: string
|
|
@@ -553,6 +564,26 @@ export function PodRenderer({
|
|
|
553
564
|
<span className="text-sm font-medium text-theme-text-primary">{container.name}</span>
|
|
554
565
|
</div>
|
|
555
566
|
<div className="flex items-center gap-2">
|
|
567
|
+
{isSidecar && isInitRunning && canExec && onOpenTerminal && (
|
|
568
|
+
<Tooltip content="Terminal" delay={150}>
|
|
569
|
+
<button
|
|
570
|
+
onClick={() => handleOpenTerminal(container.name)}
|
|
571
|
+
className="p-1 text-slate-400 hover:text-blue-400 hover:bg-slate-600/50 rounded transition-colors"
|
|
572
|
+
>
|
|
573
|
+
<TerminalIcon className="w-4 h-4" />
|
|
574
|
+
</button>
|
|
575
|
+
</Tooltip>
|
|
576
|
+
)}
|
|
577
|
+
{isSidecar && isInitRunning && canExec && renderPodBrowser && (
|
|
578
|
+
<Tooltip content="Browse files" delay={150}>
|
|
579
|
+
<button
|
|
580
|
+
onClick={() => setPodFilesContainer(container.name)}
|
|
581
|
+
className="p-1 text-slate-400 hover:text-blue-400 hover:bg-slate-600/50 rounded transition-colors"
|
|
582
|
+
>
|
|
583
|
+
<FolderOpen className="w-4 h-4" />
|
|
584
|
+
</button>
|
|
585
|
+
</Tooltip>
|
|
586
|
+
)}
|
|
556
587
|
{canViewLogs && (
|
|
557
588
|
<Tooltip content="Logs" delay={150}>
|
|
558
589
|
<button
|
|
@@ -973,10 +1004,8 @@ export function PodRenderer({
|
|
|
973
1004
|
podName: podName || '',
|
|
974
1005
|
pullSecrets: imagePullSecrets,
|
|
975
1006
|
onClose: () => setSelectedImage(null),
|
|
976
|
-
onSwitchToPodFiles:
|
|
977
|
-
|
|
978
|
-
const match = containers.find((c: any) => c.image === selectedImage)
|
|
979
|
-
setPodFilesContainer(match?.name || containers[0]?.name)
|
|
1007
|
+
onSwitchToPodFiles: canExec && renderPodBrowser && (isRunning || runningImageContainer) ? () => {
|
|
1008
|
+
setPodFilesContainer(runningImageContainer || containers[0]?.name)
|
|
980
1009
|
} : undefined,
|
|
981
1010
|
})}
|
|
982
1011
|
|
|
@@ -984,12 +1013,12 @@ export function PodRenderer({
|
|
|
984
1013
|
{podFilesContainer && renderPodBrowser && renderPodBrowser({
|
|
985
1014
|
namespace: namespace || '',
|
|
986
1015
|
podName: podName || '',
|
|
987
|
-
containers:
|
|
1016
|
+
containers: getExecContainerNames(data),
|
|
988
1017
|
initialContainer: podFilesContainer,
|
|
989
1018
|
onClose: () => setPodFilesContainer(null),
|
|
990
|
-
onSwitchToImageFiles: () => {
|
|
991
|
-
|
|
992
|
-
const match = containers.find((c: any) => c.name ===
|
|
1019
|
+
onSwitchToImageFiles: (containerName?: string) => {
|
|
1020
|
+
const selected = containerName || podFilesContainer
|
|
1021
|
+
const match = [...containers, ...initContainers].find((c: any) => c.name === selected)
|
|
993
1022
|
if (match?.image) setSelectedImage(match.image)
|
|
994
1023
|
},
|
|
995
1024
|
})}
|
|
@@ -106,7 +106,7 @@ export function summarizeSchedulerMessage(message?: string, options: { plain?: b
|
|
|
106
106
|
[/^(\d+) (?:node\(s\) )?Insufficient memory(?: \(0\/\d+ nodes available\))?$/i, 'do not have enough memory'],
|
|
107
107
|
[/^(\d+) node\(s\) (?:didn.t match Pod.s node affinity\/selector|node affinity\/selector mismatch)(?: \(0\/\d+ nodes available\))?$/i, 'do not match the Pod’s node affinity or selector'],
|
|
108
108
|
[/^(\d+) node\(s\) (?:had )?untolerated taint(?:\(s\))?(?: \{[^}]*\}| [^\s]+)?(?: \(0\/\d+ nodes available\))?$/i, 'have taints the Pod does not tolerate'],
|
|
109
|
-
[/^(\d+) node\(s\) (?:had )?volume node[- ]affinity conflict(?: \(0\/\d+ nodes available\))?$/i, 'do not match the volume’s node affinity'],
|
|
109
|
+
[/^(\d+) node\(s\) (?:(?:had )?volume node[- ]affinity conflict|didn.t match PersistentVolume.s node affinity)(?: \(0\/\d+ nodes available\))?$/i, 'do not match the volume’s node affinity'],
|
|
110
110
|
]
|
|
111
111
|
return splitSchedulerClauses(m).filter((part) => part.trim()).map((part) => {
|
|
112
112
|
const clause = part.trim()
|
|
@@ -738,6 +738,19 @@ export function getDefaultContainerName(pod: any): string | undefined {
|
|
|
738
738
|
return containers[0]?.name
|
|
739
739
|
}
|
|
740
740
|
|
|
741
|
+
/**
|
|
742
|
+
* The containers a terminal can target: the regular containers, then native
|
|
743
|
+
* sidecars (init containers with restartPolicy: Always, Kubernetes 1.29+),
|
|
744
|
+
* which keep running for the pod's lifetime like regular containers.
|
|
745
|
+
*/
|
|
746
|
+
export function getExecContainerNames(pod: any): string[] {
|
|
747
|
+
const regular = (pod?.spec?.containers || []).map((c: any) => c.name)
|
|
748
|
+
const sidecars = (pod?.spec?.initContainers || [])
|
|
749
|
+
.filter((c: any) => c.restartPolicy === 'Always')
|
|
750
|
+
.map((c: any) => c.name)
|
|
751
|
+
return [...regular, ...sidecars].filter(Boolean)
|
|
752
|
+
}
|
|
753
|
+
|
|
741
754
|
export function getContainerSquareStates(pod: any): ContainerSquareState[] {
|
|
742
755
|
const result: ContainerSquareState[] = []
|
|
743
756
|
const initStatuses = pod.status?.initContainerStatuses || []
|
|
@@ -1314,7 +1327,7 @@ export function getCronJobStatus(cj: any): StatusBadge {
|
|
|
1314
1327
|
if (activeJobs > 0) {
|
|
1315
1328
|
return { text: `Active (${activeJobs})`, color: healthColors.neutral, level: 'neutral' }
|
|
1316
1329
|
}
|
|
1317
|
-
return { text: '
|
|
1330
|
+
return { text: 'Enabled', color: healthColors.neutral, level: 'neutral' }
|
|
1318
1331
|
}
|
|
1319
1332
|
|
|
1320
1333
|
export function getCronJobSchedule(cj: any): { cron: string; readable: string } {
|
|
@@ -44,6 +44,7 @@ it.each([
|
|
|
44
44
|
['2 node(s) node affinity/selector mismatch (0/2 nodes available)', 'both nodes do not match the Pod’s node affinity or selector'],
|
|
45
45
|
['unbound PersistentVolumeClaim, 1 node(s) volume node-affinity conflict (0/2 nodes available)', 'a required volume claim is not bound; 1 node does not match the volume’s node affinity'],
|
|
46
46
|
['0/2 nodes are available: 1 node(s) had volume node affinity conflict, 1 Insufficient memory.', '1 node does not match the volume’s node affinity; 1 node does not have enough memory'],
|
|
47
|
+
["0/3 nodes are available: 1 node(s) didn't satisfy existing pods anti-affinity rules, 2 node(s) didn't match PersistentVolume's node affinity.", "1 node(s) didn't satisfy existing pods anti-affinity rules; 2 nodes do not match the volume’s node affinity"],
|
|
47
48
|
['no node has kubernetes.io/arch=arm64 — 2 node(s) carry kubernetes.io/arch: [amd64]; 1 node(s) insufficient cpu (0/3 nodes available)', 'no node has kubernetes.io/arch=arm64 — 2 node(s) carry kubernetes.io/arch: [amd64]; 1 node does not have enough CPU'],
|
|
48
49
|
['custom scheduler says 2 Insufficient cpu, 1 Insufficient memory', 'custom scheduler says 2 Insufficient cpu; 1 node does not have enough memory'],
|
|
49
50
|
['1 node(s) pod anti-affinity conflict, 1 node(s) insufficient memory, no node has zone=west — 2 node(s) carry zone: [east, north]', '1 node(s) pod anti-affinity conflict; 1 node does not have enough memory; no node has zone=west — 2 node(s) carry zone: [east, north]'],
|
|
@@ -108,6 +108,13 @@ describe('DrainPlanContent', () => {
|
|
|
108
108
|
expect(html).toContain('About this estimate')
|
|
109
109
|
})
|
|
110
110
|
|
|
111
|
+
it('distinguishes expected exclusions from protected workloads before draining', () => {
|
|
112
|
+
const html = render({ plan: plan([pod('agent', 'skip', { expectedSkip: true }), pod('bare', 'skip', { expectedSkip: false })]) })
|
|
113
|
+
expect(html).toContain('left by design')
|
|
114
|
+
expect(html).toContain('requires attention')
|
|
115
|
+
expect(html).toContain('bare reason')
|
|
116
|
+
})
|
|
117
|
+
|
|
111
118
|
it('offers a refresh only when the host can recompute the plan', () => {
|
|
112
119
|
const p = plan([pod('web', 'evict')])
|
|
113
120
|
expect(render({ plan: p, onRefreshPlan: noop })).toContain('Recompute the plan')
|
|
@@ -19,6 +19,7 @@ export interface DrainPlanPod {
|
|
|
19
19
|
pdb?: string
|
|
20
20
|
pdbChecked: boolean
|
|
21
21
|
terminating?: boolean
|
|
22
|
+
expectedSkip?: boolean
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export interface DrainPlan {
|
|
@@ -95,7 +96,8 @@ const OUTCOME_LABEL: Record<DrainOutcome, string> = {
|
|
|
95
96
|
|
|
96
97
|
const ESTIMATE_CAVEAT =
|
|
97
98
|
'An estimate, not a guarantee: the drain re-lists live state when it runs, and pods, budgets and permissions can change until then. ' +
|
|
98
|
-
'
|
|
99
|
+
'Drain normally waits up to 2 minutes for evictions and pod deletion, retrying when a disruption budget temporarily blocks eviction. ' +
|
|
100
|
+
'Older Radar versions may return before pods are deleted. A pod covered by more than one budget is refused outright.'
|
|
99
101
|
|
|
100
102
|
interface DrainPlanContentProps {
|
|
101
103
|
nodeName: string
|
|
@@ -217,7 +219,9 @@ export function DrainPlanContent({
|
|
|
217
219
|
{current.pods.map((p) => (
|
|
218
220
|
<tr key={`${p.namespace}/${p.name}`} className="border-b border-theme-border last:border-b-0 align-top">
|
|
219
221
|
<td className="px-2 py-1.5 whitespace-nowrap">
|
|
220
|
-
<Badge severity={OUTCOME_SEVERITY[p.outcome]} size="sm">
|
|
222
|
+
<Badge severity={p.outcome === 'skip' && p.expectedSkip === false ? 'warning' : OUTCOME_SEVERITY[p.outcome]} size="sm">
|
|
223
|
+
{p.outcome === 'skip' && p.expectedSkip !== undefined ? p.expectedSkip ? 'left by design' : 'requires attention' : OUTCOME_LABEL[p.outcome]}
|
|
224
|
+
</Badge>
|
|
221
225
|
</td>
|
|
222
226
|
<td className="px-2 py-1.5 font-mono whitespace-nowrap text-theme-text-primary">
|
|
223
227
|
{p.namespace}/{p.name}
|
|
@@ -28,7 +28,7 @@ import { DialogPortal } from '../ui/DialogPortal'
|
|
|
28
28
|
import type { SelectedResource, WorkloadRevision } from '../../types'
|
|
29
29
|
import type { RadarUpgradeRequirement } from '../../types/fetch-error'
|
|
30
30
|
import { displayKindName } from '../ui/drawer-components'
|
|
31
|
-
import { getDefaultContainerName } from '../resources/resource-utils'
|
|
31
|
+
import { getDefaultContainerName, getExecContainerNames } from '../resources/resource-utils'
|
|
32
32
|
import { SetImageDialog, type SetImageOwnership } from './SetImageDialog'
|
|
33
33
|
import { DrainPlanDialog, DEFAULT_DRAIN_DIALOG_OPTIONS, type DrainDialogOptions, type DrainPlan } from './DrainPlanDialog'
|
|
34
34
|
import type { WorkloadImageInventory, WorkloadImageUpdate } from '../../types/core'
|
|
@@ -254,7 +254,7 @@ export function ResourceActionsBar({
|
|
|
254
254
|
namespace: resource.namespace,
|
|
255
255
|
podName: resource.name,
|
|
256
256
|
containerName: getDefaultContainerName(data) || containers[0],
|
|
257
|
-
containers,
|
|
257
|
+
containers: getExecContainerNames(data),
|
|
258
258
|
})
|
|
259
259
|
}
|
|
260
260
|
}
|
|
@@ -370,7 +370,7 @@ export function ResourceActionsBar({
|
|
|
370
370
|
onUncordonNode && (
|
|
371
371
|
<button
|
|
372
372
|
onClick={() => onUncordonNode({ name: resource.name })}
|
|
373
|
-
disabled={isUncordoningNode}
|
|
373
|
+
disabled={isUncordoningNode || isDrainingNode}
|
|
374
374
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-emerald-600 hover:bg-emerald-700 rounded-lg transition-colors disabled:opacity-50"
|
|
375
375
|
>
|
|
376
376
|
<Play className={`w-3.5 h-3.5 ${isUncordoningNode ? 'animate-pulse' : ''}`} />
|
|
@@ -314,6 +314,7 @@ import type { ScalerDiagnosis } from '../resources/renderers/WorkloadRenderer'
|
|
|
314
314
|
* When an override is not provided, the base (shared) renderer is used.
|
|
315
315
|
*/
|
|
316
316
|
export interface RendererOverrides {
|
|
317
|
+
FluxHelmReleaseRenderer?: React.ComponentType<React.ComponentProps<typeof FluxHelmReleaseRenderer>>
|
|
317
318
|
JobRenderer?: React.ComponentType<{ data: any; onNavigate?: (ref: ResourceRef) => void }>
|
|
318
319
|
JobSetRenderer?: React.ComponentType<{ data: any; onNavigate?: (ref: ResourceRef) => void }>
|
|
319
320
|
RayJobRenderer?: React.ComponentType<{ data: any; onNavigate?: (ref: ResourceRef) => void }>
|
|
@@ -755,6 +756,7 @@ export function ResourceRendererDispatch({
|
|
|
755
756
|
const isKnownKind = KNOWN_KINDS.has(kind) || isCrossplaneMR || isCrossplaneClaim || isCrossplaneXR
|
|
756
757
|
|
|
757
758
|
const JobComp = rendererOverrides?.JobRenderer ?? JobRenderer
|
|
759
|
+
const FluxHelmReleaseComp = rendererOverrides?.FluxHelmReleaseRenderer ?? FluxHelmReleaseRenderer
|
|
758
760
|
const JobSetComp = rendererOverrides?.JobSetRenderer ?? JobSetRenderer
|
|
759
761
|
const RayJobComp = rendererOverrides?.RayJobRenderer ?? RayJobRenderer
|
|
760
762
|
const RayClusterComp = rendererOverrides?.RayClusterRenderer ?? RayClusterRenderer
|
|
@@ -879,7 +881,7 @@ export function ResourceRendererDispatch({
|
|
|
879
881
|
{kind === 'ocirepositories' && <OCIRepositoryRenderer data={data} />}
|
|
880
882
|
{kind === 'helmrepositories' && <HelmRepositoryRenderer data={data} />}
|
|
881
883
|
{kind === 'kustomizations' && <KustomizationRenderer data={data} onNavigate={onNavigate} />}
|
|
882
|
-
{kind === 'helmreleases' && <
|
|
884
|
+
{kind === 'helmreleases' && <FluxHelmReleaseComp data={data} onNavigate={onNavigate} />}
|
|
883
885
|
{kind === 'alerts' && <AlertRenderer data={data} />}
|
|
884
886
|
{kind === 'applications' && <ArgoApplicationRenderer data={data} />}
|
|
885
887
|
{kind === 'nodepools' && <KarpenterNodePoolComp data={data} onNavigate={onNavigate} />}
|
|
@@ -23,4 +23,73 @@ describe("DiffViewer", () => {
|
|
|
23
23
|
expect(html).toContain("Old value:");
|
|
24
24
|
expect(html).toContain("New value:");
|
|
25
25
|
});
|
|
26
|
+
|
|
27
|
+
it("renders values kept out of the feed as a badge, not as struck-through text", () => {
|
|
28
|
+
const html = renderToStaticMarkup(
|
|
29
|
+
<DiffViewer
|
|
30
|
+
diff={{
|
|
31
|
+
summary: "pod template annotation checksum/config changed",
|
|
32
|
+
fields: [
|
|
33
|
+
{ path: "spec.template.metadata.annotations[checksum/config]", oldValue: "(value hidden)", newValue: "(value hidden)" },
|
|
34
|
+
{ path: "spec.template.metadata.annotations[example.com/new]", oldValue: null, newValue: "(set)" },
|
|
35
|
+
{ path: "spec.template.metadata.annotations[example.com/old]", oldValue: "(set)", newValue: null },
|
|
36
|
+
{ path: "spec.template.spec.affinity", oldValue: "changed", newValue: "changed" },
|
|
37
|
+
],
|
|
38
|
+
}}
|
|
39
|
+
/>,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
expect(html).not.toContain("line-through");
|
|
43
|
+
expect(html).not.toContain("(value hidden)");
|
|
44
|
+
expect(html).not.toContain("(set)");
|
|
45
|
+
expect(html.match(/value hidden/g)).toHaveLength(4);
|
|
46
|
+
expect(html).toContain("Set");
|
|
47
|
+
expect(html).toContain("Removed");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("names key changes instead of striking through an unchanged key list", () => {
|
|
51
|
+
const html = renderToStaticMarkup(
|
|
52
|
+
<DiffViewer
|
|
53
|
+
diff={{
|
|
54
|
+
summary: "data modified keys: [password]",
|
|
55
|
+
fields: [{ path: "data (modified keys)", oldValue: ["password"], newValue: ["password"] }],
|
|
56
|
+
}}
|
|
57
|
+
/>,
|
|
58
|
+
);
|
|
59
|
+
expect(html).toContain("keys changed: <span class=\"font-mono\">password</span>");
|
|
60
|
+
expect(html).not.toContain("line-through");
|
|
61
|
+
expect(html).not.toContain("Old value:");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("merges added, removed and modified key lists of one field into one row", () => {
|
|
65
|
+
const html = renderToStaticMarkup(
|
|
66
|
+
<DiffViewer
|
|
67
|
+
diff={{
|
|
68
|
+
summary: "",
|
|
69
|
+
fields: [
|
|
70
|
+
{ path: "data (added keys)", oldValue: null, newValue: ["token"] },
|
|
71
|
+
{ path: "data (removed keys)", oldValue: ["old"], newValue: null },
|
|
72
|
+
{ path: "data (modified keys)", oldValue: ["a", "b"], newValue: ["a", "b"] },
|
|
73
|
+
{ path: "stringData (added keys)", oldValue: null, newValue: ["extra"] },
|
|
74
|
+
{ path: "type", oldValue: "Opaque", newValue: "kubernetes.io/tls" },
|
|
75
|
+
],
|
|
76
|
+
}}
|
|
77
|
+
/>,
|
|
78
|
+
);
|
|
79
|
+
expect(html.match(/>data</g)).toHaveLength(1);
|
|
80
|
+
expect(html).toMatch(/keys changed: <span[^>]*>a, b<\/span>.*added: <span[^>]*>token<\/span>.*removed: <span[^>]*>old<\/span>/);
|
|
81
|
+
expect(html).toContain(">stringData<");
|
|
82
|
+
expect(html).toContain("added: <span class=\"font-mono\">extra</span>");
|
|
83
|
+
// Ordinary fields keep the old→new rendering.
|
|
84
|
+
expect(html).toContain("Old value:");
|
|
85
|
+
expect(html).toContain("kubernetes.io/tls");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("keeps the old→new rendering when a key-list path does not carry a list", () => {
|
|
89
|
+
const html = renderToStaticMarkup(
|
|
90
|
+
<DiffViewer diff={{ summary: "", fields: [{ path: "data (modified keys)", oldValue: "x", newValue: "y" }] }} />,
|
|
91
|
+
);
|
|
92
|
+
expect(html).toContain("Old value:");
|
|
93
|
+
expect(html).not.toContain("keys changed");
|
|
94
|
+
});
|
|
26
95
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { memo } from 'react'
|
|
2
2
|
import { clsx } from 'clsx'
|
|
3
|
-
import { ArrowRight, Plus, Minus, RefreshCw } from 'lucide-react'
|
|
3
|
+
import { ArrowRight, EyeOff, Plus, Minus, RefreshCw } from 'lucide-react'
|
|
4
4
|
import type { DiffInfo, FieldChange } from '../../types'
|
|
5
5
|
|
|
6
6
|
interface DiffViewerProps {
|
|
@@ -34,19 +34,128 @@ export const DiffViewer = memo(function DiffViewer({ diff, compact = false }: Di
|
|
|
34
34
|
|
|
35
35
|
{/* Field changes */}
|
|
36
36
|
<div className="space-y-1.5">
|
|
37
|
-
{diff.fields.map((
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
{diffRows(diff.fields).map((row, idx) =>
|
|
38
|
+
row.kind === 'keys' ? (
|
|
39
|
+
<KeyListRow key={`${row.field}-${idx}`} row={row} />
|
|
40
|
+
) : (
|
|
41
|
+
<FieldChangeRow key={`${row.field.path}-${idx}`} field={row.field} />
|
|
42
|
+
),
|
|
43
|
+
)}
|
|
40
44
|
</div>
|
|
41
45
|
</div>
|
|
42
46
|
)
|
|
43
47
|
})
|
|
44
48
|
|
|
49
|
+
// Secret and ConfigMap diffs report which keys changed as name lists, under
|
|
50
|
+
// paths like "data (modified keys)", never values. Rendered as an old→new
|
|
51
|
+
// pair, a modified key reads as removed and re-added, so key lists of one
|
|
52
|
+
// field are merged into a single row that names what happened to each key.
|
|
53
|
+
const KEY_LIST_PATH = /^(.+) \((added|removed|modified) keys\)$/
|
|
54
|
+
|
|
55
|
+
type KeyChange = 'added' | 'removed' | 'modified'
|
|
56
|
+
|
|
57
|
+
interface KeyListDiffRow {
|
|
58
|
+
kind: 'keys'
|
|
59
|
+
field: string
|
|
60
|
+
keys: Record<KeyChange, string[]>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type DiffRow = KeyListDiffRow | { kind: 'field'; field: FieldChange }
|
|
64
|
+
|
|
65
|
+
function keyNames(value: unknown): string[] | null {
|
|
66
|
+
if (value === null || value === undefined) return []
|
|
67
|
+
return Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function diffRows(fields: FieldChange[]): DiffRow[] {
|
|
71
|
+
const rows: DiffRow[] = []
|
|
72
|
+
const byField = new Map<string, KeyListDiffRow>()
|
|
73
|
+
for (const field of fields) {
|
|
74
|
+
const match = KEY_LIST_PATH.exec(field.path)
|
|
75
|
+
const oldKeys = keyNames(field.oldValue)
|
|
76
|
+
const newKeys = keyNames(field.newValue)
|
|
77
|
+
if (!match || !oldKeys || !newKeys) {
|
|
78
|
+
rows.push({ kind: 'field', field })
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
const [, base, change] = match
|
|
82
|
+
let row = byField.get(base)
|
|
83
|
+
if (!row) {
|
|
84
|
+
row = { kind: 'keys', field: base, keys: { added: [], removed: [], modified: [] } }
|
|
85
|
+
byField.set(base, row)
|
|
86
|
+
rows.push(row)
|
|
87
|
+
}
|
|
88
|
+
const list = row.keys[change as KeyChange]
|
|
89
|
+
for (const key of [...oldKeys, ...newKeys]) {
|
|
90
|
+
if (!list.includes(key)) list.push(key)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return rows
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const KEY_CHANGE_LABEL: Record<KeyChange, string> = {
|
|
97
|
+
modified: 'keys changed',
|
|
98
|
+
added: 'added',
|
|
99
|
+
removed: 'removed',
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function KeyListRow({ row }: { row: KeyListDiffRow }) {
|
|
103
|
+
const parts = (['modified', 'added', 'removed'] as const).filter((change) => row.keys[change].length > 0)
|
|
104
|
+
return (
|
|
105
|
+
<div className="rounded bg-theme-surface/50 border border-theme-border px-3 py-2">
|
|
106
|
+
<div className="text-xs font-mono text-theme-text-tertiary mb-1 break-words [overflow-wrap:anywhere]">{row.field}</div>
|
|
107
|
+
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-sm text-theme-text-secondary">
|
|
108
|
+
{parts.map((change, idx) => (
|
|
109
|
+
<span key={change} className="min-w-0 [overflow-wrap:anywhere]">
|
|
110
|
+
{idx > 0 ? <span aria-hidden className="mr-2 text-theme-text-tertiary">·</span> : null}
|
|
111
|
+
{KEY_CHANGE_LABEL[change]}: <span className="font-mono">{row.keys[change].join(', ')}</span>
|
|
112
|
+
</span>
|
|
113
|
+
))}
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
45
119
|
interface FieldChangeRowProps {
|
|
46
120
|
field: FieldChange
|
|
47
121
|
}
|
|
48
122
|
|
|
123
|
+
// The backend never sends values it keeps out of the feed (secrets, config
|
|
124
|
+
// blobs, large structures); it sends one of these markers instead. Older
|
|
125
|
+
// events carry a bare "changed" on both sides.
|
|
126
|
+
const HIDDEN_VALUE = '(value hidden)'
|
|
127
|
+
const HIDDEN_SET = '(set)'
|
|
128
|
+
|
|
129
|
+
function hiddenChange(field: FieldChange): 'changed' | 'set' | 'removed' | null {
|
|
130
|
+
const { oldValue, newValue } = field
|
|
131
|
+
const absent = (v: unknown) => v === null || v === undefined
|
|
132
|
+
if ((oldValue === HIDDEN_VALUE && newValue === HIDDEN_VALUE) || (oldValue === 'changed' && newValue === 'changed')) {
|
|
133
|
+
return 'changed'
|
|
134
|
+
}
|
|
135
|
+
if (absent(oldValue) && newValue === HIDDEN_SET) return 'set'
|
|
136
|
+
if (oldValue === HIDDEN_SET && absent(newValue)) return 'removed'
|
|
137
|
+
return null
|
|
138
|
+
}
|
|
139
|
+
|
|
49
140
|
function FieldChangeRow({ field }: FieldChangeRowProps) {
|
|
141
|
+
const hidden = hiddenChange(field)
|
|
142
|
+
if (hidden) {
|
|
143
|
+
const Icon = hidden === 'set' ? Plus : hidden === 'removed' ? Minus : RefreshCw
|
|
144
|
+
return (
|
|
145
|
+
<div className="rounded bg-theme-surface/50 border border-theme-border px-3 py-2">
|
|
146
|
+
<div className="text-xs font-mono text-theme-text-tertiary mb-1 break-words [overflow-wrap:anywhere]">{field.path}</div>
|
|
147
|
+
<div className="flex flex-wrap items-center gap-2 text-sm text-theme-text-secondary">
|
|
148
|
+
<Icon className="w-3.5 h-3.5 shrink-0" aria-hidden />
|
|
149
|
+
<span>{hidden === 'set' ? 'Set' : hidden === 'removed' ? 'Removed' : 'Changed'}</span>
|
|
150
|
+
<span className="inline-flex items-center gap-1 rounded-full bg-theme-elevated px-2 py-0.5 text-xs text-theme-text-tertiary">
|
|
151
|
+
<EyeOff className="w-3 h-3" aria-hidden />
|
|
152
|
+
value hidden
|
|
153
|
+
</span>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
50
159
|
const isAdded = field.oldValue === null || field.oldValue === undefined
|
|
51
160
|
const isRemoved = field.newValue === null || field.newValue === undefined
|
|
52
161
|
const isModified = !isAdded && !isRemoved
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
2
|
import {
|
|
3
|
+
isRolloutEvent,
|
|
3
4
|
buildHealthSpans,
|
|
4
5
|
sweepAggregateHealth,
|
|
5
6
|
formatAggregateHealthTooltip,
|
|
@@ -217,3 +218,50 @@ describe('buildLaneMemberSpans (leaf gathering)', () => {
|
|
|
217
218
|
expect(members.map((m) => m.name)).toEqual(['web'])
|
|
218
219
|
})
|
|
219
220
|
})
|
|
221
|
+
|
|
222
|
+
describe('isRolloutEvent', () => {
|
|
223
|
+
type F = { path: string; oldValue?: unknown; newValue?: unknown }
|
|
224
|
+
const ev = (kind: string, fields: F[], summary = '') => ({
|
|
225
|
+
id: 'e', source: 'informer', eventType: 'update', kind, namespace: 'shop', name: 'web',
|
|
226
|
+
timestamp: new Date(0).toISOString(),
|
|
227
|
+
diff: { summary, fields: fields.map((f) => ({ oldValue: 1, newValue: 2, ...f })) },
|
|
228
|
+
}) as any
|
|
229
|
+
|
|
230
|
+
it('does not treat a pod-template edit alone as a rollout (OnDelete and paused workloads do not roll)', () => {
|
|
231
|
+
expect(isRolloutEvent(ev('Deployment', [{ path: 'spec.template.metadata.annotations[kubectl.kubernetes.io/restartedAt]' }], 'pod template annotation kubectl.kubernetes.io/restartedAt added (new pod template revision)'))).toBe(false)
|
|
232
|
+
expect(isRolloutEvent(ev('DaemonSet', [{ path: 'spec.template.metadata.annotations[checksum/config]' }]))).toBe(false)
|
|
233
|
+
expect(isRolloutEvent(ev('Rollout', [{ path: 'spec.template.spec.containers[].image' }], 'image: web:2'))).toBe(false)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('treats updated-count movement with steady readiness as a rollout', () => {
|
|
237
|
+
expect(isRolloutEvent(ev('Deployment', [{ path: 'status.updatedReplicas', oldValue: 1, newValue: 2 }]))).toBe(true)
|
|
238
|
+
expect(isRolloutEvent(ev('DaemonSet', [{ path: 'status.updatedNumberScheduled', oldValue: 1, newValue: 2 }]))).toBe(true)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('keeps degraded health visible when readiness moves in the same update', () => {
|
|
242
|
+
// A pod swap, a lost pod and a scale-up under failing readiness are
|
|
243
|
+
// indistinguishable from the counts alone.
|
|
244
|
+
expect(isRolloutEvent(ev('DaemonSet', [
|
|
245
|
+
{ path: 'status.numberReady', oldValue: 4, newValue: 3 },
|
|
246
|
+
{ path: 'status.updatedNumberScheduled', oldValue: 1, newValue: 2 },
|
|
247
|
+
]))).toBe(false)
|
|
248
|
+
expect(isRolloutEvent(ev('Deployment', [
|
|
249
|
+
{ path: 'status.readyReplicas', oldValue: 3, newValue: 2 },
|
|
250
|
+
{ path: 'status.updatedReplicas', oldValue: 3, newValue: 5 },
|
|
251
|
+
]))).toBe(false)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('treats the reset at the start of a rollout as a rollout', () => {
|
|
255
|
+
expect(isRolloutEvent(ev('Deployment', [{ path: 'status.updatedReplicas', oldValue: 3, newValue: 0 }], 'updated: 3→0'))).toBe(true)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('does not treat a lost pod or unrelated changes as a rollout', () => {
|
|
259
|
+
expect(isRolloutEvent(ev('Deployment', [
|
|
260
|
+
{ path: 'status.readyReplicas', oldValue: 3, newValue: 2 },
|
|
261
|
+
{ path: 'status.updatedReplicas', oldValue: 3, newValue: 2 },
|
|
262
|
+
]))).toBe(false)
|
|
263
|
+
expect(isRolloutEvent(ev('Deployment', [{ path: 'status.readyReplicas', oldValue: 3, newValue: 1 }]))).toBe(false)
|
|
264
|
+
expect(isRolloutEvent(ev('Deployment', [{ path: 'spec.strategy.rollingUpdate.maxUnavailable' }], 'updated: wording without the field'))).toBe(false)
|
|
265
|
+
expect(isRolloutEvent(ev('Service', [{ path: 'status.updatedReplicas', oldValue: 1, newValue: 2 }]))).toBe(false)
|
|
266
|
+
})
|
|
267
|
+
})
|
|
@@ -57,28 +57,23 @@ export interface HealthSpanResult {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
|
-
* Check if an event
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
60
|
+
* Check if an event shows a rollout in progress (expected degradation): a
|
|
61
|
+
* deployment-like workload whose updated-replica count moved while its ready
|
|
62
|
+
* count held. Decided from the diff's field paths, not from summary wording.
|
|
63
|
+
*
|
|
64
|
+
* The rule errs toward keeping degraded health visible. A pod-template edit
|
|
65
|
+
* alone does not count (OnDelete and paused workloads take it without
|
|
66
|
+
* rolling), and an update that also moves the ready count does not count
|
|
67
|
+
* either: a lost pod and a scale-up under failing readiness look the same as
|
|
68
|
+
* a pod swap from the counts alone.
|
|
65
69
|
*/
|
|
66
|
-
function isRolloutEvent(event: TimelineEvent): boolean {
|
|
70
|
+
export function isRolloutEvent(event: TimelineEvent): boolean {
|
|
67
71
|
// Only deployment-like workload controllers can have rollout transitions.
|
|
68
72
|
if (!isDeploymentLikeWorkloadKind(event.kind)) return false
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
// "updated:" typically indicates replica count changes during rollout
|
|
74
|
-
if (summary.includes('updated:')) return true
|
|
75
|
-
// Image changes trigger rollouts
|
|
76
|
-
if (summary.includes('image(') || summary.includes('image:')) return true
|
|
77
|
-
// Spec template changes often indicate rollout
|
|
78
|
-
if (summary.includes('template')) return true
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
return false
|
|
73
|
+
const paths = new Set((event.diff?.fields ?? []).map((field) => field.path))
|
|
74
|
+
const updatedMoved = paths.has('status.updatedReplicas') || paths.has('status.updatedNumberScheduled')
|
|
75
|
+
const readyMoved = paths.has('status.readyReplicas') || paths.has('status.numberReady')
|
|
76
|
+
return updatedMoved && !readyMoved
|
|
82
77
|
}
|
|
83
78
|
|
|
84
79
|
/**
|
|
@@ -36,6 +36,7 @@ class ToastManager {
|
|
|
36
36
|
private static instance: ToastManager
|
|
37
37
|
private showErrorFn: ((message: string, detail?: string) => void) | null = null
|
|
38
38
|
private showSuccessFn: ((message: string, detail?: string, action?: Toast['action'], onDetailClick?: () => void) => void) | null = null
|
|
39
|
+
private showToastFn: ToastContextType['showToast'] | null = null
|
|
39
40
|
|
|
40
41
|
static getInstance(): ToastManager {
|
|
41
42
|
if (!ToastManager.instance) {
|
|
@@ -44,14 +45,16 @@ class ToastManager {
|
|
|
44
45
|
return ToastManager.instance
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
register(showError: typeof this.showErrorFn, showSuccess: typeof this.showSuccessFn) {
|
|
48
|
+
register(showError: typeof this.showErrorFn, showSuccess: typeof this.showSuccessFn, showToast: ToastContextType['showToast']) {
|
|
48
49
|
this.showErrorFn = showError
|
|
49
50
|
this.showSuccessFn = showSuccess
|
|
51
|
+
this.showToastFn = showToast
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
unregister() {
|
|
53
55
|
this.showErrorFn = null
|
|
54
56
|
this.showSuccessFn = null
|
|
57
|
+
this.showToastFn = null
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
showError(message: string, detail?: string) {
|
|
@@ -61,6 +64,10 @@ class ToastManager {
|
|
|
61
64
|
showSuccess(message: string, detail?: string, action?: Toast['action'], onDetailClick?: () => void) {
|
|
62
65
|
this.showSuccessFn ? this.showSuccessFn(message, detail, action, onDetailClick) : console.log('[Toast]', message, detail)
|
|
63
66
|
}
|
|
67
|
+
|
|
68
|
+
showNotice(message: string, detail: string | undefined, type: 'warning' | 'info') {
|
|
69
|
+
this.showToastFn ? this.showToastFn(message, { detail, type }) : console.warn('[Toast]', message, detail)
|
|
70
|
+
}
|
|
64
71
|
}
|
|
65
72
|
|
|
66
73
|
const toastManager = ToastManager.getInstance()
|
|
@@ -73,6 +80,10 @@ export function showApiSuccess(message: string, detail?: string, action?: Toast[
|
|
|
73
80
|
toastManager.showSuccess(message, detail, action, onDetailClick)
|
|
74
81
|
}
|
|
75
82
|
|
|
83
|
+
export function showApiNotice(message: string, detail: string | undefined, type: 'warning' | 'info') {
|
|
84
|
+
toastManager.showNotice(message, detail, type)
|
|
85
|
+
}
|
|
86
|
+
|
|
76
87
|
export function useToast() {
|
|
77
88
|
const context = useContext(ToastContext)
|
|
78
89
|
if (!context) {
|
|
@@ -138,9 +149,9 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
|
|
138
149
|
|
|
139
150
|
// Wire up singleton for use outside React components
|
|
140
151
|
useEffect(() => {
|
|
141
|
-
toastManager.register(showError, showSuccess)
|
|
152
|
+
toastManager.register(showError, showSuccess, showToast)
|
|
142
153
|
return () => toastManager.unregister()
|
|
143
|
-
}, [showError, showSuccess])
|
|
154
|
+
}, [showError, showSuccess, showToast])
|
|
144
155
|
|
|
145
156
|
return (
|
|
146
157
|
<ToastContext.Provider value={{ showToast, showCopied, showError, showSuccess }}>
|
|
@@ -233,7 +244,7 @@ function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }
|
|
|
233
244
|
{toast.detail}
|
|
234
245
|
</button>
|
|
235
246
|
) : (
|
|
236
|
-
<p className={clsx('mt-1 text-xs break-words', isError ? 'text-red-300/80' : isSuccess ? 'text-emerald-300/80' : 'text-theme-text-secondary')}>
|
|
247
|
+
<p className={clsx('mt-1 text-xs break-words whitespace-pre-line', isError ? 'text-red-300/80' : isSuccess ? 'text-emerald-300/80' : 'text-theme-text-secondary')}>
|
|
237
248
|
{toast.detail}
|
|
238
249
|
</p>
|
|
239
250
|
)
|
|
@@ -33,7 +33,7 @@ export { Disclosure } from './Disclosure'
|
|
|
33
33
|
export { ResourceBar } from './ResourceBar'
|
|
34
34
|
export { ForceDeleteConfirmDialog } from './ForceDeleteConfirmDialog'
|
|
35
35
|
export { InClusterConsentDialog } from './InClusterConsentDialog'
|
|
36
|
-
export { ToastProvider, useToast, showApiError, showApiSuccess } from './Toast'
|
|
36
|
+
export { ToastProvider, useToast, showApiError, showApiSuccess, showApiNotice } from './Toast'
|
|
37
37
|
export { CodeViewer } from './CodeViewer'
|
|
38
38
|
export { YamlEditor, YamlDiffEditor } from './YamlEditor'
|
|
39
39
|
// Boots the self-hosted Monaco runtime (bundled editor, local workers) without
|