diff --git a/docs/sphinx/user-docs/images/ui-view-clusters.png b/docs/sphinx/user-docs/images/ui-view-clusters.png index 259d2dc11..f0b123389 100644 Binary files a/docs/sphinx/user-docs/images/ui-view-clusters.png and b/docs/sphinx/user-docs/images/ui-view-clusters.png differ diff --git a/docs/sphinx/user-docs/ui-widgets.rst b/docs/sphinx/user-docs/ui-widgets.rst index 6c797e043..923354236 100644 --- a/docs/sphinx/user-docs/ui-widgets.rst +++ b/docs/sphinx/user-docs/ui-widgets.rst @@ -39,13 +39,15 @@ requests and limits along with the clusters status. Above is a list of two Ray Clusters ``raytest`` and ``raytest2`` each of those headings is clickable and will update the table to view the -selected Cluster's information. There are three buttons under the table -``Cluster Down``, ``View Jobs`` and ``Open Ray Dashboard``. \* The +selected Cluster's information. There are four buttons under the table +``Cluster Down``, ``View Jobs``, ``Open Ray Dashboard``, and ``Refresh Data``. \* The ``Cluster Down`` button will delete the selected Cluster. \* The ``View Jobs`` button will try to open the Ray Dashboard's Jobs view in a Web Browser. The link will also be printed to the console. \* The ``Open Ray Dashboard`` button will try to open the Ray Dashboard view in -a Web Browser. The link will also be printed to the console. +a Web Browser. The link will also be printed to the console. \* The +``Refresh Data`` button will refresh the list of RayClusters, the spec, and +the status of the Ray Cluster. The UI Table can be viewed by calling the following function. diff --git a/src/codeflare_sdk/common/widgets/test_widgets.py b/src/codeflare_sdk/common/widgets/test_widgets.py index e01b91933..12c238544 100644 --- a/src/codeflare_sdk/common/widgets/test_widgets.py +++ b/src/codeflare_sdk/common/widgets/test_widgets.py @@ -296,6 +296,9 @@ def test_ray_cluster_manager_widgets_init(mocker, capsys): assert ( ray_cluster_manager_instance.ray_dashboard_button == mock_button.return_value ), "ray_dashboard_button is not set correctly" + assert ( + ray_cluster_manager_instance.refresh_data_button == mock_button.return_value + ), "refresh_data_button is not set correctly" assert ( ray_cluster_manager_instance.raycluster_data_output == mock_output.return_value ), "raycluster_data_output is not set correctly" @@ -310,6 +313,7 @@ def test_ray_cluster_manager_widgets_init(mocker, capsys): mock_delete_button = MagicMock() mock_list_jobs_button = MagicMock() mock_ray_dashboard_button = MagicMock() + mock_refresh_dataframe_button = MagicMock() mock_javascript = mocker.patch("codeflare_sdk.common.widgets.widgets.Javascript") ray_cluster_manager_instance.url_output = MagicMock() @@ -332,6 +336,12 @@ def test_ray_cluster_manager_widgets_init(mocker, capsys): f'window.open("{mock_dashboard_uri.return_value}/#/jobs", "_blank");' ) + # Simulate clicking the refresh data button + ray_cluster_manager_instance._on_refresh_data_button_click( + mock_refresh_dataframe_button + ) + mock_fetch_cluster_data.assert_called_with(namespace) + # Simulate clicking the Ray dashboard button ray_cluster_manager_instance.classification_widget.value = "test-cluster-1" ray_cluster_manager_instance._on_ray_dashboard_button_click( diff --git a/src/codeflare_sdk/common/widgets/widgets.py b/src/codeflare_sdk/common/widgets/widgets.py index 8a13a4d4d..6f3283ce6 100644 --- a/src/codeflare_sdk/common/widgets/widgets.py +++ b/src/codeflare_sdk/common/widgets/widgets.py @@ -74,6 +74,12 @@ def __init__(self, ray_clusters_df: pd.DataFrame, namespace: str = None): tooltip="Open the Ray Dashboard in a new tab", layout=widgets.Layout(width="auto"), ) + self.refresh_data_button = widgets.Button( + description="Refresh Data", + icon="refresh", + tooltip="Refresh the list of Ray Clusters", + layout=widgets.Layout(width="auto", left="1em"), + ) # Set up interactions self._initialize_callbacks() @@ -95,6 +101,9 @@ def _initialize_callbacks(self): self.ray_dashboard_button.on_click( lambda b: self._on_ray_dashboard_button_click(b) ) + self.refresh_data_button.on_click( + lambda b: self._on_refresh_data_button_click(b) + ) def _trigger_initial_display(self): """ @@ -110,59 +119,25 @@ def _on_cluster_click(self, selection_change): _on_cluster_click handles the event when a cluster is selected from the toggle buttons, updating the output with cluster details. """ new_value = selection_change["new"] - self.raycluster_data_output.clear_output() - ray_clusters_df = _fetch_cluster_data(self.namespace) - self.classification_widget.options = ray_clusters_df["Name"].tolist() - with self.raycluster_data_output: - display( - HTML( - ray_clusters_df[ray_clusters_df["Name"] == new_value][ - [ - "Name", - "Namespace", - "Num Workers", - "Head GPUs", - "Head CPU Req~Lim", - "Head Memory Req~Lim", - "Worker GPUs", - "Worker CPU Req~Lim", - "Worker Memory Req~Lim", - "status", - ] - ].to_html(escape=False, index=False, border=2) - ) - ) + self.classification_widget.value = new_value + self._refresh_dataframe() def _on_delete_button_click(self, b): """ _on_delete_button_click handles the event when the Delete Button is clicked, deleting the selected cluster. """ cluster_name = self.classification_widget.value - namespace = self.ray_clusters_df[ - self.ray_clusters_df["Name"] == self.classification_widget.value - ]["Namespace"].values[0] - _delete_cluster(cluster_name, namespace) + _delete_cluster(cluster_name, self.namespace) with self.user_output: self.user_output.clear_output() print( - f"Cluster {cluster_name} in the {namespace} namespace was deleted successfully." + f"Cluster {cluster_name} in the {self.namespace} namespace was deleted successfully." ) # Refresh the dataframe - new_df = _fetch_cluster_data(namespace) - self.ray_clusters_df = new_df - if new_df.empty: - self.classification_widget.close() - self.delete_button.close() - self.list_jobs_button.close() - self.ray_dashboard_button.close() - with self.raycluster_data_output: - self.raycluster_data_output.clear_output() - print(f"No clusters found in the {namespace} namespace.") - else: - self.classification_widget.options = new_df["Name"].tolist() + self._refresh_dataframe() def _on_list_jobs_button_click(self, b): """ @@ -171,15 +146,12 @@ def _on_list_jobs_button_click(self, b): from codeflare_sdk import Cluster cluster_name = self.classification_widget.value - namespace = self.ray_clusters_df[ - self.ray_clusters_df["Name"] == self.classification_widget.value - ]["Namespace"].values[0] # Suppress from Cluster Object initialisation widgets and outputs with widgets.Output(), contextlib.redirect_stdout( io.StringIO() ), contextlib.redirect_stderr(io.StringIO()): - cluster = Cluster(ClusterConfiguration(cluster_name, namespace)) + cluster = Cluster(ClusterConfiguration(cluster_name, self.namespace)) dashboard_url = cluster.cluster_dashboard_uri() with self.user_output: @@ -197,15 +169,12 @@ def _on_ray_dashboard_button_click(self, b): from codeflare_sdk import Cluster cluster_name = self.classification_widget.value - namespace = self.ray_clusters_df[ - self.ray_clusters_df["Name"] == self.classification_widget.value - ]["Namespace"].values[0] # Suppress from Cluster Object initialisation widgets and outputs with widgets.Output(), contextlib.redirect_stdout( io.StringIO() ), contextlib.redirect_stderr(io.StringIO()): - cluster = Cluster(ClusterConfiguration(cluster_name, namespace)) + cluster = Cluster(ClusterConfiguration(cluster_name, self.namespace)) dashboard_url = cluster.cluster_dashboard_uri() with self.user_output: @@ -214,11 +183,88 @@ def _on_ray_dashboard_button_click(self, b): with self.url_output: display(Javascript(f'window.open("{dashboard_url}", "_blank");')) + def _on_refresh_data_button_click(self, b): + """ + _on_refresh_button_click handles the event when the Refresh Data button is clicked, refreshing the list of Ray Clusters. + """ + self.refresh_data_button.disabled = True + self._refresh_dataframe() + self.refresh_data_button.disabled = False + + def _refresh_dataframe(self): + """ + _refresh_data function refreshes the list of Ray Clusters. + """ + self.ray_clusters_df = _fetch_cluster_data(self.namespace) + if self.ray_clusters_df.empty: + self.classification_widget.close() + self.delete_button.close() + self.list_jobs_button.close() + self.ray_dashboard_button.close() + self.refresh_data_button.close() + with self.raycluster_data_output: + self.raycluster_data_output.clear_output() + print(f"No clusters found in the {self.namespace} namespace.") + else: + # Store the current selection if it still exists (Was not previously deleted). + selected_cluster = ( + self.classification_widget.value + if self.classification_widget.value + in self.ray_clusters_df["Name"].tolist() + else None + ) + + # Update list of Ray Clusters. + self.classification_widget.options = self.ray_clusters_df["Name"].tolist() + + # If the selected cluster exists, preserve the selection to remain viewing the currently selected cluster. + # If it does not exist, default to the first available cluster. + if selected_cluster: + self.classification_widget.value = selected_cluster + else: + self.classification_widget.value = self.ray_clusters_df["Name"].iloc[0] + + # Update the output with the current Ray Cluster details. + self._display_cluster_details() + + def _display_cluster_details(self): + """ + _display_cluster_details function displays the selected cluster details in the output widget. + """ + self.raycluster_data_output.clear_output() + selected_cluster = self.ray_clusters_df[ + self.ray_clusters_df["Name"] == self.classification_widget.value + ] + with self.raycluster_data_output: + display( + HTML( + selected_cluster[ + [ + "Name", + "Namespace", + "Num Workers", + "Head GPUs", + "Head CPU Req~Lim", + "Head Memory Req~Lim", + "Worker GPUs", + "Worker CPU Req~Lim", + "Worker Memory Req~Lim", + "status", + ] + ].to_html(escape=False, index=False, border=2) + ) + ) + def display_widgets(self): display(widgets.VBox([self.classification_widget, self.raycluster_data_output])) display( widgets.HBox( - [self.delete_button, self.list_jobs_button, self.ray_dashboard_button] + [ + self.delete_button, + self.list_jobs_button, + self.ray_dashboard_button, + self.refresh_data_button, + ] ), self.url_output, self.user_output, diff --git a/ui-tests/tests/widget_notebook_example.test.ts b/ui-tests/tests/widget_notebook_example.test.ts index 823a73f47..db746e00f 100644 --- a/ui-tests/tests/widget_notebook_example.test.ts +++ b/ui-tests/tests/widget_notebook_example.test.ts @@ -36,6 +36,8 @@ test.describe("Visual Regression", () => { // Hide the cell toolbar before capturing the screenshots await page.addStyleTag({ content: '.jp-cell-toolbar { display: none !important; }' }); + // Hide the file explorer + await page.keyboard.press('Control+Shift+F'); const captures: (Buffer | null)[] = []; // Array to store cell screenshots const cellCount = await page.notebook.getCellCount(); @@ -109,30 +111,48 @@ test.describe("Visual Regression", () => { await runPreviousCell(page, cellCount, '(, False)'); - // view_clusters table with buttons - await interactWithWidget(page, upDownWidgetCellIndex, 'input[type="checkbox"]', async (checkbox) => { - await checkbox.click(); - const isChecked = await checkbox.isChecked(); - expect(isChecked).toBe(false); - }); + // Replace text in ClusterConfiguration to run a new RayCluster + const cell = page.getByText('raytest').first(); + await cell.fill('"raytest-1"'); + await page.notebook.runCell(cellCount - 3, true); // Run ClusterConfiguration cell await interactWithWidget(page, upDownWidgetCellIndex, 'button:has-text("Cluster Up")', async (button) => { await button.click(); - const successMessage = await page.waitForSelector('text=Ray Cluster: \'raytest\' has successfully been created', { timeout: 10000 }); + const successMessage = await page.waitForSelector('text=Ray Cluster: \'raytest-1\' has successfully been created', { timeout: 10000 }); expect(successMessage).not.toBeNull(); }); const viewClustersCellIndex = 4; // 5 on OpenShift await page.notebook.runCell(cellCount - 2, true); + + // Wait until the RayCluster status in the table updates to "Ready" + await interactWithWidget(page, viewClustersCellIndex, 'button:has-text("Refresh Data")', async (button) => { + let clusterReady = false; + const maxRefreshRetries = 24; // 24 retries * 5 seconds = 120 seconds + let numRefreshRetries = 0; + while (!clusterReady && numRefreshRetries < maxRefreshRetries) { + await button.click(); + try { + await page.waitForSelector('text=Ready ✓', { timeout: 5000 }); + clusterReady = true; + } + catch (e) { + console.log(`Cluster not ready yet. Retrying...`); + numRefreshRetries++; + } + } + expect(clusterReady).toBe(true); + }); + await interactWithWidget(page, viewClustersCellIndex, 'button:has-text("Open Ray Dashboard")', async (button) => { await button.click(); - const successMessage = await page.waitForSelector('text=Opening Ray Dashboard for raytest cluster', { timeout: 5000 }); + const successMessage = await page.waitForSelector('text=Opening Ray Dashboard for raytest-1 cluster', { timeout: 5000 }); expect(successMessage).not.toBeNull(); }); await interactWithWidget(page, viewClustersCellIndex, 'button:has-text("View Jobs")', async (button) => { await button.click(); - const successMessage = await page.waitForSelector('text=Opening Ray Jobs Dashboard for raytest cluster', { timeout: 5000 }); + const successMessage = await page.waitForSelector('text=Opening Ray Jobs Dashboard for raytest-1 cluster', { timeout: 5000 }); expect(successMessage).not.toBeNull(); }); @@ -141,7 +161,7 @@ test.describe("Visual Regression", () => { const noClustersMessage = await page.waitForSelector(`text=No clusters found in the ${namespace} namespace.`, { timeout: 5000 }); expect(noClustersMessage).not.toBeNull(); - const successMessage = await page.waitForSelector(`text=Cluster raytest in the ${namespace} namespace was deleted successfully.`, { timeout: 5000 }); + const successMessage = await page.waitForSelector(`text=Cluster raytest-1 in the ${namespace} namespace was deleted successfully.`, { timeout: 5000 }); expect(successMessage).not.toBeNull(); }); diff --git a/ui-tests/tests/widget_notebook_example.test.ts-snapshots/widgets-cell-0-linux.png b/ui-tests/tests/widget_notebook_example.test.ts-snapshots/widgets-cell-0-linux.png index efb098ef7..2d3aa1806 100644 Binary files a/ui-tests/tests/widget_notebook_example.test.ts-snapshots/widgets-cell-0-linux.png and b/ui-tests/tests/widget_notebook_example.test.ts-snapshots/widgets-cell-0-linux.png differ diff --git a/ui-tests/tests/widget_notebook_example.test.ts-snapshots/widgets-cell-2-linux.png b/ui-tests/tests/widget_notebook_example.test.ts-snapshots/widgets-cell-2-linux.png index 1fd47fcd2..babe9dcbe 100644 Binary files a/ui-tests/tests/widget_notebook_example.test.ts-snapshots/widgets-cell-2-linux.png and b/ui-tests/tests/widget_notebook_example.test.ts-snapshots/widgets-cell-2-linux.png differ diff --git a/ui-tests/tests/widget_notebook_example.test.ts-snapshots/widgets-cell-3-linux.png b/ui-tests/tests/widget_notebook_example.test.ts-snapshots/widgets-cell-3-linux.png index 3d01d57a3..bd55aa778 100644 Binary files a/ui-tests/tests/widget_notebook_example.test.ts-snapshots/widgets-cell-3-linux.png and b/ui-tests/tests/widget_notebook_example.test.ts-snapshots/widgets-cell-3-linux.png differ diff --git a/ui-tests/tests/widget_notebook_example.test.ts-snapshots/widgets-cell-4-linux.png b/ui-tests/tests/widget_notebook_example.test.ts-snapshots/widgets-cell-4-linux.png index 691e7124f..bfa203a2b 100644 Binary files a/ui-tests/tests/widget_notebook_example.test.ts-snapshots/widgets-cell-4-linux.png and b/ui-tests/tests/widget_notebook_example.test.ts-snapshots/widgets-cell-4-linux.png differ diff --git a/ui-tests/tests/widget_notebook_example.test.ts-snapshots/widgets-cell-5-linux.png b/ui-tests/tests/widget_notebook_example.test.ts-snapshots/widgets-cell-5-linux.png index 46861fd8a..92781875d 100644 Binary files a/ui-tests/tests/widget_notebook_example.test.ts-snapshots/widgets-cell-5-linux.png and b/ui-tests/tests/widget_notebook_example.test.ts-snapshots/widgets-cell-5-linux.png differ