@skyhook-io/k8s-ui 1.17.1 → 1.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/package.json +4 -4
- package/src/components/compare/CompareResourcePicker.tsx +18 -4
- package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -2
- package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
- package/src/components/gitops/GitOpsTableView.tsx +33 -4
- package/src/components/gitops/detail-helpers.test.ts +58 -13
- package/src/components/gitops/detail-helpers.ts +37 -6
- package/src/components/gitops/index.ts +1 -0
- 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 +18 -10
- 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 +183 -60
- package/src/components/logs/LogsViewer.tsx +49 -12
- 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/namespace-switcher/NamespacePicker.test.tsx +236 -2
- package/src/components/namespace-switcher/NamespacePicker.tsx +42 -8
- package/src/components/resources/ColumnPickerList.test.tsx +46 -0
- package/src/components/resources/ColumnPickerList.tsx +174 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
- package/src/components/resources/ResourcesSidebar.tsx +36 -21
- package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -2
- package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
- package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
- package/src/components/resources/ResourcesView.tsx +198 -93
- 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.test.tsx +115 -0
- package/src/components/resources/renderers/PodRenderer.tsx +72 -20
- package/src/components/resources/resource-utils-trivy.test.ts +87 -0
- package/src/components/resources/resource-utils-trivy.ts +122 -0
- package/src/components/resources/resource-utils.ts +15 -2
- package/src/components/resources/summarize-scheduler-message.test.ts +1 -0
- package/src/components/shared/DetailShell.tsx +9 -7
- package/src/components/shared/DrainPlanDialog.test.tsx +7 -0
- package/src/components/shared/DrainPlanDialog.tsx +6 -2
- package/src/components/shared/ManagedByChip.test.tsx +22 -0
- package/src/components/shared/ManagedByChip.tsx +24 -4
- package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
- package/src/components/shared/ResourceActionsBar.tsx +12 -6
- package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +48 -31
- package/src/components/timeline/DiffViewer.test.tsx +69 -0
- package/src/components/timeline/DiffViewer.tsx +113 -4
- package/src/components/timeline/TimelineList.tsx +5 -1
- package/src/components/timeline/shared.test.tsx +48 -0
- package/src/components/timeline/shared.tsx +14 -19
- package/src/components/ui/FetchResult.test.tsx +19 -2
- package/src/components/ui/FetchResult.tsx +13 -1
- package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
- package/src/components/ui/MultiSelectPicker.tsx +20 -2
- package/src/components/ui/PageHeader.tsx +6 -4
- package/src/components/ui/Toast.tsx +48 -30
- package/src/components/ui/drawer-components.test.tsx +253 -0
- package/src/components/ui/drawer-components.tsx +315 -11
- package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
- package/src/components/ui/index.ts +1 -1
- package/src/components/workload/WorkloadView.relationships.test.tsx +33 -0
- package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
- package/src/components/workload/WorkloadView.tsx +68 -14
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/usePhoneLayout.ts +12 -0
- package/src/theme/tailwind-theme.css +5 -0
- package/src/types/core.ts +91 -3
- package/src/types/gitops-flux-inventory.test.ts +24 -0
- package/src/types/gitops-insights.ts +1 -0
- package/src/types/gitops-tree.ts +2 -0
- package/src/types/gitops.ts +5 -5
- package/src/types/index.ts +3 -0
- package/src/types/read-coverage.ts +65 -0
- package/src/types/resource-coverage.ts +40 -0
- package/src/utils/format.test.ts +7 -0
- package/src/utils/format.ts +6 -1
- package/src/utils/gitops-owner.test.ts +42 -1
- package/src/utils/gitops-owner.ts +22 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/read-actions.test.ts +22 -0
- package/src/utils/read-actions.ts +56 -0
- package/src/utils/resource-coverage.test.ts +121 -0
- package/src/utils/resource-coverage.ts +117 -0
|
@@ -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
|
|
@@ -216,6 +216,10 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
|
|
|
216
216
|
const singleEvents: TimelineEvent[] = []
|
|
217
217
|
|
|
218
218
|
for (const item of items) {
|
|
219
|
+
if (item.id === selectedEventId) {
|
|
220
|
+
singleEvents.push(item)
|
|
221
|
+
continue
|
|
222
|
+
}
|
|
219
223
|
if (foldPerResource?.(item)) {
|
|
220
224
|
const key = `fold:${item.kind}:${item.namespace}:${item.name}`
|
|
221
225
|
const existing = groups.get(key) || []
|
|
@@ -344,7 +348,7 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
|
|
|
344
348
|
if (older.length > 0) groups.push({ label: 'Older', items: aggregateEvents(older) })
|
|
345
349
|
|
|
346
350
|
return groups
|
|
347
|
-
}, [filteredActivity, compact, foldPerResource])
|
|
351
|
+
}, [filteredActivity, compact, foldPerResource, selectedEventId])
|
|
348
352
|
|
|
349
353
|
// Visible-window reporting: rows carry their time span in data attributes;
|
|
350
354
|
// on scroll (rAF-throttled) and whenever the row set changes, the span of
|
|
@@ -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
|
/**
|
|
@@ -54,11 +54,12 @@ describe('FetchResult', () => {
|
|
|
54
54
|
expect(html).toContain('Resource cache not available')
|
|
55
55
|
})
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
// kind_sync_failed is how Radar releases before kind_sync_timed_out spelled it.
|
|
58
|
+
it.each(['kind_sync_timed_out', 'kind_sync_failed'])('does not blame the cluster for a %s 503', (code) => {
|
|
58
59
|
const html = renderToString(
|
|
59
60
|
<FetchResult
|
|
60
61
|
loading={false}
|
|
61
|
-
error={shaped('pods failed to load within the sync deadline', 503, { error_code:
|
|
62
|
+
error={shaped('pods failed to load within the sync deadline', 503, { error_code: code })}
|
|
62
63
|
/>,
|
|
63
64
|
)
|
|
64
65
|
expect(html).not.toContain('Cluster unavailable')
|
|
@@ -66,6 +67,22 @@ describe('FetchResult', () => {
|
|
|
66
67
|
expect(html).toContain('pods failed to load within the sync deadline')
|
|
67
68
|
})
|
|
68
69
|
|
|
70
|
+
it('blames Radar\'s collector, not the cluster, for a collector failure', () => {
|
|
71
|
+
const html = renderToString(
|
|
72
|
+
<FetchResult loading={false} error={shaped('Radar is not allowed to list pods', 503, { error_code: 'radar_forbidden' })} />,
|
|
73
|
+
)
|
|
74
|
+
expect(html).toContain('Radar can')
|
|
75
|
+
expect(html).not.toContain('Cluster unavailable')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('keeps a disconnected cluster reading as unavailable', () => {
|
|
79
|
+
const html = renderToString(
|
|
80
|
+
<FetchResult loading={false} error={shaped('Not connected to cluster', 503, { error_code: 'cluster_disconnected' })} />,
|
|
81
|
+
)
|
|
82
|
+
expect(html).toContain('Cluster unavailable')
|
|
83
|
+
expect(html).not.toContain('Radar can')
|
|
84
|
+
})
|
|
85
|
+
|
|
69
86
|
it('renders a Retry button only when onRetry is given', () => {
|
|
70
87
|
const err = shaped('internal server error', 500)
|
|
71
88
|
expect(renderToString(<FetchResult loading={false} error={err} onRetry={() => {}} />)).toContain('Retry')
|
|
@@ -3,6 +3,7 @@ import { ShieldOff, AlertTriangle, ServerCrash, LogIn, Copy, Check, RefreshCw, t
|
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
4
|
import { PaneLoader } from './PaneLoader'
|
|
5
5
|
import { isFetchError } from '../../types/fetch-error'
|
|
6
|
+
import { readActionFor, canonicalReadCode } from '../../utils/read-actions'
|
|
6
7
|
|
|
7
8
|
// FetchResult collapses (loading, error, no-data) into one rendered outcome:
|
|
8
9
|
// loader while loading, a typed error surface when the fetch threw, or the
|
|
@@ -15,6 +16,7 @@ import { isFetchError } from '../../types/fetch-error'
|
|
|
15
16
|
// error (403) → "Access denied" + error.message
|
|
16
17
|
// error (404) → notFoundMessage + error.message
|
|
17
18
|
// error (503 sync failed) → "Couldn't load this view" + error.message
|
|
19
|
+
// error (503 collector) → "Radar can't read this" + error.message
|
|
18
20
|
// error (503) → "Cluster unavailable" + error.message
|
|
19
21
|
// error (401) → "Sign-in required" (apiFetch redirects; fallback)
|
|
20
22
|
// error (other / no shape) → "Couldn't load this view" + error.message
|
|
@@ -118,9 +120,19 @@ function classify(error: unknown, notFoundMessage: string): Classified {
|
|
|
118
120
|
case 503:
|
|
119
121
|
// The cluster is connected; one kind's informer missed its sync
|
|
120
122
|
// deadline, so "Cluster unavailable" would send the reader the wrong way.
|
|
121
|
-
if (errorCodeOf(error) === '
|
|
123
|
+
if (canonicalReadCode(errorCodeOf(error)) === 'kind_sync_timed_out') {
|
|
122
124
|
return { headline: "Couldn't load this view", detail: error.message, icon: AlertTriangle }
|
|
123
125
|
}
|
|
126
|
+
// Shares the collector's action, but it is the cluster connection
|
|
127
|
+
// that is down rather than one read.
|
|
128
|
+
if (errorCodeOf(error) === 'cluster_disconnected') {
|
|
129
|
+
return { headline: 'Cluster unavailable', detail: error.message, icon: ServerCrash }
|
|
130
|
+
}
|
|
131
|
+
// Radar's own identity or collection can't serve the read: neither
|
|
132
|
+
// the cluster being down nor the reader's access.
|
|
133
|
+
if (readActionFor(errorCodeOf(error)) === 'radar_config') {
|
|
134
|
+
return { headline: "Radar can't read this", detail: error.message, icon: ShieldOff }
|
|
135
|
+
}
|
|
124
136
|
// The cluster is healthy and still loading — the retry policies keep
|
|
125
137
|
// these surfaces polling, but a caller without one must not tell the
|
|
126
138
|
// user a connecting cluster is down.
|
|
@@ -36,6 +36,26 @@ describe('MultiSelectPicker', () => {
|
|
|
36
36
|
expect(html).toContain('>Done</button>')
|
|
37
37
|
})
|
|
38
38
|
|
|
39
|
+
it('adds an Only button to each row only when a select-only handler is given', () => {
|
|
40
|
+
expect(render()).not.toContain('>Only</button>')
|
|
41
|
+
|
|
42
|
+
const html = render({ onSelectOnly: () => {} })
|
|
43
|
+
expect(html.match(/>Only<\/button>/g)).toHaveLength(KINDS.length)
|
|
44
|
+
expect(html).toContain('aria-label="Show only Pod"')
|
|
45
|
+
// The name stays inside the checkbox label, so clicking it still toggles.
|
|
46
|
+
expect(html).toMatch(/<label[^>]*><span[^>]*><input type="checkbox"[^>]*\/><span class="truncate">Pod<\/span><\/span><\/label><button/)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('hides Only until hover or keyboard focus, and keeps it visible on touch', () => {
|
|
50
|
+
const html = render({ onSelectOnly: () => {} })
|
|
51
|
+
const only = html.match(/<button[^>]*aria-label="Show only Pod"[^>]*>/)?.[0] ?? ''
|
|
52
|
+
expect(only).toContain('opacity-0')
|
|
53
|
+
expect(only).toContain('group-hover/row:opacity-100')
|
|
54
|
+
expect(only).toContain('group-has-[:focus-visible]/row:opacity-100')
|
|
55
|
+
expect(only).toContain('focus-visible:opacity-100')
|
|
56
|
+
expect(only).toContain('any-pointer-coarse:opacity-100')
|
|
57
|
+
})
|
|
58
|
+
|
|
39
59
|
it('shows the filter input above the threshold and hides it below', () => {
|
|
40
60
|
const shown = render()
|
|
41
61
|
expect(shown).toContain('placeholder="Filter kinds"')
|
|
@@ -16,6 +16,12 @@ export interface MultiSelectPickerProps {
|
|
|
16
16
|
onClearAll: () => void
|
|
17
17
|
/** Footer "Done" button. */
|
|
18
18
|
onDone: () => void
|
|
19
|
+
/**
|
|
20
|
+
* When set, each row gets an "Only" button that selects just that item. The
|
|
21
|
+
* name and checkbox still toggle. The host decides whether it also commits
|
|
22
|
+
* and closes.
|
|
23
|
+
*/
|
|
24
|
+
onSelectOnly?: (item: string) => void
|
|
19
25
|
|
|
20
26
|
/** Controlled search term (kept in the host so the body stays SSR-testable). */
|
|
21
27
|
search: string
|
|
@@ -50,6 +56,7 @@ export function MultiSelectPicker({
|
|
|
50
56
|
onSelectionChange,
|
|
51
57
|
onClearAll,
|
|
52
58
|
onDone,
|
|
59
|
+
onSelectOnly,
|
|
53
60
|
search,
|
|
54
61
|
onSearchChange,
|
|
55
62
|
searchPlaceholder,
|
|
@@ -136,8 +143,8 @@ export function MultiSelectPicker({
|
|
|
136
143
|
)}
|
|
137
144
|
|
|
138
145
|
{filteredItems.map(item => (
|
|
139
|
-
<li key={item}>
|
|
140
|
-
<label className="w-
|
|
146
|
+
<li key={item} className="group/row flex items-center hover:bg-theme-hover">
|
|
147
|
+
<label className="flex-1 min-w-0 flex items-center px-3 py-1.5 text-sm text-left text-theme-text-primary cursor-pointer">
|
|
141
148
|
<span className="flex items-center gap-2 min-w-0">
|
|
142
149
|
<input
|
|
143
150
|
type="checkbox"
|
|
@@ -149,6 +156,17 @@ export function MultiSelectPicker({
|
|
|
149
156
|
{renderItemMeta?.(item)}
|
|
150
157
|
</span>
|
|
151
158
|
</label>
|
|
159
|
+
{onSelectOnly && (
|
|
160
|
+
// Touchscreens can coexist with a hover-capable mouse.
|
|
161
|
+
<button
|
|
162
|
+
type="button"
|
|
163
|
+
onClick={() => onSelectOnly(item)}
|
|
164
|
+
aria-label={`Show only ${item}`}
|
|
165
|
+
className="shrink-0 mr-2 px-1.5 py-0.5 rounded border border-theme-border bg-theme-surface text-[11px] text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary opacity-0 transition-opacity group-hover/row:opacity-100 group-has-[:focus-visible]/row:opacity-100 focus-visible:opacity-100 any-pointer-coarse:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
|
166
|
+
>
|
|
167
|
+
Only
|
|
168
|
+
</button>
|
|
169
|
+
)}
|
|
152
170
|
</li>
|
|
153
171
|
))}
|
|
154
172
|
</ul>
|
|
@@ -18,7 +18,9 @@ interface PageHeaderProps {
|
|
|
18
18
|
|
|
19
19
|
export function PageHeader({ icon: Icon, title, description, onBack, actions }: PageHeaderProps) {
|
|
20
20
|
return (
|
|
21
|
-
|
|
21
|
+
// Phones: actions (freshness, summary tiles) wrap under the title
|
|
22
|
+
// instead of squeezing it to nothing.
|
|
23
|
+
<div className="flex items-center gap-4 phone:flex-wrap phone:gap-y-3">
|
|
22
24
|
{onBack && (
|
|
23
25
|
<button
|
|
24
26
|
onClick={onBack}
|
|
@@ -28,16 +30,16 @@ export function PageHeader({ icon: Icon, title, description, onBack, actions }:
|
|
|
28
30
|
<ArrowLeft className="w-5 h-5 text-theme-text-secondary" />
|
|
29
31
|
</button>
|
|
30
32
|
)}
|
|
31
|
-
<div className="flex-1 min-w-0">
|
|
33
|
+
<div className="flex-1 min-w-0 phone:basis-full">
|
|
32
34
|
<div className="flex items-center gap-2">
|
|
33
35
|
<Icon className="w-5 h-5 text-theme-text-secondary shrink-0" />
|
|
34
36
|
<h1 className="text-lg font-semibold text-theme-text-primary truncate">{title}</h1>
|
|
35
37
|
</div>
|
|
36
38
|
{description && (
|
|
37
|
-
<p className="text-sm text-theme-text-tertiary mt-1 ml-7 truncate">{description}</p>
|
|
39
|
+
<p className="text-sm text-theme-text-tertiary mt-1 ml-7 truncate phone:whitespace-normal phone:line-clamp-2">{description}</p>
|
|
38
40
|
)}
|
|
39
41
|
</div>
|
|
40
|
-
{actions && <div className="flex flex-wrap items-center justify-end gap-2 shrink-0">{actions}</div>}
|
|
42
|
+
{actions && <div className="flex flex-wrap items-center justify-end gap-2 shrink-0 phone:shrink phone:justify-start">{actions}</div>}
|
|
41
43
|
</div>
|
|
42
44
|
)
|
|
43
45
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react'
|
|
1
|
+
import { createContext, useContext, useState, useCallback, ReactNode, useEffect, useRef } from 'react'
|
|
2
2
|
import { DURATION_TOAST_EXIT } from '../../utils/animation'
|
|
3
3
|
import { Check, Terminal, X, AlertTriangle, Info } from 'lucide-react'
|
|
4
4
|
import { clsx } from 'clsx'
|
|
@@ -15,15 +15,13 @@ interface Toast {
|
|
|
15
15
|
onDetailClick?: () => void
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
type ToastOptions = Omit<Toast, 'id' | 'message' | 'dismissing'> & {
|
|
19
|
+
id?: string
|
|
20
|
+
duration?: number
|
|
21
|
+
}
|
|
22
|
+
|
|
18
23
|
interface ToastContextType {
|
|
19
|
-
showToast: (message: string, options?:
|
|
20
|
-
detail?: string
|
|
21
|
-
command?: string
|
|
22
|
-
type?: Toast['type']
|
|
23
|
-
position?: { x: number; y: number }
|
|
24
|
-
action?: Toast['action']
|
|
25
|
-
onDetailClick?: () => void
|
|
26
|
-
}) => void
|
|
24
|
+
showToast: (message: string, options?: ToastOptions) => void
|
|
27
25
|
showCopied: (command: string, label?: string, event?: React.MouseEvent) => void
|
|
28
26
|
showError: (message: string, detail?: string) => void
|
|
29
27
|
showSuccess: (message: string, detail?: string, action?: Toast['action'], onDetailClick?: () => void) => void
|
|
@@ -36,6 +34,7 @@ class ToastManager {
|
|
|
36
34
|
private static instance: ToastManager
|
|
37
35
|
private showErrorFn: ((message: string, detail?: string) => void) | null = null
|
|
38
36
|
private showSuccessFn: ((message: string, detail?: string, action?: Toast['action'], onDetailClick?: () => void) => void) | null = null
|
|
37
|
+
private showToastFn: ToastContextType['showToast'] | null = null
|
|
39
38
|
|
|
40
39
|
static getInstance(): ToastManager {
|
|
41
40
|
if (!ToastManager.instance) {
|
|
@@ -44,14 +43,16 @@ class ToastManager {
|
|
|
44
43
|
return ToastManager.instance
|
|
45
44
|
}
|
|
46
45
|
|
|
47
|
-
register(showError: typeof this.showErrorFn, showSuccess: typeof this.showSuccessFn) {
|
|
46
|
+
register(showError: typeof this.showErrorFn, showSuccess: typeof this.showSuccessFn, showToast: ToastContextType['showToast']) {
|
|
48
47
|
this.showErrorFn = showError
|
|
49
48
|
this.showSuccessFn = showSuccess
|
|
49
|
+
this.showToastFn = showToast
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
unregister() {
|
|
53
53
|
this.showErrorFn = null
|
|
54
54
|
this.showSuccessFn = null
|
|
55
|
+
this.showToastFn = null
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
showError(message: string, detail?: string) {
|
|
@@ -61,6 +62,10 @@ class ToastManager {
|
|
|
61
62
|
showSuccess(message: string, detail?: string, action?: Toast['action'], onDetailClick?: () => void) {
|
|
62
63
|
this.showSuccessFn ? this.showSuccessFn(message, detail, action, onDetailClick) : console.log('[Toast]', message, detail)
|
|
63
64
|
}
|
|
65
|
+
|
|
66
|
+
showNotice(message: string, detail: string | undefined, type: 'warning' | 'info') {
|
|
67
|
+
this.showToastFn ? this.showToastFn(message, { detail, type }) : console.warn('[Toast]', message, detail)
|
|
68
|
+
}
|
|
64
69
|
}
|
|
65
70
|
|
|
66
71
|
const toastManager = ToastManager.getInstance()
|
|
@@ -73,6 +78,10 @@ export function showApiSuccess(message: string, detail?: string, action?: Toast[
|
|
|
73
78
|
toastManager.showSuccess(message, detail, action, onDetailClick)
|
|
74
79
|
}
|
|
75
80
|
|
|
81
|
+
export function showApiNotice(message: string, detail: string | undefined, type: 'warning' | 'info') {
|
|
82
|
+
toastManager.showNotice(message, detail, type)
|
|
83
|
+
}
|
|
84
|
+
|
|
76
85
|
export function useToast() {
|
|
77
86
|
const context = useContext(ToastContext)
|
|
78
87
|
if (!context) {
|
|
@@ -83,34 +92,42 @@ export function useToast() {
|
|
|
83
92
|
|
|
84
93
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
|
85
94
|
const [toasts, setToasts] = useState<Toast[]>([])
|
|
95
|
+
const timers = useRef(new Map<string, ReturnType<typeof setTimeout>>())
|
|
86
96
|
|
|
87
97
|
const animateDismiss = useCallback((id: string) => {
|
|
98
|
+
clearTimeout(timers.current.get(id))
|
|
88
99
|
setToasts(prev => prev.map(t => t.id === id ? { ...t, dismissing: true } : t))
|
|
89
|
-
setTimeout(() => {
|
|
100
|
+
timers.current.set(id, setTimeout(() => {
|
|
90
101
|
setToasts(prev => prev.filter(t => t.id !== id))
|
|
91
|
-
|
|
102
|
+
timers.current.delete(id)
|
|
103
|
+
}, DURATION_TOAST_EXIT))
|
|
92
104
|
}, [])
|
|
93
105
|
|
|
94
|
-
const showToast = useCallback((message: string, options?: {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
type?: Toast['type']
|
|
98
|
-
position?: { x: number; y: number }
|
|
99
|
-
action?: Toast['action']
|
|
100
|
-
onDetailClick?: () => void
|
|
101
|
-
}) => {
|
|
102
|
-
const id = Math.random().toString(36).slice(2)
|
|
103
|
-
const toast: Toast = { id, message, ...options }
|
|
106
|
+
const showToast = useCallback((message: string, options?: ToastOptions) => {
|
|
107
|
+
const { id = Math.random().toString(36).slice(2), duration, ...toastOptions } = options ?? {}
|
|
108
|
+
const toast: Toast = { id, message, ...toastOptions }
|
|
104
109
|
|
|
105
|
-
|
|
110
|
+
clearTimeout(timers.current.get(id))
|
|
111
|
+
timers.current.delete(id)
|
|
112
|
+
setToasts(prev => prev.some(t => t.id === id)
|
|
113
|
+
? prev.map(t => t.id === id ? toast : t)
|
|
114
|
+
: [...prev, toast])
|
|
106
115
|
|
|
107
116
|
// Auto-dismiss: errors stay longer (10s), others 7s
|
|
108
|
-
const dismissTime =
|
|
109
|
-
|
|
110
|
-
animateDismiss(id)
|
|
111
|
-
}
|
|
117
|
+
const dismissTime = duration ?? (toast.type === 'error' ? 10000 : 7000)
|
|
118
|
+
if (dismissTime > 0) {
|
|
119
|
+
timers.current.set(id, setTimeout(() => animateDismiss(id), dismissTime))
|
|
120
|
+
}
|
|
112
121
|
}, [animateDismiss])
|
|
113
122
|
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
const activeTimers = timers.current
|
|
125
|
+
return () => {
|
|
126
|
+
activeTimers.forEach(clearTimeout)
|
|
127
|
+
activeTimers.clear()
|
|
128
|
+
}
|
|
129
|
+
}, [])
|
|
130
|
+
|
|
114
131
|
const showCopied = useCallback((command: string, label?: string, event?: React.MouseEvent) => {
|
|
115
132
|
navigator.clipboard.writeText(command)
|
|
116
133
|
|
|
@@ -138,9 +155,9 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
|
|
138
155
|
|
|
139
156
|
// Wire up singleton for use outside React components
|
|
140
157
|
useEffect(() => {
|
|
141
|
-
toastManager.register(showError, showSuccess)
|
|
158
|
+
toastManager.register(showError, showSuccess, showToast)
|
|
142
159
|
return () => toastManager.unregister()
|
|
143
|
-
}, [showError, showSuccess])
|
|
160
|
+
}, [showError, showSuccess, showToast])
|
|
144
161
|
|
|
145
162
|
return (
|
|
146
163
|
<ToastContext.Provider value={{ showToast, showCopied, showError, showSuccess }}>
|
|
@@ -176,6 +193,7 @@ function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }
|
|
|
176
193
|
|
|
177
194
|
return (
|
|
178
195
|
<div
|
|
196
|
+
role="status"
|
|
179
197
|
className={clsx(
|
|
180
198
|
'flex items-start gap-3 p-4 rounded-lg shadow-2xl border backdrop-blur-sm',
|
|
181
199
|
'w-[480px] max-w-[calc(100vw-32px)]',
|
|
@@ -233,7 +251,7 @@ function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }
|
|
|
233
251
|
{toast.detail}
|
|
234
252
|
</button>
|
|
235
253
|
) : (
|
|
236
|
-
<p className={clsx('mt-1 text-xs break-words', isError ? 'text-red-300/80' : isSuccess ? 'text-emerald-300/80' : 'text-theme-text-secondary')}>
|
|
254
|
+
<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
255
|
{toast.detail}
|
|
238
256
|
</p>
|
|
239
257
|
)
|