@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.
- package/README.md +22 -0
- package/package.json +3 -3
- package/src/components/cnpg/CNPGBackupSummary.tsx +223 -0
- package/src/components/cnpg/CNPGClusterSummary.tsx +222 -0
- package/src/components/cnpg/CNPGDeclarativeSummary.tsx +264 -0
- package/src/components/cnpg/CNPGImageCatalogSummary.tsx +80 -0
- package/src/components/cnpg/CNPGObjectStoreSummary.tsx +176 -0
- package/src/components/cnpg/CNPGObjectSummary.test.tsx +236 -0
- package/src/components/cnpg/CNPGPoolerSummary.tsx +70 -0
- package/src/components/cnpg/CNPGSharedSummary.tsx +88 -0
- package/src/components/cnpg/index.ts +14 -0
- package/src/components/cnpg/primitives.tsx +134 -0
- package/src/components/cnpg/relations.test.ts +304 -0
- package/src/components/cnpg/relations.ts +417 -0
- package/src/components/cnpg/workspace.test.ts +285 -0
- package/src/components/cnpg/workspace.ts +700 -0
- package/src/components/dock/BottomDock.tsx +8 -1
- package/src/components/dock/DockContext.tsx +8 -0
- package/src/components/dock/LocalTerminalTab.test.tsx +189 -0
- package/src/components/dock/LocalTerminalTab.tsx +56 -18
- package/src/components/dock/TerminalTab.tsx +2 -4
- package/src/components/issues/IssuesView.tsx +10 -1
- package/src/components/issues/types.ts +24 -0
- package/src/components/logs/LogCore.theme.test.tsx +77 -0
- package/src/components/logs/LogCore.tsx +23 -2
- package/src/components/logs/LogsViewer.tsx +5 -1
- package/src/components/logs/WorkloadLogsViewer.tsx +16 -4
- package/src/components/logs/useLogBuffer.test.ts +17 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +76 -0
- package/src/components/resources/ResourcesSidebar.tsx +183 -11
- package/src/components/resources/ResourcesView.tsx +5 -1
- package/src/components/resources/index.ts +1 -1
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +25 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +16 -7
- package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +1 -1
- package/src/components/resources/resource-utils-cnpg.ts +3 -3
- package/src/components/resources/status-overclaim.test.ts +1 -1
- package/src/components/shared/EditableYamlView.test.tsx +268 -0
- package/src/components/shared/EditableYamlView.tsx +25 -1
- package/src/components/timeline/TimelineList.tsx +185 -23
- package/src/components/timeline/TimelineListFold.test.tsx +56 -0
- package/src/components/timeline/TimelineToolbarState.test.tsx +98 -0
- package/src/components/timeline/index.ts +1 -0
- package/src/components/timeline/timeline-filters.ts +7 -0
- package/src/components/ui/SelectMenu.test.tsx +25 -0
- package/src/components/ui/SelectMenu.tsx +11 -9
- package/src/components/ui/YamlReview.test.tsx +40 -0
- package/src/components/ui/YamlReview.tsx +22 -1
- package/src/components/workload/WorkloadView.tsx +96 -5
- package/src/components/workload/index.ts +1 -0
- package/src/components/workload/workload-logs-availability.test.ts +6 -0
- package/src/index.ts +3 -0
- 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
|
|
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
|
|
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: '
|
|
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: '
|
|
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
|
-
|
|
391
|
+
changedSinceReview={preview.changedSinceReview}
|
|
392
|
+
onBack={() => {
|
|
393
|
+
reviewGeneration.current++
|
|
394
|
+
setPreview(null)
|
|
395
|
+
}}
|
|
372
396
|
onApply={handleApplyReviewed}
|
|
373
397
|
/>
|
|
374
398
|
)
|