diff --git a/selenium-tests/src/test/java/com/nuodb/selenium/SeleniumTestHelper.java b/selenium-tests/src/test/java/com/nuodb/selenium/SeleniumTestHelper.java index 2ba9330..a02000f 100644 --- a/selenium-tests/src/test/java/com/nuodb/selenium/SeleniumTestHelper.java +++ b/selenium-tests/src/test/java/com/nuodb/selenium/SeleniumTestHelper.java @@ -288,4 +288,36 @@ public void sleep(int ms) { Thread.currentThread().interrupt(); } } + + public void retry(Runnable r) { + retry(10, 100, r); + } + + public void retry(int count, long delayMS, Runnable r) { + Throwable exception = null; + for(int i=count; i>=0; i--) { + try { + r.run(); + return; + } + catch(Throwable e) { + exception = e; + } + try { + Thread.sleep(delayMS); + } + catch(InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + throw new RuntimeException(exception); + } + + public void clearSessionStorage(String key) { + ((JavascriptExecutor)driver).executeScript("window.sessionStorage.removeItem(\"" + key + "\")"); + } + + public String getSessionStorage(String key) { + return (String) ((JavascriptExecutor)driver).executeScript("return window.sessionStorage.getItem(\"" + key + "\")"); + } } diff --git a/selenium-tests/src/test/java/com/nuodb/selenium/TestRoutines.java b/selenium-tests/src/test/java/com/nuodb/selenium/TestRoutines.java index b44e608..bd371f0 100644 --- a/selenium-tests/src/test/java/com/nuodb/selenium/TestRoutines.java +++ b/selenium-tests/src/test/java/com/nuodb/selenium/TestRoutines.java @@ -138,6 +138,11 @@ public void clickPopupMenu(WebElement element, String dataTestId) { menuItems.get(0).click(); } + public void clickUserMenu(String dataTestId) { + WebElement userMenu = waitElement("user-menu"); + clickPopupMenu(userMenu, dataTestId); + } + private void createResource(Resource resource, String name, String ...fieldValueList) { clickMenu(resource.name()); diff --git a/selenium-tests/src/test/java/com/nuodb/selenium/basic/AutomationTest.java b/selenium-tests/src/test/java/com/nuodb/selenium/basic/AutomationTest.java new file mode 100644 index 0000000..86fb6a8 --- /dev/null +++ b/selenium-tests/src/test/java/com/nuodb/selenium/basic/AutomationTest.java @@ -0,0 +1,56 @@ +// (C) Copyright 2024 Dassault Systemes SE. All Rights Reserved. + +package com.nuodb.selenium.basic; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nuodb.selenium.Constants; +import com.nuodb.selenium.TestRoutines; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class AutomationTest extends TestRoutines { + @Test + public void testRecordingCreateAndDeleteUsers() throws JsonProcessingException { + // Start Recording + login(Constants.ADMIN_ORGANIZATION, Constants.ADMIN_USER, Constants.ADMIN_PASSWORD); + clickUserMenu("automation"); + clearSessionStorage("nuodbaas-webui-recorded"); + clearSessionStorage("nuodbaas-webui-isRecording"); + click("btnStartRecording"); + + // Setup and list users + String userName = createUser(); + clickMenu("users"); + + // find user and delete + deleteUser(userName); + waitRestComplete(); + + // Stop Recording + retry(()->{ + clickUserMenu("automation"); + click("btnStopRecording"); + }); + + // Validate recording + String strRecording = getSessionStorage("nuodbaas-webui-recorded"); + ObjectMapper mapper = new ObjectMapper(); + JsonNode items = mapper.readTree(strRecording); + int [] putDelete = { 0, 0}; + items.forEach(item -> { + String method = item.get("method").asText(); + if(method.equalsIgnoreCase("put")) { + putDelete[0]++; + } + else if(method.equalsIgnoreCase("delete")) { + putDelete[1]++; + } + }); + assertEquals(1, putDelete[0]); + assertEquals(1, putDelete[1]); + } +} \ No newline at end of file diff --git a/selenium-tests/src/test/java/com/nuodb/selenium/basic/BackupTest.java b/selenium-tests/src/test/java/com/nuodb/selenium/basic/BackupTest.java index e0c0bf5..0e49352 100644 --- a/selenium-tests/src/test/java/com/nuodb/selenium/basic/BackupTest.java +++ b/selenium-tests/src/test/java/com/nuodb/selenium/basic/BackupTest.java @@ -41,8 +41,10 @@ public void testListCreateAndDeleteBackups() { // verify backup is gone waitRestComplete(); - List buttonsCell = waitTableElements("list_resource__table", "name", backupName, MENU_COLUMN); - assertEquals(0, buttonsCell.size()); + retry(()->{ + List buttonsCell = waitTableElements("list_resource__table", "name", backupName, MENU_COLUMN); + assertEquals(0, buttonsCell.size()); + }); } @Test @@ -68,10 +70,12 @@ public void testEditBackup() { waitRestComplete(); // verify backup was modified - List labelCells = waitTableElements("list_resource__table", "name", backupName, "labels"); - assertThat(labelCells) - .hasSize(1) - .get(0) - .mapContains(projectName, databaseName); + retry(()->{ + List labelCells = waitTableElements("list_resource__table", "name", backupName, "labels"); + assertThat(labelCells) + .hasSize(1) + .get(0) + .mapContains(projectName, databaseName); + }); } } \ No newline at end of file diff --git a/selenium-tests/src/test/java/com/nuodb/selenium/basic/DatabaseTest.java b/selenium-tests/src/test/java/com/nuodb/selenium/basic/DatabaseTest.java index e7c4530..a9f4d5e 100644 --- a/selenium-tests/src/test/java/com/nuodb/selenium/basic/DatabaseTest.java +++ b/selenium-tests/src/test/java/com/nuodb/selenium/basic/DatabaseTest.java @@ -40,8 +40,10 @@ public void testListCreateAndDeleteDatabases() { // verify database is gone waitRestComplete(); - List buttonsCell = waitTableElements("list_resource__table", "name", databaseName, MENU_COLUMN); - assertEquals(0, buttonsCell.size()); + retry(()->{ + List buttonsCell = waitTableElements("list_resource__table", "name", databaseName, MENU_COLUMN); + assertEquals(0, buttonsCell.size()); + }); } @Test @@ -64,11 +66,13 @@ public void testEditDatabase() { waitRestComplete(); // verify database was modified - List labelCells = waitTableElements("list_resource__table", "name", databaseName, "labels"); - assertThat(labelCells) - .hasSize(1) - .get(0) - .mapContains(projectName, databaseName); + retry(()->{ + List labelCells = waitTableElements("list_resource__table", "name", databaseName, "labels"); + assertThat(labelCells) + .hasSize(1) + .get(0) + .mapContains(projectName, databaseName); + }); } private WebElement findSingleDatabaseButton(String databaseName, String buttonLabel) { diff --git a/selenium-tests/src/test/java/com/nuodb/selenium/basic/ProjectTest.java b/selenium-tests/src/test/java/com/nuodb/selenium/basic/ProjectTest.java index 2cdc7cc..5dd39fd 100644 --- a/selenium-tests/src/test/java/com/nuodb/selenium/basic/ProjectTest.java +++ b/selenium-tests/src/test/java/com/nuodb/selenium/basic/ProjectTest.java @@ -35,8 +35,10 @@ public void testListCreateAndDeleteProjects() { // verify project is gone waitRestComplete(); - List buttonsCell = waitTableElements("list_resource__table", "name", projectName, MENU_COLUMN); - assertEquals(0, buttonsCell.size()); + retry(()->{ + List buttonsCell = waitTableElements("list_resource__table", "name", projectName, MENU_COLUMN); + assertEquals(0, buttonsCell.size()); + }); } @@ -61,11 +63,13 @@ public void testEditProject() { waitRestComplete(); // verify project was modified - List tierCells = waitTableElements("list_resource__table", "name", projectName, "tier"); - assertThat(tierCells) - .hasSize(1) - .get(0) - .hasValue("n0.small"); + retry(()->{ + List tierCells = waitTableElements("list_resource__table", "name", projectName, "tier"); + assertThat(tierCells) + .hasSize(1) + .get(0) + .hasValue("n0.small"); + }); List maintenanceCells = waitTableElements("list_resource__table", "name", projectName, "maintenance"); assertThat(maintenanceCells) diff --git a/selenium-tests/src/test/java/com/nuodb/selenium/basic/SearchTest.java b/selenium-tests/src/test/java/com/nuodb/selenium/basic/SearchTest.java index e42e194..1651df5 100644 --- a/selenium-tests/src/test/java/com/nuodb/selenium/basic/SearchTest.java +++ b/selenium-tests/src/test/java/com/nuodb/selenium/basic/SearchTest.java @@ -39,56 +39,72 @@ public void testSearch() throws IOException { login(Constants.ADMIN_ORGANIZATION, Constants.ADMIN_USER, Constants.ADMIN_PASSWORD); clickMenu("users"); waitRestComplete(); - List nameCell = waitTableElements("list_resource__table", "name", null, "name"); - assertEquals(20, nameCell.size()); + retry(()->{ + List nameCell = waitTableElements("list_resource__table", "name", null, "name"); + assertEquals(20, nameCell.size()); + }); // search users starting with "1" index and check that 10 users are returned replaceInputElementByName("search", name + "1"); waitElement("searchButton").click(); waitRestComplete(); - nameCell = waitTableElements("list_resource__table", "name", null, "name"); - assertEquals(10, nameCell.size()); + retry(()-> { + var nc = waitTableElements("list_resource__table", "name", null, "name"); + assertEquals(10, nc.size()); + }); // search users by label existence replaceInputElementByName("search", "label=label1"); waitElement("searchButton").click(); waitRestComplete(); - nameCell = waitTableElements("list_resource__table", "name", null, "name"); - assertEquals(20, nameCell.size()); + retry(()-> { + var nc = waitTableElements("list_resource__table", "name", null, "name"); + assertEquals(20, nc.size()); + }); // search users by label value replaceInputElementByName("search", "label=label2=" + name + "8"); waitElement("searchButton").click(); waitRestComplete(); - nameCell = waitTableElements("list_resource__table", "name", null, "name"); - assertEquals(9, nameCell.size()); + retry(()->{ + var nc = waitTableElements("list_resource__table", "name", null, "name"); + assertEquals(9, nc.size()); + }); // search users by label value and name replaceInputElementByName("search", "label=label2=" + name + "8" + " name=" + name + "1"); waitElement("searchButton").click(); waitRestComplete(); - nameCell = waitTableElements("list_resource__table", "name", null, "name"); - assertEquals(1, nameCell.size()); + retry(()-> { + var nc = waitTableElements("list_resource__table", "name", null, "name"); + assertEquals(1, nc.size()); + }); // search users by partial name replaceInputElementByName("search", "name=" + name + "1"); waitElement("searchButton").click(); waitRestComplete(); - nameCell = waitTableElements("list_resource__table", "name", null, "name"); - assertEquals(10, nameCell.size()); + retry(()->{ + var nc = waitTableElements("list_resource__table", "name", null, "name"); + assertEquals(10, nc.size()); + }); // search users by full name replaceInputElementByName("search", "name=" + name + "19"); waitElement("searchButton").click(); waitRestComplete(); - nameCell = waitTableElements("list_resource__table", "name", null, "name"); - assertEquals(1, nameCell.size()); + retry(()->{ + var nc = waitTableElements("list_resource__table", "name", null, "name"); + assertEquals(1, nc.size()); + }); // search users by invalid name replaceInputElementByName("search", "name=" + name + "invalid"); waitElement("searchButton").click(); waitRestComplete(); - nameCell = waitTableElements("list_resource__table", "name", null, "name"); - assertEquals(0, nameCell.size()); + retry(()->{ + var nc = waitTableElements("list_resource__table", "name", null, "name"); + assertEquals(0, nc.size()); + }); } } \ No newline at end of file diff --git a/selenium-tests/src/test/java/com/nuodb/selenium/basic/UserTest.java b/selenium-tests/src/test/java/com/nuodb/selenium/basic/UserTest.java index 488cd92..99d0c04 100644 --- a/selenium-tests/src/test/java/com/nuodb/selenium/basic/UserTest.java +++ b/selenium-tests/src/test/java/com/nuodb/selenium/basic/UserTest.java @@ -3,7 +3,6 @@ package com.nuodb.selenium.basic; import org.junit.jupiter.api.Test; -import org.openqa.selenium.By; import org.openqa.selenium.WebElement; import com.nuodb.selenium.Constants; @@ -36,8 +35,10 @@ public void testListCreateAndDeleteUsers() { // verify user is gone waitRestComplete(); - List buttonsCell = waitTableElements("list_resource__table", "name", userName, MENU_COLUMN); - assertEquals(0, buttonsCell.size()); + retry(()-> { + List buttonsCell = waitTableElements("list_resource__table", "name", userName, MENU_COLUMN); + assertEquals(0, buttonsCell.size()); + }); } @Test @@ -69,10 +70,12 @@ public void testEditUser() { waitRestComplete(); // verify user was modified - List labelsCells = waitTableElements("list_resource__table", "name", userName, "labels"); - assertThat(labelsCells) - .hasSize(1) - .get(0) - .mapContains(userName, userName); + retry(()->{ + List labelsCells = waitTableElements("list_resource__table", "name", userName, "labels"); + assertThat(labelsCells) + .hasSize(1) + .get(0) + .mapContains(userName, userName); + }); } } \ No newline at end of file diff --git a/ui/public/theme/base.css b/ui/public/theme/base.css index 1d60d9a..f0665e9 100644 --- a/ui/public/theme/base.css +++ b/ui/public/theme/base.css @@ -4,6 +4,11 @@ margin-left: auto; margin-right: auto; } +.NuoContainerLG { + max-width: 100%; + margin-left: 24px; + margin-right: 24px; +} .NuoBanner { width: 100%; padding-left: 24px; @@ -317,3 +322,14 @@ code { height: 25px; float: left; } + +.NuoButtons { + display: flex; + flex-direction: row; + gap: 10px; + margin: 5px 0; +} +.NuoRecordingBanner { + background-color: #e0e033; + padding: 5px 24px; +} \ No newline at end of file diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 42d65b8..7c3611c 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -18,8 +18,10 @@ import Dialog from "./components/pages/parts/Dialog"; import GlobalErrorBoundary from "./components/GlobalErrorBoundary"; import Auth from "./utils/auth"; import Settings from './components/pages/Settings'; +import Automation from './components/pages/Automation'; import Customizations from './utils/Customizations'; import { PopupMenu } from './components/controls/Menu'; +import { NUODBAAS_WEBUI_ISRECORDING, Rest } from './components/pages/parts/Rest'; /** * React Root Application. Sets up dialogs, BrowserRouter and Schema from Control Plane @@ -28,6 +30,7 @@ import { PopupMenu } from './components/controls/Menu'; export default function App() { const [schema, setSchema] = useState(); const [isLoggedIn, setIsLoggedIn] = useState(Auth.isLoggedIn()); + const [isRecording, setIsRecording] = useState(sessionStorage.getItem(NUODBAAS_WEBUI_ISRECORDING) === "true"); return (
@@ -35,12 +38,13 @@ export default function App() { + {isLoggedIn ? - {schema && } + {schema && } } /> } /> @@ -50,6 +54,7 @@ export default function App() { } /> } /> } /> + } /> } /> : diff --git a/ui/src/components/controls/Button.tsx b/ui/src/components/controls/Button.tsx index 4d996d3..89c9545 100644 --- a/ui/src/components/controls/Button.tsx +++ b/ui/src/components/controls/Button.tsx @@ -7,6 +7,7 @@ import { Button as MuiButton } from '@mui/material' export type ButtonProps = { "data-testid"?: string, type?: "button" | "reset" | "submit", + disabled?: boolean, variant?: "contained" | "outlined" | "text", style?: React.CSSProperties, children: ReactNode, @@ -15,7 +16,7 @@ export type ButtonProps = { } export default function Button(props: ButtonProps): JSX.Element { if (isMaterial()) { - return {props.children} + return {props.children} } else { return diff --git a/ui/src/components/pages/Automation.tsx b/ui/src/components/pages/Automation.tsx new file mode 100644 index 0000000..61faa0b --- /dev/null +++ b/ui/src/components/pages/Automation.tsx @@ -0,0 +1,143 @@ +// (C) Copyright 2024 Dassault Systemes SE. All Rights Reserved. + +import Button from "../controls/Button"; +import { withTranslation } from "react-i18next"; +import { Rest } from "./parts/Rest"; +import { useEffect, useState } from "react"; +import { JsonType, RestLogEntry } from "../../utils/types"; +import { Tooltip } from "@mui/material"; +import Accordion from "../controls/Accordion"; +import ContentCopyOutlinedIcon from '@mui/icons-material/ContentCopyOutlined'; +import Auth from "../../utils/auth"; + +type AutomationProps = { + isRecording: boolean, + t: any +}; + +let copiedTimeout: NodeJS.Timeout | undefined = undefined; + +function Automation({ isRecording, t }: AutomationProps) { + const [log, setLog] = useState([]); + const [selectedTimestamp, setSelectedTimestamp] = useState(""); //using the timestamp (millisecond granularity) as unique key + const [hideGetRequests, setHideGetRequests] = useState(true); + const [convertUpdateToPatch, setConvertUpdateToPatch] = useState(false); + const [copiedField, setCopiedField] = useState(""); + + useEffect(() => { + const initialLog = Rest.getLog(); + setLog(initialLog); + setSelectedTimestamp(initialLog.length > 0 ? initialLog[0].timestamp : ""); + }, []); + + const filteredLog = log.filter(entry => hideGetRequests ? entry.method !== "get" : true); + const token = Auth.getCredentials()?.token; + + let selectedLogEntry = filteredLog.find(fl => fl.timestamp === selectedTimestamp); + if (!selectedLogEntry && filteredLog.length > 0) { + selectedLogEntry = filteredLog[0]; + } + + return ( +
+

{t("dialog.automation.title")}

+
+ + +
+ {!isRecording && log.length > 0 && +
+ + {filteredLog.length > 0 ? +
+ + {selectedLogEntry && + + + } + +} + +export default withTranslation()(Automation) \ No newline at end of file diff --git a/ui/src/components/pages/EditResource.tsx b/ui/src/components/pages/EditResource.tsx index 514cca1..9e32a03 100644 --- a/ui/src/components/pages/EditResource.tsx +++ b/ui/src/components/pages/EditResource.tsx @@ -4,7 +4,7 @@ import React, { useEffect, useState } from "react"; import { useParams } from "react-router-dom"; import CreateEditEntry from "./parts/CreateEditEntry"; import { getResourceByPath } from "../../utils/schema"; -import RestSpinner from "./parts/RestSpinner"; +import { Rest } from "./parts/Rest"; import Auth from "../../utils/auth"; import { SchemaType, TempAny } from "../../utils/types"; @@ -21,7 +21,7 @@ export default function EditResource({ schema }: Props) { useEffect(() => { let resourceByPath = getResourceByPath(schema, path); if ("get" in resourceByPath) { - RestSpinner.get(path).then((data: TempAny) => { + Rest.get(path).then((data: TempAny) => { setData(data); }).catch((error) => { Auth.handle401Error(error); diff --git a/ui/src/components/pages/ListResource.tsx b/ui/src/components/pages/ListResource.tsx index 15152ba..bddd14b 100644 --- a/ui/src/components/pages/ListResource.tsx +++ b/ui/src/components/pages/ListResource.tsx @@ -4,7 +4,7 @@ import React, { useEffect, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import Table from "./parts/Table"; import { getResourceEvents, getCreatePath, getResourceByPath, getFilterField } from "../../utils/schema"; -import RestSpinner from "./parts/RestSpinner"; +import { Rest } from "./parts/Rest"; import Button from "../controls/Button"; import Path, { parseSearch } from './parts/Path' import Auth from "../../utils/auth" @@ -52,7 +52,7 @@ function ListResource({ schema, t }: SchemaType) { let resourcesByPath_ = getResourceByPath(schema, path); if ("get" in resourcesByPath_) { - RestSpinner.get(path + "?listAccessible=true" + labelFilter).then((data: TempAny) => { + Rest.get(path + "?listAccessible=true" + labelFilter).then((data: TempAny) => { let start = 0; let end = data.items.length; while (data.items.length > start && !lastPartLower(data.items[start]).startsWith(name)) { @@ -76,7 +76,7 @@ function ListResource({ schema, t }: SchemaType) { }) ); }).catch((reason) => { - RestSpinner.toastError("Unable to get resource in " + path, reason); + Rest.toastError("Unable to get resource in " + path, reason); }); } setCreatePath(getCreatePath(schema, path)); diff --git a/ui/src/components/pages/ViewResource.tsx b/ui/src/components/pages/ViewResource.tsx index e47e35b..2f0e3f7 100644 --- a/ui/src/components/pages/ViewResource.tsx +++ b/ui/src/components/pages/ViewResource.tsx @@ -5,7 +5,7 @@ import { useParams } from "react-router-dom"; import CreateEditEntry from "./parts/CreateEditEntry"; import Path from "./parts/Path"; import { getResourceByPath } from "../../utils/schema"; -import RestSpinner from "./parts/RestSpinner"; +import { Rest } from "./parts/Rest"; import Auth from "../../utils/auth"; import { SchemaType, TempAny } from "../../utils/types"; @@ -22,7 +22,7 @@ export default function ViewResource({ schema }: Props) { useEffect(() => { let resourceByPath = getResourceByPath(schema, path); if ("get" in resourceByPath) { - RestSpinner.get(path).then((data: TempAny) => { + Rest.get(path).then((data: TempAny) => { setData(data); }).catch((error) => { Auth.handle401Error(error); diff --git a/ui/src/components/pages/parts/Banner.tsx b/ui/src/components/pages/parts/Banner.tsx index 81b3163..df7d7af 100644 --- a/ui/src/components/pages/parts/Banner.tsx +++ b/ui/src/components/pages/parts/Banner.tsx @@ -15,11 +15,19 @@ import Avatar from '@mui/material/Avatar'; import Tooltip from '@mui/material/Tooltip'; import { SchemaType } from "../../../utils/types"; import Menu from "../../controls/Menu"; +import { Rest } from "./Rest"; +import Button from "../../controls/Button"; -function ResponsiveAppBar(resources: string[], t: any) { +function ResponsiveAppBar(resources: string[], isRecording: boolean, t: any) { const navigate = useNavigate(); - return ( + return (<> + {isRecording &&
{t("dialog.automation.recordingInProgress")} + +
} 0 ? "banner-done" : ""} position="static"> @@ -100,20 +108,30 @@ function ResponsiveAppBar(resources: string[], t: any) { /> - + { navigate("/ui/settings"); } }, + { + label: t("button.automation"), + id: "automation", + "data-testid": "automation", + onClick: () => { + navigate("/ui/automation"); + } + }, { label: t("button.logout"), id: "logout", + "data-testid": "logout", onClick: () => { Auth.logout(); window.location.href = "/ui"; @@ -130,16 +148,17 @@ function ResponsiveAppBar(resources: string[], t: any) { - + ); } interface Props { schema: SchemaType, + isRecording: boolean, t: any } -function Banner({ schema, t }: Props) { +function Banner({ schema, isRecording, t }: Props) { if (!schema) { return null; @@ -152,7 +171,7 @@ function Banner({ schema, t }: Props) { return path; }); - return ResponsiveAppBar(resources, t); + return ResponsiveAppBar(resources, isRecording, t); } export default withTranslation()(Banner); \ No newline at end of file diff --git a/ui/src/components/pages/parts/CreateEditEntry.tsx b/ui/src/components/pages/parts/CreateEditEntry.tsx index 06f7898..c5f7157 100644 --- a/ui/src/components/pages/parts/CreateEditEntry.tsx +++ b/ui/src/components/pages/parts/CreateEditEntry.tsx @@ -4,7 +4,7 @@ import React, { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import FieldFactory from "../../fields/FieldFactory"; import { getResourceByPath, getCreatePath, getChild, arrayToObject, getDefaultValue, submitForm } from "../../../utils/schema"; -import RestSpinner from "./RestSpinner"; +import { RestSpinner } from "./Rest"; import Button from "../../controls/Button"; import Accordion from "../../controls/Accordion"; import Auth from "../../../utils/auth"; diff --git a/ui/src/components/pages/parts/Path.tsx b/ui/src/components/pages/parts/Path.tsx index 0bc4ac2..16fb9c3 100644 --- a/ui/src/components/pages/parts/Path.tsx +++ b/ui/src/components/pages/parts/Path.tsx @@ -10,7 +10,7 @@ import Link from '@mui/material/Link' import Typography from '@mui/material/Typography'; import TextField from "../../controls/TextField" import { styled } from '@mui/material'; -import RestSpinner from './RestSpinner'; +import { RestSpinner } from './Rest'; import { getFilterField, getSchemaPath } from "../../../utils/schema"; import { TempAny } from "../../../utils/types" diff --git a/ui/src/components/pages/parts/Rest.tsx b/ui/src/components/pages/parts/Rest.tsx new file mode 100644 index 0000000..07d484d --- /dev/null +++ b/ui/src/components/pages/parts/Rest.tsx @@ -0,0 +1,218 @@ +// (C) Copyright 2024 Dassault Systemes SE. All Rights Reserved. + +import React, { ReactNode } from "react" +import CircularProgress from '@mui/material/CircularProgress'; +import Snackbar from '@mui/material/Snackbar'; +import axios from "axios"; +import Auth from "../../../utils/auth"; +import { JsonType, RestLogEntry, RestMethodType } from "../../../utils/types"; + +let instance: Rest | null = null; + +interface State { + pendingRequests: number, + errorMessage?: string | null, +} + +const AUTOMATION_LOG = "nuodbaas-webui-recorded"; +export const NUODBAAS_WEBUI_ISRECORDING = "nuodbaas-webui-isRecording"; + +export class Rest extends React.Component<{ isRecording: boolean, setIsRecording: (isRecording: boolean) => void }> { + state: State = { + pendingRequests: 0, + errorMessage: null, + } + + componentDidMount() { + if (!instance) { + instance = this; + } + } + + lastTimestamp = new Date(); + + static toastError(msg: string, error: string) { + instance && instance.setState({ errorMessage: msg }); + console.log(msg, error); + } + + static incrementPending() { + if (instance === null) { + return; + } + + instance.setState((prevState: State) => { + return { pendingRequests: prevState.pendingRequests + 1 }; + }); + } + + static decrementPending() { + if (instance === null) { + return; + } + + instance.setState((prevState: State) => { + return { pendingRequests: prevState.pendingRequests - 1 }; + }); + } + + static setIsRecording(isRecording: boolean) { + if (!instance || !instance.props.setIsRecording) { + return; + } + instance.props.setIsRecording(isRecording); + if (isRecording) { + sessionStorage.setItem(NUODBAAS_WEBUI_ISRECORDING, "true"); + } + else { + sessionStorage.removeItem(NUODBAAS_WEBUI_ISRECORDING); + } + } + + static isRecording() { + if (instance === null) { + return false; + } + return instance.props.isRecording; + } + + static log(method: RestMethodType, url: string, success: boolean, body?: JsonType) { + if (instance === null || !instance.props.isRecording) { + return; + } + let automationLog = Rest.getLog(); + const now = new Date(); + if (now <= instance.lastTimestamp) { + instance.lastTimestamp = new Date(instance.lastTimestamp.getTime() + 1) + } + else { + instance.lastTimestamp = now; + } + automationLog.push({ timestamp: instance.lastTimestamp.toISOString(), method, url, body, success }); + window.sessionStorage.setItem(AUTOMATION_LOG, JSON.stringify(automationLog)); + } + + static getLog(): RestLogEntry[] { + const strAutomationLog = sessionStorage.getItem(AUTOMATION_LOG); + return strAutomationLog ? JSON.parse(strAutomationLog) : []; + } + + static clearLog() { + window.sessionStorage.removeItem(AUTOMATION_LOG); + } + + render(): ReactNode { + return null; + } + + static async get(path: string) { + return new Promise((resolve, reject) => { + Rest.incrementPending(); + const url = Auth.getNuodbCpRestUrl(path); + axios.get(url, { headers: Auth.getHeaders() }) + .then(response => { + Rest.log("get", url, true); + resolve(response.data); + }).catch(reason => { + Rest.log("get", url, false); + reject(reason); + }).finally(() => { + Rest.decrementPending(); + }) + }) + } + + static async getStream(path: string, eventsAbortController: AbortController) { + return new Promise((resolve, reject) => { + Rest.incrementPending(); + const url = Auth.getNuodbCpRestUrl(path); + axios({ + headers: { ...Auth.getHeaders(), 'Accept': 'text/event-stream' }, + method: 'get', + url: url, + responseType: 'stream', + adapter: 'fetch', + signal: eventsAbortController.signal + }) + .then(async response => { + Rest.log("get", url, true); + resolve(response.data); + }).catch(reason => { + Rest.log("get", url, false); + reject(reason); + }).finally(() => { + Rest.decrementPending(); + }) + }) + } + + static async put(path: string, data: JsonType) { + return new Promise((resolve, reject) => { + Rest.incrementPending(); + const url = Auth.getNuodbCpRestUrl(path); + axios.put(url, data, { headers: Auth.getHeaders() }) + .then(response => { + Rest.log("put", url, true, data); + resolve(response.data); + }).catch(error => { + Rest.log("put", url, false, data); + return reject(error); + }).finally(() => { + Rest.decrementPending(); + }) + }); + } + + static async delete(path: string) { + return new Promise((resolve, reject) => { + Rest.incrementPending(); + const url = Auth.getNuodbCpRestUrl(path); + axios.delete(url, { headers: Auth.getHeaders() }) + .then(response => { + Rest.log("delete", url, true); + resolve(response.data); + }).catch(reason => { + Rest.log("delete", url, false); + reject(reason); + }).finally(() => { + Rest.decrementPending(); + }) + }); + } + + static async patch(path: string, data: JsonType) { + return new Promise((resolve, reject) => { + Rest.incrementPending(); + const url = Auth.getNuodbCpRestUrl(path); + axios.patch(url, data, { headers: { ...Auth.getHeaders(), "Content-Type": "application/json-patch+json" } }) + .then(response => { + Rest.log("patch", url, true, data); + resolve(response.data); + }).catch(error => { + Rest.log("patch", url, false, data); + reject(error); + }).finally(() => { + Rest.decrementPending(); + }) + }); + } +} + +export function RestSpinner() { + if (!instance) return null; + + return + instance?.setState({ errorMessage: null })} + /> + {instance.state.pendingRequests > 0 ? + + : +
 
+ } +
; +} \ No newline at end of file diff --git a/ui/src/components/pages/parts/RestSpinner.tsx b/ui/src/components/pages/parts/RestSpinner.tsx deleted file mode 100644 index 2618605..0000000 --- a/ui/src/components/pages/parts/RestSpinner.tsx +++ /dev/null @@ -1,145 +0,0 @@ -// (C) Copyright 2024 Dassault Systemes SE. All Rights Reserved. - -import React, { ReactNode } from "react" -import CircularProgress from '@mui/material/CircularProgress'; -import Snackbar from '@mui/material/Snackbar'; -import axios from "axios"; -import Auth from "../../../utils/auth"; -import { TempAny } from "../../../utils/types"; - -let instance: RestSpinner | null = null; - -interface State { - pendingRequests: number, - errorMessage?: string | null -} - -export default class RestSpinner extends React.Component { - state: State = { - pendingRequests: 0, - errorMessage: null - } - - componentDidMount() { - instance = this; - } - - static toastError(msg: string, error: string) { - instance && instance.setState({ errorMessage: msg }); - console.log(msg, error); - } - - static incrementPending() { - if (instance === null) { - return; - } - - instance.setState((prevState: State) => { - return { pendingRequests: prevState.pendingRequests + 1 }; - }); - } - - static decrementPending() { - if (instance === null) { - return; - } - - instance.setState((prevState: State) => { - return { pendingRequests: prevState.pendingRequests - 1 }; - }); - } - - render(): ReactNode { - return - this.setState({ errorMessage: null })} - /> - {this.state.pendingRequests > 0 ? - - : -
 
- } -
; - } - - static async get(path: string) { - return new Promise((resolve, reject) => { - RestSpinner.incrementPending(); - axios.get(Auth.getNuodbCpRestUrl(path), { headers: Auth.getHeaders() }) - .then(response => { - resolve(response.data); - }).catch(reason => { - reject(reason); - }).finally(() => { - RestSpinner.decrementPending(); - }) - }) - } - - static async getStream(path: string, eventsAbortController: TempAny) { - return new Promise((resolve, reject) => { - RestSpinner.incrementPending(); - axios({ - headers: { ...Auth.getHeaders(), 'Accept': 'text/event-stream' }, - method: 'get', - url: Auth.getNuodbCpRestUrl(path), - responseType: 'stream', - adapter: 'fetch', - signal: eventsAbortController.signal - }) - .then(async response => { - resolve(response.data); - }).catch(reason => { - reject(reason); - }).finally(() => { - RestSpinner.decrementPending(); - }) - }) - } - - static async put(path: string, data: TempAny) { - return new Promise((resolve, reject) => { - RestSpinner.incrementPending(); - axios.put(Auth.getNuodbCpRestUrl(path), data, { headers: Auth.getHeaders() }) - .then(response => { - resolve(response.data); - }).catch(error => { - return reject(error); - }).finally(() => { - RestSpinner.decrementPending(); - }) - }); - } - - static async delete(path: string) { - return new Promise((resolve, reject) => { - RestSpinner.incrementPending(); - axios.delete(Auth.getNuodbCpRestUrl(path), { headers: Auth.getHeaders() }) - .then(response => { - resolve(response.data); - }).catch(reason => { - reject(reason); - }).finally(() => { - RestSpinner.decrementPending(); - }) - }); - } - - static async patch(path: string, data: TempAny) { - return new Promise((resolve, reject) => { - RestSpinner.incrementPending(); - axios.patch(Auth.getNuodbCpRestUrl(path), data, { headers: { ...Auth.getHeaders(), "Content-Type": "application/json-patch+json" } }) - .then(response => { - resolve(response.data); - }).catch(error => { - reject(error); - }).finally(() => { - RestSpinner.decrementPending(); - }) - }); - } -} \ No newline at end of file diff --git a/ui/src/components/pages/parts/Table.tsx b/ui/src/components/pages/parts/Table.tsx index 91f2e88..eea537c 100644 --- a/ui/src/components/pages/parts/Table.tsx +++ b/ui/src/components/pages/parts/Table.tsx @@ -5,7 +5,7 @@ import { withTranslation } from "react-i18next"; import { TableBody, TableTh, TableCell, Table as TableCustom, TableHead, TableRow } from '../../controls/Table'; import { getResourceByPath, getCreatePath, getChild, replaceVariables } from "../../../utils/schema"; import FieldFactory from "../../fields/FieldFactory"; -import RestSpinner from "./RestSpinner"; +import { Rest } from "./Rest"; import Dialog from "./Dialog"; import { MenuItemProps, TempAny } from "../../../utils/types"; import { CustomViewField, evaluate, getCustomizationsView } from '../../../utils/Customizations'; @@ -136,13 +136,12 @@ function Table(props: TempAny) { const createPathFirstPart = path?.replace(/^\//, "").split("/")[0]; row = { ...row, resources_one: t("resource.label." + createPathFirstPart + "_one", createPathFirstPart) }; if ("yes" === await Dialog.confirm(t("confirm.delete.resource.title", row), t("confirm.delete.resource.body", row), t)) { - RestSpinner.delete(path + "/" + row["$ref"]) + Rest.delete(path + "/" + row["$ref"]) .then(() => { window.location.reload(); }).catch((error) => { - RestSpinner.toastError("Unable to delete " + path + "/" + row["$ref"], error); + Rest.toastError("Unable to delete " + path + "/" + row["$ref"], error); }); - window.location.reload(); } } @@ -176,7 +175,7 @@ function Table(props: TempAny) { } catch (ex) { const msg = "Error in checking visibility of button."; - RestSpinner.toastError(msg, String(ex)); + Rest.toastError(msg, String(ex)); console.log(msg, ex, row); } @@ -194,9 +193,9 @@ function Table(props: TempAny) { } } if (menu.patch) { - RestSpinner.patch(path + "/" + row["$ref"], menu.patch) + Rest.patch(path + "/" + row["$ref"], menu.patch) .catch((error) => { - RestSpinner.toastError("Unable to update " + path + "/" + row["$ref"], error); + Rest.toastError("Unable to update " + path + "/" + row["$ref"], error); }) } else if (menu.link) { @@ -245,7 +244,7 @@ function Table(props: TempAny) { } catch (ex) { const msg = "Error in custom value evaluation for field \"" + fieldName + "\""; - RestSpinner.toastError(msg, String(ex)); + Rest.toastError(msg, String(ex)); console.log(msg, ex, row); value = "" } diff --git a/ui/src/resources/translation/en.json b/ui/src/resources/translation/en.json index b231816..c86a1cf 100644 --- a/ui/src/resources/translation/en.json +++ b/ui/src/resources/translation/en.json @@ -19,6 +19,7 @@ "show.databases": "Show Databases", "show.backups": "Show Backups", "settings": "Settings", + "automation": "Automation", "logout": "Logout", "add": "Add", "copy": "Copy", @@ -159,6 +160,16 @@ "jdbc": "Java (JDBC)", "cpp": "C++" } + }, + "automation": { + "title": "Automation", + "curl": "Export recording as CURL commands", + "startRecording": "Start new recording", + "stopRecording": "Stop recording", + "recordingInProgress": "Recording in progress...", + "hideGetRequests": "Hide GET requests", + "convertUpdatesToPatchRequests": "Convert updates to PATCH requests", + "noWriteRequests": "No write requests recorded." } }, "text": { diff --git a/ui/src/utils/schema.ts b/ui/src/utils/schema.ts index cc1dee3..6712a9d 100644 --- a/ui/src/utils/schema.ts +++ b/ui/src/utils/schema.ts @@ -1,6 +1,6 @@ // (C) Copyright 2024 Dassault Systemes SE. All Rights Reserved. -import RestSpinner from "../components/pages/parts/RestSpinner"; +import { Rest } from "../components/pages/parts/Rest"; import Auth from "./auth" import { FieldValuesType, TempAny, SchemaType, FieldParametersType } from "./types"; let schema : TempAny = null; @@ -12,7 +12,7 @@ let schema : TempAny = null; export async function getSchema() { if(!schema) { try { - schema = await RestSpinner.get("openapi"); + schema = await Rest.get("openapi"); parseSchema(schema, schema, []); schema = schema["paths"]; } @@ -267,7 +267,7 @@ export function getResourceEvents(path: string, multiResolve: TempAny, multiReje //only one event stream is supported - close prior one if it exists. let eventsAbortController = new AbortController(); - RestSpinner.getStream("events" + path, eventsAbortController) + Rest.getStream("events" + path, eventsAbortController) .then(async (response: TempAny) => { let event = null; let data = null; @@ -374,12 +374,12 @@ export function getResourceEvents(path: string, multiResolve: TempAny, multiReje .catch((error) => { if(error.status === 404) { // fall back to non-streaming request - RestSpinner.get(path) + Rest.get(path) .then(data => multiResolve(data)) .catch(reason => multiReject(reason)); } else if(error.status) { - RestSpinner.toastError("Cannot retrieve resource for path " + path, error.status + " " + error.message); + Rest.toastError("Cannot retrieve resource for path " + path, error.status + " " + error.message); } else { //request was aborted. Ignore. @@ -536,5 +536,5 @@ export async function submitForm(urlParameters: FieldParametersType, formParamet deleteEmptyFields(values); - return RestSpinner.put(path, values); + return Rest.put(path, values); } \ No newline at end of file diff --git a/ui/src/utils/types.ts b/ui/src/utils/types.ts index 7a4590b..732aebf 100644 --- a/ui/src/utils/types.ts +++ b/ui/src/utils/types.ts @@ -6,6 +6,10 @@ import { ReactNode } from "react"; // so we can easily find them in source control and fix in future PR's export type TempAny = any; +export type JsonType = { + [key: string]: JsonType +} | JsonType[] | string | number | boolean | null; + export type FieldValuesType = TempAny; export type FieldParameterType = { @@ -53,3 +57,12 @@ export type MenuProps = { setItems?: (items: MenuItemProps[]) => void, className?: string }; + +export type RestMethodType = "get" | "put" | "delete" | "patch"; +export type RestLogEntry = { + timestamp: string, + method: RestMethodType, + url: string, + success: boolean + body?: JsonType, +}