diff --git a/e2e/tests/routes.edit-payload-preservation.spec.ts b/e2e/tests/routes.edit-payload-preservation.spec.ts index 85d1987f..6db78e95 100644 --- a/e2e/tests/routes.edit-payload-preservation.spec.ts +++ b/e2e/tests/routes.edit-payload-preservation.spec.ts @@ -17,7 +17,7 @@ import { randomId } from '@e2e/utils/common'; import { e2eReq } from '@e2e/utils/req'; import { test } from '@e2e/utils/test'; -import { uiGoto } from '@e2e/utils/ui'; +import { uiFillMonacoEditor, uiGoto } from '@e2e/utils/ui'; import { expect, type Request } from '@playwright/test'; import { deleteAllRoutes, getRouteReq } from '@/apis/routes'; @@ -26,6 +26,11 @@ import { API_ROUTES } from '@/config/constant'; const routeId = randomId('route-payload-preserve'); const routeUri = '/route-payload-preserve'; const updatedDesc = 'updated through form while preserving raw payload'; +const rawDraftDesc = 'raw json draft that will fail once'; +const rawLatestDesc = 'latest server value after failed raw save'; + +const readMonacoValue = async (page: Parameters[0]) => + page.evaluate(() => window.__monacoEditor__?.getValue() ?? ''); test.beforeAll(async () => { await deleteAllRoutes(e2eReq); @@ -107,3 +112,61 @@ test('route form save preserves raw payload and strips readonly fields', async ( }, }); }); + +test('raw JSON save failure offers reset and reload recovery actions', async ({ + page, +}) => { + await uiGoto(page, '/routes/detail/$id', { id: routeId }); + await page.getByRole('tab', { name: 'Raw JSON' }).click(); + + const rawJsonPanel = page.getByRole('tabpanel', { name: 'Raw JSON' }); + const editor = rawJsonPanel.locator('.monaco-editor').first(); + await expect(editor).toBeVisible(); + await uiFillMonacoEditor( + page, + editor, + JSON.stringify({ + uri: routeUri, + desc: rawDraftDesc, + }) + ); + + let patchAttempts = 0; + await page.route(`**${API_ROUTES}/${routeId}`, async (route) => { + if (route.request().method() === 'PATCH') { + patchAttempts += 1; + await route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ error_msg: 'temporary raw save failure' }), + }); + return; + } + await route.continue(); + }); + + await page.getByRole('button', { name: 'Save Changes' }).click(); + + await expect( + page.getByRole('alert').filter({ hasText: 'Save failed' }).first() + ).toBeVisible(); + await expect(page.getByRole('button', { name: 'Reset draft' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Reload latest' })).toBeVisible(); + expect(patchAttempts).toBe(1); + + await e2eReq.put(`${API_ROUTES}/${routeId}`, { + uri: routeUri, + desc: rawLatestDesc, + methods: ['GET'], + plugins: { + 'response-rewrite': { + body: 'latest response body', + }, + }, + }); + + await page.getByRole('button', { name: 'Reload latest' }).click(); + await expect + .poll(() => readMonacoValue(page)) + .toContain(`"desc": "${rawLatestDesc}"`); +}); diff --git a/src/components/page/AdminApiJsonEditor.tsx b/src/components/page/AdminApiJsonEditor.tsx index d54b49f5..01610717 100644 --- a/src/components/page/AdminApiJsonEditor.tsx +++ b/src/components/page/AdminApiJsonEditor.tsx @@ -184,6 +184,37 @@ export const AdminApiJsonEditor = ({ userEditedRef.current = false; }, [api]); + const handleResetDraft = useCallback(() => { + userEditedRef.current = false; + setValue(original); + setError(null); + setSaveFeedback(null); + }, [original]); + + const handleReloadLatest = useCallback(async () => { + if (!api || saving) return; + setLoading(true); + setError(null); + setSaveFeedback(null); + try { + const res = await req.get(api); + const data = res.data?.value as Record | undefined; + if (!isRecord(data)) { + throw new Error('Admin API returned no resource value'); + } + loadData(data); + setSaveFeedback({ + type: 'success', + message: 'Reloaded latest APISIX resource state.', + at: new Date().toLocaleTimeString(), + }); + } catch (e) { + setError('Reload failed: ' + getAdminApiErrorMessage(e)); + } finally { + setLoading(false); + } + }, [api, loadData, saving]); + useEffect(() => { if (!active || !api) return; @@ -413,6 +444,18 @@ export const AdminApiJsonEditor = ({ type="error" showIcon message={
{error}
} + action={ + !disabled && ( + + + + + ) + } style={{ marginBottom: 12 }} closable onClose={() => setError(null)} @@ -490,10 +533,7 @@ export const AdminApiJsonEditor = ({