@skyhook-io/k8s-ui 1.16.1 → 1.16.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +22 -0
  2. package/package.json +3 -3
  3. package/src/components/cnpg/CNPGBackupSummary.tsx +223 -0
  4. package/src/components/cnpg/CNPGClusterSummary.tsx +222 -0
  5. package/src/components/cnpg/CNPGDeclarativeSummary.tsx +264 -0
  6. package/src/components/cnpg/CNPGImageCatalogSummary.tsx +80 -0
  7. package/src/components/cnpg/CNPGObjectStoreSummary.tsx +176 -0
  8. package/src/components/cnpg/CNPGObjectSummary.test.tsx +236 -0
  9. package/src/components/cnpg/CNPGPoolerSummary.tsx +70 -0
  10. package/src/components/cnpg/CNPGSharedSummary.tsx +88 -0
  11. package/src/components/cnpg/index.ts +14 -0
  12. package/src/components/cnpg/primitives.tsx +134 -0
  13. package/src/components/cnpg/relations.test.ts +304 -0
  14. package/src/components/cnpg/relations.ts +417 -0
  15. package/src/components/cnpg/workspace.test.ts +285 -0
  16. package/src/components/cnpg/workspace.ts +700 -0
  17. package/src/components/dock/BottomDock.tsx +8 -1
  18. package/src/components/dock/DockContext.tsx +8 -0
  19. package/src/components/dock/LocalTerminalTab.test.tsx +189 -0
  20. package/src/components/dock/LocalTerminalTab.tsx +56 -18
  21. package/src/components/dock/TerminalTab.tsx +2 -4
  22. package/src/components/issues/IssuesView.tsx +10 -1
  23. package/src/components/issues/types.ts +24 -0
  24. package/src/components/logs/LogCore.theme.test.tsx +77 -0
  25. package/src/components/logs/LogCore.tsx +23 -2
  26. package/src/components/logs/LogsViewer.tsx +5 -1
  27. package/src/components/logs/WorkloadLogsViewer.tsx +16 -4
  28. package/src/components/logs/useLogBuffer.test.ts +17 -0
  29. package/src/components/resources/ResourcesSidebar.test.tsx +76 -0
  30. package/src/components/resources/ResourcesSidebar.tsx +183 -11
  31. package/src/components/resources/ResourcesView.tsx +5 -1
  32. package/src/components/resources/index.ts +1 -1
  33. package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +25 -0
  34. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +16 -7
  35. package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +1 -1
  36. package/src/components/resources/resource-utils-cnpg.ts +3 -3
  37. package/src/components/resources/status-overclaim.test.ts +1 -1
  38. package/src/components/shared/EditableYamlView.test.tsx +268 -0
  39. package/src/components/shared/EditableYamlView.tsx +25 -1
  40. package/src/components/timeline/TimelineList.tsx +185 -23
  41. package/src/components/timeline/TimelineListFold.test.tsx +56 -0
  42. package/src/components/timeline/TimelineToolbarState.test.tsx +98 -0
  43. package/src/components/timeline/index.ts +1 -0
  44. package/src/components/timeline/timeline-filters.ts +7 -0
  45. package/src/components/ui/SelectMenu.test.tsx +25 -0
  46. package/src/components/ui/SelectMenu.tsx +11 -9
  47. package/src/components/ui/YamlReview.test.tsx +40 -0
  48. package/src/components/ui/YamlReview.tsx +22 -1
  49. package/src/components/workload/WorkloadView.tsx +96 -5
  50. package/src/components/workload/index.ts +1 -0
  51. package/src/components/workload/workload-logs-availability.test.ts +6 -0
  52. package/src/index.ts +3 -0
  53. package/src/utils/log-level.ts +14 -0
@@ -600,7 +600,7 @@ export interface CNPGObjectStoreRecoveryWindow {
600
600
  firstRecoverabilityPoint?: string
601
601
  lastSuccessfulBackupTime?: string
602
602
  lastFailedBackupTime?: string
603
- /** A failure newer than the last success — the window has stopped advancing. */
603
+ /** A failure newer than the last success, or a failure and no success at all. */
604
604
  failingSinceLastSuccess: boolean
605
605
  }
606
606
 
