From 814893f7320577f6e1dc4fd12027752b9d4f9357 Mon Sep 17 00:00:00 2001 From: arafat Date: Sat, 6 Jun 2026 00:12:12 +0530 Subject: [PATCH 1/8] HDDS-14818. Recon: Add AI Assistant chat UI for natural-language cluster queries. Generated-by: Cursor (Gemini 3.1 Pro / Claude 3.5 Sonnet) --- .../impl/OzoneManagerServiceProviderImpl.java | 278 +++---- .../webapps/recon/ozone-recon-web/.gitignore | 6 + .../e2e/chatbot-errors.spec.ts | 251 ++++++ .../recon/ozone-recon-web/package.json | 4 + .../ozone-recon-web/playwright.config.ts | 25 + .../recon/ozone-recon-web/pnpm-lock.yaml | 758 ++++++++++++++++++ .../__tests__/assistant/Assistant.test.tsx | 241 ++++++ .../mocks/assistantMocks/assistantServer.ts | 141 ++++ .../src/__tests__/vitest.setup.ts | 4 +- .../webapps/recon/ozone-recon-web/src/app.tsx | 120 +-- .../src/utils/axiosRequestHelper.tsx | 14 + .../src/v2/components/navBar/navBar.tsx | 19 +- .../v2/constants/breadcrumbs.constants.tsx | 3 +- .../src/v2/constants/chatbot.constants.tsx | 80 ++ .../src/v2/hooks/useChat.hook.tsx | 190 +++++ .../src/v2/pages/assistant/assistant.less | 743 +++++++++++++++++ .../src/v2/pages/assistant/assistant.tsx | 185 +++++ .../pages/assistant/components/Composer.tsx | 118 +++ .../assistant/components/DisabledState.tsx | 50 ++ .../pages/assistant/components/EmptyState.tsx | 83 ++ .../assistant/components/LoadingIndicator.tsx | 70 ++ .../assistant/components/MessageBubble.tsx | 88 ++ .../assistant/components/MessageList.tsx | 89 ++ .../assistant/components/ModelPicker.tsx | 110 +++ .../assistant/components/ReconAIMark.tsx | 55 ++ .../ozone-recon-web/src/v2/routes-v2.tsx | 5 + .../src/v2/types/chatbot.types.ts | 49 ++ 27 files changed, 3586 insertions(+), 193 deletions(-) create mode 100644 hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/e2e/chatbot-errors.spec.ts create mode 100644 hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/playwright.config.ts create mode 100644 hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/assistant/Assistant.test.tsx create mode 100644 hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/mocks/assistantMocks/assistantServer.ts create mode 100644 hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/constants/chatbot.constants.tsx create mode 100644 hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/hooks/useChat.hook.tsx create mode 100644 hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/assistant/assistant.less create mode 100644 hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/assistant/assistant.tsx create mode 100644 hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/assistant/components/Composer.tsx create mode 100644 hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/assistant/components/DisabledState.tsx create mode 100644 hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/assistant/components/EmptyState.tsx create mode 100644 hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/assistant/components/LoadingIndicator.tsx create mode 100644 hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/assistant/components/MessageBubble.tsx create mode 100644 hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/assistant/components/MessageList.tsx create mode 100644 hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/assistant/components/ModelPicker.tsx create mode 100644 hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/assistant/components/ReconAIMark.tsx create mode 100644 hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/types/chatbot.types.ts diff --git a/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/spi/impl/OzoneManagerServiceProviderImpl.java b/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/spi/impl/OzoneManagerServiceProviderImpl.java index cd62b2160daf..710cbdaea137 100644 --- a/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/spi/impl/OzoneManagerServiceProviderImpl.java +++ b/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/spi/impl/OzoneManagerServiceProviderImpl.java @@ -117,6 +117,7 @@ public class OzoneManagerServiceProviderImpl private static final Logger LOG = LoggerFactory.getLogger(OzoneManagerServiceProviderImpl.class); private URLConnectionFactory connectionFactory; + private final AtomicBoolean reInitializeTasksCalled = new AtomicBoolean(false); private File omSnapshotDBParentDir = null; private File reconDbDir = null; @@ -711,145 +712,152 @@ public boolean syncDataFromOM() { try { long currentSequenceNumber = getCurrentOMDBSequenceNumber(); LOG.info("Seq number of Recon's OM DB : {}", currentSequenceNumber); - boolean fullSnapshot = false; + boolean fullSnapshot = true; - if (currentSequenceNumber <= 0) { - fullSnapshot = true; + if (reInitializeTasksCalled.compareAndSet(false, true)) { + LOG.info("Calling reprocess on Recon tasks."); + reconTaskController.reInitializeTasks(omMetadataManager,null); } else { - // Get updates from OM and apply to local Recon OM DB and update task status in table - deltaReconTaskStatusUpdater.recordRunStart(); - int loopCount = 0; - long fromSequenceNumber = currentSequenceNumber; - long diffBetweenOMDbAndReconDBSeqNumber = deltaUpdateLimit + 1; - /** - * This loop will continue to fetch and apply OM DB updates and with every - * OM DB fetch request, it will fetch {@code deltaUpdateLimit} count of DB updates. - * It continues to fetch from OM till the lag, between OM DB WAL sequence number - * and Recon OM DB snapshot WAL sequence number, is less than this lag threshold value. - * In high OM write TPS cluster, this simulates continuous pull from OM without any delay. - */ - while (diffBetweenOMDbAndReconDBSeqNumber > omDBLagThreshold) { - try (OMDBUpdatesHandler omdbUpdatesHandler = - new OMDBUpdatesHandler(omMetadataManager)) { - - // If interrupt was previously signalled, - // we should check for it before starting delta update sync. - if (Thread.currentThread().isInterrupted()) { - throw new InterruptedException("Thread interrupted during delta update."); - } - diffBetweenOMDbAndReconDBSeqNumber = - getAndApplyDeltaUpdatesFromOM(currentSequenceNumber, omdbUpdatesHandler); - deltaReconTaskStatusUpdater.setLastTaskRunStatus(0); - // Keeping last updated sequence number for both full and delta tasks to be same - // because sequence number of DB denotes and points to same OM DB copy of Recon, - // even though two different tasks are updating the DB at different conditions, but - // it tells the sync state with actual OM DB for the same Recon OM DB copy. - deltaReconTaskStatusUpdater.setLastUpdatedSeqNumber(getCurrentOMDBSequenceNumber()); - fullSnapshotReconTaskUpdater.setLastUpdatedSeqNumber(getCurrentOMDBSequenceNumber()); - deltaReconTaskStatusUpdater.recordRunCompletion(); - fullSnapshotReconTaskUpdater.updateDetails(); - // Update the current OM metadata manager in task controller - reconTaskController.updateOMMetadataManager(omMetadataManager); - - // Pass on DB update events to tasks that are listening. - reconTaskController.consumeOMEvents(new OMUpdateEventBatch( - omdbUpdatesHandler.getEvents(), omdbUpdatesHandler.getLatestSequenceNumber()), omMetadataManager); - - // Check if task reinitialization is needed due to buffer overflow or task failures - boolean bufferOverflowed = reconTaskController.hasEventBufferOverflowed(); - boolean tasksFailed = reconTaskController.hasTasksFailed(); - - if (bufferOverflowed || tasksFailed) { - ReconTaskReInitializationEvent.ReInitializationReason reason = bufferOverflowed ? - ReconTaskReInitializationEvent.ReInitializationReason.BUFFER_OVERFLOW : - ReconTaskReInitializationEvent.ReInitializationReason.TASK_FAILURES; - - LOG.warn("Detected condition for task reinitialization: {}, queueing async reinitialization event", - reason); - - markDeltaTaskStatusAsFailed(deltaReconTaskStatusUpdater); - - // Queue async reinitialization event - checkpoint creation and retry logic is handled internally - ReconTaskController.ReInitializationResult result = - reconTaskController.queueReInitializationEvent(reason); - - //TODO: Create a metric to track this event buffer overflow or task failure event - boolean triggerFullSnapshot = - Optional.ofNullable(result) - .map(r -> { - switch (r) { - case MAX_RETRIES_EXCEEDED: - LOG.warn( - "Reinitialization queue failures exceeded maximum retries, triggering full snapshot " + - "fallback"); - return true; - - case RETRY_LATER: - LOG.debug("Reinitialization event queueing will be retried in next iteration"); - return false; - - default: - LOG.info("Reinitialization event successfully queued"); - return false; - } - }) - .orElseGet(() -> { - LOG.error( - "ReInitializationResult is null, something went wrong in queueing reinitialization " + - "event"); - return true; - }); - - if (triggerFullSnapshot) { - fullSnapshot = true; - } - } - currentSequenceNumber = getCurrentOMDBSequenceNumber(); - LOG.debug("Updated current sequence number: {}", currentSequenceNumber); - loopCount++; - } catch (InterruptedException intEx) { - LOG.error("OM DB Delta update sync thread was interrupted and delta sync failed."); - // We are updating the table even if it didn't run i.e. got interrupted beforehand - // to indicate that a task was supposed to run, but it didn't. - markDeltaTaskStatusAsFailed(deltaReconTaskStatusUpdater); - Thread.currentThread().interrupt(); - // Since thread is interrupted, we do not fall back to snapshot sync. - // Return with sync failed status. - return false; - } catch (Exception e) { - markDeltaTaskStatusAsFailed(deltaReconTaskStatusUpdater); - LOG.warn("Unable to get and apply delta updates from OM: {}, falling back to full snapshot", - e.getMessage()); - fullSnapshot = true; - } - if (fullSnapshot) { - break; - } - } - LOG.info("Delta updates received from OM : {} loops, {} records", loopCount, - getCurrentOMDBSequenceNumber() - fromSequenceNumber); + LOG.info("reInitializeTasks already called once; skipping."); } - if (fullSnapshot) { - try { - executeFullSnapshot(fullSnapshotReconTaskUpdater, deltaReconTaskStatusUpdater); - } catch (InterruptedException intEx) { - LOG.error("OM DB Snapshot update sync thread was interrupted."); - fullSnapshotReconTaskUpdater.setLastTaskRunStatus(-1); - fullSnapshotReconTaskUpdater.recordRunCompletion(); - Thread.currentThread().interrupt(); - // Mark sync status as failed. - return false; - } catch (Exception e) { - metrics.incrNumSnapshotRequestsFailed(); - fullSnapshotReconTaskUpdater.setLastTaskRunStatus(-1); - fullSnapshotReconTaskUpdater.recordRunCompletion(); - LOG.error("Unable to update Recon's metadata with new OM DB. ", e); - // Update health status in ReconContext - reconContext.updateHealthStatus(new AtomicBoolean(false)); - reconContext.updateErrors(ReconContext.ErrorCode.GET_OM_DB_SNAPSHOT_FAILED); - } - } +// if (currentSequenceNumber <= 0) { +// fullSnapshot = true; +// } else { +// // Get updates from OM and apply to local Recon OM DB and update task status in table +// deltaReconTaskStatusUpdater.recordRunStart(); +// int loopCount = 0; +// long fromSequenceNumber = currentSequenceNumber; +// long diffBetweenOMDbAndReconDBSeqNumber = deltaUpdateLimit + 1; +// /** +// * This loop will continue to fetch and apply OM DB updates and with every +// * OM DB fetch request, it will fetch {@code deltaUpdateLimit} count of DB updates. +// * It continues to fetch from OM till the lag, between OM DB WAL sequence number +// * and Recon OM DB snapshot WAL sequence number, is less than this lag threshold value. +// * In high OM write TPS cluster, this simulates continuous pull from OM without any delay. +// */ +// while (diffBetweenOMDbAndReconDBSeqNumber > omDBLagThreshold) { +// try (OMDBUpdatesHandler omdbUpdatesHandler = +// new OMDBUpdatesHandler(omMetadataManager)) { +// +// // If interrupt was previously signalled, +// // we should check for it before starting delta update sync. +// if (Thread.currentThread().isInterrupted()) { +// throw new InterruptedException("Thread interrupted during delta update."); +// } +// diffBetweenOMDbAndReconDBSeqNumber = +// getAndApplyDeltaUpdatesFromOM(currentSequenceNumber, omdbUpdatesHandler); +// deltaReconTaskStatusUpdater.setLastTaskRunStatus(0); +// // Keeping last updated sequence number for both full and delta tasks to be same +// // because sequence number of DB denotes and points to same OM DB copy of Recon, +// // even though two different tasks are updating the DB at different conditions, but +// // it tells the sync state with actual OM DB for the same Recon OM DB copy. +// deltaReconTaskStatusUpdater.setLastUpdatedSeqNumber(getCurrentOMDBSequenceNumber()); +// fullSnapshotReconTaskUpdater.setLastUpdatedSeqNumber(getCurrentOMDBSequenceNumber()); +// deltaReconTaskStatusUpdater.recordRunCompletion(); +// fullSnapshotReconTaskUpdater.updateDetails(); +// // Update the current OM metadata manager in task controller +// reconTaskController.updateOMMetadataManager(omMetadataManager); +// +// // Pass on DB update events to tasks that are listening. +// reconTaskController.consumeOMEvents(new OMUpdateEventBatch( +// omdbUpdatesHandler.getEvents(), omdbUpdatesHandler.getLatestSequenceNumber()), omMetadataManager); +// +// // Check if task reinitialization is needed due to buffer overflow or task failures +// boolean bufferOverflowed = reconTaskController.hasEventBufferOverflowed(); +// boolean tasksFailed = reconTaskController.hasTasksFailed(); +// +// if (bufferOverflowed || tasksFailed) { +// ReconTaskReInitializationEvent.ReInitializationReason reason = bufferOverflowed ? +// ReconTaskReInitializationEvent.ReInitializationReason.BUFFER_OVERFLOW : +// ReconTaskReInitializationEvent.ReInitializationReason.TASK_FAILURES; +// +// LOG.warn("Detected condition for task reinitialization: {}, queueing async reinitialization event", +// reason); +// +// markDeltaTaskStatusAsFailed(deltaReconTaskStatusUpdater); +// +// // Queue async reinitialization event - checkpoint creation and retry logic is handled internally +// ReconTaskController.ReInitializationResult result = +// reconTaskController.queueReInitializationEvent(reason); +// +// //TODO: Create a metric to track this event buffer overflow or task failure event +// boolean triggerFullSnapshot = +// Optional.ofNullable(result) +// .map(r -> { +// switch (r) { +// case MAX_RETRIES_EXCEEDED: +// LOG.warn( +// "Reinitialization queue failures exceeded maximum retries, triggering full snapshot " + +// "fallback"); +// return true; +// +// case RETRY_LATER: +// LOG.debug("Reinitialization event queueing will be retried in next iteration"); +// return false; +// +// default: +// LOG.info("Reinitialization event successfully queued"); +// return false; +// } +// }) +// .orElseGet(() -> { +// LOG.error( +// "ReInitializationResult is null, something went wrong in queueing reinitialization " + +// "event"); +// return true; +// }); +// +// if (triggerFullSnapshot) { +// fullSnapshot = true; +// } +// } +// currentSequenceNumber = getCurrentOMDBSequenceNumber(); +// LOG.debug("Updated current sequence number: {}", currentSequenceNumber); +// loopCount++; +// } catch (InterruptedException intEx) { +// LOG.error("OM DB Delta update sync thread was interrupted and delta sync failed."); +// // We are updating the table even if it didn't run i.e. got interrupted beforehand +// // to indicate that a task was supposed to run, but it didn't. +// markDeltaTaskStatusAsFailed(deltaReconTaskStatusUpdater); +// Thread.currentThread().interrupt(); +// // Since thread is interrupted, we do not fall back to snapshot sync. +// // Return with sync failed status. +// return false; +// } catch (Exception e) { +// markDeltaTaskStatusAsFailed(deltaReconTaskStatusUpdater); +// LOG.warn("Unable to get and apply delta updates from OM: {}, falling back to full snapshot", +// e.getMessage()); +// fullSnapshot = true; +// } +// if (fullSnapshot) { +// break; +// } +// } +// LOG.info("Delta updates received from OM : {} loops, {} records", loopCount, +// getCurrentOMDBSequenceNumber() - fromSequenceNumber); +// } + +// if (fullSnapshot) { +// try { +// executeFullSnapshot(fullSnapshotReconTaskUpdater, deltaReconTaskStatusUpdater); +// } catch (InterruptedException intEx) { +// LOG.error("OM DB Snapshot update sync thread was interrupted."); +// fullSnapshotReconTaskUpdater.setLastTaskRunStatus(-1); +// fullSnapshotReconTaskUpdater.recordRunCompletion(); +// Thread.currentThread().interrupt(); +// // Mark sync status as failed. +// return false; +// } catch (Exception e) { +// metrics.incrNumSnapshotRequestsFailed(); +// fullSnapshotReconTaskUpdater.setLastTaskRunStatus(-1); +// fullSnapshotReconTaskUpdater.recordRunCompletion(); +// LOG.error("Unable to update Recon's metadata with new OM DB. ", e); +// // Update health status in ReconContext +// reconContext.updateHealthStatus(new AtomicBoolean(false)); +// reconContext.updateErrors(ReconContext.ErrorCode.GET_OM_DB_SNAPSHOT_FAILED); +// } +// } printOMDBMetaInfo(); } finally { isSyncDataFromOMRunning.set(false); diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/.gitignore b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/.gitignore index 4d29575de804..072cffde05c6 100644 --- a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/.gitignore +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/.gitignore @@ -21,3 +21,9 @@ npm-debug.log* yarn-debug.log* yarn-error.log* + +# Playwright +e2e/screenshots/ +test-results/ +playwright-report/ +blob-report/ diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/e2e/chatbot-errors.spec.ts b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/e2e/chatbot-errors.spec.ts new file mode 100644 index 000000000000..214d3f59d73f --- /dev/null +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/e2e/chatbot-errors.spec.ts @@ -0,0 +1,251 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Recon AI Error Handling Scenarios', () => { + + test('health-disabled', async ({ page }) => { + await page.route('**/api/v1/chatbot/health', async route => { + await route.fulfill({ + status: 200, + json: { enabled: false, llmClientAvailable: true } + }); + }); + + await page.goto('/#/Assistant'); + await expect(page.locator('text=Recon AI is Disabled')).toBeVisible(); + await page.screenshot({ path: 'e2e/screenshots/health-disabled.png' }); + }); + + test('health-not-configured', async ({ page }) => { + await page.route('**/api/v1/chatbot/health', async route => { + await route.fulfill({ + status: 200, + json: { enabled: true, llmClientAvailable: false } + }); + }); + + await page.goto('/#/Assistant'); + await expect(page.locator('text=Recon AI is Not Configured')).toBeVisible(); + await page.screenshot({ path: 'e2e/screenshots/health-not-configured.png' }); + }); + + test('empty-state', async ({ page }) => { + await page.route('**/api/v1/chatbot/health', async route => { + await route.fulfill({ status: 200, json: { enabled: true, llmClientAvailable: true } }); + }); + await page.route('**/api/v1/chatbot/models', async route => { + await route.fulfill({ status: 200, json: { models: ['gemini-2.5-flash'] } }); + }); + + await page.goto('/#/Assistant'); + await expect(page.locator('text=Welcome to Recon AI')).toBeVisible(); + await page.screenshot({ path: 'e2e/screenshots/empty-state.png' }); + }); + + test('chat-loading', async ({ page }) => { + await page.route('**/api/v1/chatbot/health', async route => { + await route.fulfill({ status: 200, json: { enabled: true, llmClientAvailable: true } }); + }); + await page.route('**/api/v1/chatbot/models', async route => { + await route.fulfill({ status: 200, json: { models: ['gemini-2.5-flash'] } }); + }); + await page.route('**/api/v1/chatbot/chat', async route => { + // Delay the response to capture the loading state + await new Promise(resolve => setTimeout(resolve, 1000)); + await route.fulfill({ status: 200, json: { response: 'Done', success: true } }); + }); + + await page.goto('/#/Assistant'); + await expect(page.locator('text=Welcome to Recon AI')).toBeVisible(); + + await page.fill('textarea', 'Hello'); + await page.keyboard.press('Enter'); + + await expect(page.locator('.loading-bubble')).toBeVisible(); + await page.screenshot({ path: 'e2e/screenshots/chat-loading.png' }); + }); + + test('chat-success', async ({ page }) => { + await page.route('**/api/v1/chatbot/health', async route => { + await route.fulfill({ status: 200, json: { enabled: true, llmClientAvailable: true } }); + }); + await page.route('**/api/v1/chatbot/models', async route => { + await route.fulfill({ status: 200, json: { models: ['gemini-2.5-flash'] } }); + }); + await page.route('**/api/v1/chatbot/chat', async route => { + await route.fulfill({ + status: 200, + json: { + response: 'This is a **Markdown** response with a table:\n\n| Col 1 | Col 2 |\n|---|---|\n| A | B |', + success: true + } + }); + }); + + await page.goto('/#/Assistant'); + await expect(page.locator('text=Welcome to Recon AI')).toBeVisible(); + + await page.fill('textarea', 'Show me a table'); + await page.keyboard.press('Enter'); + + await expect(page.locator('table')).toBeVisible(); + await page.screenshot({ path: 'e2e/screenshots/chat-success.png' }); + }); + + test('chat-503-busy', async ({ page }) => { + await page.route('**/api/v1/chatbot/health', async route => { + await route.fulfill({ status: 200, json: { enabled: true, llmClientAvailable: true } }); + }); + await page.route('**/api/v1/chatbot/models', async route => { + await route.fulfill({ status: 200, json: { models: ['gemini-2.5-flash'] } }); + }); + await page.route('**/api/v1/chatbot/chat', async route => { + await route.fulfill({ + status: 503, + json: { error: 'The chatbot is currently handling too many requests. Please try again in a moment.' } + }); + }); + + await page.goto('/#/Assistant'); + await expect(page.locator('text=Welcome to Recon AI')).toBeVisible(); + + await page.fill('textarea', 'Hello'); + await page.keyboard.press('Enter'); + + await expect(page.locator('text=The chatbot is currently handling too many requests. Please try again in a moment.')).toBeVisible(); + await page.screenshot({ path: 'e2e/screenshots/chat-503-busy.png' }); + }); + + test('chat-504-timeout', async ({ page }) => { + await page.route('**/api/v1/chatbot/health', async route => { + await route.fulfill({ status: 200, json: { enabled: true, llmClientAvailable: true } }); + }); + await page.route('**/api/v1/chatbot/models', async route => { + await route.fulfill({ status: 200, json: { models: ['gemini-2.5-flash'] } }); + }); + await page.route('**/api/v1/chatbot/chat', async route => { + await route.fulfill({ + status: 504, + json: { error: 'The chatbot request timed out. The LLM or Recon API took too long to respond. Please try again or use a different model.' } + }); + }); + + await page.goto('/#/Assistant'); + await expect(page.locator('text=Welcome to Recon AI')).toBeVisible(); + + await page.fill('textarea', 'Hello'); + await page.keyboard.press('Enter'); + + await expect(page.locator('text=The chatbot request timed out. The LLM or Recon API took too long to respond. Please try again or use a different model.')).toBeVisible(); + await page.screenshot({ path: 'e2e/screenshots/chat-504-timeout.png' }); + }); + + test('chat-500-internal', async ({ page }) => { + await page.route('**/api/v1/chatbot/health', async route => { + await route.fulfill({ status: 200, json: { enabled: true, llmClientAvailable: true } }); + }); + await page.route('**/api/v1/chatbot/models', async route => { + await route.fulfill({ status: 200, json: { models: ['gemini-2.5-flash'] } }); + }); + await page.route('**/api/v1/chatbot/chat', async route => { + await route.fulfill({ + status: 500, + json: { error: 'An error occurred processing your request.' } + }); + }); + + await page.goto('/#/Assistant'); + await expect(page.locator('text=Welcome to Recon AI')).toBeVisible(); + + await page.fill('textarea', 'Hello'); + await page.keyboard.press('Enter'); + + await expect(page.locator('text=An error occurred processing your request.')).toBeVisible(); + await page.screenshot({ path: 'e2e/screenshots/chat-500-internal.png' }); + }); + + test('chat-503-interrupted', async ({ page }) => { + await page.route('**/api/v1/chatbot/health', async route => { + await route.fulfill({ status: 200, json: { enabled: true, llmClientAvailable: true } }); + }); + await page.route('**/api/v1/chatbot/models', async route => { + await route.fulfill({ status: 200, json: { models: ['gemini-2.5-flash'] } }); + }); + await page.route('**/api/v1/chatbot/chat', async route => { + await route.fulfill({ + status: 503, + json: { error: 'Request was interrupted. Please try again.' } + }); + }); + + await page.goto('/#/Assistant'); + await expect(page.locator('text=Welcome to Recon AI')).toBeVisible(); + + await page.fill('textarea', 'Hello'); + await page.keyboard.press('Enter'); + + await expect(page.locator('text=Request was interrupted. Please try again.')).toBeVisible(); + await page.screenshot({ path: 'e2e/screenshots/chat-503-interrupted.png' }); + }); + + test('chat-503-disabled', async ({ page }) => { + await page.route('**/api/v1/chatbot/health', async route => { + await route.fulfill({ status: 200, json: { enabled: true, llmClientAvailable: true } }); + }); + await page.route('**/api/v1/chatbot/models', async route => { + await route.fulfill({ status: 200, json: { models: ['gemini-2.5-flash'] } }); + }); + await page.route('**/api/v1/chatbot/chat', async route => { + await route.fulfill({ + status: 503, + json: { error: 'Chatbot service is not enabled' } + }); + }); + + await page.goto('/#/Assistant'); + await expect(page.locator('text=Welcome to Recon AI')).toBeVisible(); + + await page.fill('textarea', 'Hello'); + await page.keyboard.press('Enter'); + + await expect(page.locator('text=Chatbot service is not enabled')).toBeVisible(); + await page.screenshot({ path: 'e2e/screenshots/chat-503-disabled.png' }); + }); + + test('models-500', async ({ page }) => { + await page.route('**/api/v1/chatbot/health', async route => { + await route.fulfill({ status: 200, json: { enabled: true, llmClientAvailable: true } }); + }); + await page.route('**/api/v1/chatbot/models', async route => { + await route.fulfill({ + status: 500, + json: { error: 'Failed to fetch models' } + }); + }); + + await page.goto('/#/Assistant'); + await expect(page.locator('text=Welcome to Recon AI')).toBeVisible(); + + // Check that we can still use the chat with default provider + await expect(page.locator('text=Default Provider')).toBeVisible(); + await page.screenshot({ path: 'e2e/screenshots/models-500.png' }); + }); + + test('models-503', async ({ page }) => { + await page.route('**/api/v1/chatbot/health', async route => { + await route.fulfill({ status: 200, json: { enabled: true, llmClientAvailable: true } }); + }); + await page.route('**/api/v1/chatbot/models', async route => { + await route.fulfill({ + status: 503, + json: { error: 'Chatbot service is not enabled' } + }); + }); + + await page.goto('/#/Assistant'); + await expect(page.locator('text=Welcome to Recon AI')).toBeVisible(); + + // Check that we can still use the chat with default provider + await expect(page.locator('text=Default Provider')).toBeVisible(); + await page.screenshot({ path: 'e2e/screenshots/models-503.png' }); + }); +}); \ No newline at end of file diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/package.json b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/package.json index 8101e0dafc5b..a3a6afb4b95d 100644 --- a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/package.json +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/package.json @@ -25,9 +25,11 @@ "pretty-ms": "^5.1.0", "react": "^16.8.6", "react-dom": "^16.14.0", + "react-markdown": "^8.0.7", "react-router": "^5.3.4", "react-router-dom": "^5.3.4", "react-select": "^3.2.0", + "remark-gfm": "^3.0.1", "typescript": "4.9.5" }, "scripts": { @@ -35,6 +37,7 @@ "build": "vite build", "serve": "vite preview", "test": "vitest", + "e2e": "playwright test", "mock:api": "json-server --watch api/db.json --routes api/routes.json --middlewares api/pagination.js --port 9888", "dev": "npm-run-all --parallel mock:api start", "lint": "eslint src/*", @@ -56,6 +59,7 @@ ] }, "devDependencies": { + "@playwright/test": "^1.60.0", "@testing-library/jest-dom": "^6.4.8", "@testing-library/react": "^12.1.5", "@testing-library/user-event": "^14.5.2", diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/playwright.config.ts b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/playwright.config.ts new file mode 100644 index 000000000000..2c97c12660e2 --- /dev/null +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: 'http://localhost:3000', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'pnpm start', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + }, +}); \ No newline at end of file diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/pnpm-lock.yaml b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/pnpm-lock.yaml index 5facaaa116d3..5f72801998c0 100644 --- a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/pnpm-lock.yaml +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: react-dom: specifier: ^16.14.0 version: 16.14.0(react@16.14.0) + react-markdown: + specifier: ^8.0.7 + version: 8.0.7(@types/react@16.8.15)(react@16.14.0) react-router: specifier: ^5.3.4 version: 5.3.4(react@16.14.0) @@ -59,10 +62,16 @@ importers: react-select: specifier: ^3.2.0 version: 3.2.0(react-dom@16.14.0(react@16.14.0))(react@16.14.0) + remark-gfm: + specifier: ^3.0.1 + version: 3.0.1 typescript: specifier: 4.9.5 version: 4.9.5 devDependencies: + '@playwright/test': + specifier: ^1.60.0 + version: 1.60.0 '@testing-library/jest-dom': specifier: ^6.4.8 version: 6.9.1 @@ -651,6 +660,11 @@ packages: '@open-draft/until@1.0.3': resolution: {integrity: sha512-Aq58f5HiWdyDlFffbbSjAlv596h/cOnt2DO1w3DOC7OJ5EHs0hd/nycJfiu9RJbT6Yk6F1knnRRXNSpxoIVZ9Q==} + '@playwright/test@1.60.0': + resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} + engines: {node: '>=18'} + hasBin: true + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -921,6 +935,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/hast@2.3.10': + resolution: {integrity: sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==} + '@types/history@4.7.11': resolution: {integrity: sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==} @@ -936,6 +953,9 @@ packages: '@types/keyv@3.1.4': resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} + '@types/mdast@3.0.15': + resolution: {integrity: sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==} + '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} @@ -977,6 +997,9 @@ packages: '@types/set-cookie-parser@2.4.10': resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==} + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + '@typescript-eslint/eslint-plugin@5.62.0': resolution: {integrity: sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1355,6 +1378,9 @@ packages: babel-plugin-syntax-jsx@6.18.0: resolution: {integrity: sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw==} + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1432,6 +1458,9 @@ packages: caseless@0.12.0: resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@4.5.0: resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} engines: {node: '>=4'} @@ -1444,6 +1473,9 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} @@ -1507,6 +1539,9 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -1669,6 +1704,9 @@ packages: decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + decompress-response@3.3.0: resolution: {integrity: sha512-BzRPQuY1ip+qDonAOz42gRm/pg9F768C+npV/4JOsxRC2sq+Rlk+Q4ZCAsOhnIaMrgarILY+RMUIvMmmX1qAEA==} engines: {node: '>=4'} @@ -1710,6 +1748,10 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + destroy@1.2.0: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -1718,6 +1760,10 @@ packages: resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + diff@5.2.2: + resolution: {integrity: sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==} + engines: {node: '>=0.3.1'} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -1858,6 +1904,10 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + eslint-config-prettier@8.10.2: resolution: {integrity: sha512-/IGJ6+Dka158JnP5n5YFMOszjDWrXggGz1LaK/guZq9vZTmniaKlHcsscvkAhn9y4U+BU3JuUdYvtAMcv30y4A==} hasBin: true @@ -2122,6 +2172,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2281,6 +2336,9 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + hast-util-whitespace@2.0.1: + resolution: {integrity: sha512-nAxA0v8+vXSBDt3AnRUNjyRIQ0rD+ntpbAp4LnPkumc5M9yUbSMa4XDU9Q6etY4f1Wp4bNgvc1yjiZtsTTrSng==} + headers-polyfill@3.2.5: resolution: {integrity: sha512-tUCGvt191vNSQgttSyJoibR+VO+I6+iCHIUdhzEMJKE+EAL8BwCN7fUOZlY4ofOelNHsK+gEjxB/B+9N3EWtdA==} @@ -2374,6 +2432,9 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + inline-style-parser@0.1.1: + resolution: {integrity: sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==} + inquirer@8.2.7: resolution: {integrity: sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==} engines: {node: '>=12.0.0'} @@ -2413,6 +2474,10 @@ packages: resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} engines: {node: '>= 0.4'} + is-buffer@2.0.5: + resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==} + engines: {node: '>=4'} + is-bun-module@2.0.0: resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} @@ -2499,6 +2564,10 @@ packages: resolution: {integrity: sha512-qhsCR/Esx4U4hg/9I19OVUAJkGWtjRYHMRgUMZE2TDdj+Ag+kttZanLupfddNyglzz50cUlmWzUaI37GDfNx/g==} engines: {node: '>=0.10.0'} + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} @@ -2673,6 +2742,10 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + latest-version@5.1.0: resolution: {integrity: sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA==} engines: {node: '>=8'} @@ -2718,6 +2791,9 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -2758,10 +2834,52 @@ packages: resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} engines: {node: '>=6'} + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdast-util-definitions@5.1.2: + resolution: {integrity: sha512-8SVPMuHqlPME/z3gqVwWY4zVXn8lqKv/pAhC57FuJ40ImXyBpmO5ukh98zB2v7Blql2FiHjHv9LVztSIqjY+MA==} + + mdast-util-find-and-replace@2.2.2: + resolution: {integrity: sha512-MTtdFRz/eMDHXzeK6W3dO7mXUlF82Gom4y0oOgvHhh/HXZAGvIQDUvQ0SuUx+j2tv44b8xTHOm8K/9OoRFnXKw==} + + mdast-util-from-markdown@1.3.1: + resolution: {integrity: sha512-4xTO/M8c82qBcnQc1tgpNtubGUW/Y1tBQ1B0i5CtSoelOLKFYlElIr3bvgREYYO5iRqbMY1YuqZng0GVOI8Qww==} + + mdast-util-gfm-autolink-literal@1.0.3: + resolution: {integrity: sha512-My8KJ57FYEy2W2LyNom4n3E7hKTuQk/0SES0u16tjA9Z3oFkF4RrC/hPAPgjlSpezsOvI8ObcXcElo92wn5IGA==} + + mdast-util-gfm-footnote@1.0.2: + resolution: {integrity: sha512-56D19KOGbE00uKVj3sgIykpwKL179QsVFwx/DCW0u/0+URsryacI4MAdNJl0dh+u2PSsD9FtxPFbHCzJ78qJFQ==} + + mdast-util-gfm-strikethrough@1.0.3: + resolution: {integrity: sha512-DAPhYzTYrRcXdMjUtUjKvW9z/FNAMTdU0ORyMcbmkwYNbKocDpdk+PX1L1dQgOID/+vVs1uBQ7ElrBQfZ0cuiQ==} + + mdast-util-gfm-table@1.0.7: + resolution: {integrity: sha512-jjcpmNnQvrmN5Vx7y7lEc2iIOEytYv7rTvu+MeyAsSHTASGCCRA79Igg2uKssgOs1i1po8s3plW0sTu1wkkLGg==} + + mdast-util-gfm-task-list-item@1.0.2: + resolution: {integrity: sha512-PFTA1gzfp1B1UaiJVyhJZA1rm0+Tzn690frc/L8vNX1Jop4STZgOE6bxUhnzdVSB+vm2GU1tIsuQcA9bxTQpMQ==} + + mdast-util-gfm@2.0.2: + resolution: {integrity: sha512-qvZ608nBppZ4icQlhQQIAdc6S3Ffj9RGmzwUKUWuEICFnd1LVkN3EktF7ZHAgfcEdvZB5owU9tQgt99e2TlLjg==} + + mdast-util-phrasing@3.0.1: + resolution: {integrity: sha512-WmI1gTXUBJo4/ZmSk79Wcb2HcjPJBzM1nlI/OUWA8yk2X9ik3ffNbBGsU+09BFmXaL1IBb9fiuvq6/KMiNycSg==} + + mdast-util-to-hast@12.3.0: + resolution: {integrity: sha512-pits93r8PhnIoU4Vy9bjW39M2jJ6/tdHyja9rrot9uujkN7UTU9SDnE6WNJz/IGyQk3XHX6yNNtrBH6cQzm8Hw==} + + mdast-util-to-markdown@1.5.0: + resolution: {integrity: sha512-bbv7TPv/WC49thZPg3jXuqzuvI45IL2EVAr/KxF0BSdHsU0ceFHOmwQn6evxAh1GaoK/6GQ1wp4R4oW2+LFL/A==} + + mdast-util-to-string@3.2.0: + resolution: {integrity: sha512-V4Zn/ncyN1QNSqSBxTrMOLpjr+IKdHl2v3KVLoWmDPscP4r9GcCi71gjgvUV1SFSKh92AjAG4peFuBl2/YgCJg==} + media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} @@ -2791,6 +2909,90 @@ packages: resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} engines: {node: '>= 0.6'} + micromark-core-commonmark@1.1.0: + resolution: {integrity: sha512-BgHO1aRbolh2hcrzL2d1La37V0Aoz73ymF8rAcKnohLy93titmv62E0gP8Hrx9PKcKrqCZ1BbLGbP3bEhoXYlw==} + + micromark-extension-gfm-autolink-literal@1.0.5: + resolution: {integrity: sha512-z3wJSLrDf8kRDOh2qBtoTRD53vJ+CWIyo7uyZuxf/JAbNJjiHsOpG1y5wxk8drtv3ETAHutCu6N3thkOOgueWg==} + + micromark-extension-gfm-footnote@1.1.2: + resolution: {integrity: sha512-Yxn7z7SxgyGWRNa4wzf8AhYYWNrwl5q1Z8ii+CSTTIqVkmGZF1CElX2JI8g5yGoM3GAman9/PVCUFUSJ0kB/8Q==} + + micromark-extension-gfm-strikethrough@1.0.7: + resolution: {integrity: sha512-sX0FawVE1o3abGk3vRjOH50L5TTLr3b5XMqnP9YDRb34M0v5OoZhG+OHFz1OffZ9dlwgpTBKaT4XW/AsUVnSDw==} + + micromark-extension-gfm-table@1.0.7: + resolution: {integrity: sha512-3ZORTHtcSnMQEKtAOsBQ9/oHp9096pI/UvdPtN7ehKvrmZZ2+bbWhi0ln+I9drmwXMt5boocn6OlwQzNXeVeqw==} + + micromark-extension-gfm-tagfilter@1.0.2: + resolution: {integrity: sha512-5XWB9GbAUSHTn8VPU8/1DBXMuKYT5uOgEjJb8gN3mW0PNW5OPHpSdojoqf+iq1xo7vWzw/P8bAHY0n6ijpXF7g==} + + micromark-extension-gfm-task-list-item@1.0.5: + resolution: {integrity: sha512-RMFXl2uQ0pNQy6Lun2YBYT9g9INXtWJULgbt01D/x8/6yJ2qpKyzdZD3pi6UIkzF++Da49xAelVKUeUMqd5eIQ==} + + micromark-extension-gfm@2.0.3: + resolution: {integrity: sha512-vb9OoHqrhCmbRidQv/2+Bc6pkP0FrtlhurxZofvOEy5o8RtuuvTq+RQ1Vw5ZDNrVraQZu3HixESqbG+0iKk/MQ==} + + micromark-factory-destination@1.1.0: + resolution: {integrity: sha512-XaNDROBgx9SgSChd69pjiGKbV+nfHGDPVYFs5dOoDd7ZnMAE+Cuu91BCpsY8RT2NP9vo/B8pds2VQNCLiu0zhg==} + + micromark-factory-label@1.1.0: + resolution: {integrity: sha512-OLtyez4vZo/1NjxGhcpDSbHQ+m0IIGnT8BoPamh+7jVlzLJBH98zzuCoUeMxvM6WsNeh8wx8cKvqLiPHEACn0w==} + + micromark-factory-space@1.1.0: + resolution: {integrity: sha512-cRzEj7c0OL4Mw2v6nwzttyOZe8XY/Z8G0rzmWQZTBi/jjwyw/U4uqKtUORXQrR5bAZZnbTI/feRV/R7hc4jQYQ==} + + micromark-factory-title@1.1.0: + resolution: {integrity: sha512-J7n9R3vMmgjDOCY8NPw55jiyaQnH5kBdV2/UXCtZIpnHH3P6nHUKaH7XXEYuWwx/xUJcawa8plLBEjMPU24HzQ==} + + micromark-factory-whitespace@1.1.0: + resolution: {integrity: sha512-v2WlmiymVSp5oMg+1Q0N1Lxmt6pMhIHD457whWM7/GUlEks1hI9xj5w3zbc4uuMKXGisksZk8DzP2UyGbGqNsQ==} + + micromark-util-character@1.2.0: + resolution: {integrity: sha512-lXraTwcX3yH/vMDaFWCQJP1uIszLVebzUa3ZHdrgxr7KEU/9mL4mVgCpGbyhvNLNlauROiNUq7WN5u7ndbY6xg==} + + micromark-util-chunked@1.1.0: + resolution: {integrity: sha512-Ye01HXpkZPNcV6FiyoW2fGZDUw4Yc7vT0E9Sad83+bEDiCJ1uXu0S3mr8WLpsz3HaG3x2q0HM6CTuPdcZcluFQ==} + + micromark-util-classify-character@1.1.0: + resolution: {integrity: sha512-SL0wLxtKSnklKSUplok1WQFoGhUdWYKggKUiqhX+Swala+BtptGCu5iPRc+xvzJ4PXE/hwM3FNXsfEVgoZsWbw==} + + micromark-util-combine-extensions@1.1.0: + resolution: {integrity: sha512-Q20sp4mfNf9yEqDL50WwuWZHUrCO4fEyeDCnMGmG5Pr0Cz15Uo7KBs6jq+dq0EgX4DPwwrh9m0X+zPV1ypFvUA==} + + micromark-util-decode-numeric-character-reference@1.1.0: + resolution: {integrity: sha512-m9V0ExGv0jB1OT21mrWcuf4QhP46pH1KkfWy9ZEezqHKAxkj4mPCy3nIH1rkbdMlChLHX531eOrymlwyZIf2iw==} + + micromark-util-decode-string@1.1.0: + resolution: {integrity: sha512-YphLGCK8gM1tG1bd54azwyrQRjCFcmgj2S2GoJDNnh4vYtnL38JS8M4gpxzOPNyHdNEpheyWXCTnnTDY3N+NVQ==} + + micromark-util-encode@1.1.0: + resolution: {integrity: sha512-EuEzTWSTAj9PA5GOAs992GzNh2dGQO52UvAbtSOMvXTxv3Criqb6IOzJUBCmEqrrXSblJIJBbFFv6zPxpreiJw==} + + micromark-util-html-tag-name@1.2.0: + resolution: {integrity: sha512-VTQzcuQgFUD7yYztuQFKXT49KghjtETQ+Wv/zUjGSGBioZnkA4P1XXZPT1FHeJA6RwRXSF47yvJ1tsJdoxwO+Q==} + + micromark-util-normalize-identifier@1.1.0: + resolution: {integrity: sha512-N+w5vhqrBihhjdpM8+5Xsxy71QWqGn7HYNUvch71iV2PM7+E3uWGox1Qp90loa1ephtCxG2ftRV/Conitc6P2Q==} + + micromark-util-resolve-all@1.1.0: + resolution: {integrity: sha512-b/G6BTMSg+bX+xVCshPTPyAu2tmA0E4X98NSR7eIbeC6ycCqCeE7wjfDIgzEbkzdEVJXRtOG4FbEm/uGbCRouA==} + + micromark-util-sanitize-uri@1.2.0: + resolution: {integrity: sha512-QO4GXv0XZfWey4pYFndLUKEAktKkG5kZTdUNaTAkzbuJxn2tNBOr+QtxR2XpWaMhbImT2dPzyLrPXLlPhph34A==} + + micromark-util-subtokenize@1.1.0: + resolution: {integrity: sha512-kUQHyzRoxvZO2PuLzMt2P/dwVsTiivCK8icYTeR+3WgbuPqfHgPPy7nFKbeqRivBvn/3N3GBiNC+JRTMSxEC7A==} + + micromark-util-symbol@1.1.0: + resolution: {integrity: sha512-uEjpEYY6KMs1g7QfJ2eX1SQEV+ZT4rUD3UcF6l57acZvLNK7PBZL+ty82Z1qhK1/yXIY4bdx04FKMgR0g4IAag==} + + micromark-util-types@1.1.0: + resolution: {integrity: sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg==} + + micromark@3.2.0: + resolution: {integrity: sha512-uD66tJj54JLYq0De10AhWycZWGQNUvDI55xPgk2sQM5kn1JYlhbCMTtEeT27+vAhW2FBQxLlOmS3pmA7/2z4aA==} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -2850,6 +3052,10 @@ packages: resolution: {integrity: sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==} engines: {node: '>= 0.8.0'} + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} @@ -3159,6 +3365,16 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} + engines: {node: '>=18'} + hasBin: true + please-upgrade-node@3.2.0: resolution: {integrity: sha512-gQR3WpIgNIKwBMVLkpMUeR3e1/E1y42bqDQZfql+kDeXd8COYfM8PQA4X6y7a8u9Ua9FHmsrrmirW2vHs45hWg==} @@ -3210,6 +3426,9 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + property-information@6.5.0: + resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -3495,6 +3714,12 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-markdown@8.0.7: + resolution: {integrity: sha512-bvWbzG4MtOU62XqBx3Xx+zB2raaFFsq4mYiAzfjXJMEz2sixgeAfraA3tvzULF02ZdOMUOKTBFFaZJDDrq+BJQ==} + peerDependencies: + '@types/react': '>=16' + react: '>=16' + react-router-dom@5.3.4: resolution: {integrity: sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==} peerDependencies: @@ -3560,6 +3785,15 @@ packages: resolution: {integrity: sha512-8acYXXTI0AkQv6RAOjE3vOaIXZkT9wo4LOFbBKYQEEnnMNBpKqdUrI6S4NT0KPIo/WVvJ5tE/X5LF/TQUf0ekw==} engines: {node: '>=8'} + remark-gfm@3.0.1: + resolution: {integrity: sha512-lEFDoi2PICJyNrACFOfDD3JlLkuSbOa5Wd8EPt06HUdptv8Gn0bxYTdbU/XXQ3swAPkEaGxxPN9cbnMHvVu1Ig==} + + remark-parse@10.0.2: + resolution: {integrity: sha512-3ydxgHa/ZQzG8LvC7jTXccARYDcRld3VfcgIIFs7bI6vbRSxJJmzgLEIIoYKyrfhaY+ujuWaf/PJiMZXoiCXgw==} + + remark-rehype@10.1.0: + resolution: {integrity: sha512-EFmR5zppdBp0WQeDVZ/b66CWJipB2q2VLNFMabzDSGR66Z2fQii83G5gTBbgGEnEEA0QRussvrFHxk1HWGJskw==} + request@2.88.2: resolution: {integrity: sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==} engines: {node: '>= 6'} @@ -3644,6 +3878,10 @@ packages: rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + safe-array-concat@1.1.3: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} @@ -3796,6 +4034,9 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + spdx-correct@3.2.0: resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} @@ -3922,6 +4163,9 @@ packages: strip-literal@2.1.1: resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} + style-to-object@0.4.4: + resolution: {integrity: sha512-HYNoHZa2GorYNyqiCaBgsxvcJIn7OHq6inEga+E6Ke3m5JkoqpQbnFssk4jwe+K7AhGa2fcha4wSOf1Kn01dMg==} + sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} @@ -4014,6 +4258,12 @@ packages: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} @@ -4104,10 +4354,31 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + unified@10.1.2: + resolution: {integrity: sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==} + unique-string@1.0.0: resolution: {integrity: sha512-ODgiYu03y5g76A1I9Gt0/chLCzQjvzDy7DsZGsLOE/1MrF6wriEskSncj1+/C58Xk/kPZDppSctDybCwOSaGAg==} engines: {node: '>=4'} + unist-util-generated@2.0.1: + resolution: {integrity: sha512-qF72kLmPxAw0oN2fwpWIqbXAVyEqUzDHMsbtPvOudIlUzXYFIeQIuxXQCRCFh22B7cixvU0MG7m3MW8FTq/S+A==} + + unist-util-is@5.2.1: + resolution: {integrity: sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw==} + + unist-util-position@4.0.4: + resolution: {integrity: sha512-kUBE91efOWfIVBo8xzh/uZQ7p9ffYRtUbMRZBNFYwf0RK8koUMx6dGUfwylLOKmaT2cs4wSW96QoYUSXAyEtpg==} + + unist-util-stringify-position@3.0.3: + resolution: {integrity: sha512-k5GzIBZ/QatR8N5X2y+drfpWG8IDBzdnVj6OInRNWm1oXrzydiaAT2OQiA8DPRRZyAKb9b6I2a6PxYklZD0gKg==} + + unist-util-visit-parents@5.1.3: + resolution: {integrity: sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg==} + + unist-util-visit@4.1.2: + resolution: {integrity: sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg==} + universalify@0.2.0: resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} engines: {node: '>= 4.0.0'} @@ -4148,6 +4419,11 @@ packages: deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true + uvu@0.5.6: + resolution: {integrity: sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==} + engines: {node: '>=8'} + hasBin: true + v8-compile-cache@2.4.0: resolution: {integrity: sha512-ocyWc3bAHBB/guyqJQVI5o4BZkPhznPYUG2ea80Gond/BgNWpap8TOmLSeeQG7bnh2KMISxskdADG59j7zruhw==} @@ -4165,6 +4441,12 @@ packages: resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} engines: {'0': node >=0.6.0} + vfile-message@3.1.4: + resolution: {integrity: sha512-fa0Z6P8HUrQN4BZaX05SIVXic+7kE3b05PWAtPuYP9QLHsLKYR7/AlLW3NtOrpXRLeawpDLMsVkmk5DG0NXgWw==} + + vfile@5.3.7: + resolution: {integrity: sha512-r7qlzkgErKjobAmyNIkkSpizsFPYiUPuJb5pNW1RB4JcYVZhs4lIbVqk8XPk033CV/1z8ss5pkax8SuhGpcG8g==} + vite-node@1.6.1: resolution: {integrity: sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -4412,6 +4694,9 @@ packages: zrender@5.6.1: resolution: {integrity: sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==} + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + snapshots: '@adobe/css-tools@4.4.4': {} @@ -4844,6 +5129,10 @@ snapshots: '@open-draft/until@1.0.3': {} + '@playwright/test@1.60.0': + dependencies: + playwright: 1.60.0 + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.59.0': @@ -5030,6 +5319,10 @@ snapshots: '@types/estree@1.0.8': {} + '@types/hast@2.3.10': + dependencies: + '@types/unist': 2.0.11 + '@types/history@4.7.11': {} '@types/js-levenshtein@1.1.3': {} @@ -5042,6 +5335,10 @@ snapshots: dependencies: '@types/node': 25.3.5 + '@types/mdast@3.0.15': + dependencies: + '@types/unist': 2.0.11 + '@types/ms@2.1.0': {} '@types/node@25.3.5': @@ -5092,6 +5389,8 @@ snapshots: dependencies: '@types/node': 25.3.5 + '@types/unist@2.0.11': {} + '@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@4.9.5))(eslint@7.32.0)(typescript@4.9.5)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -5536,6 +5835,8 @@ snapshots: babel-plugin-syntax-jsx@6.18.0: {} + bail@2.0.2: {} + balanced-match@1.0.2: {} base64-js@1.5.1: {} @@ -5642,6 +5943,8 @@ snapshots: caseless@0.12.0: {} + ccount@2.0.1: {} + chai@4.5.0: dependencies: assertion-error: 1.1.0 @@ -5663,6 +5966,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + character-entities@2.0.2: {} + chardet@2.1.1: {} check-error@1.0.3: @@ -5729,6 +6034,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + comma-separated-tokens@2.0.3: {} + commander@4.1.1: {} compressible@2.0.18: @@ -5887,6 +6194,10 @@ snapshots: decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + decompress-response@3.3.0: dependencies: mimic-response: 1.0.1 @@ -5942,10 +6253,14 @@ snapshots: depd@2.0.0: {} + dequal@2.0.3: {} + destroy@1.2.0: {} diff-sequences@29.6.3: {} + diff@5.2.2: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -6253,6 +6568,8 @@ snapshots: escape-string-regexp@4.0.0: {} + escape-string-regexp@5.0.0: {} + eslint-config-prettier@8.10.2(eslint@7.32.0): dependencies: eslint: 7.32.0 @@ -6608,6 +6925,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -6776,6 +7096,8 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-whitespace@2.0.1: {} + headers-polyfill@3.2.5: {} history@4.10.1: @@ -6870,6 +7192,8 @@ snapshots: ini@1.3.8: {} + inline-style-parser@0.1.1: {} + inquirer@8.2.7(@types/node@25.3.5): dependencies: '@inquirer/external-editor': 1.0.3(@types/node@25.3.5) @@ -6932,6 +7256,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-buffer@2.0.5: {} + is-bun-module@2.0.0: dependencies: semver: 7.7.4 @@ -7007,6 +7333,8 @@ snapshots: dependencies: path-is-inside: 1.0.2 + is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} is-promise@2.2.2: {} @@ -7205,6 +7533,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + kleur@4.1.5: {} + latest-version@5.1.0: dependencies: package-json: 6.5.0 @@ -7259,6 +7589,8 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 + longest-streak@3.1.0: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -7302,8 +7634,115 @@ snapshots: semver: 5.7.2 optional: true + markdown-table@3.0.4: {} + math-intrinsics@1.1.0: {} + mdast-util-definitions@5.1.2: + dependencies: + '@types/mdast': 3.0.15 + '@types/unist': 2.0.11 + unist-util-visit: 4.1.2 + + mdast-util-find-and-replace@2.2.2: + dependencies: + '@types/mdast': 3.0.15 + escape-string-regexp: 5.0.0 + unist-util-is: 5.2.1 + unist-util-visit-parents: 5.1.3 + + mdast-util-from-markdown@1.3.1: + dependencies: + '@types/mdast': 3.0.15 + '@types/unist': 2.0.11 + decode-named-character-reference: 1.3.0 + mdast-util-to-string: 3.2.0 + micromark: 3.2.0 + micromark-util-decode-numeric-character-reference: 1.1.0 + micromark-util-decode-string: 1.1.0 + micromark-util-normalize-identifier: 1.1.0 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + unist-util-stringify-position: 3.0.3 + uvu: 0.5.6 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@1.0.3: + dependencies: + '@types/mdast': 3.0.15 + ccount: 2.0.1 + mdast-util-find-and-replace: 2.2.2 + micromark-util-character: 1.2.0 + + mdast-util-gfm-footnote@1.0.2: + dependencies: + '@types/mdast': 3.0.15 + mdast-util-to-markdown: 1.5.0 + micromark-util-normalize-identifier: 1.1.0 + + mdast-util-gfm-strikethrough@1.0.3: + dependencies: + '@types/mdast': 3.0.15 + mdast-util-to-markdown: 1.5.0 + + mdast-util-gfm-table@1.0.7: + dependencies: + '@types/mdast': 3.0.15 + markdown-table: 3.0.4 + mdast-util-from-markdown: 1.3.1 + mdast-util-to-markdown: 1.5.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@1.0.2: + dependencies: + '@types/mdast': 3.0.15 + mdast-util-to-markdown: 1.5.0 + + mdast-util-gfm@2.0.2: + dependencies: + mdast-util-from-markdown: 1.3.1 + mdast-util-gfm-autolink-literal: 1.0.3 + mdast-util-gfm-footnote: 1.0.2 + mdast-util-gfm-strikethrough: 1.0.3 + mdast-util-gfm-table: 1.0.7 + mdast-util-gfm-task-list-item: 1.0.2 + mdast-util-to-markdown: 1.5.0 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@3.0.1: + dependencies: + '@types/mdast': 3.0.15 + unist-util-is: 5.2.1 + + mdast-util-to-hast@12.3.0: + dependencies: + '@types/hast': 2.3.10 + '@types/mdast': 3.0.15 + mdast-util-definitions: 5.1.2 + micromark-util-sanitize-uri: 1.2.0 + trim-lines: 3.0.1 + unist-util-generated: 2.0.1 + unist-util-position: 4.0.4 + unist-util-visit: 4.1.2 + + mdast-util-to-markdown@1.5.0: + dependencies: + '@types/mdast': 3.0.15 + '@types/unist': 2.0.11 + longest-streak: 3.1.0 + mdast-util-phrasing: 3.0.1 + mdast-util-to-string: 3.2.0 + micromark-util-decode-string: 1.1.0 + unist-util-visit: 4.1.2 + zwitch: 2.0.4 + + mdast-util-to-string@3.2.0: + dependencies: + '@types/mdast': 3.0.15 + media-typer@0.3.0: {} memoize-one@5.2.1: {} @@ -7327,6 +7766,197 @@ snapshots: methods@1.1.2: {} + micromark-core-commonmark@1.1.0: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-factory-destination: 1.1.0 + micromark-factory-label: 1.1.0 + micromark-factory-space: 1.1.0 + micromark-factory-title: 1.1.0 + micromark-factory-whitespace: 1.1.0 + micromark-util-character: 1.2.0 + micromark-util-chunked: 1.1.0 + micromark-util-classify-character: 1.1.0 + micromark-util-html-tag-name: 1.2.0 + micromark-util-normalize-identifier: 1.1.0 + micromark-util-resolve-all: 1.1.0 + micromark-util-subtokenize: 1.1.0 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + uvu: 0.5.6 + + micromark-extension-gfm-autolink-literal@1.0.5: + dependencies: + micromark-util-character: 1.2.0 + micromark-util-sanitize-uri: 1.2.0 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + + micromark-extension-gfm-footnote@1.1.2: + dependencies: + micromark-core-commonmark: 1.1.0 + micromark-factory-space: 1.1.0 + micromark-util-character: 1.2.0 + micromark-util-normalize-identifier: 1.1.0 + micromark-util-sanitize-uri: 1.2.0 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + uvu: 0.5.6 + + micromark-extension-gfm-strikethrough@1.0.7: + dependencies: + micromark-util-chunked: 1.1.0 + micromark-util-classify-character: 1.1.0 + micromark-util-resolve-all: 1.1.0 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + uvu: 0.5.6 + + micromark-extension-gfm-table@1.0.7: + dependencies: + micromark-factory-space: 1.1.0 + micromark-util-character: 1.2.0 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + uvu: 0.5.6 + + micromark-extension-gfm-tagfilter@1.0.2: + dependencies: + micromark-util-types: 1.1.0 + + micromark-extension-gfm-task-list-item@1.0.5: + dependencies: + micromark-factory-space: 1.1.0 + micromark-util-character: 1.2.0 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + uvu: 0.5.6 + + micromark-extension-gfm@2.0.3: + dependencies: + micromark-extension-gfm-autolink-literal: 1.0.5 + micromark-extension-gfm-footnote: 1.1.2 + micromark-extension-gfm-strikethrough: 1.0.7 + micromark-extension-gfm-table: 1.0.7 + micromark-extension-gfm-tagfilter: 1.0.2 + micromark-extension-gfm-task-list-item: 1.0.5 + micromark-util-combine-extensions: 1.1.0 + micromark-util-types: 1.1.0 + + micromark-factory-destination@1.1.0: + dependencies: + micromark-util-character: 1.2.0 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + + micromark-factory-label@1.1.0: + dependencies: + micromark-util-character: 1.2.0 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + uvu: 0.5.6 + + micromark-factory-space@1.1.0: + dependencies: + micromark-util-character: 1.2.0 + micromark-util-types: 1.1.0 + + micromark-factory-title@1.1.0: + dependencies: + micromark-factory-space: 1.1.0 + micromark-util-character: 1.2.0 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + + micromark-factory-whitespace@1.1.0: + dependencies: + micromark-factory-space: 1.1.0 + micromark-util-character: 1.2.0 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + + micromark-util-character@1.2.0: + dependencies: + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + + micromark-util-chunked@1.1.0: + dependencies: + micromark-util-symbol: 1.1.0 + + micromark-util-classify-character@1.1.0: + dependencies: + micromark-util-character: 1.2.0 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + + micromark-util-combine-extensions@1.1.0: + dependencies: + micromark-util-chunked: 1.1.0 + micromark-util-types: 1.1.0 + + micromark-util-decode-numeric-character-reference@1.1.0: + dependencies: + micromark-util-symbol: 1.1.0 + + micromark-util-decode-string@1.1.0: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 1.2.0 + micromark-util-decode-numeric-character-reference: 1.1.0 + micromark-util-symbol: 1.1.0 + + micromark-util-encode@1.1.0: {} + + micromark-util-html-tag-name@1.2.0: {} + + micromark-util-normalize-identifier@1.1.0: + dependencies: + micromark-util-symbol: 1.1.0 + + micromark-util-resolve-all@1.1.0: + dependencies: + micromark-util-types: 1.1.0 + + micromark-util-sanitize-uri@1.2.0: + dependencies: + micromark-util-character: 1.2.0 + micromark-util-encode: 1.1.0 + micromark-util-symbol: 1.1.0 + + micromark-util-subtokenize@1.1.0: + dependencies: + micromark-util-chunked: 1.1.0 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + uvu: 0.5.6 + + micromark-util-symbol@1.1.0: {} + + micromark-util-types@1.1.0: {} + + micromark@3.2.0: + dependencies: + '@types/debug': 4.1.12 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + micromark-core-commonmark: 1.1.0 + micromark-factory-space: 1.1.0 + micromark-util-character: 1.2.0 + micromark-util-chunked: 1.1.0 + micromark-util-combine-extensions: 1.1.0 + micromark-util-decode-numeric-character-reference: 1.1.0 + micromark-util-encode: 1.1.0 + micromark-util-normalize-identifier: 1.1.0 + micromark-util-resolve-all: 1.1.0 + micromark-util-sanitize-uri: 1.2.0 + micromark-util-subtokenize: 1.1.0 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + uvu: 0.5.6 + transitivePeerDependencies: + - supports-color + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -7382,6 +8012,8 @@ snapshots: transitivePeerDependencies: - supports-color + mri@1.2.0: {} + ms@2.0.0: {} ms@2.1.3: {} @@ -7691,6 +8323,14 @@ snapshots: mlly: 1.8.1 pathe: 2.0.3 + playwright-core@1.60.0: {} + + playwright@1.60.0: + dependencies: + playwright-core: 1.60.0 + optionalDependencies: + fsevents: 2.3.2 + please-upgrade-node@3.2.0: dependencies: semver-compare: 1.0.0 @@ -7739,6 +8379,8 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + property-information@6.5.0: {} + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -8118,6 +8760,28 @@ snapshots: react-is@18.3.1: {} + react-markdown@8.0.7(@types/react@16.8.15)(react@16.14.0): + dependencies: + '@types/hast': 2.3.10 + '@types/prop-types': 15.7.15 + '@types/react': 16.8.15 + '@types/unist': 2.0.11 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 2.0.1 + prop-types: 15.8.1 + property-information: 6.5.0 + react: 16.14.0 + react-is: 18.3.1 + remark-parse: 10.0.2 + remark-rehype: 10.1.0 + space-separated-tokens: 2.0.2 + style-to-object: 0.4.4 + unified: 10.1.2 + unist-util-visit: 4.1.2 + vfile: 5.3.7 + transitivePeerDependencies: + - supports-color + react-router-dom@5.3.4(react@16.14.0): dependencies: '@babel/runtime': 7.28.6 @@ -8231,6 +8895,30 @@ snapshots: dependencies: rc: 1.2.8 + remark-gfm@3.0.1: + dependencies: + '@types/mdast': 3.0.15 + mdast-util-gfm: 2.0.2 + micromark-extension-gfm: 2.0.3 + unified: 10.1.2 + transitivePeerDependencies: + - supports-color + + remark-parse@10.0.2: + dependencies: + '@types/mdast': 3.0.15 + mdast-util-from-markdown: 1.3.1 + unified: 10.1.2 + transitivePeerDependencies: + - supports-color + + remark-rehype@10.1.0: + dependencies: + '@types/hast': 2.3.10 + '@types/mdast': 3.0.15 + mdast-util-to-hast: 12.3.0 + unified: 10.1.2 + request@2.88.2: dependencies: aws-sign2: 0.7.0 @@ -8349,6 +9037,10 @@ snapshots: dependencies: tslib: 2.8.1 + sade@1.8.1: + dependencies: + mri: 1.2.0 + safe-array-concat@1.1.3: dependencies: call-bind: 1.0.8 @@ -8521,6 +9213,8 @@ snapshots: source-map@0.6.1: optional: true + space-separated-tokens@2.0.2: {} + spdx-correct@3.2.0: dependencies: spdx-expression-parse: 3.0.1 @@ -8676,6 +9370,10 @@ snapshots: dependencies: js-tokens: 9.0.1 + style-to-object@0.4.4: + dependencies: + inline-style-parser: 0.1.1 + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -8765,6 +9463,10 @@ snapshots: dependencies: punycode: 2.3.1 + trim-lines@3.0.1: {} + + trough@2.2.0: {} + ts-interface-checker@0.1.13: {} tsconfig-paths@3.15.0: @@ -8862,10 +9564,45 @@ snapshots: undici-types@7.18.2: {} + unified@10.1.2: + dependencies: + '@types/unist': 2.0.11 + bail: 2.0.2 + extend: 3.0.2 + is-buffer: 2.0.5 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 5.3.7 + unique-string@1.0.0: dependencies: crypto-random-string: 1.0.0 + unist-util-generated@2.0.1: {} + + unist-util-is@5.2.1: + dependencies: + '@types/unist': 2.0.11 + + unist-util-position@4.0.4: + dependencies: + '@types/unist': 2.0.11 + + unist-util-stringify-position@3.0.3: + dependencies: + '@types/unist': 2.0.11 + + unist-util-visit-parents@5.1.3: + dependencies: + '@types/unist': 2.0.11 + unist-util-is: 5.2.1 + + unist-util-visit@4.1.2: + dependencies: + '@types/unist': 2.0.11 + unist-util-is: 5.2.1 + unist-util-visit-parents: 5.1.3 + universalify@0.2.0: {} unpipe@1.0.0: {} @@ -8936,6 +9673,13 @@ snapshots: uuid@3.4.0: {} + uvu@0.5.6: + dependencies: + dequal: 2.0.3 + diff: 5.2.2 + kleur: 4.1.5 + sade: 1.8.1 + v8-compile-cache@2.4.0: {} validate-npm-package-license@3.0.4: @@ -8953,6 +9697,18 @@ snapshots: core-util-is: 1.0.2 extsprintf: 1.3.0 + vfile-message@3.1.4: + dependencies: + '@types/unist': 2.0.11 + unist-util-stringify-position: 3.0.3 + + vfile@5.3.7: + dependencies: + '@types/unist': 2.0.11 + is-buffer: 2.0.5 + unist-util-stringify-position: 3.0.3 + vfile-message: 3.1.4 + vite-node@1.6.1(@types/node@25.3.5)(less@3.13.1): dependencies: cac: 6.7.14 @@ -9214,3 +9970,5 @@ snapshots: zrender@5.6.1: dependencies: tslib: 2.3.0 + + zwitch@2.0.4: {} diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/assistant/Assistant.test.tsx b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/assistant/Assistant.test.tsx new file mode 100644 index 000000000000..3f6c5ea3de41 --- /dev/null +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/assistant/Assistant.test.tsx @@ -0,0 +1,241 @@ +import React from 'react'; +import { BrowserRouter } from 'react-router-dom'; +import '@testing-library/react/dont-cleanup-after-each'; +import { cleanup, render, screen, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { + assistantServer, + mockHealthDisabled, + mockHealthNotConfigured, + mockChatBusy, + mockChatTimeout, + mockChatError, + mockChatDisabled, + mockChatInterrupted, + mockHealthEnabled, + mockModels, + mockModelsError, + mockModelsDisabled, + mockChatSuccess, + mockChatDelayed +} from '@tests/mocks/assistantMocks/assistantServer'; +import Assistant from '@/v2/pages/assistant/assistant'; +import { CHATBOT_ENDPOINTS } from '@/v2/constants/chatbot.constants'; +import { rest } from 'msw'; + +const WrappedAssistantComponent = () => { + return ( + + + + ) +} + +describe('Assistant Tests', () => { + afterEach(() => { + assistantServer.resetHandlers(); + sessionStorage.clear(); + cleanup(); + }); + + beforeAll(() => { + assistantServer.listen(); + }); + + afterAll(() => { + assistantServer.close(); + }); + + it('renders disabled state when health check returns enabled=false', async () => { + assistantServer.use(mockHealthDisabled); + render(); + + await waitFor(() => { + expect(screen.getByText('Recon AI is Disabled')).toBeVisible(); + }); + }); + + it('renders not configured state when health check returns llmClientAvailable=false', async () => { + assistantServer.use(mockHealthNotConfigured); + render(); + + await waitFor(() => { + expect(screen.getByText('Recon AI is Not Configured')).toBeVisible(); + }); + }); + + it('renders empty state when enabled and configured', async () => { + assistantServer.use(mockHealthEnabled, mockModels); + render(); + + await waitFor(() => { + expect(screen.getByText('Welcome to Recon AI')).toBeVisible(); + }); + + // Seed prompts should be visible + expect(screen.getByText('How many unhealthy containers are there?')).toBeVisible(); + }); + + it('sends a message and renders markdown response', async () => { + assistantServer.use(mockHealthEnabled, mockModels, mockChatSuccess); + render(); + + await waitFor(() => { + expect(screen.getByText('Welcome to Recon AI')).toBeVisible(); + }); + + // Type a message + const input = screen.getByPlaceholderText('Ask Recon AI about your cluster...'); + await userEvent.type(input, 'Hello'); + + // Click send + const sendButton = screen.getByRole('button', { name: /send/i }); + await userEvent.click(sendButton); + + // Wait for the response + await waitFor(() => { + // Markdown bold should render as a strong tag + expect(screen.getByText('Markdown')).toHaveStyle('font-weight: bold'); + // Table should render + expect(screen.getByRole('table')).toBeVisible(); + }); + }); + + it('shows error bubble on 503 busy', async () => { + assistantServer.use(mockHealthEnabled, mockModels, mockChatBusy); + render(); + + await waitFor(() => { + expect(screen.getByText('Welcome to Recon AI')).toBeVisible(); + }); + + const input = screen.getByPlaceholderText('Ask Recon AI about your cluster...'); + await userEvent.type(input, 'Hello'); + + const sendButton = screen.getByRole('button', { name: /send/i }); + await userEvent.click(sendButton); + + await waitFor(() => { + expect(screen.getByText('The chatbot is currently handling too many requests. Please try again in a moment.')).toBeVisible(); + }); + }); + + it('shows error bubble on 504 timeout', async () => { + assistantServer.use(mockHealthEnabled, mockModels, mockChatTimeout); + render(); + + await waitFor(() => { + expect(screen.getByText('Welcome to Recon AI')).toBeVisible(); + }); + + const input = screen.getByPlaceholderText('Ask Recon AI about your cluster...'); + await userEvent.type(input, 'Hello'); + + const sendButton = screen.getByRole('button', { name: /send/i }); + await userEvent.click(sendButton); + + await waitFor(() => { + expect(screen.getByText('The chatbot request timed out. The LLM or Recon API took too long to respond. Please try again or use a different model.')).toBeVisible(); + }); + }); + + it('shows error bubble on 500 error', async () => { + assistantServer.use(mockHealthEnabled, mockModels, mockChatError); + render(); + + await waitFor(() => { + expect(screen.getByText('Welcome to Recon AI')).toBeVisible(); + }); + + const input = screen.getByPlaceholderText('Ask Recon AI about your cluster...'); + await userEvent.type(input, 'Hello'); + + const sendButton = screen.getByRole('button', { name: /send/i }); + await userEvent.click(sendButton); + + await waitFor(() => { + expect(screen.getByText('An error occurred processing your request.')).toBeVisible(); + }); + }); + + it('shows error bubble on 503 chat disabled', async () => { + assistantServer.use(mockHealthEnabled, mockModels, mockChatDisabled); + render(); + + await waitFor(() => { + expect(screen.getByText('Welcome to Recon AI')).toBeVisible(); + }); + + const input = screen.getByPlaceholderText('Ask Recon AI about your cluster...'); + await userEvent.type(input, 'Hello'); + + const sendButton = screen.getByRole('button', { name: /send/i }); + await userEvent.click(sendButton); + + await waitFor(() => { + expect(screen.getByText('Chatbot service is not enabled')).toBeVisible(); + }); + }); + + it('shows error bubble on 503 interrupted', async () => { + assistantServer.use(mockHealthEnabled, mockModels, mockChatInterrupted); + render(); + + await waitFor(() => { + expect(screen.getByText('Welcome to Recon AI')).toBeVisible(); + }); + + const input = screen.getByPlaceholderText('Ask Recon AI about your cluster...'); + await userEvent.type(input, 'Hello'); + + const sendButton = screen.getByRole('button', { name: /send/i }); + await userEvent.click(sendButton); + + await waitFor(() => { + expect(screen.getByText('Request was interrupted. Please try again.')).toBeVisible(); + }); + }); + + it('handles models fetch failure gracefully', async () => { + assistantServer.use(mockHealthEnabled, mockModelsError); + render(); + + await waitFor(() => { + expect(screen.getByText('Welcome to Recon AI')).toBeVisible(); + }); + + // Default model should be selected or available even if fetch fails + expect(screen.getByText('Default Provider')).toBeVisible(); + }); + + it('disables send button while in-flight', async () => { + // Delay the response to test in-flight state + assistantServer.use( + mockHealthEnabled, + mockModels, + mockChatDelayed + ); + + render(); + + await waitFor(() => { + expect(screen.getByText('Welcome to Recon AI')).toBeVisible(); + }); + + const input = screen.getByPlaceholderText('Ask Recon AI about your cluster...'); + await userEvent.type(input, 'Hello'); + + const sendButton = screen.getByRole('button', { name: /send/i }); + await userEvent.click(sendButton); + + // Stop button should appear, send button should be gone or disabled + await waitFor(() => { + expect(screen.getByRole('button', { name: /stop/i })).toBeVisible(); + }); + + // Wait for completion + await waitFor(() => { + expect(screen.getByText('Delayed')).toBeVisible(); + }); + }); +}); diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/mocks/assistantMocks/assistantServer.ts b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/mocks/assistantMocks/assistantServer.ts new file mode 100644 index 000000000000..f9c77b379f4c --- /dev/null +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/mocks/assistantMocks/assistantServer.ts @@ -0,0 +1,141 @@ +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { CHATBOT_ENDPOINTS } from '@/v2/constants/chatbot.constants'; + +export const mockHealthEnabled = rest.get(CHATBOT_ENDPOINTS.HEALTH, (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + enabled: true, + llmClientAvailable: true + }) + ); +}); + +export const mockHealthDisabled = rest.get(CHATBOT_ENDPOINTS.HEALTH, (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + enabled: false, + llmClientAvailable: true + }) + ); +}); + +export const mockHealthNotConfigured = rest.get(CHATBOT_ENDPOINTS.HEALTH, (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + enabled: true, + llmClientAvailable: false + }) + ); +}); + +export const mockModels = rest.get(CHATBOT_ENDPOINTS.MODELS, (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + models: ['gemini-2.5-flash', 'gemini-2.5-pro'] + }) + ); +}); + +export const mockModelsError = rest.get(CHATBOT_ENDPOINTS.MODELS, (req, res, ctx) => { + return res( + ctx.status(500), + ctx.json({ + error: 'Failed to fetch models' + }) + ); +}); + +export const mockModelsDisabled = rest.get(CHATBOT_ENDPOINTS.MODELS, (req, res, ctx) => { + return res( + ctx.status(503), + ctx.json({ + error: 'Chatbot service is not enabled' + }) + ); +}); + +export const mockChatSuccess = rest.post(CHATBOT_ENDPOINTS.CHAT, (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + response: 'This is a **Markdown** response with a table:\n\n| Col 1 | Col 2 |\n|---|---|\n| A | B |', + success: true + }) + ); +}); + +export const mockChatDelayed = rest.post(CHATBOT_ENDPOINTS.CHAT, (req, res, ctx) => { + return res( + ctx.delay(100), + ctx.status(200), + ctx.json({ + response: 'Delayed', + success: true + }) + ); +}); + +export const mockChatBusy = rest.post(CHATBOT_ENDPOINTS.CHAT, (req, res, ctx) => { + return res( + ctx.status(503), + ctx.json({ + error: 'The chatbot is currently handling too many requests. Please try again in a moment.' + }) + ); +}); + +export const mockChatTimeout = rest.post(CHATBOT_ENDPOINTS.CHAT, (req, res, ctx) => { + return res( + ctx.status(504), + ctx.json({ + error: 'The chatbot request timed out. The LLM or Recon API took too long to respond. Please try again or use a different model.' + }) + ); +}); + +export const mockChatError = rest.post(CHATBOT_ENDPOINTS.CHAT, (req, res, ctx) => { + return res( + ctx.status(500), + ctx.json({ + error: 'An error occurred processing your request.' + }) + ); +}); + +export const mockChatDisabled = rest.post(CHATBOT_ENDPOINTS.CHAT, (req, res, ctx) => { + return res( + ctx.status(503), + ctx.json({ + error: 'Chatbot service is not enabled' + }) + ); +}); + +export const mockChatInterrupted = rest.post(CHATBOT_ENDPOINTS.CHAT, (req, res, ctx) => { + return res( + ctx.status(503), + ctx.json({ + error: 'Request was interrupted. Please try again.' + }) + ); +}); + +export const mockChatEmpty = rest.post(CHATBOT_ENDPOINTS.CHAT, (req, res, ctx) => { + return res( + ctx.status(400), + ctx.json({ + error: 'Query cannot be empty' + }) + ); +}); + +export const assistantServer = setupServer( + mockHealthEnabled, + mockModels, + mockChatSuccess +); diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/vitest.setup.ts b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/vitest.setup.ts index 54dc2d5e52b7..cf410d82df58 100644 --- a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/vitest.setup.ts +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/vitest.setup.ts @@ -58,4 +58,6 @@ Object.defineProperty(window, 'matchMedia', { removeEventListener: vi.fn(), dispatchEvent: vi.fn(), })), -}) \ No newline at end of file +}); + +window.Element.prototype.scrollIntoView = vi.fn(); \ No newline at end of file diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/app.tsx b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/app.tsx index 3f1327f1d6cd..b3c8f764a628 100644 --- a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/app.tsx +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/app.tsx @@ -23,7 +23,7 @@ import NavBar from './components/navBar/navBar'; import NavBarV2 from '@/v2/components/navBar/navBar'; import Breadcrumbs from './components/breadcrumbs/breadcrumbs'; import BreadcrumbsV2 from '@/v2/components/breadcrumbs/breadcrumbs'; -import { HashRouter as Router, Switch, Route, Redirect } from 'react-router-dom'; +import { HashRouter as Router, Switch, Route, Redirect, useLocation } from 'react-router-dom'; import { routes } from '@/routes'; import { routesV2 } from '@/v2/routes-v2'; import { MakeRouteWithSubRoutes } from '@/makeRouteWithSubRoutes'; @@ -43,6 +43,58 @@ interface IAppState { enableOldUI: boolean; } +const AppLayout = ({ enableOldUI, collapsed, onCollapse, onToggleUI }: any) => { + const location = useLocation(); + const isAssistantRoute = location.pathname === '/Assistant'; + const layoutClass = classNames('content-layout', { 'sidebar-collapsed': collapsed }); + + return ( + + { + (enableOldUI) + ? + : + } + +
+
+ {(enableOldUI) ? : } + + Switch to + Old UI
} + checkedChildren={
New UI
} + checked={enableOldUI} + onChange={onToggleUI} /> + + +
+ + }> + + + + + {(enableOldUI) + ? routes.map( + (route, index) => + ) + : routesV2.map( + (route, index) => { + return + } + ) + } + + + + + {!isAssistantRoute &&