Compare commits

...
Author SHA1 Message Date
Daniil 24ee5298f4 dbeaver/pro#5650 hotfix -> release_26_1_5: Add fields for SSL: databend driver is enable in cb-ce (#4554) 2026-08-14 17:53:39 +02:00
sergeyteleshevandmr-anton-t 707cb3ea41 dbeaver/pro#10205 removes nanoid CVE (#4543)
* dbeaver/pro#10205 removes nanoid CVE

* dbeaver/pro#10205 fixes image-size CVE with updating LESS

---------

Co-authored-by: mr-anton-t <42037741+mr-anton-t@users.noreply.github.com>
2026-08-11 19:42:03 +02:00
5be49b8a7e dbeaver/pro#8821 feat: add import settings step (#4506)
* dbeaver/pro#8821 feat: add gql query for import

* dbeaver/cloudbeaver#8821 throw readable exception on import fail

* dbeaver/pro#8821 feat: add import settings step

* dbeaver/pro#8821 refactor: gql types for driver config

* dbeaver/pro#8821 refactor: use connectionKey

* dbeaver/pro#8821 add open New Connection

* dbeaver/pro#8821 feat: add openNewConnection checkbox and replace docs link

* dbeaver/pro#8821 feat: clear onDuplicateKey if useBulk is active

* dbeaver/pro#8821 feat: add titles

* dbeaver/pro#8821 refactor: remove extra checks (not a driver config, always true)

* dbeaver/pro#8821 remove defaultOpenNewConnection from driverConfiguration

* dbeaver/pro#8821 feat: let a user to clear the replace method

* dbeaver/pro#8821 refactor: update open new connection tooltip

* dbeaver/pro#8821 feat: remove checks for settings step

Always show settings steps with at least one option: use new connection

* dbeaver/pro#8821 refactor: simplify dialog's footer

---------

Co-authored-by: Ruslan <pupkinvasya7000@gmail.com>
Co-authored-by: Daria Marutkina <125263541+dariamarutkina@users.noreply.github.com>
Co-authored-by: Ainur <59531286+yagudin10@users.noreply.github.com>
2026-08-11 18:45:36 +02:00
Viktor Kirst 317750ea7d dbeaver/pro#9733 Rename X- headers to CB- with backward compatibility (#4517) 2026-08-11 19:16:38 +03:00
alexandDaria Marutkina 5fb7234e24 dbeaver/pro#9901 reset password after submit (#4542)
Co-authored-by: Daria Marutkina <125263541+dariamarutkina@users.noreply.github.com>
2026-08-11 15:22:11 +02:00
7a93d56459 dbeaver/pro#6455 add cancel button (#4533)
* dbeaver/pro#6455 add cancel button

* dbeaver/pro#6455 add cancel button

* dbeaver/pro#6455 add cancel button

* dbeaver/pro#6455 fix cancel before job start

* dbeaver/pro#6455 chat message now in common model

* dbeaver/pro#6455 add synchronization for cancel

* dbeaver/pro#6455 add message cancel

---------

Co-authored-by: naumov <iamemptyhuh@gmail.com>
Co-authored-by: Daria Marutkina <125263541+dariamarutkina@users.noreply.github.com>
2026-08-11 15:17:44 +02:00
sergeyteleshevandEvgenia bd1e926929 dbeaver/pro#9981 adds export button for admin users page (#4531)
* dbeaver/pro#9981 adds export button for admin users page

* dbeaver/pro#9981 uses fetch to download csv instead of submitForm approach (backend issue)

* dbeaver/pro#9981 makes users export to be available only for PRO products

* dbeaver/pro#9981 reverts export logic

* dbeaver/pro#9981 add actionButtonsPlaceholder

* dbeaver/pro#9981 cleanup

---------

Co-authored-by: Evgenia <139753579+EvgeniaBzzz@users.noreply.github.com>
2026-08-11 12:29:14 +02:00
170650e6c2 dbeaver/pro#4711 support uploadable textarea (#4522)
Co-authored-by: Evgenia <139753579+EvgeniaBzzz@users.noreply.github.com>
Co-authored-by: mr-anton-t <42037741+mr-anton-t@users.noreply.github.com>
2026-08-11 09:34:22 +02:00
sergeyteleshevandEvgenia ffc48e1979 dbeaver/pro#9494 removes circle after closing menu (#4541)
Co-authored-by: Evgenia <139753579+EvgeniaBzzz@users.noreply.github.com>
2026-08-10 19:44:01 +02:00
c4b0dd4650 dbeaver/pro#9999 api for ai engine icon (#4518)
* dbeaver/pro#9999 api for ai engine icon

* dbeaver/pro#9999 make list engines functions public

* dbeaver/pro#9999 add icons for engines and profiles

* dbeaver/pro#9999 keep icons + titles

---------

Co-authored-by: naumov <iamemptyhuh@gmail.com>
Co-authored-by: Daria Marutkina <125263541+dariamarutkina@users.noreply.github.com>
Co-authored-by: mr-anton-t <42037741+mr-anton-t@users.noreply.github.com>
2026-08-10 17:41:39 +02:00
Ainur 2d955087ea dbeaver/pro#10001 fix validation for custom path implementations (#4539) 2026-08-10 17:26:55 +02:00
sergeyteleshevandEvgenia b5289c7865 dbeaver/pro#9880 updates isChanged with isReadonly flag to fix the form flow (#4535)
* dbeaver/pro#9880 updates isChanged with isReadonly flag to fix the form flow

* Revert "dbeaver/pro#9880 updates isChanged with isReadonly flag to fix the form flow"

This reverts commit 69ab0b0cbd.

* dbeaver/pro#9880 update isChanged logic to handle readonly mode in ConnectionFormSSHPart

---------

Co-authored-by: Evgenia <139753579+EvgeniaBzzz@users.noreply.github.com>
2026-08-10 16:35:28 +02:00
sergeyteleshevandDaria Marutkina 740599f41d dbeaver/pro#9982 adds position prop to InputAutocompletionMenu for accurate placement (#4537)
* dbeaver/pro#9982 adds position prop to InputAutocompletionMenu for accurate placement

* dbeaver/pro#9982 recomputes proposals after it is hidden with esc to restore its position

---------