@@ -641,7 +641,7 @@ export function getCNPGObjectStoreRecoveryWindows(resource: any): CNPGObjectStor
641
641
  export function getCNPGObjectStoreStatus(resource: any): StatusBadge {
642
642
  const windows = getCNPGObjectStoreRecoveryWindows(resource)
643
643
  if (windows.length === 0) {
644
- return { text: 'No backups yet', color: healthColors.unknown, level: 'unknown' }
644
+ return { text: 'No backup recorded', color: healthColors.unknown, level: 'unknown' }
645
645
  }
646
646
  if (windows.some((w) => w.failingSinceLastSuccess)) {
647
647
  return { text: 'Backups Failing', color: healthColors.unhealthy, level: 'unhealthy' }
@@ -649,7 +649,7 @@ export function getCNPGObjectStoreStatus(resource: any): StatusBadge {
649
649
  // Every timestamp on a recovery window is optional, so a server can be listed
650
650
  // with no recovery point at all. The entry existing is not a backup existing.
651
651
  if (!windows.some((w) => w.firstRecoverabilityPoint || w.lastSuccessfulBackupTime)) {
652
- return { text: 'No recovery point', color: healthColors.unknown, level: 'unknown' }
652
+ return { text: 'Recovery point not reported', color: healthColors.unknown, level: 'unknown' }
653
653
  }
654
654
  return { text: 'Recoverable', color: healthColors.healthy, level: 'healthy' }
655
655
  }
@@ -141,7 +141,7 @@ describe('getCNPGObjectStoreStatus', () => {
141
141
  // Every timestamp on a RecoveryWindow is optional, so the server can be
142
142
  // listed while holding nothing restorable.
143
143
  expect(getCNPGObjectStoreStatus({ status: { serverRecoveryWindow: { pg: {} } } }))
144
- .toMatchObject({ text: 'No recovery point', level: 'unknown' })
144
+ .toMatchObject({ text: 'Recovery point not reported', level: 'unknown' })
145
145
  })
146
146
 
147
147
  it('reports recoverable once a real point exists', () => {
@@ -0,0 +1,268 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // Applying a reviewed edit can be refused because the resource changed after
4
+ // the review. The review then refreshes against the latest version and says
5
+ // so, once. That notice belongs to one attempt: a retry that fails for another
6
+ // reason shows its own error.
7
+ import { act } from "react";
8
+ import { createRoot, type Root } from "react-dom/client";
9
+ import { afterEach, beforeEach, expect, it, vi } from "vitest";
10
+
11
+ vi.mock("../ui/YamlEditor", async () => {
12
+ const { useEffect } = await import("react");
13
+ return {
14
+ YamlEditor: () => <div />,
15
+ YamlDiffEditor: ({
16
+ onReadyChange,
17
+ }: {
18
+ onReadyChange?: (ready: boolean) => void;
19
+ }) => {
20
+ useEffect(() => onReadyChange?.(true), [onReadyChange]);
21
+ return <div />;
22
+ },
23
+ };
24
+ });
25
+
26
+ const { EditableYamlView } = await import("./EditableYamlView");
27
+
28
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
29
+ vi.stubGlobal(
30
+ "ResizeObserver",
31
+ class {
32
+ observe() {}
33
+ unobserve() {}
34
+ disconnect() {}
35
+ },
36
+ );
37
+ let root: Root;
38
+ let host: HTMLDivElement;
39
+ beforeEach(() => {
40
+ localStorage.clear();
41
+ sessionStorage.clear();
42
+ host = document.createElement("div");
43
+ document.body.appendChild(host);
44
+ root = createRoot(host);
45
+ });
46
+ afterEach(() => {
47
+ act(() => root.unmount());
48
+ host.remove();
49
+ });
50
+
51
+ const resource = { kind: "ConfigMap", namespace: "shop", name: "app" };
52
+ const data = {
53
+ apiVersion: "v1",
54
+ kind: "ConfigMap",
55
+ metadata: { name: "app", namespace: "shop" },
56
+ data: { a: "1" },
57
+ };
58
+ const reviewed = (rv: string) => ({
59
+ documents: [
60
+ {
61
+ index: 0,
62
+ kind: "ConfigMap",
63
+ name: "app",
64
+ namespace: "shop",
65
+ status: "accepted" as const,
66
+ action: "update" as const,
67
+ reviewedResourceVersion: rv,
68
+ },
69
+ ],
70
+ nonAtomic: false,
71
+ });
72
+
73
+ function render(props: {
74
+ onSave: () => Promise<void>;
75
+ onPreview: () => Promise<ReturnType<typeof reviewed>>;
76
+ saveError?: string | null;
77
+ }) {
78
+ act(() =>
79
+ root.render(
80
+ <EditableYamlView
81
+ resource={resource as never}
82
+ data={data}
83
+ onCopy={() => {}}
84
+ copied={false}
85
+ onSave={props.onSave}
86
+ onPreview={props.onPreview}
87
+ saveError={props.saveError ?? null}
88
+ />,
89
+ ),
90
+ );
91
+ }
92
+
93
+ const click = async (label: RegExp) => {
94
+ const b = [...document.querySelectorAll("button")].find((x) =>
95
+ label.test(x.textContent ?? ""),
96
+ );
97
+ if (!b) throw new Error(`no button ${label} in ${document.body.textContent}`);
98
+ await act(async () => {
99
+ b.click();
100
+ await new Promise((r) => setTimeout(r, 0));
101
+ });
102
+ };
103
+ const text = () => document.body.textContent ?? "";
104
+
105
+ it("says the resource changed after review, and only for the attempt that found it", async () => {
106
+ const onPreview = vi
107
+ .fn()
108
+ .mockResolvedValueOnce(reviewed("1")) // the review
109
+ .mockResolvedValueOnce(reviewed("2")) // refresh after the conflict: a newer version
110
+ .mockRejectedValueOnce(new Error("preview unavailable")); // refresh after the second failure
111
+ const onSave = vi
112
+ .fn()
113
+ .mockRejectedValueOnce(new Error("conflict"))
114
+ .mockRejectedValueOnce(new Error("forbidden"));
115
+ render({ onSave, onPreview });
116
+
117
+ await click(/^Edit$/);
118
+ await click(/Review changes/);
119
+ await click(/Apply reviewed changes/);
120
+ render({
121
+ onSave,
122
+ onPreview,
123
+ saveError:
124
+ "resource changed after review; review the latest version before applying",
125
+ });
126
+ expect(text()).toContain("This resource changed after your review");
127
+ expect(text()).not.toContain("review the latest version before applying");
128
+
129
+ await click(/Apply reviewed changes/);
130
+ render({
131
+ onSave,
132
+ onPreview,
133
+ saveError: "forbidden: cannot patch configmaps",
134
+ });
135
+ expect(text()).not.toContain("This resource changed after your review");
136
+ expect(text()).toContain("forbidden: cannot patch configmaps");
137
+ expect(onSave).toHaveBeenCalledTimes(2);
138
+ expect(onPreview).toHaveBeenCalledTimes(3);
139
+ });
140
+
141
+ it("says nothing extra when the refreshed review found the same version", async () => {
142
+ const onPreview = vi.fn().mockResolvedValue(reviewed("1"));
143
+ const onSave = vi.fn().mockRejectedValueOnce(new Error("webhook denied"));
144
+ render({ onSave, onPreview });
145
+ await click(/^Edit$/);
146
+ await click(/Review changes/);
147
+ await click(/Apply reviewed changes/);
148
+ render({
149
+ onSave,
150
+ onPreview,
151
+ saveError: "admission webhook denied the request",
152
+ });
153
+ expect(text()).not.toContain("This resource changed after your review");
154
+ expect(text()).toContain("admission webhook denied the request");
155
+ expect(onPreview).toHaveBeenCalledTimes(2);
156
+ });
157
+
158
+ it("doesn't call a deleted resource changed", async () => {
159
+ const gone = {
160
+ documents: [
161
+ {
162
+ index: 0,
163
+ kind: "ConfigMap",
164
+ name: "app",
165
+ namespace: "shop",
166
+ status: "rejected" as const,
167
+ message: 'configmaps "app" not found',
168
+ },
169
+ ],
170
+ nonAtomic: false,
171
+ };
172
+ const onPreview = vi
173
+ .fn()
174
+ .mockResolvedValueOnce(reviewed("1"))
175
+ .mockResolvedValueOnce(gone);
176
+ const onSave = vi.fn().mockRejectedValueOnce(new Error("not found"));
177
+ render({ onSave, onPreview });
178
+ await click(/^Edit$/);
179
+ await click(/Review changes/);
180
+ await click(/Apply reviewed changes/);
181
+ render({ onSave, onPreview, saveError: 'configmaps "app" not found' });
182
+ expect(text()).not.toContain("This resource changed after your review");
183
+ expect(text()).toContain('configmaps "app" not found');
184
+ });
185
+
186
+ it("doesn't call a switched cluster a changed resource", async () => {
187
+ const onPreview = vi
188
+ .fn()
189
+ .mockResolvedValueOnce({ ...reviewed("1"), context: "kind-a" })
190
+ .mockResolvedValueOnce({ ...reviewed("2"), context: "kind-b" });
191
+ const onSave = vi.fn().mockRejectedValueOnce(new Error("cluster changed"));
192
+ render({ onSave, onPreview });
193
+ await click(/^Edit$/);
194
+ await click(/Review changes/);
195
+ await click(/Apply reviewed changes/);
196
+ render({
197
+ onSave,
198
+ onPreview,
199
+ saveError: "the active cluster changed after review",
200
+ });
201
+ expect(text()).not.toContain("This resource changed after your review");
202
+ expect(text()).toContain("the active cluster changed after review");
203
+ });
204
+
205
+ it("ignores an earlier attempt's refresh that lands after a newer attempt", async () => {
206
+ let finishFirstRefresh: (v: ReturnType<typeof reviewed>) => void = () => {};
207
+ const onPreview = vi
208
+ .fn()
209
+ .mockResolvedValueOnce(reviewed("1"))
210
+ .mockReturnValueOnce(
211
+ new Promise<ReturnType<typeof reviewed>>((r) => {
212
+ finishFirstRefresh = r;
213
+ }),
214
+ )
215
+ .mockRejectedValueOnce(new Error("preview unavailable"));
216
+ const onSave = vi
217
+ .fn()
218
+ .mockRejectedValueOnce(new Error("conflict"))
219
+ .mockRejectedValueOnce(new Error("forbidden"));
220
+ render({ onSave, onPreview });
221
+ await click(/^Edit$/);
222
+ await click(/Review changes/);
223
+ await click(/Apply reviewed changes/);
224
+ await click(/Apply reviewed changes/);
225
+ await act(async () => {
226
+ finishFirstRefresh(reviewed("2"));
227
+ await new Promise((r) => setTimeout(r, 0));
228
+ });
229
+ render({
230
+ onSave,
231
+ onPreview,
232
+ saveError: "forbidden: cannot patch configmaps",
233
+ });
234
+ expect(text()).not.toContain("This resource changed after your review");
235
+ expect(text()).toContain("forbidden: cannot patch configmaps");
236
+ });
237
+
238
+ it("drops a refresh from an apply made before going back to a newer review", async () => {
239
+ let finishFirstRefresh: (v: ReturnType<typeof reviewed>) => void = () => {};
240
+ const onPreview = vi
241
+ .fn()
242
+ .mockResolvedValueOnce(reviewed("1"))
243
+ .mockReturnValueOnce(
244
+ new Promise<ReturnType<typeof reviewed>>((r) => {
245
+ finishFirstRefresh = r;
246
+ }),
247
+ )
248
+ .mockResolvedValueOnce(reviewed("3"));
249
+ const onSave = vi
250
+ .fn()
251
+ .mockRejectedValueOnce(new Error("conflict"))
252
+ .mockResolvedValueOnce(undefined);
253
+ render({ onSave, onPreview });
254
+ await click(/^Edit$/);
255
+ await click(/Review changes/);
256
+ await click(/Apply reviewed changes/);
257
+ await click(/Back to edit/);
258
+ await click(/Review changes/);
259
+ await act(async () => {
260
+ finishFirstRefresh(reviewed("2"));
261
+ await new Promise((r) => setTimeout(r, 0));
262
+ });
263
+ expect(text()).not.toContain("This resource changed after your review");
264
+ await click(/Apply reviewed changes/);
265
+ expect(onSave).toHaveBeenLastCalledWith(
266
+ expect.objectContaining({ reviewedResourceVersion: "3" }),
267
+ );
268
+ });
@@ -202,6 +202,9 @@ export function EditableYamlView({
202
202
  // All sessionStorage calls are wrapped in try-catch — storage can throw
203
203
  // QuotaExceededError or be blocked by browser security policies.
204
204
  const savedDraft = useRef(readOnly ? null : safeSessionGet(draftKey))
205
+ // Bumped by every apply and every change of review, so a refresh that lands
206
+ // late never replaces the review that is on screen now.
207
+ const reviewGeneration = useRef(0)
205
208
  const [isEditing, setIsEditing] = useState(savedDraft.current !== null)
206
209
  const [editedYaml, setEditedYaml] = useState(savedDraft.current ?? '')
207
210
  const [yamlErrors, setYamlErrors] = useState<string[]>([])
@@ -216,6 +219,8 @@ export function EditableYamlView({
216
219
  documents: YamlPreviewResult[]
217
220
  nonAtomic: boolean
218
221
  context?: string
222
+ // An apply failed and the refreshed review found a newer resource version.
223
+ changedSinceReview?: boolean
219
224
  } | null>(null)
220
225
 
221
226
  // Clean up restored draft flag
@@ -253,6 +258,7 @@ export function EditableYamlView({
253
258
 
254
259
  const handleStartEdit = useCallback(() => {
255
260
  if (readOnly) return
261
+ reviewGeneration.current++
256
262
  setEditedYaml(resourceToYaml(data))
257
263
  setYamlErrors([])
258
264
  setPreview(null)
@@ -260,6 +266,7 @@ export function EditableYamlView({
260
266
  }, [data, readOnly])
261
267
 
262
268
  const handleCancelEdit = useCallback(() => {
269
+ reviewGeneration.current++
263
270
  setIsEditing(false)
264
271
  setEditedYaml('')
265
272
  setYamlErrors([])
@@ -268,6 +275,7 @@ export function EditableYamlView({
268
275
 
269
276
  const handleSaveEdit = useCallback(async () => {
270
277
  if (yamlErrors.length > 0 || !onSave) return
278
+ reviewGeneration.current++
271
279
 
272
280
  try {
273
281
  if (onPreview) {
@@ -308,6 +316,10 @@ export function EditableYamlView({
308
316
 
309
317
  const handleApplyReviewed = useCallback(async () => {
310
318
  if (!preview || !onSave) return
319
+ // The notice belongs to one attempt: a retry that fails for another reason
320
+ // (and can't refresh) must show its own error, not the last notice.
321
+ const attempt = ++reviewGeneration.current
322
+ if (preview.changedSinceReview) setPreview({ ...preview, changedSinceReview: false })
311
323
  try {
312
324
  await onSave({
313
325
  kind: resource.kind,
@@ -335,11 +347,19 @@ export function EditableYamlView({
335
347
  name: resource.name,
336
348
  },
337
349
  })
350
+ if (attempt !== reviewGeneration.current) return
351
+ // Changed means the same resource, on the same cluster, at a newer
352
+ // version. A deleted resource has no version, and a cluster switch
353
+ // is its own error.
354
+ const before = preview.documents[0]?.reviewedResourceVersion
355
+ const after = refreshed.documents[0]?.reviewedResourceVersion
338
356
  setPreview({
339
357
  ...preview,
340
358
  documents: refreshed.documents,
341
359
  nonAtomic: refreshed.nonAtomic,
342
360
  context: refreshed.context,
361
+ changedSinceReview:
362
+ !!after && after !== before && refreshed.context === preview.context,
343
363
  })
344
364
  } catch {
345
365
  // Keep the last review visible when refresh is unavailable.
@@ -368,7 +388,11 @@ export function EditableYamlView({
368
388
  force={preview.force}
369
389
  isApplying={isSaving}
370
390
  applyError={saveError}
371
- onBack={() => setPreview(null)}
391
+ changedSinceReview={preview.changedSinceReview}
392
+ onBack={() => {
393
+ reviewGeneration.current++
394
+ setPreview(null)
395
+ }}
372
396
  onApply={handleApplyReviewed}
373
397
  />
374
398
  )