diff --git a/README.md b/README.md index 7a35231288..f277e17d37 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,96 @@ You can see live demo of CloudBeaver here: https://demo.cloudbeaver.io ## Changelog +### CloudBeaver 21.1.0 - 2021-07-15 + +Changes since 21.0.0 +- Navigation tree: + - User's and shared connections are divided into different groups. +- Connection form: + - Read-only connections are marked in the connection form. + - It is possible to show and hide password in the connection dialog. +- Data editor: + - New actions are available from the context menu: + - filtering + - ordering + - cell editing. + - Support for different formats is added to the Value panel. + - Work with links is improved: + - Links to the web pages can be opened from the tables + - Links to the pictures are automatically transformed to the pictures on the Value panel. + - It is possible to edit Boolean values as checkboxes in the tables. + - Read-only columns and tables are marked in the result set. +- SQL editor: + - Query execution plan is added. + - Database logos are displayed in the top menu selector. + - Auto-complete for the SQL dialects works automatically. +- Log viewer: + - Detailed information for errors is opened on the panel. +- Administration part: + - Access management for users and connections is improved. +- It is possible to change the user password from the public part of CloudBeaver. +- New design for dialogs is implemented. +- Possibility to export data for users can be configured. +- A lot of UI fixes, enhancements and improvements are made. + +### CloudBeaver 21.0.5 - 2021-06-30 +- Query execution plan was added. +- Option to show/hide password in the connection dialog was added. +- Minor UI fixes and improvements. + +### CloudBeaver 21.0.3 - 2021-06-11 + +- Filtering and Ordering from the context menu were added for the data editor . +- Read-only columns and result sets were marked in the data editor. +- Possibility to remove data export feature from UI was added. +- CloudBeaver docker image is based on Ubuntu Slim now. +- Minor UI fixes and improvements. + +### CloudBeaver 21.0.2 - 2021-05-20 + +- User's and shared connections are divided to different groups in the navigation tree. +- New design for authentication and driver properties dialogs was applied. +- Management for administrators' accounts was improved. +- Minor UI fixes and improvements. + +### CloudBeaver 21.0.1 - 2021-04-28 + +- Database logos were added to the top menu selector. +- New panel to show error details were added to Log Viewer. +- Read-only connections were marked in the connection details form. +- New design for dialogs was implemented. +- Minor UI fixes and improvements. + +### CloudBeaver 21.0.0 - 2021-04-15 + +Changes since 20.0.0: +- Navigation tree: + - Simple and advanced view for database objects + - Show system objects option +- Connection form: + - New drivers: Oracle, SQL Server, Trino (Presto), Derby Server + - SSH support + - Editing for manual connections + - Custom fields for advanced connection settings + - Connection form redesign + - Fixes for the name duplicates and deletion of the template connections +- Data editor: + - Value panel for data review and editing + - Spatial data viewer + - Sorting indication for the data grid + - Performance improvements +- SQL editor: + - Improvements for autosuggest and highlighting +- Log viewer: + - Quick review for the error details + - All errors and warnings on the panel +- New page with the product information +- Administration part: + - Redesign for the authentication engine + - Server re-configuration without re-login + - Additional labels for the connections with SSH +- A lot of UI fixes, enhancements and improvements. + ### CloudBeaver 20.0.6 - 2021-03-31 - Value panel for Data Viewer was added: a full text of your cell value on a special panel to view and edit. @@ -36,7 +126,7 @@ You can see live demo of CloudBeaver here: https://demo.cloudbeaver.io - Possibility to edit connections created manually in the public part of the application - SSH support for connections in public part of the application - Possibility to deny credentials saving -- Drivers for SQL Server, Oracle, DB2 LUW, Derby, Trino +- Drivers for MS SQL Server, Oracle, DB2 LUW, Derby, Trino (Presto SQL) - Performance improvements for data grid - A lot of minor UI fixes and improvements diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index f8d8648879..a4fe07d192 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -1,7 +1,4 @@ -FROM adoptopenjdk/openjdk11:x86_64-ubuntu-jdk-11.0.7_10 - -RUN apt-get update -RUN apt-get install -y nano +FROM adoptopenjdk/openjdk11:jdk-11.0.10_9-ubuntu-slim COPY cloudbeaver /opt/cloudbeaver diff --git a/samples/sample-databases/initial-data.conf b/samples/sample-databases/initial-data.conf index 72ee8249e6..2ba3e63ffc 100644 --- a/samples/sample-databases/initial-data.conf +++ b/samples/sample-databases/initial-data.conf @@ -9,7 +9,7 @@ { roleId: "user", name: "User", - description: "Standard user.", + description: "Standard user", permissions: [ "public" ] } ] diff --git a/samples/sample-databases/product.conf b/samples/sample-databases/product.conf index b488c0137d..a00210d1e0 100644 --- a/samples/sample-databases/product.conf +++ b/samples/sample-databases/product.conf @@ -21,6 +21,9 @@ plugin_data_spreadsheet_new: { hidden: false }, + plugin_data_export: { + disabled: false + }, // Notifications config core_events: { notificationsPool: 5 diff --git a/server/bundles/io.cloudbeaver.connect.provider.sample/META-INF/MANIFEST.MF b/server/bundles/io.cloudbeaver.connect.provider.sample/META-INF/MANIFEST.MF index 47a1d03d0c..9277400ec7 100644 --- a/server/bundles/io.cloudbeaver.connect.provider.sample/META-INF/MANIFEST.MF +++ b/server/bundles/io.cloudbeaver.connect.provider.sample/META-INF/MANIFEST.MF @@ -3,8 +3,8 @@ Bundle-ManifestVersion: 2 Bundle-Vendor: %Bundle-Vendor Bundle-Name: %Bundle-Name Bundle-SymbolicName: io.cloudbeaver.connect.provider.sample;singleton:=true -Bundle-Version: 1.0.25.qualifier -Bundle-Release-Date: 20210405 +Bundle-Version: 1.0.33.qualifier +Bundle-Release-Date: 20210802 Bundle-RequiredExecutionEnvironment: JavaSE-1.8 Bundle-ActivationPolicy: lazy Bundle-ClassPath: . diff --git a/server/bundles/io.cloudbeaver.connect.provider.sample/pom.xml b/server/bundles/io.cloudbeaver.connect.provider.sample/pom.xml index 419737adee..6c502a6452 100644 --- a/server/bundles/io.cloudbeaver.connect.provider.sample/pom.xml +++ b/server/bundles/io.cloudbeaver.connect.provider.sample/pom.xml @@ -10,7 +10,7 @@ ../ io.cloudbeaver.connect.provider.sample - 1.0.25-SNAPSHOT + 1.0.33-SNAPSHOT eclipse-plugin diff --git a/server/bundles/io.cloudbeaver.product.ce/META-INF/MANIFEST.MF b/server/bundles/io.cloudbeaver.product.ce/META-INF/MANIFEST.MF index 329fbe68cc..99fb76957d 100644 --- a/server/bundles/io.cloudbeaver.product.ce/META-INF/MANIFEST.MF +++ b/server/bundles/io.cloudbeaver.product.ce/META-INF/MANIFEST.MF @@ -3,8 +3,8 @@ Bundle-ManifestVersion: 2 Bundle-Vendor: %Bundle-Vendor Bundle-Name: %Bundle-Name Bundle-SymbolicName: io.cloudbeaver.product.ce;singleton:=true -Bundle-Version: 20.0.6.qualifier -Bundle-Release-Date: 20210405 +Bundle-Version: 21.1.1.qualifier +Bundle-Release-Date: 20210802 Bundle-RequiredExecutionEnvironment: JavaSE-1.8 Bundle-ActivationPolicy: lazy Bundle-ClassPath: . diff --git a/server/bundles/io.cloudbeaver.product.ce/pom.xml b/server/bundles/io.cloudbeaver.product.ce/pom.xml index 1056772825..b88f81ee20 100644 --- a/server/bundles/io.cloudbeaver.product.ce/pom.xml +++ b/server/bundles/io.cloudbeaver.product.ce/pom.xml @@ -10,7 +10,7 @@ ../ io.cloudbeaver.product.ce - 20.0.6-SNAPSHOT + 21.1.1-SNAPSHOT eclipse-plugin diff --git a/server/bundles/io.cloudbeaver.resources.drivers.base/META-INF/MANIFEST.MF b/server/bundles/io.cloudbeaver.resources.drivers.base/META-INF/MANIFEST.MF index 75f0fdde64..d647902247 100644 --- a/server/bundles/io.cloudbeaver.resources.drivers.base/META-INF/MANIFEST.MF +++ b/server/bundles/io.cloudbeaver.resources.drivers.base/META-INF/MANIFEST.MF @@ -2,8 +2,8 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: Base JDBC drivers Bundle-SymbolicName: io.cloudbeaver.resources.drivers.base;singleton:=true -Bundle-Version: 1.0.22.qualifier -Bundle-Release-Date: 20210405 +Bundle-Version: 1.0.30.qualifier +Bundle-Release-Date: 20210802 Bundle-Vendor: DBeaver Corp Bundle-ActivationPolicy: lazy Automatic-Module-Name: io.cloudbeaver.resources.drivers.base diff --git a/server/bundles/io.cloudbeaver.resources.drivers.base/plugin.xml b/server/bundles/io.cloudbeaver.resources.drivers.base/plugin.xml index f32cc7c622..521448ab89 100644 --- a/server/bundles/io.cloudbeaver.resources.drivers.base/plugin.xml +++ b/server/bundles/io.cloudbeaver.resources.drivers.base/plugin.xml @@ -43,7 +43,7 @@ - + diff --git a/server/bundles/io.cloudbeaver.resources.drivers.base/pom.xml b/server/bundles/io.cloudbeaver.resources.drivers.base/pom.xml index 45c0e036fa..68eb9c3224 100644 --- a/server/bundles/io.cloudbeaver.resources.drivers.base/pom.xml +++ b/server/bundles/io.cloudbeaver.resources.drivers.base/pom.xml @@ -9,6 +9,6 @@ ../ io.cloudbeaver.resources.drivers.base - 1.0.22-SNAPSHOT + 1.0.30-SNAPSHOT eclipse-plugin diff --git a/server/bundles/io.cloudbeaver.server/META-INF/MANIFEST.MF b/server/bundles/io.cloudbeaver.server/META-INF/MANIFEST.MF index b8dd6b2d82..78189c6036 100644 --- a/server/bundles/io.cloudbeaver.server/META-INF/MANIFEST.MF +++ b/server/bundles/io.cloudbeaver.server/META-INF/MANIFEST.MF @@ -3,8 +3,8 @@ Bundle-ManifestVersion: 2 Bundle-Vendor: %Bundle-Vendor Bundle-Name: %Bundle-Name Bundle-SymbolicName: io.cloudbeaver.server;singleton:=true -Bundle-Version: 20.0.6.qualifier -Bundle-Release-Date: 20210405 +Bundle-Version: 21.1.1.qualifier +Bundle-Release-Date: 20210802 Bundle-RequiredExecutionEnvironment: JavaSE-1.8 Bundle-ActivationPolicy: lazy Bundle-Activator: io.cloudbeaver.server.WebPlatformActivator diff --git a/server/bundles/io.cloudbeaver.server/plugin.xml b/server/bundles/io.cloudbeaver.server/plugin.xml index 2a8cf1ae8e..7f80f632e0 100644 --- a/server/bundles/io.cloudbeaver.server/plugin.xml +++ b/server/bundles/io.cloudbeaver.server/plugin.xml @@ -5,6 +5,7 @@ + diff --git a/server/bundles/io.cloudbeaver.server/pom.xml b/server/bundles/io.cloudbeaver.server/pom.xml index 2717504305..b1d1a2f2c8 100644 --- a/server/bundles/io.cloudbeaver.server/pom.xml +++ b/server/bundles/io.cloudbeaver.server/pom.xml @@ -10,7 +10,7 @@ ../ io.cloudbeaver.server - 20.0.6-SNAPSHOT + 21.1.1-SNAPSHOT eclipse-plugin diff --git a/server/bundles/io.cloudbeaver.server/schema/io.cloudbeaver.valueSerializer.exsd b/server/bundles/io.cloudbeaver.server/schema/io.cloudbeaver.valueSerializer.exsd new file mode 100644 index 0000000000..58e6eee392 --- /dev/null +++ b/server/bundles/io.cloudbeaver.server/schema/io.cloudbeaver.valueSerializer.exsd @@ -0,0 +1,34 @@ + + + + + + + + DBeaver web value serializers + + + + + + + + + + + + + + + + + + + + DBeaver web value serializer + + + + + + diff --git a/server/bundles/io.cloudbeaver.server/schema/service.core.graphqls b/server/bundles/io.cloudbeaver.server/schema/service.core.graphqls index 5425bb9bca..7c39847187 100644 --- a/server/bundles/io.cloudbeaver.server/schema/service.core.graphqls +++ b/server/bundles/io.cloudbeaver.server/schema/service.core.graphqls @@ -90,6 +90,10 @@ type ServerConfig { version: String! workspaceId: ID! + serverURL: String! + + rootURI: String! + anonymousAccessEnabled: Boolean authenticationEnabled: Boolean @deprecated supportsCustomConnections: Boolean diff --git a/server/bundles/io.cloudbeaver.server/schema/service.sql.graphqls b/server/bundles/io.cloudbeaver.server/schema/service.sql.graphqls index d797891151..32944bfb08 100644 --- a/server/bundles/io.cloudbeaver.server/schema/service.sql.graphqls +++ b/server/bundles/io.cloudbeaver.server/schema/service.sql.graphqls @@ -3,29 +3,31 @@ #################################################### type SQLDialectInfo { - name: String + name: String! - dataTypes: [ String ] - functions: [ String ] - reservedWords: [ String ] - quoteStrings: [ [ String ] ] - singleLineComments: [ String ] - multiLineComments: [ [ String ] ] + dataTypes: [ String ]! + functions: [ String ]! + reservedWords: [ String ]! + quoteStrings: [ [ String ] ]! + singleLineComments: [ String ]! + multiLineComments: [ [ String ] ]! catalogSeparator: String structSeparator: String scriptDelimiter: String + supportsExplainExecutionPlan: Boolean! + } type SQLCompletionProposal { - displayString: String - type: String + displayString: String! + type: String! score: Int - replacementString: String - replacementOffset: Int - replacementLength: Int + replacementString: String! + replacementOffset: Int! + replacementLength: Int! cursorPosition: Int icon: String @@ -40,6 +42,7 @@ type SQLCompletionProposal { type SQLContextInfo { id: ID! + connectionId: ID! defaultCatalog: String defaultSchema: String @@ -80,8 +83,13 @@ type SQLResultColumn { scale: Int precision: Int + required: Boolean! + readOnly: Boolean! readOnlyStatus: String + + # Operations supported for this attribute + supportedOperations: [DataTypeLogicalOperation!]! } type DatabaseDocument { @@ -112,8 +120,13 @@ type SQLQueryResults { type SQLExecuteInfo { + # Status message statusMessage: String - duration: Int + # Execute time (ms) + duration: Int! + # Actual conditions applied to query + filterText: String + # Results results: [ SQLQueryResults! ]! } @@ -122,20 +135,59 @@ input SQLResultRow { updateValues: Object } +type DataTypeLogicalOperation { + id: ID! + expression: String! + argumentCount: Int +} + +#################################################### +# SQL Executio plan +#################################################### + +type SQLExecutionPlan { + query: String! + nodes: [SQLExecutionPlanNode!]! +} + +type SQLExecutionPlanNode { + id: ID! + parentId: ID + + kind: String! + name: String + type: String! + condition: String + description: String + properties: [ObjectPropertyInfo!]! +} + +#################################################### +# Query and Mutation +#################################################### + extend type Query { sqlDialectInfo( connectionId: ID! ): SQLDialectInfo - sqlListContexts( connectionId: ID! ): [ SQLContextInfo ] + # Lists SQL contexts for a connection (optional) or returns the particular context info + sqlListContexts( connectionId: ID, contextId: ID ): [ SQLContextInfo ]! sqlCompletionProposals( connectionId: ID!, contextId: ID!, query: String!, position: Int!, - maxResults: Int + maxResults: Int, + simpleMode: Boolean ): [ SQLCompletionProposal ] + sqlSupportedOperations( + connectionId: ID!, + contextId: ID!, + resultsId: ID!, + attributeIndex: Int! + ): [DataTypeLogicalOperation!]! } extend type Mutation { @@ -171,9 +223,9 @@ extend type Mutation { connectionId: ID!, contextId: ID!, resultsId: ID!, - + # Row cell values (to identify row) updateRow: [ Object ]!, - # Map + # Values to update - Map updateValues: Object ): SQLExecuteInfo @@ -191,4 +243,16 @@ extend type Mutation { # Returns SQLExecuteInfo asyncSqlExecuteResults(taskId: ID!): SQLExecuteInfo ! + + # Read data from table + asyncSqlExplainExecutionPlan( + connectionId: ID!, + contextId: ID!, + query: String!, + configuration: Object! + ): AsyncTaskInfo! + + # Returns SQLExecutionPlan + asyncSqlExplainExecutionPlanResult(taskId: ID!): SQLExecutionPlan ! + } diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/DBWAuthProviderExternal.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/DBWAuthProviderExternal.java index 286726eb75..82b83e7725 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/DBWAuthProviderExternal.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/DBWAuthProviderExternal.java @@ -45,6 +45,17 @@ public interface DBWAuthProviderExternal extend @NotNull Map authParameters // Passed auth parameters (e.g. user name or password) ) throws DBException; + /** + * Returns new identifying credentials which can be used to find/create user in database + */ + @NotNull + Map authFederatedUser( + @NotNull DBRProgressMonitor monitor, + @NotNull Map providerConfig, // Auth provider configuration (e.g. 3rd party auth server address) + @NotNull String faProvider, + @NotNull Map faParameters // Passed federated auth parameters (e.g. assertion ID, session token, etc) + ) throws DBException; + /** * Validates that external user may be associated with local user * @param userCredentials credentials from authExternalUser diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/DBWSecurityController.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/DBWSecurityController.java index e7babd2ae2..9f6340e471 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/DBWSecurityController.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/DBWSecurityController.java @@ -85,6 +85,8 @@ public interface DBWSecurityController { void createRole(WebRole role) throws DBCException; + void updateRole(WebRole role) throws DBCException; + void deleteRole(String roleId) throws DBCException; /////////////////////////////////////////// diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/WebServiceUtils.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/WebServiceUtils.java index 63477280af..62fcb9c1ac 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/WebServiceUtils.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/WebServiceUtils.java @@ -71,10 +71,6 @@ public class WebServiceUtils { return icon == null ? null : icon.getLocation(); } - public static String makeDriverFullId(DBPDriver driver) { - return driver.getProviderId() + ":" + driver.getId(); - } - @NotNull public static DBPDriver getDriverById(String id) throws DBWebException { int divPos = id.indexOf(':'); @@ -236,7 +232,12 @@ public class WebServiceUtils { } } - public static void saveAuthProperties(DBPDataSourceContainer dataSourceContainer, DBPConnectionConfiguration configuration, Map authProperties, boolean saveCredentials) { + public static void saveAuthProperties( + @NotNull DBPDataSourceContainer dataSourceContainer, + @NotNull DBPConnectionConfiguration configuration, + @Nullable Map authProperties, + boolean saveCredentials) + { dataSourceContainer.setSavePassword(saveCredentials); if (!saveCredentials) { // Reset credentials @@ -247,6 +248,9 @@ public class WebServiceUtils { return; } } + if (!saveCredentials) { + configuration.setUserPassword(null); + } { // Read save credentials DBAAuthCredentials credentials = configuration.getAuthModel().loadCredentials(dataSourceContainer, configuration); diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/auth/provider/AbstractExternalAuthProvider.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/auth/provider/AbstractExternalAuthProvider.java index f6ab04e602..a4f089836a 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/auth/provider/AbstractExternalAuthProvider.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/auth/provider/AbstractExternalAuthProvider.java @@ -18,14 +18,20 @@ package io.cloudbeaver.auth.provider; import io.cloudbeaver.DBWAuthProviderExternal; import io.cloudbeaver.model.user.WebUser; +import org.jkiss.code.NotNull; +import org.jkiss.dbeaver.DBException; import org.jkiss.dbeaver.model.access.DBASession; +import org.jkiss.dbeaver.model.runtime.DBRProgressMonitor; import org.jkiss.utils.CommonUtils; +import java.util.Map; + /** * Abstract external auth provider */ public abstract class AbstractExternalAuthProvider implements DBWAuthProviderExternal { + @Deprecated protected void setUserOrigin(WebUser user, String type, String subType) { user.setMetaParameter(META_AUTH_PROVIDER, type); if (!CommonUtils.isEmpty(subType)) { @@ -33,4 +39,9 @@ public abstract class AbstractExternalAuthProvider i } } + @NotNull + @Override + public Map authFederatedUser(@NotNull DBRProgressMonitor monitor, @NotNull Map providerConfig, @NotNull String faProvider, @NotNull Map faParameters) throws DBException { + throw new DBException("Federated authorization not supported"); + } } diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/auth/provider/local/LocalAuthProvider.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/auth/provider/local/LocalAuthProvider.java index fd2cfdc986..8bbdc50bb5 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/auth/provider/local/LocalAuthProvider.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/auth/provider/local/LocalAuthProvider.java @@ -77,4 +77,31 @@ public class LocalAuthProvider implements DBWAuthProvider { return SecurityUtils.makeDigest(password); } + public static boolean changeUserPassword(@NotNull WebSession webSession, @NotNull String oldPassword, @NotNull String newPassword) throws DBException { + String userName = webSession.getUser().getUserId(); + + WebAuthProviderDescriptor authProvider = WebServiceRegistry.getInstance().getAuthProvider(PROVIDER_ID); + Map storedCredentials = CBApplication.getInstance().getSecurityController().getUserCredentials(userName, authProvider); + if (storedCredentials == null) { + throw new DBException("Invalid user name or password"); + } + String storedPasswordHash = CommonUtils.toString(storedCredentials.get(CRED_PASSWORD), null); + if (CommonUtils.isEmpty(storedPasswordHash)) { + throw new DBException("User has no saved credentials"); + } + if (CommonUtils.isEmpty(oldPassword)) { + throw new DBException("No user password provided"); + } + String oldPasswordHash = WebAuthProviderPropertyEncryption.hash.encrypt(userName, oldPassword); + if (!storedPasswordHash.equals(oldPasswordHash)) { + throw new DBException("Invalid user name or password"); + } + + //String newPasswordHash = WebAuthProviderPropertyEncryption.hash.encrypt(userName, newPassword); + + storedCredentials.put(CRED_PASSWORD, newPassword); + CBApplication.getInstance().getSecurityController().setUserCredentials(userName, authProvider, storedCredentials); + return true; + } + } diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/model/WebConnectionInfo.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/model/WebConnectionInfo.java index 9ffe17202d..afb9f416c4 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/model/WebConnectionInfo.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/model/WebConnectionInfo.java @@ -77,7 +77,7 @@ public class WebConnectionInfo { @Property public String getDriverId() { - return WebServiceUtils.makeDriverFullId(dataSourceContainer.getDriver()); + return dataSourceContainer.getDriver().getFullId(); } @Property @@ -263,7 +263,7 @@ public class WebConnectionInfo { // Fill session and user provided credentials boolean hasContextCredentials = session.hasContextCredentials(); DBPConnectionConfiguration configWithAuth = new DBPConnectionConfiguration(dataSourceContainer.getConnectionConfiguration()); - session.provideAuthParameters(dataSourceContainer, configWithAuth); + session.provideAuthParameters(session.getProgressMonitor(), dataSourceContainer, configWithAuth); DBPPropertySource credentialsSource = authModel.createCredentialsSource(dataSourceContainer, configWithAuth); diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/model/WebDataSourceConfig.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/model/WebDataSourceConfig.java index 0d57ada10d..dad9857457 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/model/WebDataSourceConfig.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/model/WebDataSourceConfig.java @@ -16,7 +16,6 @@ */ package io.cloudbeaver.model; -import io.cloudbeaver.WebServiceUtils; import org.jkiss.dbeaver.model.DBPDataSourceContainer; import org.jkiss.dbeaver.model.meta.Property; @@ -40,7 +39,7 @@ public class WebDataSourceConfig { this.id = ds.getId(); this.name = ds.getName(); this.description = ds.getDescription(); - this.driverId = WebServiceUtils.makeDriverFullId(ds.getDriver()); + this.driverId = ds.getDriver().getFullId(); this.host = ds.getConnectionConfiguration().getHostName(); this.port = ds.getConnectionConfiguration().getHostPort(); this.url = ds.getConnectionConfiguration().getUrl(); diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/model/WebDatabaseDriverConfig.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/model/WebDatabaseDriverConfig.java index 5a74695a08..361f178ab7 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/model/WebDatabaseDriverConfig.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/model/WebDatabaseDriverConfig.java @@ -46,7 +46,7 @@ public class WebDatabaseDriverConfig { public WebDatabaseDriverConfig(WebSession webSession, DBPDriver driver) { this.webSession = webSession; this.driver = driver; - this.id = WebServiceUtils.makeDriverFullId(driver); + this.id = driver.getFullId(); } @Property diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/model/WebServerConfig.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/model/WebServerConfig.java index b65aae1202..cb19130906 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/model/WebServerConfig.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/model/WebServerConfig.java @@ -59,6 +59,16 @@ public class WebServerConfig { return DBWorkbench.getPlatform().getWorkspace().getWorkspaceId(); } + @Property + public String getServerURL() { + return CommonUtils.notEmpty(application.getServerURL()); + } + + @Property + public String getRootURI() { + return CommonUtils.notEmpty(application.getRootURI()); + } + @Property public boolean isAnonymousAccessEnabled() { return application.getAppConfiguration().isAnonymousAccessEnabled(); diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/model/session/WebSession.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/model/session/WebSession.java index b64de35a17..89bc6d246a 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/model/session/WebSession.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/model/session/WebSession.java @@ -31,6 +31,7 @@ import org.jkiss.code.NotNull; import org.jkiss.code.Nullable; import org.jkiss.dbeaver.DBException; import org.jkiss.dbeaver.Log; +import org.jkiss.dbeaver.model.DBConstants; import org.jkiss.dbeaver.model.DBPDataSourceContainer; import org.jkiss.dbeaver.model.access.DBASession; import org.jkiss.dbeaver.model.app.DBPDataSourceRegistry; @@ -56,10 +57,13 @@ import org.jkiss.dbeaver.registry.ProjectMetadata; import org.jkiss.dbeaver.runtime.jobs.DisconnectJob; import org.jkiss.utils.CommonUtils; +import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.File; import java.lang.reflect.InvocationTargetException; +import java.text.SimpleDateFormat; import java.util.*; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Function; @@ -74,6 +78,7 @@ public class WebSession implements DBASession, DBAAuthCredentialsProvider, IAdap private static final Log log = Log.getLog(WebSession.class); private static final String ATTR_LOCALE = "locale"; + private static final String SESSION_TEMP_COOKIE = "cb-session"; private static final AtomicInteger TASK_ID = new AtomicInteger(); @@ -407,7 +412,7 @@ public class WebSession implements DBASession, DBAAuthCredentialsProvider, IAdap } } - synchronized void updateInfo(HttpServletRequest request) { + synchronized void updateInfo(HttpServletRequest request, HttpServletResponse response) { HttpSession httpSession = request.getSession(); this.lastAccessTime = System.currentTimeMillis(); this.lastRemoteAddr = request.getRemoteAddr(); @@ -431,6 +436,17 @@ public class WebSession implements DBASession, DBAAuthCredentialsProvider, IAdap log.error("Error persisting web session", e); } } + { + long maxSessionIdleTime = CBApplication.getInstance().getMaxSessionIdleTime(); + + SimpleDateFormat sdf = new SimpleDateFormat(DBConstants.DEFAULT_ISO_TIMESTAMP_FORMAT); + sdf.setTimeZone(TimeZone.getTimeZone("GMT")); + Cookie sessionCookie = new Cookie(SESSION_TEMP_COOKIE, sdf.format(new Date(System.currentTimeMillis() + maxSessionIdleTime))); + sessionCookie.setMaxAge((int) (maxSessionIdleTime / 1000)); + sessionCookie.setPath(CBApplication.getInstance().getRootURI()); + //sessionCookie.setComment("CB session cookie"); + response.addCookie(sessionCookie); + } } @Association @@ -718,13 +734,13 @@ public class WebSession implements DBASession, DBAAuthCredentialsProvider, IAdap // Auth credentials provider // Adds auth properties passed from web (by user) @Override - public boolean provideAuthParameters(DBPDataSourceContainer dataSourceContainer, DBPConnectionConfiguration configuration) { + public boolean provideAuthParameters(@NotNull DBRProgressMonitor monitor, @NotNull DBPDataSourceContainer dataSourceContainer, @NotNull DBPConnectionConfiguration configuration) { try { // Properties from nested auth sessions // FIXME: we need to support multiple credential providers (e.g. multiple clouds). DBAAuthCredentialsProvider nestedProvider = getAdapter(DBAAuthCredentialsProvider.class); if (nestedProvider != null) { - if (!nestedProvider.provideAuthParameters(dataSourceContainer, configuration)) { + if (!nestedProvider.provideAuthParameters(monitor, dataSourceContainer, configuration)) { return false; } } diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/model/session/WebSessionManager.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/model/session/WebSessionManager.java index c9b3091e39..2e9ffcd01f 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/model/session/WebSessionManager.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/model/session/WebSessionManager.java @@ -25,6 +25,7 @@ import org.jkiss.dbeaver.runtime.DBWorkbench; import org.jkiss.utils.CommonUtils; import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.util.*; @@ -65,21 +66,21 @@ public class WebSessionManager { return false; } - public boolean touchSession(@NotNull HttpServletRequest request) throws DBWebException { - WebSession webSession = getWebSession(request, false); - webSession.updateInfo(request); + public boolean touchSession(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response) throws DBWebException { + WebSession webSession = getWebSession(request, response, false); + webSession.updateInfo(request, response); return true; } - public WebSession getWebSession(@NotNull HttpServletRequest request) throws DBWebException { - return getWebSession(request, true); + public WebSession getWebSession(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response) throws DBWebException { + return getWebSession(request, response, true); } - public WebSession getWebSession(@NotNull HttpServletRequest request, boolean errorOnNoFound) throws DBWebException { - return getWebSession(request, true, errorOnNoFound); + public WebSession getWebSession(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, boolean errorOnNoFound) throws DBWebException { + return getWebSession(request, response, true, errorOnNoFound); } - public WebSession getWebSession(HttpServletRequest request, boolean updateInfo, boolean errorOnNoFound) throws DBWebException { + public WebSession getWebSession(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, boolean updateInfo, boolean errorOnNoFound) throws DBWebException { HttpSession httpSession = request.getSession(true); String sessionId = httpSession.getId(); WebSession webSession; @@ -103,7 +104,7 @@ public class WebSessionManager { if (updateInfo) { // Update only once per request if (!CommonUtils.toBoolean(request.getAttribute("sessionUpdated"))) { - webSession.updateInfo(request); + webSession.updateInfo(request, response); request.setAttribute("sessionUpdated", true); } } diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/registry/WebDriverRegistry.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/registry/WebDriverRegistry.java index c508cf9f48..20fdce4bb5 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/registry/WebDriverRegistry.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/registry/WebDriverRegistry.java @@ -16,7 +16,6 @@ */ package io.cloudbeaver.registry; -import io.cloudbeaver.WebServiceUtils; import io.cloudbeaver.server.CBApplication; import org.eclipse.core.runtime.IConfigurationElement; import org.eclipse.core.runtime.IExtensionRegistry; @@ -63,7 +62,7 @@ public class WebDriverRegistry { } public boolean isDriverEnabled(DBPDriver driver) { - String driverId = WebServiceUtils.makeDriverFullId(driver); + String driverId = driver.getFullId(); if (webDrivers.contains(driverId)) { String[] enabledDrivers = CBApplication.getInstance().getAppConfiguration().getEnabledDrivers(); if (enabledDrivers.length > 0 && !ArrayUtils.contains(enabledDrivers, driverId)) { diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/registry/WebServiceRegistry.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/registry/WebServiceRegistry.java index 49a5d22c31..2d0dfa59f2 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/registry/WebServiceRegistry.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/registry/WebServiceRegistry.java @@ -17,16 +17,15 @@ package io.cloudbeaver.registry; import io.cloudbeaver.service.DBWServiceBinding; +import io.cloudbeaver.service.sql.DBWValueSerializer; import org.eclipse.core.runtime.IConfigurationElement; import org.eclipse.core.runtime.IExtensionRegistry; import org.eclipse.core.runtime.Platform; +import org.jkiss.dbeaver.DBException; import org.jkiss.dbeaver.Log; import org.jkiss.dbeaver.registry.RegistryConstants; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; +import java.util.*; public class WebServiceRegistry { @@ -50,6 +49,7 @@ public class WebServiceRegistry { private DBWServiceBinding[] webServiceInstances; private final Map authProviders = new LinkedHashMap<>(); + private final Map valueSerializers = new HashMap<>(); private WebServiceRegistry() { } @@ -90,6 +90,15 @@ public class WebServiceRegistry { } } webServiceInstances = instances.toArray(new DBWServiceBinding[0]); + + { + IConfigurationElement[] extConfigs = registry.getConfigurationElementsFor(WebValueSerializerDescriptor.EXTENSION_ID); + for (IConfigurationElement ext : extConfigs) { + WebValueSerializerDescriptor descriptor = new WebValueSerializerDescriptor(ext); + valueSerializers.put(descriptor.getValueType(), descriptor); + } + } + } public List getWebServices() { @@ -125,4 +134,17 @@ public class WebServiceRegistry { return authProviders.get(id); } + public DBWValueSerializer createValueSerializer(String valueType) { + WebValueSerializerDescriptor descriptor = valueSerializers.get(valueType); + if (descriptor == null) { + return null; + } + try { + return descriptor.createInstance(); + } catch (DBException e) { + log.error("Error creating value '" + valueType + "' serializer", e); + return null; + } + } + } diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/registry/WebValueSerializerDescriptor.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/registry/WebValueSerializerDescriptor.java new file mode 100644 index 0000000000..262577d552 --- /dev/null +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/registry/WebValueSerializerDescriptor.java @@ -0,0 +1,53 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2021 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cloudbeaver.registry; + +import io.cloudbeaver.service.sql.DBWValueSerializer; +import org.eclipse.core.runtime.IConfigurationElement; +import org.jkiss.code.NotNull; +import org.jkiss.dbeaver.DBException; +import org.jkiss.dbeaver.model.impl.AbstractDescriptor; + +/** + * Value serializer descriptor + */ +public class WebValueSerializerDescriptor extends AbstractDescriptor { + + public static final String EXTENSION_ID = "io.cloudbeaver.valueSerializer"; //$NON-NLS-1$ + + private final IConfigurationElement cfg; + + private final String valueType; + private final ObjectType implType; + + public WebValueSerializerDescriptor(IConfigurationElement cfg) { + super(cfg); + this.cfg = cfg; + this.valueType = cfg.getAttribute("type"); + this.implType = new ObjectType(cfg, "class"); + } + + @NotNull + public String getValueType() { + return valueType; + } + + public DBWValueSerializer createInstance() throws DBException { + return implType.createInstance(DBWValueSerializer.class); + } + +} diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/CBAppConfig.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/CBAppConfig.java index 649850ced5..2d972545c2 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/CBAppConfig.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/CBAppConfig.java @@ -19,6 +19,7 @@ package io.cloudbeaver.server; import io.cloudbeaver.auth.provider.local.LocalAuthProvider; import io.cloudbeaver.registry.WebAuthProviderDescriptor; import io.cloudbeaver.registry.WebServiceRegistry; +import org.jkiss.code.NotNull; import org.jkiss.dbeaver.model.navigator.DBNBrowseSettings; import org.jkiss.dbeaver.registry.DataSourceNavigatorSettings; @@ -46,6 +47,7 @@ public class CBAppConfig { private String[] enabledAuthProviders = null; private DataSourceNavigatorSettings defaultNavigatorSettings = DEFAULT_VIEW_SETTINGS; private Map plugins = new LinkedHashMap<>(); + private Map authConfiguration = new LinkedHashMap<>(); public boolean isAnonymousAccessEnabled() { return anonymousAccessEnabled; @@ -132,15 +134,16 @@ public class CBAppConfig { this.defaultNavigatorSettings = new DataSourceNavigatorSettings(defaultNavigatorSettings); } + @NotNull public Map getPlugins() { return plugins; } - public Map getPluginConfig(String pluginId) { + public Map getPluginConfig(@NotNull String pluginId) { return getPluginConfig(pluginId, false); } - public Map getPluginConfig(String pluginId, boolean create) { + public Map getPluginConfig(@NotNull String pluginId, boolean create) { Object config = plugins.get(pluginId); if (config instanceof Map) { return (Map) config; @@ -155,8 +158,21 @@ public class CBAppConfig { } } - public T getPluginOption(String pluginId, String option) { + public T getPluginOption(@NotNull String pluginId, @NotNull String option) { return (T)getPluginConfig(pluginId).get(option); } + public Map getAuthConfiguration() { + return authConfiguration; + } + + @NotNull + public Map getAuthConfiguration(@NotNull String providerId) { + Object apConfig = authConfiguration.get(providerId); + if (apConfig instanceof Map) { + return (Map) apConfig; + } + return Collections.emptyMap(); + } + } diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/CBApplication.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/CBApplication.java index ce951b5766..d39f1f49d7 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/CBApplication.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/CBApplication.java @@ -74,6 +74,7 @@ public class CBApplication extends BaseApplicationImpl { return (CBApplication) BaseApplicationImpl.getInstance(); } + private String serverURL; private int serverPort = CBConstants.DEFAULT_SERVER_PORT; private String serverName = null; private String contentRoot = CBConstants.DEFAULT_CONTENT_ROOT; @@ -103,6 +104,10 @@ public class CBApplication extends BaseApplicationImpl { public CBApplication() { } + public String getServerURL() { + return serverURL; + } + public int getServerPort() { return serverPort; } @@ -174,7 +179,7 @@ public class CBApplication extends BaseApplicationImpl { try { loadConfiguration(configPath); - File runtimeConfigFile = getRuntimeConfigFile(); + File runtimeConfigFile = getRuntimeAppConfigFile(); if (runtimeConfigFile.exists()) { log.debug("Runtime configuration [" + runtimeConfigFile.getAbsolutePath() + "]"); parseConfiguration(runtimeConfigFile); @@ -274,6 +279,11 @@ public class CBApplication extends BaseApplicationImpl { }); Runtime.getRuntime().addShutdownHook(shutdownThread); + if (configurationMode) { + // Try to configure automatically + performAutoConfiguration(new File(configPath).getParentFile()); + } + try { initializeServer(); } catch (DBException e) { @@ -287,6 +297,55 @@ public class CBApplication extends BaseApplicationImpl { return null; } + /** + * Configures server automatically. + * Called on startup + * @param configPath + */ + protected void performAutoConfiguration(File configPath) { + String autoServerName = System.getenv(CBConstants.VAR_AUTO_CB_SERVER_NAME); + String autoServerURL = System.getenv(CBConstants.VAR_AUTO_CB_SERVER_URL); + String autoAdminName = System.getenv(CBConstants.VAR_AUTO_CB_ADMIN_NAME); + String autoAdminPassword = System.getenv(CBConstants.VAR_AUTO_CB_ADMIN_PASSWORD); + + if (CommonUtils.isEmpty(autoServerName) || CommonUtils.isEmpty(autoAdminName) || CommonUtils.isEmpty(autoAdminPassword)) { + // Try to load from auto config file + if (configPath.exists()) { + File autoConfigFile = new File(configPath, CBConstants.AUTO_CONFIG_FILE_NAME); + if (autoConfigFile.exists()) { + Properties autoProps = new Properties(); + try (InputStream is = new FileInputStream(autoConfigFile)) { + autoProps.load(is); + + autoServerName = autoProps.getProperty(CBConstants.VAR_AUTO_CB_SERVER_NAME); + autoServerURL = autoProps.getProperty(CBConstants.VAR_AUTO_CB_SERVER_URL); + autoAdminName = autoProps.getProperty(CBConstants.VAR_AUTO_CB_ADMIN_NAME); + autoAdminPassword = autoProps.getProperty(CBConstants.VAR_AUTO_CB_ADMIN_PASSWORD); + } catch (IOException e) { + log.error("Error loading auto configuration file '" + autoConfigFile.getAbsolutePath() + "'", e); + } + } + } + } + + if (CommonUtils.isEmpty(autoServerName) || CommonUtils.isEmpty(autoAdminName) || CommonUtils.isEmpty(autoAdminPassword)) { + log.info("No auto configuration was found. Server must be configured manually"); + return; + } + try { + finishConfiguration( + autoServerName, + autoServerURL, + autoAdminName, + autoAdminPassword, + Collections.emptyList(), + maxSessionIdleTime, + getAppConfiguration()); + } catch (Exception e) { + log.error("Error loading server auto configuration", e); + } + } + protected void initializeServer() throws DBException { } @@ -327,8 +386,13 @@ public class CBApplication extends BaseApplicationImpl { } @NotNull - private File getRuntimeConfigFile() { - return new File(getDataDirectory(false), CBConstants.RUNTIME_CONFIG_FILE_NAME); + private File getRuntimeAppConfigFile() { + return new File(getDataDirectory(false), CBConstants.RUNTIME_APP_CONFIG_FILE_NAME); + } + + @NotNull + private File getRuntimeProductConfigFile() { + return new File(getDataDirectory(false), CBConstants.RUNTIME_PRODUCT_CONFIG_FILE_NAME); } @NotNull @@ -398,6 +462,12 @@ public class CBApplication extends BaseApplicationImpl { Map serverConfig = JSONUtils.getObject(configProps, "server"); serverPort = JSONUtils.getInteger(serverConfig, CBConstants.PARAM_SERVER_PORT, serverPort); + if (serverConfig.containsKey(CBConstants.PARAM_SERVER_URL)) { + serverURL = JSONUtils.getString(serverConfig, CBConstants.PARAM_SERVER_URL, serverURL); + } else if (serverURL == null) { + serverURL = "http://" + InetAddress.getLocalHost().getHostName() + ":" + serverPort; + } + serverName = JSONUtils.getString(serverConfig, CBConstants.PARAM_SERVER_NAME, serverName); contentRoot = getRelativePath( JSONUtils.getString(serverConfig, CBConstants.PARAM_CONTENT_ROOT, contentRoot), homeFolder); @@ -439,6 +509,19 @@ public class CBApplication extends BaseApplicationImpl { } } } + + // Add product config from runtime + { + File rtConfig = getRuntimeProductConfigFile(); + if (rtConfig.exists()) { + log.debug("Load product runtime configuration from '" + rtConfig.getAbsolutePath() + "'"); + try (Reader reader = new InputStreamReader(new FileInputStream(rtConfig), StandardCharsets.UTF_8)) { + productConfiguration.putAll(JSONUtils.parseMap(gson, reader)); + } catch (Exception e) { + log.error("Error reading product runtime configuration", e); + } + } + } } static String getRelativePath(String path, String curDir) { @@ -504,6 +587,7 @@ public class CBApplication extends BaseApplicationImpl { public synchronized void finishConfiguration( @NotNull String newServerName, + @NotNull String newServerURL, @NotNull String adminName, @Nullable String adminPassword, @NotNull List authInfoList, @@ -520,7 +604,7 @@ public class CBApplication extends BaseApplicationImpl { // Save runtime configuration log.debug("Saving runtime configuration"); - saveRuntimeConfig(newServerName, sessionExpireTime, appConfig); + saveRuntimeConfig(newServerName, newServerURL, sessionExpireTime, appConfig); // Grant permissions to predefined connections if (isConfigurationMode() && appConfig.isAnonymousAccessEnabled()) { @@ -530,7 +614,7 @@ public class CBApplication extends BaseApplicationImpl { // Re-load runtime configuration try { log.debug("Reloading application configuration"); - File runtimeConfigFile = getRuntimeConfigFile(); + File runtimeConfigFile = getRuntimeAppConfigFile(); if (runtimeConfigFile.exists()) { log.debug("Runtime configuration [" + runtimeConfigFile.getAbsolutePath() + "]"); parseConfiguration(runtimeConfigFile); @@ -543,7 +627,7 @@ public class CBApplication extends BaseApplicationImpl { } public synchronized void flushConfiguration() throws DBException { - saveRuntimeConfig(serverName, maxSessionIdleTime, appConfiguration); + saveRuntimeConfig(serverName, serverURL, maxSessionIdleTime, appConfiguration); } @@ -565,9 +649,9 @@ public class CBApplication extends BaseApplicationImpl { } } - private void saveRuntimeConfig(String newServerName, long sessionExpireTime, CBAppConfig appConfig) throws DBException { + private void saveRuntimeConfig(String newServerName, String newServerURL, long sessionExpireTime, CBAppConfig appConfig) throws DBException { - File runtimeConfigFile = getRuntimeConfigFile(); + File runtimeConfigFile = getRuntimeAppConfigFile(); try (Writer out = new OutputStreamWriter(new FileOutputStream(runtimeConfigFile), StandardCharsets.UTF_8)) { Gson gson = new GsonBuilder() .setLenient() @@ -582,6 +666,9 @@ public class CBApplication extends BaseApplicationImpl { if (!CommonUtils.isEmpty(newServerName)) { JSONUtils.field(json, CBConstants.PARAM_SERVER_NAME, newServerName); } + if (!CommonUtils.isEmpty(newServerURL)) { + JSONUtils.field(json, CBConstants.PARAM_SERVER_URL, newServerURL); + } if (sessionExpireTime > 0) { JSONUtils.field(json, CBConstants.PARAM_SESSION_EXPIRE_PERIOD, sessionExpireTime); } @@ -619,12 +706,15 @@ public class CBApplication extends BaseApplicationImpl { json.endObject(); } if (appConfig.getEnabledAuthProviders() != null) { - JSONUtils.serializeStringList(json, "enabledAuthProviders", Arrays.asList(appConfig.getEnabledAuthProviders())); + JSONUtils.serializeStringList(json, "enabledAuthProviders", Arrays.asList(appConfig.getEnabledAuthProviders()), true); } if (!CommonUtils.isEmpty(appConfig.getPlugins())) { JSONUtils.serializeProperties(json, "plugins", appConfig.getPlugins()); } + if (!CommonUtils.isEmpty(appConfig.getAuthConfiguration())) { + JSONUtils.serializeProperties(json, "authConfiguration", appConfig.getAuthConfiguration()); + } json.endObject(); } diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/CBConstants.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/CBConstants.java index 76a2fba9df..a4378501d8 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/CBConstants.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/CBConstants.java @@ -30,13 +30,16 @@ public class CBConstants { public static final String DEFAULT_CONFIG_FILE_PATH = "/etc/cloudbeaver.conf"; public static final String RUNTIME_DATA_DIR_NAME = ".data"; - public static final String RUNTIME_CONFIG_FILE_NAME = ".cloudbeaver.runtime.conf"; + public static final String RUNTIME_APP_CONFIG_FILE_NAME = ".cloudbeaver.runtime.conf"; + public static final String RUNTIME_PRODUCT_CONFIG_FILE_NAME = ".product.runtime.conf"; + public static final String AUTO_CONFIG_FILE_NAME = ".cloudbeaver.auto.conf"; public static final String CLI_PARAM_WEB_CONFIG = "-web-config"; public static final String PARAM_SERVER_PORT = "serverPort"; public static final String PARAM_SERVER_NAME = "serverName"; public static final String PARAM_CONTENT_ROOT = "contentRoot"; + public static final String PARAM_SERVER_URL = "serverURL"; public static final String PARAM_ROOT_URI = "rootURI"; public static final String PARAM_SERVICES_URI = "serviceURI"; public static final String PARAM_DRIVERS_LOCATION = "driversLocation"; @@ -66,9 +69,15 @@ public class CBConstants { public static final String DEFAUL_APP_ANONYMOUS_ROLE_NAME = "user"; + + public static final String DEFAULT_SERVER_ADDR = "localhost"; + public static final String VAR_CB_LOCAL_HOST_ADDR = "CB_LOCAL_HOST_ADDR"; - - public static final String VAR_HOST_DOCKER_INTERNAL = "host.docker.internal"; + public static final String VAR_AUTO_CB_SERVER_NAME = "CB_SERVER_NAME"; + public static final String VAR_AUTO_CB_SERVER_URL = "CB_SERVER_URL"; + public static final String VAR_AUTO_CB_ADMIN_NAME = "CB_ADMIN_NAME"; + public static final String VAR_AUTO_CB_ADMIN_PASSWORD = "CB_ADMIN_PASSWORD"; + } diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/CBSecurityController.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/CBSecurityController.java index 1d38f60dbd..4f22f46d79 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/CBSecurityController.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/CBSecurityController.java @@ -463,6 +463,28 @@ class CBSecurityController implements DBWSecurityController { } } + @Override + public void updateRole(WebRole role) throws DBCException { + if (!isSubjectExists(role.getRoleId())) { + throw new DBCException("Role '" + role.getRoleId() + "' doesn't exists"); + } + try (Connection dbCon = database.openConnection()) { + try (JDBCTransaction txn = new JDBCTransaction(dbCon)) { + createAuthSubject(dbCon, role.getRoleId(), SUBJECT_ROLE); + try (PreparedStatement dbStat = dbCon.prepareStatement( + "UPDATE CB_ROLE SET ROLE_NAME=?,ROLE_DESCRIPTION=? WHERE ROLE_ID=?")) { + dbStat.setString(1, CommonUtils.notEmpty(role.getName())); + dbStat.setString(2, CommonUtils.notEmpty(role.getDescription())); + dbStat.setString(3, role.getRoleId()); + dbStat.execute(); + } + txn.commit(); + } + } catch (SQLException e) { + throw new DBCException("Error updating role info in database", e); + } + } + @Override public void deleteRole(String roleId) throws DBCException { try (Connection dbCon = database.openConnection()) { diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/graphql/GraphQLEndpoint.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/graphql/GraphQLEndpoint.java index ef85af5748..a9602038fd 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/graphql/GraphQLEndpoint.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/graphql/GraphQLEndpoint.java @@ -319,6 +319,15 @@ public class GraphQLEndpoint extends HttpServlet { return request; } + public static HttpServletResponse getServletResponse(DataFetchingEnvironment env) { + GraphQLContext context = env.getContext(); + HttpServletResponse response = context.get("response"); + if (response == null) { + throw new IllegalStateException("Null response"); + } + return response; + } + public static GraphQLBindingContext getBindingContext(DataFetchingEnvironment env) { GraphQLContext context = env.getContext(); return context.get("bindingContext"); diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/jetty/CBJettyServer.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/jetty/CBJettyServer.java index 410e0c584a..9d8b539b07 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/jetty/CBJettyServer.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/jetty/CBJettyServer.java @@ -45,8 +45,9 @@ public class CBJettyServer { // Handler configuration ServletContextHandler servletContextHandler = new ServletContextHandler(ServletContextHandler.SESSIONS); servletContextHandler.setResourceBase(application.getContentRoot()); - servletContextHandler.setContextPath(application.getRootURI()); - servletContextHandler.addServlet(new ServletHolder("static", new CBStaticServlet()), application.getRootURI()); + String rootURI = application.getRootURI(); + servletContextHandler.setContextPath(rootURI); + servletContextHandler.addServlet(new ServletHolder("static", new CBStaticServlet()), "/*"); servletContextHandler.addServlet(new ServletHolder("images", new CBImageServlet()), application.getServicesURI() + "images/*"); servletContextHandler.addServlet(new ServletHolder("graphql", new GraphQLEndpoint()), application.getServicesURI() + "gql/*"); servletContextHandler.addEventListener(new CBServerContextListener()); diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/jetty/CBStaticServlet.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/jetty/CBStaticServlet.java index 4d212c4d52..b85a421854 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/jetty/CBStaticServlet.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/jetty/CBStaticServlet.java @@ -1,5 +1,7 @@ package io.cloudbeaver.server.jetty; +import io.cloudbeaver.server.CBApplication; +import org.eclipse.jetty.http.HttpContent; import org.eclipse.jetty.http.HttpField; import org.eclipse.jetty.http.HttpHeader; import org.eclipse.jetty.server.ResourceService; @@ -9,7 +11,10 @@ import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Enumeration; @WebServlet(urlPatterns = "/") public class CBStaticServlet extends DefaultServlet { @@ -26,10 +31,33 @@ public class CBStaticServlet extends DefaultServlet { } private static ResourceService makeResourceService() { - ResourceService resourceService = new ResourceService(); + ResourceService resourceService = new ProxyResourceService(); resourceService.setCacheControl(new HttpField(HttpHeader.CACHE_CONTROL, "public, max-age=" + STATIC_CACHE_SECONDS)); return resourceService; } + private static class ProxyResourceService extends ResourceService { + @Override + protected boolean sendData(HttpServletRequest request, HttpServletResponse response, boolean include, HttpContent content, Enumeration reqRanges) throws IOException { + if (content.getResource().getName().endsWith("index.html")) { + return patchIndexHtml(response, content); + } + return super.sendData(request, response, include, content, reqRanges); + } + + private boolean patchIndexHtml(HttpServletResponse response, HttpContent content) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + content.getResource().writeTo(baos, 0, content.getContentLengthValue()); + String indexContents = new String(baos.toByteArray(), StandardCharsets.UTF_8); + indexContents = indexContents.replace("{ROOT_URI}", CBApplication.getInstance().getRootURI()); + byte[] indexBytes = indexContents.getBytes(StandardCharsets.UTF_8); + + putHeaders(response, content, indexBytes.length); + response.getOutputStream().write(indexBytes); + + return true; + } + } + } \ No newline at end of file diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/WebServiceBindingBase.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/WebServiceBindingBase.java index 62f8d96611..7889c6deab 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/WebServiceBindingBase.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/WebServiceBindingBase.java @@ -31,6 +31,7 @@ import org.jkiss.utils.ArrayUtils; import org.jkiss.utils.CommonUtils; import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; @@ -92,18 +93,22 @@ public abstract class WebServiceBindingBase impleme return GraphQLEndpoint.getServletRequest(env); } + protected static HttpServletResponse getServletResponse(DataFetchingEnvironment env) { + return GraphQLEndpoint.getServletResponse(env); + } + protected static DBWBindingContext getBindingContext(DataFetchingEnvironment env) { return GraphQLEndpoint.getBindingContext(env); } protected static WebSession getWebSession(DataFetchingEnvironment env) throws DBWebException { return CBPlatform.getInstance().getSessionManager().getWebSession( - getServletRequest(env)); + getServletRequest(env), getServletResponse(env)); } protected static WebSession getWebSession(DataFetchingEnvironment env, boolean errorOnNotFound) throws DBWebException { return CBPlatform.getInstance().getSessionManager().getWebSession( - getServletRequest(env), errorOnNotFound); + getServletRequest(env), getServletResponse(env), errorOnNotFound); } @NotNull diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/core/DBWServiceCore.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/core/DBWServiceCore.java index a355201a6e..bafc61591e 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/core/DBWServiceCore.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/core/DBWServiceCore.java @@ -26,6 +26,7 @@ import org.jkiss.code.Nullable; import org.jkiss.dbeaver.model.navigator.DBNBrowseSettings; import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; import java.util.List; import java.util.Map; @@ -75,13 +76,13 @@ public interface DBWServiceCore extends DBWService { boolean closeSession(HttpServletRequest request) throws DBWebException; @WebAction(requirePermissions = {}) - boolean touchSession(HttpServletRequest request) throws DBWebException; + boolean touchSession(@NotNull HttpServletRequest request, @NotNull HttpServletResponse servletResponse) throws DBWebException; @WebAction(requirePermissions = {}) - boolean refreshSessionConnections(HttpServletRequest request) throws DBWebException; + boolean refreshSessionConnections(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response) throws DBWebException; @WebAction - boolean changeSessionLanguage(WebSession webSession, String locale) throws DBWebException; + boolean changeSessionLanguage(@NotNull WebSession webSession, String locale) throws DBWebException; /////////////////////////////////////////// // Connections @@ -89,13 +90,6 @@ public interface DBWServiceCore extends DBWService { @WebAction WebConnectionInfo getConnectionState(WebSession webSession, String connectionId) throws DBWebException; - // Replaced with initConnection - @Deprecated - @WebAction - WebConnectionInfo openConnection( - @NotNull WebSession webSession, - @NotNull WebConnectionConfig connectionConfig) throws DBWebException; - @WebAction WebConnectionInfo initConnection( @NotNull WebSession webSession, diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/core/WebServiceBindingCore.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/core/WebServiceBindingCore.java index 479ec91de5..4c19c47ac9 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/core/WebServiceBindingCore.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/core/WebServiceBindingCore.java @@ -79,13 +79,15 @@ public class WebServiceBindingCore extends WebServiceBindingBase ; model.getMutationType() - .dataFetcher("openSession", env -> getService(env).openSession(sessionManager.getWebSession(GraphQLEndpoint.getServletRequest(env), false))) + .dataFetcher("openSession", env -> getService(env).openSession( + sessionManager.getWebSession(GraphQLEndpoint.getServletRequest(env), GraphQLEndpoint.getServletResponse(env), false))) .dataFetcher("closeSession", env -> getService(env).closeSession(GraphQLEndpoint.getServletRequest(env))) - .dataFetcher("touchSession", env -> getService(env).touchSession(GraphQLEndpoint.getServletRequest(env))) - .dataFetcher("refreshSessionConnections", env -> getService(env).refreshSessionConnections(GraphQLEndpoint.getServletRequest(env))) + .dataFetcher("touchSession", env -> getService(env).touchSession( + GraphQLEndpoint.getServletRequest(env), GraphQLEndpoint.getServletResponse(env))) + .dataFetcher("refreshSessionConnections", env -> getService(env).refreshSessionConnections( + GraphQLEndpoint.getServletRequest(env), GraphQLEndpoint.getServletResponse(env))) .dataFetcher("changeSessionLanguage", env -> getService(env).changeSessionLanguage(getWebSession(env), env.getArgument("locale"))) - .dataFetcher("openConnection", env -> getService(env).openConnection(getWebSession(env), getConnectionConfig(env))) .dataFetcher("createConnection", env -> getService(env).createConnection(getWebSession(env), getConnectionConfig(env))) .dataFetcher("updateConnection", env -> getService(env).updateConnection(getWebSession(env), getConnectionConfig(env))) .dataFetcher("deleteConnection", env -> getService(env).deleteConnection(getWebSession(env), env.getArgument("id"))) diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/core/impl/WebServiceCore.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/core/impl/WebServiceCore.java index 6ee0eb2a53..8aced9bf54 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/core/impl/WebServiceCore.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/core/impl/WebServiceCore.java @@ -33,8 +33,6 @@ import org.jkiss.dbeaver.model.DBPDataSourceContainer; import org.jkiss.dbeaver.model.app.DBPDataSourceRegistry; import org.jkiss.dbeaver.model.connection.DBPConnectionConfiguration; import org.jkiss.dbeaver.model.connection.DBPDriver; -import org.jkiss.dbeaver.model.data.json.JSONUtils; -import org.jkiss.dbeaver.model.impl.auth.AuthModelDatabaseNative; import org.jkiss.dbeaver.model.navigator.DBNBrowseSettings; import org.jkiss.dbeaver.model.navigator.DBNDataSource; import org.jkiss.dbeaver.model.navigator.DBNModel; @@ -54,6 +52,7 @@ import org.jkiss.dbeaver.utils.RuntimeUtils; import org.jkiss.utils.CommonUtils; import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -76,7 +75,7 @@ public class WebServiceCore implements DBWServiceCore { public List getDriverList(@NotNull WebSession webSession, String driverId) { List result = new ArrayList<>(); for (DBPDriver driver : CBPlatform.getInstance().getApplicableDrivers()) { - if (driverId == null || driverId.equals(WebServiceUtils.makeDriverFullId(driver))) { + if (driverId == null || driverId.equals(driver.getFullId())) { result.add(new WebDatabaseDriverConfig(webSession, driver)); } } @@ -173,13 +172,13 @@ public class WebServiceCore implements DBWServiceCore { } @Override - public boolean touchSession(HttpServletRequest request) throws DBWebException { - return CBPlatform.getInstance().getSessionManager().touchSession(request); + public boolean touchSession(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response) throws DBWebException { + return CBPlatform.getInstance().getSessionManager().touchSession(request, response); } @Override - public boolean refreshSessionConnections(HttpServletRequest request) throws DBWebException { - WebSession session = CBPlatform.getInstance().getSessionManager().getWebSession(request); + public boolean refreshSessionConnections(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response) throws DBWebException { + WebSession session = CBPlatform.getInstance().getSessionManager().getWebSession(request, response); if (session == null) { return false; } else { @@ -190,7 +189,7 @@ public class WebServiceCore implements DBWServiceCore { } @Override - public boolean changeSessionLanguage(WebSession webSession, String locale) { + public boolean changeSessionLanguage(@NotNull WebSession webSession, String locale) { webSession.setLocale(locale); return true; } @@ -200,51 +199,6 @@ public class WebServiceCore implements DBWServiceCore { return webSession.getWebConnectionInfo(connectionId); } - @Deprecated - @Override - public WebConnectionInfo openConnection(@NotNull WebSession webSession, @NotNull WebConnectionConfig config) throws DBWebException { - String templateId = config.getTemplateId(); - if (CommonUtils.isEmpty(templateId)) { - throw new DBWebException("Only preconfigured data sources are supported yet"); - } - DBPDataSourceRegistry templateRegistry = WebServiceUtils.getGlobalDataSourceRegistry(); - DBPDataSourceContainer dataSourceTemplate = templateRegistry.getDataSource(templateId); - if (dataSourceTemplate == null) { - throw new DBWebException("Datasource '" + templateId + "' not found"); - } - - DBPDataSourceRegistry sessionRegistry = webSession.getSingletonProject().getDataSourceRegistry(); - DBPDataSourceContainer newDataSource = sessionRegistry.createDataSource(dataSourceTemplate); - newDataSource.setSavePassword(true); - ((DataSourceDescriptor)newDataSource).setTemporary(true); - - DBPConnectionConfiguration cfg = newDataSource.getConnectionConfiguration(); - if (AuthModelDatabaseNative.ID.equals(config.getAuthModelId())) { - cfg.setUserName(JSONUtils.getString(config.getCredentials(), "userName")); - cfg.setUserPassword(JSONUtils.getString(config.getCredentials(), "userPassword")); - } else { - cfg.setUserName(config.getUserName()); - cfg.setUserPassword(config.getUserPassword()); - } - if (!CommonUtils.isEmpty(config.getName())) { - newDataSource.setName(config.getName()); - } - if (!CommonUtils.isEmpty(config.getDescription())) { - newDataSource.setDescription(config.getDescription()); - } - try { - newDataSource.connect(webSession.getProgressMonitor(), true, false); - } catch (DBException e) { - throw new DBWebException("Error connecting to database", e); - } - sessionRegistry.addDataSource(newDataSource); - - WebConnectionInfo connectionInfo = new WebConnectionInfo(webSession, newDataSource); - webSession.addConnection(connectionInfo); - - return connectionInfo; - } - @Override public WebConnectionInfo initConnection( @NotNull WebSession webSession, @@ -321,9 +275,11 @@ public class WebServiceCore implements DBWServiceCore { @Override public WebConnectionInfo updateConnection(@NotNull WebSession webSession, @NotNull WebConnectionConfig config) throws DBWebException { - if (!CBApplication.getInstance().getAppConfiguration().isSupportsCustomConnections()) { - throw new DBWebException("Connection edit is restricted by server configuration"); - } + // Do not check for custom connection option. Already created connections can be edited. + // Also template connections can be edited +// if (!CBApplication.getInstance().getAppConfiguration().isSupportsCustomConnections()) { +// throw new DBWebException("Connection edit is restricted by server configuration"); +// } DBPDataSourceRegistry sessionRegistry = webSession.getSingletonProject().getDataSourceRegistry(); WebConnectionInfo connectionInfo = webSession.getWebConnectionInfo(config.getConnectionId()); @@ -345,8 +301,9 @@ public class WebServiceCore implements DBWServiceCore { @Override public boolean deleteConnection(@NotNull WebSession webSession, @NotNull String connectionId) throws DBWebException { - if (!CBApplication.getInstance().getAppConfiguration().isSupportsCustomConnections()) { - throw new DBWebException("Connection delete is restricted by server configuration"); + WebConnectionInfo connectionInfo = webSession.getWebConnectionInfo(connectionId); + if (connectionInfo.getDataSourceContainer().getProject() != webSession.getSingletonProject()) { + throw new DBWebException("Global connection '" + connectionInfo.getName() + "' configuration cannot be deleted"); } closeAndDeleteConnection(webSession, connectionId, true); return true; @@ -431,7 +388,7 @@ public class WebServiceCore implements DBWServiceCore { } else { testDataSource = WebServiceUtils.createConnectionFromConfig(connectionConfig, sessionRegistry); } - webSession.provideAuthParameters(testDataSource, testDataSource.getConnectionConfiguration()); + webSession.provideAuthParameters(webSession.getProgressMonitor(), testDataSource, testDataSource.getConnectionConfiguration()); testDataSource.setSavePassword(true); // We need for test to avoid password callback try { ConnectionTestJob ct = new ConnectionTestJob(testDataSource, param -> {}); diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/navigator/WebDatabaseObjectInfo.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/navigator/WebDatabaseObjectInfo.java index ce9c32fe95..381da845af 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/navigator/WebDatabaseObjectInfo.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/navigator/WebDatabaseObjectInfo.java @@ -132,14 +132,7 @@ public class WebDatabaseObjectInfo { @Property public String[] getFeatures() { List features = new ArrayList<>(); - if (object instanceof DBPScriptObject) features.add("script"); - if (object instanceof DBPScriptObjectExt) features.add("scriptExtended"); - if (object instanceof DBSDataContainer) features.add("dataContainer"); - if (object instanceof DBSDataManipulator) features.add("dataManipulator"); - if (object instanceof DBSEntity) features.add("entity"); - if (object instanceof DBSSchema) features.add("schema"); - if (object instanceof DBSCatalog) features.add("catalog"); - if (object instanceof DBSObjectContainer) features.add("objectContainer"); + getObjectFeatures(object, features); if (object instanceof DBPDataSourceContainer) { features.add("dataSource"); DBPDataSourceContainer dbpDataSourceContainer = (DBPDataSourceContainer) this.object; @@ -149,10 +142,37 @@ public class WebDatabaseObjectInfo { if (dbpDataSourceContainer.isTemporary()) { features.add("dataSourceTemporary"); } + if (dbpDataSourceContainer.isConnected()) { + DBPDataSource dataSource = dbpDataSourceContainer.getDataSource(); + if (dataSource != null) { + getObjectFeatures(dataSource, features); + } + } } return features.toArray(new String[0]); } + private static void getObjectFeatures(DBSObject object, List features) { + if (object instanceof DBPScriptObject) features.add("script"); + if (object instanceof DBPScriptObjectExt) features.add("scriptExtended"); + if (object instanceof DBSDataContainer) features.add("dataContainer"); + if (object instanceof DBSDataManipulator) features.add("dataManipulator"); + if (object instanceof DBSEntity) features.add("entity"); + if (object instanceof DBSSchema) features.add("schema"); + if (object instanceof DBSCatalog) features.add("catalog"); + if (object instanceof DBSObjectContainer) { + features.add("objectContainer"); + try { + Class childType = ((DBSObjectContainer) object).getPrimaryChildType(null); + if (DBSEntity.class.isAssignableFrom(childType)) { + features.add("entityContainer"); + } + } catch (Exception e) { + log.error(e); + } + } + } + @Property public String[] getEditors() { return null; diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/navigator/WebNavigatorNodeInfo.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/navigator/WebNavigatorNodeInfo.java index 6bd65f64d1..073750fec1 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/navigator/WebNavigatorNodeInfo.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/navigator/WebNavigatorNodeInfo.java @@ -118,6 +118,15 @@ public class WebNavigatorNodeInfo { if (node instanceof DBNContainer) { features.add("container"); } + boolean isShared = false; + if (node instanceof DBNDatabaseNode) { + isShared = !((DBNDatabaseNode) node).getDataSourceContainer().isManageable(); + } else if (node instanceof DBNLocalFolder) { + //isShared = ((DBNLocalFolder) node).getDataSourceRegistry().is + } + if (isShared) { + features.add("shared"); + } return features.toArray(new String[0]); } diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/DBWServiceSQL.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/DBWServiceSQL.java index ac338c1884..d24eec7485 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/DBWServiceSQL.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/DBWServiceSQL.java @@ -24,6 +24,7 @@ import io.cloudbeaver.model.session.WebSession; import org.jkiss.code.NotNull; import org.jkiss.code.Nullable; import org.jkiss.dbeaver.DBException; +import org.jkiss.dbeaver.model.exec.DBCLogicalOperator; import java.util.List; import java.util.Map; @@ -33,11 +34,25 @@ import java.util.Map; */ public interface DBWServiceSQL extends DBWService { + @WebAction + WebSQLContextInfo[] listContexts(@NotNull WebSession session, @Nullable String connectionId, @Nullable String contextId) throws DBWebException; + @WebAction WebSQLDialectInfo getDialectInfo(@NotNull WebSQLProcessor processor) throws DBWebException; @WebAction - WebSQLCompletionProposal[] getCompletionProposals(@NotNull WebSQLContextInfo sqlContext, @NotNull String query, Integer position, Integer maxResults) throws DBWebException; + WebSQLCompletionProposal[] getCompletionProposals( + @NotNull WebSQLContextInfo sqlContext, + @NotNull String query, + Integer position, + Integer maxResults, + Boolean simpleMode) throws DBWebException; + + @WebAction + DBCLogicalOperator[] getSupportedOperations( + @NotNull WebSQLContextInfo contextInfo, + @NotNull String resultsId, + int attributeIndex) throws DBWebException; @WebAction WebSQLContextInfo createContext(@NotNull WebSQLProcessor processor, String defaultCatalog, String defaultSchema) throws DBWebException; @@ -82,4 +97,14 @@ public interface DBWServiceSQL extends DBWService { @WebAction WebSQLExecuteInfo asyncGetQueryResults(@NotNull WebSession webSession, @NotNull String taskId) throws DBWebException; + + @WebAction + WebAsyncTaskInfo asyncSqlExplainExecutionPlan( + @NotNull WebSQLContextInfo contextInfo, + @NotNull String sql, + @NotNull Map options) throws DBException; + + @WebAction + WebSQLExecutionPlan asyncSqlExplainExecutionPlanResult(@NotNull WebSession webSession, @NotNull String taskId) throws DBWebException; + } diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/DBWValueSerializer.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/DBWValueSerializer.java new file mode 100644 index 0000000000..60ee9b84c6 --- /dev/null +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/DBWValueSerializer.java @@ -0,0 +1,35 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2021 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cloudbeaver.service.sql; + +import org.jkiss.code.NotNull; +import org.jkiss.dbeaver.model.data.DBDValue; +import org.jkiss.dbeaver.model.exec.DBCException; +import org.jkiss.dbeaver.model.exec.DBCSession; +import org.jkiss.dbeaver.model.struct.DBSTypedObject; + +import java.util.Map; + +/** + * Web value serializer + */ +public interface DBWValueSerializer { + + @NotNull + VALUE_TYPE deserializeValue(@NotNull DBCSession session, DBSTypedObject attribute, @NotNull Map webValue) throws DBCException; + +} diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLConstants.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLConstants.java new file mode 100644 index 0000000000..644df5a6ae --- /dev/null +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLConstants.java @@ -0,0 +1,39 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2021 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cloudbeaver.service.sql; + +/** + * Web SQL constants. + */ +public class WebSQLConstants { + + public static final int BINARY_PREVIEW_LENGTH = 255; + public static final int BINARY_MAX_LENGTH = 1 * 1024 * 1024; + + public static final String VALUE_TYPE_ATTR = "$type"; + + public static final String VALUE_TYPE_COLLECTION = "collection"; + public static final String VALUE_TYPE_MAP = "map"; + public static final String VALUE_TYPE_DOCUMENT = "document"; + public static final String VALUE_TYPE_CONTENT = "content"; + public static final String VALUE_TYPE_GEOMETRY = "geometry"; + + public static final String ATTR_TEXT = "text"; + public static final String ATTR_BINARY = "binary"; + public static final String ATTR_DATA = "data"; + +} diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLContextInfo.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLContextInfo.java index 7d908771ef..1208e9f7c4 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLContextInfo.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLContextInfo.java @@ -150,6 +150,10 @@ public class WebSQLContextInfo { return id; } + public String getConnectionId() { + return processor.getConnection().getId(); + } + public String getDefaultCatalog() { return defaultCatalog == null ? null : defaultCatalog.getName(); } diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLDataFilter.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLDataFilter.java index 24b639c806..77199942fa 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLDataFilter.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLDataFilter.java @@ -16,9 +16,14 @@ */ package io.cloudbeaver.service.sql; +import org.jkiss.dbeaver.DBException; import org.jkiss.dbeaver.model.data.DBDAttributeConstraint; import org.jkiss.dbeaver.model.data.DBDDataFilter; import org.jkiss.dbeaver.model.exec.DBCLogicalOperator; +import org.jkiss.dbeaver.model.runtime.DBRProgressMonitor; +import org.jkiss.dbeaver.model.struct.DBSDataContainer; +import org.jkiss.dbeaver.model.struct.DBSEntity; +import org.jkiss.dbeaver.model.struct.DBSEntityAttribute; import org.jkiss.utils.CommonUtils; import java.util.ArrayList; @@ -38,7 +43,7 @@ public class WebSQLDataFilter { private int offset; private int limit ; private String where; - private List constraints = new ArrayList<>(); + private final List constraints = new ArrayList<>(); public WebSQLDataFilter() { this.offset = 0; @@ -85,13 +90,29 @@ public class WebSQLDataFilter { return constraints; } - public DBDDataFilter makeDataFilter() { + public String getWhere() { + return where; + } + + public DBDDataFilter makeDataFilter(DBRProgressMonitor monitor, DBSDataContainer dataContainer) throws DBException { DBDDataFilter dataFilter = new DBDDataFilter(); dataFilter.setWhere(where); if (!CommonUtils.isEmpty(constraints)) { List dbdConstraints = new ArrayList<>(); for (WebSQLDataFilterConstraint webConstr : constraints) { - DBDAttributeConstraint dbConstraint = new DBDAttributeConstraint(webConstr.getAttribute(), -1); + DBDAttributeConstraint dbConstraint; + if (dataContainer instanceof DBSEntity) { + DBSEntityAttribute attribute = ((DBSEntity) dataContainer).getAttribute(monitor, webConstr.getAttribute()); + if (attribute == null) { + dbConstraint = new DBDAttributeConstraint(webConstr.getAttribute(), -1); + } else { + dbConstraint = new DBDAttributeConstraint(attribute, -1); + } + } else { + dbConstraint = new DBDAttributeConstraint(webConstr.getAttribute(), -1); + } + dbConstraint.setPlainNameReference(true); + if (webConstr.getOrderPosition() != null) { dbConstraint.setOrderPosition(webConstr.getOrderPosition()); } diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLDialectInfo.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLDialectInfo.java index 18696a03d2..b11ddb39e2 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLDialectInfo.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLDialectInfo.java @@ -18,8 +18,10 @@ package io.cloudbeaver.service.sql; import org.jkiss.dbeaver.Log; import org.jkiss.dbeaver.model.DBPDataSource; +import org.jkiss.dbeaver.model.exec.plan.DBCQueryPlanner; import org.jkiss.dbeaver.model.sql.SQLDialect; import org.jkiss.dbeaver.model.sql.SQLUtils; +import org.jkiss.dbeaver.utils.GeneralUtils; import org.jkiss.utils.Pair; /** @@ -81,4 +83,7 @@ public class WebSQLDialectInfo { return SQLUtils.getDefaultScriptDelimiter(dialect); } + public boolean isSupportsExplainExecutionPlan() { + return GeneralUtils.adapt(dataSource, DBCQueryPlanner.class) != null; + } } diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLExecuteInfo.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLExecuteInfo.java index 07e4830f91..18e731924a 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLExecuteInfo.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLExecuteInfo.java @@ -28,6 +28,7 @@ public class WebSQLExecuteInfo { private String statusMessage; private long duration; + private String filterText; private WebSQLQueryResults[] results; @Property @@ -48,6 +49,15 @@ public class WebSQLExecuteInfo { this.duration = duration; } + @Property + public String getFilterText() { + return filterText; + } + + public void setFilterText(String filterText) { + this.filterText = filterText; + } + @Property public WebSQLQueryResults[] getResults() { return results; diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLExecutionPlan.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLExecutionPlan.java new file mode 100644 index 0000000000..20f63dec4f --- /dev/null +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLExecutionPlan.java @@ -0,0 +1,71 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2021 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cloudbeaver.service.sql; + +import io.cloudbeaver.model.session.WebSession; +import org.jkiss.dbeaver.Log; +import org.jkiss.dbeaver.model.exec.plan.DBCPlan; +import org.jkiss.dbeaver.model.exec.plan.DBCPlanNode; + +import java.util.*; + +/** + * WebSQLExecutionPlan. + */ +public class WebSQLExecutionPlan { + + private static final Log log = Log.getLog(WebSQLExecutionPlan.class); + + private final WebSession webSession; + private final DBCPlan plan; + + public WebSQLExecutionPlan(WebSession webSession, DBCPlan plan) { + this.webSession = webSession; + this.plan = plan; + } + + public String getQuery() { + return plan.getQueryString(); + } + + public WebSQLExecutionPlanNode[] getNodes() { + Map options = new LinkedHashMap<>(); + Map idMap = new IdentityHashMap<>(); + + List result = new ArrayList<>(); + + for (DBCPlanNode node : plan.getPlanNodes(options)) { + addNodeWithNested(result, null, node, idMap); + } + + return result.toArray(new WebSQLExecutionPlanNode[0]); + } + + private void addNodeWithNested(List result, DBCPlanNode parentNode, DBCPlanNode node, Map idMap) { + String parentId = idMap.get(parentNode); + String nodeId = String.valueOf(idMap.size()); + idMap.put(node, nodeId); + result.add(new WebSQLExecutionPlanNode(webSession, node, nodeId, parentId)); + Collection nested = node.getNested(); + if (nested != null) { + for (DBCPlanNode nn : nested) { + addNodeWithNested(result, node, nn, idMap); + } + } + } + +} diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLExecutionPlanNode.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLExecutionPlanNode.java new file mode 100644 index 0000000000..d16d1040c7 --- /dev/null +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLExecutionPlanNode.java @@ -0,0 +1,85 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2021 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cloudbeaver.service.sql; + +import io.cloudbeaver.model.WebPropertyInfo; +import io.cloudbeaver.model.session.WebSession; +import org.jkiss.code.NotNull; +import org.jkiss.dbeaver.Log; +import org.jkiss.dbeaver.model.exec.plan.DBCPlanNode; +import org.jkiss.dbeaver.runtime.properties.ObjectPropertyDescriptor; +import org.jkiss.dbeaver.runtime.properties.PropertyCollector; + +import java.util.Arrays; + +/** + * WebSQLExecutionPlanNode. + */ +public class WebSQLExecutionPlanNode { + + private static final Log log = Log.getLog(WebSQLExecutionPlanNode.class); + + private final WebSession webSession; + private final DBCPlanNode node; + private final String id; + private final String parentId; + + public WebSQLExecutionPlanNode(WebSession webSession, DBCPlanNode node, String id, String parentId) { + this.webSession = webSession; + this.node = node; + this.id = id; + this.parentId = parentId; + } + + public String getId() { + return id; + } + + public String getParentId() { + return parentId; + } + + public String getKind() { + return node.getNodeKind().name(); + } + + public String getName() { + return node.getNodeName(); + } + + public String getType() { + return node.getNodeType(); + } + + public String getCondition() { + return node.getNodeCondition(); + } + + public String getDescription() { + return node.getNodeDescription(); + } + + @NotNull + public WebPropertyInfo[] getProperties() { + PropertyCollector propertyCollector = new PropertyCollector(node, false); + propertyCollector.collectProperties(); + return Arrays.stream(propertyCollector.getProperties()) + .filter(p -> !(p instanceof ObjectPropertyDescriptor && ((ObjectPropertyDescriptor) p).isHidden())) + .map(p -> new WebPropertyInfo(webSession, p, propertyCollector)).toArray(WebPropertyInfo[]::new); + } + +} diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLProcessor.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLProcessor.java index 25fbfee929..c4b23379b9 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLProcessor.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLProcessor.java @@ -25,20 +25,23 @@ import org.jkiss.code.Nullable; import org.jkiss.dbeaver.DBException; import org.jkiss.dbeaver.Log; import org.jkiss.dbeaver.model.DBPDataKind; +import org.jkiss.dbeaver.model.DBPDataSource; import org.jkiss.dbeaver.model.DBUtils; -import org.jkiss.dbeaver.model.data.DBDAttributeBinding; -import org.jkiss.dbeaver.model.data.DBDDataFilter; -import org.jkiss.dbeaver.model.data.DBDDocument; -import org.jkiss.dbeaver.model.data.DBDRowIdentifier; +import org.jkiss.dbeaver.model.data.*; import org.jkiss.dbeaver.model.exec.*; +import org.jkiss.dbeaver.model.exec.plan.DBCPlan; +import org.jkiss.dbeaver.model.exec.plan.DBCQueryPlanner; +import org.jkiss.dbeaver.model.exec.plan.DBCQueryPlannerConfiguration; import org.jkiss.dbeaver.model.impl.AbstractExecutionSource; import org.jkiss.dbeaver.model.navigator.DBNDatabaseItem; import org.jkiss.dbeaver.model.navigator.DBNNode; import org.jkiss.dbeaver.model.runtime.DBRProgressMonitor; import org.jkiss.dbeaver.model.sql.SQLQuery; import org.jkiss.dbeaver.model.sql.SQLSyntaxManager; +import org.jkiss.dbeaver.model.sql.SQLUtils; import org.jkiss.dbeaver.model.sql.parser.SQLRuleManager; import org.jkiss.dbeaver.model.struct.*; +import org.jkiss.dbeaver.utils.GeneralUtils; import org.jkiss.utils.CommonUtils; import java.lang.reflect.InvocationTargetException; @@ -96,7 +99,7 @@ public class WebSQLProcessor { return ruleManager; } - DBCExecutionContext getExecutionContext() { + public DBCExecutionContext getExecutionContext() { return DBUtils.getDefaultContext(connection.getDataSource(), false); } @@ -153,22 +156,22 @@ public class WebSQLProcessor { DBCExecutionContext context = getExecutionContext(dataContainer); - { - DBDDataFilter dataFilter = filter.makeDataFilter(); - if (dataFilter.hasFilters()) { - sql = context.getDataSource().getSQLDialect().addFiltersToQuery( - monitor, - context.getDataSource(), - sql, - dataFilter); - } - } - - final WebSQLDataFilter dataFilter = filter; - final String sqlQueryText = sql; - SQLQuery sqlQuery = new SQLQuery(context.getDataSource(), sqlQueryText); - try { + { + DBDDataFilter dataFilter = filter.makeDataFilter(monitor, dataContainer); + if (dataFilter.hasFilters()) { + sql = context.getDataSource().getSQLDialect().addFiltersToQuery( + monitor, + context.getDataSource(), + sql, + dataFilter); + } + } + + final WebSQLDataFilter dataFilter = filter; + final String sqlQueryText = sql; + SQLQuery sqlQuery = new SQLQuery(context.getDataSource(), sqlQueryText); + DBExecUtils.tryExecuteRecover(monitor, connection.getDataSource(), param -> { try (DBCSession session = context.openSession(monitor, DBCExecutionPurpose.USER, "Execute SQL")) { AbstractExecutionSource source = new AbstractExecutionSource( @@ -215,7 +218,7 @@ public class WebSQLProcessor { WebSQLExecuteInfo executeInfo = new WebSQLExecuteInfo(); DBCExecutionContext executionContext = getExecutionContext(dataContainer); - DBDDataFilter dataFilter = filter.makeDataFilter(); + DBDDataFilter dataFilter = filter.makeDataFilter(monitor, dataContainer); DBExecUtils.tryExecuteRecover(monitor, connection.getDataSource(), param -> { try (DBCSession session = executionContext.openSession(monitor, DBCExecutionPurpose.USER, "Read data from container")) { try (WebSQLQueryDataReceiver dataReceiver = new WebSQLQueryDataReceiver(contextInfo, dataContainer, dataFormat)) { @@ -231,10 +234,14 @@ public class WebSQLProcessor { executeInfo.setDuration(statistics.getTotalTime()); WebSQLQueryResults results = new WebSQLQueryResults(webSession, dataFormat); - results.setResultSet(dataReceiver.getResultSet()); + WebSQLQueryResultSet resultSet = dataReceiver.getResultSet(); + results.setResultSet(resultSet); executeInfo.setResults(new WebSQLQueryResults[]{results}); + setResultFilterText(dataContainer, session.getDataSource(), executeInfo, filter); - executeInfo.setStatusMessage(dataReceiver.getResultSet().getRows().length + " row(s) fetched"); + if (resultSet != null && resultSet.getRows() != null) { + executeInfo.setStatusMessage(resultSet.getRows().length + " row(s) fetched"); + } } catch (DBException e) { throw new InvocationTargetException(e); } @@ -379,7 +386,12 @@ public class WebSQLProcessor { for (int i = 0; i < updateAttributes.length; i++) { DBDAttributeBinding updateAttribute = updateAttributes[i]; Object cellRawValue = updateValues.get(String.valueOf(updateAttribute.getOrdinalPosition())); - Object realCellValue = updateAttribute.getValueHandler().getValueFromObject(session, updateAttribute, cellRawValue, false, true); + cellRawValue = WebSQLUtils.makePlainCellValue(session, updateAttribute, cellRawValue); + Object realCellValue = cellRawValue; + // In some cases we already have final value here + if (!(realCellValue instanceof DBDValue)) { + realCellValue = updateAttribute.getValueHandler().getValueFromObject(session, updateAttribute, cellRawValue, false, true); + } rowValues[i] = realCellValue; finalRow[updateAttribute.getOrdinalPosition()] = WebSQLUtils.makeWebCellValue(webSession, null, realCellValue, dataFormat); } @@ -387,19 +399,30 @@ public class WebSQLProcessor { DBDAttributeBinding keyAttribute = keyAttributes[i]; if (keyAttributes.length == 1 && keyAttribute.getDataKind() == DBPDataKind.DOCUMENT && dataContainer instanceof DBSDocumentLocator) { // Document reference + DBDDocument document = null; Map keyMap = new LinkedHashMap<>(); DBDAttributeBinding[] attributes = resultsInfo.getAttributes(); for (int j = 0; j < attributes.length; j++) { DBDAttributeBinding attr = attributes[j]; - keyMap.put(attr.getName(), row.getData().get(j)); + Object plainValue = WebSQLUtils.makePlainCellValue(session, attr, row.getData().get(j)); + if (plainValue instanceof DBDDocument) { + // FIXME: Hack for DynamoDB. We pass entire document as a key + // FIXME: Let's just return it back for now + document = (DBDDocument) plainValue; + break; + } + keyMap.put(attr.getName(), plainValue); } - DBDDocument document = ((DBSDocumentLocator) dataContainer).findDocument(session.getProgressMonitor(), keyMap); if (document == null) { - throw new DBCException("Error finding document by key " + keyMap); + document = ((DBSDocumentLocator) dataContainer).findDocument(session.getProgressMonitor(), keyMap); + if (document == null) { + throw new DBCException("Error finding document by key " + keyMap); + } } rowValues[updateAttributes.length + i] = document; } else { Object cellValueRaw = row.getData().get(keyAttribute.getOrdinalPosition()); + cellValueRaw = WebSQLUtils.makePlainCellValue(session, keyAttribute, cellValueRaw); rowValues[updateAttributes.length + i] = keyAttribute.getValueHandler().getValueFromObject(session, keyAttribute, cellValueRaw, false, true); } } @@ -440,6 +463,51 @@ public class WebSQLProcessor { return result; } + //////////////////////////////////////////////// + // ExecutionPlan + + @NotNull + public WebSQLExecutionPlan explainExecutionPlan( + @NotNull DBRProgressMonitor monitor, + @NotNull WebSQLContextInfo contextInfo, + @NotNull String sql, + @NotNull Map configuration) throws DBWebException { + + DBCQueryPlanner planner; + DBCExecutionContext executionContext = getExecutionContext(); + if (executionContext != null) { + DBPDataSource dataSource = executionContext.getDataSource(); + planner = GeneralUtils.adapt(dataSource, DBCQueryPlanner.class); + } else { + throw new DBWebException("Not connected to data source"); + } + + if (planner == null) { + throw new DBWebException("Datasource '" + executionContext.getDataSource() + "' doesn't support execution plan"); + } + + DBCPlan[] dbcPlan = new DBCPlan[1]; + + try { + DBExecUtils.tryExecuteRecover(monitor, connection.getDataSource(), param -> { + try (DBCSession session = executionContext.openSession(monitor, DBCExecutionPurpose.USER, "Execute SQL")) { + DBCQueryPlannerConfiguration planConfig = new DBCQueryPlannerConfiguration(); + planConfig.getParameters().putAll(configuration); + dbcPlan[0] = planner.planQueryExecution(session, sql, planConfig); + } catch (DBException e) { + throw new InvocationTargetException(e); + } + }); + } catch (DBException e) { + throw new DBWebException("Error explaining execution plan", e); + } + + return new WebSQLExecutionPlan(webSession, dbcPlan[0]); + } + + //////////////////////////////////////////////// + // Misc + private void checkRowIdentifier(WebSQLResultsInfo resultsInfo, DBDRowIdentifier rowIdentifier) throws DBWebException { if (rowIdentifier == null || !rowIdentifier.isValidIdentifier()) { throw new DBWebException("Can't detect row identifier for data container '" + resultsInfo.getDataContainer().getName() + "'. It must have at least one unique key."); @@ -470,7 +538,8 @@ public class WebSQLProcessor { private void fillQueryResults( - @NotNull WebSQLContextInfo contextInfo, @NotNull DBSDataContainer dataContainer, + @NotNull WebSQLContextInfo contextInfo, + @NotNull DBSDataContainer dataContainer, @NotNull DBCStatement dbStat, boolean hasResultSet, @NotNull WebSQLExecuteInfo executeInfo, @@ -490,7 +559,7 @@ public class WebSQLProcessor { results.setResultSet(dataReceiver.getResultSet()); } } else { - int updateRowCount = dbStat.getUpdateRowCount(); + long updateRowCount = dbStat.getUpdateRowCount(); if (updateRowCount >= 0) { results.setUpdateRowCount(updateRowCount); } else { @@ -502,6 +571,21 @@ public class WebSQLProcessor { } executeInfo.setResults(resultList.toArray(new WebSQLQueryResults[0])); + + setResultFilterText(dataContainer, dbStat.getSession().getDataSource(), executeInfo, filter); + } + + private void setResultFilterText(@NotNull DBSDataContainer dataContainer, @NotNull DBPDataSource dataSource, @NotNull WebSQLExecuteInfo executeInfo, @NotNull WebSQLDataFilter filter) throws DBException { + if (!filter.getConstraints().isEmpty() || !CommonUtils.isEmpty(filter.getWhere())) { + StringBuilder where = new StringBuilder(); + SQLUtils.appendConditionString( + filter.makeDataFilter(webSession.getProgressMonitor(), dataContainer), + dataSource, + null, + where, + true); + executeInfo.setFilterText(where.toString()); + } } private void readResultSet(@NotNull DBCSession session, @NotNull DBCResultSet dbResult, @NotNull WebSQLDataFilter filter, @NotNull WebSQLQueryDataReceiver dataReceiver) throws DBCException { diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLQueryDataContainer.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLQueryDataContainer.java index 6cc63404af..46f167dce8 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLQueryDataContainer.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLQueryDataContainer.java @@ -40,9 +40,9 @@ public class WebSQLQueryDataContainer implements DBSDataContainer, DBPContextPro private static final Log log = Log.getLog(WebSQLQueryDataContainer.class); - private DBPDataSource dataSource; - private String query; - private DBSDataContainer queryDataContainer; + private final DBPDataSource dataSource; + private final String query; + private final DBSDataContainer queryDataContainer; public WebSQLQueryDataContainer(DBPDataSource dataSource, String query) { this.dataSource = dataSource; diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLQueryResultColumn.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLQueryResultColumn.java index 84520db52c..64dc7db7d1 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLQueryResultColumn.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLQueryResultColumn.java @@ -21,6 +21,7 @@ import org.jkiss.dbeaver.model.DBPDataKind; import org.jkiss.dbeaver.model.DBPEvaluationContext; import org.jkiss.dbeaver.model.DBValueFormatting; import org.jkiss.dbeaver.model.data.DBDAttributeBinding; +import org.jkiss.dbeaver.model.exec.DBCLogicalOperator; import org.jkiss.dbeaver.model.exec.DBExecUtils; import org.jkiss.dbeaver.model.meta.Property; @@ -99,6 +100,11 @@ public class WebSQLQueryResultColumn { return attrMeta.getPrecision(); } + @Property + public boolean isRequired() { + return attrMeta.isRequired(); + } + @Property public boolean isReadOnly() { return DBExecUtils.isAttributeReadOnly(attrMeta); @@ -109,6 +115,11 @@ public class WebSQLQueryResultColumn { return DBExecUtils.getAttributeReadOnlyStatus(attrMeta); } + @Property + public DBCLogicalOperator[] getSupportedOperations() { + return attrMeta.getValueHandler().getSupportedOperators(attrMeta); + } + @Override public String toString() { return attrMeta.getName(); diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLUtils.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLUtils.java index bcf7abc17e..84700de9b4 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLUtils.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLUtils.java @@ -17,11 +17,13 @@ package io.cloudbeaver.service.sql; import io.cloudbeaver.model.session.WebSession; +import io.cloudbeaver.registry.WebServiceRegistry; import io.cloudbeaver.server.CBConstants; import org.jkiss.code.NotNull; import org.jkiss.dbeaver.Log; import org.jkiss.dbeaver.model.data.*; import org.jkiss.dbeaver.model.exec.DBCException; +import org.jkiss.dbeaver.model.exec.DBCSession; import org.jkiss.dbeaver.model.gis.DBGeometry; import org.jkiss.dbeaver.model.gis.GisConstants; import org.jkiss.dbeaver.model.gis.GisTransformUtils; @@ -29,9 +31,11 @@ import org.jkiss.dbeaver.model.struct.DBSAttributeBase; import org.jkiss.dbeaver.model.struct.DBSTypedObject; import org.jkiss.dbeaver.utils.ContentUtils; import org.jkiss.dbeaver.utils.GeneralUtils; +import org.jkiss.utils.Base64; import org.jkiss.utils.CommonUtils; import java.io.ByteArrayOutputStream; +import java.math.BigDecimal; import java.nio.charset.StandardCharsets; import java.util.*; @@ -42,11 +46,18 @@ public class WebSQLUtils { private static final Log log = Log.getLog(WebSQLUtils.class); - public static final int BINARY_PREVIEW_LENGTH = 255; - public static Object makeWebCellValue(WebSession session, DBSTypedObject type, Object cellValue, WebDataFormat dataFormat) throws DBCException { if (cellValue instanceof Date) { return CBConstants.ISO_DATE_FORMAT.format(cellValue); + } else if (cellValue instanceof Number) { + if (cellValue instanceof Double) { + return CommonUtils.niceFormatDouble((Double) cellValue); + } else if (cellValue instanceof Float) { + return CommonUtils.niceFormatDouble((Float) cellValue); + } else if (cellValue instanceof BigDecimal) { + return ((BigDecimal) cellValue).toPlainString(); + } + return cellValue.toString(); } if (cellValue instanceof DBDValue) { DBDValue dbValue = (DBDValue) cellValue; @@ -75,7 +86,7 @@ public class WebSQLUtils { items[i] = makeWebCellValue(session, collection.getComponentType(), collection.getItem(i), dataFormat); } - Map map = createMapOfType("collection"); + Map map = createMapOfType(WebSQLConstants.VALUE_TYPE_COLLECTION); map.put("value", items); return map; } else if (value instanceof DBDComposite) { @@ -85,7 +96,7 @@ public class WebSQLUtils { struct.put(attr.getName(), makeWebCellValue(session, attr, composite.getAttributeValue(attr), dataFormat)); } - Map map = createMapOfType("map"); + Map map = createMapOfType(WebSQLConstants.VALUE_TYPE_MAP); map.put("value", struct); return map; } @@ -95,7 +106,7 @@ public class WebSQLUtils { @NotNull private static Map createMapOfType(String type) { Map map = new LinkedHashMap<>(); - map.put("$type", type); + map.put(WebSQLConstants.VALUE_TYPE_ATTR, type); return map; } @@ -109,30 +120,37 @@ public class WebSQLUtils { throw new DBCException("Error serializing document", e); } - Map map = createMapOfType("document"); + Map map = createMapOfType(WebSQLConstants.VALUE_TYPE_DOCUMENT); map.put("id", CommonUtils.toString(document.getDocumentId())); map.put("contentType", document.getDocumentContentType()); map.put("properties", Collections.emptyMap()); - map.put("data", documentData); + map.put(WebSQLConstants.ATTR_DATA, documentData); return map; } private static Object serializeContentValue(WebSession session, DBDContent value) throws DBCException { - Map map = createMapOfType("content"); + Map map = createMapOfType(WebSQLConstants.VALUE_TYPE_CONTENT); if (ContentUtils.isTextContent(value)) { String stringValue = ContentUtils.getContentStringValue(session.getProgressMonitor(), value); - map.put("text", stringValue); + map.put(WebSQLConstants.ATTR_TEXT, stringValue); } else { - map.put("binary", true); + map.put(WebSQLConstants.ATTR_BINARY, true); byte[] binaryValue = ContentUtils.getContentBinaryValue(session.getProgressMonitor(), value); if (binaryValue != null) { - if (binaryValue.length > BINARY_PREVIEW_LENGTH) { - binaryValue = Arrays.copyOf(binaryValue, BINARY_PREVIEW_LENGTH); + byte[] previewValue = binaryValue; + if (previewValue.length > WebSQLConstants.BINARY_PREVIEW_LENGTH) { + previewValue = Arrays.copyOf(previewValue, WebSQLConstants.BINARY_PREVIEW_LENGTH); } - map.put("text", GeneralUtils.convertToString(binaryValue, 0, binaryValue.length)); + map.put(WebSQLConstants.ATTR_TEXT, GeneralUtils.convertToString(previewValue, 0, previewValue.length)); + + byte[] inlineValue = binaryValue; + if (inlineValue.length > WebSQLConstants.BINARY_MAX_LENGTH) { + inlineValue = Arrays.copyOf(inlineValue, WebSQLConstants.BINARY_PREVIEW_LENGTH); + } + map.put(WebSQLConstants.ATTR_BINARY, Base64.encode(inlineValue)); } else { - map.put("text", null); + map.put(WebSQLConstants.ATTR_TEXT, null); } } map.put("contentType", value.getContentType()); @@ -141,9 +159,9 @@ public class WebSQLUtils { } private static Object serializeGeometryValue(DBGeometry value) { - Map map = createMapOfType("geometry"); + Map map = createMapOfType(WebSQLConstants.VALUE_TYPE_GEOMETRY); map.put("srid", value.getSRID()); - map.put("text", value.toString()); + map.put(WebSQLConstants.ATTR_TEXT, value.toString()); map.put("properties", value.getProperties()); DBGeometry xValue = GisTransformUtils.transformToSRID(value, GisConstants.SRID_4326); @@ -153,4 +171,29 @@ public class WebSQLUtils { return map; } + public static Object makePlainCellValue(DBCSession session, DBSTypedObject attribute, Object value) throws DBCException { + if (value instanceof Map) { + Map map = (Map) value; + Object typeAttr = map.get(WebSQLConstants.VALUE_TYPE_ATTR); + if (typeAttr instanceof String) { + switch ((String)typeAttr) { + case WebSQLConstants.VALUE_TYPE_CONTENT: { + if (map.get(WebSQLConstants.ATTR_BINARY) != null) { + throw new DBCException("Binary content edit is not supported yet"); + } + value = map.get(WebSQLConstants.ATTR_TEXT); + break; + } + default: { + DBWValueSerializer valueSerializer = WebServiceRegistry.getInstance().createValueSerializer((String) typeAttr); + if (valueSerializer == null) { + throw new DBCException("Value type '" + typeAttr + "' edit is not supported yet"); + } + value = valueSerializer.deserializeValue(session, attribute, map); + } + } + } + } + return value; + } } diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebServiceBindingSQL.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebServiceBindingSQL.java index 652205c81b..e186b7af0f 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebServiceBindingSQL.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebServiceBindingSQL.java @@ -28,6 +28,7 @@ import io.cloudbeaver.service.sql.impl.WebServiceSQL; import org.eclipse.jetty.servlet.ServletContextHandler; import org.eclipse.jetty.servlet.ServletHolder; import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; import org.jkiss.dbeaver.DBException; import org.jkiss.utils.CommonUtils; @@ -51,14 +52,27 @@ public class WebServiceBindingSQL extends WebServiceBindingBase i .dataFetcher("sqlDialectInfo", env -> getService(env).getDialectInfo(getSQLProcessor(env)) ) + .dataFetcher("sqlListContexts", env -> + getService(env).listContexts(getWebSession(env), + env.getArgument("connectionId"), + env.getArgument("contextId")) + ) .dataFetcher("sqlCompletionProposals", env -> getService(env).getCompletionProposals( getSQLContext(env), env.getArgument("query"), env.getArgument("position"), - env.getArgument("maxResults") + env.getArgument("maxResults"), + env.getArgument("simpleMode") ) - ); + ) + .dataFetcher("sqlSupportedOperations", env -> + getService(env).getSupportedOperations( + getSQLContext(env), + env.getArgument("resultsId"), + env.getArgument("attributeIndex")) + ) + ; model.getMutationType() .dataFetcher("sqlContextCreate", env -> getService(env).createContext( @@ -111,6 +125,16 @@ public class WebServiceBindingSQL extends WebServiceBindingBase i .dataFetcher("asyncSqlExecuteResults", env -> getService(env).asyncGetQueryResults( getWebSession(env), env.getArgument("taskId") + )) + .dataFetcher("asyncSqlExplainExecutionPlan", env -> + getService(env).asyncSqlExplainExecutionPlan( + getSQLContext(env), + env.getArgument("query"), + env.getArgument("configuration") + )) + .dataFetcher("asyncSqlExplainExecutionPlanResult", env -> + getService(env).asyncSqlExplainExecutionPlanResult( + getWebSession(env), env.getArgument("taskId") )); } @@ -136,6 +160,11 @@ public class WebServiceBindingSQL extends WebServiceBindingBase i return getSQLConfiguration(connectionInfo.getSession()).getSQLProcessor(connectionInfo); } + @Nullable + public static WebSQLProcessor getSQLProcessor(WebConnectionInfo connectionInfo, boolean connect) throws DBWebException { + return getSQLConfiguration(connectionInfo.getSession()).getSQLProcessor(connectionInfo, connect); + } + @NotNull public static WebSQLContextInfo getSQLContext(DataFetchingEnvironment env) throws DBWebException { WebSQLProcessor processor = getSQLProcessor(env); @@ -163,7 +192,14 @@ public class WebServiceBindingSQL extends WebServiceBindingBase i private final Map processors = new HashMap<>(); WebSQLProcessor getSQLProcessor(WebConnectionInfo connectionInfo) throws DBWebException { + return WebServiceBindingSQL.getSQLProcessor(connectionInfo, true); + } + + WebSQLProcessor getSQLProcessor(WebConnectionInfo connectionInfo, boolean connect) throws DBWebException { if (connectionInfo.getDataSource() == null) { + if (!connect) { + return null; + } try { connectionInfo.getDataSourceContainer().connect(connectionInfo.getSession().getProgressMonitor(), true, false); } catch (DBException e) { diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/impl/WebServiceSQL.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/impl/WebServiceSQL.java index 4be9119870..03bfcfda16 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/impl/WebServiceSQL.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/impl/WebServiceSQL.java @@ -20,15 +20,19 @@ package io.cloudbeaver.service.sql.impl; import io.cloudbeaver.DBWebException; import io.cloudbeaver.WebAction; import io.cloudbeaver.model.WebAsyncTaskInfo; +import io.cloudbeaver.model.WebConnectionInfo; import io.cloudbeaver.model.session.WebAsyncTaskProcessor; import io.cloudbeaver.model.session.WebSession; +import io.cloudbeaver.service.WebServiceBindingBase; import io.cloudbeaver.service.sql.*; import org.eclipse.jface.text.Document; import org.jkiss.code.NotNull; import org.jkiss.code.Nullable; import org.jkiss.dbeaver.DBException; import org.jkiss.dbeaver.model.DBPDataSource; +import org.jkiss.dbeaver.model.data.DBDAttributeBinding; import org.jkiss.dbeaver.model.exec.DBCException; +import org.jkiss.dbeaver.model.exec.DBCLogicalOperator; import org.jkiss.dbeaver.model.runtime.DBRProgressMonitor; import org.jkiss.dbeaver.model.sql.SQLDialect; import org.jkiss.dbeaver.model.sql.SQLQuery; @@ -37,9 +41,14 @@ import org.jkiss.dbeaver.model.sql.SQLUtils; import org.jkiss.dbeaver.model.sql.completion.SQLCompletionAnalyzer; import org.jkiss.dbeaver.model.sql.completion.SQLCompletionProposalBase; import org.jkiss.dbeaver.model.sql.completion.SQLCompletionRequest; +import org.jkiss.dbeaver.model.sql.parser.SQLParserContext; +import org.jkiss.dbeaver.model.sql.parser.SQLScriptParser; import org.jkiss.dbeaver.model.struct.DBSDataContainer; +import org.jkiss.utils.CommonUtils; import java.lang.reflect.InvocationTargetException; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Map; @@ -48,6 +57,30 @@ import java.util.Map; */ public class WebServiceSQL implements DBWServiceSQL { + @Override + public WebSQLContextInfo[] listContexts(@NotNull WebSession session, @Nullable String connectionId, @Nullable String contextId) throws DBWebException { + List conToRead = new ArrayList<>(); + if (connectionId != null) { + WebConnectionInfo webConnection = WebServiceBindingBase.getWebConnection(session, connectionId); + conToRead.add(webConnection); + } else { + conToRead.addAll(session.getConnections()); + } + + List contexts = new ArrayList<>(); + for (WebConnectionInfo con : conToRead) { + WebSQLProcessor sqlProcessor = WebServiceBindingSQL.getSQLProcessor(con, false); + if (sqlProcessor != null) { + WebSQLContextInfo[] conContexts = sqlProcessor.getContexts(); + contexts.addAll(Arrays.asList(conContexts)); + } + } + if (contextId != null) { + contexts.removeIf(c -> !c.getId().equals(contextId)); + } + return contexts.toArray(new WebSQLContextInfo[0]); + } + @Override @NotNull public WebSQLDialectInfo getDialectInfo(@NotNull WebSQLProcessor processor) throws DBWebException { @@ -57,19 +90,43 @@ public class WebServiceSQL implements DBWServiceSQL { } @NotNull - public WebSQLCompletionProposal[] getCompletionProposals(@NotNull WebSQLContextInfo sqlContext, @NotNull String query, Integer position, Integer maxResults) throws DBWebException { + public WebSQLCompletionProposal[] getCompletionProposals( + @NotNull WebSQLContextInfo sqlContext, + @NotNull String query, + Integer position, + Integer maxResults, + Boolean simpleMode) throws DBWebException + { try { DBPDataSource dataSource = sqlContext.getProcessor().getConnection().getDataSourceContainer().getDataSource(); + Document document = new Document(); document.set(query); - SQLScriptElement activeQuery = new SQLQuery(dataSource, query); + + WebSQLCompletionContext completionContext = new WebSQLCompletionContext(sqlContext); + + SQLScriptElement activeQuery; + + if (position != null) { + SQLParserContext parserContext = new SQLParserContext( + () -> sqlContext.getProcessor().getExecutionContext(), + completionContext.getSyntaxManager(), + completionContext.getRuleManager(), + document); + activeQuery = SQLScriptParser.extractActiveQuery(parserContext, position, 0); + } else { + activeQuery = new SQLQuery(dataSource, query); + } + + SQLCompletionRequest request = new SQLCompletionRequest( - new WebSQLCompletionContext(sqlContext), + completionContext, document, position == null ? 0 : position, activeQuery, - false + CommonUtils.getBoolean(simpleMode, false) ); + SQLCompletionAnalyzer analyzer = new SQLCompletionAnalyzer(request); analyzer.runAnalyzer(sqlContext.getProcessor().getWebSession().getProgressMonitor()); List proposals = analyzer.getProposals(); @@ -88,6 +145,16 @@ public class WebServiceSQL implements DBWServiceSQL { } } + @Override + public DBCLogicalOperator[] getSupportedOperations(@NotNull WebSQLContextInfo contextInfo, @NotNull String resultsId, int attributeIndex) throws DBWebException { + WebSQLResultsInfo results = contextInfo.getResults(resultsId); + if (attributeIndex < 0 || attributeIndex >= results.getAttributes().length) { + throw new DBWebException("Invalid attribute index (" + attributeIndex + ")"); + } + DBDAttributeBinding attribute = results.getAttributes()[attributeIndex]; + return attribute.getValueHandler().getSupportedOperators(attribute); + } + @Override public WebSQLContextInfo createContext(@NotNull WebSQLProcessor processor, String defaultCatalog, String defaultSchema) throws DBWebException { try { @@ -199,5 +266,36 @@ public class WebServiceSQL implements DBWServiceSQL { return null; } + //////////////////////////////////////////////////// + // Explain plan + @Override + public WebAsyncTaskInfo asyncSqlExplainExecutionPlan(@NotNull WebSQLContextInfo contextInfo, @NotNull String sql, @NotNull Map configuration) throws DBException { + WebAsyncTaskProcessor runnable = new WebAsyncTaskProcessor() { + @Override + public void run(DBRProgressMonitor monitor) throws InvocationTargetException, InterruptedException { + try { + monitor.beginTask("Explain execution plan", 1); + monitor.subTask("Explain query [" + sql + "] execution plan"); + WebSQLExecutionPlan executeResults = contextInfo.getProcessor().explainExecutionPlan(monitor, contextInfo, sql, configuration); + this.result = "Execution plan explain has been scheduled"; + this.extendedResults = executeResults; + } catch (Throwable e) { + throw new InvocationTargetException(e); + } finally { + monitor.done(); + } + } + }; + return contextInfo.getProcessor().getWebSession().createAndRunAsyncTask("SQL query execution plan explain", runnable); + } + + @Override + public WebSQLExecutionPlan asyncSqlExplainExecutionPlanResult(@NotNull WebSession webSession, @NotNull String taskId) throws DBWebException { + WebAsyncTaskInfo taskStatus = webSession.asyncTaskStatus(taskId, false); + if (taskStatus != null) { + return (WebSQLExecutionPlan) taskStatus.getExtendedResult(); + } + return null; + } } diff --git a/server/bundles/io.cloudbeaver.service.admin/META-INF/MANIFEST.MF b/server/bundles/io.cloudbeaver.service.admin/META-INF/MANIFEST.MF index 463323d472..11d65f8592 100644 --- a/server/bundles/io.cloudbeaver.service.admin/META-INF/MANIFEST.MF +++ b/server/bundles/io.cloudbeaver.service.admin/META-INF/MANIFEST.MF @@ -3,8 +3,8 @@ Bundle-ManifestVersion: 2 Bundle-Vendor: %Bundle-Vendor Bundle-Name: %Bundle-Name Bundle-SymbolicName: io.cloudbeaver.service.admin;singleton:=true -Bundle-Version: 1.0.21.qualifier -Bundle-Release-Date: 20210405 +Bundle-Version: 1.0.29.qualifier +Bundle-Release-Date: 20210802 Bundle-RequiredExecutionEnvironment: JavaSE-1.8 Bundle-ActivationPolicy: lazy Bundle-ClassPath: . diff --git a/server/bundles/io.cloudbeaver.service.admin/pom.xml b/server/bundles/io.cloudbeaver.service.admin/pom.xml index 66fc3d2657..b6fb407666 100644 --- a/server/bundles/io.cloudbeaver.service.admin/pom.xml +++ b/server/bundles/io.cloudbeaver.service.admin/pom.xml @@ -10,7 +10,7 @@ ../ io.cloudbeaver.service.admin - 1.0.21-SNAPSHOT + 1.0.29-SNAPSHOT eclipse-plugin diff --git a/server/bundles/io.cloudbeaver.service.admin/schema/service.admin.graphqls b/server/bundles/io.cloudbeaver.service.admin/schema/service.admin.graphqls index c3c02bbe76..468655d8a0 100644 --- a/server/bundles/io.cloudbeaver.service.admin/schema/service.admin.graphqls +++ b/server/bundles/io.cloudbeaver.service.admin/schema/service.admin.graphqls @@ -26,12 +26,15 @@ type AdminUserInfo { grantedRoles: [ID!]! grantedConnections: [AdminConnectionGrantInfo!]! - origin: ObjectOrigin! + origins: [ObjectOrigin!]! + + linkedAuthProviders: [String!]! } type AdminRoleInfo { roleId: ID! roleName: String + description: String rolePermissions: [ID]! } @@ -47,6 +50,7 @@ type AdminPermissionInfo { input ServerConfigInput { serverName: String + serverURL: String adminName: String adminPassword: String @@ -73,7 +77,8 @@ extend type Query { createUser(userId: ID!): AdminUserInfo! deleteUser(userId: ID!): Boolean - createRole(roleId: ID!): AdminRoleInfo! + createRole(roleId: ID!, roleName: String, description: String): AdminRoleInfo! + updateRole(roleId: ID!, roleName: String, description: String): AdminRoleInfo! deleteRole(roleId: ID!): Boolean grantUserRole(userId: ID!, roleId: ID!): Boolean diff --git a/server/bundles/io.cloudbeaver.service.admin/src/io/cloudbeaver/service/admin/AdminRoleInfo.java b/server/bundles/io.cloudbeaver.service.admin/src/io/cloudbeaver/service/admin/AdminRoleInfo.java index 08b01d1e58..1d6257df20 100644 --- a/server/bundles/io.cloudbeaver.service.admin/src/io/cloudbeaver/service/admin/AdminRoleInfo.java +++ b/server/bundles/io.cloudbeaver.service.admin/src/io/cloudbeaver/service/admin/AdminRoleInfo.java @@ -40,6 +40,10 @@ public class AdminRoleInfo { return role.getName(); } + public String getDescription() { + return role.getDescription(); + } + public List getRolePermissions() { return rolePermissions; } diff --git a/server/bundles/io.cloudbeaver.service.admin/src/io/cloudbeaver/service/admin/AdminServerConfig.java b/server/bundles/io.cloudbeaver.service.admin/src/io/cloudbeaver/service/admin/AdminServerConfig.java index db231e48ce..b9f23beee5 100644 --- a/server/bundles/io.cloudbeaver.service.admin/src/io/cloudbeaver/service/admin/AdminServerConfig.java +++ b/server/bundles/io.cloudbeaver.service.admin/src/io/cloudbeaver/service/admin/AdminServerConfig.java @@ -30,20 +30,22 @@ import java.util.Map; public class AdminServerConfig { private String serverName; + private String serverURL; private String adminName; private String adminPassword; - private boolean anonymousAccessEnabled; - private boolean customConnectionsEnabled; - private boolean publicCredentialsSaveEnabled; - private boolean adminCredentialsSaveEnabled; + private final boolean anonymousAccessEnabled; + private final boolean customConnectionsEnabled; + private final boolean publicCredentialsSaveEnabled; + private final boolean adminCredentialsSaveEnabled; private final List enabledAuthProviders; private long sessionExpireTime; public AdminServerConfig(Map params) { this.serverName = JSONUtils.getString(params, "serverName"); + this.serverURL = JSONUtils.getString(params, "serverURL"); this.adminName = JSONUtils.getString(params, "adminName"); this.adminPassword = JSONUtils.getString(params, "adminPassword"); @@ -69,6 +71,14 @@ public class AdminServerConfig { this.serverName = serverName; } + public String getServerURL() { + return serverURL; + } + + public void setServerURL(String serverURL) { + this.serverURL = serverURL; + } + public String getAdminName() { return adminName; } diff --git a/server/bundles/io.cloudbeaver.service.admin/src/io/cloudbeaver/service/admin/AdminUserInfo.java b/server/bundles/io.cloudbeaver.service.admin/src/io/cloudbeaver/service/admin/AdminUserInfo.java index e874084dbd..c2ee9f52aa 100644 --- a/server/bundles/io.cloudbeaver.service.admin/src/io/cloudbeaver/service/admin/AdminUserInfo.java +++ b/server/bundles/io.cloudbeaver.service.admin/src/io/cloudbeaver/service/admin/AdminUserInfo.java @@ -16,21 +16,22 @@ */ package io.cloudbeaver.service.admin; -import io.cloudbeaver.DBWAuthProviderExternal; import io.cloudbeaver.DBWConnectionGrant; -import io.cloudbeaver.auth.provider.local.LocalAuthProvider; +import io.cloudbeaver.DBWebException; import io.cloudbeaver.model.session.WebSession; import io.cloudbeaver.model.user.WebRole; import io.cloudbeaver.model.user.WebUser; import io.cloudbeaver.model.user.WebUserOriginInfo; import io.cloudbeaver.registry.WebAuthProviderDescriptor; import io.cloudbeaver.registry.WebServiceRegistry; +import io.cloudbeaver.server.CBApplication; import io.cloudbeaver.server.CBPlatform; import org.jkiss.dbeaver.Log; import org.jkiss.dbeaver.model.exec.DBCException; import org.jkiss.dbeaver.model.meta.Property; -import org.jkiss.utils.CommonUtils; +import java.util.ArrayList; +import java.util.List; import java.util.Map; /** @@ -42,6 +43,8 @@ public class AdminUserInfo { private final WebSession session; private final WebUser user; + private String[] userLinkedProviders; + public AdminUserInfo(WebSession session, WebUser user) { this.session = session; @@ -78,16 +81,34 @@ public class AdminUserInfo { } @Property - public WebUserOriginInfo getOrigin() { - String providerId = user.getMetaParameter(DBWAuthProviderExternal.META_AUTH_PROVIDER); - if (CommonUtils.isEmpty(providerId)) { - providerId = LocalAuthProvider.PROVIDER_ID; + public WebUserOriginInfo[] getOrigins() throws DBWebException { + List result = new ArrayList<>(); + for (String provider : getUserLinkedProviders()) { + WebAuthProviderDescriptor authProvider = WebServiceRegistry.getInstance().getAuthProvider(provider); + if (authProvider == null) { + log.error("Auth provider '" + provider + "' not found"); + } else { + result.add(new WebUserOriginInfo(session, user, authProvider)); + } } - WebAuthProviderDescriptor authProvider = WebServiceRegistry.getInstance().getAuthProvider(providerId); - if (authProvider == null) { - log.error("Auth provider '" + providerId + "' not found"); + return result.toArray(new WebUserOriginInfo[0]); + } + + @Property + public String[] getLinkedAuthProviders() throws DBWebException { + return getUserLinkedProviders(); + } + + private String[] getUserLinkedProviders() throws DBWebException { + if (userLinkedProviders != null) { + return userLinkedProviders; } - return new WebUserOriginInfo(session, user, authProvider); + try { + userLinkedProviders = CBApplication.getInstance().getSecurityController().getUserLinkedProviders(user.getUserId()); + } catch (DBCException e) { + throw new DBWebException("Error reading user linked providers", e); + } + return userLinkedProviders; } } diff --git a/server/bundles/io.cloudbeaver.service.admin/src/io/cloudbeaver/service/admin/DBWServiceAdmin.java b/server/bundles/io.cloudbeaver.service.admin/src/io/cloudbeaver/service/admin/DBWServiceAdmin.java index 7862e8cb01..5d278c0cf4 100644 --- a/server/bundles/io.cloudbeaver.service.admin/src/io/cloudbeaver/service/admin/DBWServiceAdmin.java +++ b/server/bundles/io.cloudbeaver.service.admin/src/io/cloudbeaver/service/admin/DBWServiceAdmin.java @@ -53,7 +53,11 @@ public interface DBWServiceAdmin extends DBWService { @WebAction(requirePermissions = DBWConstants.PERMISSION_ADMIN) @NotNull - AdminRoleInfo createRole(@NotNull WebSession webSession, String roleId) throws DBWebException; + AdminRoleInfo createRole(@NotNull WebSession webSession, String roleId, String roleName, String description) throws DBWebException; + + @WebAction(requirePermissions = DBWConstants.PERMISSION_ADMIN) + @NotNull + AdminRoleInfo updateRole(@NotNull WebSession webSession, String roleId, String roleName, String description) throws DBWebException; @WebAction(requirePermissions = DBWConstants.PERMISSION_ADMIN) boolean deleteRole(@NotNull WebSession webSession, String roleId) throws DBWebException; diff --git a/server/bundles/io.cloudbeaver.service.admin/src/io/cloudbeaver/service/admin/WebServiceBindingAdmin.java b/server/bundles/io.cloudbeaver.service.admin/src/io/cloudbeaver/service/admin/WebServiceBindingAdmin.java index 2ff69d6d0a..382eeee62e 100644 --- a/server/bundles/io.cloudbeaver.service.admin/src/io/cloudbeaver/service/admin/WebServiceBindingAdmin.java +++ b/server/bundles/io.cloudbeaver.service.admin/src/io/cloudbeaver/service/admin/WebServiceBindingAdmin.java @@ -53,7 +53,17 @@ public class WebServiceBindingAdmin extends WebServiceBindingBase getService(env).deleteUser(getWebSession(env), env.getArgument("userId"))) .dataFetcher("createRole", - env -> getService(env).createRole(getWebSession(env), env.getArgument("roleId"))) + env -> getService(env).createRole( + getWebSession(env), + env.getArgument("roleId"), + env.getArgument("roleName"), + env.getArgument("description"))) + .dataFetcher("updateRole", + env -> getService(env).createRole( + getWebSession(env), + env.getArgument("roleId"), + env.getArgument("roleName"), + env.getArgument("description"))) .dataFetcher("deleteRole", env -> getService(env).deleteRole(getWebSession(env), env.getArgument("roleId"))) diff --git a/server/bundles/io.cloudbeaver.service.admin/src/io/cloudbeaver/service/admin/impl/ConnectionSearcher.java b/server/bundles/io.cloudbeaver.service.admin/src/io/cloudbeaver/service/admin/impl/ConnectionSearcher.java index b94b8c34ce..a11cb8c358 100644 --- a/server/bundles/io.cloudbeaver.service.admin/src/io/cloudbeaver/service/admin/impl/ConnectionSearcher.java +++ b/server/bundles/io.cloudbeaver.service.admin/src/io/cloudbeaver/service/admin/impl/ConnectionSearcher.java @@ -16,7 +16,6 @@ */ package io.cloudbeaver.service.admin.impl; -import io.cloudbeaver.WebServiceUtils; import io.cloudbeaver.model.session.WebSession; import io.cloudbeaver.server.CBPlatform; import io.cloudbeaver.service.admin.AdminConnectionSearchInfo; @@ -135,7 +134,7 @@ public class ConnectionSearcher implements DBRRunnableWithProgress { } AdminConnectionSearchInfo searchInfo = portCache.get(driverPort); - String driverId = WebServiceUtils.makeDriverFullId(driver); + String driverId = driver.getFullId(); if (searchInfo != null) { searchInfo.getPossibleDrivers().add(driverId); return; diff --git a/server/bundles/io.cloudbeaver.service.admin/src/io/cloudbeaver/service/admin/impl/WebServiceAdmin.java b/server/bundles/io.cloudbeaver.service.admin/src/io/cloudbeaver/service/admin/impl/WebServiceAdmin.java index 8e1b10f27c..1f151c7dcf 100644 --- a/server/bundles/io.cloudbeaver.service.admin/src/io/cloudbeaver/service/admin/impl/WebServiceAdmin.java +++ b/server/bundles/io.cloudbeaver.service.admin/src/io/cloudbeaver/service/admin/impl/WebServiceAdmin.java @@ -137,12 +137,14 @@ public class WebServiceAdmin implements DBWServiceAdmin { @NotNull @Override - public AdminRoleInfo createRole(@NotNull WebSession webSession, String roleId) throws DBWebException { + public AdminRoleInfo createRole(@NotNull WebSession webSession, String roleId, String roleName, String description) throws DBWebException { if (roleId.isEmpty()) { throw new DBWebException("Empty role ID"); } try { WebRole newRole = new WebRole(roleId); + newRole.setName(roleName); + newRole.setDescription(description); CBPlatform.getInstance().getApplication().getSecurityController().createRole(newRole); return new AdminRoleInfo(newRole); } catch (Exception e) { @@ -150,6 +152,23 @@ public class WebServiceAdmin implements DBWServiceAdmin { } } + @NotNull + @Override + public AdminRoleInfo updateRole(@NotNull WebSession webSession, String roleId, String roleName, String description) throws DBWebException { + if (roleId.isEmpty()) { + throw new DBWebException("Empty role ID"); + } + try { + WebRole newRole = new WebRole(roleId); + newRole.setName(roleName); + newRole.setDescription(description); + CBPlatform.getInstance().getApplication().getSecurityController().updateRole(newRole); + return new AdminRoleInfo(newRole); + } catch (Exception e) { + throw new DBWebException("Error updating role " + roleId, e); + } + } + @Override public boolean deleteRole(@NotNull WebSession webSession, String roleId) throws DBWebException { try { @@ -396,8 +415,11 @@ public class WebServiceAdmin implements DBWServiceAdmin { } } + boolean configurationMode = CBApplication.getInstance().isConfigurationMode(); + CBApplication.getInstance().finishConfiguration( config.getServerName(), + config.getServerURL(), adminName, adminPassword, authInfoList, @@ -405,7 +427,13 @@ public class WebServiceAdmin implements DBWServiceAdmin { appConfig); // Refresh active session - webSession.forceUserRefresh(null); + if (configurationMode) { + // In config mode we always refresh because admin user doesn't exist yet + webSession.forceUserRefresh(null); + } else { + // Just reload session state + webSession.forceUserRefresh(webSession.getUser()); + } } catch (Throwable e) { throw new DBWebException("Error configuring server", e); } diff --git a/server/bundles/io.cloudbeaver.service.auth/META-INF/MANIFEST.MF b/server/bundles/io.cloudbeaver.service.auth/META-INF/MANIFEST.MF index fbd4ce883f..b9fc9d96de 100644 --- a/server/bundles/io.cloudbeaver.service.auth/META-INF/MANIFEST.MF +++ b/server/bundles/io.cloudbeaver.service.auth/META-INF/MANIFEST.MF @@ -3,8 +3,8 @@ Bundle-ManifestVersion: 2 Bundle-Vendor: %Bundle-Vendor Bundle-Name: %Bundle-Name Bundle-SymbolicName: io.cloudbeaver.service.auth;singleton:=true -Bundle-Version: 1.0.21.qualifier -Bundle-Release-Date: 20210405 +Bundle-Version: 1.0.29.qualifier +Bundle-Release-Date: 20210802 Bundle-RequiredExecutionEnvironment: JavaSE-1.8 Bundle-ActivationPolicy: lazy Bundle-ClassPath: . diff --git a/server/bundles/io.cloudbeaver.service.auth/pom.xml b/server/bundles/io.cloudbeaver.service.auth/pom.xml index ab13094b68..ca3f06bc33 100644 --- a/server/bundles/io.cloudbeaver.service.auth/pom.xml +++ b/server/bundles/io.cloudbeaver.service.auth/pom.xml @@ -10,7 +10,7 @@ ../ io.cloudbeaver.service.auth - 1.0.21-SNAPSHOT + 1.0.29-SNAPSHOT eclipse-plugin diff --git a/server/bundles/io.cloudbeaver.service.auth/schema/service.auth.graphqls b/server/bundles/io.cloudbeaver.service.auth/schema/service.auth.graphqls index 68f4637954..a992b3c211 100644 --- a/server/bundles/io.cloudbeaver.service.auth/schema/service.auth.graphqls +++ b/server/bundles/io.cloudbeaver.service.auth/schema/service.auth.graphqls @@ -13,6 +13,9 @@ type AuthCredentialInfo { admin: Boolean # This field must be shown in login form user: Boolean + # This field can be used as user identifier + + identifying: Boolean possibleValues: [String] encryption: AuthCredentialEncryption } @@ -52,7 +55,7 @@ type UserAuthToken { type UserInfo { # User unique identifier - userId: String! + userId: ID! # Human readable display name. It is taken from the first auth provider which was used for user login. displayName: String @@ -75,4 +78,6 @@ extend type Query { authProviders: [AuthProviderInfo!]! + authChangeLocalPassword(oldPassword: String!, newPassword: String!): Boolean! + } diff --git a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/DBWServiceAuth.java b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/DBWServiceAuth.java index f2131c58f4..d59f914648 100644 --- a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/DBWServiceAuth.java +++ b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/DBWServiceAuth.java @@ -48,4 +48,6 @@ public interface DBWServiceAuth extends DBWService { @WebAction(requirePermissions = {}) WebAuthProviderInfo[] getAuthProviders(); + @WebAction() + boolean changeLocalPassword(@NotNull WebSession webSession, @NotNull String oldPassword, @NotNull String newPassword) throws DBWebException; } diff --git a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/WebServiceBindingAuth.java b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/WebServiceBindingAuth.java index 4a98cd5dd7..f9bfc67b81 100644 --- a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/WebServiceBindingAuth.java +++ b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/WebServiceBindingAuth.java @@ -47,6 +47,11 @@ public class WebServiceBindingAuth extends WebServiceBindingBase }) .dataFetcher("activeUser", env -> getService(env).activeUser(getWebSession(env, false))) .dataFetcher("authProviders", env -> getService(env).getAuthProviders()) + .dataFetcher("authChangeLocalPassword", env -> getService(env).changeLocalPassword( + getWebSession(env), + env.getArgument("oldPassword"), + env.getArgument("newPassword") + )) ; } diff --git a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/impl/WebServiceAuthImpl.java b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/impl/WebServiceAuthImpl.java index 025ac157ff..5ee85dee6f 100644 --- a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/impl/WebServiceAuthImpl.java +++ b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/impl/WebServiceAuthImpl.java @@ -17,6 +17,7 @@ package io.cloudbeaver.service.auth.impl; import io.cloudbeaver.*; +import io.cloudbeaver.auth.provider.local.LocalAuthProvider; import io.cloudbeaver.model.session.WebAuthInfo; import io.cloudbeaver.model.session.WebSession; import io.cloudbeaver.model.user.WebAuthProviderInfo; @@ -74,7 +75,10 @@ public class WebServiceAuthImpl implements DBWServiceAuth { } } else { if (!providerEnabled) { - throw new DBWebException("Authentication provider '" + providerId + "' is disabled"); + // Admin can use local provider anytime + if (!isAdminAuthTry(providerId, authParameters)) { + throw new DBWebException("Authentication provider '" + providerId + "' is disabled"); + } } } WebAuthProviderDescriptor authProvider = WebServiceRegistry.getInstance().getAuthProvider(providerId); @@ -137,11 +141,10 @@ public class WebServiceAuthImpl implements DBWServiceAuth { userId); } } - } else { - // We may need to associate new credentials with active user - if (linkWithActiveUser) { - securityController.setUserCredentials(userId, authProvider, userCredentials); - } + } + // We may need to associate new credentials with active user + if (linkWithActiveUser) { + securityController.setUserCredentials(userId, authProvider, userCredentials); } } @@ -149,7 +152,7 @@ public class WebServiceAuthImpl implements DBWServiceAuth { throw new DBCException("Invalid user credentials"); } } - if (curUser != null && !curUser.getUserId().equals(userId)) { + if (linkWithActiveUser && curUser != null && !curUser.getUserId().equals(userId)) { log.debug("Attempt to authorize user '" + userId + "' while user '" + curUser.getUserId() + "' already authorized"); throw new DBCException("You cannot authorize with different users credentials"); } @@ -205,6 +208,23 @@ public class WebServiceAuthImpl implements DBWServiceAuth { } } + private boolean isAdminAuthTry(@NotNull String providerId, @NotNull Map authParameters) { + boolean isAdmin = false; + if (LocalAuthProvider.PROVIDER_ID.equals(providerId)) { + Object userId = authParameters.get(LocalAuthProvider.CRED_USER); + if (userId != null) { + try { + isAdmin = CBPlatform.getInstance().getApplication().getSecurityController() + .getUserPermissions(CommonUtils.toString(userId)) + .contains(DBWConstants.PERMISSION_ADMIN); + } catch (DBCException e) { + log.error(e); + } + } + } + return isAdmin; + } + @Override public void authLogout(@NotNull WebSession webSession, @Nullable String providerId) throws DBWebException { if (webSession.getUser() == null) { @@ -228,4 +248,16 @@ public class WebServiceAuthImpl implements DBWServiceAuth { .toArray(WebAuthProviderInfo[]::new); } + @Override + public boolean changeLocalPassword(@NotNull WebSession webSession, @NotNull String oldPassword, @NotNull String newPassword) throws DBWebException { + if (webSession.getUser() == null) { + throw new DBWebException("User must be logged in"); + } + try { + return LocalAuthProvider.changeUserPassword(webSession, oldPassword, newPassword); + } catch (DBException e) { + throw new DBWebException("Error changing user password", e); + } + } + } diff --git a/server/bundles/io.cloudbeaver.service.data.transfer/META-INF/MANIFEST.MF b/server/bundles/io.cloudbeaver.service.data.transfer/META-INF/MANIFEST.MF index 376532c1e3..9048f8e655 100644 --- a/server/bundles/io.cloudbeaver.service.data.transfer/META-INF/MANIFEST.MF +++ b/server/bundles/io.cloudbeaver.service.data.transfer/META-INF/MANIFEST.MF @@ -3,8 +3,8 @@ Bundle-ManifestVersion: 2 Bundle-Vendor: %Bundle-Vendor Bundle-Name: %Bundle-Name Bundle-SymbolicName: io.cloudbeaver.service.data.transfer;singleton:=true -Bundle-Version: 1.0.22.qualifier -Bundle-Release-Date: 20210405 +Bundle-Version: 1.0.30.qualifier +Bundle-Release-Date: 20210802 Bundle-RequiredExecutionEnvironment: JavaSE-1.8 Bundle-ActivationPolicy: lazy Bundle-ClassPath: . diff --git a/server/bundles/io.cloudbeaver.service.data.transfer/pom.xml b/server/bundles/io.cloudbeaver.service.data.transfer/pom.xml index 6f07da1396..9bb598225a 100644 --- a/server/bundles/io.cloudbeaver.service.data.transfer/pom.xml +++ b/server/bundles/io.cloudbeaver.service.data.transfer/pom.xml @@ -10,7 +10,7 @@ ../ io.cloudbeaver.service.data.transfer - 1.0.22-SNAPSHOT + 1.0.30-SNAPSHOT eclipse-plugin diff --git a/server/bundles/io.cloudbeaver.service.data.transfer/src/io/cloudbeaver/service/data/transfer/impl/WebServiceDataTransfer.java b/server/bundles/io.cloudbeaver.service.data.transfer/src/io/cloudbeaver/service/data/transfer/impl/WebServiceDataTransfer.java index c5c4559b7e..1182c0f3c3 100644 --- a/server/bundles/io.cloudbeaver.service.data.transfer/src/io/cloudbeaver/service/data/transfer/impl/WebServiceDataTransfer.java +++ b/server/bundles/io.cloudbeaver.service.data.transfer/src/io/cloudbeaver/service/data/transfer/impl/WebServiceDataTransfer.java @@ -207,7 +207,9 @@ public class WebServiceDataTransfer implements DBWServiceDataTransfer { exporter, properties); - DatabaseTransferProducer producer = new DatabaseTransferProducer(dataContainer, parameters.getFilter() == null ? null : parameters.getFilter().makeDataFilter()); + DatabaseTransferProducer producer = new DatabaseTransferProducer( + dataContainer, + parameters.getFilter() == null ? null : parameters.getFilter().makeDataFilter(monitor, dataContainer)); DatabaseProducerSettings producerSettings = new DatabaseProducerSettings(); producerSettings.setExtractType(DatabaseProducerSettings.ExtractType.SINGLE_QUERY); producerSettings.setQueryRowCount(false); diff --git a/server/bundles/io.cloudbeaver.service.metadata/META-INF/MANIFEST.MF b/server/bundles/io.cloudbeaver.service.metadata/META-INF/MANIFEST.MF index 30df09fadf..fbf7510320 100644 --- a/server/bundles/io.cloudbeaver.service.metadata/META-INF/MANIFEST.MF +++ b/server/bundles/io.cloudbeaver.service.metadata/META-INF/MANIFEST.MF @@ -3,8 +3,8 @@ Bundle-ManifestVersion: 2 Bundle-Vendor: %Bundle-Vendor Bundle-Name: %Bundle-Name Bundle-SymbolicName: io.cloudbeaver.service.metadata;singleton:=true -Bundle-Version: 1.0.25.qualifier -Bundle-Release-Date: 20210405 +Bundle-Version: 1.0.33.qualifier +Bundle-Release-Date: 20210802 Bundle-RequiredExecutionEnvironment: JavaSE-1.8 Bundle-ActivationPolicy: lazy Bundle-ClassPath: . diff --git a/server/bundles/io.cloudbeaver.service.metadata/pom.xml b/server/bundles/io.cloudbeaver.service.metadata/pom.xml index 871278454d..78d3093537 100644 --- a/server/bundles/io.cloudbeaver.service.metadata/pom.xml +++ b/server/bundles/io.cloudbeaver.service.metadata/pom.xml @@ -10,7 +10,7 @@ ../ io.cloudbeaver.service.metadata - 1.0.25-SNAPSHOT + 1.0.33-SNAPSHOT eclipse-plugin diff --git a/server/drivers/clickhouse/pom.xml b/server/drivers/clickhouse/pom.xml index c60cb3584f..e55e276e7f 100644 --- a/server/drivers/clickhouse/pom.xml +++ b/server/drivers/clickhouse/pom.xml @@ -1,5 +1,5 @@ - + 4.0.0 drivers.clickhouse 1.0.0 @@ -18,7 +18,14 @@ ru.yandex.clickhouse clickhouse-jdbc - 0.2.6 + shaded + 0.3.1-patch + + + * + * + + diff --git a/server/drivers/postgresql/pom.xml b/server/drivers/postgresql/pom.xml index 9768530a95..c6e1b9c82b 100644 --- a/server/drivers/postgresql/pom.xml +++ b/server/drivers/postgresql/pom.xml @@ -18,12 +18,12 @@ org.postgresql postgresql - 42.2.5 + 42.2.20 net.postgis postgis-jdbc - 2.2.1 + 2.5.0 diff --git a/server/features/io.cloudbeaver.ce.drivers.feature/feature.xml b/server/features/io.cloudbeaver.ce.drivers.feature/feature.xml index 64b45ecf35..a377b39d6b 100644 --- a/server/features/io.cloudbeaver.ce.drivers.feature/feature.xml +++ b/server/features/io.cloudbeaver.ce.drivers.feature/feature.xml @@ -2,7 +2,7 @@ diff --git a/server/features/io.cloudbeaver.ce.drivers.feature/pom.xml b/server/features/io.cloudbeaver.ce.drivers.feature/pom.xml index 4a4b5c8b6a..9eb39af082 100644 --- a/server/features/io.cloudbeaver.ce.drivers.feature/pom.xml +++ b/server/features/io.cloudbeaver.ce.drivers.feature/pom.xml @@ -9,6 +9,6 @@ ../ io.cloudbeaver.ce.drivers.feature - 1.0.45-SNAPSHOT + 1.0.53-SNAPSHOT eclipse-feature diff --git a/server/features/io.cloudbeaver.product.ce.feature/feature.xml b/server/features/io.cloudbeaver.product.ce.feature/feature.xml index 353c2768c9..e862b70cf1 100644 --- a/server/features/io.cloudbeaver.product.ce.feature/feature.xml +++ b/server/features/io.cloudbeaver.product.ce.feature/feature.xml @@ -2,7 +2,7 @@ diff --git a/server/features/io.cloudbeaver.product.ce.feature/pom.xml b/server/features/io.cloudbeaver.product.ce.feature/pom.xml index 46d95e5385..10f2d6019d 100644 --- a/server/features/io.cloudbeaver.product.ce.feature/pom.xml +++ b/server/features/io.cloudbeaver.product.ce.feature/pom.xml @@ -10,7 +10,7 @@ ../ io.cloudbeaver.product.ce.feature - 20.0.6-SNAPSHOT + 21.1.1-SNAPSHOT eclipse-feature diff --git a/server/features/io.cloudbeaver.sample.feature/feature.xml b/server/features/io.cloudbeaver.sample.feature/feature.xml index c6efa3b5c0..4be87d1b61 100644 --- a/server/features/io.cloudbeaver.sample.feature/feature.xml +++ b/server/features/io.cloudbeaver.sample.feature/feature.xml @@ -2,7 +2,7 @@ diff --git a/server/features/io.cloudbeaver.sample.feature/pom.xml b/server/features/io.cloudbeaver.sample.feature/pom.xml index 9b21108671..19b95ab52e 100644 --- a/server/features/io.cloudbeaver.sample.feature/pom.xml +++ b/server/features/io.cloudbeaver.sample.feature/pom.xml @@ -9,6 +9,6 @@ ../ io.cloudbeaver.sample.feature - 1.0.25-SNAPSHOT + 1.0.33-SNAPSHOT eclipse-feature diff --git a/server/features/io.cloudbeaver.server.feature/feature.xml b/server/features/io.cloudbeaver.server.feature/feature.xml index b0eee41f4b..7b435efb1a 100644 --- a/server/features/io.cloudbeaver.server.feature/feature.xml +++ b/server/features/io.cloudbeaver.server.feature/feature.xml @@ -2,7 +2,7 @@ diff --git a/server/features/io.cloudbeaver.server.feature/pom.xml b/server/features/io.cloudbeaver.server.feature/pom.xml index 1987f11cf7..d81e071dc6 100644 --- a/server/features/io.cloudbeaver.server.feature/pom.xml +++ b/server/features/io.cloudbeaver.server.feature/pom.xml @@ -10,6 +10,6 @@ ../ io.cloudbeaver.server.feature - 20.0.6-SNAPSHOT + 21.1.1-SNAPSHOT eclipse-feature diff --git a/server/pom.xml b/server/pom.xml index 262b9f85c7..5c17aa4459 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -19,7 +19,7 @@ CloudBeaver CE - 20.0.6 + 21.1.1 diff --git a/server/product/provided-sample/DBeaverWebProvidedSample.product b/server/product/provided-sample/DBeaverWebProvidedSample.product index 7173fe62a7..fe5e584b46 100644 --- a/server/product/provided-sample/DBeaverWebProvidedSample.product +++ b/server/product/provided-sample/DBeaverWebProvidedSample.product @@ -1,7 +1,7 @@ - + diff --git a/server/product/provided-sample/pom.xml b/server/product/provided-sample/pom.xml index 946083d69e..d0d363618a 100644 --- a/server/product/provided-sample/pom.xml +++ b/server/product/provided-sample/pom.xml @@ -9,7 +9,7 @@ 1.0.0-SNAPSHOT ../../ - 20.0.6-SNAPSHOT + 21.1.1-SNAPSHOT provided-sample eclipse-repository Cloudbeaver Provided Sample Product diff --git a/server/product/web-server/CloudbeaverServer.product b/server/product/web-server/CloudbeaverServer.product index 0c1fb043e8..ccc55bd0b6 100644 --- a/server/product/web-server/CloudbeaverServer.product +++ b/server/product/web-server/CloudbeaverServer.product @@ -2,7 +2,7 @@ diff --git a/server/product/web-server/pom.xml b/server/product/web-server/pom.xml index a54e046171..bdaeb520cd 100644 --- a/server/product/web-server/pom.xml +++ b/server/product/web-server/pom.xml @@ -9,7 +9,7 @@ 1.0.0-SNAPSHOT ../../ - 20.0.6-SNAPSHOT + 21.1.1-SNAPSHOT web-server eclipse-repository Cloudbeaver Server Product diff --git a/webapp/package.json b/webapp/package.json index c0379fba06..8a02b369e3 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -6,15 +6,18 @@ "packages": [ "packages/*" ], - "nohoist": ["**/core-cli", "**/core-cli/**"] + "nohoist": [ + "**/core-cli", + "**/core-cli/**" + ] }, "scripts": { "prebuild": "npm run clean-dist", "clean-dist": "rimraf lib/*" }, "devDependencies": { - "@types/react": "^16.9.49", - "@types/react-dom": "^16.9.8", + "@types/react": "^16.9.12", + "@types/react-dom": "^16.9.12", "@typescript-eslint/eslint-plugin": "^4.13.0", "@typescript-eslint/parser": "^4.13.0", "eslint": "^7.18.0", @@ -33,19 +36,21 @@ "eslint-plugin-standard": "^5.0.0", "eslint-webpack-plugin": "^2.4.1", "lerna": "^3.22.1", - "mobx": "^6.0.4", - "mobx-react-lite": "^3.1.6", + "mobx": "^6.3.0", + "mobx-react-lite": "^3.2.0", "node-sass": "^5.0.0", - "preact": "^10.5.10", - "preact-render-to-string": "^5.1.12", - "preact-ssr-prepass": "^1.1.3", + "path-browserify": "^1.0.1", + "preact": "^10.5.13", "preact-jsx-runtime": "^1.2.0", - "react": "^16.13.1", - "react-dom": "^16.13.1", - "reakit": "^1.3.4", + "preact-render-to-string": "^5.1.19", + "preact-ssr-prepass": "^1.1.3", + "react": "^16.14.0", + "react-dom": "^16.14.0", + "reakit": "^1.3.8", "reflect-metadata": "^0.1.13", - "reshadow": "^0.0.1-alpha.78", - "typescript": "^4.2.3" + "reshadow": "^0.0.1-alpha.80", + "rimraf": "^3.0.2", + "typescript": "^4.2.4" }, "dependencies": {} -} \ No newline at end of file +} diff --git a/webapp/packages/core-administration/src/Administration/AdministrationTools/AdministrationTools.tsx b/webapp/packages/core-administration/src/Administration/AdministrationTools/AdministrationTools.tsx deleted file mode 100644 index 095aa7b751..0000000000 --- a/webapp/packages/core-administration/src/Administration/AdministrationTools/AdministrationTools.tsx +++ /dev/null @@ -1,21 +0,0 @@ -/* - * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2021 DBeaver Corp and others - * - * Licensed under the Apache License, Version 2.0. - * you may not use this file except in compliance with the License. - */ - -import styled from 'reshadow'; - -interface Props { - className?: string; -} - -export const AdministrationTools: React.FC = function AdministrationTools({ children, className }) { - return styled()( - - {children} - - ); -}; diff --git a/webapp/packages/core-administration/src/AdministrationItem/AdministrationItemService.ts b/webapp/packages/core-administration/src/AdministrationItem/AdministrationItemService.ts index ef775be0eb..ec0c2cb3ec 100644 --- a/webapp/packages/core-administration/src/AdministrationItem/AdministrationItemService.ts +++ b/webapp/packages/core-administration/src/AdministrationItem/AdministrationItemService.ts @@ -23,6 +23,7 @@ interface IActivationData { screen: IAdministrationItemRoute; configurationWizard: boolean; outside: boolean; + outsideAdminPage: boolean; } @injectable() @@ -161,17 +162,19 @@ export class AdministrationItemService { async activate( screen: IAdministrationItemRoute, configurationWizard: boolean, - outside: boolean + outside: boolean, + outsideAdminPage: boolean ): Promise { - await this.activationTask.execute({ screen, configurationWizard, outside }); + await this.activationTask.execute({ screen, configurationWizard, outside, outsideAdminPage }); } async deActivate( screen: IAdministrationItemRoute, configurationWizard: boolean, - outside: boolean + outside: boolean, + outsideAdminPage: boolean ): Promise { - await this.deActivationTask.execute({ screen, configurationWizard, outside }); + await this.deActivationTask.execute({ screen, configurationWizard, outside, outsideAdminPage }); } async canActivate( @@ -198,7 +201,22 @@ export class AdministrationItemService { return true; } - private activateHandler: IExecutorHandler = async ({ screen, configurationWizard, outside }) => { + private activateHandler: IExecutorHandler = async ({ + screen, + configurationWizard, + outside, + outsideAdminPage, + }) => { + let lastItem = 0; + while (true) { + const items = this.getActiveItems(configurationWizard); + if (lastItem === items.length) { + break; + } + await items[lastItem]?.onLoad?.(configurationWizard, outside, outsideAdminPage); + lastItem++; + } + if (configurationWizard) { let item = 0; while (true) { @@ -216,20 +234,25 @@ export class AdministrationItemService { return; } - await item.onActivate?.(configurationWizard, outside); + await item.onActivate?.(configurationWizard, outside, outsideAdminPage); if (screen.sub) { await this.getItemSub(item, screen.sub)?.onActivate?.(screen.param, configurationWizard, outside); } }; - private deActivateHandler: IExecutorHandler = async ({ screen, configurationWizard, outside }) => { + private deActivateHandler: IExecutorHandler = async ({ + screen, + configurationWizard, + outside, + outsideAdminPage, + }) => { const item = this.getItem(screen.item, configurationWizard); if (!item) { return; } - await item.onDeActivate?.(configurationWizard, outside); + await item.onDeActivate?.(configurationWizard, outside, outsideAdminPage); if (screen.sub) { await this.getItemSub(item, screen.sub)?.onDeActivate?.(screen.param, configurationWizard, outside); diff --git a/webapp/packages/core-administration/src/AdministrationItem/IAdministrationItem.ts b/webapp/packages/core-administration/src/AdministrationItem/IAdministrationItem.ts index 61b370d814..f1abbe3614 100644 --- a/webapp/packages/core-administration/src/AdministrationItem/IAdministrationItem.ts +++ b/webapp/packages/core-administration/src/AdministrationItem/IAdministrationItem.ts @@ -44,7 +44,11 @@ export type AdministrationItemSubContentProps = AdministrationItemContentProps & }; export type AdministrationItemSubContentComponent = React.FunctionComponent; -export type AdministrationItemEvent = (configurationWizard: boolean, outside: boolean) => Promise | void; +export type AdministrationItemEvent = ( + configurationWizard: boolean, + outside: boolean, + outsideAdminPage: boolean +) => Promise | void; export type AdministrationItemCanActivateEvent = ( configurationWizard: boolean, administration: boolean @@ -91,6 +95,7 @@ export interface IAdministrationItemOptions { replace?: IAdministrationItemReplaceOptions; getDrawerComponent: () => AdministrationItemDrawerComponent; getContentComponent: () => AdministrationItemContentComponent; + onLoad?: AdministrationItemEvent; onActivate?: AdministrationItemEvent; onDeActivate?: AdministrationItemEvent; canActivate?: AdministrationItemCanActivateEvent; diff --git a/webapp/packages/core-administration/src/AdministrationScreen/AdministrationScreenService.ts b/webapp/packages/core-administration/src/AdministrationScreen/AdministrationScreenService.ts index 20eae196eb..c71498623d 100644 --- a/webapp/packages/core-administration/src/AdministrationScreen/AdministrationScreenService.ts +++ b/webapp/packages/core-administration/src/AdministrationScreen/AdministrationScreenService.ts @@ -13,7 +13,7 @@ import { IExecutor, Executor } from '@cloudbeaver/core-executor'; import { PermissionsResource, PermissionsService, ServerConfigResource } from '@cloudbeaver/core-root'; import { ScreenService, RouterState } from '@cloudbeaver/core-routing'; import { LocalStorageSaveService } from '@cloudbeaver/core-settings'; -import { BuildVersion } from '@cloudbeaver/core-utils'; +import { GlobalConstants } from '@cloudbeaver/core-utils'; import { AdministrationItemService } from '../AdministrationItem/AdministrationItemService'; import type { IAdministrationItemRoute } from '../AdministrationItem/IAdministrationItemRoute'; @@ -68,15 +68,9 @@ export class AdministrationScreenService { private autoSaveService: LocalStorageSaveService, private serverConfigResource: ServerConfigResource ) { - makeObservable(this, { - info: observable, - itemState: observable, - activeScreen: computed, - }); - this.info = { workspaceId: '', - version: BuildVersion.version || '', + version: GlobalConstants.version || '', serverVersion: '', configurationMode: false, }; @@ -84,6 +78,12 @@ export class AdministrationScreenService { this.activationEvent = new Executor(); this.ensurePermissions = new Executor(); + makeObservable(this, { + info: observable, + itemState: observable, + activeScreen: computed, + }); + this.autoSaveService.withAutoSave(this.itemState, ADMINISTRATION_ITEMS_STATE); this.autoSaveService.withAutoSave(this.info, ADMINISTRATION_INFO); this.permissionsResource.onDataUpdate.addPostHandler(() => { @@ -150,18 +150,7 @@ export class AdministrationScreenService { if (!this.serverConfigResource.isLoaded()) { throw new Error('Administration screen getItemState can be used only after server configuration loaded'); } - if ( - this.info.workspaceId !== this.serverConfigResource.workspaceId - || this.info.configurationMode !== this.isConfigurationMode - || this.info.serverVersion !== this.serverConfigResource.serverVersion - || this.info.version !== BuildVersion.version - ) { - this.clearItemsState(); - this.info.workspaceId = this.serverConfigResource.workspaceId; - this.info.configurationMode = this.isConfigurationMode; - this.info.serverVersion = this.serverConfigResource.serverVersion; - this.info.version = BuildVersion.version || ''; - } + this.validateState(); if (defaultState) { if (!this.itemState.has(name) || update) { @@ -194,11 +183,13 @@ export class AdministrationScreenService { const toScreen = this.getScreen(nextState); const screen = this.getScreen(state); + if (screen) { await this.administrationItemService.deActivate( screen, this.isConfigurationMode, - screen.item !== toScreen?.item + screen.item !== toScreen?.item, + toScreen === null ); } @@ -239,17 +230,35 @@ export class AdministrationScreenService { await this.administrationItemService.activate( screen, this.isConfigurationMode, - screen.item !== fromScreen?.item + screen.item !== fromScreen?.item, + fromScreen === null ); } } + private validateState() { + if ( + this.info.workspaceId !== this.serverConfigResource.workspaceId + || this.info.configurationMode !== this.isConfigurationMode + || this.info.serverVersion !== this.serverConfigResource.serverVersion + || this.info.version !== GlobalConstants.version + ) { + this.clearItemsState(); + this.info.workspaceId = this.serverConfigResource.workspaceId; + this.info.configurationMode = this.isConfigurationMode; + this.info.serverVersion = this.serverConfigResource.serverVersion; + this.info.version = GlobalConstants.version || ''; + } + } + private async checkPermissions(state: RouterState): Promise { if (!this.isAdministrationRouteActive(state.name)) { return false; } - if (!(await this.isAccessProvided(state))) { + const accessProvided = await this.isAccessProvided(state); + + if (!accessProvided) { this.screenService.navigateToRoot(); return false; } @@ -269,7 +278,9 @@ export class AdministrationScreenService { await this.ensurePermissions.execute(); - if (!(await this.permissionsService.hasAsync(EAdminPermission.admin))) { + const administrator = await this.permissionsService.hasAsync(EAdminPermission.admin); + + if (!administrator) { return false; } diff --git a/webapp/packages/core-administration/src/Administration/AdministrationTools/AdministrationToolsStyles.ts b/webapp/packages/core-administration/src/AdministrationToolsPanelStyles.ts similarity index 61% rename from webapp/packages/core-administration/src/Administration/AdministrationTools/AdministrationToolsStyles.ts rename to webapp/packages/core-administration/src/AdministrationToolsPanelStyles.ts index 47a968df1b..853d9932bc 100644 --- a/webapp/packages/core-administration/src/Administration/AdministrationTools/AdministrationToolsStyles.ts +++ b/webapp/packages/core-administration/src/AdministrationToolsPanelStyles.ts @@ -11,25 +11,16 @@ import { css } from 'reshadow'; import { composes } from '@cloudbeaver/core-theming'; -export const ADMINISTRATION_TOOLS_STYLES = composes( +export const ADMINISTRATION_TOOLS_PANEL_STYLES = composes( css` - AdministrationTools { + ToolsPanel { composes: theme-background-surface theme-text-on-surface theme-border-color-background from global; } `, css` - AdministrationTools { - padding: 0 16px; - display: flex; - align-items: center; + ToolsPanel { border-bottom: solid 1px; - height: 48px; - - & IconButton { - height: 32px; - width: 32px; - margin-right: 16px; - } + flex: 0 0 auto; } ` ); diff --git a/webapp/packages/core-administration/src/index.ts b/webapp/packages/core-administration/src/index.ts index bdac796b81..87d36baa22 100644 --- a/webapp/packages/core-administration/src/index.ts +++ b/webapp/packages/core-administration/src/index.ts @@ -1,5 +1,3 @@ -export * from './Administration/AdministrationTools/AdministrationTools'; -export * from './Administration/AdministrationTools/AdministrationToolsStyles'; export * from './AdministrationItem/AdministrationItemService'; export * from './AdministrationItem/IAdministrationItem'; export * from './AdministrationScreen/AdministrationScreenService'; @@ -9,6 +7,7 @@ export * from './AdministrationScreen/ConfigurationWizard/ConfigurationWizardSer export * from './AdministrationScreen/ConfigurationWizard/WizardTopAppBar/WizardTopAppBarService'; export * from './AdministrationScreen/ConfigurationWizard/ConfigurationWizardService'; export * from './AdministrationScreen/ConfigurationWizard/WizardTopAppBar/WizardTopAppBarService'; +export * from './AdministrationToolsPanelStyles'; export * from './AdministrationLocaleService'; export * from './EAdminPermission'; diff --git a/webapp/packages/core-app/public/icons/error_icon_sm.svg b/webapp/packages/core-app/public/icons/error_icon_sm.svg new file mode 100644 index 0000000000..38e253b861 --- /dev/null +++ b/webapp/packages/core-app/public/icons/error_icon_sm.svg @@ -0,0 +1,3 @@ + + + diff --git a/webapp/packages/core-app/public/icons/icons.svg b/webapp/packages/core-app/public/icons/icons.svg index effe4b9279..912e069d91 100644 --- a/webapp/packages/core-app/public/icons/icons.svg +++ b/webapp/packages/core-app/public/icons/icons.svg @@ -29,8 +29,12 @@ + + + + - + @@ -61,7 +65,8 @@ - + @@ -73,28 +78,20 @@ - - - - - - + + - - - - - - + + - - + + - - + + @@ -126,22 +123,36 @@ - - - - + + + + + + + + + + - + - + + + + + + + + + @@ -183,10 +194,91 @@ - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webapp/packages/core-app/public/icons/lock.png b/webapp/packages/core-app/public/icons/lock.png new file mode 100644 index 0000000000..d601efde61 Binary files /dev/null and b/webapp/packages/core-app/public/icons/lock.png differ diff --git a/webapp/packages/core-app/public/icons/permissions.png b/webapp/packages/core-app/public/icons/permissions.png new file mode 100644 index 0000000000..57e0ed0311 Binary files /dev/null and b/webapp/packages/core-app/public/icons/permissions.png differ diff --git a/webapp/packages/core-app/public/icons/product-logo_dark.svg b/webapp/packages/core-app/public/icons/product-logo_dark.svg new file mode 100644 index 0000000000..966881ade7 --- /dev/null +++ b/webapp/packages/core-app/public/icons/product-logo_dark.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/webapp/packages/core-app/public/icons/product-logo_light.svg b/webapp/packages/core-app/public/icons/product-logo_light.svg new file mode 100644 index 0000000000..693353bef4 --- /dev/null +++ b/webapp/packages/core-app/public/icons/product-logo_light.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/webapp/packages/core-app/public/icons/sorting.png b/webapp/packages/core-app/public/icons/sorting.png new file mode 100644 index 0000000000..1d94297cfc Binary files /dev/null and b/webapp/packages/core-app/public/icons/sorting.png differ diff --git a/webapp/packages/core-app/public/icons/warning_icon_sm.svg b/webapp/packages/core-app/public/icons/warning_icon_sm.svg new file mode 100644 index 0000000000..3c782da092 --- /dev/null +++ b/webapp/packages/core-app/public/icons/warning_icon_sm.svg @@ -0,0 +1,3 @@ + + + diff --git a/webapp/packages/core-app/src/AppScreen/RightArea.tsx b/webapp/packages/core-app/src/AppScreen/RightArea.tsx index 0f44c8ab42..793a74818c 100644 --- a/webapp/packages/core-app/src/AppScreen/RightArea.tsx +++ b/webapp/packages/core-app/src/AppScreen/RightArea.tsx @@ -12,13 +12,13 @@ import styled, { css } from 'reshadow'; import { Pane, ResizerControls, SlideBox, SlideElement, slideBoxStyles, Split, splitHorizontalStyles, splitStyles, SlideOverlay } from '@cloudbeaver/core-blocks'; -import { useController, useService } from '@cloudbeaver/core-di'; +import { useService } from '@cloudbeaver/core-di'; import { useStyles, composes } from '@cloudbeaver/core-theming'; import { OptionsPanelService } from '@cloudbeaver/core-ui'; import { NavigationTabsBar } from '../shared/NavigationTabs/NavigationTabsBar'; -import { LogViewTab } from '../shared/ToolsPanel/LogViewTab/LogViewTab'; -import { LogViewTabController } from '../shared/ToolsPanel/LogViewTab/LogViewTabController'; +import { LogViewer } from '../shared/ToolsPanel/LogViewer/LogViewer'; +import { LogViewerService } from '../shared/ToolsPanel/LogViewer/LogViewerService'; const styles = composes( css` @@ -45,7 +45,7 @@ const styles = composes( ); export const RightArea = observer(function RightArea() { - const controller = useController(LogViewTabController); + const logViewerService = useService(LogViewerService); const optionsPanelService = useService(OptionsPanelService); const OptionsPanel = optionsPanelService.getPanelComponent(); @@ -55,13 +55,13 @@ export const RightArea = observer(function RightArea() { - + - {controller.isActive && } + {logViewerService.isActive && } - + optionsPanelService.close()} /> diff --git a/webapp/packages/core-app/src/Body.tsx b/webapp/packages/core-app/src/Body.tsx index 320678d88b..07e065c72d 100644 --- a/webapp/packages/core-app/src/Body.tsx +++ b/webapp/packages/core-app/src/Body.tsx @@ -25,6 +25,7 @@ const bodyStyles = css` display: flex; padding: 0 !important; /* fix additional padding with modal reakit menu */ flex-direction: column; + overflow: hidden; } `; @@ -44,7 +45,7 @@ export const Body = observer(function Body() { }); return styled(useStyles(bodyStyles))( - + {Screen && } diff --git a/webapp/packages/core-app/src/CoreSettingsService.ts b/webapp/packages/core-app/src/CoreSettingsService.ts index a6abacd327..4d5917a883 100644 --- a/webapp/packages/core-app/src/CoreSettingsService.ts +++ b/webapp/packages/core-app/src/CoreSettingsService.ts @@ -14,6 +14,7 @@ const defaultSettings = { 'app.logViewer.maxLogRecords': 1000, 'app.logViewer.logBatchSize': 2000, 'app.logViewer.maxFailedRequests': 3, + // temporary limit for all nodes children in app 'app.navigationTree.childrenLimit': 500, }; diff --git a/webapp/packages/core-app/src/NavigationTree/ElementsTree.tsx b/webapp/packages/core-app/src/NavigationTree/ElementsTree.tsx index 0763829520..5b42fb6d47 100644 --- a/webapp/packages/core-app/src/NavigationTree/ElementsTree.tsx +++ b/webapp/packages/core-app/src/NavigationTree/ElementsTree.tsx @@ -7,16 +7,22 @@ */ import { observer } from 'mobx-react-lite'; -import { useCallback, useMemo, useState } from 'react'; +import { useMemo } from 'react'; import styled, { css } from 'reshadow'; -import { Loader } from '@cloudbeaver/core-blocks'; -import { MetadataMap } from '@cloudbeaver/core-utils'; +import { Loader, useMapResource } from '@cloudbeaver/core-blocks'; +import { useService } from '@cloudbeaver/core-di'; +import type { MetadataMap } from '@cloudbeaver/core-utils'; import type { NavNode } from '../shared/NodesManager/EntityTypes'; +import { NavNodeInfoResource, ROOT_NODE_PATH } from '../shared/NodesManager/NavNodeInfoResource'; +import { NavTreeResource } from '../shared/NodesManager/NavTreeResource'; import { useChildren } from '../shared/useChildren'; +import { elementsTreeNameFilter } from './elementsTreeNameFilter'; +import { NavigationNodeNested } from './NavigationTreeNode/NavigationNode/NavigationNodeNested'; import { NavigationNodeElement } from './NavigationTreeNode/NavigationNodeElement'; import { ITreeContext, TreeContext } from './TreeContext'; +import { IElementsTreeCustomRenderer, IElementsTreeFilter, ITreeNodeState, useElementsTree } from './useElementsTree'; const styles = css` tree { @@ -32,10 +38,6 @@ const styles = css` } `; -export interface ITreeNodeState { - filter: string; -} - interface Props { root?: string; selectionTree?: boolean; @@ -45,60 +47,66 @@ interface Props { }>; emptyPlaceholder: React.FC; className?: string; + filters?: IElementsTreeFilter[]; + renderers?: IElementsTreeCustomRenderer[]; + customSelect?: (node: NavNode, multiple: boolean) => void; + isGroup?: (node: NavNode) => boolean; + onExpand?: (node: NavNode, state: boolean) => Promise | void; onOpen?: (node: NavNode) => Promise | void; - onSelect?: (node: NavNode, multiple: boolean) => void; - isSelected?: (node: NavNode) => boolean; + onSelect?: (node: NavNode, state: boolean) => void; onFilter?: (node: NavNode, value: string) => void; } export const ElementsTree: React.FC = observer(function ElementsTree({ - root, + root = ROOT_NODE_PATH, control, localState, selectionTree = false, emptyPlaceholder, + filters, + renderers, className, + isGroup, + customSelect, + onExpand, onOpen, onSelect, - isSelected, onFilter, }) { const nodeChildren = useChildren(root); const Placeholder = emptyPlaceholder; - const [localTreeNodesState] = useState(() => new MetadataMap(() => ({ filter: '' }))); + const navNodeInfoResource = useService(NavNodeInfoResource); - const treeNodesState = localState || localTreeNodesState; + useMapResource(NavTreeResource, root); - const getTreeNodeState = useCallback((node: NavNode) => - treeNodesState.get(node.id), [treeNodesState]); + const nameFilter = useMemo(() => elementsTreeNameFilter(navNodeInfoResource), [navNodeInfoResource]); - const onFilterHandler = useCallback((node: NavNode, value: string) => { - const treeNodeState = treeNodesState.get(node.id); - treeNodeState.filter = value; - - if (onFilter) { - onFilter(node, value); - } - }, [treeNodesState, onFilter]); + const tree = useElementsTree({ + root, + localState, + filters: [nameFilter, ...(filters || [])], + renderers, + isGroup, + onFilter, + customSelect, + onExpand, + onSelect, + }); const context = useMemo( () => ({ - treeNodesState, - getTreeNodeState, + tree, selectionTree, control, onOpen, - onSelect, - isSelected, - onFilter: onFilterHandler, }), - [control, selectionTree, onOpen, onSelect, isSelected, onFilterHandler, treeNodesState, getTreeNodeState] + [control, selectionTree, onOpen] ); if (!nodeChildren.children || nodeChildren.children.length === 0) { if (nodeChildren.isLoading()) { return styled(styles)( -
+
); @@ -109,10 +117,8 @@ export const ElementsTree: React.FC = observer(function ElementsTree({ return styled(styles)( - - {nodeChildren.children.map(id => ( - - ))} + + diff --git a/webapp/packages/core-app/src/NavigationTree/NavigationNodeComponent.ts b/webapp/packages/core-app/src/NavigationTree/NavigationNodeComponent.ts new file mode 100644 index 0000000000..e7903a70e7 --- /dev/null +++ b/webapp/packages/core-app/src/NavigationTree/NavigationNodeComponent.ts @@ -0,0 +1,20 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2021 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import type React from 'react'; + +import type { NavNode } from '../shared/NodesManager/EntityTypes'; + +interface Props { + node: NavNode; + component: React.FC<{ + nodeId: string; + }>; +} + +export type NavigationNodeComponent = React.FC; diff --git a/webapp/packages/core-app/src/NavigationTree/NavigationTree.tsx b/webapp/packages/core-app/src/NavigationTree/NavigationTree.tsx index d2658b9c8d..a61c149e25 100644 --- a/webapp/packages/core-app/src/NavigationTree/NavigationTree.tsx +++ b/webapp/packages/core-app/src/NavigationTree/NavigationTree.tsx @@ -7,15 +7,19 @@ */ import { observer } from 'mobx-react-lite'; +import { useMemo } from 'react'; import styled, { css } from 'reshadow'; import { useFocus } from '@cloudbeaver/core-blocks'; +import { ConnectionInfoResource } from '@cloudbeaver/core-connections'; import { useService } from '@cloudbeaver/core-di'; import { usePermission, EPermission } from '@cloudbeaver/core-root'; import { useActiveView } from '@cloudbeaver/core-view'; -import { ROOT_NODE_PATH } from '../shared/NodesManager/NavNodeInfoResource'; +import { NavNodeInfoResource, ROOT_NODE_PATH } from '../shared/NodesManager/NavNodeInfoResource'; import { ElementsTree } from './ElementsTree'; +import { navigationTreeConnectionGroupFilter } from './navigationTreeConnectionGroupFilter'; +import { navigationTreeConnectionGroupRenderer } from './navigationTreeConnectionGroupRenderer'; import { NavigationTreeService } from './NavigationTreeService'; import { useNavigationTree } from './useNavigationTree'; @@ -55,10 +59,18 @@ const navigationTreeStyles = css` export const NavigationTree = observer(function NavigationTree() { const navTreeService = useService(NavigationTreeService); + const navNodeInfoResource = useService(NavNodeInfoResource); + const connectionInfoResource = useService(ConnectionInfoResource); + const [onFocus, onBlur] = useActiveView(navTreeService.getView); const [ref] = useFocus({ onFocus, onBlur }); const isEnabled = usePermission(EPermission.public); - const { isSelected, handleOpen, handleSelect } = useNavigationTree(); + const { handleOpen, handleSelect } = useNavigationTree(); + + const connectionGroupFilter = useMemo(() => navigationTreeConnectionGroupFilter( + connectionInfoResource, + navNodeInfoResource + ), [connectionInfoResource, navNodeInfoResource]); if (!isEnabled) { return null; @@ -68,6 +80,9 @@ export const NavigationTree = observer(function NavigationTree() { styled(navigationTreeStyles)(
@@ -76,9 +91,8 @@ export const NavigationTree = observer(function NavigationTree() {
)} - isSelected={isSelected} + customSelect={handleSelect} onOpen={handleOpen} - onSelect={handleSelect} />
); diff --git a/webapp/packages/core-app/src/NavigationTree/NavigationTreeContextMenuService.ts b/webapp/packages/core-app/src/NavigationTree/NavigationTreeContextMenuService.ts index c76e3a614c..a4101e7d7d 100644 --- a/webapp/packages/core-app/src/NavigationTree/NavigationTreeContextMenuService.ts +++ b/webapp/packages/core-app/src/NavigationTree/NavigationTreeContextMenuService.ts @@ -177,9 +177,13 @@ export class NavigationTreeContextMenuService extends Bootstrap { }, order: Number.MAX_SAFE_INTEGER, title: 'app_navigationTree_refreshNode', - onClick: context => { + onClick: async context => { const node = context.data; - this.navNodeManagerService.refreshTree(node.id); + try { + await this.navNodeManagerService.refreshTree(node.id); + } catch (exception) { + this.notificationService.logException(exception, 'Failed to refresh node'); + } }, } ); diff --git a/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/NavigationNode.tsx b/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/NavigationNode.tsx index 3f067d3f53..569d03264e 100644 --- a/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/NavigationNode.tsx +++ b/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/NavigationNode.tsx @@ -7,28 +7,18 @@ */ import { observer } from 'mobx-react-lite'; -import { useContext } from 'react'; -import { TreeNode, TreeNodeContext } from '@cloudbeaver/core-blocks'; +import { TreeNode } from '@cloudbeaver/core-blocks'; -import type { NavNode } from '../../shared/NodesManager/EntityTypes'; +import type { NavigationNodeComponent } from '../NavigationNodeComponent'; import { NavigationNodeControl } from './NavigationNode/NavigationNodeControl'; import { NavigationNodeNested } from './NavigationNode/NavigationNodeNested'; import { useNavigationNode } from './useNavigationNode'; -interface Props { - node: NavNode; - component: React.FC<{ - nodeId: string; - }>; -} - -export const NavigationNode: React.FC = observer(function NavigationNode({ +export const NavigationNode: NavigationNodeComponent = observer(function NavigationNode({ node, component, }) { - const context = useContext(TreeNodeContext); - const { control, selected, @@ -57,7 +47,7 @@ export const NavigationNode: React.FC = observer(function NavigationNode( onFilter={handleFilter} > - + {expanded && } ); }); diff --git a/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/NavigationNode/NavigationNodeControl.tsx b/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/NavigationNode/NavigationNodeControl.tsx index 9016180952..b3d7491f47 100644 --- a/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/NavigationNode/NavigationNodeControl.tsx +++ b/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/NavigationNode/NavigationNodeControl.tsx @@ -8,16 +8,15 @@ import { observer } from 'mobx-react-lite'; import { useCallback, useContext } from 'react'; -import styled, { css } from 'reshadow'; +import styled, { css, use } from 'reshadow'; import { TreeNodeContext, TreeNodeControl, TreeNodeExpand, TreeNodeIcon, TreeNodeName, TREE_NODE_STYLES } from '@cloudbeaver/core-blocks'; -import { ConnectionInfoResource } from '@cloudbeaver/core-connections'; import { useService } from '@cloudbeaver/core-di'; import { composes, useStyles } from '@cloudbeaver/core-theming'; import type { NavNode } from '../../../shared/NodesManager/EntityTypes'; import { EObjectFeature } from '../../../shared/NodesManager/EObjectFeature'; -import { NodeManagerUtils } from '../../../shared/NodesManager/NodeManagerUtils'; +import { NavNodeInfoResource } from '../../../shared/NodesManager/NavNodeInfoResource'; import { TreeNodeMenu } from '../TreeNodeMenu/TreeNodeMenu'; const styles = composes( @@ -27,6 +26,14 @@ const styles = composes( } `, css` + TreeNodeControl { + opacity: 1; + transition: opacity 0.2s ease; + + &[|outdated] { + opacity: 0.5; + } + } TreeNodeControl:hover > portal, TreeNodeControl:global([aria-selected=true]) > portal, portal:focus-within { @@ -37,6 +44,8 @@ const styles = composes( } status { position: absolute; + opacity: 0; + transition: opacity 0.3s ease; bottom: 0; right: 0; box-sizing: border-box; @@ -44,6 +53,10 @@ const styles = composes( height: 8px; border-radius: 50%; border: 1px solid; + + &[|connected] { + opacity: 1; + } } portal { box-sizing: border-box; @@ -61,28 +74,23 @@ export const NavigationNodeControl: React.FC = observer(function Navigati node, }) { const context = useContext(TreeNodeContext); - const connectionInfoResource = useService(ConnectionInfoResource); + const navNodeInfoResource = useService(NavNodeInfoResource); + const outdated = navNodeInfoResource.isOutdated(node.id) && !context?.loading; - let connected = false; - - if (node.objectFeatures.includes(EObjectFeature.dataSource)) { - const connectionInfo = connectionInfoResource.get(NodeManagerUtils.connectionNodeIdToConnectionId(node.id)); - - connected = !!connectionInfo?.connected; - } + const connected = node.objectFeatures.includes(EObjectFeature.dataSourceConnected); const onClickHandler = useCallback((event: React.MouseEvent) => { context?.select(event.ctrlKey || event.metaKey); }, [context]); return styled(useStyles(TREE_NODE_STYLES, styles))( - + - {connected && } + {node.name} - + diff --git a/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/NavigationNode/NavigationNodeNested.tsx b/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/NavigationNode/NavigationNodeNested.tsx index 2749281ea7..8e5348322e 100644 --- a/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/NavigationNode/NavigationNodeNested.tsx +++ b/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/NavigationNode/NavigationNodeNested.tsx @@ -11,57 +11,49 @@ import { observer } from 'mobx-react-lite'; import { useContext, useMemo } from 'react'; import styled from 'reshadow'; -import { TreeNodeContext, TreeNodeNested, TREE_NODE_STYLES } from '@cloudbeaver/core-blocks'; -import { useService } from '@cloudbeaver/core-di'; -import { resourceKeyList } from '@cloudbeaver/core-sdk'; +import { TreeNodeNested, TREE_NODE_STYLES } from '@cloudbeaver/core-blocks'; import { useStyles } from '@cloudbeaver/core-theming'; -import { NavNodeInfoResource } from '../../../shared/NodesManager/NavNodeInfoResource'; -import { useChildren } from '../../../shared/useChildren'; +import { TreeContext } from '../../TreeContext'; interface Props { nodeId: string; component: React.FC<{ nodeId: string; }>; -} - -function isDefined(val: T | undefined | null): val is T { - return val !== undefined && val !== null; + root?: boolean; } export const NavigationNodeNested: React.FC = observer(function NavigationNodeNested({ nodeId, component, + root, }) { const styles = useStyles(TREE_NODE_STYLES); - const context = useContext(TreeNodeContext); - const navNodeInfo = useService(NavNodeInfoResource); - const childrenInfo = useChildren(nodeId); + const treeContext = useContext(TreeContext); - const children = useMemo(() => computed(() => { - if (!childrenInfo?.children) { - return []; - } + const children = useMemo( + () => computed(() => treeContext?.tree.getNodeChildren(nodeId) || []), + [nodeId, treeContext?.tree] + ).get(); - const childrenEntities = navNodeInfo.get(resourceKeyList(childrenInfo.children)).filter(isDefined); - - if (!context?.filterValue) { - return childrenEntities; - } - - return childrenEntities.filter(child => child.name?.toLowerCase().includes(context.filterValue.toLowerCase())); - }), [childrenInfo.children, context?.filterValue, navNodeInfo]).get(); - - if (!children.length || !context?.expanded) { + if (children.length === 0) { return null; } const NavigationNode = component; + if (root) { + return styled(styles)( + <> + {children.map(child => )} + + ); + } + return styled(styles)( - {children.map(child => )} + {children.map(child => )} ); }); diff --git a/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/NavigationNodeElement.tsx b/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/NavigationNodeElement.tsx index 44dc1db873..acd7c7ed8f 100644 --- a/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/NavigationNodeElement.tsx +++ b/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/NavigationNodeElement.tsx @@ -7,8 +7,12 @@ */ import { observer } from 'mobx-react-lite'; +import { useContext } from 'react'; -import { useNode } from '../../shared/NodesManager/useNode'; +import { useService } from '@cloudbeaver/core-di'; + +import { NavNodeInfoResource } from '../../shared/NodesManager/NavNodeInfoResource'; +import { TreeContext } from '../TreeContext'; import { NavigationNode } from './NavigationNode'; interface NavigationTreeNodeProps { @@ -18,11 +22,27 @@ interface NavigationTreeNodeProps { export const NavigationNodeElement = observer(function NavigationNodeElement({ nodeId, }: NavigationTreeNodeProps) { - const { node } = useNode(nodeId); + const context = useContext(TreeContext); + const navNodeInfoResource = useService(NavNodeInfoResource); + + if (context?.tree.renderers) { + for (const renderer of context.tree.renderers) { + const CustomRenderer = renderer(nodeId); + + if (CustomRenderer === undefined) { + continue; + } + + return ; + } + } + + const node = navNodeInfoResource.get(nodeId); if (!node) { return null; } + // TODO: after node update reference can be lost and NavigationNode skip update return ; }); diff --git a/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/TreeNodeMenu/TreeNodeMenu.tsx b/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/TreeNodeMenu/TreeNodeMenu.tsx index 03eb1f53ab..cea859bdf1 100644 --- a/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/TreeNodeMenu/TreeNodeMenu.tsx +++ b/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/TreeNodeMenu/TreeNodeMenu.tsx @@ -45,7 +45,7 @@ export const TreeNodeMenu = observer(function TreeNodeMenu({ } return styled(treeNodeMenuStyles)( - + ); diff --git a/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/TreeNodeMenu/treeNodeMenuStyles.ts b/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/TreeNodeMenu/treeNodeMenuStyles.ts index d536d2827e..31bc93f280 100644 --- a/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/TreeNodeMenu/treeNodeMenuStyles.ts +++ b/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/TreeNodeMenu/treeNodeMenuStyles.ts @@ -12,7 +12,7 @@ export const treeNodeMenuStyles = css` MenuTrigger { margin-left: 16px; padding: 0; - height: 20px; + height: 16px; &:before { display: none; diff --git a/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/useNavigationNode.ts b/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/useNavigationNode.ts index 5dc903fd17..642475db1a 100644 --- a/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/useNavigationNode.ts +++ b/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/useNavigationNode.ts @@ -6,15 +6,14 @@ * you may not use this file except in compliance with the License. */ -import { useContext, useEffect, useState } from 'react'; +import { useContext, useEffect } from 'react'; -import { useService } from '@cloudbeaver/core-di'; +import { useObjectRef } from '@cloudbeaver/core-blocks'; import type { NavNode } from '../../shared/NodesManager/EntityTypes'; import { EObjectFeature } from '../../shared/NodesManager/EObjectFeature'; import { useNode } from '../../shared/NodesManager/useNode'; import { useChildren } from '../../shared/useChildren'; -import { NavigationTreeService } from '../NavigationTreeService'; import { TreeContext } from '../TreeContext'; interface INavigationNode { @@ -25,98 +24,62 @@ interface INavigationNode { loading: boolean; expanded: boolean; leaf: boolean; - handleExpand: () => void; - handleOpen: () => void; - handleSelect: (isMultiple?: boolean) => void; - handleFilter: (value: string) => void; + handleExpand: () => Promise; + handleOpen: () => Promise; + handleSelect: (isMultiple?: boolean, nested?: boolean) => Promise; + handleFilter: (value: string) => Promise; filterValue: string; } -export function useNavigationNode(node: NavNode): INavigationNode { - const context = useContext(TreeContext); - const navigationTreeService = useService(NavigationTreeService); - const [processing, setProcessing] = useState(false); - const { isLoading, isOutdated } = useNode(node.id); - const children = useChildren(node.id); - const loading = isLoading() || children.isLoading() || processing; +export function useNavigationNode({ id }: NavNode): INavigationNode { + const contextRef = useObjectRef({ + context: useContext(TreeContext), + }); + const { node, isLoading } = useNode(id); - const isExpanded = navigationTreeService.isNodeExpanded(node.id); - let leaf = isLeaf(node) || (children.children?.length === 0 && !children.isOutdated()); + // TODO: hack to provide actual node information + if (!node) { + throw new Error('Node should exists'); + } + + const children = useChildren(node.id); + const loading = isLoading() || children.isLoading(); + + const state = contextRef.context?.tree.getNodeState(node.id); + const isExpanded = state?.expanded || false; + + let leaf = isLeaf(node); let expanded = isExpanded && !leaf; if ( node.objectFeatures.includes(EObjectFeature.dataSource) - && ( - !node.objectFeatures.includes(EObjectFeature.dataSourceConnected) - || (!children.children && isOutdated()) - ) + && !node.objectFeatures.includes(EObjectFeature.dataSourceConnected) ) { leaf = false; expanded = false; } - const handleExpand = async () => { - if (!expanded) { - const timeout = setTimeout(() => setProcessing(true), 1); - const state = await navigationTreeService.loadNestedNodes(node.id); - clearTimeout(timeout); - setProcessing(false); - if (!state) { - navigationTreeService.expandNode(node.id, false); - return; + const handleOpen = async () => contextRef.context?.onOpen?.(node); + const handleExpand = async () => contextRef.context?.tree.expand(node, !expanded); + const handleSelect = async ( + multiple = false, + nested = false + ) => contextRef.context?.tree.select(node, multiple, nested); + const handleFilter = async (value: string) => contextRef.context?.tree.filter(node, value); + + useEffect(() => () => { + if (!contextRef.context?.selectionTree) { + const state = contextRef.context?.tree.getNodeState(node.id); + + if (state?.selected) { + contextRef.context?.tree.select(node, true, false); } } - navigationTreeService.expandNode(node.id, !expanded); - }; - - const handleOpen = async () => { - setProcessing(true); - try { - await context?.onOpen?.(node); - } finally { - setProcessing(false); - } - }; - - const handleSelect = (multiple = false) => { - context?.onSelect?.(node, multiple); - }; - - const handleFilter = (value: string) => { - context?.onFilter?.(node, value); - }; - - // TODO: probably should be refactored - useEffect(() => { - if (expanded && children.isOutdated() && !children.isLoading() && children.isLoaded() && !isOutdated()) { - setProcessing(true); - navigationTreeService - .loadNestedNodes(node.id) - .then(state => { - setProcessing(false); - if (!state) { - navigationTreeService.expandNode(node.id, false); - } - }); - } - }, [expanded, children.isOutdated(), children.isLoading(), children.isLoaded(), isOutdated(), node]); - - useEffect(() => () => { - if (!context?.selectionTree && node && context?.isSelected?.(node)) { - context.onSelect?.(node, true); - } - }, [context, node.id]); - - useEffect(() => () => { - // TODO: seems like selection & expand should be specific for separate tree definitions - navigationTreeService.expandNode(node.id, false); - - context?.treeNodesState?.delete(node.id); }, [node.id]); return { - control: context?.control, - selected: context?.isSelected?.(node) || false, + control: contextRef.context?.control, + selected: state?.selected || false, loading, expanded, leaf, @@ -124,7 +87,7 @@ export function useNavigationNode(node: NavNode): INavigationNode { handleOpen, handleSelect, handleFilter, - filterValue: context?.treeNodesState?.get(node.id).filter || '', + filterValue: state?.filter || '', }; } diff --git a/webapp/packages/core-app/src/NavigationTree/NavigationTreeService.ts b/webapp/packages/core-app/src/NavigationTree/NavigationTreeService.ts index f4eaffc6bb..0acfd9ab2e 100644 --- a/webapp/packages/core-app/src/NavigationTree/NavigationTreeService.ts +++ b/webapp/packages/core-app/src/NavigationTree/NavigationTreeService.ts @@ -6,28 +6,22 @@ * you may not use this file except in compliance with the License. */ -import { action, computed, makeObservable } from 'mobx'; +import { action, makeObservable } from 'mobx'; -import { ConnectionAuthService } from '@cloudbeaver/core-connections'; +import { ConnectionAuthService, ConnectionInfoResource } from '@cloudbeaver/core-connections'; import { injectable } from '@cloudbeaver/core-di'; import { NotificationService } from '@cloudbeaver/core-events'; import { Executor, IExecutor } from '@cloudbeaver/core-executor'; -import { ResourceKeyUtils } from '@cloudbeaver/core-sdk'; import { MetadataMap } from '@cloudbeaver/core-utils'; import type { IActiveView } from '@cloudbeaver/core-view'; -import { CoreSettingsService } from '../CoreSettingsService'; import { EObjectFeature } from '../shared/NodesManager/EObjectFeature'; import { NavNodeExtensionsService } from '../shared/NodesManager/NavNodeExtensionsService'; -import { NavNodeInfoResource, ROOT_NODE_PATH } from '../shared/NodesManager/NavNodeInfoResource'; +import { ROOT_NODE_PATH } from '../shared/NodesManager/NavNodeInfoResource'; import { NavNodeManagerService } from '../shared/NodesManager/NavNodeManagerService'; import { NavTreeResource } from '../shared/NodesManager/NavTreeResource'; import { NodeManagerUtils } from '../shared/NodesManager/NodeManagerUtils'; - -export interface INavigationNodeMetadata { - selected: boolean; - expanded: boolean; -} +import type { ITreeNodeState } from './useElementsTree'; export interface INavigationNodeSelectionData { id: string; @@ -36,66 +30,56 @@ export interface INavigationNodeSelectionData { @injectable() export class NavigationTreeService { - readonly navigationTreeMetadata: MetadataMap; + readonly treeState: MetadataMap; readonly nodeSelectionTask: IExecutor; - get childrenLimit(): number { - return this.coreSettingsService.settings.getValue('app.navigationTree.childrenLimit'); - } - constructor( private navNodeManagerService: NavNodeManagerService, private notificationService: NotificationService, private connectionAuthService: ConnectionAuthService, + private connectionInfoResource: ConnectionInfoResource, private navNodeExtensionsService: NavNodeExtensionsService, - private navTreeResource: NavTreeResource, - private coreSettingsService: CoreSettingsService, - private navNodeInfoResource: NavNodeInfoResource + private navTreeResource: NavTreeResource ) { makeObservable(this, { - childrenLimit: computed, unselectAll: action, }); + this.treeState = new MetadataMap(() => ({ + filter: '', + expanded: false, + selected: false, + })); this.nodeSelectionTask = new Executor(); - this.navigationTreeMetadata = new MetadataMap(() => ({ - selected: false, - expanded: false, - })); this.getView = this.getView.bind(this); - - this.navNodeInfoResource.onItemDelete.addHandler(key => { - ResourceKeyUtils.forEach(key, key => { - this.navigationTreeMetadata.delete(key); - }); - }); - this.navTreeResource.onItemDelete.addHandler(key => { - ResourceKeyUtils.forEach(key, key => { - this.expandNode(key, false); - }); - }); } getChildren(id: string): string[] | undefined { - const children = this.navTreeResource.get(id); - - if (children) { - return children.slice(0, this.childrenLimit); - } - - return children; + return this.navTreeResource.get(id); } async navToNode(id: string, parentId: string): Promise { await this.navNodeManagerService.navToNode(id, parentId); } - async loadNestedNodes(id = ROOT_NODE_PATH): Promise { + async loadNestedNodes(id = ROOT_NODE_PATH, tryConnect?: boolean): Promise { try { - if (this.isConnectionNode(id) && !(await this.tryInitConnection(id))) { - return false; + if (this.isConnectionNode(id)) { + const connection = await this.connectionInfoResource.load( + NodeManagerUtils.connectionNodeIdToConnectionId(id) + ); + + if (!connection.connected && !tryConnect) { + return false; + } + + const connected = await this.tryInitConnection(id); + + if (!connected) { + return false; + } } - await this.navNodeManagerService.loadTree(id); + await this.navTreeResource.load(id); return true; } catch (exception) { this.notificationService.logException(exception); @@ -108,7 +92,7 @@ export class NavigationTreeService { await this.unselectAll(); } - const metadata = this.navigationTreeMetadata.get(id); + const metadata = this.treeState.get(id); metadata.selected = !metadata.selected; await this.nodeSelectionTask.execute({ @@ -118,20 +102,20 @@ export class NavigationTreeService { } isNodeExpanded(navNodeId: string): boolean { - return this.navigationTreeMetadata.get(navNodeId).expanded; + return this.treeState.get(navNodeId).expanded; } isNodeSelected(navNodeId: string): boolean { - return this.navigationTreeMetadata.get(navNodeId).selected; + return this.treeState.get(navNodeId).selected; } expandNode(navNodeId: string, state: boolean): void { - const metadata = this.navigationTreeMetadata.get(navNodeId); + const metadata = this.treeState.get(navNodeId); metadata.expanded = state; } getView(): IActiveView | null { - const element = Array.from(this.navigationTreeMetadata).find(([key, metadata]) => metadata.selected); + const element = Array.from(this.treeState).find(([key, metadata]) => metadata.selected); if (!element) { return null; @@ -144,7 +128,7 @@ export class NavigationTreeService { } private async unselectAll() { - for (const [id, metadata] of this.navigationTreeMetadata) { + for (const [id, metadata] of this.treeState) { metadata.selected = false; await this.nodeSelectionTask.execute({ id, diff --git a/webapp/packages/core-app/src/NavigationTree/TreeContext.ts b/webapp/packages/core-app/src/NavigationTree/TreeContext.ts index feda4b1ee3..31e9d5ecc4 100644 --- a/webapp/packages/core-app/src/NavigationTree/TreeContext.ts +++ b/webapp/packages/core-app/src/NavigationTree/TreeContext.ts @@ -8,22 +8,16 @@ import { createContext } from 'react'; -import type { MetadataMap } from '@cloudbeaver/core-utils'; - import type { NavNode } from '../shared/NodesManager/EntityTypes'; -import type { ITreeNodeState } from './ElementsTree'; +import type { IElementsTree } from './useElementsTree'; export interface ITreeContext { - treeNodesState?: MetadataMap; - getTreeNodeState?: (node: NavNode) => ITreeNodeState; + tree: IElementsTree; selectionTree: boolean; control?: React.FC<{ node: NavNode; }>; onOpen?: (node: NavNode) => Promise | void; - onSelect?: (node: NavNode, multiple: boolean) => void; - isSelected?: (node: NavNode) => boolean; - onFilter?: (node: NavNode, value: string) => void; } export const TreeContext = createContext(null); diff --git a/webapp/packages/core-app/src/NavigationTree/elementsTreeNameFilter.ts b/webapp/packages/core-app/src/NavigationTree/elementsTreeNameFilter.ts new file mode 100644 index 0000000000..9d1672d885 --- /dev/null +++ b/webapp/packages/core-app/src/NavigationTree/elementsTreeNameFilter.ts @@ -0,0 +1,35 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2021 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { resourceKeyList } from '@cloudbeaver/core-sdk'; +import type { MetadataMap } from '@cloudbeaver/core-utils'; + +import type { NavNode } from '../shared/NodesManager/EntityTypes'; +import type { NavNodeInfoResource } from '../shared/NodesManager/NavNodeInfoResource'; +import type { IElementsTreeFilter, ITreeNodeState } from './useElementsTree'; + +function isDefined(val: T | undefined | null): val is T { + return val !== undefined && val !== null; +} + +export function elementsTreeNameFilter(resource: NavNodeInfoResource): IElementsTreeFilter { + return (node: NavNode, children: string[], state: MetadataMap) => { + const nodeState = state.get(node.id); + + if (nodeState.filter === '') { + return children; + } + + const nodes = resource + .get(resourceKeyList(children)) + .filter(isDefined) + .filter(child => child.name?.toLowerCase().includes(nodeState.filter.toLowerCase())); + + return nodes.map(node => node.id); + }; +} diff --git a/webapp/packages/core-app/src/NavigationTree/navigationTreeConnectionGroupFilter.ts b/webapp/packages/core-app/src/NavigationTree/navigationTreeConnectionGroupFilter.ts new file mode 100644 index 0000000000..1b2d7758cb --- /dev/null +++ b/webapp/packages/core-app/src/NavigationTree/navigationTreeConnectionGroupFilter.ts @@ -0,0 +1,106 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2021 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { ConnectionInfoResource, EConnectionFeature } from '@cloudbeaver/core-connections'; +import { resourceKeyList } from '@cloudbeaver/core-sdk'; + +import type { NavNode } from '../shared/NodesManager/EntityTypes'; +import { EObjectFeature } from '../shared/NodesManager/EObjectFeature'; +import { NavNodeInfoResource, ROOT_NODE_PATH } from '../shared/NodesManager/NavNodeInfoResource'; +import { NodeManagerUtils } from '../shared/NodesManager/NodeManagerUtils'; +import type { IElementsTreeFilter } from './useElementsTree'; + +export const NAVIGATION_TREE_CONNECTION_GROUPS = { + unsorted: 'nav-tree://connection-group/unsorted', + manageable: 'nav-tree://connection-group/manageable', + unmanageable: 'nav-tree://connection-group/unmanageable', +}; + +function isDefined(val: T | undefined | null): val is T { + return val !== undefined && val !== null; +} + +function sortManageable(connectionInfoResource: ConnectionInfoResource): (nodeA: NavNode, nodeB: NavNode) => number { + return (nodeA: NavNode, nodeB: NavNode): number => { + const nodeAConnection = nodeA.objectFeatures.includes(EObjectFeature.dataSource); + const nodeBConnection = nodeB.objectFeatures.includes(EObjectFeature.dataSource); + + if (!nodeAConnection || !nodeBConnection) { + if (nodeAConnection === nodeBConnection) { + return 0; + } + return nodeAConnection ? 1 : -1; + } + + const connectionA = connectionInfoResource.get(NodeManagerUtils.connectionNodeIdToConnectionId(nodeA.id)); + const connectionB = connectionInfoResource.get(NodeManagerUtils.connectionNodeIdToConnectionId(nodeB.id)); + + const nodeAManageable = connectionA?.features.includes(EConnectionFeature.manageable); + const nodeBManageable = connectionB?.features.includes(EConnectionFeature.manageable); + + if (nodeAManageable === nodeBManageable) { + return 0; + } + + return nodeBManageable ? 1 : -1; + }; +} + +export function navigationTreeConnectionGroupFilter( + connectionInfoResource: ConnectionInfoResource, + resource: NavNodeInfoResource +): IElementsTreeFilter { + return (node: NavNode, children: string[]) => { + if (node.id !== ROOT_NODE_PATH) { + return children; + } + + const nodes = resource + .get(resourceKeyList(children)) + .filter(isDefined) + .sort(sortManageable(connectionInfoResource)); + + let groupedChildren: string[] = []; + let lastGroup = NAVIGATION_TREE_CONNECTION_GROUPS.unsorted; + let groups = 0; + + for (const node of nodes) { + const connection = connectionInfoResource.get(NodeManagerUtils.connectionNodeIdToConnectionId(node.id)); + const manageable = connection?.features.includes(EConnectionFeature.manageable); + + let nextGroup = NAVIGATION_TREE_CONNECTION_GROUPS.unsorted; + + if (manageable) { + nextGroup = NAVIGATION_TREE_CONNECTION_GROUPS.manageable; + } else if (connection) { + nextGroup = NAVIGATION_TREE_CONNECTION_GROUPS.unmanageable; + } + + if (nextGroup !== lastGroup) { + if (nextGroup === NAVIGATION_TREE_CONNECTION_GROUPS.manageable) { + groupedChildren.push(NAVIGATION_TREE_CONNECTION_GROUPS.manageable); + } else { + groupedChildren.push(NAVIGATION_TREE_CONNECTION_GROUPS.unmanageable); + } + groups++; + lastGroup = nextGroup; + } + + groupedChildren.push(node.id); + } + + if (groups === 1) { + groupedChildren = groupedChildren.filter(id => + id !== NAVIGATION_TREE_CONNECTION_GROUPS.manageable + && id !== NAVIGATION_TREE_CONNECTION_GROUPS.unmanageable + ); + } + + return groupedChildren; + }; +} diff --git a/webapp/packages/core-app/src/NavigationTree/navigationTreeConnectionGroupRenderer.tsx b/webapp/packages/core-app/src/NavigationTree/navigationTreeConnectionGroupRenderer.tsx new file mode 100644 index 0000000000..3f787c880a --- /dev/null +++ b/webapp/packages/core-app/src/NavigationTree/navigationTreeConnectionGroupRenderer.tsx @@ -0,0 +1,53 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2021 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import styled, { css } from 'reshadow'; + +import { Translate } from '@cloudbeaver/core-localization'; +import { composes, useStyles } from '@cloudbeaver/core-theming'; + +import { NAVIGATION_TREE_CONNECTION_GROUPS } from './navigationTreeConnectionGroupFilter'; +import type { ElementsTreeCustomRendererComponent } from './useElementsTree'; + +export function navigationTreeConnectionGroupRenderer(nodeId: string): ElementsTreeCustomRendererComponent | undefined { + if (nodeId !== NAVIGATION_TREE_CONNECTION_GROUPS.manageable + && nodeId !== NAVIGATION_TREE_CONNECTION_GROUPS.unmanageable) { + return; + } + + if (nodeId === NAVIGATION_TREE_CONNECTION_GROUPS.manageable) { + return ManageableGroup; + } + + return UnManageableGroup; +} + +const styles = composes( + css` + connection-group { + composes: theme-text-text-hint-on-light from global; + } + `, + css` + connection-group { + composes: theme-typography--caption from global; + padding: 4px 12px; + + &:not(:first-child) { + margin-top: 8px; + } + } + ` +); + +const ManageableGroup: ElementsTreeCustomRendererComponent = function ManageableGroup() { + return styled(useStyles(styles))(); +}; +const UnManageableGroup: ElementsTreeCustomRendererComponent = function UnManageableGroup() { + return styled(useStyles(styles))(); +}; diff --git a/webapp/packages/core-app/src/NavigationTree/useElementsTree.ts b/webapp/packages/core-app/src/NavigationTree/useElementsTree.ts new file mode 100644 index 0000000000..fe7cd5b01c --- /dev/null +++ b/webapp/packages/core-app/src/NavigationTree/useElementsTree.ts @@ -0,0 +1,275 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2021 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { observable } from 'mobx'; +import { useState } from 'react'; + +import { useExecutor, useObjectRef } from '@cloudbeaver/core-blocks'; +import { useService } from '@cloudbeaver/core-di'; +import { ResourceKeyUtils } from '@cloudbeaver/core-sdk'; +import { MetadataMap } from '@cloudbeaver/core-utils'; + +import type { NavNode } from '../shared/NodesManager/EntityTypes'; +import { NavNodeInfoResource } from '../shared/NodesManager/NavNodeInfoResource'; +import { NavTreeResource } from '../shared/NodesManager/NavTreeResource'; +import { NavigationTreeService } from './NavigationTreeService'; + +export type ElementsTreeCustomRendererComponent = React.FC<{ + nodeId: string; + component: React.FC<{ + nodeId: string; + }>; +}>; + +export type IElementsTreeCustomRenderer = (nodeId: string) => ElementsTreeCustomRendererComponent | undefined; + +export type IElementsTreeFilter = ( + node: NavNode, + children: string[], + state: MetadataMap +) => string[]; + +export interface ITreeNodeState { + filter: string; + selected: boolean; + expanded: boolean; +} + +interface IOptions { + root: string; + localState?: MetadataMap; + filters?: IElementsTreeFilter[]; + renderers?: IElementsTreeCustomRenderer[]; + customSelect?: (node: NavNode, multiple: boolean, nested: boolean) => Promise | void; + isGroup?: (node: NavNode) => boolean; + onExpand?: (node: NavNode, state: boolean) => Promise | void; + onSelect?: (node: NavNode, state: boolean) => Promise | void; + onFilter?: (node: NavNode, value: string) => Promise | void; +} + +export interface IElementsTree { + root: string; + renderers: IElementsTreeCustomRenderer[]; + state: MetadataMap; + getNodeState: (nodeId: string) => ITreeNodeState; + getNodeChildren: (nodeId: string) => string[]; + filter: (node: NavNode, value: string) => Promise; + select: (node: NavNode, multiple: boolean, nested: boolean) => Promise; + expand: (node: NavNode, state: boolean) => Promise; +} + +export function useElementsTree(options: IOptions): IElementsTree { + const navNodeInfoResource = useService(NavNodeInfoResource); + const navTreeService = useService(NavigationTreeService); + const navTreeResource = useService(NavTreeResource); + + const [localTreeNodesState] = useState(() => new MetadataMap(() => ({ + filter: '', + selected: false, + expanded: false, + }))); + + const state = options.localState || localTreeNodesState; + + async function loadTree(nodeId: string) { + let children = [nodeId]; + + while (children.length > 0) { + const nextChildren: string[] = []; + + for (const child of children) { + const nodeState = state.get(child); + if (!nodeState.expanded && child !== options.root) { + continue; + } + + const loaded = await navTreeService.loadNestedNodes(child, false); + + if (!loaded) { + const node = navNodeInfoResource.get(child); + + if (node) { + await elementsTree.expand(node, false); + } + continue; + } + + nextChildren.push(...(navTreeResource.get(child) || [])); + } + + children = nextChildren; + } + } + + function getNodeChildren(nodeId: string): string[] { + const node = navNodeInfoResource.get(nodeId); + + if (!node) { + return []; // Maybe filter should accept nodeId, so we be able to apply filters to empty node + } + + return (options.filters || []) + .reduce( + (children, filter) => filter(node, children, state), + navTreeService.getChildren(node.id) || [] + ); + } + + function getNestedChildren(nodeId: string): string [] { + const nestedChildren: string[] = []; + const prevChildren = getNodeChildren(nodeId); + nestedChildren.push(...prevChildren); + + while (prevChildren.length) { + const nodeKey = prevChildren.shift()!; + const children = getNodeChildren(nodeKey); + prevChildren.push(...children); + nestedChildren.push(...children); + } + + return nestedChildren; + } + + async function clearSelection(nodeId: string) { + const node = navNodeInfoResource.get(nodeId); + + const ignore = node && options.isGroup?.(node) + ? getNestedChildren(nodeId) + : []; + + for (const [id, nodeState] of state) { + if (nodeState.selected && id !== nodeId && !ignore.includes(id)) { + nodeState.selected = false; + + if (options.onSelect) { + const node = navNodeInfoResource.get(id); + + if (node) { + await options.onSelect(node, false); + } + } + } + } + } + + async function setSelection(nodeId: string, selected: boolean): Promise { + const node = navNodeInfoResource.get(nodeId); + + if (!node) { + return; + } + + const treeNodeState = state.get(nodeId); + + if (treeNodeState.selected === selected) { + return; + } + + if (options.isGroup?.(node)) { + const children = getNodeChildren(nodeId); + + for (const child of children) { + await setSelection(child, selected); + } + + if (children.length === 0) { + return; + } + } + + treeNodeState.selected = selected; + await options.onSelect?.(node, selected); + } + + const elementsTree = useObjectRef({ + root: options.root, + renderers: options.renderers || [], + state, + getNodeState(nodeId: string) { + return this.state.get(nodeId); + }, + getNodeChildren, + async filter(node: NavNode, value: string) { + const treeNodeState = this.state.get(node.id); + treeNodeState.filter = value; + + await options.onFilter?.(node, value); + }, + async expand(node: NavNode, state: boolean) { + const treeNodeState = this.state.get(node.id); + + try { + if (state) { + state = await navTreeService.loadNestedNodes(node.id, true); + } + + await options.onExpand?.(node, state); + treeNodeState.expanded = state; + + if (state) { + await loadTree(node.id); + } + } catch { + treeNodeState.expanded = false; + } + }, + async select(node: NavNode, multiple: boolean, nested: boolean) { + if (options.customSelect) { + await options.customSelect(node, multiple, nested); + return; + } + + const treeNodeState = this.state.get(node.id); + + if (!multiple) { + await clearSelection(node.id); + + if (treeNodeState.selected) { + return; + } + } + + await setSelection(node.id, !treeNodeState.selected); + }, + }, undefined, { root: observable.ref, renderers: observable.ref }); + + useExecutor({ + executor: navNodeInfoResource.onDataOutdated, + postHandlers: [function refreshRoot() { + loadTree(options.root); + }], + }); + useExecutor({ + executor: navTreeResource.onNodeRefresh, + postHandlers: [loadTree], + }); + + useExecutor({ + executor: navNodeInfoResource.onItemDelete, + handlers: [function deleteNodeState(key) { + ResourceKeyUtils.forEach(key, key => { + state.delete(key); + }); + }], + }); + + useExecutor({ + executor: navTreeResource.onItemDelete, + handlers: [async function collapseDeletedTree(key) { + await ResourceKeyUtils.forEachAsync(key, async key => { + const node = navNodeInfoResource.get(key); + + if (node) { + await elementsTree.expand(node, false); + } + }); + }], + }); + + return elementsTree; +} diff --git a/webapp/packages/core-app/src/NavigationTree/useNavigationTree.ts b/webapp/packages/core-app/src/NavigationTree/useNavigationTree.ts index 8883ec0a03..143d51a113 100644 --- a/webapp/packages/core-app/src/NavigationTree/useNavigationTree.ts +++ b/webapp/packages/core-app/src/NavigationTree/useNavigationTree.ts @@ -6,39 +6,30 @@ * you may not use this file except in compliance with the License. */ -import { useCallback } from 'react'; - +import { useObjectRef } from '@cloudbeaver/core-blocks'; import { useService } from '@cloudbeaver/core-di'; import type { NavNode } from '../shared/NodesManager/EntityTypes'; import { NavigationTreeService } from './NavigationTreeService'; interface INavigationTree { - isSelected: (node: NavNode) => boolean; + navigationTreeService: NavigationTreeService; handleOpen: (node: NavNode) => Promise; - handleSelect: (node: NavNode, isMultiple: boolean) => void; + handleSelect: (node: NavNode, state: boolean) => void; } +const bindActions: Array = ['handleOpen', 'handleSelect']; + export function useNavigationTree(): INavigationTree { const navigationTreeService = useService(NavigationTreeService); - const handleOpen = useCallback( - (node: NavNode) => navigationTreeService.navToNode(node.id, node.parentId), - [navigationTreeService] - ); - - const handleSelect = useCallback( - (node: NavNode, multiple: boolean) => navigationTreeService.selectNode(node.id, multiple), - [navigationTreeService]); - - const isSelected = useCallback( - (node: NavNode) => navigationTreeService.isNodeSelected(node.id), - [navigationTreeService] - ); - - return { - isSelected, - handleOpen, - handleSelect, - }; + return useObjectRef({ + navigationTreeService, + handleOpen(node: NavNode) { + return this.navigationTreeService.navToNode(node.id, node.parentId); + }, + handleSelect(node: NavNode, state: boolean) { + return this.navigationTreeService.selectNode(node.id, state); + }, + }, { navigationTreeService }, undefined, bindActions); } diff --git a/webapp/packages/core-app/src/TopNavBar/ConnectionSchemaManager/ConnectionSelector/ConnectionSelector.tsx b/webapp/packages/core-app/src/TopNavBar/ConnectionSchemaManager/ConnectionSelector/ConnectionSelector.tsx index 82fea2d57b..01995e2a45 100644 --- a/webapp/packages/core-app/src/TopNavBar/ConnectionSchemaManager/ConnectionSelector/ConnectionSelector.tsx +++ b/webapp/packages/core-app/src/TopNavBar/ConnectionSchemaManager/ConnectionSelector/ConnectionSelector.tsx @@ -9,7 +9,10 @@ import { observer } from 'mobx-react-lite'; import styled, { css, use } from 'reshadow'; +import { useMapResource } from '@cloudbeaver/core-blocks'; +import { DBDriverResource } from '@cloudbeaver/core-connections'; import { useController } from '@cloudbeaver/core-di'; +import { EPermission, usePermission } from '@cloudbeaver/core-root'; import { useStyles } from '@cloudbeaver/core-theming'; import { TopMenuItem } from '../../shared/TopMenuItem'; @@ -23,6 +26,11 @@ const menuStyles = css` `; const connectionMenu = css` + MenuItem IconOrImage { + background-color: #fff; + padding: 2px; + border-radius: 2px; + } menu-trigger-icon { background-color: #fff; border-radius: 4px; @@ -34,7 +42,7 @@ const connectionMenu = css` } `; -const style = css` +const styles = css` connection-selector { display: flex; height: 100%; @@ -47,11 +55,18 @@ const style = css` } `; export const ConnectionSelector = observer(function ConnectionSelector() { + const style = useStyles(styles); const controller = useController(ConnectionSelectorController); + const isEnabled = usePermission(EPermission.public); + const driver = useMapResource(DBDriverResource, null, { onLoad: resource => isEnabled && resource.loadAll() }); const ConnectionMenu = TopMenuItem; const SchemaOrCatalogMenu = TopMenuItem; - return styled(useStyles(style))( + if (!isEnabled || !driver.isLoaded()) { + return null; + } + + return styled(style)( {controller.isObjectContainerSelectorVisible && ( diff --git a/webapp/packages/core-app/src/TopNavBar/ConnectionSchemaManager/ConnectionSelector/ConnectionSelectorController.ts b/webapp/packages/core-app/src/TopNavBar/ConnectionSchemaManager/ConnectionSelector/ConnectionSelectorController.ts index 43f27a0348..f50e8646f1 100644 --- a/webapp/packages/core-app/src/TopNavBar/ConnectionSchemaManager/ConnectionSelector/ConnectionSelectorController.ts +++ b/webapp/packages/core-app/src/TopNavBar/ConnectionSchemaManager/ConnectionSelector/ConnectionSelectorController.ts @@ -12,7 +12,8 @@ import { ConnectionInfoResource, DBDriverResource, Connection, - ConnectionsManagerService + ConnectionsManagerService, + compareConnectionsInfo } from '@cloudbeaver/core-connections'; import { injectable } from '@cloudbeaver/core-di'; import { ComputedMenuItemModel, ComputedMenuPanelModel, IMenuItem } from '@cloudbeaver/core-dialogs'; @@ -73,14 +74,14 @@ export class ConnectionSelectorController { || this.getObjectContainerItems().length === 0; } - private get currentConnectionTitle(): string | undefined { + private get currentConnectionTitle(): string { if (this.currentConnection) { return this.currentConnection.name; } return 'app_topnavbar_connection_schema_manager_not_selected'; } - private get currentObjectContainerTitle(): string | undefined { + private get currentObjectContainerTitle(): string { const value = NodeManagerUtils.concatSchemaAndCatalog( this.connectionSelectorService.currentObjectCatalogId, this.connectionSelectorService.currentObjectSchemaId @@ -95,7 +96,7 @@ export class ConnectionSelectorController { private get currentObjectContainerIcon(): string { if (this.connectionSelectorService.currentObjectSchema?.features?.includes(EObjectFeature.schema)) { - // TODO move such kind of icon paths to a set of constants + // TODO move such kind of icon paths to a set of constants return 'schema_system'; } if (this.connectionSelectorService.currentObjectCatalog?.features?.includes(EObjectFeature.catalog)) { @@ -116,6 +117,7 @@ export class ConnectionSelectorController { currentConnectionIcon: computed, objectContainerSelectionDisabled: computed, currentObjectContainerIcon: computed, + isObjectContainerSelectorVisible: computed, }); this.connectionMenu = new ComputedMenuItemModel({ @@ -143,14 +145,20 @@ export class ConnectionSelectorController { } private getConnectionItems(): IMenuItem[] { - return Array.from(this.connectionInfo.data.values()).map(item => { - const menuItem: IMenuItem = { - id: item.id, - title: item.name || item.id, - onClick: () => this.connectionSelectorService.selectConnection(item.id), - }; - return menuItem; - }); + return this.connectionInfo.values + .slice() + .sort(compareConnectionsInfo) + .map(item => { + const icon = this.dbDriverResource.get(item.driverId)?.icon; + + const menuItem: IMenuItem = { + id: item.id, + title: item.name || item.id, + icon, + onClick: () => this.connectionSelectorService.selectConnection(item.id), + }; + return menuItem; + }); } private getObjectContainerItems(): IMenuItem[] { @@ -174,9 +182,16 @@ export class ConnectionSelectorController { ? () => this.connectionSelectorService.selectCatalog(catalogName!) : () => this.connectionSelectorService.selectSchema(schemaName!); + let icon = 'database'; + + if (catalogName && schemaName) { + icon = 'schema_system'; + } + const menuItem: IMenuItem = { id: title, title, + icon, onClick: handler, }; return menuItem; diff --git a/webapp/packages/core-app/src/TopNavBar/MainMenu/MainMenu.tsx b/webapp/packages/core-app/src/TopNavBar/MainMenu/MainMenu.tsx index 45da064f64..e8ce659d61 100644 --- a/webapp/packages/core-app/src/TopNavBar/MainMenu/MainMenu.tsx +++ b/webapp/packages/core-app/src/TopNavBar/MainMenu/MainMenu.tsx @@ -31,7 +31,7 @@ export const MainMenu = observer(function MainMenu() { const mainMenuService = useService(MainMenuService); return styled(styles)( - + {mainMenuService.getMainMenu().map((topItem, i) => ( ))} diff --git a/webapp/packages/core-app/src/TopNavBar/SettingsMenu/SettingsMenu.tsx b/webapp/packages/core-app/src/TopNavBar/SettingsMenu/SettingsMenu.tsx index f13b3ba9da..ff4b750637 100644 --- a/webapp/packages/core-app/src/TopNavBar/SettingsMenu/SettingsMenu.tsx +++ b/webapp/packages/core-app/src/TopNavBar/SettingsMenu/SettingsMenu.tsx @@ -22,7 +22,7 @@ export const SettingsMenu = observer(function SettingsMenu() { const settingsMenuService = useService(SettingsMenuService); return styled(useStyles(settingsMenuStyles))( - + ); diff --git a/webapp/packages/core-app/src/TopNavBar/SettingsMenu/settingsMenuStyles.ts b/webapp/packages/core-app/src/TopNavBar/SettingsMenu/settingsMenuStyles.ts index 05dd5cf8f4..d986965784 100644 --- a/webapp/packages/core-app/src/TopNavBar/SettingsMenu/settingsMenuStyles.ts +++ b/webapp/packages/core-app/src/TopNavBar/SettingsMenu/settingsMenuStyles.ts @@ -26,13 +26,11 @@ export const settingsMenuStyles = css` height: 24px; } menu-panel-item { - flex-direction: row-reverse; & menu-item-text { text-align: right; - margin-left: auto; } & Icon { - transform: rotate(-180deg); + transform: rotate(90deg); } } `; diff --git a/webapp/packages/core-app/src/TopNavBar/shared/TopMenuItem.tsx b/webapp/packages/core-app/src/TopNavBar/shared/TopMenuItem.tsx index 7200b5d3a0..adc93ce70b 100644 --- a/webapp/packages/core-app/src/TopNavBar/shared/TopMenuItem.tsx +++ b/webapp/packages/core-app/src/TopNavBar/shared/TopMenuItem.tsx @@ -25,7 +25,8 @@ type TopMenuItemProps = Omit, 'style'> & { export const TopMenuItem = observer(function TopMenuItem({ menuItem, style = [], ...props }: TopMenuItemProps) { const translate = useTranslate(); - const styles = useStyles(!menuItem.panel ? [] : [...style, topMenuStyles]); + style = [...style, topMenuStyles]; + const styles = useStyles(!menuItem.panel ? [] : style); if (!menuItem.panel) { return ( @@ -44,12 +45,13 @@ export const TopMenuItem = observer(function TopMenuItem({ menuItem, style = [], {...props} panel={menuItem.panel} disabled={menuItem.isDisabled} - style={[...style, topMenuStyles]} + style={style} placement="bottom-end" + modal onClick={() => menuItem.onClick?.()} > {menuItem.icon && ( - + )} diff --git a/webapp/packages/core-app/src/TopNavBar/shared/topMenuStyles.ts b/webapp/packages/core-app/src/TopNavBar/shared/topMenuStyles.ts index 8c45278b29..d835506b17 100644 --- a/webapp/packages/core-app/src/TopNavBar/shared/topMenuStyles.ts +++ b/webapp/packages/core-app/src/TopNavBar/shared/topMenuStyles.ts @@ -12,7 +12,7 @@ import { composes } from '@cloudbeaver/core-theming'; export const topMenuStyles = composes( css` - Menu { + menu-box { composes: theme-text-on-primary from global; } @@ -21,7 +21,7 @@ export const topMenuStyles = composes( } `, css` - Menu { + menu-box { background-color: #338ecc!important; } MenuTrigger { @@ -55,7 +55,9 @@ export const topMenuStyles = composes( background: #236ea0; } } - MenuItem { + MenuItem, + MenuItemCheckbox, + MenuItemRadio { &:hover, &:global([aria-expanded="true"]) { background: #236ea0; } diff --git a/webapp/packages/core-app/src/index.ts b/webapp/packages/core-app/src/index.ts index 60bc7d93e7..314602273b 100644 --- a/webapp/packages/core-app/src/index.ts +++ b/webapp/packages/core-app/src/index.ts @@ -6,6 +6,7 @@ export * from './TopNavBar/SettingsMenu/SettingsMenuService'; export * from './TopNavBar/ConnectionSchemaManager/ConnectionSchemaManagerService'; export * from './TopNavBar/TopNavBarService'; export * from './TopNavBar/AdministrationTopAppBarBootstrapService'; +export * from './TopNavBar/shared/topMenuStyles'; export * from './shared/NodesManager/ConnectionDialogsService'; export * from './shared/NodesManager/extensions/IObjectCatalogProvider'; export * from './shared/NodesManager/extensions/IObjectCatalogSetter'; @@ -16,16 +17,20 @@ export * from './shared/NodesManager/NavNodeInfoResource'; export * from './shared/NodesManager/NavNodeManagerService'; export * from './shared/NodesManager/NavTreeResource'; export * from './shared/NodesManager/NavNodeExtensionsService'; +export * from './shared/NodesManager/NodeLink'; export * from './shared/NodesManager/NodeManagerUtils'; export * from './shared/NavigationTabs/NavigationTabsService'; export * from './shared/NavigationTabs/TabNavigationContext'; -export * from './shared/ToolsPanel/LogViewTab/LogViewerMenuService'; -export * from './shared/ToolsPanel/LogViewTab/LogViewerService'; +export * from './shared/ToolsPanel/LogViewer/LogViewerBootstrap'; +export * from './shared/ToolsPanel/LogViewer/LogViewerService'; + export * from './shared/SessionExpireDialog/SessionExpiredDialogService'; +export * from './shared/SessionExpireWarningDialog/SessionExpireWarningDialogService'; export * from './NavigationTree/ElementsTree'; export * from './NavigationTree/TreeContext'; export * from './NavigationTree/NavigationTreeService'; export * from './NavigationTree/NavigationTreeContextMenuService'; +export * from './NavigationTree/useElementsTree'; export * from './AppLocaleService'; // Models diff --git a/webapp/packages/core-app/src/locales/en.ts b/webapp/packages/core-app/src/locales/en.ts index 52c35f6a9c..ab7371bb09 100644 --- a/webapp/packages/core-app/src/locales/en.ts +++ b/webapp/packages/core-app/src/locales/en.ts @@ -5,6 +5,7 @@ export default [ ['app_shared_connectionMenu_disconnect', 'Disconnect All'], ['app_shared_toolsMenu_tools', 'Tools'], ['app_shared_toolsMenu_logViewer', 'Log viewer'], + ['app_shared_toolsMenu_logViewer_placeholder', 'Log viewer is not active'], ['app_shared_settingsMenu_config', 'Configuration'], ['app_shared_settingsMenu_theme', 'Theme'], ['app_shared_settingsMenu_lang', 'Language'], @@ -17,6 +18,8 @@ export default [ ['app_navigationTree_connection_view_option_showSystemObjects', 'Show system objects'], ['app_navigationTree_refreshNode', 'Refresh'], ['app_navigationTree_context_disconnect', 'Disconnect'], + ['app_navigationTree_connection_group_user', 'User Connections'], + ['app_navigationTree_connection_group_shared', 'Shared Connections'], ['app_shared_inlineEditor_dialog_title', 'Edit mode'], ['app_shared_inlineEditor_dialog_apply', 'Apply'], ['app_shared_inlineEditor_dialog_cancel', 'Cancel'], diff --git a/webapp/packages/core-app/src/locales/ru.ts b/webapp/packages/core-app/src/locales/ru.ts index 66fb95bdb8..36d39a8b49 100644 --- a/webapp/packages/core-app/src/locales/ru.ts +++ b/webapp/packages/core-app/src/locales/ru.ts @@ -4,6 +4,7 @@ export default [ ['app_shared_connectionMenu_disconnect', 'Отключить все'], ['app_shared_toolsMenu_tools', 'Инструменты'], ['app_shared_toolsMenu_logViewer', 'Просмотр лога'], + ['app_shared_toolsMenu_logViewer_placeholder', 'Просмотр логов неактивен'], ['app_shared_settingsMenu_config', 'Настройки'], ['app_shared_settingsMenu_theme', 'Тема'], ['app_shared_settingsMenu_lang', 'Язык'], @@ -20,6 +21,8 @@ export default [ ['app_navigationTree_connection_view_option_showSystemObjects', 'Показывать системные объекты'], ['app_navigationTree_refreshNode', 'Обновить'], ['app_navigationTree_context_disconnect', 'Отключить'], + ['app_navigationTree_connection_group_user', 'Пользовательские подключения'], + ['app_navigationTree_connection_group_shared', 'Общие подключения'], ['app_shared_inlineEditor_dialog_title', 'Редактирование'], ['app_shared_inlineEditor_dialog_apply', 'Применить'], ['app_shared_inlineEditor_dialog_cancel', 'Отменить'], diff --git a/webapp/packages/core-app/src/shared/InlineEditor/InlineEditor.tsx b/webapp/packages/core-app/src/shared/InlineEditor/InlineEditor.tsx index bbf7af7906..e97550af10 100644 --- a/webapp/packages/core-app/src/shared/InlineEditor/InlineEditor.tsx +++ b/webapp/packages/core-app/src/shared/InlineEditor/InlineEditor.tsx @@ -40,7 +40,7 @@ export interface InlineEditorProps { onChange: (value: string) => void; onSave: () => void; onReject?: () => void; - onUndo: () => void; + onUndo?: () => void; className?: string; } @@ -128,7 +128,7 @@ export const InlineEditor = observer {!hideSave && } {!hideCancel && onReject && } - {onUndo && {}} {...use({ disabled: !edited })}>} + {onUndo && {}} {...use({ disabled: !edited })}>} {!simple && } diff --git a/webapp/packages/core-app/src/shared/InlineEditor/styles.ts b/webapp/packages/core-app/src/shared/InlineEditor/styles.ts index 3fd57c874f..56bfba8faa 100644 --- a/webapp/packages/core-app/src/shared/InlineEditor/styles.ts +++ b/webapp/packages/core-app/src/shared/InlineEditor/styles.ts @@ -115,7 +115,7 @@ export const InlineEditorStyles = composes( box-sizing: border-box; display: flex; width: 24px; - padding: 5px; + padding: 4px; cursor: pointer; & Icon { diff --git a/webapp/packages/core-app/src/shared/NavigationTabs/NavigationTabsBar/NavigationTabsBar.tsx b/webapp/packages/core-app/src/shared/NavigationTabs/NavigationTabsBar/NavigationTabsBar.tsx index 12217ad68c..50df3ae6ee 100644 --- a/webapp/packages/core-app/src/shared/NavigationTabs/NavigationTabsBar/NavigationTabsBar.tsx +++ b/webapp/packages/core-app/src/shared/NavigationTabs/NavigationTabsBar/NavigationTabsBar.tsx @@ -7,14 +7,15 @@ */ import { observer } from 'mobx-react-lite'; -import { useCallback } from 'react'; +import { useCallback, useEffect } from 'react'; import styled, { css } from 'reshadow'; import { - TextPlaceholder, TabsBox, TabPanel, useFocus + TextPlaceholder, TabsBox, TabPanel, useFocus, useExecutor } from '@cloudbeaver/core-blocks'; import { useService } from '@cloudbeaver/core-di'; import { useTranslate } from '@cloudbeaver/core-localization'; +import { SessionDataResource } from '@cloudbeaver/core-root'; import { useStyles, composes } from '@cloudbeaver/core-theming'; import { useActiveView } from '@cloudbeaver/core-view'; @@ -40,6 +41,7 @@ const styles = composes( const stylesArray = [styles]; export const NavigationTabsBar = observer(function NavigationTabsBar() { + const sessionDataResource = useService(SessionDataResource); const navigation = useService(NavigationTabsService); // TODO: we get exception when after closing the restored page trying to open another // it's related to hooks order and state restoration @@ -52,6 +54,18 @@ export const NavigationTabsBar = observer(function NavigationTabsBar() { const handleSelect = useCallback((tabId: string) => navigation.selectTab(tabId), [navigation]); const handleClose = useCallback((tabId: string) => navigation.closeTab(tabId), [navigation]); + async function restoreTabs() { + await navigation.unloadTabs(); + await navigation.restoreTabs(); + } + + useExecutor({ + executor: sessionDataResource.onDataUpdate, + postHandlers: [restoreTabs], + }); + + useEffect(() => { restoreTabs(); }, []); + if (navigation.tabIdList.length === 0) { return {translate('app_shared_navigationTabsBar_placeholder')}; } diff --git a/webapp/packages/core-app/src/shared/NavigationTabs/NavigationTabsService.ts b/webapp/packages/core-app/src/shared/NavigationTabs/NavigationTabsService.ts index 211eb0a9b8..64a215b515 100644 --- a/webapp/packages/core-app/src/shared/NavigationTabs/NavigationTabsService.ts +++ b/webapp/packages/core-app/src/shared/NavigationTabs/NavigationTabsService.ts @@ -13,7 +13,6 @@ import { AdministrationScreenService } from '@cloudbeaver/core-administration'; import { AppAuthService, UserInfoResource } from '@cloudbeaver/core-authentication'; import { injectable } from '@cloudbeaver/core-di'; import { NotificationService } from '@cloudbeaver/core-events'; -import { SessionDataResource } from '@cloudbeaver/core-root'; import { LocalStorageSaveService } from '@cloudbeaver/core-settings'; import type { IActiveView } from '@cloudbeaver/core-view'; @@ -69,8 +68,7 @@ export class NavigationTabsService { private autoSaveService: LocalStorageSaveService, private userInfoResource: UserInfoResource, private administrationScreenService: AdministrationScreenService, - sessionDataResource: SessionDataResource, - appAuthService: AppAuthService + private appAuthService: AppAuthService ) { makeObservable(this, { handlers: observable, @@ -121,14 +119,6 @@ export class NavigationTabsService { return map; } ); - - sessionDataResource.onDataUpdate - .addHandler(() => this.unloadTabs()) - .addPostHandler(async () => { - if (appAuthService.authenticated) { - await this.restoreTabs(); - } - }); } openTab(tab: ITab, isSelected?: boolean): void { @@ -156,13 +146,12 @@ export class NavigationTabsService { this.userTabsState.history = this.userTabsState.history.filter(id => id !== tabId); this.userTabsState.history.unshift(tabId); this.userTabsState.currentId = tabId; + this.tabSelectSubject.next(tab); } if (!skipHandlers) { await this.callHandlerCallback(tab, handler => handler.onSelect); } - - this.tabSelectSubject.next(tab); } async closeTab(tabId: string, skipHandlers?: boolean): Promise { @@ -256,7 +245,7 @@ export class NavigationTabsService { } } - private async unloadTabs() { + async unloadTabs(): Promise { if (this.administrationScreenService.publicDisabled) { return; } @@ -271,11 +260,15 @@ export class NavigationTabsService { } // must be executed with low priority, because this call runs many requests to backend and blocks others - private async restoreTabs(): Promise { + async restoreTabs(): Promise { if (this.administrationScreenService.publicDisabled) { return; } + if (!this.appAuthService.authenticated) { + return; + } + const removedTabs: string[] = []; for (const tabId of this.userTabsState.tabs) { @@ -296,8 +289,11 @@ export class NavigationTabsService { this.closeTab(tabId, true); } - if (this.tabsMap.has(this.userTabsState.currentId)) { + const tab = this.tabsMap.get(this.userTabsState.currentId); + + if (tab) { this.selectTab(this.userTabsState.currentId); + this.tabSelectSubject.next(tab); } } diff --git a/webapp/packages/core-app/src/shared/NodesManager/ConnectionDialogsService.ts b/webapp/packages/core-app/src/shared/NodesManager/ConnectionDialogsService.ts index b80fa2390b..f2943953f6 100644 --- a/webapp/packages/core-app/src/shared/NodesManager/ConnectionDialogsService.ts +++ b/webapp/packages/core-app/src/shared/NodesManager/ConnectionDialogsService.ts @@ -9,6 +9,7 @@ import { ConnectionInfoResource, ConnectionsManagerService, EConnectionFeature } from '@cloudbeaver/core-connections'; import { Bootstrap, injectable } from '@cloudbeaver/core-di'; import { ContextMenuService, IMenuContext } from '@cloudbeaver/core-dialogs'; +import { NotificationService } from '@cloudbeaver/core-events'; import { NavigationTreeContextMenuService } from '../../NavigationTree/NavigationTreeContextMenuService'; import { EMainMenu, MainMenuService } from '../../TopNavBar/MainMenu/MainMenuService'; @@ -22,7 +23,8 @@ export class ConnectionDialogsService extends Bootstrap { private mainMenuService: MainMenuService, private contextMenuService: ContextMenuService, private connectionsManagerService: ConnectionsManagerService, - private connectionInfoResource: ConnectionInfoResource + private connectionInfoResource: ConnectionInfoResource, + private notificationService: NotificationService, ) { super(); } @@ -76,15 +78,19 @@ export class ConnectionDialogsService extends Bootstrap { || !connection?.features.includes(EConnectionFeature.manageable); }, title: 'ui_delete', - onClick: (context: IMenuContext) => { + onClick: async (context: IMenuContext) => { const node = context.data; - this.connectionsManagerService.deleteConnection( - NodeManagerUtils.connectionNodeIdToConnectionId(node.id) - ); + try { + await this.connectionsManagerService.deleteConnection( + NodeManagerUtils.connectionNodeIdToConnectionId(node.id) + ); + } catch (exception) { + this.notificationService.logException(exception, 'Failed to delete connection'); + } }, } ); } - load(): void {} + load(): void { } } diff --git a/webapp/packages/core-app/src/shared/NodesManager/ENodeFeature.ts b/webapp/packages/core-app/src/shared/NodesManager/ENodeFeature.ts index 23d2275a49..13f6977f53 100644 --- a/webapp/packages/core-app/src/shared/NodesManager/ENodeFeature.ts +++ b/webapp/packages/core-app/src/shared/NodesManager/ENodeFeature.ts @@ -8,6 +8,7 @@ export enum ENodeFeature { 'objectContainer' = 'objectContainer', + 'entityContainer' = 'entityContainer', 'entity' = 'entity', 'container' = 'container', 'dataContainer' = 'dataContainer', diff --git a/webapp/packages/core-app/src/shared/NodesManager/NavNodeInfoResource.ts b/webapp/packages/core-app/src/shared/NodesManager/NavNodeInfoResource.ts index 682d0db38c..cfa8c2960b 100644 --- a/webapp/packages/core-app/src/shared/NodesManager/NavNodeInfoResource.ts +++ b/webapp/packages/core-app/src/shared/NodesManager/NavNodeInfoResource.ts @@ -53,14 +53,14 @@ export class NavNodeInfoResource extends CachedMapResource { ResourceKeyUtils.forEach(keyObject, key => { const metadata = this.metadata.get(key); - if (!metadata.withDetails) { + if (metadata.withDetails !== state) { metadata.outdated = true; } metadata.withDetails = state; }); } - protected async loader(key: ResourceKey) { + protected async loader(key: ResourceKey): Promise> { if (isResourceKeyList(key)) { const values: NavNode[] = []; for (const nodePath of key.list) { diff --git a/webapp/packages/core-app/src/shared/NodesManager/NavNodeManagerService.ts b/webapp/packages/core-app/src/shared/NodesManager/NavNodeManagerService.ts index 558c9ee759..e0a826b59a 100644 --- a/webapp/packages/core-app/src/shared/NodesManager/NavNodeManagerService.ts +++ b/webapp/packages/core-app/src/shared/NodesManager/NavNodeManagerService.ts @@ -13,11 +13,9 @@ import { injectable, Bootstrap } from '@cloudbeaver/core-di'; import { NotificationService } from '@cloudbeaver/core-events'; import { IExecutor, Executor, IExecutionContextProvider } from '@cloudbeaver/core-executor'; import { - PermissionsService, EPermission, ServerService, SessionDataResource + PermissionsService, EPermission, ServerService } from '@cloudbeaver/core-root'; -import { - GraphQLService, resourceKeyList, ResourceKey, ResourceKeyUtils -} from '@cloudbeaver/core-sdk'; +import { resourceKeyList } from '@cloudbeaver/core-sdk'; import { NavigationService } from '@cloudbeaver/core-ui'; import { ENodeFeature } from './ENodeFeature'; @@ -92,7 +90,6 @@ export class NavNodeManagerService extends Bootstrap { readonly navigator: IExecutor; constructor( - private graphQLService: GraphQLService, private permissionsService: PermissionsService, readonly connectionInfo: ConnectionInfoResource, readonly navTree: NavTreeResource, @@ -100,7 +97,6 @@ export class NavNodeManagerService extends Bootstrap { private connectionAuthService: ConnectionAuthService, private notificationService: NotificationService, private serverService: ServerService, - private sessionDataResource: SessionDataResource, navigationService: NavigationService ) { super(); @@ -116,13 +112,7 @@ export class NavNodeManagerService extends Bootstrap { .addHandler(this.navigateHandler.bind(this)); } - register(): void { - this.sessionDataResource.onDataUpdate.addHandler(this.refreshRoot.bind(this)); - this.connectionInfo.onSessionUpdate.addHandler(this.connectionRefreshHandler.bind(this)); - this.connectionInfo.onItemAdd.addHandler(this.connectionUpdateHandler.bind(this)); - this.connectionInfo.onItemDelete.addHandler(this.connectionRemoveHandler.bind(this)); - this.connectionInfo.onConnectionCreate.addHandler(this.connectionCreateHandler.bind(this)); - } + register(): void { } load(): void {} @@ -136,15 +126,7 @@ export class NavNodeManagerService extends Bootstrap { } async refreshTree(navNodeId: string): Promise { - await this.graphQLService.sdk.navRefreshNode({ - nodePath: navNodeId, - }); - this.markTreeOutdated(navNodeId); - await this.navTree.refresh(navNodeId); - } - - markTreeOutdated(navNodeId: ResourceKey): void { - this.navTree.markOutdated(resourceKeyList(this.navTree.getNestedChildren(navNodeId))); + await this.navTree.refreshTree(navNodeId); } getTree(navNodeId: string): string[] | undefined @@ -152,14 +134,14 @@ export class NavNodeManagerService extends Bootstrap { getTree(navNodeKey: NavNodeKey[]): Array getTree(navNodeId: string | NavNodeKey | NavNodeKey[]): string[] | undefined | Array { if (typeof navNodeId === 'string') { - return this.navTree.data.get(navNodeId); + return this.navTree.get(navNodeId); } if (Array.isArray(navNodeId)) { return navNodeId.map(node => this.navTree.data.get(node.nodeId)); } - return this.navTree.data.get(navNodeId.nodeId); + return this.navTree.get(navNodeId.nodeId); } loadTree(navNodeId: string): Promise { @@ -170,10 +152,6 @@ export class NavNodeManagerService extends Bootstrap { this.navTree.delete(path); } - async refreshNode(navNodeId: string): Promise { - await this.navNodeInfoResource.refresh(navNodeId); - } - getNode(navNodeId: string): NavNode | undefined getNode(navNodeKey: NavNodeKey): NavNode | undefined getNode(navNodeKey: NavNodeKey[]): Array @@ -298,6 +276,10 @@ export class NavNodeManagerService extends Bootstrap { const parents: string[] = []; let parent = this.getNode(nodeId); + if (!parent) { + return NodeManagerUtils.parentsFromPath(nodeId); + } + while (parent && parent.parentId !== ROOT_NODE_PATH && parent.id !== parent.parentId) { parents.unshift(parent.parentId); parent = this.getNode(parent.parentId); @@ -330,87 +312,6 @@ export class NavNodeManagerService extends Bootstrap { }; }; - async updateRoot(): Promise { - const enabled = await this.isNavTreeEnabled(); - if (enabled) { - await this.navTree.refresh(ROOT_NODE_PATH); - } - } - - async refreshRoot(): Promise { - this.navTree.delete(ROOT_NODE_PATH); - const enabled = await this.isNavTreeEnabled(); - if (enabled) { - await this.navTree.refresh(ROOT_NODE_PATH); - } - } - - private async connectionCreateHandler(connection: Connection) { - const enabled = await this.isNavTreeEnabled(); - if (!enabled) { - return; - } - - const nodeId = NodeManagerUtils.connectionIdToConnectionNodeId(connection.id); - this.markTreeOutdated(nodeId); - - const tree = await this.navTree.load(ROOT_NODE_PATH); - - if (!tree.includes(nodeId)) { - await this.navTree.refresh(ROOT_NODE_PATH); - } - } - - private async connectionRefreshHandler(connections: Connection[]) { - const enabled = await this.isNavTreeEnabled(); - if (!enabled) { - return; - } - - await this.navTree.refresh(ROOT_NODE_PATH); - } - - private async connectionUpdateHandler(key: ResourceKey) { - const enabled = await this.isNavTreeEnabled(); - if (!enabled) { - return; - } - - const count = ResourceKeyUtils.count(key); - - await this.navTree.load(ROOT_NODE_PATH); - await ResourceKeyUtils.forEachAsync(key, async key => { - const nodeId = NodeManagerUtils.connectionIdToConnectionNodeId(key); - this.markTreeOutdated(nodeId); - - // addOpenedConnection - const connectionInfo = this.connectionInfo.get(key); - - if (!connectionInfo?.connected) { - this.removeTree(nodeId); - } - - if (count > 1) { - this.navNodeInfoResource.markOutdated(nodeId); - } else { - await this.navNodeInfoResource.refresh(nodeId); - } - }); - } - - private async connectionRemoveHandler(key: ResourceKey) { - ResourceKeyUtils.forEach(key, key => { - // deleteConnection - const navNodeId = NodeManagerUtils.connectionIdToConnectionNodeId(key); - - const node = this.getNode(navNodeId); - if (!node) { - return; - } - this.navTree.deleteInNode(node.parentId, [navNodeId]); - }); - } - private async navigateHandler( data: INodeNavigationData, contexts: IExecutionContextProvider diff --git a/webapp/packages/core-app/src/shared/NodesManager/NavTreeResource.ts b/webapp/packages/core-app/src/shared/NodesManager/NavTreeResource.ts index 5681024ec9..b148304968 100644 --- a/webapp/packages/core-app/src/shared/NodesManager/NavTreeResource.ts +++ b/webapp/packages/core-app/src/shared/NodesManager/NavTreeResource.ts @@ -6,9 +6,12 @@ * you may not use this file except in compliance with the License. */ -import { action, makeObservable } from 'mobx'; +import { action, computed, makeObservable, runInAction } from 'mobx'; +import { Connection, ConnectionInfoResource } from '@cloudbeaver/core-connections'; import { injectable } from '@cloudbeaver/core-di'; +import { Executor, IExecutor } from '@cloudbeaver/core-executor'; +import { SessionDataResource } from '@cloudbeaver/core-root'; import { GraphQLService, CachedMapResource, @@ -22,7 +25,9 @@ import { } from '@cloudbeaver/core-sdk'; import { MetadataMap } from '@cloudbeaver/core-utils'; -import { NavNodeInfoResource } from './NavNodeInfoResource'; +import { CoreSettingsService } from '../../CoreSettingsService'; +import { NavNodeInfoResource, ROOT_NODE_PATH } from './NavNodeInfoResource'; +import { NodeManagerUtils } from './NodeManagerUtils'; // TODO: so much dirty export interface NodePath { @@ -37,16 +42,27 @@ interface INodeMetadata extends ICachedMapResourceMetadata { @injectable() export class NavTreeResource extends CachedMapResource { + readonly onNodeRefresh: IExecutor; protected metadata: MetadataMap; + get childrenLimit(): number { + return this.coreSettingsService.settings.getValue('app.navigationTree.childrenLimit'); + } + constructor( private graphQLService: GraphQLService, - private navNodeInfoResource: NavNodeInfoResource + private navNodeInfoResource: NavNodeInfoResource, + private coreSettingsService: CoreSettingsService, + private sessionDataResource: SessionDataResource, + private connectionInfo: ConnectionInfoResource ) { super(); - makeObservable(this, { + makeObservable(this, { + childrenLimit: computed, setDetails: action, + setNavObject: action, + connectionRemoveHandler: action.bound, }); this.metadata = new MetadataMap(() => ({ @@ -56,7 +72,26 @@ export class NavTreeResource extends CachedMapResource { exception: null, includes: [], })); + + this.onNodeRefresh = new Executor(null, (a, b) => a === b); this.onDataOutdated.addHandler(navNodeInfoResource.markOutdated.bind(navNodeInfoResource)); + this.sessionDataResource.onDataUpdate.addPostHandler(() => this.markOutdated()); + this.connectionInfo.onItemAdd.addHandler(this.connectionUpdateHandler.bind(this)); + this.connectionInfo.onItemDelete.addHandler(this.connectionRemoveHandler); + this.connectionInfo.onConnectionCreate.addHandler(this.connectionCreateHandler.bind(this)); + } + + async refreshTree(navNodeId: string): Promise { + await this.graphQLService.sdk.navRefreshNode({ + nodePath: navNodeId, + }); + await this.markTreeOutdated(navNodeId); + await this.refresh(navNodeId); + await this.onNodeRefresh.execute(navNodeId); + } + + async markTreeOutdated(navNodeId: ResourceKey): Promise { + await this.markOutdated(resourceKeyList(this.getNestedChildren(navNodeId))); } setDetails(keyObject: ResourceKey, state: boolean): void { @@ -127,7 +162,7 @@ export class NavTreeResource extends CachedMapResource { set(keyObject: ResourceKey, valueObject: string[] | string[][]): void { ResourceKeyUtils.forEach(keyObject, (key, i) => { const value = i === -1 ? (valueObject as string[]) : (valueObject as string[][])[i]; - const childrenToRemove = this.data.get(key)?.concat() || []; + const childrenToRemove = this.data.get(key) || []; this.data.set(key, value); this.delete(resourceKeyList(childrenToRemove.filter(navNodeId => !value.includes(navNodeId)))); }); @@ -151,7 +186,7 @@ export class NavTreeResource extends CachedMapResource { const allKeys = resourceKeyList(items); this.markUpdated(allKeys); this.onItemDelete.execute(allKeys); - this.navNodeInfoResource.delete(resourceKeyList(items.filter(navNodeId => navNodeId !== key))); + this.navNodeInfoResource.delete(ResourceKeyUtils.exclude(allKeys, key)); } protected async loader(key: ResourceKey): Promise> { @@ -174,7 +209,7 @@ export class NavTreeResource extends CachedMapResource { if (isResourceKeyList(navNode)) { prevChildren = navNode.list.concat(); } else { - prevChildren = [navNode, ...(this.get(navNode)?.concat() || [])]; + prevChildren = [navNode, ...(this.get(navNode) || [])]; } nestedChildren.push(...prevChildren); @@ -188,6 +223,50 @@ export class NavTreeResource extends CachedMapResource { return nestedChildren; } + private async connectionUpdateHandler(key: ResourceKey) { + await this.markOutdated(ROOT_NODE_PATH); + + await ResourceKeyUtils.forEachAsync(key, async key => { + const nodeId = NodeManagerUtils.connectionIdToConnectionNodeId(key); + + if (this.has(nodeId)) { + const connectionInfo = this.connectionInfo.get(key); + + if (!connectionInfo?.connected) { + this.delete(nodeId); + } else { + await this.markTreeOutdated(nodeId); + } + } + + const node = this.navNodeInfoResource.get(nodeId); + + if (node) { + await this.markOutdated(node.parentId); + } + }); + } + + private connectionRemoveHandler(key: ResourceKey) { + runInAction(() => { + ResourceKeyUtils.forEach(key, key => { + const nodeId = NodeManagerUtils.connectionIdToConnectionNodeId(key); + + const node = this.navNodeInfoResource.get(nodeId); + + if (node) { + this.deleteInNode(node.parentId, [nodeId]); + } + }); + }); + } + + private async connectionCreateHandler(connection: Connection) { + const nodeId = NodeManagerUtils.connectionIdToConnectionNodeId(connection.id); + await this.markOutdated(ROOT_NODE_PATH); + await this.markTreeOutdated(nodeId); + } + private setNavObject(data: NavNodeChildrenQuery | NavNodeChildrenQuery[]) { if (Array.isArray(data)) { for (const node of data) { @@ -242,6 +321,8 @@ export class NavTreeResource extends CachedMapResource { withDetails: metadata.withDetails, }); - return { navNodeChildren, navNodeInfo, parentPath }; + navNodeInfo.hasChildren = navNodeInfo.hasChildren && navNodeChildren.length > 0; + + return { navNodeChildren: navNodeChildren.slice(0, this.childrenLimit), navNodeInfo, parentPath }; } } diff --git a/webapp/packages/core-app/src/shared/NodesManager/NodeLink.tsx b/webapp/packages/core-app/src/shared/NodesManager/NodeLink.tsx new file mode 100644 index 0000000000..08883791ea --- /dev/null +++ b/webapp/packages/core-app/src/shared/NodesManager/NodeLink.tsx @@ -0,0 +1,47 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2021 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import React, { useCallback } from 'react'; +import styled from 'reshadow'; + +import { Link } from '@cloudbeaver/core-blocks'; +import { useService } from '@cloudbeaver/core-di'; +import { css } from '@reshadow/react'; + +import { NavigationTreeService } from '../../NavigationTree/NavigationTreeService'; +import { parseNodeParentId } from './NavNodeManagerService'; + +const style = css` + Link { + cursor: pointer; + } +`; + +interface Props { + name: string; + nodeId?: string; +} + +// TODO: we probably can get name from NavNodeInfoResource but element can be not loaded +export const NodeLink: React.FC = function NodeLink({ children, name, nodeId }) { + const navigationTreeService = useService(NavigationTreeService); + + const handleClick = useCallback(() => { + if (nodeId) { + navigationTreeService.navToNode(nodeId, parseNodeParentId(nodeId)); + } + }, + [nodeId, navigationTreeService] + ); + + if (nodeId) { + return styled(style)({children}); + } + + return <>{children}; +}; diff --git a/webapp/packages/core-app/src/shared/NodesManager/NodeManagerUtils.ts b/webapp/packages/core-app/src/shared/NodesManager/NodeManagerUtils.ts index fe40f3ea33..4833568ef2 100644 --- a/webapp/packages/core-app/src/shared/NodesManager/NodeManagerUtils.ts +++ b/webapp/packages/core-app/src/shared/NodesManager/NodeManagerUtils.ts @@ -16,6 +16,25 @@ export const NodeManagerUtils = { return connectionNodeId.replace('database://', ''); }, + parentsFromPath(nodeId: string): string[] { + const parts = nodeId + .replace('//', '\\') + .split('/'); + let lastPath = ''; + const parents: string[] = []; + + for (const part of parts) { + if (lastPath !== '') { + lastPath += '/'; + } + lastPath += part.replace('\\', '//'); + + parents.push(lastPath); + } + + return parents; + }, + connectionIdToConnectionNodeId(connectionId: string): string { return `database://${connectionId}`; }, diff --git a/webapp/packages/core-app/src/shared/NodesManager/useNode.ts b/webapp/packages/core-app/src/shared/NodesManager/useNode.ts index 3c4dd5d3c6..2e36b30192 100644 --- a/webapp/packages/core-app/src/shared/NodesManager/useNode.ts +++ b/webapp/packages/core-app/src/shared/NodesManager/useNode.ts @@ -6,23 +6,40 @@ * you may not use this file except in compliance with the License. */ -import { useCallback } from 'react'; - +import { useObjectRef } from '@cloudbeaver/core-blocks'; import { useService } from '@cloudbeaver/core-di'; +import type { NavNode } from './EntityTypes'; import { NavNodeInfoResource } from './NavNodeInfoResource'; -export function useNode(navNodeId: string) { +interface IUseNodeHook { + navNodeId: string; + node: NavNode | undefined; + isLoading: () => boolean; + isLoaded: () => boolean; + isOutdated: () => boolean; +} + +const bindActions: Array = ['isLoading', 'isLoaded', 'isOutdated']; + +export function useNode(navNodeId: string): IUseNodeHook { const navNodeInfoResource = useService(NavNodeInfoResource); const node = navNodeInfoResource.get(navNodeId); - const deps = [navNodeId]; - - const isLoading = useCallback(() => navNodeInfoResource.isDataLoading(navNodeId), deps); - const isLoaded = useCallback(() => navNodeInfoResource.isLoaded(navNodeId), deps); - const isOutdated = useCallback(() => navNodeInfoResource.isOutdated(navNodeId), deps); - - return { - node, isLoading, isLoaded, isOutdated, - }; + return useObjectRef({ + navNodeId, + node, + isLoading() { + return navNodeInfoResource.isDataLoading(this.navNodeId); + }, + isLoaded() { + return navNodeInfoResource.isLoaded(this.navNodeId); + }, + isOutdated() { + return navNodeInfoResource.isOutdated(this.navNodeId); + }, + }, { + navNodeId, + node, + }, undefined, bindActions); } diff --git a/webapp/packages/core-app/src/shared/SessionExpireDialog/SessionExpiredDialog.tsx b/webapp/packages/core-app/src/shared/SessionExpireDialog/SessionExpiredDialog.tsx index d1fd532807..370976ddc3 100644 --- a/webapp/packages/core-app/src/shared/SessionExpireDialog/SessionExpiredDialog.tsx +++ b/webapp/packages/core-app/src/shared/SessionExpireDialog/SessionExpiredDialog.tsx @@ -16,11 +16,9 @@ import { useTranslate } from '@cloudbeaver/core-localization'; import { useStyles } from '@cloudbeaver/core-theming'; const styles = css` - dialog-content { - display: flex; - height: 150px; - justify-content: center; - align-items: center; + CommonDialogWrapper { + width: 400px; + min-width: auto; } controls { display: flex; @@ -30,6 +28,9 @@ const styles = css` margin: auto; justify-content: flex-end; } + p { + margin: 0; + } `; export const SessionExpiredDialog = observer(function SessionExpiredDialog({ @@ -43,7 +44,7 @@ export const SessionExpiredDialog = observer(function SessionExpiredDialog({ + + + )} + onReject={rejectDialog} + > +

{translate('app_root_session_expire_warning_message')}

+
+ ); +}); diff --git a/webapp/packages/core-app/src/shared/SessionExpireWarningDialog/SessionExpireWarningDialogService.ts b/webapp/packages/core-app/src/shared/SessionExpireWarningDialog/SessionExpireWarningDialogService.ts new file mode 100644 index 0000000000..7641520387 --- /dev/null +++ b/webapp/packages/core-app/src/shared/SessionExpireWarningDialog/SessionExpireWarningDialogService.ts @@ -0,0 +1,93 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2021 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { Bootstrap, injectable } from '@cloudbeaver/core-di'; +import { CommonDialogService, DialogueStateResult } from '@cloudbeaver/core-dialogs'; +import { ServerConfigResource, SessionExpireService, SessionResource } from '@cloudbeaver/core-root'; +import { getCookies } from '@cloudbeaver/core-utils'; + +import { SessionExpireWarningDialog } from '../SessionExpireWarningDialog/SessionExpireWarningDialog'; + +const SESSION_COOKIE_NAME = 'cb-session'; +const WARN_IN = 5 * 1000 * 60; +const POLL_INTERVAL = 1 * 1000 * 60; + +@injectable() +export class SessionExpireWarningDialogService extends Bootstrap { + private dialogInternalPromise: Promise | null; + + constructor( + private commonDialogService: CommonDialogService, + private sessionExpireService: SessionExpireService, + private serverConfigResource: ServerConfigResource, + private sessionResource: SessionResource, + ) { + super(); + this.dialogInternalPromise = null; + } + + register(): void { + this.sessionExpireService.onSessionExpire.addHandler(this.close.bind(this)); + } + + load(): void { + this.startSessionPolling(); + } + + private startSessionPolling() { + const poll = () => { + const cookies = getCookies(); + const sessionDuration = this.serverConfigResource.data?.sessionExpireTime; + const sessionExpiredTime = cookies[SESSION_COOKIE_NAME]; + + if (!sessionExpiredTime) { + this.sessionExpireService.sessionExpired(); + return; + } + + const remainingTime = new Date(sessionExpiredTime).getTime() - Date.now(); + + if (this.sessionExpireService.expired || !sessionDuration || sessionDuration < WARN_IN) { + this.close(); + return; + } + + if (remainingTime < WARN_IN) { + this.open(); + } else { + this.close(); + } + }; + + setInterval(poll, POLL_INTERVAL); + } + + private async open(): Promise { + if (!this.dialogInternalPromise) { + this.dialogInternalPromise = this.commonDialogService.open(SessionExpireWarningDialog, null); + await this.dialogInternalPromise; + this.dialogInternalPromise = null; + + const cookies = getCookies(); + + if (!this.sessionExpireService.expired) { + if (cookies[SESSION_COOKIE_NAME]) { + await this.sessionResource.refreshSilent(); + } else { + this.sessionExpireService.sessionExpired(); + } + } + } + } + + private close(): void { + if (this.dialogInternalPromise) { + this.commonDialogService.rejectDialog(this.dialogInternalPromise); + } + } +} diff --git a/webapp/packages/core-app/src/shared/TabEntity/TabViewModel.ts b/webapp/packages/core-app/src/shared/TabEntity/TabViewModel.ts index e51e89b3cb..df24819e8f 100644 --- a/webapp/packages/core-app/src/shared/TabEntity/TabViewModel.ts +++ b/webapp/packages/core-app/src/shared/TabEntity/TabViewModel.ts @@ -31,9 +31,11 @@ export class TabViewModel implements ITab { onClose = this.tabModel.onClose ? () => this._onClose() : undefined; onActivate = () => this.tabContainer.activateTab(this.tabId); - constructor(@inject(TabToken) private tabModel: ITab, + constructor( + @inject(TabToken) private tabModel: ITab, private entity: Entity, - @inject(TabContainerToken) private tabContainer: ITabContainerEntity) { + @inject(TabContainerToken) private tabContainer: ITabContainerEntity + ) { this.tabId = this.entity.id; this.panel = () => TabContext(tabContainer.getTabServiceInjector(this.tabId), this.tabModel.panel); } diff --git a/webapp/packages/core-app/src/shared/ToolsPanel/LogViewTab/LogEntry/LogEntry.tsx b/webapp/packages/core-app/src/shared/ToolsPanel/LogViewTab/LogEntry/LogEntry.tsx deleted file mode 100644 index 94e4eb64d3..0000000000 --- a/webapp/packages/core-app/src/shared/ToolsPanel/LogViewTab/LogEntry/LogEntry.tsx +++ /dev/null @@ -1,73 +0,0 @@ -/* - * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2021 DBeaver Corp and others - * - * Licensed under the Apache License, Version 2.0. - * you may not use this file except in compliance with the License. - */ - -import { observer } from 'mobx-react-lite'; -import styled, { css } from 'reshadow'; - -import { Icon } from '@cloudbeaver/core-blocks'; -import { useController } from '@cloudbeaver/core-di'; -import { useStyles } from '@cloudbeaver/core-theming'; - -import type { ILogEntry } from '../ILogEntry'; -import { LogEntryController } from './LogEntryController'; - -export interface LogEntryProps { - item: ILogEntry; -} - -const style = css` - message-cell { - display: flex; - align-items: center; - } - message { - flex: 1; - word-break: break-word; - white-space: nowrap; - overflow: hidden; - padding-right: 16px; - text-overflow: ellipsis; - } - - snack { - display: flex; - cursor: pointer; - } - - snack Icon { - display: block; - width: 16px; - height: 10px; - fill: rgba(255, 255, 255, 0); - } - - tr:hover Icon { - fill: #338fcc; - } -`; - -export const LogEntry = observer(function LogEntry({ item }: LogEntryProps) { - const controller = useController(LogEntryController, item); - - return styled(useStyles(style))( - - {item.type} - {item.time} - - - - {item.message} - - - - - - - - ); -}); diff --git a/webapp/packages/core-app/src/shared/ToolsPanel/LogViewTab/LogEntry/LogEntryController.ts b/webapp/packages/core-app/src/shared/ToolsPanel/LogViewTab/LogEntry/LogEntryController.ts deleted file mode 100644 index 1e9e4ecea9..0000000000 --- a/webapp/packages/core-app/src/shared/ToolsPanel/LogViewTab/LogEntry/LogEntryController.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2021 DBeaver Corp and others - * - * Licensed under the Apache License, Version 2.0. - * you may not use this file except in compliance with the License. - */ - -import { IInitializableController, injectable } from '@cloudbeaver/core-di'; -import { CommonDialogService } from '@cloudbeaver/core-dialogs'; - -import type { ILogEntry } from '../ILogEntry'; -import { LogEntryDetailsDialog } from './LogEntryDetailsDialog'; - -@injectable() -export class LogEntryController implements IInitializableController { - get hasDetails() { - return Boolean(this.item.stackTrace); - } - - showDetails = () => this.commonDialogService.open(LogEntryDetailsDialog, this.item); - - private item!: ILogEntry; - - constructor(private commonDialogService: CommonDialogService) { - } - - init(item: ILogEntry) { - this.item = item; - } -} diff --git a/webapp/packages/core-app/src/shared/ToolsPanel/LogViewTab/LogEntry/LogEntryDetailsDialog.tsx b/webapp/packages/core-app/src/shared/ToolsPanel/LogViewTab/LogEntry/LogEntryDetailsDialog.tsx deleted file mode 100644 index 63b5fc7226..0000000000 --- a/webapp/packages/core-app/src/shared/ToolsPanel/LogViewTab/LogEntry/LogEntryDetailsDialog.tsx +++ /dev/null @@ -1,45 +0,0 @@ -/* - * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2021 DBeaver Corp and others - * - * Licensed under the Apache License, Version 2.0. - * you may not use this file except in compliance with the License. - */ - -import { observer } from 'mobx-react-lite'; -import styled, { css } from 'reshadow'; - -import { CommonDialogWrapper, DialogComponent, DialogComponentProps } from '@cloudbeaver/core-dialogs'; -import { useStyles } from '@cloudbeaver/core-theming'; - -import type { ILogEntry } from '../ILogEntry'; - -const styles = css` - message { - width: 700px; - max-height: 80px; - margin-bottom: 18px; - overflow: auto; - } - textarea { - width: 100% !important; - min-height: 240px; - box-sizing: border-box; - } -`; - -export const LogEntryDetailsDialog: DialogComponent = observer( - function LogEntryDetailsDialog(props: DialogComponentProps) { - return styled(useStyles(styles))( - - - {props.payload.message} - -