Co-authored-by: Daria Marutkina <125263541+dariamarutkina@users.noreply.github.com>
2026-08-10 16:02:44 +02:00
sergeyteleshevandDaria Marutkina 16915940ba 4551 generate full ddl for postgresql metaobjects (#4507)
* dbeaver/pro#4551 adds full DDL for GeneratedSql dialog

* dbeaver/pro#4551 introduces core-sql-generator plugin + adds "full DDL" action into data editor footer for DDL properties tab

* dbeaver/pro#4551 adds supportsFullDdl flag to hide full ddl feature if connection doesn't support it

* dbeaver/pro#4551 adds SqlEntityQueryResource to separate sqlGenerator logic

* dbeaver/pro#4551 refactor: remove core-sql-generator package and related files; migrate SQL generation logic to plugin-sql-generator

* dbeaver/pro#4551 implements DDL query state management and refactors DDL viewer context

* dbeaver/pro#4551 removes state & name in favor of onChange for GeneratedSqlDIalog

---------

Co-authored-by: Daria Marutkina <125263541+dariamarutkina@users.noreply.github.com>
2026-08-09 10:12:16 +02:00
sergeyteleshevandDaria Marutkina 1c789cf49b dbeaver/pro#9946 resets admin settings in leave layout scenario (#4528)
Co-authored-by: Daria Marutkina <125263541+dariamarutkina@users.noreply.github.com>
2026-08-07 15:47:35 +02:00
sergeyteleshevandDaria Marutkina 9474ec9718 dbeaver/pro#8405 adds accessibility for the filter button in the users page (#4527)
Co-authored-by: Daria Marutkina <125263541+dariamarutkina@users.noreply.github.com>
2026-08-07 14:25:30 +02:00
Ainurandkseniaguzeeva 46a07033ad dbeaver/pro#10001 validate resource folder fix for multinode apps (#4529)
* dbeaver/pro#10001 validate resource folder fix for multinode apps

* dbeaver/pro#10001 fix validation

* dbeaver/pro#10001 validate only dots fix

---------

Co-authored-by: kseniaguzeeva <112612526+kseniaguzeeva@users.noreply.github.com>
2026-08-07 14:02:19 +02:00
eb907e95f4 dbeaver/pro#8926 feat: add js helpers for export as image (#4525)
* dbeaver/pro#8926 feat: add js helpers for export as image

* dbeaver/pro#8926 refactor: remove duplicated helpers from core-utils and used from js-helpers

* dbeaver/pro#8926 fix: resolveSize doesn't use partial sizes

---------

Co-authored-by: Evgenia <139753579+EvgeniaBzzz@users.noreply.github.com>
Co-authored-by: sergeyteleshev <iamsergeyteleshev@gmail.com>
2026-08-07 13:25:07 +02:00
alexandmr-anton-t cb01f6a988 dbeaver/pro#9957 add labels to ai profile form (#4516)
Co-authored-by: mr-anton-t <42037741+mr-anton-t@users.noreply.github.com>
2026-08-06 13:39:55 +02:00
sergeyteleshevandDaria Marutkina b2065ffe62 dbeaver/pro#10119 fixes fast-uri, undici, brace-expansion CVEs (#4524)
Co-authored-by: Daria Marutkina <125263541+dariamarutkina@users.noreply.github.com>
2026-08-06 12:30:47 +02:00
sergeyteleshevandDaria Marutkina 9e8f4b83a6 dbeaver/pro#4428 removes frontend reordering for the suggestions (#4511)
* dbeaver/pro#4428 removes frontend reordering for the suggestions

* dbeaver/pro#4428 refactor: improve fuzzy filtering and sorting of SQL autocompletion proposals

---------

Co-authored-by: Daria Marutkina <125263541+dariamarutkina@users.noreply.github.com>
2026-08-06 12:29:50 +02:00
alexandEvgenia 5170fa7dfc dbeaver/pro#10061 disable default profile deletion (#4526)
Co-authored-by: Evgenia <139753579+EvgeniaBzzz@users.noreply.github.com>
2026-08-06 10:59:16 +02:00
Ainur ad26e0e849 dbeaver/pro#10097 enable ai feature by default (#4530) 2026-08-06 10:30:44 +02:00
eed9cd979e dbeaver/cloudbeaver#4417 add redirect on the same tab (#4502)
* dbeaver/cloudbeaver#4417 add redirect on the same tab

* dbeaver/pro#4417 feat:support full-page redirect

* dbeaver/pro#4417 refactor: remove extra type

* dbeaver/pro#4417 feat: skip navigation when in-tab redirect

* Revert "dbeaver/pro#4417 feat: skip navigation when in-tab redirect"

This reverts commit 4244d41582.

---------

Co-authored-by: Sychev Andrey <44414066+SychevAndrey@users.noreply.github.com>
Co-authored-by: Sychev Andrey <nukemoore@gmail.com>
Co-authored-by: Evgenia <139753579+EvgeniaBzzz@users.noreply.github.com>
2026-08-05 16:46:01 +02:00
sergeyteleshevandEvgenia 1b8ea64cd0 dbeaver/pro#10062 moves focused cell after pin/unpin (#4513)
Co-authored-by: Evgenia <139753579+EvgeniaBzzz@users.noreply.github.com>
2026-08-04 17:34:39 +02:00
fb43b0f567 dbeaver/pro#9076 clear log for ws and long pooling (#4514)
Co-authored-by: mr-anton-t <42037741+mr-anton-t@users.noreply.github.com>
Co-authored-by: Evgenia <139753579+EvgeniaBzzz@users.noreply.github.com>
2026-08-04 17:16:45 +02:00
317ba5486a dbeaver/pro#8482 fetch conditions for handlers (#4475)
Co-authored-by: Daria Marutkina <125263541+dariamarutkina@users.noreply.github.com>
Co-authored-by: Ainur <59531286+yagudin10@users.noreply.github.com>
2026-08-04 17:14:12 +02:00
4d4049fcd1 dbeaver/pro#10054 fix returning default values for non-object properties (#4510)
Co-authored-by: Evgenia <139753579+EvgeniaBzzz@users.noreply.github.com>
Co-authored-by: mr-anton-t <42037741+mr-anton-t@users.noreply.github.com>
2026-08-04 14:55:27 +02:00
alexandEvgenia 27cfdb405f dbeaver/pro#9256 treat boolean as editable on the data level (#4505)
Co-authored-by: Evgenia <139753579+EvgeniaBzzz@users.noreply.github.com>
2026-08-03 18:48:55 +02:00
alexandDaria Marutkina f1dc4e07bb dbeaver/pro#10056 adjust height in frame (#4509)
Co-authored-by: Daria Marutkina <125263541+dariamarutkina@users.noreply.github.com>
2026-08-03 18:48:20 +02:00
Ruslan MusaevandEvgenia 57cc6e4808 dbeaver/pro#10048 fix ldap groups matching (#4508)
Co-authored-by: Evgenia <139753579+EvgeniaBzzz@users.noreply.github.com>
2026-08-03 15:11:21 +02:00
Daria Marutkina 6bfe58506f dbeaver/pro#10040 Update README file (#4520)
Added changelog entries for version 26.1.4, including new AI features, connectivity improvements, accessibility updates, general enhancements, security fixes, and database driver updates.
2026-08-03 12:09:23 +02:00
Ruslan Musaev 1d6e5c6ce6 dbeaver/pro#9724 fix graphql schema for doc (#4519)
* dbeaver/pro#9724 fix graphql schema for doc

* dbeaver/pro#9724 fix graphql schema for doc
2026-07-31 13:58:54 +02:00
185 changed files with 2195 additions and 607 deletions
+37
View File
@@ -28,6 +28,43 @@ You can see a live demo of CloudBeaver server here: https://demo.cloudbeaver.io
## Changelog
### 26.1.4 2026-08-03
- AI Assistant:
- Added AI Chat to help users generate and fix queries and explore data more easily. It’s integrated with the SQL Editor to run generated queries instantly. Access it from the top toolbar. All AI features can be turned off in the Server configuration.
- Added the option to configure AI profiles in the Administration panel, usable in the AI Chat and for the @ai command in the SQL Editor. Both OpenAI and Copilot engines can be utilized for configuration.
- Connectivity:
- Resolved the issue where the application could no longer connect to the database after running for an extended period.
- Resolved the issue where user connection settings required re-login in a new session to apply their changes.
- Accessibility:
- Added a loader to the Save button in the Administration panel for visualizing the loading process.
- Improved drag-and-drop in the Navigator Tree for objects by enlarging the auto-scroll trigger areas.
- General:
- Added the ability to export generated SQL to a .sql file from the Generate SQL dialog.
- Improved "Open in SQL Editor" behavior by preserving the source table context for the Generate SQL dialog.
- Fixed synchronization between instances eliminates the need to refresh to see the updated connection or folder in another instance.
- Security:
- Added a new "Auto-create users" setting for the reverse proxy provider, allowing new user creation on login. It is enabled by default.
- Updated the PostgreSQL JDBC driver to version 42.7.13, including a fix for a high-severity security vulnerability (CVE-2026-54291).
- Fixed the critical vulnerability (CVE-2026-59873) in the tar library. The library was updated to version 7.5.19.
- Fixed the high vulnerability (CVE-2026-10050) in the jetty-security library. The library was updated to version 12.1.11.
- Fixed the high vulnerability (CVE-2026-14257) in the brace-expansion library. The library was updated to version 5.0.8.
- Fixed the high vulnerability (GHSA-r28c-9q8g-f849) in the postcss library. The library was updated to version 8.5.18.
- Fixed the high vulnerability (GHSA-pm4m-ph32-ghv5) in the js-yaml library. The library was updated to version 5.2.2.
- Fixed the high vulnerability (CVE-2026-59880) in the immutable library. The library was updated to version 5.1.8.
- Fixed the high vulnerability (CVE-2026-59879) in the fast-uri library. The library was updated to version 3.1.3.
- Fixed the high vulnerability (CVE-2026-13311) in the shell-quote library. The library was updated to version 1.9.0.
- Fixed the high vulnerability (GHSA-gcfj-64vw-6mp9) in the axios library. The library was updated to version 1.18.0.
- Databases and drivers:
- ClickHouse
- The driver was updated to version 0.10.0.
- Improved JSON display in the Value panel.
- MySQL: Fixed support for comments starting with '#' symbol in the SQL Editor.
- PostgreSQL:
- The driver was updated to version 42.7.13.
- Fixed an error that occurred when applying filters to database nodes.
- Fixed generated calls for procedures with OUT parameters.
### 26.1.3 2026-07-20
- Data Editor:
@@ -42,63 +42,58 @@ import java.util.*;
* Web connection info
*/
public class WebPropertyInfo {
private WebSession session;
private DBPPropertyDescriptor property;
private DBPPropertySource propertySource;
private boolean showProtected;
@NotNull
private final WebSession session;
@NotNull
private final DBPPropertyDescriptor property;
@Nullable
private final DBPPropertySource propertySource;
private Object[] validValues;
private String[] supportedConfigurationTypes = new String[0];
private Object defaultValue;
public WebPropertyInfo(WebSession session, DBPPropertyDescriptor property, DBPPropertySource propertySource) {
public WebPropertyInfo(
@NotNull WebSession session,
@NotNull DBPPropertyDescriptor property,
@Nullable DBPPropertySource propertySource
) {
this.session = session;
this.property = property;
this.propertySource = propertySource;
}
public WebPropertyInfo(WebSession session, DBPPropertyDescriptor property) {
this.session = session;
this.property = property;
}
public boolean isShowProtected() {
return showProtected;
}
public void setShowProtected(boolean showProtected) {
this.showProtected = showProtected;
public WebPropertyInfo(@NotNull WebSession session, @NotNull DBPPropertyDescriptor property) {
this(session, property, null);
}
///////////////////////////////////
// General properties
///////////////////////////////////
@NotNull
@Property
public String getId() {
return CommonUtils.toString(property.getId());
}
@NotNull
@Property
public String getDisplayName() {
if (property instanceof DBPNamedObjectLocalized) {
return ((DBPNamedObjectLocalized) property).getLocalizedName(session.getLocale());
if (property instanceof DBPNamedObjectLocalized localized) {
return localized.getLocalizedName(session.getLocale());
} else {
return property.getDisplayName();
}
}
@Nullable
@Property
public String getDescription() {
if (property instanceof DBPObjectWithDescriptionLocalized) {
return ((DBPObjectWithDescriptionLocalized) property).getLocalizedDescription(session.getLocale());
if (property instanceof DBPObjectWithDescriptionLocalized localized) {
return localized.getLocalizedDescription(session.getLocale());
} else {
return property.getDescription();
}
}
@Nullable
@Property
public String getHint() {
return property.getHint();
@@ -109,38 +104,47 @@ public class WebPropertyInfo {
return property instanceof ObjectPropertyDescriptor ? ((ObjectPropertyDescriptor) property).getOrderNumber() : -1;
}
@Nullable
@Property
public String getCategory() {
return property.getCategory();
}
@Nullable
@Property
public String getType() {
return getDataType();
}
@Nullable
@Property
public String getDataType() {
Class<?> dataType = property.getDataType();
return dataType == null ? null : dataType.getSimpleName();
}
@NotNull
@Property
public PropertyLength getLength() {
return property.getLength();
}
@Nullable
@Property
public Object getDefaultValue() throws DBException {
var defaultValue = property.getDefaultValue() == null ? this.defaultValue : property.getDefaultValue();
return defaultValue == null ? getValue() : defaultValue;
Object defaultValue = property.getDefaultValue();
if (property instanceof ObjectPropertyDescriptor && defaultValue == null) {
return getValue();
}
return defaultValue;
}
@Nullable
@Property
public Object getValue() throws DBException {
Object value = propertySource == null ? null : propertySource.getPropertyValue(session.getProgressMonitor(), property.getId());
if (property instanceof ObjectPropertyDescriptor opd &&
(!showProtected && opd.isPassword() || opd.isHidden())
(opd.isPassword() || opd.isHidden())
) {
return maskValue(value);
} else if (property instanceof PropertyDescriptor pd) {
@@ -152,6 +156,7 @@ public class WebPropertyInfo {
return value == null ? null : makePropertyValue(value);
}
@Nullable
@Property
public Object[] getValidValues() {
if (property instanceof IPropertyValueListProvider) {
@@ -164,17 +169,18 @@ public class WebPropertyInfo {
}
return validValues;
}
return validValues;
}
return validValues;
return null;
}
@NotNull
@Property
public String[] getFeatures() {
String[] features = property.getFeatures();
return features == null ? new String[0] : features;
}
@NotNull
@Property
public String[] getSupportedConfigurationTypes() {
if (property instanceof ProviderPropertyDescriptor) {
@@ -182,7 +188,7 @@ public class WebPropertyInfo {
.map(DBPDriverConfigurationType::toString)
.toArray(String[]::new);
}
return supportedConfigurationTypes;
return new String[0];
}
@Property
@@ -203,12 +209,14 @@ public class WebPropertyInfo {
return false;
}
@NotNull
@Override
public String toString() {
return CommonUtils.toString(property.getId());
}
private Object makePropertyValue(Object value) {
@Nullable
private Object makePropertyValue(@Nullable Object value) {
if (value == null) {
return null;
// } else if (value instanceof DBSObject) {
@@ -290,21 +298,6 @@ public class WebPropertyInfo {
return conditions;
}
//TODO: delete after refactoring on front-end
public void setDefaultValue(String defaultValue) {
this.defaultValue = defaultValue;
}
//TODO: delete after refactoring on front-end
public void setValidValues(Object[] validValues) {
this.validValues = validValues;
}
//TODO: delete after refactoring on front-end
public void setSupportedConfigurationTypes(String[] supportedConfigurationTypes) {
this.supportedConfigurationTypes = supportedConfigurationTypes;
}
public record Condition(@NotNull String expression, @NotNull Type conditionType) {
public enum Type {
HIDE,
@@ -1028,8 +1028,24 @@ public class LocalResourceController extends BaseLocalResourceController {
try {
while (resourcePath.startsWith("/")) resourcePath = resourcePath.substring(1);
Path targetPath = projectPath.resolve(resourcePath).normalize();
if (!targetPath.startsWith(projectPath)) {
throw new DBException("Invalid resource path");
// hack for custom path implementations that returns / as root path
Iterator<Path> projectParts = projectPath.iterator();
Iterator<Path> targetParts = targetPath.iterator();
Iterator<Path> unnormalizedTargetParts = projectPath.resolve(resourcePath).iterator();
while (projectParts.hasNext()) {
if (!targetParts.hasNext()) {
throw new DBException("Invalid resource path");
}
Path projectPart = projectParts.next();
Path targetPart = targetParts.next();
unnormalizedTargetParts.next();
if (!projectPart.equals(targetPart)) {
throw new DBException("Invalid resource path");
}
}
// validating unnormalized target parts to avoid invalid characters in resource names
while (unnormalizedTargetParts.hasNext()) {
GeneralUtils.validateResourceNameUnconditionally(unnormalizedTargetParts.next().toString());
}
return targetPath;
} catch (InvalidPathException e) {
@@ -59,6 +59,7 @@ public class WebAuthProviderDescriptor extends AbstractDescriptor {
private final boolean isPrivate;
private final boolean isAuthHidden;
private final boolean isCaseInsensitive;
private final boolean sameTabRedirectOnLogout;
private final boolean serviceProvider;
private final String[] requiredFeatures;
private final boolean isRequired;
@@ -75,6 +76,7 @@ public class WebAuthProviderDescriptor extends AbstractDescriptor {
this.isRequired = CommonUtils.toBoolean(cfg.getAttribute(WebRegistryConstant.ATTR_REQUIRED));
this.isAuthHidden = CommonUtils.toBoolean(cfg.getAttribute(WebRegistryConstant.ATTR_AUTH_HIDDEN));
this.isCaseInsensitive = CommonUtils.toBoolean(cfg.getAttribute(WebRegistryConstant.ATTR_CASE_INSENSITIVE));
this.sameTabRedirectOnLogout = CommonUtils.toBoolean(cfg.getAttribute(WebRegistryConstant.ATTR_SAME_TAB_LOGOUT_REDIRECT));
this.serviceProvider = CommonUtils.toBoolean(cfg.getAttribute(WebRegistryConstant.ATTR_SERVICE_PROVIDER));
for (IConfigurationElement cfgElement : cfg.getChildren(WebRegistryConstant.TAG_CONFIGURATION)) {
@@ -129,6 +131,10 @@ public class WebAuthProviderDescriptor extends AbstractDescriptor {
return trusted;
}
public boolean isSameTabRedirectOnLogout() {
return sameTabRedirectOnLogout;
}
public boolean isPrivate() {
return isPrivate;
}
@@ -29,6 +29,7 @@ public class WebRegistryConstant {
public static final String ATTR_REQUIRED = "required";
public static final String ATTR_AUTH_HIDDEN = "authHidden";
public static final String ATTR_CASE_INSENSITIVE = "caseInsensitive";
public static final String ATTR_SAME_TAB_LOGOUT_REDIRECT = "sameTabRedirectOnLogout";
public static final String ATTR_REQUIRED_FEATURES = "requiredFeatures";
public static final String ATTR_CATEGORIES = "categories";
public static final String ATTR_SERVICE_PROVIDER = "serviceProvider";
@@ -235,6 +235,14 @@ public class ServletAppUtils {
response.addCookie(sessionCookie);
}
public static String getHeaderOrLegacy(HttpServletRequest request, String headerName, String legacyHeaderName) {
String value = request.getHeader(headerName);
if (CommonUtils.isEmpty(value)) {
value = request.getHeader(legacyHeaderName);
}
return value;
}
public static String getRequestCookie(HttpServletRequest request, String cookieName) {
Cookie[] cookies = request.getCookies();
if (cookies != null) {
@@ -17,7 +17,7 @@
</extension>
<extension point="io.cloudbeaver.feature">
<feature id="ai" label="AI Integration" description="Enables AI Chat in the application."/>
<feature id="ai" label="AI Integration" description="Enables AI Chat in the application." enabledByDefault="true"/>
</extension>
<extension point="org.jkiss.dbeaver.application">
@@ -66,7 +66,7 @@
<driver id="generic:trino_jdbc"/>
<driver id="duckdb:duckdb_jdbc"/>
<driver id="generic:kyuubi_hive"/>
<driver id="databend:databend"/>
<driver id="databend:databend_jdbc"/>
</extension>
@@ -1,3 +1,7 @@
####################################################
# Core API
####################################################
extend type ServerConfig {
serverURL: String! @deprecated
rootURI: String!
@@ -1,6 +1,10 @@
# Any object (JSON)
####################################################
# Base schema
####################################################
"Any object (JSON)"
scalar Object
# Date/Time
"Date/Time"
scalar DateTime
scalar Date
@@ -556,7 +556,7 @@ type ConnectionInfo {
supportedDataFormats: [ ResultDataFormat! ]!
configurationType: DriverConfigurationType
#Access properties
"Access properties"
canViewSettings: Boolean!
canEdit: Boolean!
canDelete: Boolean!
@@ -737,7 +737,7 @@ input ConnectionConfig {
"ID of database driver"
driverId: ID
# Custom connection parameters (all optional)
"Custom connection parameters (all optional)"
host: String
port: String
serverName: String
@@ -1,4 +1,7 @@
####################################################
# Datasource events
####################################################
extend enum CBServerEventId {
"Datasource created"
@@ -1,3 +1,7 @@
####################################################
# SQL events
####################################################
extend enum CBServerEventId {
"Database output log updated (e.g., DBMS in Oracle)"
cb_database_output_log_updated,
@@ -1,4 +1,6 @@
####################################################
# Nodes, objects and properties
####################################################
type ObjectDescriptor {
id: Int
@@ -143,7 +145,7 @@ extend type Query {
"Refreshes node based on the node path"
navRefreshNode( nodePath: ID! ): Boolean @deprecated # Use navReloadNode method from Mutation (24.2.4)
# contextId currently not using
"contextId currently not using"
navGetStructContainers( projectId: ID, connectionId: ID!, contextId: ID, catalog: ID ): DatabaseStructContainers!
}
@@ -38,7 +38,7 @@ type SQLCompletionProposal {
# Data read / SQL execution
####################################################
# SQL context must be created for each SQL editor
"SQL context must be created for each SQL editor"
type SQLContextInfo {
id: ID!
@@ -153,10 +153,12 @@ type SQLResultSet {
"Returns list of rows in the result set. Each row contains data and metadata"
rowsWithMetaData: [SQLResultRowMetaData] @since(version: "23.3.5")
# True means that resultset was generated by single entity query
# New rows can be added, old rows can be deleted
"""
True means that resultset was generated by single entity query
New rows can be added, old rows can be deleted
"""
singleEntity: Boolean!
# server always returns hasMoreData = false
"server always returns hasMoreData = false"
hasMoreData: Boolean!
"Identifies if result set has row identifier. If true then it is possible update data or load LOB files"
hasRowIdentifier: Boolean! @deprecated (reason: "use rowIdentifierState (25.3.4)")
@@ -213,7 +215,7 @@ type SQLQueryResults {
updateRowCount: Float
sourceQuery: String
# Actual data format of this result
"Actual data format of this result"
dataFormat: ResultDataFormat
resultSet: SQLResultSet
@@ -221,17 +223,17 @@ type SQLQueryResults {
type SQLExecuteInfo {
# Status message
"Status message"
statusMessage: String
# Execute time (ms)
"Execute time (ms)"
duration: Int!
# Actual conditions applied to query
"Actual conditions applied to query"
filterText: String
# Full query that was executed, contains all used filters
"Full query that was executed, contains all used filters"
fullQuery: String
# Original query that was executed, without modifications
"Original query that was executed, without modifications"
originalQuery: String
# Results
"Results"
results: [ SQLQueryResults! ]!
}
@@ -168,7 +168,7 @@ public class CBEventsLongPollingServlet extends HttpServlet {
log.debug("LP request rejected: access token expired");
sendError(resp, HttpConstants.CODE_TOKEN_EXPIRED, e.getMessage());
} catch (SecurityException e) {
log.warn("LP session resolve failed", e);
log.debug("LP request rejected: " + e.getMessage());
sendError(resp, HttpConstants.CODE_UNAUTHORIZED, e.getMessage());
}
return null;
@@ -215,6 +215,7 @@ public class CBEventsLongPollingServlet extends HttpServlet {
ps.handleWebSessionEvent(new WSSocketConnectedEvent(ws.getApplication().getApplicationRunId()));
resp.setHeader(WSConstants.WS_SESSION_HEADER, sid);
resp.setHeader(WSConstants.WS_SESSION_HEADER_LEGACY, sid);
log.debug("HTTP Long-Poll channel opened for session " + sid);
return ps;
@@ -304,7 +305,7 @@ public class CBEventsLongPollingServlet extends HttpServlet {
@Override
public BaseWebSession resolve(@NotNull HttpServletRequest req) throws SMException {
String sid = req.getHeader(WSConstants.WS_SESSION_HEADER);
String sid = ServletAppUtils.getHeaderOrLegacy(req, WSConstants.WS_SESSION_HEADER, WSConstants.WS_SESSION_HEADER_LEGACY);
String token = req.getHeader(WSConstants.WS_AUTH_HEADER);
if (CommonUtils.isEmpty(sid) && CommonUtils.isEmpty(token)) {
@@ -1,6 +1,6 @@
/*
* DBeaver - Universal Database Manager
* Copyright (C) 2010-2024 DBeaver Corp and others
* Copyright (C) 2010-2026 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.
@@ -47,6 +47,11 @@ public class CBEventsWebSocket extends CBAbstractWebSocket implements CBWebSessi
} else {
this.webSession = (BaseWebSession) session.getUserProperties()
.get(CBWebSocketServerConfigurator.PROP_WEB_SESSION);
if (this.webSession == null) {
log.debug("No web session for websocket connection, closing");
close();
return;
}
this.webSession.addEventHandler(this);
handleEvent(new WSSocketConnectedEvent(webSession.getApplication().getApplicationRunId()));
log.debug("EventWebSocket connected to the " + webSession.getSessionId() + " session");
@@ -1,6 +1,6 @@
/*
* DBeaver - Universal Database Manager
* Copyright (C) 2010-2025 DBeaver Corp and others
* Copyright (C) 2010-2026 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.
@@ -88,7 +88,8 @@ public class CBWebSocketServerConfigurator extends ServerEndpointConfig.Configur
throw new RuntimeException(e.getMessage(), e);
}
if (sec.getUserProperties().get(PROP_WEB_SESSION) == null) {
throw new RuntimeException("No web session found for websocket request");
// no throwing exception here. will be handled in onOpen
log.debug("No web session found for websocket request, connection will be closed on open");
}
}
@@ -1,3 +1,7 @@
####################################################
# Administration API
####################################################
"Describes the type of the administrative subject (user or team)"
enum AdminSubjectType {
"User"
@@ -1,3 +1,7 @@
####################################################
# AI events
####################################################
extend enum CBServerEventId {
cb_ai_chat_message_chunk @since(version: "25.1.1")
cb_ai_chat_message_error @since(version: "25.1.1")
@@ -1,4 +1,7 @@
####################################################
# AI admin queries
####################################################
enum AIMessageType {
SYSTEM,
USER,
@@ -32,6 +35,8 @@ type AIEngineInfo @since(version: "23.2.2") {
id: ID!
"Name of the AI engine."
name: String!
"Path to the AI engine icon."
icon: String
}
type AIChatConversationInfo @since(version: "25.1.1") {
@@ -269,6 +274,9 @@ extend type Mutation @since(version: "23.2.2") {
"Clears the chat messages in the AI chat conversation. The messages will be removed from the conversation."
aiClearLastChatMessages(conversationId: ID!, messageId: ID!): Boolean! @since(version: "25.1.1")
"Cancels the in-progress AI response generation in the specified conversation."
aiCancelChatMessage(conversationId: ID!): Boolean! @since(version: "26.1.5")
"Saves AI settings (e.g. supporting confirming metadata transfer) for the specified connection."
aiSaveDataSourceSettings(dataSourceId: DataSourceIdInput!, settings: AIDataSourceSettingsInput!): AIDataSourceSettingsInfo! @since(version: "25.3.3")
}
@@ -24,6 +24,8 @@ import io.cloudbeaver.service.ai.model.WebAISendChatMessageInfo;
import io.cloudbeaver.service.ai.model.WebAiChatResponseConsumer;
import io.cloudbeaver.service.ai.model.events.WSAiChatMessageEvent;
import io.cloudbeaver.utils.ServletAppUtils;
import org.eclipse.core.runtime.IStatus;
import org.eclipse.core.runtime.Status;
import org.jkiss.code.NotNull;
import org.jkiss.code.Nullable;
import org.jkiss.dbeaver.DBException;
@@ -39,9 +41,9 @@ import org.jkiss.dbeaver.model.app.DBPProject;
import org.jkiss.dbeaver.model.navigator.DBNDatabaseNode;
import org.jkiss.dbeaver.model.navigator.DBNNode;
import org.jkiss.dbeaver.model.navigator.DBNUtils;
import org.jkiss.dbeaver.model.runtime.AbstractJob;
import org.jkiss.dbeaver.model.runtime.DBRProgressMonitor;
import org.jkiss.dbeaver.model.struct.DBSObject;
import org.jkiss.dbeaver.utils.RuntimeUtils;
import org.jkiss.utils.CommonUtils;
import java.time.Clock;
@@ -139,10 +141,11 @@ public class WebAIUtils {
@Nullable AIConfirmation confirmation,
@NotNull String jobName
) {
webSession.setAttribute(getWaitingAttr(conversation), true);
CompletableFuture<AIChatConversation> result = new CompletableFuture<>();
RuntimeUtils.scheduleJob(
jobName, monitor -> {
AbstractJob job = new AbstractJob(jobName) {
@NotNull
@Override
protected IStatus run(@NotNull DBRProgressMonitor monitor) {
try {
AIChatResponseConsumer subscriber = new WebAiChatResponseConsumer(conversation, webSession, aiChatSession);
aiChatSession.processAICompletion(
@@ -159,15 +162,25 @@ public class WebAIUtils {
}
});
} catch (DBException e) {
log.error("Error processing AI completion", e);
var errorMessage = conversation.addMessage(AIMessage.errorMessage(e));
webSession.addSessionEvent(new WSAiChatMessageEvent(new WebAIMessage(errorMessage, conversation)));
aiChatSession.notifyMessageAdd(conversation, errorMessage);
if (monitor.isCanceled()) {
log.debug("AI completion cancelled", e);
} else {
log.error("Error processing AI completion", e);
var errorMessage = conversation.addMessage(AIMessage.errorMessage(e));
webSession.addSessionEvent(new WSAiChatMessageEvent(new WebAIMessage(errorMessage, conversation)));
aiChatSession.notifyMessageAdd(conversation, errorMessage);
}
} finally {
webSession.removeAttribute(getWaitingAttr(conversation));
// Only clear the flag if it still points to this job
if (webSession.getAttribute(getWaitingAttr(conversation)) == this) {
webSession.removeAttribute(getWaitingAttr(conversation));
}
}
return Status.OK_STATUS;
}
);
};
webSession.setAttribute(getWaitingAttr(conversation), job);
job.schedule();
return result;
}
@@ -257,21 +270,25 @@ public class WebAIUtils {
throw new DBWebException("AI services restricted for '%s'. Please contact your administrator if you need it.".formatted(
conversation.getDataSource()));
}
String caption = conversation.getCaption();
AIChatMessage promptMessage = conversation.addMessage(message);
webSession.addSessionEvent(new WSAiChatMessageEvent(new WebAIMessage(promptMessage, conversation)));
aiChatSession.notifyMessageAdd(conversation, promptMessage);
if (!CommonUtils.equalObjects(caption, conversation.getCaption())) {
aiChatSession.notifyConversationRenamed(conversation, conversation.getCaption());
AIChatMessage promptMessage;
AIChatMessage result;
synchronized (conversation) {
String caption = conversation.getCaption();
promptMessage = conversation.addMessage(message);
webSession.addSessionEvent(new WSAiChatMessageEvent(new WebAIMessage(promptMessage, conversation)));
aiChatSession.notifyMessageAdd(conversation, promptMessage);
if (!CommonUtils.equalObjects(caption, conversation.getCaption())) {
aiChatSession.notifyConversationRenamed(conversation, conversation.getCaption());
}
if (!AIUtils.hasValidConfiguration()) {
throw new DBWebException("Invalid AI configuration");
}
if (webSession.getAttribute(WebAIUtils.getWaitingAttr(conversation)) != null) {
throw new DBWebException("Conversation is already waiting for response");
}
result = new AIChatMessage(conversation.getNextMessageId(), AIMessage.assistantMessage("", null));
WebAIUtils.scheduleConversationSubmission(webSession, aiChatSession, conversation, null, "AI completion");
}
if (!AIUtils.hasValidConfiguration()) {
throw new DBWebException("Invalid AI configuration");
}
if (webSession.getAttribute(WebAIUtils.getWaitingAttr(conversation)) != null) {
throw new DBWebException("Conversation is already waiting for response");
}
AIChatMessage result = new AIChatMessage(conversation.getNextMessageId(), AIMessage.assistantMessage("", null));
WebAIUtils.scheduleConversationSubmission(webSession, aiChatSession, conversation, null, "AI completion");
return new WebAISendChatMessageInfo(
new WebAIChatConversation(webSession, conversation),
new WebAIMessage(promptMessage, conversation),
@@ -45,7 +45,7 @@ public interface DBWServiceAI extends DBWService {
WebAISettingsConfig getAiSettings() throws DBWebException;
@NotNull
@WebAction(requirePermissions = DBWConstants.PERMISSION_ADMIN)
@WebAction
List<WebAIEngine> getEngineConfigurations() throws DBWebException;
@NotNull
@@ -139,6 +139,12 @@ public interface DBWServiceAI extends DBWService {
@NotNull String messageId
) throws DBWebException;
@WebAction
boolean cancelChatMessage(
@NotNull WebSession webSession,
@NotNull String conversationId
) throws DBWebException;
@NotNull
@WebAction
WebAIDataSourceSettings getDataSourceAiSettings(
@@ -16,7 +16,9 @@
*/
package io.cloudbeaver.service.ai.gql;
import io.cloudbeaver.WebServiceUtils;
import org.jkiss.code.NotNull;
import org.jkiss.code.Nullable;
import org.jkiss.dbeaver.model.ai.registry.AIEngineDescriptor;
import org.jkiss.dbeaver.model.meta.Property;
@@ -41,4 +43,10 @@ public class WebAIEngine {
return descriptor.getLabel();
}
@Nullable
@Property
public String getIcon() {
return WebServiceUtils.makeIconId(descriptor.getIcon());
}
}
@@ -25,6 +25,7 @@ import io.cloudbeaver.model.session.WebSession;
import io.cloudbeaver.server.CBApplication;
import io.cloudbeaver.service.ai.WebAIUtils;
import io.cloudbeaver.service.ai.model.*;
import io.cloudbeaver.service.ai.model.events.WSAiChatMessageEvent;
import io.cloudbeaver.service.ai.model.inputs.DataSourceId;
import io.cloudbeaver.service.ai.model.inputs.WebAIChatConversationInput;
import io.cloudbeaver.service.ai.model.inputs.WebAIConfigurationProfileInput;
@@ -46,12 +47,14 @@ import org.jkiss.dbeaver.model.ai.engine.AIDatabaseContext;
import org.jkiss.dbeaver.model.ai.engine.AIEngine;
import org.jkiss.dbeaver.model.ai.engine.AIEngineProperties;
import org.jkiss.dbeaver.model.ai.engine.AIModel;
import org.jkiss.dbeaver.model.ai.internal.AIChatMessages;
import org.jkiss.dbeaver.model.ai.prompt.AIPromptGenerateSql;
import org.jkiss.dbeaver.model.ai.registry.*;
import org.jkiss.dbeaver.model.app.DBPProject;
import org.jkiss.dbeaver.model.data.json.JSONUtils;
import org.jkiss.dbeaver.model.logical.DBSLogicalDataSource;
import org.jkiss.dbeaver.model.preferences.DBPPropertyDescriptor;
import org.jkiss.dbeaver.model.runtime.AbstractJob;
import org.jkiss.dbeaver.model.runtime.DBRProgressMonitor;
import org.jkiss.dbeaver.model.websocket.event.WSWorkspaceConfigurationChangedEvent;
import org.jkiss.dbeaver.runtime.DBWorkbench;
@@ -397,6 +400,33 @@ public class WebServiceAI implements DBWServiceAI {
return true;
}
@Override
public boolean cancelChatMessage(
@NotNull WebSession webSession,
@NotNull String conversationId
) throws DBWebException {
WebAIUtils.validateAiPluginEnabled();
AIChatConversation conversation = WebAIUtils.getAiChatConversation(webSession, conversationId);
synchronized (conversation) {
boolean completionStarted = conversation.isActive();
conversation.cancelConversation();
boolean hadPendingJob = false;
if (webSession.getAttribute(WebAIUtils.getWaitingAttr(conversation)) instanceof AbstractJob job) {
hadPendingJob = true;
job.cancel();
}
webSession.removeAttribute(WebAIUtils.getWaitingAttr(conversation));
if (hadPendingJob && !completionStarted) {
// The completion job was still queued, so its response consumer will not be called.
// We need to add a cancellation message to the conversation and notify the client.
AIChatMessage cancelMessage = conversation.addMessage(
AIMessage.warningMessage(AIChatMessages.ai_chat_conversation_cancelled));
webSession.addSessionEvent(new WSAiChatMessageEvent(new WebAIMessage(cancelMessage, conversation)));
}
}
return true;
}
@NotNull
@Override
public WebAIDataSourceSettings getDataSourceAiSettings(
@@ -163,6 +163,12 @@ public class WebServiceBindingAI extends WebServiceBindingBase<DBWServiceAI> imp
getArgumentVal(env, "conversationId"),
getArgumentVal(env, "messageId")
)
).dataFetcher(
"aiCancelChatMessage",
env -> getService(env).cancelChatMessage(
getWebSession(env),
getArgumentVal(env, "conversationId")
)
).dataFetcher(
"aiCreateProfile", env -> getService(env).createProfile(
getWebSession(env),
@@ -1,18 +1,18 @@
/*
* DBeaver - Universal Database Manager
* Copyright (C) 2010-2026 DBeaver Corp
* Copyright (C) 2010-2026 DBeaver Corp and others
*
* All Rights Reserved.
* 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
*
* NOTICE: All information contained herein is, and remains
* the property of DBeaver Corp and its suppliers, if any.
* The intellectual and technical concepts contained
* herein are proprietary to DBeaver Corp and its suppliers
* and may be covered by U.S. and Foreign Patents,
* patents in process, and are protected by trade secret or copyright law.
* Dissemination of this information or reproduction of this material
* is strictly forbidden unless prior written permission is obtained
* from DBeaver Corp.
* 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.ai.model;
@@ -22,9 +22,11 @@ import io.cloudbeaver.service.ai.model.events.WSAiChatMessageErrorEvent;
import io.cloudbeaver.service.ai.model.events.WSAiChatMessageEvent;
import org.jkiss.code.NotNull;
import org.jkiss.dbeaver.model.ai.*;
import org.jkiss.dbeaver.model.ai.internal.AIChatMessages;
import org.jkiss.utils.CommonUtils;
import java.util.List;
import java.util.concurrent.CancellationException;
public class WebAiChatResponseConsumer implements AIChatResponseConsumer {
private final StringBuilder responseBuilder;
@@ -78,7 +80,12 @@ public class WebAiChatResponseConsumer implements AIChatResponseConsumer {
@Override
public void error(@NotNull Throwable throwable) {
var errorMessage = conversation.addMessage(AIMessage.errorMessage(throwable));
AIMessage aiMessage = throwable instanceof CancellationException cancellationException
? AIMessage.warningMessage(cancellationException.getMessage())
: AIMessage.errorMessage(throwable);
AIChatMessage errorMessage = conversation.addMessage(aiMessage);
if (responseBuilder.isEmpty()) {
webSession.addSessionEvent(
new WSAiChatMessageEvent(new WebAIMessage(errorMessage, conversation)));
@@ -94,12 +101,19 @@ public class WebAiChatResponseConsumer implements AIChatResponseConsumer {
}
@Override
public void complete(@NotNull List<AIMessageMeta> meta, boolean finishConversation) {
public void complete(@NotNull List<AIMessageMeta> meta, boolean finishConversation, boolean isCanceled) {
if (responseBuilder.isEmpty()) {
if (isCanceled) {
warning(AIChatMessages.ai_chat_conversation_cancelled);
}
return;
}
AIChatMessage responseMessage = conversation.addMessage(AIMessage.assistantMessage(responseBuilder.toString(), meta));
chatSession.notifyMessageAdd(conversation, responseMessage);
webSession.addSessionEvent(new WSAiChatMessageChunkEvent(conversation.getId(), responseMessage.id(), null, true));
if (isCanceled) {
warning(AIChatMessages.ai_chat_conversation_cancelled);
}
}
}
@@ -1,3 +1,7 @@
####################################################
# Authentication API
####################################################
enum AuthCredentialEncryption {
none,
plain,
@@ -15,11 +19,11 @@ type AuthCredentialInfo {
displayName: String!
description: String
# This field must be shown in admin panel
"This field must be shown in admin panel"
admin: Boolean!
# This field must be shown in login form
"This field must be shown in login form"
user: Boolean!
# This field can be used as user identifier
"This field can be used as user identifier"
identifying: Boolean!
possibleValues: [String]
@@ -104,8 +108,14 @@ type FederatedAuthResult @since(version: "25.0.3") {
userTokens: [UserAuthToken!]! @since(version: "25.0.3")
}
type LogoutLink @since(version: "26.1.4") {
url: String!
"If true, the URL must be opened as a full-page redirect in the current tab instead of a popup window"
sameTabRedirect: Boolean!
}
type LogoutInfo @since(version: "23.3.3") {
redirectLinks: [String!]!
redirectLinks: [LogoutLink!]!
}
type UserAuthToken {
@@ -20,5 +20,5 @@ import org.jkiss.code.NotNull;
import java.util.List;
public record WebLogoutInfo(@NotNull List<String> redirectLinks) {
public record WebLogoutInfo(@NotNull List<WebLogoutLink> redirectLinks) {
}
@@ -0,0 +1,29 @@
/*
* DBeaver - Universal Database Manager
* Copyright (C) 2010-2025 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.auth;
import org.jkiss.code.NotNull;
/**
* A single logout redirect link.
*
* @param url the URL to open on logout
* @param sameTabRedirect if true, the URL must be opened as a full-page redirect
* in the current tab instead of a popup window
*/
public record WebLogoutLink(@NotNull String url, boolean sameTabRedirect) {
}
@@ -249,7 +249,7 @@ public class WebServiceAuthImpl implements DBWServiceAuth {
List<WebAuthInfo> removedInfos = webSession.removeAuthInfo(providerId);
var cbApp = CBApplication.getInstance();
List<String> logoutUrls = new ArrayList<>();
List<WebLogoutLink> redirectLinks = new ArrayList<>();
String origin = ServletAppUtils.getOriginFromRequest(httpRequest);
for (WebAuthInfo removedInfo : removedInfos) {
if (removedInfo.getAuthProviderDescriptor()
@@ -274,11 +274,14 @@ public class WebServiceAuthImpl implements DBWServiceAuth {
);
}
if (CommonUtils.isNotEmpty(logoutUrl)) {
logoutUrls.add(logoutUrl);
redirectLinks.add(new WebLogoutLink(
logoutUrl,
removedInfo.getAuthProviderDescriptor().isSameTabRedirectOnLogout()
));
}
}
}
return new WebLogoutInfo(logoutUrls);
return new WebLogoutInfo(redirectLinks);
} catch (DBException e) {
throw new DBWebException("User logout failed", e);
}
@@ -1,4 +1,6 @@
####################################################
# Data transfer queries
####################################################
type DataTransferProcessorInfo {
id: ID!
@@ -46,6 +48,8 @@ input DataTransferImportSettings {
useBulkLoad: Boolean
"Use transactions during import"
useTransactions: Boolean
"Open new connection(s) for the import task"
openNewConnection: Boolean
}
input DataTransferImportParameters {
@@ -425,7 +425,7 @@ public class WebServiceDataTransfer implements DBWServiceDataTransfer {
throw new DBWebException("Import is canceled");
}
} catch (DBException e) {
throw new DBWebException("Import failed cause: " + e.getMessage());
throw new DBWebException("Import failed", e);
}
}
}
@@ -446,6 +446,9 @@ public class WebServiceDataTransfer implements DBWServiceDataTransfer {
consumerSettings.setUseTransactions(CommonUtils.getBoolean(
settings.get(DTConstants.PROP_USE_TRANSACTIONS),
consumerSettings.isUseTransactions()));
consumerSettings.setOpenNewConnections(CommonUtils.getBoolean(
settings.get(DTConstants.PROP_OPEN_NEW_CONNECTION),
consumerSettings.isOpenNewConnections()));
}
}
@@ -1,3 +1,7 @@
####################################################
# File system API
####################################################
type FSFile @since(version: "23.2.2") {
"Name of the file or folder"
name: String!
@@ -48,6 +48,7 @@ import java.util.*;
import javax.naming.Context;
import javax.naming.NamingEnumeration;
import javax.naming.NamingException;
import javax.naming.PartialResultException;
import javax.naming.directory.*;
import javax.naming.ldap.LdapName;
import javax.naming.ldap.Rdn;
@@ -562,12 +563,22 @@ public class LdapAuthProvider implements SMAuthProviderExternal<SMSession>, SMBr
}
context = initConnection(environment);
List<String> groupsByMemberOfAttribute = findGroupsByMemberOfAttribute(fullDN, context);
log.debug("Found " + groupsByMemberOfAttribute.size() + " groups by memberOf attribute");
result.addAll(groupsByMemberOfAttribute);
List<String> groupsByMemberAttribute = findGroupsByMemberAttribute(fullDN, ldapSettings, context);
log.debug("Found " + groupsByMemberAttribute.size() + " groups by member attribute");
result.addAll(groupsByMemberAttribute);
// Run both lookups independently: a failure in one must not discard the groups
// already collected by the other.
try {
List<String> groupsByMemberOfAttribute = findGroupsByMemberOfAttribute(fullDN, context);
log.debug("Found " + groupsByMemberOfAttribute.size() + " groups by memberOf attribute");
result.addAll(groupsByMemberOfAttribute);
} catch (Exception e) {
log.error("Failed to fetch groups by memberOf attribute. " + e.getMessage());
}
try {
List<String> groupsByMemberAttribute = findGroupsByMemberAttribute(fullDN, ldapSettings, context);
log.debug("Found " + groupsByMemberAttribute.size() + " groups by member attribute");
result.addAll(groupsByMemberAttribute);
} catch (Exception e) {
log.error("Failed to fetch groups by member attribute. " + e.getMessage());
}
} catch (Exception e) {
log.error("Group not found. " + e.getMessage());
} finally {
@@ -579,6 +590,7 @@ public class LdapAuthProvider implements SMAuthProviderExternal<SMSession>, SMBr
log.error("Close resource of ldap group search failed", e);
}
}
log.debug("Resolved " + result.size() + " external group id(s) for member '" + fullDN + "': " + result);
return new ArrayList<>(result);
}
@@ -602,6 +614,8 @@ public class LdapAuthProvider implements SMAuthProviderExternal<SMSession>, SMBr
}
}
}
} catch (PartialResultException e) {
log.debug("Ignoring LDAP continuation references while reading memberOf for '" + fullDN + "'");
} finally {
if (userRecord != null) {
userRecord.close();
@@ -634,6 +648,8 @@ public class LdapAuthProvider implements SMAuthProviderExternal<SMSession>, SMBr
log.error("Failed fetch user group. " + e.getMessage());
}
}
} catch (PartialResultException e) {
log.debug("Ignoring LDAP continuation references during member group search for '" + fullDN + "'");
} finally {
if (searchResults != null) {
searchResults.close();
@@ -1,4 +1,6 @@
####################################################
# Metadata queries
####################################################
extend type Query {
@@ -1,3 +1,7 @@
####################################################
# Resource manager events
####################################################
extend enum CBServerEventId {
"Resource manager resource created"
cb_rm_resource_created,
@@ -1,4 +1,6 @@
####################################################
# Metadata queries
####################################################
type RMProject {
id: ID!
@@ -17,7 +19,7 @@ type RMResource {
name: String!
folder: Boolean!
length: Int!
# Properties map
"Properties map"
properties: Object
}
@@ -20,6 +20,7 @@
description="Reverse proxy header based authentication"
trusted="true"
configurable="true"
sameTabRedirectOnLogout="true"
class="io.cloudbeaver.auth.provider.rp.RPAuthProvider"
icon="platform:/plugin/org.jkiss.dbeaver.model/icons/tree/key.svg">
<credentials>
@@ -1,6 +1,6 @@
/*
* DBeaver - Universal Database Manager
* Copyright (C) 2010-2024 DBeaver Corp and others
* Copyright (C) 2010-2026 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.
@@ -48,7 +48,10 @@ public class AuthenticationTest extends CloudbeaverMockTest {
private static final String GQL_AUTH_LOGOUT = """
query authLogoutExtended($provider: ID, $configuration: ID) {
result: authLogoutExtended(provider: $provider, configuration: $configuration) {
redirectLinks
redirectLinks {
url
sameTabRedirect
}
}
}""";
@@ -31,6 +31,10 @@
opacity: 1;
scale: 1;
}
&[data-leave] {
transition-duration: 0ms;
}
}
}
@@ -34,6 +34,7 @@
"dependencies": {
"async-mutex": "^0",
"color": "^5.0.2",
"modern-screenshot": "^4",
"p-debounce": "^5",
"p-memoize": "^8.0.0",
"quick-lru": "^7.1.0",
@@ -0,0 +1,37 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2026 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
export const IMAGE_EXPORT_FORMATS = ['SVG', 'PNG'] as const;
export type ImageExportFormat = (typeof IMAGE_EXPORT_FORMATS)[number];
export interface IImageExportOptions {
fileName: string;
format: ImageExportFormat;
transparent: boolean;
}
/** Turns the requested options into the name the file is saved under, see {@link timestampedImageFileName}. */
export type ImageExportFileNameFormatter = (options: IImageExportOptions) => string;
export interface IImageExportResult {
fileName: string;
format: ImageExportFormat;
blob: Blob;
}
export type ImageExportReadyHandler = (result: IImageExportResult) => void | Promise<void>;
/** For consumers that cannot take a Blob, see {@link toDataUrlImageExport}. */
export interface IImageExportDataUrlResult {
fileName: string;
format: ImageExportFormat;
dataUrl: string;
}
export type ImageExportDataUrlReadyHandler = (result: IImageExportDataUrlResult) => void;
@@ -0,0 +1,16 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2026 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
export function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
}
@@ -1,6 +1,6 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2024 DBeaver Corp and others
* Copyright (C) 2020-2026 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
@@ -0,0 +1,13 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2026 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 { download } from './download.js';
import type { IImageExportResult } from './IImageExportOptions.js';
export function downloadImageExport(result: IImageExportResult): void {
download(result.blob, result.fileName);
}
@@ -0,0 +1,91 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2026 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 { domToBlob, domToForeignObjectSvg, type Options } from 'modern-screenshot';
import { DEFAULT_MAX_IMAGE_SIDE, fitToPixelBudget, type IPixelBudget } from './fitToPixelBudget.js';
import type { IImageExportOptions, ImageExportFileNameFormatter, ImageExportReadyHandler } from './IImageExportOptions.js';
import { isImageBroken } from './isImageBroken.js';
import { timestampedImageFileName } from './timestampedImageFileName.js';
export const IMAGE_EXPORT_BROKEN_ERROR = 'Something went wrong. Please try to select another file format';
const DEFAULT_BACKGROUND_COLOR = '#ffffff';
const DEFAULT_PNG_SCALE = 2;
export interface IDomImageExportConfig extends IPixelBudget {
/** In CSS pixels, defaults to the bounding box of `node`. Required for nodes with no box of their own. */
width?: number;
height?: number;
/** Used when `options.transparent` is false. */
backgroundColor?: string;
/** Applied to the clone before rendering, the live DOM is left alone. */
style?: Partial<CSSStyleDeclaration>;
/** Return false to keep a node (and its children) out of the snapshot. */
filter?: (node: Node) => boolean;
/** Defaults to {@link timestampedImageFileName}. */
formatFileName?: ImageExportFileNameFormatter;
}
/**
* PNG is scaled down to fit {@link fitToPixelBudget}, SVG is always 1:1
*/
export async function exportDomImage(
node: Element,
options: IImageExportOptions,
onReady: ImageExportReadyHandler,
config?: IDomImageExportConfig,
): Promise<void> {
const { width, height } = resolveSize(node, config);
const scale = options.format === 'PNG' ? fitToPixelBudget(width, height, { ...config, scale: config?.scale ?? DEFAULT_PNG_SCALE }) : 1;
const screenshotOptions: Options = {
// a size given here is forced onto the clone, so pass it on only when asked for
...(config?.width === undefined ? {} : { width: config.width }),
...(config?.height === undefined ? {} : { height: config.height }),
scale,
style: config?.style,
filter: config?.filter,
backgroundColor: options.transparent ? 'transparent' : (config?.backgroundColor ?? DEFAULT_BACKGROUND_COLOR),
quality: 1,
maximumCanvasSize: config?.maxSide ?? DEFAULT_MAX_IMAGE_SIDE,
};
const blob = options.format === 'PNG' ? await renderPng(node, screenshotOptions) : await renderSvg(node, screenshotOptions);
const uri = URL.createObjectURL(blob);
const broken = await isImageBroken(uri);
URL.revokeObjectURL(uri);
if (broken) {
throw new Error(IMAGE_EXPORT_BROKEN_ERROR);
}
const formatFileName = config?.formatFileName ?? timestampedImageFileName;
await onReady({ fileName: formatFileName(options), format: options.format, blob });
}
function renderPng(node: Element, screenshotOptions: Options): Promise<Blob> {
return domToBlob(node, { ...screenshotOptions, type: 'image/png' });
}
async function renderSvg(node: Element, screenshotOptions: Options): Promise<Blob> {
const svg = await domToForeignObjectSvg(node, screenshotOptions);
const svgData = new XMLSerializer().serializeToString(svg);
return new Blob(['<?xml version="1.0" standalone="no"?>\r\n', svgData], { type: 'image/svg+xml;charset=utf-8' });
}
function resolveSize(node: Element, config?: IDomImageExportConfig): { width: number; height: number } {
if (config?.width !== undefined && config.height !== undefined) {
return { width: config.width, height: config.height };
}
const rect = node.getBoundingClientRect();
return { width: rect.width, height: rect.height };
}
@@ -0,0 +1,42 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2026 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 { describe, expect, test } from 'vitest';
import { DEFAULT_MAX_IMAGE_PIXELS, DEFAULT_MAX_IMAGE_SIDE, fitToPixelBudget } from './fitToPixelBudget.js';
describe('fitToPixelBudget', () => {
test('should keep the requested scale when the content fits', () => {
expect(fitToPixelBudget(1200, 800, { scale: 2 })).toBe(2);
});
test('should default to scale 1', () => {
expect(fitToPixelBudget(1200, 800)).toBe(1);
});
test('should scale down when the total area exceeds the pixel budget', () => {
const scale = fitToPixelBudget(12000, 9000, { scale: 2 });
expect(scale).toBeLessThan(1);
expect(12000 * scale * 9000 * scale).toBeLessThanOrEqual(DEFAULT_MAX_IMAGE_PIXELS + 1);
});
test('should scale down when a single side exceeds the max side', () => {
const scale = fitToPixelBudget(20000, 300, { scale: 1 });
expect(20000 * scale).toBeLessThanOrEqual(DEFAULT_MAX_IMAGE_SIDE + 1);
});
test('should respect custom budgets', () => {
expect(fitToPixelBudget(2000, 2000, { scale: 1, maxSide: 1000 })).toBe(0.5);
expect(fitToPixelBudget(2000, 2000, { scale: 1, maxPixels: 1_000_000 })).toBe(0.5);
});
test('should not collapse to zero on an enormous content', () => {
expect(fitToPixelBudget(10_000_000, 10_000_000, { scale: 1 })).toBeGreaterThan(0);
});
});
@@ -0,0 +1,34 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2026 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
/** Canvases stop rendering reliably somewhere around this side length. */
export const DEFAULT_MAX_IMAGE_SIDE = 8192;
/** ~16 Mpx, about 64 MB of RGBA. */
export const DEFAULT_MAX_IMAGE_PIXELS = 16_777_216;
const MIN_SCALE = 0.05;
export interface IPixelBudget {
/** Device pixel ratio to aim for. Defaults to 1. */
scale?: number;
maxSide?: number;
maxPixels?: number;
}
export function fitToPixelBudget(width: number, height: number, budget?: IPixelBudget): number {
const { scale = 1, maxSide = DEFAULT_MAX_IMAGE_SIDE, maxPixels = DEFAULT_MAX_IMAGE_PIXELS } = budget ?? {};
if (width <= 0 || height <= 0) {
return scale;
}
const fitted = Math.min(scale, maxSide / width, maxSide / height, Math.sqrt(maxPixels / (width * height)));
return Math.max(fitted, Math.min(scale, MIN_SCALE));
}
@@ -6,8 +6,18 @@
* you may not use this file except in compliance with the License.
*/
export * from './blobToDataUrl.js';
export * from './ColorConvert.js';
export * from './debouncePromise.js';
export * from './download.js';
export * from './downloadImageExport.js';
export * from './exportDomImage.js';
export * from './fitToPixelBudget.js';
export * from './IImageExportOptions.js';
export * from './isImageBroken.js';
export * from './timestampedImageFileName.js';
export * from './toDataUrlImageExport.js';
export * from './withTimestamp.js';
export * from './eventContext.js';
export * from './eventStopPropagationFlag.js';
export * from './isDefined.js';
@@ -1,6 +1,6 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2025 DBeaver Corp and others
* Copyright (C) 2020-2026 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
@@ -0,0 +1,36 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2026 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 { describe, expect, test } from 'vitest';
import { timestampedImageFileName } from './timestampedImageFileName.js';
describe('timestampedImageFileName', () => {
test('should append the timestamp and the extension', () => {
const name = timestampedImageFileName({ fileName: 'Chart', format: 'PNG', transparent: false });
expect(name).toMatch(/^Chart \d{4}-\d{2}-\d{2} \d{2}-\d{2}-\d{2}\.png$/);
});
test('should lowercase the extension per format', () => {
const name = timestampedImageFileName({ fileName: 'Plan', format: 'SVG', transparent: false });
expect(name.endsWith('.svg')).toBe(true);
});
test('should trim the requested name', () => {
const name = timestampedImageFileName({ fileName: ' Chart ', format: 'PNG', transparent: false });
expect(name.startsWith('Chart 20')).toBe(true);
});
test('should fall back when the requested name is blank', () => {
const name = timestampedImageFileName({ fileName: '', format: 'PNG', transparent: false });
expect(name.startsWith('image')).toBe(true);
});
});
@@ -0,0 +1,16 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2026 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 { IImageExportOptions } from './IImageExportOptions.js';
import { withTimestamp } from './withTimestamp.js';
const FALLBACK_FILE_NAME = 'image';
/** `{ fileName: 'Chart', format: 'PNG' }` → `'Chart 2026-07-27 10-30-00.png'` */
export function timestampedImageFileName(options: IImageExportOptions): string {
return `${withTimestamp(options.fileName.trim() || FALLBACK_FILE_NAME)}.${options.format.toLowerCase()}`;
}
@@ -0,0 +1,15 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2026 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 { blobToDataUrl } from './blobToDataUrl.js';
import type { ImageExportDataUrlReadyHandler, ImageExportReadyHandler } from './IImageExportOptions.js';
export function toDataUrlImageExport(handler: ImageExportDataUrlReadyHandler): ImageExportReadyHandler {
return async result => {
handler({ fileName: result.fileName, format: result.format, dataUrl: await blobToDataUrl(result.blob) });
};
}
@@ -1,12 +1,12 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2024 DBeaver Corp and others
* Copyright (C) 2020-2026 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
export function withTimestamp(value: string) {
export function withTimestamp(value: string): string {
const now = new Date();
return `${value} ${now.toISOString().slice(0, 10)} ${('0' + now.getHours()).slice(-2)}-${('0' + now.getMinutes()).slice(-2)}-${(
'0' + now.getSeconds()
@@ -1,6 +1,11 @@
{
"extends": "@dbeaver/tsconfig/tsconfig.json",
"compilerOptions": {
"lib": [
"ESNext",
"DOM",
"DOM.Iterable"
],
"tsBuildInfoFile": "./lib/.tsbuildinfo",
"rootDir": "src",
"rootDirs": [
+6 -4
View File
@@ -10,19 +10,21 @@
"resolutions": {
"lodash": ">4.17.23",
"immutable": ">=5.1.8",
"fast-uri": ">3.1.3",
"fast-uri": ">=4.1.2",
"glob": ">11.1.0",
"inflight": ">1.0.6",
"node-domexception": ">1.0.0",
"shell-quote": ">=1.9.0",
"esbuild": ">=0.28.1",
"form-data": ">=4.0.6",
"undici": ">=6.27.0",
"undici": ">=8.9.0",
"tar": ">=7.5.19",
"js-yaml": ">=5.2.2",
"brace-expansion": ">=5.0.8",
"brace-expansion": ">=5.0.9",
"axios": ">=1.18.0",
"postcss": ">=8.5.18"
"postcss": ">=8.5.18",
"nanoid": ">=3.3.17",
"less": ">=4.7.0"
},
"scripts": {
"test": "dbeaver-test",
@@ -1,6 +1,6 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2025 DBeaver Corp and others
* Copyright (C) 2020-2026 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
@@ -92,7 +92,7 @@ export const ExportImageDialog = observer<DialogComponentProps<ExportImagePayloa
<Button type="button" variant="secondary" onClick={() => props.rejectDialog()}>
{translate('app_shared_inlineEditor_dialog_cancel')}
</Button>
<Button type="button" onClick={onExportHandler}>
<Button type="button" loader onClick={onExportHandler}>
{translate('ui_export')}
</Button>
</CommonDialogFooter>
@@ -45,7 +45,11 @@ export const AutoResizeTextarea: Props = observer(function AutoResizeTextarea({
}, []);
useLayoutEffect(() => {
adjustHeight();
const frame = requestAnimationFrame(() => {
adjustHeight();
});
return () => cancelAnimationFrame(frame);
}, [value, adjustHeight]);
const mergedRef = useMergeRefs(...[innerRef, ref!].filter(Boolean));
@@ -10,22 +10,45 @@ import { observer } from 'mobx-react-lite';
import { Composite, CompositeItem, Popover } from '@dbeaver/ui-kit';
import { type InputAutocompleteProposal } from './useInputAutocomplete.js';
import type { IContextMenuPositionCoords } from '../Menu/useContextMenuPosition.js';
import { useS } from '../useS.js';
import { BaseDropdownStyles, IconOrImage, s, Text } from '../index.js';
import { forwardRef } from 'react';
interface AutocompletionProps {
proposals: InputAutocompleteProposal[];
position: IContextMenuPositionCoords;
className?: string;
onSelect?: (proposal: InputAutocompleteProposal) => void;
}
export const InputAutocompletionMenu = observer(
forwardRef<HTMLDivElement, AutocompletionProps>(function InputAutocompletionMenu({ proposals, onSelect }, ref) {
forwardRef<HTMLDivElement, AutocompletionProps>(function InputAutocompletionMenu({ proposals, position, onSelect }, ref) {
const styles = useS(BaseDropdownStyles);
function getAnchorRect(anchor: HTMLElement | null) {
if (!anchor) {
return null;
}
const rect = anchor.getBoundingClientRect();
return {
x: rect.left + position.x,
y: rect.top + position.y,
};
}
return (
<Popover.PopoverContent ref={ref} className={s(styles, { menu: true })} gutter={4} modal={false} autoFocusOnShow={false} unmountOnHide>
<Popover.PopoverContent
ref={ref}
className={s(styles, { menu: true })}
gutter={4}
modal={false}
autoFocusOnShow={false}
getAnchorRect={getAnchorRect}
unmountOnHide
>
<Composite>
{proposals.map(proposal => (
<CompositeItem key={proposal.title} className={s(styles, { menuItem: true }, 'tw:w-full')} onClick={() => onSelect?.(proposal)}>
@@ -115,6 +115,9 @@ export const useInputAutocomplete = (
this.selectionStart = null;
this.position = { x: 0, y: 0 };
},
setPosition(position: IContextMenuPositionCoords) {
this.position = position;
},
}),
{
proposals: computed,
@@ -123,6 +126,7 @@ export const useInputAutocomplete = (
currentWord: computed,
resetState: action.bound,
replaceCurrentWord: action.bound,
setPosition: action.bound,
position: observable.ref,
inputValue: observable.ref,
},
@@ -182,8 +186,13 @@ export const useInputAutocomplete = (
});
useLayoutEffect(() => {
const inputElement = inputRef.current!;
const caretPosition = inputElement.selectionStart || 0;
const inputElement = inputRef.current;
if (!inputElement || state.selectionStart === null) {
return;
}
const caretPosition = inputElement.selectionStart ?? state.selectionStart;
const inputStyle = window.getComputedStyle(inputElement);
const span = document.createElement('span');
@@ -199,14 +208,14 @@ export const useInputAutocomplete = (
document.body.appendChild(span);
const spanRect = span.getBoundingClientRect();
const letterWidth = spanRect.width / span.textContent.length;
const letterWidth = span.textContent.length > 0 ? spanRect.width / span.textContent.length : 0;
document.body.removeChild(span);
state.position = {
state.setPosition({
x: spanRect.width + letterWidth,
y: spanRect.height + CONTEXT_INPUT_OFFSET_Y,
};
}, [state.inputValue]);
});
}, [state.inputValue, state.selectionStart]);
return state as Readonly<State>;
};
@@ -17,7 +17,8 @@ import {
getObjectPropertyValueType,
type IObjectPropertyInfo,
} from '@cloudbeaver/core-sdk';
import { EMPTY_ARRAY, removeMetadataFromDataURL } from '@cloudbeaver/core-utils';
import { EMPTY_ARRAY, getTextFileReadingProcess, removeMetadataFromDataURL } from '@cloudbeaver/core-utils';
import { clsx } from '@dbeaver/ui-kit';
import { FieldCheckbox } from '../../FormControls/Checkboxes/FieldCheckbox.js';
import { Select } from '../../FormControls/Select.js';
@@ -30,6 +31,8 @@ import { Link } from '../../Link.js';
import { useTranslate } from '../../localization/useTranslate.js';
import { evaluate } from '../evaluate.js';
import { SAVED_VALUE_INDICATOR } from '../../SAVED_VALUE_INDICATOR.js';
import { UploadArea } from '../../UploadArea.js';
import { Button } from '../../Button.js';
interface RenderFieldProps {
property: IObjectPropertyInfo;
@@ -204,6 +207,46 @@ export const RenderField = observer<RenderFieldProps>(function RenderField({
);
}
if (controlType === 'uploadable-textarea' && state && property.id) {
async function handleFileUpload(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) {
throw new Error('File is not found');
}
const process = getTextFileReadingProcess(file);
const result = await process.promise;
if (state && property.id) {
state[property.id] = result;
}
}
return (
<div className="tw:flex tw:flex-col tw:gap-2">
<Textarea
required={required}
title={state[property.id]}
labelTooltip={property.description || property.displayName}
placeholder={placeholder}
name={property.id}
state={state}
disabled={disabled}
readOnly={readonly}
className={clsx('tw:flex-0!', className)}
>
{property.displayName ?? ''}
</Textarea>
<UploadArea disabled={disabled || readonly} reset onChange={handleFileUpload}>
<Button className="tw:w-max" tag="div" disabled={disabled || readonly} variant="secondary">
{translate('ui_file')}
</Button>
</UploadArea>
</div>
);
}
if (controlType === 'file' && state) {
return (
<InputFileTextContent
@@ -1,6 +1,6 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2024 DBeaver Corp and others
* Copyright (C) 2020-2026 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
@@ -20,4 +20,5 @@ export enum EObjectFeature {
'dataSourceConnected' = 'dataSourceConnected',
'entityContainer' = 'entityContainer',
'supportsDataFilter' = 'supportsDataFilter',
'supportsFullDdl' = 'supportsFullDdl', // some connections may not have the full DDL feature
}
@@ -7,10 +7,12 @@
*/
import type { IObjectPropertyInfo } from './IObjectPropertyInfo.js';
export type ObjectPropertyType = 'checkbox' | 'selector' | 'link' | 'textarea' | 'file' | 'input';
export type ObjectPropertyType = 'checkbox' | 'selector' | 'link' | 'textarea' | 'file' | 'input' | 'uploadable-textarea';
export function getObjectPropertyType(property: IObjectPropertyInfo): ObjectPropertyType {
const dataType = property.dataType?.toLowerCase();
const isTextarea = dataType === 'string' && property.length === 'MULTILINE';
const isFile = property.features.includes('file');
if (dataType === 'boolean') {
return 'checkbox';
@@ -18,9 +20,11 @@ export function getObjectPropertyType(property: IObjectPropertyInfo): ObjectProp
return 'selector';
} else if (property.features.includes('href')) {
return 'link';
} else if (dataType === 'string' && property.length === 'MULTILINE') {
} else if (isTextarea && isFile) {
return 'uploadable-textarea';
} else if (isTextarea) {
return 'textarea';
} else if (property.features.includes('file')) {
} else if (isFile) {
return 'file';
}
@@ -0,0 +1,3 @@
mutation cancelConversation($conversationId: ID!) {
result: aiCancelChatMessage(conversationId: $conversationId)
}
@@ -2,5 +2,6 @@ query getEngines {
engines: aiListEngines {
id
name
icon
}
}
@@ -0,0 +1,5 @@
mutation asyncSqlGenerateEntityQuery($generatorId: String!, $nodePathList: [String!]!, $generatorOptions: SQLQueryGeneratorOptions) {
taskInfo: asyncSqlGenerateEntityQuery(generatorId: $generatorId, nodePathList: $nodePathList, generatorOptions: $generatorOptions) {
...AsyncTaskInfo
}
}
@@ -1,5 +1,8 @@
query authLogout($provider: ID, $configuration: ID) {
result: authLogoutExtended(provider: $provider, configuration: $configuration) {
redirectLinks
redirectLinks {
url
sameTabRedirect
}
}
}
@@ -0,0 +1,9 @@
query getConnectionImportDriverConfiguration($projectId: ID, $connectionId: ID!) {
connections: userConnections(projectId: $projectId, id: $connectionId) {
id
projectId
driverConfiguration {
...DriverConfiguration
}
}
}
@@ -0,0 +1,9 @@
fragment DriverConfiguration on DriverConfiguration {
supportedInsertReplaceMethods {
id
name
description
}
supportsBulkLoad
supportsTransactions
}
@@ -11,4 +11,7 @@ fragment UserConnectionNetworkHandlerProperties on ObjectPropertyInfo {
length
features
required
}
conditions {
...Condition
}
}
@@ -1,3 +0,0 @@
query metadataGetNodeDDL( $nodeId: ID! ) {
metadataGetNodeDDL( nodeId: $nodeId )
}
@@ -1,3 +0,0 @@
query sqlGenerateEntityQuery($generatorId: String!, $nodePathList: [String!]!, $generatorOptions: SQLQueryGeneratorOptions) {
sqlGenerateEntityQuery(generatorId: $generatorId, nodePathList: $nodePathList, generatorOptions: $generatorOptions)
}
@@ -1,52 +0,0 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2025 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 { beforeEach, describe, expect, it, vitest } from 'vitest';
import { download } from './download.js';
describe('download', () => {
beforeEach(() => {
// Clean up any existing links from previous tests
document.body.innerHTML = '';
});
it('should not create links after download', () => {
const blob = new Blob(['test'], { type: 'text/plain' });
const linksBefore = document.querySelectorAll('a');
expect(linksBefore.length).toBe(0);
download(blob, 'test.txt');
const linksAfter = document.querySelectorAll('a');
expect(linksAfter.length).toBe(0);
});
it('should create a link with url and click it to download file', () => {
const element = document.createElement('a');
const createElementSpy = vitest.spyOn(document, 'createElement').mockImplementation(() => element);
const clickSpy = vitest.spyOn(element, 'click');
const createObjectURLSpy = vitest.spyOn(URL, 'createObjectURL');
const blob = new Blob(['test'], { type: 'text/plain' });
download(blob, 'test.txt');
expect(createElementSpy).toHaveBeenCalledWith('a');
expect(createElementSpy).toHaveBeenCalledTimes(1);
expect(clickSpy).toHaveBeenCalledWith();
expect(clickSpy).toHaveBeenCalledTimes(1);
expect(createObjectURLSpy).toHaveBeenCalledWith(blob);
expect(createObjectURLSpy).toHaveBeenCalledTimes(1);
createElementSpy.mockRestore();
clickSpy.mockRestore();
createObjectURLSpy.mockRestore();
});
});
@@ -1,6 +1,6 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2025 DBeaver Corp and others
* Copyright (C) 2020-2026 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
@@ -8,8 +8,7 @@
import * as modernScreenshot from 'modern-screenshot';
import type { Options } from 'modern-screenshot';
import { download } from './download.js';
import { isImageBroken } from './isBrokenImage.js';
import { download, isImageBroken } from '@dbeaver/js-helpers';
export type { Options as IScreenshotOptions };
-2
View File
@@ -62,7 +62,6 @@ export * from './getUniqueName.js';
export * from './isMapsEqual.js';
export * from './isObjectsEqual.js';
export * from './openCenteredPopup.js';
export * from './download.js';
export * from './downloadFromURL.js';
export * from './getTextFileReadingProcess.js';
export * from './getTextBetween.js';
@@ -81,7 +80,6 @@ export * from './removeMetadataFromDataURL.js';
export * from './removeLineBreak.js';
export * from './replaceSubstring.js';
export * from './formatNumber.js';
export * from './withTimestamp.js';
export * from './toSafeHtmlString.js';
export * from './getProgressPercent.js';
export * from './types/UndefinedToNull.js';
@@ -1,77 +0,0 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2025 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { isImageBroken } from './isBrokenImage.js';
describe('isImageBroken', () => {
const OriginalImage = globalThis.Image;
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
if (OriginalImage) {
globalThis.Image = OriginalImage;
} else {
delete (globalThis as Record<string, unknown>)['Image'];
}
vi.restoreAllMocks();
vi.useRealTimers();
});
it('should return false if image loads successfully', async () => {
const srcSpy = vi.fn();
class MockImage {
public onload: (() => void) | null = null;
public onerror: (() => void) | null = null;
set src(value: string) {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const thisRef = this;
srcSpy(value);
setTimeout(() => {
thisRef.onload?.();
}, 0);
vi.runAllTimers();
}
}
vi.stubGlobal('Image', MockImage);
await expect(isImageBroken('https://example.com/ok.png')).resolves.toBe(false);
expect(srcSpy).toHaveBeenCalledWith('https://example.com/ok.png');
});
it('should return true if image loading fails', async () => {
const srcSpy = vi.fn();
class MockImage {
public onload: (() => void) | null = null;
public onerror: (() => void) | null = null;
set src(value: string) {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const thisRef = this;
srcSpy(value);
setTimeout(() => {
thisRef.onerror?.();
}, 0);
vi.runAllTimers();
}
}
vi.stubGlobal('Image', MockImage);
await expect(isImageBroken('https://example.com/broken.png')).resolves.toBe(true);
expect(srcSpy).toHaveBeenCalledWith('https://example.com/broken.png');
});
});
@@ -1,30 +0,0 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2025 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { withTimestamp } from './withTimestamp.js';
describe('withTimestamp', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should generate a value with timestamp at the end', () => {
const mockDate = new Date('2020-09-09T14:13:20');
vi.setSystemTime(mockDate);
const value = 'value';
const expectedValue = `${value} 2020-09-09 14-13-20`;
expect(withTimestamp(value)).toEqual(expectedValue);
});
});
@@ -0,0 +1,13 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_11514_20)">
<path d="M19 13.42H21V4.41998H19V13.42Z" fill="#3387C5"/>
<path d="M23.54 8.36L20 4.83L16.46 8.36L15.05 6.95L20 2L24.95 6.95L23.54 8.36Z" fill="#3387C5"/>
<path d="M21 23.42L12 14.42L3.71004 22.71L2.29004 21.3L12 11.59L21 20.59L25 16.59L29.71 21.3L28.29 22.71L25 19.42L21 23.42Z" fill="#3387C5"/>
<path d="M25 4L27 6H28V26H4V6H13L15 4H2V28H30V4H25Z" fill="#3387C5"/>
</g>
<defs>
<clipPath id="clip0_11514_20">
<rect width="32" height="32" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 614 B

@@ -0,0 +1,13 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_11514_2)">
<path d="M14.3572 10.166H15.9286V2.94231H14.3572V10.166Z" fill="#3387C5"/>
<path d="M17.9244 6.10471L15.1429 3.27143L12.3615 6.10471L11.2537 4.973L15.1429 1L19.0322 4.973L17.9244 6.10471Z" fill="#3387C5"/>
<path d="M15.9286 18.5214L8.85719 11.2977L2.34362 17.9515L1.22791 16.8198L8.85719 9.02631L15.9286 16.2499L19.0715 13.0394L22.7722 16.8198L21.6565 17.9515L19.0715 15.3109L15.9286 18.5214Z" fill="#3387C5"/>
<path d="M19.0714 2.93439L20.6429 4.53964H21.4286V21.3948H2.57143V4.53964H9.64286L11.2143 2.93439H1V23.0001H23V2.93439H19.0714Z" fill="#3387C5"/>
</g>
<defs>
<clipPath id="clip0_11514_2">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 802 B

@@ -0,0 +1,13 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_11514_11)">
<path d="M9.5 6.83289H10.5V2.23602H9.5V6.83289Z" fill="#3387C5"/>
<path d="M11.77 4.24845L10 2.44546L8.23002 4.24845L7.52502 3.52827L10 1L12.475 3.52827L11.77 4.24845Z" fill="#3387C5"/>
<path d="M10.5 12.15L6.00002 7.55312L1.85502 11.7873L1.14502 11.0672L6.00002 6.10767L10.5 10.7045L12.5 8.66148L14.855 11.0672L14.145 11.7873L12.5 10.1069L10.5 12.15Z" fill="#3387C5"/>
<path d="M12.5 2.23096L13.5 3.25248H14V13.9785H2V3.25248H6.5L7.5 2.23096H1V15H15V2.23096H12.5Z" fill="#3387C5"/>
</g>
<defs>
<clipPath id="clip0_11514_11">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 730 B

@@ -34,6 +34,7 @@
"@cloudbeaver/core-sdk": "workspace:*",
"@cloudbeaver/core-ui": "workspace:*",
"@cloudbeaver/core-utils": "workspace:*",
"@cloudbeaver/plugin-ai": "workspace:*",
"@cloudbeaver/plugin-data-grid": "workspace:*",
"@dbeaver/js-helpers": "workspace:*",
"@dbeaver/ui-kit": "workspace:*",
@@ -32,6 +32,7 @@ import { LANGUAGE_OPTIONS } from './AISettingsForm/getLanguageOptions.js';
import type { AdministrationAISettingsFormState } from './AISettingsForm/AdministrationAISettingsFormState.js';
import { getFirstException } from '@cloudbeaver/core-utils';
import { isDefined } from '@dbeaver/js-helpers';
import { AiEnginesResource } from '@cloudbeaver/plugin-ai';
export const AIAdministrationPage = observer<{
formState: AdministrationAISettingsFormState;
@@ -39,6 +40,7 @@ export const AIAdministrationPage = observer<{
const translate = useTranslate();
const notificationService = useService(NotificationService);
const profilesLoader = useResource(AIAdministrationPage, AIProfilesResource, CachedMapAllKey);
const aiEnginesResource = useResource(AIAdministrationPage, AiEnginesResource, undefined);
const profiles = profilesLoader.data.filter(isDefined);
const settingsInfoPart = getAdministrationAISettingsFormInfoPart(formState);
@@ -102,6 +104,7 @@ export const AIAdministrationPage = observer<{
value={settingsInfoPart.state.defaultConfiguration}
description={translate('plugin_ai_administration_default_profile_description')}
disabled={!profiles.length}
iconSelector={value => aiEnginesResource.data.find(e => e.id === value.engineId)?.icon}
small
onSelect={selectDefaultProfile}
>
@@ -42,15 +42,22 @@ export const AIProfileFormPanel = observer<IAIProfileFormProps>(function AIProfi
},
});
const label = translate(creating ? 'plugin_ai_administration_profile_create' : 'plugin_ai_administration_profile_edit');
return (
<Form context={form} contents>
<TabsState container={aiProfileFormService.parts} localState={formState.parts} formState={formState}>
<div className="tw:flex tw:flex-col tw:flex-1 tw:h-full tw:overflow-auto theme-background-secondary theme-text-on-secondary">
<div
aria-label={label}
className="tw:flex tw:flex-col tw:flex-1 tw:h-full tw:overflow-auto theme-background-secondary theme-text-on-secondary"
>
<div className="tw:relative tw:flex tw:items-end tw:border-b-2 theme-border-color-background theme-background-secondary theme-text-on-secondary">
<div className="tw:flex-1 tw:overflow-hidden tw:pt-2 tw:pl-2">
<GroupTitle header>
<GroupBack onClick={() => aiProfileFormService.close()}>
<Text truncate>{formState.state.name || translate('ui_stepper_back')}</Text>
<Text truncate>
{creating ? translate('plugin_ai_administration_profile_create') : `${translate('ui_edit')} "${formState.state.name}"`}
</Text>
</GroupBack>
</GroupTitle>
<div className="tw:p-2">
@@ -27,8 +27,8 @@ import { useService } from '@cloudbeaver/core-di';
import { NotificationService } from '@cloudbeaver/core-events';
import { FormMode, type TabContainerPanelComponent } from '@cloudbeaver/core-ui';
import { getObjectPropertyOptionName, getObjectPropertyOptionValue } from '@cloudbeaver/core-sdk';
import { AiEnginesResource } from '@cloudbeaver/plugin-ai';
import { EnginesResource } from '../../../Engines/EnginesResource.js';
import { AIEnginePropertiesResource, MODEL_PROPERTY_ID } from '../../AIEnginePropertiesResource.js';
import type { IAIProfileFormProps } from '../IAIProfileFormProps.js';
import { AI_PROFILE_NAME_MAX_LENGTH, AI_PROFILE_NAME_MIN_LENGTH } from './AIProfileSchema.js';
@@ -37,7 +37,7 @@ import { getAIProfileFormPart } from './getAIProfileFormPart.js';
export const AIProfileOptions: TabContainerPanelComponent<IAIProfileFormProps> = observer(function AIProfileOptions({ formState }) {
const translate = useTranslate();
const notificationService = useService(NotificationService);
const enginesLoader = useResource(AIProfileOptions, EnginesResource, undefined);
const enginesLoader = useResource(AIProfileOptions, AiEnginesResource, undefined);
const part = getAIProfileFormPart(formState);
const propertiesLoader = useResource(AIProfileOptions, AIEnginePropertiesResource, part.state.engineId || null);
const propertiesInfo = propertiesLoader.data ?? [];
@@ -96,6 +96,7 @@ export const AIProfileOptions: TabContainerPanelComponent<IAIProfileFormProps> =
keySelector={value => value.id}
value={part.state.engineId}
disabled={formState.isDisabled || isEditMode}
iconSelector={value => value.icon}
small
required
onSelect={value => value && part.changeEngine(value)}
@@ -55,7 +55,7 @@ export const AIProfilesPanel = observer<Props>(function AIProfilesPanel({ formSt
const profiles = profilesLoader.data.filter(isDefined);
const defaultProfileId = settingsInfoPart.initialState.defaultConfiguration;
const selection = useTableSelection(profiles.map(p => p.id));
const selection = useTableSelection(profiles.filter(p => p.id !== defaultProfileId).map(p => p.id));
const table = useAIProfilesTable(selection);
return (
@@ -9,13 +9,13 @@
import { reaction } from 'mobx';
import { observer } from 'mobx-react-lite';
import { Link, s, TextPlaceholder, useResource, useS, useTranslate } from '@cloudbeaver/core-blocks';
import { IconOrImage, Link, s, TextPlaceholder, useResource, useS, useTranslate } from '@cloudbeaver/core-blocks';
import { useService } from '@cloudbeaver/core-di';
import { ADMINISTRATION_TABLE_DEFAULT_ROW_HEIGHT, AdministrationTableStyles } from '@cloudbeaver/core-administration';
import { DataGrid, TableRowSelect, useCreateGridReactiveValue } from '@cloudbeaver/plugin-data-grid';
import { AiEnginesResource } from '@cloudbeaver/plugin-ai';
import { Command } from '@dbeaver/ui-kit';
import { EnginesResource } from '../Engines/EnginesResource.js';
import { AIProfileFormService } from './AIProfileForm/AIProfileFormService.js';
import type { AIProfile } from './AIProfilesResource.js';
@@ -34,7 +34,7 @@ export const AIProfilesTable = observer<Props>(function AIProfilesTable({ profil
const translate = useTranslate();
const styles = useS(AdministrationTableStyles);
const aiProfileFormService = useService(AIProfileFormService);
const enginesLoader = useResource(AIProfilesTable, EnginesResource, undefined);
const enginesLoader = useResource(AIProfilesTable, AiEnginesResource, undefined);
const columnsCount = useCreateGridReactiveValue(() => COLUMNS.length, null, [COLUMNS]);
const rowsCount = useCreateGridReactiveValue(
@@ -51,8 +51,16 @@ export const AIProfilesTable = observer<Props>(function AIProfilesTable({ profil
return null;
}
const isDefault = profile.id === defaultProfileId;
if (column.key === SELECT_COLUMN.key) {
return <TableRowSelect id={profile.id} />;
return (
<TableRowSelect
id={profile.id}
disabled={isDefault}
title={isDefault ? translate('plugin_ai_administration_profile_default_delete_info') : undefined}
/>
);
}
if (column.key === NAME_COLUMN.key) {
@@ -65,7 +73,7 @@ export const AIProfilesTable = observer<Props>(function AIProfilesTable({ profil
onClick={() => aiProfileFormService.open(profile.id, profile.name)}
>
<Link truncate>{profile.name}</Link>
{profile.id === defaultProfileId && (
{isDefault && (
<span className="tw:text-xs tw:opacity-60 tw:whitespace-nowrap">{translate('plugin_ai_administration_profile_default_badge')}</span>
)}
</Command>
@@ -74,7 +82,18 @@ export const AIProfilesTable = observer<Props>(function AIProfilesTable({ profil
if (column.key === ENGINE_COLUMN.key) {
const engine = enginesLoader.data.find(engine => engine.id === profile.engineId);
return <span>{engine?.name ?? profile.engineId}</span>;
const title = engine?.name ?? profile.engineId;
if (engine?.icon) {
return (
<div title={title} className="tw:flex tw:gap-2">
<IconOrImage icon={engine.icon} />
<span className="tw:truncate">{title}</span>
</div>
);
}
return <span title={title}>{title}</span>;
}
return null;
@@ -43,4 +43,10 @@ export default [
['plugin_ai_administration_profile_form_tab_options', 'Profil'],
['plugin_ai_administration_profile_name_max_length', 'Der Profilname darf {arg:length} Zeichen nicht überschreiten'],
['plugin_ai_administration_profile_name_min_length', 'Der Profilname muss mindestens {arg:length} Zeichen lang sein'],
[
'plugin_ai_administration_profile_default_delete_info',
'Das Standardprofil kann nicht gelöscht werden. Bitte wählen Sie ein anderes Profil als Standard aus, bevor Sie dieses Profil löschen.',
],
['plugin_ai_administration_profile_create', 'Neues Profil'],
['plugin_ai_administration_profile_edit', 'Profil bearbeiten'],
];
@@ -43,4 +43,10 @@ export default [
['plugin_ai_administration_profile_form_tab_options', 'Profile'],
['plugin_ai_administration_profile_name_max_length', 'Profile name must not exceed {arg:length} characters'],
['plugin_ai_administration_profile_name_min_length', 'Profile name must be at least {arg:length} characters'],
[
'plugin_ai_administration_profile_default_delete_info',
'The default profile cannot be deleted. Please select another profile as the default before deleting this profile.',
],
['plugin_ai_administration_profile_create', 'New Profile'],
['plugin_ai_administration_profile_edit', 'Edit Profile'],
];
@@ -43,4 +43,10 @@ export default [
['plugin_ai_administration_profile_form_tab_options', 'Profil'],
['plugin_ai_administration_profile_name_max_length', 'Le nom du profil ne doit pas dépasser {arg:length} caractères'],
['plugin_ai_administration_profile_name_min_length', 'Le nom du profil doit contenir au moins {arg:length} caractères'],
[
'plugin_ai_administration_profile_default_delete_info',
'Le profil par défaut ne peut pas être supprimé. Veuillez sélectionner un autre profil comme profil par défaut avant de supprimer ce profil.',
],
['plugin_ai_administration_profile_create', 'Nouveau profil'],
['plugin_ai_administration_profile_edit', 'Modifier le profil'],
];
@@ -43,4 +43,10 @@ export default [
['plugin_ai_administration_profile_form_tab_options', 'Профиль'],
['plugin_ai_administration_profile_name_max_length', 'Название профиля не должно превышать {arg:length} символов'],
['plugin_ai_administration_profile_name_min_length', 'Название профиля должно содержать не менее {arg:length} символов'],
[
'plugin_ai_administration_profile_default_delete_info',
'Профиль по умолчанию нельзя удалить. Перед удалением этого профиля выберите другой профиль по умолчанию.',
],
['plugin_ai_administration_profile_create', 'Новый профиль'],
['plugin_ai_administration_profile_edit', 'Редактировать профиль'],
];
@@ -8,7 +8,6 @@
import { Bootstrap, Dependency, ModuleRegistry, proxy } from '@cloudbeaver/core-di';
import { LocaleService } from './LocaleService.js';
import { EnginesResource } from './Engines/EnginesResource.js';
import { AISettingsResource } from './AISettingsResource.js';
import { AISettingsService } from './AISettingsService.js';
import { AIEnginePropertiesResource } from './AIProfiles/AIEnginePropertiesResource.js';
@@ -29,11 +28,9 @@ export default ModuleRegistry.add({
.addSingleton(AIAdministrationBootstrap)
.addSingleton(Bootstrap, LocaleService)
.addSingleton(Bootstrap, proxy(AIAdministrationTabsService))
.addSingleton(Dependency, proxy(EnginesResource))
.addSingleton(Dependency, proxy(AISettingsResource))
.addSingleton(Dependency, proxy(AIProfilesResource))
.addSingleton(Dependency, proxy(AIEnginePropertiesResource))
.addSingleton(EnginesResource)
.addSingleton(AISettingsResource)
.addSingleton(AISettingsService)
.addSingleton(AIProfilesResource)
@@ -59,6 +59,9 @@
{
"path": "../core-utils"
},
{
"path": "../plugin-ai"
},
{
"path": "../plugin-data-grid"
}
@@ -39,6 +39,7 @@
"@cloudbeaver/core-ui": "workspace:*",
"@cloudbeaver/core-utils": "workspace:*",
"@cloudbeaver/core-view": "workspace:*",
"@cloudbeaver/plugin-ai": "workspace:*",
"@cloudbeaver/plugin-codemirror6": "workspace:*",
"@cloudbeaver/plugin-datasource-context-switch": "workspace:*",
"@cloudbeaver/plugin-navigation-tabs": "workspace:*",
@@ -9,9 +9,10 @@
import { observer } from 'mobx-react-lite';
import { MenuGroup, MenuGroupLabel, MenuItemRadio } from '@dbeaver/ui-kit';
import { RadioIndicator, useTranslate } from '@cloudbeaver/core-blocks';
import { IconOrImage, RadioIndicator, useResource, useTranslate } from '@cloudbeaver/core-blocks';
import { useService } from '@cloudbeaver/core-di';
import { NotificationService } from '@cloudbeaver/core-events';
import { AiEnginesResource } from '@cloudbeaver/plugin-ai';
import type { AIChatProfile } from '../../../AIChatProfilesResource.js';
import type { AIChatConversationInfo } from '../AIChatConversationsResource.js';
@@ -28,6 +29,8 @@ export const AIChatConversationProfile = observer<Props>(function AIChatConversa
const notificationService = useService(NotificationService);
const aiChatConversationsService = useService(AIChatConversationsService);
const aiEnginesResource = useResource(AIChatConversationProfile, AiEnginesResource, undefined);
async function selectProfile(profileId: string) {
try {
await aiChatConversationsService.updateConversationProfile(conversation.id, profileId);
@@ -46,6 +49,7 @@ export const AIChatConversationProfile = observer<Props>(function AIChatConversa
{profiles.map(profile => {
const isCurrent = conversation.profile === profile.id;
const engine = aiEnginesResource.data.find(e => e.id === profile.engineId);
return (
<MenuItemRadio
@@ -62,6 +66,7 @@ export const AIChatConversationProfile = observer<Props>(function AIChatConversa
>
<div className="tw:flex tw:items-center tw:gap-2 tw:overflow-hidden">
<RadioIndicator size="small" checked={isCurrent} />
{engine?.icon && <IconOrImage className="tw:w-[14px]" icon={engine.icon} />}
<span className="tw:truncate">{profile.name}</span>
</div>
</MenuItemRadio>
@@ -92,6 +92,11 @@ export class AIChatConversationsResource extends CachedMapResource<string, AICha
return this.get(conversation.id)!;
}
async cancelConversation(conversationId: string): Promise<boolean> {
const { result } = await this.graphQLService.sdk.cancelConversation({ conversationId });
return result;
}
protected async loader(originalKey: ResourceKey<string>): Promise<Map<string, AIChatConversationInfo>> {
const conversationList: AIChatConversationInfo[] = [];
@@ -13,10 +13,12 @@ import { ActionIconButton, AutoResizeTextarea, Form, s, useS, useTranslate } fro
import { useService } from '@cloudbeaver/core-di';
import { getOS, OperatingSystem } from '@cloudbeaver/core-utils';
import { NotificationService } from '@cloudbeaver/core-events';
import { Command } from '@dbeaver/ui-kit';
import { AIChatMessageService } from './AIChatMessageService.js';
import { AIChatConversationsService } from '../AIChatConversation/AIChatConversationsService.js';
import { AIChatContext } from '../AIChatContext.js';
import { AIChatConversationsResource } from '../AIChatConversation/AIChatConversationsResource.js';
import classes from './AIChatMessageForm.module.css';
interface Props {
@@ -30,6 +32,7 @@ export const AIChatMessageForm = observer<PropsWithChildren<Props>>(function AIC
const notificationService = useService(NotificationService);
const aiChatMessageService = useService(AIChatMessageService);
const aiChatConversationsService = useService(AIChatConversationsService);
const aiChatConversationsResource = useService(AIChatConversationsResource);
const [value, setValue] = useState('');
@@ -54,6 +57,16 @@ export const AIChatMessageForm = observer<PropsWithChildren<Props>>(function AIC
}
}
async function cancel() {
if (currentConversationId) {
try {
await aiChatConversationsResource.cancelConversation(currentConversationId);
} catch (exception: any) {
notificationService.logException(exception, 'plugin_ai_chat_conversation_cancel_failed');
}
}
}
function getPlaceholder() {
const OS = getOS();
const symbol = OS === OperatingSystem.macOS ? '⌘' : 'Ctrl';
@@ -65,7 +78,12 @@ export const AIChatMessageForm = observer<PropsWithChildren<Props>>(function AIC
return (
<div className={s(styles, { container: true })}>
<Form className="tw:flex tw:items-end tw:gap-2" disableEnterSubmit={disabled} contents onSubmit={sendMessage}>
<Form
className="tw:flex tw:items-end tw:gap-2"
disableEnterSubmit={disabled}
contents
onSubmit={aiChatConversationsService.processing ? cancel : sendMessage}
>
<div className={s(styles, { textareaContainer: true })}>
<AutoResizeTextarea
className={s(styles, { textarea: true })}
@@ -74,7 +92,16 @@ export const AIChatMessageForm = observer<PropsWithChildren<Props>>(function AIC
autoFocus
onChange={v => setValue(v)}
/>
<ActionIconButton name="/icons/send.svg" disabled={disabled} img onClick={sendMessage} />
{!aiChatConversationsService.processing ? (
<ActionIconButton name="/icons/send.svg" disabled={disabled} img onClick={sendMessage} />
) : (
<Command
className="tw:cursor-pointer tw:w-10 tw:h-10 tw:bg-[var(--theme-primary)] tw:rounded-md tw:flex tw:items-center tw:justify-center tw:focus:opacity-80 tw:hover:opacity-80 tw:transition-opacity"
onClick={cancel}
>
<div className="tw:w-3 tw:h-3 tw:bg-[var(--theme-surface)] tw:rounded-xs" />
</Command>
)}
</div>
</Form>
{children}
@@ -44,6 +44,8 @@ export default [
['plugin_ai_chat_conversation_history_date_group_last_7_days', 'Last 7 days'],
['plugin_ai_chat_conversation_history_date_group_over_week_ago', 'Over a week ago'],
['plugin_ai_chat_conversation_cancel_failed', 'Failed to cancel the conversation'],
['plugin_ai_chat_scope_change', 'Configure AI context'],
['plugin_ai_chat_scope_change_fail', 'Failed to change context'],
['plugin_ai_chat_profile_group', 'Active configuration'],
@@ -44,6 +44,8 @@ export default [
['plugin_ai_chat_conversation_history_date_group_last_7_days', 'Last 7 days'],
['plugin_ai_chat_conversation_history_date_group_over_week_ago', 'Over a week ago'],
['plugin_ai_chat_conversation_cancel_failed', 'Impossibile annullare la conversazione'],
['plugin_ai_chat_scope_change', 'Configure AI context'],
['plugin_ai_chat_scope_change_fail', 'Failed to change context'],
['plugin_ai_chat_profile_group', 'Active configuration'],
@@ -44,6 +44,8 @@ export default [
['plugin_ai_chat_conversation_history_date_group_last_7_days', 'Последние 7 дней'],
['plugin_ai_chat_conversation_history_date_group_over_week_ago', 'Более недели назад'],
['plugin_ai_chat_conversation_cancel_failed', 'Не удалось отменить разговор'],
['plugin_ai_chat_scope_change', 'Настроить AI контекст'],
['plugin_ai_chat_scope_change_fail', 'Не удалось изменить контекст'],
['plugin_ai_chat_profile_group', 'Активная конфигурация'],

Some files were not shown because too many files have changed in this diff Show More