Merge branch 'master' into feature/firebird

This commit is contained in:
Pavel
2025-06-10 14:57:26 +02:00
145 changed files with 5117 additions and 1626 deletions

116
.github/workflows/build-app-check.yaml vendored Normal file
View File

@@ -0,0 +1,116 @@
# --------------------------------------------------------------------------------------------
# This file is generated. Do not edit manually
# --------------------------------------------------------------------------------------------
name: Electron app check build
'on':
push:
tags:
- check-[0-9]+-[0-9]+-[0-9]+.[0-9]+
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os:
- macos-14
- windows-2022
- ubuntu-22.04
steps:
- name: Install python 3.11 (MacOS)
if: matrix.os == 'macos-14'
run: |
brew install python@3.11
echo "PYTHON=/opt/homebrew/bin/python3.11" >> $GITHUB_ENV
- name: Context
env:
GITHUB_CONTEXT: ${{ toJson(github) }}
run: echo "$GITHUB_CONTEXT"
- uses: actions/checkout@v2
with:
fetch-depth: 1
- name: Use Node.js 22.x
uses: actions/setup-node@v1
with:
node-version: 22.x
- name: adjustPackageJson
run: |
node adjustPackageJson --community
- name: yarn set timeout
run: |
yarn config set network-timeout 100000
- name: yarn install
run: |
yarn install
- name: setCurrentVersion
run: |
yarn setCurrentVersion
- name: printSecrets
run: |
yarn printSecrets
env:
GIST_UPLOAD_SECRET: ${{secrets.GIST_UPLOAD_SECRET}}
- name: fillPackagedPlugins
run: |
yarn fillPackagedPlugins
- name: Install Snapcraft
if: matrix.os == 'ubuntu-22.04'
uses: samuelmeuli/action-snapcraft@v1
- name: Publish
run: |
yarn run build:app
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
WIN_CSC_LINK: ${{ secrets.WINCERT_2025 }}
WIN_CSC_KEY_PASSWORD: ${{ secrets.WINCERT_2025_PASSWORD }}
CSC_LINK: ${{ secrets.APPLECERT_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.APPLECERT_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
SNAPCRAFT_STORE_CREDENTIALS: ${{secrets.SNAPCRAFT_LOGIN}}
APPLE_APP_SPECIFIC_PASSWORD: ${{secrets.APPLE_APP_SPECIFIC_PASSWORD}}
- name: Copy artifacts
run: |
mkdir artifacts
cp app/dist/*.deb artifacts/dbgate-check.deb || true
cp app/dist/*x86*.AppImage artifacts/dbgate-check.AppImage || true
cp app/dist/*arm64*.AppImage artifacts/dbgate-check-arm64.AppImage || true
cp app/dist/*armv7l*.AppImage artifacts/dbgate-check-armv7l.AppImage || true
cp app/dist/*win*.exe artifacts/dbgate-check.exe || true
cp app/dist/*win_x64.zip artifacts/dbgate-windows-check.zip || true
cp app/dist/*win_arm64.zip artifacts/dbgate-windows-check-arm64.zip || true
cp app/dist/*-mac_universal.dmg artifacts/dbgate-check.dmg || true
cp app/dist/*-mac_x64.dmg artifacts/dbgate-check-x64.dmg || true
cp app/dist/*-mac_arm64.dmg artifacts/dbgate-check-arm64.dmg || true
mv app/dist/*.snap artifacts/dbgate-check.snap || true
mv app/dist/*.exe artifacts/ || true
mv app/dist/*.zip artifacts/ || true
mv app/dist/*.tar.gz artifacts/ || true
mv app/dist/*.AppImage artifacts/ || true
mv app/dist/*.deb artifacts/ || true
mv app/dist/*.snap artifacts/ || true
mv app/dist/*.dmg artifacts/ || true
mv app/dist/*.blockmap artifacts/ || true
mv app/dist/*.yml artifacts/ || true
rm artifacts/builder-debug.yml
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.os }}
path: artifacts
- name: Print content of notarization-error.log
if: failure() && matrix.os == 'macos-14'
run: |
find . -type f -name "notarization-error.log" -exec echo "=== Start of {} ===" \; -exec cat {} \; -exec echo "=== End of {} ===" \;

View File

@@ -39,7 +39,7 @@ jobs:
repository: dbgate/dbgate-pro repository: dbgate/dbgate-pro
token: '${{ secrets.GH_TOKEN }}' token: '${{ secrets.GH_TOKEN }}'
path: dbgate-pro path: dbgate-pro
ref: 55cf42d58b843c4f1ffd6ab9b808f5f971bc3c8b ref: ecea1eef17c69c56b0633317e24a68c5220a4810
- name: Merge dbgate/dbgate-pro - name: Merge dbgate/dbgate-pro
run: | run: |
mkdir ../dbgate-pro mkdir ../dbgate-pro

View File

@@ -39,7 +39,7 @@ jobs:
repository: dbgate/dbgate-pro repository: dbgate/dbgate-pro
token: '${{ secrets.GH_TOKEN }}' token: '${{ secrets.GH_TOKEN }}'
path: dbgate-pro path: dbgate-pro
ref: 55cf42d58b843c4f1ffd6ab9b808f5f971bc3c8b ref: ecea1eef17c69c56b0633317e24a68c5220a4810
- name: Merge dbgate/dbgate-pro - name: Merge dbgate/dbgate-pro
run: | run: |
mkdir ../dbgate-pro mkdir ../dbgate-pro

View File

@@ -39,7 +39,7 @@ jobs:
repository: dbgate/dbgate-pro repository: dbgate/dbgate-pro
token: '${{ secrets.GH_TOKEN }}' token: '${{ secrets.GH_TOKEN }}'
path: dbgate-pro path: dbgate-pro
ref: 55cf42d58b843c4f1ffd6ab9b808f5f971bc3c8b ref: ecea1eef17c69c56b0633317e24a68c5220a4810
- name: Merge dbgate/dbgate-pro - name: Merge dbgate/dbgate-pro
run: | run: |
mkdir ../dbgate-pro mkdir ../dbgate-pro

View File

@@ -44,7 +44,7 @@ jobs:
repository: dbgate/dbgate-pro repository: dbgate/dbgate-pro
token: '${{ secrets.GH_TOKEN }}' token: '${{ secrets.GH_TOKEN }}'
path: dbgate-pro path: dbgate-pro
ref: 55cf42d58b843c4f1ffd6ab9b808f5f971bc3c8b ref: ecea1eef17c69c56b0633317e24a68c5220a4810
- name: Merge dbgate/dbgate-pro - name: Merge dbgate/dbgate-pro
run: | run: |
mkdir ../dbgate-pro mkdir ../dbgate-pro

View File

@@ -32,7 +32,7 @@ jobs:
repository: dbgate/dbgate-pro repository: dbgate/dbgate-pro
token: '${{ secrets.GH_TOKEN }}' token: '${{ secrets.GH_TOKEN }}'
path: dbgate-pro path: dbgate-pro
ref: 55cf42d58b843c4f1ffd6ab9b808f5f971bc3c8b ref: ecea1eef17c69c56b0633317e24a68c5220a4810
- name: Merge dbgate/dbgate-pro - name: Merge dbgate/dbgate-pro
run: | run: |
mkdir ../dbgate-pro mkdir ../dbgate-pro

View File

@@ -26,7 +26,7 @@ jobs:
repository: dbgate/dbgate-pro repository: dbgate/dbgate-pro
token: '${{ secrets.GH_TOKEN }}' token: '${{ secrets.GH_TOKEN }}'
path: dbgate-pro path: dbgate-pro
ref: 55cf42d58b843c4f1ffd6ab9b808f5f971bc3c8b ref: ecea1eef17c69c56b0633317e24a68c5220a4810
- name: Merge dbgate/dbgate-pro - name: Merge dbgate/dbgate-pro
run: | run: |
mkdir ../dbgate-pro mkdir ../dbgate-pro

View File

@@ -8,7 +8,19 @@ Builds:
- linux - application for linux - linux - application for linux
- win - application for Windows - win - application for Windows
## 6.4.0 - not released yet ## 6.4.2
- ADDED: Source label to docker container #1105
- FIXED: DbGate restart needed to take effect after trigger is created/deleted on mariadb #1112
- ADDED: View PostgreSQL query console output #1108
- FIXED: Single quote generete MySql error #1107
- ADDED: Ability to limit query result count #1098
- CHANGED: Correct processing of bigint columns #1087 #1055 #583
- CHANGED: Improved and optimalized algorithm of loading redis keys #1062, #1034
- FIXED: Fixed loading Redis keys with :: in key name
## 6.4.0
- ADDED: DuckDB support
- ADDED: Data deployer (Premium) - ADDED: Data deployer (Premium)
- ADDED: Compare data between JSON lines file in archive and database table - ADDED: Compare data between JSON lines file in archive and database table
- CHANGED: Data Duplicator => Data Replicator (suitable for update, create and delete data, much more customizable) - CHANGED: Data Duplicator => Data Replicator (suitable for update, create and delete data, much more customizable)
@@ -18,6 +30,15 @@ Builds:
- ADDED: Upload SQLite files - ADDED: Upload SQLite files
- ADDED: Upload archive as ZIP folder (Premium) - ADDED: Upload archive as ZIP folder (Premium)
- ADDED: Compress, uncompress archive folder (Premium) - ADDED: Compress, uncompress archive folder (Premium)
- ADDED: Export connections and settings #357
- ADDED: Filtering by MongoDB ObjectId works now also without ObjectId(...) wrapper
- ADDED: Split queries using blank lines #1089
- FIXED: JSON-to-Grid only works if there is no newline #1085
- CHANGED: When running multiple commands in script, stop execution after first error #1070
- FIXED: Selection rectangle remains visible after closing JSONB edit cell value form #1031
- FIXED: Diplaying numeric FK column with right alignement #1021
- ADDED: Additional arguments for MySQL and PostgreSQL backup #1092
- CHANGED: Amazon and Azure instalations are not auto-upgraded by default
## 6.3.3 ## 6.3.3
- CHANGED: New administration UI, redesigned administration of users, connections and roles - CHANGED: New administration UI, redesigned administration of users, connections and roles

View File

@@ -20,6 +20,7 @@ DbGate is licensed under GPL-3.0 license and is free to use for any purpose.
* Run web version as [NPM package](https://www.npmjs.com/package/dbgate-serve) or as [docker image](https://hub.docker.com/r/dbgate/dbgate) * Run web version as [NPM package](https://www.npmjs.com/package/dbgate-serve) or as [docker image](https://hub.docker.com/r/dbgate/dbgate)
* Use nodeJs [scripting interface](https://docs.dbgate.io/scripting) ([API documentation](https://docs.dbgate.io/apidoc)) * Use nodeJs [scripting interface](https://docs.dbgate.io/scripting) ([API documentation](https://docs.dbgate.io/apidoc))
* [Recommend DbGate](https://testimonial.to/dbgate) | [Rate on G2](https://www.g2.com/products/dbgate/reviews) * [Recommend DbGate](https://testimonial.to/dbgate) | [Rate on G2](https://www.g2.com/products/dbgate/reviews)
* [Give us feedback](https://dbgate.org/feedback) - it will help us to decide, how to improve DbGate in future
## Supported databases ## Supported databases
* MySQL * MySQL
@@ -35,6 +36,8 @@ DbGate is licensed under GPL-3.0 license and is free to use for any purpose.
* CosmosDB (Premium) * CosmosDB (Premium)
* ClickHouse * ClickHouse
* Apache Cassandra * Apache Cassandra
* libSQL/Turso (Premium)
* DuckDB
<a href="https://raw.githubusercontent.com/dbgate/dbgate/master/img/screenshot1.png"> <a href="https://raw.githubusercontent.com/dbgate/dbgate/master/img/screenshot1.png">
@@ -184,4 +187,4 @@ yarn plugin # this compiles plugin and copies it into existing DbGate installati
After restarting DbGate, you could use your new plugin from DbGate. After restarting DbGate, you could use your new plugin from DbGate.
## Logging ## Logging
DbGate uses [pinomin logger](https://github.com/dbgate/pinomin). So by default, it produces JSON log messages into console and log files. If you want to see formatted logs, please use [pino-pretty](https://github.com/pinojs/pino-pretty) log formatter. DbGate uses [pinomin logger](https://github.com/dbgate/pinomin). So by default, it produces JSON log messages into console and log files. If you want to see formatted logs, please use [pino-pretty](https://github.com/pinojs/pino-pretty) log formatter.

View File

@@ -108,6 +108,7 @@ module.exports = ({ editMenu, isMac }) => [
{ command: 'app.openWeb', hideDisabled: true }, { command: 'app.openWeb', hideDisabled: true },
{ command: 'app.openIssue', hideDisabled: true }, { command: 'app.openIssue', hideDisabled: true },
{ command: 'app.openSponsoring', hideDisabled: true }, { command: 'app.openSponsoring', hideDisabled: true },
{ command: 'app.giveFeedback', hideDisabled: true },
{ divider: true }, { divider: true },
{ command: 'settings.commands', hideDisabled: true }, { command: 'settings.commands', hideDisabled: true },
{ command: 'tabs.changelog', hideDisabled: true }, { command: 'tabs.changelog', hideDisabled: true },

View File

@@ -1,5 +1,7 @@
FROM node:22 FROM node:22
LABEL org.opencontainers.image.source="https://github.com/dbgate/dbgate"
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
iputils-ping \ iputils-ping \
iproute2 \ iproute2 \

View File

@@ -1,5 +1,7 @@
FROM node:18-alpine FROM node:18-alpine
LABEL org.opencontainers.image.source="https://github.com/dbgate/dbgate"
WORKDIR /home/dbgate-docker WORKDIR /home/dbgate-docker
RUN apk --no-cache upgrade \ RUN apk --no-cache upgrade \

View File

@@ -112,4 +112,11 @@ describe('Add connection', () => {
cy.contains('performance_schema'); cy.contains('performance_schema');
}); });
it('export connections', () => {
cy.testid('WidgetIconPanel_menu').click();
cy.contains('Tools').click();
cy.contains('Export connections').click();
cy.themeshot('export-connections');
});
}); });

View File

@@ -248,14 +248,14 @@ describe('Data browser data', () => {
cy.themeshot('database-diagram'); cy.themeshot('database-diagram');
}); });
it('Charts', () => { // it('Charts', () => {
cy.testid('WidgetIconPanel_file').click(); // cy.testid('WidgetIconPanel_file').click();
cy.contains('pie-chart').click(); // cy.contains('pie-chart').click();
cy.contains('line-chart').click(); // cy.contains('line-chart').click();
cy.testid('TabsPanel_buttonSplit').click(); // cy.testid('TabsPanel_buttonSplit').click();
cy.testid('WidgetIconPanel_file').click(); // cy.testid('WidgetIconPanel_file').click();
cy.themeshot('view-split-charts'); // cy.themeshot('view-split-charts');
}); // });
it('Keyboard configuration', () => { it('Keyboard configuration', () => {
cy.testid('WidgetIconPanel_settings').click(); cy.testid('WidgetIconPanel_settings').click();

View File

@@ -17,6 +17,17 @@ services:
environment: environment:
- MYSQL_ROOT_PASSWORD=Pwd2020Db - MYSQL_ROOT_PASSWORD=Pwd2020Db
db2:
image: icr.io/db2_community/db2:11.5.8.0
privileged: true
ports:
- "15055:50000"
environment:
LICENSE: accept
DB2INST1_PASSWORD: Pwd2020Db
DBNAME: testdb
DB2INSTANCE: db2inst1
# mysql: # mysql:
# image: mysql:8.0.18 # image: mysql:8.0.18
# command: --default-authentication-plugin=mysql_native_password # command: --default-authentication-plugin=mysql_native_password

View File

@@ -1,6 +1,6 @@
{ {
"private": true, "private": true,
"version": "6.3.4-premium-beta.3", "version": "6.4.3-premium-beta.4",
"name": "dbgate-all", "name": "dbgate-all",
"workspaces": [ "workspaces": [
"packages/*", "packages/*",
@@ -9,6 +9,7 @@
], ],
"scripts": { "scripts": {
"start:api": "yarn workspace dbgate-api start | pino-pretty", "start:api": "yarn workspace dbgate-api start | pino-pretty",
"start:api:watch": "nodemon --watch 'src/**' --ext 'ts,json,js' --exec yarn start:api",
"start:api:json": "yarn workspace dbgate-api start", "start:api:json": "yarn workspace dbgate-api start",
"start:app": "cd app && yarn start | pino-pretty", "start:app": "cd app && yarn start | pino-pretty",
"start:app:singledb": "CONNECTIONS=con1 SERVER_con1=localhost ENGINE_con1=mysql@dbgate-plugin-mysql USER_con1=root PASSWORD_con1=Pwd2020Db SINGLE_CONNECTION=con1 SINGLE_DATABASE=Chinook yarn start:app", "start:app:singledb": "CONNECTIONS=con1 SERVER_con1=localhost ENGINE_con1=mysql@dbgate-plugin-mysql USER_con1=root PASSWORD_con1=Pwd2020Db SINGLE_CONNECTION=con1 SINGLE_DATABASE=Chinook yarn start:app",

View File

@@ -1,5 +1,7 @@
DEVMODE=1 DEVMODE=1
SHELL_SCRIPTING=1 SHELL_SCRIPTING=1
# LOCAL_DBGATE_CLOUD=1
# LOCAL_DBGATE_IDENTITY=1
# CLOUD_UPGRADE_FILE=c:\test\upg\upgrade.zip # CLOUD_UPGRADE_FILE=c:\test\upg\upgrade.zip

View File

@@ -31,7 +31,7 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"cross-env": "^6.0.3", "cross-env": "^6.0.3",
"dbgate-datalib": "^6.0.0-alpha.1", "dbgate-datalib": "^6.0.0-alpha.1",
"dbgate-query-splitter": "^4.11.4", "dbgate-query-splitter": "^4.11.5",
"dbgate-sqltree": "^6.0.0-alpha.1", "dbgate-sqltree": "^6.0.0-alpha.1",
"dbgate-tools": "^6.0.0-alpha.1", "dbgate-tools": "^6.0.0-alpha.1",
"debug": "^4.3.4", "debug": "^4.3.4",

View File

@@ -13,6 +13,8 @@ const {
} = require('../auth/authProvider'); } = require('../auth/authProvider');
const storage = require('./storage'); const storage = require('./storage');
const { decryptPasswordString } = require('../utility/crypting'); const { decryptPasswordString } = require('../utility/crypting');
const { createDbGateIdentitySession, startCloudTokenChecking } = require('../utility/cloudIntf');
const socket = require('../utility/socket');
const logger = getLogger('auth'); const logger = getLogger('auth');
@@ -135,5 +137,14 @@ module.exports = {
return getAuthProviderById(amoid).redirect(params); return getAuthProviderById(amoid).redirect(params);
}, },
createCloudLoginSession_meta: true,
async createCloudLoginSession({ client }) {
const res = await createDbGateIdentitySession(client);
startCloudTokenChecking(res.sid, tokenHolder => {
socket.emit('got-cloud-token', tokenHolder);
});
return res;
},
authMiddleware, authMiddleware,
}; };

View File

@@ -0,0 +1,250 @@
const {
getPublicCloudFiles,
getPublicFileData,
refreshPublicFiles,
callCloudApiGet,
callCloudApiPost,
getCloudFolderEncryptor,
getCloudContent,
putCloudContent,
removeCloudCachedConnection,
} = require('../utility/cloudIntf');
const connections = require('./connections');
const socket = require('../utility/socket');
const { recryptConnection, getInternalEncryptor, encryptConnection } = require('../utility/crypting');
const { getConnectionLabel, getLogger, extractErrorLogData } = require('dbgate-tools');
const logger = getLogger('cloud');
const _ = require('lodash');
const fs = require('fs-extra');
module.exports = {
publicFiles_meta: true,
async publicFiles() {
const res = await getPublicCloudFiles();
return res;
},
publicFileData_meta: true,
async publicFileData({ path }) {
const res = getPublicFileData(path);
return res;
},
refreshPublicFiles_meta: true,
async refreshPublicFiles({ isRefresh }) {
await refreshPublicFiles(isRefresh);
return {
status: 'ok',
};
},
contentList_meta: true,
async contentList() {
try {
const resp = await callCloudApiGet('content-list');
return resp;
} catch (err) {
logger.error(extractErrorLogData(err), 'Error getting cloud content list');
return [];
}
},
getContent_meta: true,
async getContent({ folid, cntid }) {
const resp = await getCloudContent(folid, cntid);
return resp;
},
putContent_meta: true,
async putContent({ folid, cntid, content, name, type }) {
const resp = await putCloudContent(folid, cntid, content, name, type);
socket.emitChanged('cloud-content-changed');
socket.emit('cloud-content-updated');
return resp;
},
createFolder_meta: true,
async createFolder({ name }) {
const resp = await callCloudApiPost(`folders/create`, { name });
socket.emitChanged('cloud-content-changed');
socket.emit('cloud-content-updated');
return resp;
},
grantFolder_meta: true,
async grantFolder({ inviteLink }) {
const m = inviteLink.match(/^dbgate\:\/\/folder\/v1\/([a-zA-Z0-9]+)\?mode=(read|write|admin)$/);
if (!m) {
throw new Error('Invalid invite link format');
}
const invite = m[1];
const mode = m[2];
const resp = await callCloudApiPost(`folders/grant/${mode}`, { invite });
socket.emitChanged('cloud-content-changed');
socket.emit('cloud-content-updated');
return resp;
},
renameFolder_meta: true,
async renameFolder({ folid, name }) {
const resp = await callCloudApiPost(`folders/rename`, { folid, name });
socket.emitChanged('cloud-content-changed');
socket.emit('cloud-content-updated');
return resp;
},
deleteFolder_meta: true,
async deleteFolder({ folid }) {
const resp = await callCloudApiPost(`folders/delete`, { folid });
socket.emitChanged('cloud-content-changed');
socket.emit('cloud-content-updated');
return resp;
},
getInviteToken_meta: true,
async getInviteToken({ folid, role }) {
const resp = await callCloudApiGet(`invite-token/${folid}/${role}`);
return resp;
},
refreshContent_meta: true,
async refreshContent() {
socket.emitChanged('cloud-content-changed');
socket.emit('cloud-content-updated');
return {
status: 'ok',
};
},
copyConnectionCloud_meta: true,
async copyConnectionCloud({ conid, folid }) {
const conn = await connections.getCore({ conid });
const folderEncryptor = await getCloudFolderEncryptor(folid);
const recryptedConn = recryptConnection(conn, getInternalEncryptor(), folderEncryptor);
const connToSend = _.omit(recryptedConn, ['_id']);
const resp = await putCloudContent(
folid,
undefined,
JSON.stringify(connToSend),
getConnectionLabel(conn),
'connection'
);
return resp;
},
saveConnection_meta: true,
async saveConnection({ folid, connection }) {
let cntid = undefined;
if (connection._id) {
const m = connection._id.match(/^cloud\:\/\/(.+)\/(.+)$/);
if (!m) {
throw new Error('Invalid cloud connection ID format');
}
folid = m[1];
cntid = m[2];
}
if (!folid) {
throw new Error('Missing cloud folder ID');
}
const folderEncryptor = await getCloudFolderEncryptor(folid);
const recryptedConn = encryptConnection(connection, folderEncryptor);
const resp = await putCloudContent(
folid,
cntid,
JSON.stringify(recryptedConn),
getConnectionLabel(recryptedConn),
'connection'
);
if (resp.apiErrorMessage) {
return resp;
}
removeCloudCachedConnection(folid, resp.cntid);
cntid = resp.cntid;
socket.emitChanged('cloud-content-changed');
socket.emit('cloud-content-updated');
return {
...recryptedConn,
_id: `cloud://${folid}/${cntid}`,
};
},
duplicateConnection_meta: true,
async duplicateConnection({ conid }) {
const m = conid.match(/^cloud\:\/\/(.+)\/(.+)$/);
if (!m) {
throw new Error('Invalid cloud connection ID format');
}
const folid = m[1];
const cntid = m[2];
const respGet = await getCloudContent(folid, cntid);
const conn = JSON.parse(respGet.content);
const conn2 = {
...conn,
displayName: getConnectionLabel(conn) + ' - copy',
};
const respPut = await putCloudContent(folid, undefined, JSON.stringify(conn2), conn2.displayName, 'connection');
return respPut;
},
deleteConnection_meta: true,
async deleteConnection({ conid }) {
const m = conid.match(/^cloud\:\/\/(.+)\/(.+)$/);
if (!m) {
throw new Error('Invalid cloud connection ID format');
}
const folid = m[1];
const cntid = m[2];
const resp = await callCloudApiPost(`content/delete/${folid}/${cntid}`);
socket.emitChanged('cloud-content-changed');
socket.emit('cloud-content-updated');
return resp;
},
deleteContent_meta: true,
async deleteContent({ folid, cntid }) {
const resp = await callCloudApiPost(`content/delete/${folid}/${cntid}`);
socket.emitChanged('cloud-content-changed');
socket.emit('cloud-content-updated');
return resp;
},
renameContent_meta: true,
async renameContent({ folid, cntid, name }) {
const resp = await callCloudApiPost(`content/rename/${folid}/${cntid}`, { name });
socket.emitChanged('cloud-content-changed');
socket.emit('cloud-content-updated');
return resp;
},
saveFile_meta: true,
async saveFile({ folid, cntid, fileName, data, contentFolder, format }) {
const resp = await putCloudContent(folid, cntid, data, fileName, 'file', contentFolder, format);
socket.emitChanged('cloud-content-changed');
socket.emit('cloud-content-updated');
return resp;
},
copyFile_meta: true,
async copyFile({ folid, cntid, name }) {
const resp = await callCloudApiPost(`content/duplicate/${folid}/${cntid}`, { name });
socket.emitChanged('cloud-content-changed');
socket.emit('cloud-content-updated');
return resp;
},
exportFile_meta: true,
async exportFile({ folid, cntid, filePath }, req) {
const { content } = await getCloudContent(folid, cntid);
if (!content) {
throw new Error('File not found');
}
await fs.writeFile(filePath, content);
return true;
},
};

View File

@@ -298,8 +298,12 @@ module.exports = {
changelog_meta: true, changelog_meta: true,
async changelog() { async changelog() {
const resp = await axios.default.get('https://raw.githubusercontent.com/dbgate/dbgate/master/CHANGELOG.md'); try {
return resp.data; const resp = await axios.default.get('https://raw.githubusercontent.com/dbgate/dbgate/master/CHANGELOG.md');
return resp.data;
} catch (err) {
return ''
}
}, },
checkLicense_meta: true, checkLicense_meta: true,

View File

@@ -239,6 +239,19 @@ module.exports = {
return (await this.datastore.find()).filter(x => connectionHasPermission(x, req)); return (await this.datastore.find()).filter(x => connectionHasPermission(x, req));
}, },
async getUsedEngines() {
const storage = require('./storage');
const storageEngines = await storage.getUsedEngines();
if (storageEngines) {
return storageEngines;
}
if (portalConnections) {
return _.uniq(_.compact(portalConnections.map(x => x.engine)));
}
return _.uniq((await this.datastore.find()).map(x => x.engine));
},
test_meta: true, test_meta: true,
test({ connection, requestDbList = false }) { test({ connection, requestDbList = false }) {
const subprocess = fork( const subprocess = fork(
@@ -410,6 +423,13 @@ module.exports = {
return volatile; return volatile;
} }
const cloudMatch = conid.match(/^cloud\:\/\/(.+)\/(.+)$/);
if (cloudMatch) {
const { loadCachedCloudConnection } = require('../utility/cloudIntf');
const conn = await loadCachedCloudConnection(cloudMatch[1], cloudMatch[2]);
return conn;
}
const storage = require('./storage'); const storage = require('./storage');
const storageConnection = await storage.getConnection({ conid }); const storageConnection = await storage.getConnection({ conid });

View File

@@ -148,6 +148,9 @@ module.exports = {
const existing = this.opened.find(x => x.conid == conid && x.database == database); const existing = this.opened.find(x => x.conid == conid && x.database == database);
if (existing) return existing; if (existing) return existing;
const connection = await connections.getCore({ conid }); const connection = await connections.getCore({ conid });
if (!connection) {
throw new Error(`databaseConnections: Connection with conid="${conid}" not found`);
}
if (connection.passwordMode == 'askPassword' || connection.passwordMode == 'askUser') { if (connection.passwordMode == 'askPassword' || connection.passwordMode == 'askUser') {
throw new MissingCredentialsError({ conid, passwordMode: connection.passwordMode }); throw new MissingCredentialsError({ conid, passwordMode: connection.passwordMode });
} }
@@ -304,6 +307,12 @@ module.exports = {
return this.loadDataCore('loadKeys', { conid, database, root, filter, limit }); return this.loadDataCore('loadKeys', { conid, database, root, filter, limit });
}, },
scanKeys_meta: true,
async scanKeys({ conid, database, root, pattern, cursor, count }, req) {
testConnectionPermission(conid, req);
return this.loadDataCore('scanKeys', { conid, database, root, pattern, cursor, count });
},
exportKeys_meta: true, exportKeys_meta: true,
async exportKeys({ conid, database, options }, req) { async exportKeys({ conid, database, options }, req) {
testConnectionPermission(conid, req); testConnectionPermission(conid, req);

View File

@@ -10,6 +10,7 @@ const requirePluginFunction = require('../utility/requirePluginFunction');
const socket = require('../utility/socket'); const socket = require('../utility/socket');
const crypto = require('crypto'); const crypto = require('crypto');
const dbgateApi = require('../shell'); const dbgateApi = require('../shell');
const { ChartProcessor } = require('dbgate-datalib');
function readFirstLine(file) { function readFirstLine(file) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -302,4 +303,29 @@ module.exports = {
await dbgateApi.download(uri, { targetFile: getJslFileName(jslid) }); await dbgateApi.download(uri, { targetFile: getJslFileName(jslid) });
return { jslid }; return { jslid };
}, },
buildChart_meta: true,
async buildChart({ jslid, definition }) {
const datastore = new JsonLinesDatastore(getJslFileName(jslid));
const processor = new ChartProcessor(definition ? [definition] : undefined);
await datastore.enumRows(row => {
processor.addRow(row);
return true;
});
processor.finalize();
return processor.charts;
},
detectChartColumns_meta: true,
async detectChartColumns({ jslid }) {
const datastore = new JsonLinesDatastore(getJslFileName(jslid));
const processor = new ChartProcessor();
processor.autoDetectCharts = false;
await datastore.enumRows(row => {
processor.addRow(row);
return true;
});
processor.finalize();
return processor.availableColumns;
},
}; };

View File

@@ -52,7 +52,7 @@ module.exports = {
if (existing) return existing; if (existing) return existing;
const connection = await connections.getCore({ conid }); const connection = await connections.getCore({ conid });
if (!connection) { if (!connection) {
throw new Error(`Connection with conid="${conid}" not found`); throw new Error(`serverConnections: Connection with conid="${conid}" not found`);
} }
if (connection.singleDatabase) { if (connection.singleDatabase) {
return null; return null;

View File

@@ -83,6 +83,11 @@ module.exports = {
jsldata.notifyChangedStats(stats); jsldata.notifyChangedStats(stats);
}, },
handle_charts(sesid, props) {
const { jslid, charts, resultIndex } = props;
socket.emit(`session-charts-${sesid}`, { jslid, resultIndex, charts });
},
handle_initializeFile(sesid, props) { handle_initializeFile(sesid, props) {
const { jslid } = props; const { jslid } = props;
socket.emit(`session-initialize-file-${jslid}`); socket.emit(`session-initialize-file-${jslid}`);
@@ -141,7 +146,7 @@ module.exports = {
}, },
executeQuery_meta: true, executeQuery_meta: true,
async executeQuery({ sesid, sql, autoCommit }) { async executeQuery({ sesid, sql, autoCommit, limitRows, frontMatter }) {
const session = this.opened.find(x => x.sesid == sesid); const session = this.opened.find(x => x.sesid == sesid);
if (!session) { if (!session) {
throw new Error('Invalid session'); throw new Error('Invalid session');
@@ -149,7 +154,7 @@ module.exports = {
logger.info({ sesid, sql }, 'Processing query'); logger.info({ sesid, sql }, 'Processing query');
this.dispatchMessage(sesid, 'Query execution started'); this.dispatchMessage(sesid, 'Query execution started');
session.subprocess.send({ msgtype: 'executeQuery', sql, autoCommit }); session.subprocess.send({ msgtype: 'executeQuery', sql, autoCommit, limitRows, frontMatter });
return { state: 'ok' }; return { state: 'ok' };
}, },

View File

@@ -32,4 +32,8 @@ module.exports = {
}, },
startRefreshLicense() {}, startRefreshLicense() {},
async getUsedEngines() {
return null;
},
}; };

View File

@@ -27,6 +27,7 @@ const plugins = require('./controllers/plugins');
const files = require('./controllers/files'); const files = require('./controllers/files');
const scheduler = require('./controllers/scheduler'); const scheduler = require('./controllers/scheduler');
const queryHistory = require('./controllers/queryHistory'); const queryHistory = require('./controllers/queryHistory');
const cloud = require('./controllers/cloud');
const onFinished = require('on-finished'); const onFinished = require('on-finished');
const processArgs = require('./utility/processArgs'); const processArgs = require('./utility/processArgs');
@@ -39,6 +40,7 @@ const { getDefaultAuthProvider } = require('./auth/authProvider');
const startCloudUpgradeTimer = require('./utility/cloudUpgrade'); const startCloudUpgradeTimer = require('./utility/cloudUpgrade');
const { isProApp } = require('./utility/checkLicense'); const { isProApp } = require('./utility/checkLicense');
const { getHealthStatus, getHealthStatusSprinx } = require('./utility/healthStatus'); const { getHealthStatus, getHealthStatusSprinx } = require('./utility/healthStatus');
const { startCloudFiles } = require('./utility/cloudIntf');
const logger = getLogger('main'); const logger = getLogger('main');
@@ -200,6 +202,8 @@ function start() {
if (process.env.CLOUD_UPGRADE_FILE) { if (process.env.CLOUD_UPGRADE_FILE) {
startCloudUpgradeTimer(); startCloudUpgradeTimer();
} }
startCloudFiles();
} }
function useAllControllers(app, electron) { function useAllControllers(app, electron) {
@@ -220,6 +224,7 @@ function useAllControllers(app, electron) {
useController(app, electron, '/query-history', queryHistory); useController(app, electron, '/query-history', queryHistory);
useController(app, electron, '/apps', apps); useController(app, electron, '/apps', apps);
useController(app, electron, '/auth', auth); useController(app, electron, '/auth', auth);
useController(app, electron, '/cloud', cloud);
} }
function setElectronSender(electronSender) { function setElectronSender(electronSender) {

View File

@@ -28,14 +28,7 @@ function start() {
let version = { let version = {
version: 'Unknown', version: 'Unknown',
}; };
try { version = await driver.getVersion(dbhan);
version = await driver.getVersion(dbhan);
} catch (err) {
logger.error(extractErrorLogData(err), 'Error getting DB server version');
version = {
version: 'Unknown',
};
}
let databases = undefined; let databases = undefined;
if (requestDbList) { if (requestDbList) {
databases = await driver.listDatabases(dbhan); databases = await driver.listDatabases(dbhan);

View File

@@ -12,6 +12,7 @@ const {
ScriptWriterEval, ScriptWriterEval,
SqlGenerator, SqlGenerator,
playJsonScriptWriter, playJsonScriptWriter,
serializeJsTypesForJsonStringify,
} = require('dbgate-tools'); } = require('dbgate-tools');
const requireEngineDriver = require('../utility/requireEngineDriver'); const requireEngineDriver = require('../utility/requireEngineDriver');
const { connectUtility } = require('../utility/connectUtility'); const { connectUtility } = require('../utility/connectUtility');
@@ -232,7 +233,7 @@ async function handleQueryData({ msgid, sql, range }, skipReadonlyCheck = false)
try { try {
if (!skipReadonlyCheck) ensureExecuteCustomScript(driver); if (!skipReadonlyCheck) ensureExecuteCustomScript(driver);
const res = await driver.query(dbhan, sql, { range }); const res = await driver.query(dbhan, sql, { range });
process.send({ msgtype: 'response', msgid, ...res }); process.send({ msgtype: 'response', msgid, ...serializeJsTypesForJsonStringify(res) });
} catch (err) { } catch (err) {
process.send({ process.send({
msgtype: 'response', msgtype: 'response',
@@ -254,7 +255,7 @@ async function handleDriverDataCore(msgid, callMethod, { logName }) {
const driver = requireEngineDriver(storedConnection); const driver = requireEngineDriver(storedConnection);
try { try {
const result = await callMethod(driver); const result = await callMethod(driver);
process.send({ msgtype: 'response', msgid, result }); process.send({ msgtype: 'response', msgid, result: serializeJsTypesForJsonStringify(result) });
} catch (err) { } catch (err) {
logger.error(extractErrorLogData(err, { logName }), `Error when handling message ${logName}`); logger.error(extractErrorLogData(err, { logName }), `Error when handling message ${logName}`);
process.send({ msgtype: 'response', msgid, errorMessage: extractErrorMessage(err, 'Error executing DB data') }); process.send({ msgtype: 'response', msgid, errorMessage: extractErrorMessage(err, 'Error executing DB data') });
@@ -274,6 +275,10 @@ async function handleLoadKeys({ msgid, root, filter, limit }) {
return handleDriverDataCore(msgid, driver => driver.loadKeys(dbhan, root, filter, limit), { logName: 'loadKeys' }); return handleDriverDataCore(msgid, driver => driver.loadKeys(dbhan, root, filter, limit), { logName: 'loadKeys' });
} }
async function handleScanKeys({ msgid, pattern, cursor, count }) {
return handleDriverDataCore(msgid, driver => driver.scanKeys(dbhan, pattern, cursor, count), { logName: 'scanKeys' });
}
async function handleExportKeys({ msgid, options }) { async function handleExportKeys({ msgid, options }) {
return handleDriverDataCore(msgid, driver => driver.exportKeys(dbhan, options), { logName: 'exportKeys' }); return handleDriverDataCore(msgid, driver => driver.exportKeys(dbhan, options), { logName: 'exportKeys' });
} }
@@ -452,6 +457,7 @@ const messageHandlers = {
updateCollection: handleUpdateCollection, updateCollection: handleUpdateCollection,
collectionData: handleCollectionData, collectionData: handleCollectionData,
loadKeys: handleLoadKeys, loadKeys: handleLoadKeys,
scanKeys: handleScanKeys,
loadKeyInfo: handleLoadKeyInfo, loadKeyInfo: handleLoadKeyInfo,
callMethod: handleCallMethod, callMethod: handleCallMethod,
loadKeyTableRange: handleLoadKeyTableRange, loadKeyTableRange: handleLoadKeyTableRange,

View File

@@ -117,7 +117,7 @@ async function handleExecuteControlCommand({ command }) {
} }
} }
async function handleExecuteQuery({ sql, autoCommit }) { async function handleExecuteQuery({ sql, autoCommit, limitRows, frontMatter }) {
lastActivity = new Date().getTime(); lastActivity = new Date().getTime();
await waitConnected(); await waitConnected();
@@ -146,7 +146,7 @@ async function handleExecuteQuery({ sql, autoCommit }) {
...driver.getQuerySplitterOptions('stream'), ...driver.getQuerySplitterOptions('stream'),
returnRichInfo: true, returnRichInfo: true,
})) { })) {
await handleQueryStream(dbhan, driver, queryStreamInfoHolder, sqlItem); await handleQueryStream(dbhan, driver, queryStreamInfoHolder, sqlItem, undefined, limitRows, frontMatter);
// const handler = new StreamHandler(resultIndex); // const handler = new StreamHandler(resultIndex);
// const stream = await driver.stream(systemConnection, sqlItem, handler); // const stream = await driver.stream(systemConnection, sqlItem, handler);
// handler.stream = stream; // handler.stream = stream;

View File

@@ -15,6 +15,7 @@ const logger = getLogger('execQuery');
* @param {string} [options.sqlFile] - SQL file * @param {string} [options.sqlFile] - SQL file
* @param {boolean} [options.logScriptItems] - whether to log script items instead of whole script * @param {boolean} [options.logScriptItems] - whether to log script items instead of whole script
* @param {boolean} [options.useTransaction] - run query in transaction * @param {boolean} [options.useTransaction] - run query in transaction
* @param {boolean} [options.skipLogging] - whether to skip logging
*/ */
async function executeQuery({ async function executeQuery({
connection = undefined, connection = undefined,
@@ -23,9 +24,10 @@ async function executeQuery({
sql, sql,
sqlFile = undefined, sqlFile = undefined,
logScriptItems = false, logScriptItems = false,
skipLogging = false,
useTransaction, useTransaction,
}) { }) {
if (!logScriptItems) { if (!logScriptItems && !skipLogging) {
logger.info({ sql: getLimitedQuery(sql) }, `Execute query`); logger.info({ sql: getLimitedQuery(sql) }, `Execute query`);
} }
@@ -38,7 +40,9 @@ async function executeQuery({
} }
try { try {
logger.debug(`Running SQL query, length: ${sql.length}`); if (!skipLogging) {
logger.debug(`Running SQL query, length: ${sql.length}`);
}
await driver.script(dbhan, sql, { logScriptItems, useTransaction }); await driver.script(dbhan, sql, { logScriptItems, useTransaction });
} finally { } finally {

View File

@@ -52,7 +52,10 @@ async function generateDeploySql({
dbdiffOptionsExtra?.['schemaMode'] !== 'ignore' && dbdiffOptionsExtra?.['schemaMode'] !== 'ignore' &&
dbdiffOptionsExtra?.['schemaMode'] !== 'ignoreImplicit' dbdiffOptionsExtra?.['schemaMode'] !== 'ignoreImplicit'
) { ) {
throw new Error('targetSchema is required for databases with multiple schemas'); if (!driver?.dialect?.defaultSchemaName) {
throw new Error('targetSchema is required for databases with multiple schemas');
}
targetSchema = driver.dialect.defaultSchemaName;
} }
try { try {

View File

@@ -7,6 +7,8 @@ const logger = getLogger('queryReader');
* Returns reader object for {@link copyStream} function. This reader object reads data from query. * Returns reader object for {@link copyStream} function. This reader object reads data from query.
* @param {object} options * @param {object} options
* @param {connectionType} options.connection - connection object * @param {connectionType} options.connection - connection object
* @param {object} options.systemConnection - system connection (result of driver.connect). If not provided, new connection will be created
* @param {object} options.driver - driver object. If not provided, it will be loaded from connection
* @param {string} options.query - SQL query * @param {string} options.query - SQL query
* @param {string} [options.queryType] - query type * @param {string} [options.queryType] - query type
* @param {string} [options.sql] - SQL query. obsolete; use query instead * @param {string} [options.sql] - SQL query. obsolete; use query instead
@@ -16,6 +18,8 @@ async function queryReader({
connection, connection,
query, query,
queryType, queryType,
systemConnection,
driver,
// obsolete; use query instead // obsolete; use query instead
sql, sql,
}) { }) {
@@ -28,10 +32,13 @@ async function queryReader({
logger.info({ sql: query || sql }, `Reading query`); logger.info({ sql: query || sql }, `Reading query`);
// else console.log(`Reading query ${JSON.stringify(json)}`); // else console.log(`Reading query ${JSON.stringify(json)}`);
const driver = requireEngineDriver(connection); if (!driver) {
const pool = await connectUtility(driver, connection, queryType == 'json' ? 'read' : 'script'); driver = requireEngineDriver(connection);
}
const dbhan = systemConnection || (await connectUtility(driver, connection, queryType == 'json' ? 'read' : 'script'));
const reader = const reader =
queryType == 'json' ? await driver.readJsonQuery(pool, query) : await driver.readQuery(pool, query || sql); queryType == 'json' ? await driver.readJsonQuery(dbhan, query) : await driver.readQuery(dbhan, query || sql);
return reader; return reader;
} }

View File

@@ -36,6 +36,10 @@ async function callRefactorSqlQueryApi(query, task, structure, dialect) {
return null; return null;
} }
function getLicenseHttpHeaders() {
return {};
}
module.exports = { module.exports = {
isAuthProxySupported, isAuthProxySupported,
authProxyGetRedirectUrl, authProxyGetRedirectUrl,
@@ -47,4 +51,5 @@ module.exports = {
callTextToSqlApi, callTextToSqlApi,
callCompleteOnCursorApi, callCompleteOnCursorApi,
callRefactorSqlQueryApi, callRefactorSqlQueryApi,
getLicenseHttpHeaders,
}; };

View File

@@ -0,0 +1,380 @@
const axios = require('axios');
const fs = require('fs-extra');
const _ = require('lodash');
const path = require('path');
const { getLicenseHttpHeaders } = require('./authProxy');
const { getLogger, extractErrorLogData, jsonLinesParse } = require('dbgate-tools');
const { datadir } = require('./directories');
const platformInfo = require('./platformInfo');
const connections = require('../controllers/connections');
const { isProApp } = require('./checkLicense');
const socket = require('./socket');
const config = require('../controllers/config');
const simpleEncryptor = require('simple-encryptor');
const currentVersion = require('../currentVersion');
const { getPublicIpInfo } = require('./hardwareFingerprint');
const logger = getLogger('cloudIntf');
let cloudFiles = null;
const DBGATE_IDENTITY_URL = process.env.LOCAL_DBGATE_IDENTITY
? 'http://localhost:3103'
: process.env.DEVWEB || process.env.DEVMODE
? 'https://identity.dbgate.udolni.net'
: 'https://identity.dbgate.io';
const DBGATE_CLOUD_URL = process.env.LOCAL_DBGATE_CLOUD
? 'http://localhost:3110'
: process.env.DEVWEB || process.env.DEVMODE
? 'https://cloud.dbgate.udolni.net'
: 'https://cloud.dbgate.io';
async function createDbGateIdentitySession(client) {
const resp = await axios.default.post(
`${DBGATE_IDENTITY_URL}/api/create-session`,
{
client,
},
{
headers: {
...getLicenseHttpHeaders(),
'Content-Type': 'application/json',
},
}
);
return {
sid: resp.data.sid,
url: `${DBGATE_IDENTITY_URL}/api/signin/${resp.data.sid}`,
};
}
function startCloudTokenChecking(sid, callback) {
const started = Date.now();
const interval = setInterval(async () => {
if (Date.now() - started > 60 * 1000) {
clearInterval(interval);
return;
}
try {
// console.log(`Checking cloud token for session: ${DBGATE_IDENTITY_URL}/api/get-token/${sid}`);
const resp = await axios.default.get(`${DBGATE_IDENTITY_URL}/api/get-token/${sid}`, {
headers: {
...getLicenseHttpHeaders(),
},
});
// console.log('CHECK RESP:', resp.data);
if (resp.data.email) {
clearInterval(interval);
callback(resp.data);
}
} catch (err) {
logger.error(extractErrorLogData(err), 'Error checking cloud token');
}
}, 500);
}
async function loadCloudFiles() {
try {
const fileContent = await fs.readFile(path.join(datadir(), 'cloud-files.jsonl'), 'utf-8');
const parsedJson = jsonLinesParse(fileContent);
cloudFiles = _.sortBy(parsedJson, x => `${x.folder}/${x.title}`);
} catch (err) {
cloudFiles = [];
}
}
async function collectCloudFilesSearchTags() {
const res = [];
if (platformInfo.isElectron) {
res.push('app');
} else {
res.push('web');
}
if (platformInfo.isWindows) {
res.push('windows');
}
if (platformInfo.isMac) {
res.push('mac');
}
if (platformInfo.isLinux) {
res.push('linux');
}
if (platformInfo.isAwsUbuntuLayout) {
res.push('aws');
}
if (platformInfo.isAzureUbuntuLayout) {
res.push('azure');
}
if (platformInfo.isSnap) {
res.push('snap');
}
if (platformInfo.isDocker) {
res.push('docker');
}
if (platformInfo.isNpmDist) {
res.push('npm');
}
const engines = await connections.getUsedEngines();
const engineTags = engines.map(engine => engine.split('@')[0]);
res.push(...engineTags);
// team-premium and trials will return the same cloud files as premium - no need to check
res.push(isProApp() ? 'premium' : 'community');
return res;
}
async function getCloudSigninHolder() {
const settingsValue = await config.getSettings();
const holder = settingsValue['cloudSigninTokenHolder'];
return holder;
}
async function getCloudSigninHeaders(holder = null) {
if (!holder) {
holder = await getCloudSigninHolder();
}
if (holder) {
return {
'x-cloud-login': holder.token,
};
}
return null;
}
async function updateCloudFiles(isRefresh) {
let lastCloudFilesTags;
try {
lastCloudFilesTags = await fs.readFile(path.join(datadir(), 'cloud-files-tags.txt'), 'utf-8');
} catch (err) {
lastCloudFilesTags = '';
}
const ipInfo = await getPublicIpInfo();
const tags = (await collectCloudFilesSearchTags()).join(',');
let lastCheckedTm = 0;
if (tags == lastCloudFilesTags && cloudFiles.length > 0) {
lastCheckedTm = _.max(cloudFiles.map(x => parseInt(x.modifiedTm)));
}
logger.info({ tags, lastCheckedTm }, 'Downloading cloud files');
const resp = await axios.default.get(
`${DBGATE_CLOUD_URL}/public-cloud-updates?lastCheckedTm=${lastCheckedTm}&tags=${tags}&isRefresh=${
isRefresh ? 1 : 0
}&country=${ipInfo?.country || ''}`,
{
headers: {
...getLicenseHttpHeaders(),
...(await getCloudSigninHeaders()),
'x-app-version': currentVersion.version,
},
}
);
logger.info(`Downloaded ${resp.data.length} cloud files`);
const filesByPath = lastCheckedTm == 0 ? {} : _.keyBy(cloudFiles, 'path');
for (const file of resp.data) {
if (file.isDeleted) {
delete filesByPath[file.path];
} else {
filesByPath[file.path] = file;
}
}
cloudFiles = Object.values(filesByPath);
await fs.writeFile(path.join(datadir(), 'cloud-files.jsonl'), cloudFiles.map(x => JSON.stringify(x)).join('\n'));
await fs.writeFile(path.join(datadir(), 'cloud-files-tags.txt'), tags);
socket.emitChanged(`public-cloud-changed`);
}
async function startCloudFiles() {
loadCloudFiles();
}
async function getPublicCloudFiles() {
if (!loadCloudFiles) {
await loadCloudFiles();
}
return cloudFiles;
}
async function getPublicFileData(path) {
const resp = await axios.default.get(`${DBGATE_CLOUD_URL}/public/${path}`, {
headers: {
...getLicenseHttpHeaders(),
},
});
return resp.data;
}
async function refreshPublicFiles(isRefresh) {
if (!cloudFiles) {
await loadCloudFiles();
}
try {
await updateCloudFiles(isRefresh);
} catch (err) {
logger.error(extractErrorLogData(err), 'Error updating cloud files');
}
}
async function callCloudApiGet(endpoint, signinHolder = null, additionalHeaders = {}) {
if (!signinHolder) {
signinHolder = await getCloudSigninHolder();
}
if (!signinHolder) {
return null;
}
const signinHeaders = await getCloudSigninHeaders(signinHolder);
const resp = await axios.default.get(`${DBGATE_CLOUD_URL}/${endpoint}`, {
headers: {
...getLicenseHttpHeaders(),
...signinHeaders,
...additionalHeaders,
},
validateStatus: status => status < 500,
});
const { errorMessage } = resp.data;
if (errorMessage) {
return { apiErrorMessage: errorMessage };
}
return resp.data;
}
async function callCloudApiPost(endpoint, body, signinHolder = null) {
if (!signinHolder) {
signinHolder = await getCloudSigninHolder();
}
if (!signinHolder) {
return null;
}
const signinHeaders = await getCloudSigninHeaders(signinHolder);
const resp = await axios.default.post(`${DBGATE_CLOUD_URL}/${endpoint}`, body, {
headers: {
...getLicenseHttpHeaders(),
...signinHeaders,
},
validateStatus: status => status < 500,
});
const { errorMessage, isLicenseLimit, limitedLicenseLimits } = resp.data;
if (errorMessage) {
return {
apiErrorMessage: errorMessage,
apiErrorIsLicenseLimit: isLicenseLimit,
apiErrorLimitedLicenseLimits: limitedLicenseLimits,
};
}
return resp.data;
}
async function getCloudFolderEncryptor(folid) {
const { encryptionKey } = await callCloudApiGet(`folder-key/${folid}`);
if (!encryptionKey) {
throw new Error('No encryption key for folder: ' + folid);
}
return simpleEncryptor.createEncryptor(encryptionKey);
}
async function getCloudContent(folid, cntid) {
const signinHolder = await getCloudSigninHolder();
if (!signinHolder) {
throw new Error('No signed in');
}
const encryptor = simpleEncryptor.createEncryptor(signinHolder.encryptionKey);
const { content, name, type, contentFolder, contentType, apiErrorMessage } = await callCloudApiGet(
`content/${folid}/${cntid}`,
signinHolder,
{
'x-kehid': signinHolder.kehid,
}
);
if (apiErrorMessage) {
return { apiErrorMessage };
}
return {
content: encryptor.decrypt(content),
name,
type,
contentFolder,
contentType,
};
}
/**
*
* @returns Promise<{ cntid: string } | { apiErrorMessage: string }>
*/
async function putCloudContent(folid, cntid, content, name, type, contentFolder = null, contentType = null) {
const signinHolder = await getCloudSigninHolder();
if (!signinHolder) {
throw new Error('No signed in');
}
const encryptor = simpleEncryptor.createEncryptor(signinHolder.encryptionKey);
const resp = await callCloudApiPost(
`put-content`,
{
folid,
cntid,
name,
type,
kehid: signinHolder.kehid,
content: encryptor.encrypt(content),
contentFolder,
contentType,
},
signinHolder
);
socket.emitChanged('cloud-content-changed');
socket.emit('cloud-content-updated');
return resp;
}
const cloudConnectionCache = {};
async function loadCachedCloudConnection(folid, cntid) {
const cacheKey = `${folid}|${cntid}`;
if (!cloudConnectionCache[cacheKey]) {
const { content } = await getCloudContent(folid, cntid);
cloudConnectionCache[cacheKey] = {
...JSON.parse(content),
_id: `cloud://${folid}/${cntid}`,
};
}
return cloudConnectionCache[cacheKey];
}
function removeCloudCachedConnection(folid, cntid) {
const cacheKey = `${folid}|${cntid}`;
delete cloudConnectionCache[cacheKey];
}
module.exports = {
createDbGateIdentitySession,
startCloudTokenChecking,
startCloudFiles,
getPublicCloudFiles,
getPublicFileData,
refreshPublicFiles,
callCloudApiGet,
callCloudApiPost,
getCloudFolderEncryptor,
getCloudContent,
loadCachedCloudConnection,
putCloudContent,
removeCloudCachedConnection,
};

View File

@@ -81,11 +81,11 @@ function decryptPasswordString(password) {
return password; return password;
} }
function encryptObjectPasswordField(obj, field) { function encryptObjectPasswordField(obj, field, encryptor = null) {
if (obj && obj[field] && !obj[field].startsWith('crypt:')) { if (obj && obj[field] && !obj[field].startsWith('crypt:')) {
return { return {
...obj, ...obj,
[field]: 'crypt:' + getInternalEncryptor().encrypt(obj[field]), [field]: 'crypt:' + (encryptor || getInternalEncryptor()).encrypt(obj[field]),
}; };
} }
return obj; return obj;
@@ -101,11 +101,11 @@ function decryptObjectPasswordField(obj, field) {
return obj; return obj;
} }
function encryptConnection(connection) { function encryptConnection(connection, encryptor = null) {
if (connection.passwordMode != 'saveRaw') { if (connection.passwordMode != 'saveRaw') {
connection = encryptObjectPasswordField(connection, 'password'); connection = encryptObjectPasswordField(connection, 'password', encryptor);
connection = encryptObjectPasswordField(connection, 'sshPassword'); connection = encryptObjectPasswordField(connection, 'sshPassword', encryptor);
connection = encryptObjectPasswordField(connection, 'sshKeyfilePassword'); connection = encryptObjectPasswordField(connection, 'sshKeyfilePassword', encryptor);
} }
return connection; return connection;
} }

View File

@@ -4,6 +4,9 @@ const fs = require('fs');
const _ = require('lodash'); const _ = require('lodash');
const { jsldir } = require('../utility/directories'); const { jsldir } = require('../utility/directories');
const { serializeJsTypesReplacer } = require('dbgate-tools');
const { ChartProcessor } = require('dbgate-datalib');
const { isProApp } = require('./checkLicense');
class QueryStreamTableWriter { class QueryStreamTableWriter {
constructor(sesid = undefined) { constructor(sesid = undefined) {
@@ -11,9 +14,12 @@ class QueryStreamTableWriter {
this.currentChangeIndex = 1; this.currentChangeIndex = 1;
this.initializedFile = false; this.initializedFile = false;
this.sesid = sesid; this.sesid = sesid;
if (isProApp()) {
this.chartProcessor = new ChartProcessor();
}
} }
initializeFromQuery(structure, resultIndex) { initializeFromQuery(structure, resultIndex, chartDefinition) {
this.jslid = crypto.randomUUID(); this.jslid = crypto.randomUUID();
this.currentFile = path.join(jsldir(), `${this.jslid}.jsonl`); this.currentFile = path.join(jsldir(), `${this.jslid}.jsonl`);
fs.writeFileSync( fs.writeFileSync(
@@ -27,6 +33,9 @@ class QueryStreamTableWriter {
this.writeCurrentStats(false, false); this.writeCurrentStats(false, false);
this.resultIndex = resultIndex; this.resultIndex = resultIndex;
this.initializedFile = true; this.initializedFile = true;
if (isProApp() && chartDefinition) {
this.chartProcessor = new ChartProcessor([chartDefinition]);
}
process.send({ msgtype: 'recordset', jslid: this.jslid, resultIndex, sesid: this.sesid }); process.send({ msgtype: 'recordset', jslid: this.jslid, resultIndex, sesid: this.sesid });
} }
@@ -38,7 +47,16 @@ class QueryStreamTableWriter {
row(row) { row(row) {
// console.log('ACCEPT ROW', row); // console.log('ACCEPT ROW', row);
this.currentStream.write(JSON.stringify(row) + '\n'); this.currentStream.write(JSON.stringify(row, serializeJsTypesReplacer) + '\n');
try {
if (this.chartProcessor) {
this.chartProcessor.addRow(row);
}
} catch (e) {
console.error('Error processing chart row', e);
this.chartProcessor = null;
}
this.currentRowCount += 1; this.currentRowCount += 1;
if (!this.plannedStats) { if (!this.plannedStats) {
@@ -81,20 +99,52 @@ class QueryStreamTableWriter {
} }
close(afterClose) { close(afterClose) {
if (this.currentStream) { return new Promise(resolve => {
this.currentStream.end(() => { if (this.currentStream) {
this.writeCurrentStats(true, true); this.currentStream.end(() => {
if (afterClose) afterClose(); this.writeCurrentStats(true, true);
}); if (afterClose) afterClose();
} if (this.chartProcessor) {
try {
this.chartProcessor.finalize();
if (this.chartProcessor.charts.length > 0) {
process.send({
msgtype: 'charts',
sesid: this.sesid,
jslid: this.jslid,
charts: this.chartProcessor.charts,
resultIndex: this.resultIndex,
});
}
} catch (e) {
console.error('Error finalizing chart processor', e);
this.chartProcessor = null;
}
}
resolve();
});
} else {
resolve();
}
});
} }
} }
class StreamHandler { class StreamHandler {
constructor(queryStreamInfoHolder, resolve, startLine, sesid = undefined) { constructor(
queryStreamInfoHolder,
resolve,
startLine,
sesid = undefined,
limitRows = undefined,
frontMatter = undefined
) {
this.recordset = this.recordset.bind(this); this.recordset = this.recordset.bind(this);
this.startLine = startLine; this.startLine = startLine;
this.sesid = sesid; this.sesid = sesid;
this.frontMatter = frontMatter;
this.limitRows = limitRows;
this.rowsLimitOverflow = false;
this.row = this.row.bind(this); this.row = this.row.bind(this);
// this.error = this.error.bind(this); // this.error = this.error.bind(this);
this.done = this.done.bind(this); this.done = this.done.bind(this);
@@ -106,6 +156,7 @@ class StreamHandler {
this.plannedStats = false; this.plannedStats = false;
this.queryStreamInfoHolder = queryStreamInfoHolder; this.queryStreamInfoHolder = queryStreamInfoHolder;
this.resolve = resolve; this.resolve = resolve;
this.rowCounter = 0;
// currentHandlers = [...currentHandlers, this]; // currentHandlers = [...currentHandlers, this];
} }
@@ -117,13 +168,18 @@ class StreamHandler {
} }
recordset(columns) { recordset(columns) {
if (this.rowsLimitOverflow) {
return;
}
this.closeCurrentWriter(); this.closeCurrentWriter();
this.currentWriter = new QueryStreamTableWriter(this.sesid); this.currentWriter = new QueryStreamTableWriter(this.sesid);
this.currentWriter.initializeFromQuery( this.currentWriter.initializeFromQuery(
Array.isArray(columns) ? { columns } : columns, Array.isArray(columns) ? { columns } : columns,
this.queryStreamInfoHolder.resultIndex this.queryStreamInfoHolder.resultIndex,
this.frontMatter?.[`chart-${this.queryStreamInfoHolder.resultIndex + 1}`]
); );
this.queryStreamInfoHolder.resultIndex += 1; this.queryStreamInfoHolder.resultIndex += 1;
this.rowCounter = 0;
// this.writeCurrentStats(); // this.writeCurrentStats();
@@ -134,8 +190,36 @@ class StreamHandler {
// }, 500); // }, 500);
} }
row(row) { row(row) {
if (this.currentWriter) this.currentWriter.row(row); if (this.rowsLimitOverflow) {
else if (row.message) process.send({ msgtype: 'info', info: { message: row.message }, sesid: this.sesid }); return;
}
if (this.limitRows && this.rowCounter >= this.limitRows) {
process.send({
msgtype: 'info',
info: { message: `Rows limit overflow, loaded ${this.rowCounter} rows, canceling query`, severity: 'error' },
sesid: this.sesid,
});
this.rowsLimitOverflow = true;
this.queryStreamInfoHolder.canceled = true;
if (this.currentWriter) {
this.currentWriter.close().then(() => {
process.exit(0);
});
} else {
process.exit(0);
}
return;
}
if (this.currentWriter) {
this.currentWriter.row(row);
this.rowCounter += 1;
} else if (row.message) {
process.send({ msgtype: 'info', info: { message: row.message }, sesid: this.sesid });
}
// this.onRow(this.jslid); // this.onRow(this.jslid);
} }
// error(error) { // error(error) {
@@ -160,10 +244,25 @@ class StreamHandler {
} }
} }
function handleQueryStream(dbhan, driver, queryStreamInfoHolder, sqlItem, sesid = undefined) { function handleQueryStream(
dbhan,
driver,
queryStreamInfoHolder,
sqlItem,
sesid = undefined,
limitRows = undefined,
frontMatter = undefined
) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const start = sqlItem.trimStart || sqlItem.start; const start = sqlItem.trimStart || sqlItem.start;
const handler = new StreamHandler(queryStreamInfoHolder, resolve, start && start.line, sesid); const handler = new StreamHandler(
queryStreamInfoHolder,
resolve,
start && start.line,
sesid,
limitRows,
frontMatter
);
driver.stream(dbhan, sqlItem.text, handler); driver.stream(dbhan, sqlItem.text, handler);
}); });
} }

View File

@@ -87,4 +87,5 @@ module.exports = {
getHardwareFingerprint, getHardwareFingerprint,
getHardwareFingerprintHash, getHardwareFingerprintHash,
getPublicHardwareFingerprint, getPublicHardwareFingerprint,
getPublicIpInfo,
}; };

View File

@@ -6,6 +6,7 @@
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"test": "jest", "test": "jest",
"test:charts": "jest -t \"Chart processor\"",
"test:ci": "jest --json --outputFile=result.json --testLocationInResults", "test:ci": "jest --json --outputFile=result.json --testLocationInResults",
"start": "tsc --watch" "start": "tsc --watch"
}, },
@@ -13,16 +14,17 @@
"lib" "lib"
], ],
"dependencies": { "dependencies": {
"date-fns": "^4.1.0",
"dbgate-filterparser": "^6.0.0-alpha.1",
"dbgate-sqltree": "^6.0.0-alpha.1", "dbgate-sqltree": "^6.0.0-alpha.1",
"dbgate-tools": "^6.0.0-alpha.1", "dbgate-tools": "^6.0.0-alpha.1",
"dbgate-filterparser": "^6.0.0-alpha.1",
"uuid": "^3.4.0" "uuid": "^3.4.0"
}, },
"devDependencies": { "devDependencies": {
"dbgate-types": "^6.0.0-alpha.1",
"@types/node": "^13.7.0", "@types/node": "^13.7.0",
"dbgate-types": "^6.0.0-alpha.1",
"jest": "^28.1.3", "jest": "^28.1.3",
"ts-jest": "^28.0.7", "ts-jest": "^28.0.7",
"typescript": "^4.4.3" "typescript": "^4.4.3"
} }
} }

View File

@@ -0,0 +1,88 @@
export type ChartTypeEnum = 'bar' | 'line' | 'pie' | 'polarArea';
export type ChartXTransformFunction =
| 'identity'
| 'date:minute'
| 'date:hour'
| 'date:day'
| 'date:month'
| 'date:year';
export type ChartYAggregateFunction = 'sum' | 'first' | 'last' | 'min' | 'max' | 'count' | 'avg';
export type ChartDataLabelFormatter = 'number' | 'size:bytes' | 'size:kb' | 'size:mb' | 'size:gb';
export const ChartConstDefaults = {
sortOrder: ' asc',
windowAlign: 'end',
windowSize: 100,
parentAggregateLimit: 200,
};
export const ChartLimits = {
AUTODETECT_CHART_LIMIT: 10, // limit for auto-detecting charts, to avoid too many charts
AUTODETECT_MEASURES_LIMIT: 10, // limit for auto-detecting measures, to avoid too many measures
APPLY_LIMIT_AFTER_ROWS: 100,
MAX_DISTINCT_VALUES: 10, // max number of distinct values to keep in topDistinctValues
VALID_VALUE_RATIO_LIMIT: 0.5, // limit for valid value ratio, y defs below this will not be used in auto-detect
PIE_RATIO_LIMIT: 0.05, // limit for other values in pie chart, if the value is below this, it will be grouped into "Other"
PIE_COUNT_LIMIT: 10, // limit for number of pie chart slices, if the number of slices is above this, it will be grouped into "Other"
};
export interface ChartXFieldDefinition {
field: string;
title?: string;
transformFunction: ChartXTransformFunction;
sortOrder?: 'natural' | 'ascKeys' | 'descKeys' | 'ascValues' | 'descValues';
windowAlign?: 'start' | 'end';
windowSize?: number;
parentAggregateLimit?: number;
}
export interface ChartYFieldDefinition {
field: string;
title?: string;
aggregateFunction: ChartYAggregateFunction;
}
export interface ChartDefinition {
chartType: ChartTypeEnum;
title?: string;
pieRatioLimit?: number; // limit for pie chart, if the value is below this, it will be grouped into "Other"
pieCountLimit?: number; // limit for number of pie chart slices, if the number of slices is above this, it will be grouped into "Other"
xdef: ChartXFieldDefinition;
ydefs: ChartYFieldDefinition[];
useDataLabels?: boolean;
dataLabelFormatter?: ChartDataLabelFormatter;
}
export interface ChartDateParsed {
year: number;
month?: number;
day?: number;
hour?: number;
minute?: number;
second?: number;
fraction?: string;
}
export interface ChartAvailableColumn {
field: string;
}
export interface ProcessedChart {
minX?: string;
maxX?: string;
rowsAdded: number;
buckets: { [key: string]: any }; // key is the bucket key, value is aggregated data
bucketKeysOrdered: string[];
bucketKeyDateParsed: { [key: string]: ChartDateParsed }; // key is the bucket key, value is parsed date
isGivenDefinition: boolean; // true if the chart was created with a given definition, false if it was created from raw data
invalidXRows: number;
invalidYRows: { [key: string]: number }; // key is the y field, value is the count of invalid rows
validYRows: { [key: string]: number }; // key is the field, value is the count of valid rows
topDistinctValues: { [key: string]: Set<any> }; // key is the field, value is the set of distinct values
availableColumns: ChartAvailableColumn[];
definition: ChartDefinition;
}

View File

@@ -0,0 +1,374 @@
import {
ChartAvailableColumn,
ChartDateParsed,
ChartDefinition,
ChartLimits,
ProcessedChart,
} from './chartDefinitions';
import _sortBy from 'lodash/sortBy';
import _sum from 'lodash/sum';
import {
aggregateChartNumericValuesFromSource,
autoAggregateCompactTimelineChart,
computeChartBucketCardinality,
computeChartBucketKey,
fillChartTimelineBuckets,
tryParseChartDate,
} from './chartTools';
import { getChartScore, getChartYFieldScore } from './chartScoring';
export class ChartProcessor {
chartsProcessing: ProcessedChart[] = [];
charts: ProcessedChart[] = [];
availableColumnsDict: { [field: string]: ChartAvailableColumn } = {};
availableColumns: ChartAvailableColumn[] = [];
autoDetectCharts = false;
rowsAdded = 0;
constructor(public givenDefinitions: ChartDefinition[] = []) {
for (const definition of givenDefinitions) {
this.chartsProcessing.push({
definition,
rowsAdded: 0,
bucketKeysOrdered: [],
buckets: {},
bucketKeyDateParsed: {},
isGivenDefinition: true,
invalidXRows: 0,
invalidYRows: {},
availableColumns: [],
validYRows: {},
topDistinctValues: {},
});
}
this.autoDetectCharts = this.givenDefinitions.length == 0;
}
// findOrCreateChart(definition: ChartDefinition, isGivenDefinition: boolean): ProcessedChart {
// const signatureItems = [
// definition.chartType,
// definition.xdef.field,
// definition.xdef.transformFunction,
// definition.ydefs.map(y => y.field).join(','),
// ];
// const signature = signatureItems.join('::');
// if (this.chartsBySignature[signature]) {
// return this.chartsBySignature[signature];
// }
// const chart: ProcessedChart = {
// definition,
// rowsAdded: 0,
// bucketKeysOrdered: [],
// buckets: {},
// bucketKeyDateParsed: {},
// isGivenDefinition,
// };
// this.chartsBySignature[signature] = chart;
// return chart;
// }
addRow(row: any) {
const dateColumns: { [key: string]: ChartDateParsed } = {};
const numericColumns: { [key: string]: number } = {};
const numericColumnsForAutodetect: { [key: string]: number } = {};
const stringColumns: { [key: string]: string } = {};
for (const [key, value] of Object.entries(row)) {
const number: number = typeof value == 'string' ? Number(value) : typeof value == 'number' ? value : NaN;
this.availableColumnsDict[key] = {
field: key,
};
const keyLower = key.toLowerCase();
const keyIsId = keyLower.endsWith('_id') || keyLower == 'id' || key.endsWith('Id');
const parsedDate = tryParseChartDate(value);
if (parsedDate) {
dateColumns[key] = parsedDate;
continue;
}
if (!isNaN(number) && isFinite(number)) {
numericColumns[key] = number;
if (!keyIsId) {
numericColumnsForAutodetect[key] = number; // for auto-detecting charts
}
continue;
}
if (typeof value === 'string' && isNaN(number) && value.length < 100) {
stringColumns[key] = value;
}
}
// const sortedNumericColumnns = Object.keys(numericColumns).sort();
if (this.autoDetectCharts) {
// create charts from data, if there are no given definitions
for (const datecol in dateColumns) {
let usedChart = this.chartsProcessing.find(
chart =>
!chart.isGivenDefinition &&
chart.definition.xdef.field === datecol &&
chart.definition.xdef.transformFunction?.startsWith('date:')
);
if (
!usedChart &&
(this.rowsAdded < ChartLimits.APPLY_LIMIT_AFTER_ROWS ||
this.chartsProcessing.length < ChartLimits.AUTODETECT_CHART_LIMIT)
) {
usedChart = {
definition: {
chartType: 'line',
xdef: {
field: datecol,
transformFunction: 'date:day',
},
ydefs: [],
},
rowsAdded: 0,
bucketKeysOrdered: [],
buckets: {},
bucketKeyDateParsed: {},
isGivenDefinition: false,
invalidXRows: 0,
invalidYRows: {},
availableColumns: [],
validYRows: {},
topDistinctValues: {},
};
this.chartsProcessing.push(usedChart);
}
for (const [key, value] of Object.entries(row)) {
if (value == null) continue;
if (key == datecol) continue; // skip date column itself
let existingYDef = usedChart.definition.ydefs.find(y => y.field === key);
if (
!existingYDef &&
(this.rowsAdded < ChartLimits.APPLY_LIMIT_AFTER_ROWS ||
usedChart.definition.ydefs.length < ChartLimits.AUTODETECT_MEASURES_LIMIT)
) {
existingYDef = {
field: key,
aggregateFunction: 'sum',
};
usedChart.definition.ydefs.push(existingYDef);
}
}
}
}
// apply on all charts with this date column
for (const chart of this.chartsProcessing) {
this.applyRawData(
chart,
row,
dateColumns[chart.definition.xdef.field],
chart.isGivenDefinition ? numericColumns : numericColumnsForAutodetect,
stringColumns
);
}
for (let i = 0; i < this.chartsProcessing.length; i++) {
this.chartsProcessing[i] = autoAggregateCompactTimelineChart(this.chartsProcessing[i]);
}
this.rowsAdded += 1;
if (this.rowsAdded == ChartLimits.APPLY_LIMIT_AFTER_ROWS) {
this.applyLimitsOnCharts();
}
}
applyLimitsOnCharts() {
const autodetectProcessingCharts = this.chartsProcessing.filter(chart => !chart.isGivenDefinition);
if (autodetectProcessingCharts.length > ChartLimits.AUTODETECT_CHART_LIMIT) {
const newAutodetectProcessingCharts = _sortBy(
this.chartsProcessing.slice(0, ChartLimits.AUTODETECT_CHART_LIMIT),
chart => -getChartScore(chart)
);
for (const chart of autodetectProcessingCharts) {
chart.definition.ydefs = _sortBy(chart.definition.ydefs, yfield => -getChartYFieldScore(chart, yfield)).slice(
0,
ChartLimits.AUTODETECT_MEASURES_LIMIT
);
}
this.chartsProcessing = [
...this.chartsProcessing.filter(chart => chart.isGivenDefinition),
...newAutodetectProcessingCharts,
];
}
}
addRows(...rows: any[]) {
for (const row of rows) {
this.addRow(row);
}
}
finalize() {
this.applyLimitsOnCharts();
this.availableColumns = Object.values(this.availableColumnsDict);
for (const chart of this.chartsProcessing) {
let addedChart: ProcessedChart = chart;
if (chart.rowsAdded == 0) {
continue; // skip empty charts
}
const sortOrder = chart.definition.xdef.sortOrder ?? 'ascKeys';
if (sortOrder != 'natural') {
if (sortOrder == 'ascKeys' || sortOrder == 'descKeys') {
if (chart.definition.xdef.transformFunction.startsWith('date:')) {
addedChart = autoAggregateCompactTimelineChart(addedChart);
fillChartTimelineBuckets(addedChart);
}
addedChart.bucketKeysOrdered = _sortBy(Object.keys(addedChart.buckets));
if (sortOrder == 'descKeys') {
addedChart.bucketKeysOrdered.reverse();
}
}
if (sortOrder == 'ascValues' || sortOrder == 'descValues') {
addedChart.bucketKeysOrdered = _sortBy(Object.keys(addedChart.buckets), key =>
computeChartBucketCardinality(addedChart.buckets[key])
);
if (sortOrder == 'descValues') {
addedChart.bucketKeysOrdered.reverse();
}
}
}
if (!addedChart.isGivenDefinition) {
addedChart = {
...addedChart,
definition: {
...addedChart.definition,
ydefs: addedChart.definition.ydefs.filter(
y =>
!addedChart.invalidYRows[y.field] &&
addedChart.validYRows[y.field] / addedChart.rowsAdded >= ChartLimits.VALID_VALUE_RATIO_LIMIT
),
},
};
}
if (addedChart) {
addedChart.availableColumns = this.availableColumns;
this.charts.push(addedChart);
}
this.groupPieOtherBuckets(addedChart);
}
this.charts = [
...this.charts.filter(x => x.isGivenDefinition),
..._sortBy(
this.charts.filter(x => !x.isGivenDefinition),
chart => -getChartScore(chart)
),
];
}
groupPieOtherBuckets(chart: ProcessedChart) {
if (chart.definition.chartType !== 'pie') {
return; // only for pie charts
}
const ratioLimit = chart.definition.pieRatioLimit ?? ChartLimits.PIE_RATIO_LIMIT;
const countLimit = chart.definition.pieCountLimit ?? ChartLimits.PIE_COUNT_LIMIT;
if (ratioLimit == 0 && countLimit == 0) {
return; // no grouping if limit is 0
}
const otherBucket: any = {};
let newBuckets: any = {};
const cardSum = _sum(Object.values(chart.buckets).map(bucket => computeChartBucketCardinality(bucket)));
if (cardSum == 0) {
return; // no buckets to process
}
for (const [bucketKey, bucket] of Object.entries(chart.buckets)) {
if (computeChartBucketCardinality(bucket) / cardSum < ratioLimit) {
for (const field in bucket) {
otherBucket[field] = (otherBucket[field] ?? 0) + bucket[field];
}
} else {
newBuckets[bucketKey] = bucket;
}
}
if (Object.keys(newBuckets).length > countLimit) {
const sortedBucketKeys = _sortBy(
Object.entries(newBuckets),
([, bucket]) => -computeChartBucketCardinality(bucket)
).map(([key]) => key);
const newBuckets2 = {};
sortedBucketKeys.forEach((key, index) => {
if (index < countLimit) {
newBuckets2[key] = newBuckets[key];
} else {
for (const field in newBuckets[key]) {
otherBucket[field] = (otherBucket[field] ?? 0) + newBuckets[key][field];
}
}
});
newBuckets = newBuckets2;
}
if (Object.keys(otherBucket).length > 0) {
newBuckets['Other'] = otherBucket;
}
chart.buckets = newBuckets;
chart.bucketKeysOrdered = [...chart.bucketKeysOrdered, 'Other'].filter(key => key in newBuckets);
}
applyRawData(
chart: ProcessedChart,
row: any,
dateParsed: ChartDateParsed,
numericColumns: { [key: string]: number },
stringColumns: { [key: string]: string }
) {
if (chart.definition.xdef == null) {
return;
}
if (row[chart.definition.xdef.field] == null) {
return;
}
if (dateParsed == null && chart.definition.xdef.transformFunction.startsWith('date:')) {
chart.invalidXRows += 1;
return; // skip if date is invalid
}
const [bucketKey, bucketKeyParsed] = computeChartBucketKey(dateParsed, chart, row);
if (!bucketKey) {
return; // skip if no bucket key
}
if (bucketKeyParsed) {
chart.bucketKeyDateParsed[bucketKey] = bucketKeyParsed;
}
if (chart.minX == null || bucketKey < chart.minX) {
chart.minX = bucketKey;
}
if (chart.maxX == null || bucketKey > chart.maxX) {
chart.maxX = bucketKey;
}
if (!chart.buckets[bucketKey]) {
chart.buckets[bucketKey] = {};
if (chart.definition.xdef.sortOrder == 'natural') {
chart.bucketKeysOrdered.push(bucketKey);
}
}
aggregateChartNumericValuesFromSource(chart, bucketKey, numericColumns, row);
chart.rowsAdded += 1;
}
}

View File

@@ -0,0 +1,23 @@
import _sortBy from 'lodash/sortBy';
import _sum from 'lodash/sum';
import { ChartLimits, ChartYFieldDefinition, ProcessedChart } from './chartDefinitions';
export function getChartScore(chart: ProcessedChart): number {
let res = 0;
res += chart.rowsAdded * 5;
const ydefScores = chart.definition.ydefs.map(yField => getChartYFieldScore(chart, yField));
const sorted = _sortBy(ydefScores).reverse();
res += _sum(sorted.slice(0, ChartLimits.AUTODETECT_MEASURES_LIMIT));
return res;
}
export function getChartYFieldScore(chart: ProcessedChart, yField: ChartYFieldDefinition): number {
let res = 0;
res += chart.validYRows[yField.field] * 5; // score for valid Y rows
res += (chart.topDistinctValues[yField.field]?.size ?? 0) * 20; // score for distinct values in Y field
res += chart.rowsAdded * 2; // base score for rows added
res -= (chart.invalidYRows[yField.field] ?? 0) * 50; // penalty for invalid Y rows
return res;
}

View File

@@ -0,0 +1,542 @@
import _toPairs from 'lodash/toPairs';
import _sumBy from 'lodash/sumBy';
import {
ChartConstDefaults,
ChartDateParsed,
ChartLimits,
ChartXTransformFunction,
ProcessedChart,
} from './chartDefinitions';
import { addMinutes, addHours, addDays, addMonths, addYears } from 'date-fns';
export function getChartDebugPrint(chart: ProcessedChart) {
let res = '';
res += `Chart: ${chart.definition.chartType} (${chart.definition.xdef.transformFunction})\n`;
for (const key of chart.bucketKeysOrdered) {
res += `${key}: ${_toPairs(chart.buckets[key])
.map(([k, v]) => `${k}=${v}`)
.join(', ')}\n`;
}
return res;
}
export function tryParseChartDate(dateInput: any): ChartDateParsed | null {
if (dateInput instanceof Date) {
return {
year: dateInput.getFullYear(),
month: dateInput.getMonth() + 1,
day: dateInput.getDate(),
hour: dateInput.getHours(),
minute: dateInput.getMinutes(),
second: dateInput.getSeconds(),
fraction: undefined, // Date object does not have fraction
};
}
if (typeof dateInput !== 'string') return null;
const m = dateInput.match(
/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:\d{2})?)?$/
);
if (!m) return null;
const [_notUsed, year, month, day, hour, minute, second, fraction] = m;
return {
year: parseInt(year, 10),
month: parseInt(month, 10),
day: parseInt(day, 10),
hour: parseInt(hour, 10) || 0,
minute: parseInt(minute, 10) || 0,
second: parseInt(second, 10) || 0,
fraction: fraction || undefined,
};
}
function pad2Digits(number) {
return ('00' + number).slice(-2);
}
export function stringifyChartDate(value: ChartDateParsed, transform: ChartXTransformFunction): string {
switch (transform) {
case 'date:year':
return `${value.year}`;
case 'date:month':
return `${value.year}-${pad2Digits(value.month)}`;
case 'date:day':
return `${value.year}-${pad2Digits(value.month)}-${pad2Digits(value.day)}`;
case 'date:hour':
return `${value.year}-${pad2Digits(value.month)}-${pad2Digits(value.day)} ${pad2Digits(value.hour)}`;
case 'date:minute':
return `${value.year}-${pad2Digits(value.month)}-${pad2Digits(value.day)} ${pad2Digits(value.hour)}:${pad2Digits(
value.minute
)}`;
default:
return '';
}
}
export function incrementChartDate(value: ChartDateParsed, transform: ChartXTransformFunction): ChartDateParsed {
const dateRepresentation = new Date(
value.year,
(value.month ?? 1) - 1,
value.day ?? 1,
value.hour ?? 0,
value.minute ?? 0
);
let newDateRepresentation: Date;
switch (transform) {
case 'date:year':
newDateRepresentation = addYears(dateRepresentation, 1);
break;
case 'date:month':
newDateRepresentation = addMonths(dateRepresentation, 1);
break;
case 'date:day':
newDateRepresentation = addDays(dateRepresentation, 1);
break;
case 'date:hour':
newDateRepresentation = addHours(dateRepresentation, 1);
break;
case 'date:minute':
newDateRepresentation = addMinutes(dateRepresentation, 1);
break;
}
switch (transform) {
case 'date:year':
return { year: newDateRepresentation.getFullYear() };
case 'date:month':
return {
year: newDateRepresentation.getFullYear(),
month: newDateRepresentation.getMonth() + 1,
};
case 'date:day':
return {
year: newDateRepresentation.getFullYear(),
month: newDateRepresentation.getMonth() + 1,
day: newDateRepresentation.getDate(),
};
case 'date:hour':
return {
year: newDateRepresentation.getFullYear(),
month: newDateRepresentation.getMonth() + 1,
day: newDateRepresentation.getDate(),
hour: newDateRepresentation.getHours(),
};
case 'date:minute':
return {
year: newDateRepresentation.getFullYear(),
month: newDateRepresentation.getMonth() + 1,
day: newDateRepresentation.getDate(),
hour: newDateRepresentation.getHours(),
minute: newDateRepresentation.getMinutes(),
};
}
}
export function computeChartBucketKey(
dateParsed: ChartDateParsed,
chart: ProcessedChart,
row: any
): [string, ChartDateParsed] {
switch (chart.definition.xdef.transformFunction) {
case 'date:year':
return [dateParsed ? `${dateParsed.year}` : null, { year: dateParsed.year }];
case 'date:month':
return [
dateParsed ? `${dateParsed.year}-${pad2Digits(dateParsed.month)}` : null,
{
year: dateParsed.year,
month: dateParsed.month,
},
];
case 'date:day':
return [
dateParsed ? `${dateParsed.year}-${pad2Digits(dateParsed.month)}-${pad2Digits(dateParsed.day)}` : null,
{
year: dateParsed.year,
month: dateParsed.month,
day: dateParsed.day,
},
];
case 'date:hour':
return [
dateParsed
? `${dateParsed.year}-${pad2Digits(dateParsed.month)}-${pad2Digits(dateParsed.day)} ${pad2Digits(
dateParsed.hour
)}`
: null,
{
year: dateParsed.year,
month: dateParsed.month,
day: dateParsed.day,
hour: dateParsed.hour,
},
];
case 'date:minute':
return [
dateParsed
? `${dateParsed.year}-${pad2Digits(dateParsed.month)}-${pad2Digits(dateParsed.day)} ${pad2Digits(
dateParsed.hour
)}:${pad2Digits(dateParsed.minute)}`
: null,
{
year: dateParsed.year,
month: dateParsed.month,
day: dateParsed.day,
hour: dateParsed.hour,
minute: dateParsed.minute,
},
];
case 'identity':
default:
return [row[chart.definition.xdef.field], null];
}
}
export function computeDateBucketDistance(
begin: ChartDateParsed,
end: ChartDateParsed,
transform: ChartXTransformFunction
): number {
switch (transform) {
case 'date:year':
return end.year - begin.year;
case 'date:month':
return (end.year - begin.year) * 12 + (end.month - begin.month);
case 'date:day':
return (
(end.year - begin.year) * 365 +
(end.month - begin.month) * 30 + // rough approximation
(end.day - begin.day)
);
case 'date:hour':
return (
(end.year - begin.year) * 365 * 24 +
(end.month - begin.month) * 30 * 24 + // rough approximation
(end.day - begin.day) * 24 +
(end.hour - begin.hour)
);
case 'date:minute':
return (
(end.year - begin.year) * 365 * 24 * 60 +
(end.month - begin.month) * 30 * 24 * 60 + // rough approximation
(end.day - begin.day) * 24 * 60 +
(end.hour - begin.hour) * 60 +
(end.minute - begin.minute)
);
case 'identity':
default:
return NaN;
}
}
export function compareChartDatesParsed(
a: ChartDateParsed,
b: ChartDateParsed,
transform: ChartXTransformFunction
): number {
switch (transform) {
case 'date:year':
return a.year - b.year;
case 'date:month':
return a.year === b.year ? a.month - b.month : a.year - b.year;
case 'date:day':
return a.year === b.year && a.month === b.month
? a.day - b.day
: a.year === b.year
? a.month - b.month
: a.year - b.year;
case 'date:hour':
return a.year === b.year && a.month === b.month && a.day === b.day
? a.hour - b.hour
: a.year === b.year && a.month === b.month
? a.day - b.day
: a.year === b.year
? a.month - b.month
: a.year - b.year;
case 'date:minute':
return a.year === b.year && a.month === b.month && a.day === b.day && a.hour === b.hour
? a.minute - b.minute
: a.year === b.year && a.month === b.month && a.day === b.day
? a.hour - b.hour
: a.year === b.year && a.month === b.month
? a.day - b.day
: a.year === b.year
? a.month - b.month
: a.year - b.year;
}
}
function getParentDateBucketKey(bucketKey: string, transform: ChartXTransformFunction): string | null {
switch (transform) {
case 'date:year':
return null; // no parent for year
case 'date:month':
return bucketKey.slice(0, 4);
case 'date:day':
return bucketKey.slice(0, 7);
case 'date:hour':
return bucketKey.slice(0, 10);
case 'date:minute':
return bucketKey.slice(0, 13);
}
}
function getParentDateBucketTransform(transform: ChartXTransformFunction): ChartXTransformFunction | null {
switch (transform) {
case 'date:year':
return null; // no parent for year
case 'date:month':
return 'date:year';
case 'date:day':
return 'date:month';
case 'date:hour':
return 'date:day';
case 'date:minute':
return 'date:hour';
default:
return null;
}
}
function getParentKeyParsed(date: ChartDateParsed, transform: ChartXTransformFunction): ChartDateParsed | null {
switch (transform) {
case 'date:year':
return null; // no parent for year
case 'date:month':
return { year: date.year };
case 'date:day':
return { year: date.year, month: date.month };
case 'date:hour':
return { year: date.year, month: date.month, day: date.day };
case 'date:minute':
return { year: date.year, month: date.month, day: date.day, hour: date.hour };
default:
return null;
}
}
function createParentChartAggregation(chart: ProcessedChart): ProcessedChart | null {
if (chart.isGivenDefinition) {
// if the chart is created with a given definition, we cannot create a parent aggregation
return null;
}
const parentTransform = getParentDateBucketTransform(chart.definition.xdef.transformFunction);
if (!parentTransform) {
return null;
}
const res: ProcessedChart = {
definition: {
...chart.definition,
xdef: {
...chart.definition.xdef,
transformFunction: parentTransform,
},
},
rowsAdded: chart.rowsAdded,
bucketKeysOrdered: [],
buckets: {},
bucketKeyDateParsed: {},
isGivenDefinition: false,
invalidXRows: chart.invalidXRows,
invalidYRows: { ...chart.invalidYRows }, // copy invalid Y rows
validYRows: { ...chart.validYRows }, // copy valid Y rows
topDistinctValues: { ...chart.topDistinctValues }, // copy top distinct values
availableColumns: chart.availableColumns,
};
for (const [bucketKey, bucketValues] of Object.entries(chart.buckets)) {
const parentKey = getParentDateBucketKey(bucketKey, chart.definition.xdef.transformFunction);
if (!parentKey) {
// skip if the bucket is already a parent
continue;
}
res.bucketKeyDateParsed[parentKey] = getParentKeyParsed(
chart.bucketKeyDateParsed[bucketKey],
chart.definition.xdef.transformFunction
);
aggregateChartNumericValuesFromChild(res, parentKey, bucketValues);
}
const bucketKeys = Object.keys(res.buckets).sort();
res.minX = bucketKeys.length > 0 ? bucketKeys[0] : null;
res.maxX = bucketKeys.length > 0 ? bucketKeys[bucketKeys.length - 1] : null;
return res;
}
export function autoAggregateCompactTimelineChart(chart: ProcessedChart) {
while (true) {
const fromParsed = chart.bucketKeyDateParsed[chart.minX];
const toParsed = chart.bucketKeyDateParsed[chart.maxX];
if (!fromParsed || !toParsed) {
return chart; // cannot fill timeline buckets without valid date range
}
const transform = chart.definition.xdef.transformFunction;
if (!transform.startsWith('date:')) {
return chart; // cannot aggregate non-date charts
}
const dateDistance = computeDateBucketDistance(fromParsed, toParsed, transform);
if (dateDistance < (chart.definition.xdef.parentAggregateLimit ?? ChartConstDefaults.parentAggregateLimit)) {
return chart; // no need to aggregate further, the distance is less than the limit
}
const parentChart = createParentChartAggregation(chart);
if (!parentChart) {
return chart; // cannot create parent aggregation
}
chart = parentChart;
}
}
export function aggregateChartNumericValuesFromSource(
chart: ProcessedChart,
bucketKey: string,
numericColumns: { [key: string]: number },
row: any
) {
for (const ydef of chart.definition.ydefs) {
if (numericColumns[ydef.field] == null) {
if (row[ydef.field]) {
chart.invalidYRows[ydef.field] = (chart.invalidYRows[ydef.field] || 0) + 1; // increment invalid row count if the field is not numeric
}
continue;
}
chart.validYRows[ydef.field] = (chart.validYRows[ydef.field] || 0) + 1; // increment valid row count
let distinctValues = chart.topDistinctValues[ydef.field];
if (!distinctValues) {
distinctValues = new Set();
chart.topDistinctValues[ydef.field] = distinctValues;
}
if (distinctValues.size < ChartLimits.MAX_DISTINCT_VALUES) {
chart.topDistinctValues[ydef.field].add(numericColumns[ydef.field]);
}
switch (ydef.aggregateFunction) {
case 'sum':
chart.buckets[bucketKey][ydef.field] =
(chart.buckets[bucketKey][ydef.field] || 0) + (numericColumns[ydef.field] || 0);
break;
case 'first':
if (chart.buckets[bucketKey][ydef.field] === undefined) {
chart.buckets[bucketKey][ydef.field] = numericColumns[ydef.field];
}
break;
case 'last':
chart.buckets[bucketKey][ydef.field] = numericColumns[ydef.field];
break;
case 'min':
if (chart.buckets[bucketKey][ydef.field] === undefined) {
chart.buckets[bucketKey][ydef.field] = numericColumns[ydef.field];
} else {
chart.buckets[bucketKey][ydef.field] = Math.min(
chart.buckets[bucketKey][ydef.field],
numericColumns[ydef.field]
);
}
break;
case 'max':
if (chart.buckets[bucketKey][ydef.field] === undefined) {
chart.buckets[bucketKey][ydef.field] = numericColumns[ydef.field];
} else {
chart.buckets[bucketKey][ydef.field] = Math.max(
chart.buckets[bucketKey][ydef.field],
numericColumns[ydef.field]
);
}
break;
case 'count':
chart.buckets[bucketKey][ydef.field] = (chart.buckets[bucketKey][ydef.field] || 0) + 1;
break;
case 'avg':
if (chart.buckets[bucketKey][ydef.field] === undefined) {
chart.buckets[bucketKey][ydef.field] = [numericColumns[ydef.field], 1]; // [sum, count]
} else {
chart.buckets[bucketKey][ydef.field][0] += numericColumns[ydef.field];
chart.buckets[bucketKey][ydef.field][1] += 1;
}
break;
}
}
}
export function aggregateChartNumericValuesFromChild(
chart: ProcessedChart,
bucketKey: string,
childBucketValues: { [key: string]: any }
) {
for (const ydef of chart.definition.ydefs) {
if (childBucketValues[ydef.field] == undefined) {
continue; // skip if the field is not present in the child bucket
}
if (!chart.buckets[bucketKey]) {
chart.buckets[bucketKey] = {};
}
switch (ydef.aggregateFunction) {
case 'sum':
case 'count':
chart.buckets[bucketKey][ydef.field] =
(chart.buckets[bucketKey][ydef.field] || 0) + (childBucketValues[ydef.field] || 0);
break;
case 'min':
if (chart.buckets[bucketKey][ydef.field] === undefined) {
chart.buckets[bucketKey][ydef.field] = childBucketValues[ydef.field];
} else {
chart.buckets[bucketKey][ydef.field] = Math.min(
chart.buckets[bucketKey][ydef.field],
childBucketValues[ydef.field]
);
}
break;
case 'max':
if (chart.buckets[bucketKey][ydef.field] === undefined) {
chart.buckets[bucketKey][ydef.field] = childBucketValues[ydef.field];
} else {
chart.buckets[bucketKey][ydef.field] = Math.max(
chart.buckets[bucketKey][ydef.field],
childBucketValues[ydef.field]
);
}
break;
case 'avg':
if (chart.buckets[bucketKey][ydef.field] === undefined) {
chart.buckets[bucketKey][ydef.field] = childBucketValues[ydef.field];
} else {
chart.buckets[bucketKey][ydef.field][0] += childBucketValues[ydef.field][0];
chart.buckets[bucketKey][ydef.field][1] += childBucketValues[ydef.field][1];
}
break;
case 'first':
case 'last':
throw new Error(`Cannot aggregate ${ydef.aggregateFunction} for ${ydef.field} in child bucket`);
}
}
}
export function fillChartTimelineBuckets(chart: ProcessedChart) {
const fromParsed = chart.bucketKeyDateParsed[chart.minX];
const toParsed = chart.bucketKeyDateParsed[chart.maxX];
if (!fromParsed || !toParsed) {
return; // cannot fill timeline buckets without valid date range
}
const transform = chart.definition.xdef.transformFunction;
let currentParsed = fromParsed;
while (compareChartDatesParsed(currentParsed, toParsed, transform) <= 0) {
const bucketKey = stringifyChartDate(currentParsed, transform);
if (!chart.buckets[bucketKey]) {
chart.buckets[bucketKey] = {};
chart.bucketKeyDateParsed[bucketKey] = currentParsed;
}
currentParsed = incrementChartDate(currentParsed, transform);
}
}
export function computeChartBucketCardinality(bucket: { [key: string]: any }): number {
return _sumBy(Object.keys(bucket), field => bucket[field]);
}

View File

@@ -23,3 +23,5 @@ export * from './FreeTableGridDisplay';
export * from './FreeTableModel'; export * from './FreeTableModel';
export * from './CustomGridDisplay'; export * from './CustomGridDisplay';
export * from './ScriptDrivedDeployer'; export * from './ScriptDrivedDeployer';
export * from './chartDefinitions';
export * from './chartProcessor';

View File

@@ -0,0 +1,376 @@
import exp from 'constants';
import { ChartProcessor } from '../chartProcessor';
import { getChartDebugPrint } from '../chartTools';
const DS1 = [
{
timestamp: '2023-10-01T12:00:00Z',
value: 42.5,
category: 'B',
related_id: 12,
},
{
timestamp: '2023-10-02T10:05:00Z',
value: 12,
category: 'A',
related_id: 13,
},
{
timestamp: '2023-10-03T07:10:00Z',
value: 57,
category: 'A',
related_id: 5,
},
{
timestamp: '2024-08-03T07:10:00Z',
value: 33,
category: 'B',
related_id: 22,
},
];
const DS2 = [
{
ts1: '2023-10-01T12:00:00Z',
ts2: '2024-10-01T12:00:00Z',
dummy1: 1,
dummy2: 1,
dummy3: 1,
dummy4: 1,
dummy5: 1,
dummy6: 1,
dummy7: 1,
dummy8: 1,
dummy9: 1,
dummy10: 1,
price1: '11',
price2: '22',
},
{
ts1: '2023-10-02T10:05:00Z',
ts2: '2024-10-02T10:05:00Z',
price1: '12',
price2: '23',
},
{
ts1: '2023-10-03T07:10:00Z',
ts2: '2024-10-03T07:10:00Z',
price1: '13',
price2: '24',
},
{
ts1: '2023-11-04T12:00:00Z',
ts2: '2024-11-04T12:00:00Z',
price1: 1,
price2: 2,
},
];
const DS3 = [
{
timestamp: '2023-10-01T12:00:00Z',
value: 42.5,
bitval: true,
},
{
timestamp: '2023-10-02T10:05:00Z',
value: 12,
bitval: false,
},
{
timestamp: '2023-10-03T07:10:00Z',
value: 57,
bitval: null,
},
];
const DS4 = [
{
object_id: 710293590,
ObjectName: 'Journal',
Total_Reserved_kb: '68696',
RowsCount: '405452',
},
{
object_id: 182291709,
ObjectName: 'Employee',
Total_Reserved_kb: '732008',
RowsCount: '1980067',
},
{
object_id: 23432525,
ObjectName: 'User',
Total_Reserved_kb: '325352',
RowsCount: '2233',
},
{
object_id: 4985159,
ObjectName: 'Project',
Total_Reserved_kb: '293523',
RowsCount: '1122',
},
];
describe('Chart processor', () => {
test('Simple by day test, autodetected', () => {
const processor = new ChartProcessor();
processor.addRows(...DS1.slice(0, 3));
processor.finalize();
expect(processor.charts.length).toEqual(1);
const chart = processor.charts[0];
expect(chart.definition.xdef.transformFunction).toEqual('date:day');
expect(chart.definition.ydefs).toEqual([
expect.objectContaining({
field: 'value',
}),
]);
expect(chart.bucketKeysOrdered).toEqual(['2023-10-01', '2023-10-02', '2023-10-03']);
});
test('By month grouped, autedetected', () => {
const processor = new ChartProcessor();
processor.addRows(...DS1.slice(0, 4));
processor.finalize();
expect(processor.charts.length).toEqual(1);
const chart = processor.charts[0];
expect(chart.definition.xdef.transformFunction).toEqual('date:month');
expect(chart.bucketKeysOrdered).toEqual([
'2023-10',
'2023-11',
'2023-12',
'2024-01',
'2024-02',
'2024-03',
'2024-04',
'2024-05',
'2024-06',
'2024-07',
'2024-08',
]);
});
test('Detect columns', () => {
const processor = new ChartProcessor();
processor.autoDetectCharts = false;
processor.addRows(...DS1);
processor.finalize();
expect(processor.charts.length).toEqual(0);
expect(processor.availableColumns).toEqual([
expect.objectContaining({
field: 'timestamp',
}),
expect.objectContaining({
field: 'value',
}),
expect.objectContaining({
field: 'category',
}),
expect.objectContaining({
field: 'related_id',
}),
]);
});
test('Explicit definition', () => {
const processor = new ChartProcessor([
{
chartType: 'pie',
xdef: {
field: 'category',
transformFunction: 'identity',
sortOrder: 'natural',
},
ydefs: [
{
field: 'related_id',
aggregateFunction: 'sum',
},
],
},
]);
processor.addRows(...DS1);
processor.finalize();
expect(processor.charts.length).toEqual(1);
const chart = processor.charts[0];
expect(chart.definition.xdef.transformFunction).toEqual('identity');
expect(chart.bucketKeysOrdered).toEqual(['B', 'A']);
expect(chart.buckets).toEqual({
B: { related_id: 34 },
A: { related_id: 18 },
});
});
test('Two data sets with different date columns', () => {
const processor = new ChartProcessor();
processor.addRows(...DS2);
processor.finalize();
expect(processor.charts.length).toEqual(2);
expect(processor.charts[0].definition).toEqual(
expect.objectContaining({
xdef: expect.objectContaining({
field: 'ts1',
transformFunction: 'date:day',
}),
ydefs: [
expect.objectContaining({
field: 'price1',
aggregateFunction: 'sum',
}),
expect.objectContaining({
field: 'price2',
aggregateFunction: 'sum',
}),
],
})
);
expect(processor.charts[1].definition).toEqual(
expect.objectContaining({
xdef: expect.objectContaining({
field: 'ts2',
transformFunction: 'date:day',
}),
ydefs: [
expect.objectContaining({
field: 'price1',
aggregateFunction: 'sum',
}),
expect.objectContaining({
field: 'price2',
aggregateFunction: 'sum',
}),
],
})
);
});
test('Exclude boolean fields in autodetected', () => {
const processor = new ChartProcessor();
processor.addRows(...DS3);
processor.finalize();
expect(processor.charts.length).toEqual(1);
const chart = processor.charts[0];
expect(chart.definition.xdef.transformFunction).toEqual('date:day');
expect(chart.definition.ydefs).toEqual([
expect.objectContaining({
field: 'value',
}),
]);
});
test('Added field manual from GUI', () => {
const processor = new ChartProcessor([
{
chartType: 'bar',
xdef: {
field: 'object_id',
transformFunction: 'identity',
},
ydefs: [
{
field: 'object_id',
aggregateFunction: 'sum',
},
],
},
]);
processor.addRows(...DS4);
processor.finalize();
expect(processor.charts.length).toEqual(1);
const chart = processor.charts[0];
expect(chart.definition.xdef.transformFunction).toEqual('identity');
expect(chart.definition.ydefs).toEqual([
expect.objectContaining({
field: 'object_id',
aggregateFunction: 'sum',
}),
]);
});
const PieMainTestData = [
['natural', ['Journal', 'Employee', 'User', 'Project']],
['ascKeys', ['Employee', 'Journal', 'Project', 'User']],
['descKeys', ['User', 'Project', 'Journal', 'Employee']],
['ascValues', ['Project', 'User', 'Journal', 'Employee']],
['descValues', ['Employee', 'Journal', 'User', 'Project']],
];
test.each(PieMainTestData)('Pie chart - used space for DB objects (%s)', (sortOrder, expectedOrder) => {
const processor = new ChartProcessor([
{
chartType: 'bar',
xdef: {
field: 'ObjectName',
transformFunction: 'identity',
sortOrder: sortOrder as any,
},
ydefs: [
{
field: 'RowsCount',
aggregateFunction: 'sum',
},
],
},
]);
processor.addRows(...DS4);
processor.finalize();
expect(processor.charts.length).toEqual(1);
const chart = processor.charts[0];
expect(chart.bucketKeysOrdered).toEqual(expectedOrder);
expect(chart.buckets).toEqual({
Employee: { RowsCount: 1980067 },
Journal: { RowsCount: 405452 },
Project: { RowsCount: 1122 },
User: { RowsCount: 2233 },
});
});
const PieOtherTestData = [
[
'ratio',
0.1,
5,
['Employee', 'Journal', 'Other'],
{
Employee: { RowsCount: 1980067 },
Journal: { RowsCount: 405452 },
Other: { RowsCount: 3355 },
},
],
[
'count',
0,
1,
['Employee', 'Other'],
{
Employee: { RowsCount: 1980067 },
Other: { RowsCount: 408807 },
},
],
];
test.each(PieOtherTestData)(
'Pie limit test - %s',
(_description, pieRatioLimit, pieCountLimit, expectedOrder, expectedBuckets) => {
const processor = new ChartProcessor([
{
chartType: 'pie',
pieRatioLimit: pieRatioLimit as number,
pieCountLimit: pieCountLimit as number,
xdef: {
field: 'ObjectName',
transformFunction: 'identity',
},
ydefs: [
{
field: 'RowsCount',
aggregateFunction: 'sum',
},
],
},
]);
processor.addRows(...DS4);
processor.finalize();
expect(processor.charts.length).toEqual(1);
const chart = processor.charts[0];
expect(chart.bucketKeysOrdered).toEqual(expectedOrder);
expect(chart.buckets).toEqual(expectedBuckets);
}
);
});

View File

@@ -20,6 +20,7 @@ export function getFilterValueExpression(value, dataType?) {
if (value === true) return 'TRUE'; if (value === true) return 'TRUE';
if (value === false) return 'FALSE'; if (value === false) return 'FALSE';
if (value.$oid) return `ObjectId("${value.$oid}")`; if (value.$oid) return `ObjectId("${value.$oid}")`;
if (value.$bigint) return value.$bigint;
if (value.type == 'Buffer' && Array.isArray(value.data)) { if (value.type == 'Buffer' && Array.isArray(value.data)) {
return '0x' + arrayToHexString(value.data); return '0x' + arrayToHexString(value.data);
} }

View File

@@ -2,14 +2,18 @@ import P from 'parsimmon';
import moment from 'moment'; import moment from 'moment';
import { Condition } from 'dbgate-sqltree'; import { Condition } from 'dbgate-sqltree';
import { interpretEscapes, token, word, whitespace } from './common'; import { interpretEscapes, token, word, whitespace } from './common';
import { hexStringToArray } from 'dbgate-tools'; import { hexStringToArray, parseNumberSafe } from 'dbgate-tools';
import { FilterBehaviour, TransformType } from 'dbgate-types'; import { FilterBehaviour, TransformType } from 'dbgate-types';
const binaryCondition = const binaryCondition =
(operator, numberDualTesting = false) => (operator, numberDualTesting = false) =>
value => { value => {
const numValue = parseFloat(value); const numValue = parseNumberSafe(value);
if (numberDualTesting && !isNaN(numValue)) { if (
numberDualTesting &&
// @ts-ignore
!isNaN(numValue)
) {
return { return {
conditionType: 'or', conditionType: 'or',
conditions: [ conditions: [
@@ -52,6 +56,18 @@ const binaryCondition =
}; };
}; };
const simpleEqualCondition = () => value => ({
conditionType: 'binary',
operator: '=',
left: {
exprType: 'placeholder',
},
right: {
exprType: 'value',
value,
},
});
const likeCondition = (conditionType, likeString) => value => ({ const likeCondition = (conditionType, likeString) => value => ({
conditionType, conditionType,
left: { left: {
@@ -333,21 +349,23 @@ const createParser = (filterBehaviour: FilterBehaviour) => {
string1Num: () => string1Num: () =>
token(P.regexp(/"-?(0|[1-9][0-9]*)([.][0-9]+)?([eE][+-]?[0-9]+)?"/, 1)) token(P.regexp(/"-?(0|[1-9][0-9]*)([.][0-9]+)?([eE][+-]?[0-9]+)?"/, 1))
.map(Number) .map(parseNumberSafe)
.desc('numer quoted'), .desc('numer quoted'),
string2Num: () => string2Num: () =>
token(P.regexp(/'-?(0|[1-9][0-9]*)([.][0-9]+)?([eE][+-]?[0-9]+)?'/, 1)) token(P.regexp(/'-?(0|[1-9][0-9]*)([.][0-9]+)?([eE][+-]?[0-9]+)?'/, 1))
.map(Number) .map(parseNumberSafe)
.desc('numer quoted'), .desc('numer quoted'),
number: () => number: () =>
token(P.regexp(/-?(0|[1-9][0-9]*)([.][0-9]+)?([eE][+-]?[0-9]+)?/)) token(P.regexp(/-?(0|[1-9][0-9]*)([.][0-9]+)?([eE][+-]?[0-9]+)?/))
.map(Number) .map(parseNumberSafe)
.desc('number'), .desc('number'),
objectid: () => token(P.regexp(/ObjectId\(['"]?[0-9a-f]{24}['"]?\)/)).desc('ObjectId'), objectid: () => token(P.regexp(/ObjectId\(['"]?[0-9a-f]{24}['"]?\)/)).desc('ObjectId'),
objectidstr: () => token(P.regexp(/[0-9a-f]{24}/)).desc('ObjectId string'),
hexstring: () => hexstring: () =>
token(P.regexp(/0x(([0-9a-fA-F][0-9a-fA-F])+)/, 1)) token(P.regexp(/0x(([0-9a-fA-F][0-9a-fA-F])+)/, 1))
.map(x => ({ .map(x => ({
@@ -366,6 +384,7 @@ const createParser = (filterBehaviour: FilterBehaviour) => {
value: r => P.alt(...allowedValues.map(x => r[x])), value: r => P.alt(...allowedValues.map(x => r[x])),
valueTestEq: r => r.value.map(binaryCondition('=')), valueTestEq: r => r.value.map(binaryCondition('=')),
hexTestEq: r => r.hexstring.map(binaryCondition('=')), hexTestEq: r => r.hexstring.map(binaryCondition('=')),
valueTestObjectIdStr: r => r.objectidstr.map(simpleEqualCondition()),
valueTestStr: r => r.value.map(likeCondition('like', '%#VALUE#%')), valueTestStr: r => r.value.map(likeCondition('like', '%#VALUE#%')),
valueTestNum: r => r.number.map(numberTestCondition()), valueTestNum: r => r.number.map(numberTestCondition()),
valueTestObjectId: r => r.objectid.map(objectIdTestCondition()), valueTestObjectId: r => r.objectid.map(objectIdTestCondition()),
@@ -546,12 +565,13 @@ const createParser = (filterBehaviour: FilterBehaviour) => {
} }
} }
if (filterBehaviour.allowNumberDualTesting) { if (filterBehaviour.allowObjectIdTesting) {
allowedElements.push('valueTestNum'); allowedElements.push('valueTestObjectIdStr');
allowedElements.push('valueTestObjectId');
} }
if (filterBehaviour.allowObjectIdTesting) { if (filterBehaviour.allowNumberDualTesting) {
allowedElements.push('valueTestObjectId'); allowedElements.push('valueTestNum');
} }
// must be last // must be last

View File

@@ -16,11 +16,17 @@ function isLike(value, test) {
return res; return res;
} }
function extractRawValue(value) {
if (value?.$bigint) return value.$bigint;
if (value?.$oid) return value.$oid;
return value;
}
export function evaluateCondition(condition: Condition, values) { export function evaluateCondition(condition: Condition, values) {
switch (condition.conditionType) { switch (condition.conditionType) {
case 'binary': case 'binary':
const left = evaluateExpression(condition.left, values); const left = extractRawValue(evaluateExpression(condition.left, values));
const right = evaluateExpression(condition.right, values); const right = extractRawValue(evaluateExpression(condition.right, values));
switch (condition.operator) { switch (condition.operator) {
case '=': case '=':
return left == right; return left == right;
@@ -50,10 +56,15 @@ export function evaluateCondition(condition: Condition, values) {
case 'or': case 'or':
return condition.conditions.some(cond => evaluateCondition(cond, values)); return condition.conditions.some(cond => evaluateCondition(cond, values));
case 'like': case 'like':
return isLike(evaluateExpression(condition.left, values), evaluateExpression(condition.right, values)); return isLike(
break; extractRawValue(evaluateExpression(condition.left, values)),
extractRawValue(evaluateExpression(condition.right, values))
);
case 'notLike': case 'notLike':
return !isLike(evaluateExpression(condition.left, values), evaluateExpression(condition.right, values)); return !isLike(
extractRawValue(evaluateExpression(condition.left, values)),
extractRawValue(evaluateExpression(condition.right, values))
);
case 'not': case 'not':
return !evaluateCondition(condition.condition, values); return !evaluateCondition(condition.condition, values);
case 'anyColumnPass': case 'anyColumnPass':

View File

@@ -32,7 +32,7 @@
"typescript": "^4.4.3" "typescript": "^4.4.3"
}, },
"dependencies": { "dependencies": {
"dbgate-query-splitter": "^4.11.4", "dbgate-query-splitter": "^4.11.5",
"dbgate-sqltree": "^6.0.0-alpha.1", "dbgate-sqltree": "^6.0.0-alpha.1",
"debug": "^4.3.4", "debug": "^4.3.4",
"json-stable-stringify": "^1.0.1", "json-stable-stringify": "^1.0.1",

View File

@@ -78,6 +78,7 @@ export class SqlDumper implements AlterProcessor {
else if (_isNumber(value)) this.putRaw(value.toString()); else if (_isNumber(value)) this.putRaw(value.toString());
else if (_isDate(value)) this.putStringValue(new Date(value).toISOString()); else if (_isDate(value)) this.putStringValue(new Date(value).toISOString());
else if (value?.type == 'Buffer' && _isArray(value?.data)) this.putByteArrayValue(value?.data); else if (value?.type == 'Buffer' && _isArray(value?.data)) this.putByteArrayValue(value?.data);
else if (value?.$bigint) this.putRaw(value?.$bigint);
else if (_isPlainObject(value) || _isArray(value)) this.putStringValue(JSON.stringify(value)); else if (_isPlainObject(value) || _isArray(value)) this.putStringValue(JSON.stringify(value));
else this.put('^null'); else this.put('^null');
} }

View File

@@ -1,40 +1,71 @@
import _omit from 'lodash/omit'; import _omit from 'lodash/omit';
import _sortBy from 'lodash/sortBy';
const SHOW_INCREMENT = 100; export const DB_KEYS_SHOW_INCREMENT = 100;
export interface DbKeysNodeModelBase { export interface DbKeysNodeModelBase {
text?: string; text?: string;
sortKey: string;
key: string;
count?: number; count?: number;
level: number; level: number;
keyPath: string[];
parentKey: string;
} }
export interface DbKeysLeafNodeModel extends DbKeysNodeModelBase { export interface DbKeysLeafNodeModel extends DbKeysNodeModelBase {
key: string;
type: 'string' | 'hash' | 'set' | 'list' | 'zset' | 'stream' | 'binary' | 'ReJSON-RL'; type: 'string' | 'hash' | 'set' | 'list' | 'zset' | 'stream' | 'binary' | 'ReJSON-RL';
} }
export interface DbKeysFolderNodeModel extends DbKeysNodeModelBase { export interface DbKeysFolderNodeModel extends DbKeysNodeModelBase {
root: string; // root: string;
type: 'dir'; type: 'dir';
maxShowCount?: number; // visibleCount?: number;
// isExpanded?: boolean;
}
export interface DbKeysFolderStateMode {
key: string;
visibleCount?: number;
isExpanded?: boolean; isExpanded?: boolean;
shouldLoadNext?: boolean;
hasNext?: boolean;
} }
export interface DbKeysTreeModel { export interface DbKeysTreeModel {
treeKeySeparator: string;
root: DbKeysFolderNodeModel; root: DbKeysFolderNodeModel;
dirsByKey: { [key: string]: DbKeysFolderNodeModel }; dirsByKey: { [key: string]: DbKeysFolderNodeModel };
dirStateByKey: { [key: string]: DbKeysFolderStateMode };
childrenByKey: { [key: string]: DbKeysNodeModel[] }; childrenByKey: { [key: string]: DbKeysNodeModel[] };
refreshAll?: boolean; keyObjectsByKey: { [key: string]: DbKeysNodeModel };
scannedKeys: number;
loadCount: number;
dbsize: number;
cursor: string;
loadedAll: boolean;
// refreshAll?: boolean;
} }
export type DbKeysNodeModel = DbKeysLeafNodeModel | DbKeysFolderNodeModel; export type DbKeysNodeModel = DbKeysLeafNodeModel | DbKeysFolderNodeModel;
export type DbKeysLoadFunction = (root: string, limit: number) => Promise<DbKeysNodeModel[]>; export interface DbKeyLoadedModel {
key: string;
export type DbKeysChangeModelFunction = (func: (model: DbKeysTreeModel) => DbKeysTreeModel) => void; type: 'string' | 'hash' | 'set' | 'list' | 'zset' | 'stream' | 'binary' | 'ReJSON-RL';
count?: number;
}
export interface DbKeysLoadResult {
nextCursor: string;
keys: DbKeyLoadedModel[];
dbsize: number;
}
// export type DbKeysLoadFunction = (root: string, limit: number) => Promise<DbKeysLoadResult>;
export type DbKeysChangeModelFunction = (
func: (model: DbKeysTreeModel) => DbKeysTreeModel,
loadNextPage: boolean
) => void;
// function dbKeys_findFolderNode(node: DbKeysNodeModel, root: string) { // function dbKeys_findFolderNode(node: DbKeysNodeModel, root: string) {
// if (node.type != 'dir') { // if (node.type != 'dir') {
@@ -73,119 +104,242 @@ export type DbKeysChangeModelFunction = (func: (model: DbKeysTreeModel) => DbKey
// }; // };
// } // }
export async function dbKeys_loadMissing(tree: DbKeysTreeModel, loader: DbKeysLoadFunction): Promise<DbKeysTreeModel> { // export async function dbKeys_loadMissing(tree: DbKeysTreeModel, loader: DbKeysLoadFunction): Promise<DbKeysTreeModel> {
const childrenByKey = { ...tree.childrenByKey }; // const childrenByKey = { ...tree.childrenByKey };
const dirsByKey = { ...tree.dirsByKey }; // const dirsByKey = { ...tree.dirsByKey };
for (const root in tree.dirsByKey) { // for (const root in tree.dirsByKey) {
const dir = tree.dirsByKey[root]; // const dir = tree.dirsByKey[root];
if (dir.isExpanded && dir.shouldLoadNext) { // if (dir.isExpanded && dir.shouldLoadNext) {
if (!tree.childrenByKey[root] || dir.hasNext) { // if (!tree.childrenByKey[root] || dir.hasNext) {
const loadCount = dir.maxShowCount && dir.shouldLoadNext ? dir.maxShowCount + SHOW_INCREMENT : SHOW_INCREMENT; // const loadCount = dir.maxShowCount && dir.shouldLoadNext ? dir.maxShowCount + SHOW_INCREMENT : SHOW_INCREMENT;
const items = await loader(root, loadCount + 1); // const items = await loader(root, loadCount + 1);
childrenByKey[root] = items.slice(0, loadCount); // childrenByKey[root] = items.slice(0, loadCount);
dirsByKey[root] = { // dirsByKey[root] = {
...dir, // ...dir,
shouldLoadNext: false, // shouldLoadNext: false,
maxShowCount: loadCount, // maxShowCount: loadCount,
hasNext: items.length > loadCount, // hasNext: items.length > loadCount,
}; // };
for (const child of items.slice(0, loadCount)) { // for (const child of items.slice(0, loadCount)) {
if (child.type == 'dir' && !dirsByKey[child.root]) { // if (child.type == 'dir' && !dirsByKey[child.root]) {
dirsByKey[child.root] = { // dirsByKey[child.root] = {
shouldLoadNext: false, // shouldLoadNext: false,
maxShowCount: null, // maxShowCount: null,
hasNext: false, // hasNext: false,
isExpanded: false, // isExpanded: false,
type: 'dir', // type: 'dir',
level: dir.level + 1, // level: dir.level + 1,
root: child.root, // root: child.root,
text: child.text, // text: child.text,
}; // };
} // }
} // }
} else { // } else {
dirsByKey[root] = { // dirsByKey[root] = {
...dir, // ...dir,
shouldLoadNext: false, // shouldLoadNext: false,
// };
// }
// }
// }
// return {
// ...tree,
// dirsByKey,
// childrenByKey,
// refreshAll: false,
// };
// }
export function dbKeys_mergeNextPage(tree: DbKeysTreeModel, nextPage: DbKeysLoadResult): DbKeysTreeModel {
const keyObjectsByKey = { ...tree.keyObjectsByKey };
for (const keyObj of nextPage.keys) {
const keyPath = keyObj.key.split(tree.treeKeySeparator);
keyObjectsByKey[keyObj.key] = {
...keyObj,
level: keyPath.length,
text: keyPath[keyPath.length - 1],
sortKey: keyPath[keyPath.length - 1],
keyPath,
parentKey: keyPath.slice(0, -1).join(tree.treeKeySeparator),
};
}
const dirsByKey: { [key: string]: DbKeysFolderNodeModel } = {};
const childrenByKey: { [key: string]: DbKeysNodeModel[] } = {};
dirsByKey[''] = tree.root;
for (const keyObj of Object.values(keyObjectsByKey)) {
const dirPath = keyObj.keyPath.slice(0, -1);
const dirKey = dirPath.join(tree.treeKeySeparator);
let dirDepth = keyObj.keyPath.length - 1;
while (dirDepth > 0) {
const newDirPath = keyObj.keyPath.slice(0, dirDepth);
const newDirKey = newDirPath.join(tree.treeKeySeparator);
if (!dirsByKey[newDirKey]) {
dirsByKey[newDirKey] = {
level: keyObj.level - 1,
keyPath: newDirPath,
parentKey: newDirPath.slice(0, -1).join(tree.treeKeySeparator),
type: 'dir',
key: newDirKey,
text: `${newDirPath[newDirPath.length - 1]}${tree.treeKeySeparator}*`,
sortKey: newDirPath[newDirPath.length - 1],
}; };
} }
dirDepth -= 1;
} }
if (!childrenByKey[dirKey]) {
childrenByKey[dirKey] = [];
}
childrenByKey[dirKey].push(keyObj);
}
for (const dirObj of Object.values(dirsByKey)) {
if (dirObj.key == '') {
continue;
}
if (!childrenByKey[dirObj.parentKey]) {
childrenByKey[dirObj.parentKey] = [];
}
childrenByKey[dirObj.parentKey].push(dirObj);
// set key count
dirsByKey[dirObj.key].count = childrenByKey[dirObj.key].length;
}
for (const key in childrenByKey) {
childrenByKey[key] = _sortBy(childrenByKey[key], 'sortKey');
} }
return { return {
...tree, ...tree,
cursor: nextPage.nextCursor,
dirsByKey, dirsByKey,
childrenByKey, childrenByKey,
refreshAll: false, keyObjectsByKey,
scannedKeys: tree.scannedKeys + tree.loadCount,
loadedAll: nextPage.nextCursor == '0',
dbsize: nextPage.dbsize,
}; };
} }
export function dbKeys_markNodeExpanded(tree: DbKeysTreeModel, root: string, isExpanded: boolean): DbKeysTreeModel { export function dbKeys_markNodeExpanded(tree: DbKeysTreeModel, root: string, isExpanded: boolean): DbKeysTreeModel {
const node = tree.dirsByKey[root]; const node = tree.dirStateByKey[root];
if (!node) {
return tree;
}
return { return {
...tree, ...tree,
dirsByKey: { dirStateByKey: {
...tree.dirsByKey, ...tree.dirStateByKey,
[root]: { [root]: {
...node, ...node,
isExpanded, isExpanded,
shouldLoadNext: isExpanded,
}, },
}, },
}; };
} }
export function dbKeys_refreshAll(tree?: DbKeysTreeModel): DbKeysTreeModel { export function dbKeys_showNextItems(tree: DbKeysTreeModel, root: string): DbKeysTreeModel {
const root: DbKeysFolderNodeModel = { const node = tree.dirStateByKey[root];
isExpanded: true,
level: 0,
root: '',
type: 'dir',
shouldLoadNext: true,
};
return { return {
...tree, ...tree,
dirStateByKey: {
...tree.dirStateByKey,
[root]: {
...node,
visibleCount: (node?.visibleCount ?? DB_KEYS_SHOW_INCREMENT) + DB_KEYS_SHOW_INCREMENT,
},
},
};
}
export function dbKeys_createNewModel(treeKeySeparator: string): DbKeysTreeModel {
const root: DbKeysFolderNodeModel = {
level: 0,
type: 'dir',
keyPath: [],
parentKey: '',
key: '',
sortKey: '',
};
return {
treeKeySeparator,
childrenByKey: {}, childrenByKey: {},
keyObjectsByKey: {},
dirsByKey: { dirsByKey: {
'': root, '': root,
}, },
refreshAll: true, dirStateByKey: {
root, '': {
}; key: '',
} visibleCount: DB_KEYS_SHOW_INCREMENT,
isExpanded: true,
export function dbKeys_reloadFolder(tree: DbKeysTreeModel, root: string): DbKeysTreeModel {
return {
...tree,
childrenByKey: _omit(tree.childrenByKey, root),
dirsByKey: {
...tree.dirsByKey,
[root]: {
...tree.dirsByKey[root],
shouldLoadNext: true,
hasNext: undefined,
}, },
}, },
scannedKeys: 0,
dbsize: 0,
loadCount: 2000,
cursor: '0',
root,
loadedAll: false,
}; };
} }
function addFlatItems(tree: DbKeysTreeModel, root: string, res: DbKeysNodeModel[]) { export function dbKeys_clearLoadedData(tree: DbKeysTreeModel): DbKeysTreeModel {
const item = tree.dirsByKey[root]; return {
if (!item.isExpanded) { ...tree,
childrenByKey: {},
keyObjectsByKey: {},
dirsByKey: {
'': tree.root,
},
scannedKeys: 0,
dbsize: 0,
cursor: '0',
loadedAll: false,
};
}
// export function dbKeys_reloadFolder(tree: DbKeysTreeModel, root: string): DbKeysTreeModel {
// return {
// ...tree,
// childrenByKey: _omit(tree.childrenByKey, root),
// dirsByKey: {
// ...tree.dirsByKey,
// [root]: {
// ...tree.dirsByKey[root],
// shouldLoadNext: true,
// hasNext: undefined,
// },
// },
// };
// }
function addFlatItems(tree: DbKeysTreeModel, root: string, res: DbKeysNodeModel[], visitedRoots: string[] = []) {
const item = tree.dirStateByKey[root];
if (!item?.isExpanded) {
return false; return false;
} }
const children = tree.childrenByKey[root] || []; const children = tree.childrenByKey[root] || [];
for (const child of children) { for (const child of children) {
res.push(child); res.push(child);
if (child.type == 'dir') { if (child.type == 'dir') {
addFlatItems(tree, child.root, res); if (visitedRoots.includes(child.key)) {
console.warn('Redis: preventing infinite loop for root', child.key);
return false;
}
addFlatItems(tree, child.key, res, [...visitedRoots, root]);
} }
} }
} }

View File

@@ -4,7 +4,11 @@ import _isDate from 'lodash/isDate';
import _isNumber from 'lodash/isNumber'; import _isNumber from 'lodash/isNumber';
import _isPlainObject from 'lodash/isPlainObject'; import _isPlainObject from 'lodash/isPlainObject';
import _pad from 'lodash/pad'; import _pad from 'lodash/pad';
import _cloneDeepWith from 'lodash/cloneDeepWith';
import _isEmpty from 'lodash/isEmpty';
import _omitBy from 'lodash/omitBy';
import { DataEditorTypesBehaviour } from 'dbgate-types'; import { DataEditorTypesBehaviour } from 'dbgate-types';
import isPlainObject from 'lodash/isPlainObject';
export type EditorDataType = export type EditorDataType =
| 'null' | 'null'
@@ -80,7 +84,7 @@ export function parseCellValue(value, editorTypes?: DataEditorTypesBehaviour) {
if (editorTypes?.parseNumber) { if (editorTypes?.parseNumber) {
if (/^-?[0-9]+(?:\.[0-9]+)?$/.test(value)) { if (/^-?[0-9]+(?:\.[0-9]+)?$/.test(value)) {
return parseFloat(value); return parseNumberSafe(value);
} }
} }
@@ -208,6 +212,18 @@ export function stringifyCellValue(
} }
} }
} }
if (value?.$bigint) {
return {
value: value.$bigint,
gridStyle: 'valueCellStyle',
};
}
if (typeof value === 'bigint') {
return {
value: value.toString(),
gridStyle: 'valueCellStyle',
};
}
if (editorTypes?.parseDateAsDollar) { if (editorTypes?.parseDateAsDollar) {
if (value?.$date) { if (value?.$date) {
@@ -343,6 +359,9 @@ export function shouldOpenMultilineDialog(value) {
if (value?.$date) { if (value?.$date) {
return false; return false;
} }
if (value?.$bigint) {
return false;
}
if (_isPlainObject(value) || _isArray(value)) { if (_isPlainObject(value) || _isArray(value)) {
return true; return true;
} }
@@ -573,3 +592,82 @@ export function jsonLinesParse(jsonLines: string): any[] {
}) })
.filter(x => x); .filter(x => x);
} }
export function serializeJsTypesForJsonStringify(obj, replacer = null) {
return _cloneDeepWith(obj, value => {
if (typeof value === 'bigint') {
return { $bigint: value.toString() };
}
if (replacer) {
return replacer(value);
}
});
}
export function deserializeJsTypesFromJsonParse(obj) {
return _cloneDeepWith(obj, value => {
if (value?.$bigint) {
return BigInt(value.$bigint);
}
});
}
export function serializeJsTypesReplacer(key, value) {
if (typeof value === 'bigint') {
return { $bigint: value.toString() };
}
return value;
}
export function deserializeJsTypesReviver(key, value) {
if (value?.$bigint) {
return BigInt(value.$bigint);
}
return value;
}
export function parseNumberSafe(value) {
if (/^-?[0-9]+$/.test(value)) {
const parsed = parseInt(value);
if (Number.isSafeInteger(parsed)) {
return parsed;
}
return BigInt(value);
}
return parseFloat(value);
}
const frontMatterRe = /^--\ >>>[ \t\r]*\n(.*)\n-- <<<[ \t\r]*\n/s;
export function getSqlFrontMatter(text: string, yamlModule) {
const match = text.match(frontMatterRe);
if (!match) return null;
const yamlContentMapped = match[1].replace(/^--[ ]?/gm, '');
return yamlModule.load(yamlContentMapped);
}
export function removeSqlFrontMatter(text: string) {
return text.replace(frontMatterRe, '');
}
export function setSqlFrontMatter(text: string, data: { [key: string]: any }, yamlModule) {
const textClean = removeSqlFrontMatter(text);
if (!isPlainObject(data)) {
return textClean;
}
const dataClean = _omitBy(data, v => v === undefined);
if (_isEmpty(dataClean)) {
return textClean;
}
const yamlContent = yamlModule.dump(dataClean);
const yamlContentMapped = yamlContent
.trimRight()
.split('\n')
.map(line => '-- ' + line)
.join('\n');
const frontMatterContent = `-- >>>\n${yamlContentMapped}\n-- <<<\n`;
return frontMatterContent + textClean;
}

View File

@@ -1,6 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2015", "target": "ES2018",
"module": "commonjs", "module": "commonjs",
"declaration": true, "declaration": true,
"skipLibCheck": true, "skipLibCheck": true,

View File

@@ -239,6 +239,7 @@ export interface EngineDriver<TClient = any> extends FilterBehaviourProvider {
}[] }[]
>; >;
loadKeys(dbhan: DatabaseHandle<TClient>, root: string, filter?: string): Promise; loadKeys(dbhan: DatabaseHandle<TClient>, root: string, filter?: string): Promise;
scanKeys(dbhan: DatabaseHandle<TClient>, root: string, pattern: string, cursor: string, count: number): Promise;
exportKeys(dbhan: DatabaseHandle<TClient>, options: {}): Promise; exportKeys(dbhan: DatabaseHandle<TClient>, options: {}): Promise;
loadKeyInfo(dbhan: DatabaseHandle<TClient>, key): Promise; loadKeyInfo(dbhan: DatabaseHandle<TClient>, key): Promise;
loadKeyTableRange(dbhan: DatabaseHandle<TClient>, key, cursor, count): Promise; loadKeyTableRange(dbhan: DatabaseHandle<TClient>, key, cursor, count): Promise;

View File

@@ -24,9 +24,10 @@
"ace-builds": "^1.36.5", "ace-builds": "^1.36.5",
"chart.js": "^4.4.2", "chart.js": "^4.4.2",
"chartjs-adapter-moment": "^1.0.0", "chartjs-adapter-moment": "^1.0.0",
"chartjs-plugin-datalabels": "^2.2.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"dbgate-datalib": "^6.0.0-alpha.1", "dbgate-datalib": "^6.0.0-alpha.1",
"dbgate-query-splitter": "^4.11.4", "dbgate-query-splitter": "^4.11.5",
"dbgate-sqltree": "^6.0.0-alpha.1", "dbgate-sqltree": "^6.0.0-alpha.1",
"dbgate-tools": "^6.0.0-alpha.1", "dbgate-tools": "^6.0.0-alpha.1",
"dbgate-types": "^6.0.0-alpha.1", "dbgate-types": "^6.0.0-alpha.1",

View File

@@ -1,5 +1,5 @@
:root { :root {
--dim-widget-icon-size: 60px; --dim-widget-icon-size: 50px;
--dim-statusbar-height: 22px; --dim-statusbar-height: 22px;
--dim-left-panel-width: 300px; --dim-left-panel-width: 300px;
--dim-tabs-height: 33px; --dim-tabs-height: 33px;

View File

@@ -36,6 +36,9 @@ body {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
} }
.align-items-center {
align-items: center;
}
.flex { .flex {
display: flex; display: flex;
} }

View File

@@ -14,7 +14,12 @@
// import { shouldWaitForElectronInitialize } from './utility/getElectron'; // import { shouldWaitForElectronInitialize } from './utility/getElectron';
import { subscribeConnectionPingers } from './utility/connectionsPinger'; import { subscribeConnectionPingers } from './utility/connectionsPinger';
import { subscribePermissionCompiler } from './utility/hasPermission'; import { subscribePermissionCompiler } from './utility/hasPermission';
import { apiCall, installNewVolatileConnectionListener } from './utility/api'; import {
apiCall,
installNewCloudTokenListener,
installNewVolatileConnectionListener,
refreshPublicCloudFiles,
} from './utility/api';
import { getConfig, getSettings, getUsedApps } from './utility/metadataLoaders'; import { getConfig, getSettings, getUsedApps } from './utility/metadataLoaders';
import AppTitleProvider from './utility/AppTitleProvider.svelte'; import AppTitleProvider from './utility/AppTitleProvider.svelte';
import getElectron from './utility/getElectron'; import getElectron from './utility/getElectron';
@@ -23,6 +28,7 @@
import { handleAuthOnStartup } from './clientAuth'; import { handleAuthOnStartup } from './clientAuth';
import { initializeAppUpdates } from './utility/appUpdate'; import { initializeAppUpdates } from './utility/appUpdate';
import { _t } from './translations'; import { _t } from './translations';
import { installCloudListeners } from './utility/cloudListeners';
export let isAdminPage = false; export let isAdminPage = false;
@@ -51,9 +57,13 @@
subscribeConnectionPingers(); subscribeConnectionPingers();
subscribePermissionCompiler(); subscribePermissionCompiler();
installNewVolatileConnectionListener(); installNewVolatileConnectionListener();
installNewCloudTokenListener();
initializeAppUpdates(); initializeAppUpdates();
installCloudListeners();
} }
refreshPublicCloudFiles();
loadedApi = loadedApiValue; loadedApi = loadedApiValue;
if (!loadedApi) { if (!loadedApi) {

View File

@@ -12,6 +12,7 @@
export let groupFunc; export let groupFunc;
export let items; export let items;
export let groupIconFunc = plusExpandIcon; export let groupIconFunc = plusExpandIcon;
export let mapGroupTitle = undefined;
export let module; export let module;
export let checkedObjectsStore = null; export let checkedObjectsStore = null;
export let disableContextMenu = false; export let disableContextMenu = false;
@@ -63,7 +64,7 @@
<FontIcon icon={groupIconFunc(isExpanded)} /> <FontIcon icon={groupIconFunc(isExpanded)} />
</span> </span>
{group} {mapGroupTitle ? mapGroupTitle(group) : group}
{items && `(${countText})`} {items && `(${countText})`}
</div> </div>

View File

@@ -26,6 +26,7 @@
export let groupIconFunc = plusExpandIcon; export let groupIconFunc = plusExpandIcon;
export let groupFunc = undefined; export let groupFunc = undefined;
export let mapGroupTitle = undefined;
export let onDropOnGroup = undefined; export let onDropOnGroup = undefined;
export let emptyGroupNames = []; export let emptyGroupNames = [];
export let isExpandedBySearch = false; export let isExpandedBySearch = false;
@@ -127,6 +128,7 @@
{subItemsComponent} {subItemsComponent}
{checkedObjectsStore} {checkedObjectsStore}
{groupFunc} {groupFunc}
{mapGroupTitle}
{disableContextMenu} {disableContextMenu}
{filter} {filter}
{passProps} {passProps}

View File

@@ -81,6 +81,7 @@
import ConfirmModal from '../modals/ConfirmModal.svelte'; import ConfirmModal from '../modals/ConfirmModal.svelte';
import { apiCall } from '../utility/api'; import { apiCall } from '../utility/api';
import { openImportExportTab } from '../utility/importExportTools'; import { openImportExportTab } from '../utility/importExportTools';
import { isProApp } from '../utility/proTools';
export let data; export let data;
$: isZipped = data.folderName?.endsWith('.zip'); $: isZipped = data.folderName?.endsWith('.zip');
@@ -187,6 +188,7 @@
data.fileType.endsWith('.sql') && { text: 'Open SQL', onClick: handleOpenSqlFile }, data.fileType.endsWith('.sql') && { text: 'Open SQL', onClick: handleOpenSqlFile },
data.fileType.endsWith('.yaml') && { text: 'Open YAML', onClick: handleOpenYamlFile }, data.fileType.endsWith('.yaml') && { text: 'Open YAML', onClick: handleOpenYamlFile },
!isZipped && !isZipped &&
isProApp() &&
data.fileType == 'jsonl' && { data.fileType == 'jsonl' && {
text: 'Open in profiler', text: 'Open in profiler',
submenu: getExtensions() submenu: getExtensions()

View File

@@ -0,0 +1,139 @@
<script lang="ts" context="module">
import { cloudConnectionsStore } from '../stores';
import { apiCall } from '../utility/api';
import AppObjectCore from './AppObjectCore.svelte';
export const extractKey = data => data.cntid;
export const createMatcher =
filter =>
({ name }) =>
filterName(filter, name);
</script>
<script lang="ts">
import { filterName, getConnectionLabel } from 'dbgate-tools';
import ConnectionAppObject, { openConnection } from './ConnectionAppObject.svelte';
import { _t } from '../translations';
import openNewTab from '../utility/openNewTab';
import { showModal } from '../modals/modalTools';
import ConfirmModal from '../modals/ConfirmModal.svelte';
import SavedFileAppObject from './SavedFileAppObject.svelte';
export let data;
export let passProps;
function createMenu() {
const res = [];
switch (data.type) {
case 'connection':
res.push({
text: _t('connection.connect', { defaultMessage: 'Connect' }),
onClick: handleConnect,
isBold: true,
});
res.push({ divider: true });
res.push({
text: _t('connection.edit', { defaultMessage: 'Edit' }),
onClick: handleEditConnection,
});
res.push({
text: _t('connection.delete', { defaultMessage: 'Delete' }),
onClick: handleDeleteConnection,
});
res.push({
text: _t('connection.duplicate', { defaultMessage: 'Duplicate' }),
onClick: handleDuplicateConnection,
});
break;
}
return res;
}
function handleEditConnection() {
openNewTab({
title: data.name,
icon: 'img cloud-connection',
tabComponent: 'ConnectionTab',
props: {
conid: data.conid,
},
});
}
async function handleDeleteConnection() {
showModal(ConfirmModal, {
message: `Really delete connection ${data.name}?`,
onConfirm: () => {
apiCall('cloud/delete-connection', { conid: data.conid });
},
});
}
async function handleDuplicateConnection() {
await apiCall('cloud/duplicate-connection', { conid: data.conid });
}
async function handleConnect() {
const conn = await apiCall('connections/get', { conid: data.conid });
$cloudConnectionsStore = {
...$cloudConnectionsStore,
[data.conid]: conn,
};
openConnection(conn);
}
async function handleOpenContent() {
switch (data.type) {
case 'connection':
await handleConnect();
break;
}
}
</script>
{#if data.conid && $cloudConnectionsStore[data.conid]}
<ConnectionAppObject
{...$$restProps}
{passProps}
data={{
...$cloudConnectionsStore[data.conid],
status: data.status,
}}
on:dblclick
on:expand
/>
{:else if data.type == 'file'}
<SavedFileAppObject
{...$$restProps}
{passProps}
data={{
file: data.name,
folder: data.contentFolder,
folid: data.folid,
cntid: data.cntid,
}}
on:dblclick
on:expand
/>
{:else}
<AppObjectCore
{...$$restProps}
{data}
icon={'img cloud-connection'}
title={data.name}
menu={createMenu}
on:click={handleOpenContent}
on:dblclick
on:expand
></AppObjectCore>
{/if}
<style>
.info {
margin-left: 30px;
margin-right: 5px;
color: var(--theme-font-3);
white-space: nowrap;
}
</style>

View File

@@ -108,6 +108,7 @@
import _ from 'lodash'; import _ from 'lodash';
import AppObjectCore from './AppObjectCore.svelte'; import AppObjectCore from './AppObjectCore.svelte';
import { import {
cloudSigninTokenHolder,
currentDatabase, currentDatabase,
DEFAULT_CONNECTION_SEARCH_SETTINGS, DEFAULT_CONNECTION_SEARCH_SETTINGS,
expandedConnections, expandedConnections,
@@ -160,7 +161,7 @@
const handleOpenConnectionTab = () => { const handleOpenConnectionTab = () => {
openNewTab({ openNewTab({
title: getConnectionLabel(data), title: getConnectionLabel(data),
icon: 'img connection', icon: data._id.startsWith('cloud://') ? 'img cloud-connection' : 'img connection',
tabComponent: 'ConnectionTab', tabComponent: 'ConnectionTab',
props: { props: {
conid: data._id, conid: data._id,
@@ -261,11 +262,15 @@
}); });
}; };
const handleDuplicate = () => { const handleDuplicate = () => {
apiCall('connections/save', { if (data._id.startsWith('cloud://')) {
...data, apiCall('cloud/duplicate-connection', { conid: data._id });
_id: undefined, } else {
displayName: `${getConnectionLabel(data)} - copy`, apiCall('connections/save', {
}); ...data,
_id: undefined,
displayName: `${getConnectionLabel(data)} - copy`,
});
}
}; };
const handleCreateDatabase = () => { const handleCreateDatabase = () => {
showModal(InputTextModal, { showModal(InputTextModal, {
@@ -332,6 +337,19 @@
text: _t('connection.duplicate', { defaultMessage: 'Duplicate' }), text: _t('connection.duplicate', { defaultMessage: 'Duplicate' }),
onClick: handleDuplicate, onClick: handleDuplicate,
}, },
!$openedConnections.includes(data._id) &&
$cloudSigninTokenHolder &&
passProps?.cloudContentList?.length > 0 && {
text: _t('connection.copyToCloudFolder', { defaultMessage: 'Copy to cloud folder' }),
submenu: passProps?.cloudContentList
?.filter(x => x.role == 'write' || x.role == 'admin')
?.map(fld => ({
text: fld.name,
onClick: () => {
apiCall('cloud/copy-connection-cloud', { conid: data._id, folid: fld.folid });
},
})),
},
], ],
{ divider: true }, { divider: true },
!data.singleDatabase && [ !data.singleDatabase && [
@@ -416,7 +434,7 @@
{...$$restProps} {...$$restProps}
{data} {data}
title={getConnectionLabel(data, { showUnsaved: true })} title={getConnectionLabel(data, { showUnsaved: true })}
icon={data.singleDatabase ? 'img database' : 'img server'} icon={data._id.startsWith('cloud://') ? 'img cloud-connection' : data.singleDatabase ? 'img database' : 'img server'}
isBold={data.singleDatabase isBold={data.singleDatabase
? $currentDatabase?.connection?._id == data._id && $currentDatabase?.name == data.defaultDatabase ? $currentDatabase?.connection?._id == data._id && $currentDatabase?.name == data.defaultDatabase
: $currentDatabase?.connection?._id == data._id} : $currentDatabase?.connection?._id == data._id}

View File

@@ -330,6 +330,29 @@ await dbgateApi.dropAllDbObjects(${JSON.stringify(
}); });
}; };
const handleGenerateRunScript = () => {
openNewTab(
{
title: 'Shell #',
icon: 'img shell',
tabComponent: 'ShellTab',
},
{
editor: `// @require ${extractPackageName(connection.engine)}
await dbgateApi.executeQuery(${JSON.stringify(
{
connection: extractShellConnection(connection, name),
sql: 'your script here',
},
undefined,
2
)});
`,
}
);
};
const handleShowDataDeployer = () => { const handleShowDataDeployer = () => {
showModal(ChooseArchiveFolderModal, { showModal(ChooseArchiveFolderModal, {
message: 'Choose archive folder for data deployer', message: 'Choose archive folder for data deployer',
@@ -338,7 +361,7 @@ await dbgateApi.dropAllDbObjects(${JSON.stringify(
{ {
title: archiveFolder, title: archiveFolder,
icon: 'img replicator', icon: 'img replicator',
tabComponent: 'DataDeployerTab', tabComponent: 'DataDeployTab',
props: { props: {
conid: connection?._id, conid: connection?._id,
database: name, database: name,
@@ -347,6 +370,8 @@ await dbgateApi.dropAllDbObjects(${JSON.stringify(
{ {
editor: { editor: {
archiveFolder, archiveFolder,
conid: connection?._id,
database: name,
}, },
} }
); );
@@ -405,6 +430,7 @@ await dbgateApi.dropAllDbObjects(${JSON.stringify(
driver?.databaseEngineTypes?.includes('sql') && driver?.databaseEngineTypes?.includes('sql') &&
hasPermission(`dbops/sql-generator`) && { onClick: handleSqlGenerator, text: 'SQL Generator' }, hasPermission(`dbops/sql-generator`) && { onClick: handleSqlGenerator, text: 'SQL Generator' },
driver?.supportsDatabaseProfiler && driver?.supportsDatabaseProfiler &&
isProApp() &&
hasPermission(`dbops/profiler`) && { onClick: handleDatabaseProfiler, text: 'Database profiler' }, hasPermission(`dbops/profiler`) && { onClick: handleDatabaseProfiler, text: 'Database profiler' },
// isSqlOrDoc && // isSqlOrDoc &&
// isSqlOrDoc && // isSqlOrDoc &&
@@ -437,6 +463,11 @@ await dbgateApi.dropAllDbObjects(${JSON.stringify(
text: 'Shell: Drop all objects', text: 'Shell: Drop all objects',
}, },
{
onClick: handleGenerateRunScript,
text: 'Shell: Run script',
},
driver?.databaseEngineTypes?.includes('sql') && driver?.databaseEngineTypes?.includes('sql') &&
hasPermission(`dbops/import`) && { hasPermission(`dbops/import`) && {
onClick: handleShowDataDeployer, onClick: handleShowDataDeployer,

View File

@@ -185,10 +185,6 @@
isImport: true, isImport: true,
requiresWriteAccess: true, requiresWriteAccess: true,
}, },
hasPermission('dbops/charts') && {
label: 'Open active chart',
isActiveChart: true,
},
]; ];
case 'views': case 'views':
return [ return [
@@ -245,10 +241,6 @@
isExport: true, isExport: true,
functionName: 'tableReader', functionName: 'tableReader',
}, },
{
label: 'Open active chart',
isActiveChart: true,
},
]; ];
case 'matviews': case 'matviews':
return [ return [
@@ -299,10 +291,6 @@
isExport: true, isExport: true,
functionName: 'tableReader', functionName: 'tableReader',
}, },
{
label: 'Open active chart',
isActiveChart: true,
},
]; ];
case 'queries': case 'queries':
return [ return [
@@ -472,28 +460,7 @@
return driver; return driver;
}; };
if (menu.isActiveChart) { if (menu.isQueryDesigner) {
const driver = await getDriver();
const dmp = driver.createDumper();
dmp.put('^select * from %f', data);
openNewTab(
{
title: data.pureName,
icon: 'img chart',
tabComponent: 'ChartTab',
props: {
conid: data.conid,
database: data.database,
},
},
{
editor: {
config: { chartType: 'bar' },
sql: dmp.s,
},
}
);
} else if (menu.isQueryDesigner) {
openNewTab( openNewTab(
{ {
title: 'Query #', title: 'Query #',

View File

@@ -0,0 +1,52 @@
<script lang="ts" context="module">
import AppObjectCore from './AppObjectCore.svelte';
export const extractKey = data => data.path;
export const createMatcher =
filter =>
({ title, description }) =>
filterName(filter, title, description);
</script>
<script lang="ts">
import { apiCall } from '../utility/api';
import newQuery from '../query/newQuery';
import { filterName } from 'dbgate-tools';
export let data;
async function handleOpenSqlFile() {
const fileData = await apiCall('cloud/public-file-data', { path: data.path });
newQuery({
initialData: fileData.text,
});
}
function createMenu() {
return [{ text: 'Open', onClick: handleOpenSqlFile }];
}
</script>
<AppObjectCore
{...$$restProps}
{data}
icon={'img sql-file'}
title={data.title}
menu={createMenu}
on:click={handleOpenSqlFile}
>
{#if data.description}
<div class="info">
{data.description}
</div>
{/if}
</AppObjectCore>
<style>
.info {
margin-left: 30px;
margin-right: 5px;
color: var(--theme-font-3);
white-space: nowrap;
}
</style>

View File

@@ -41,16 +41,6 @@
label: 'Markdown file', label: 'Markdown file',
}; };
const charts: FileTypeHandler = {
icon: 'img chart',
format: 'json',
tabComponent: 'ChartTab',
folder: 'charts',
currentConnection: true,
extension: 'json',
label: 'Chart file',
};
const query: FileTypeHandler = { const query: FileTypeHandler = {
icon: 'img query-design', icon: 'img query-design',
format: 'json', format: 'json',
@@ -139,7 +129,6 @@
sql, sql,
shell, shell,
markdown, markdown,
charts,
query, query,
sqlite, sqlite,
diagrams, diagrams,
@@ -206,7 +195,14 @@
showModal(ConfirmModal, { showModal(ConfirmModal, {
message: `Really delete file ${data.file}?`, message: `Really delete file ${data.file}?`,
onConfirm: () => { onConfirm: () => {
apiCall('files/delete', data); if (data.folid && data.cntid) {
apiCall('cloud/delete-content', {
folid: data.folid,
cntid: data.cntid,
});
} else {
apiCall('files/delete', data);
}
}, },
}); });
}; };
@@ -217,7 +213,15 @@
label: 'New file name', label: 'New file name',
header: 'Rename file', header: 'Rename file',
onConfirm: newFile => { onConfirm: newFile => {
apiCall('files/rename', { ...data, newFile }); if (data.folid && data.cntid) {
apiCall('cloud/rename-content', {
folid: data.folid,
cntid: data.cntid,
name: newFile,
});
} else {
apiCall('files/rename', { ...data, newFile });
}
}, },
}); });
}; };
@@ -226,9 +230,17 @@
showModal(InputTextModal, { showModal(InputTextModal, {
value: data.file, value: data.file,
label: 'New file name', label: 'New file name',
header: 'Rename file', header: 'Copy file',
onConfirm: newFile => { onConfirm: newFile => {
apiCall('files/copy', { ...data, newFile }); if (data.folid && data.cntid) {
apiCall('cloud/copy-file', {
folid: data.folid,
cntid: data.cntid,
name: newFile,
});
} else {
apiCall('files/copy', { ...data, newFile });
}
}, },
}); });
}; };
@@ -236,21 +248,38 @@
const handleDownload = () => { const handleDownload = () => {
saveFileToDisk( saveFileToDisk(
async filePath => { async filePath => {
await apiCall('files/export-file', { if (data.folid && data.cntid) {
folder, await apiCall('cloud/export-file', {
file: data.file, folid: data.folid,
filePath, cntid: data.cntid,
}); filePath,
});
} else {
await apiCall('files/export-file', {
folder,
file: data.file,
filePath,
});
}
}, },
{ formatLabel: handler.label, formatExtension: handler.format, defaultFileName: data.file } { formatLabel: handler.label, formatExtension: handler.format, defaultFileName: data.file }
); );
}; };
async function openTab() { async function openTab() {
const resp = await apiCall('files/load', { folder, file: data.file, format: handler.format }); let dataContent;
if (data.folid && data.cntid) {
const resp = await apiCall('cloud/get-content', {
folid: data.folid,
cntid: data.cntid,
});
dataContent = resp.content;
} else {
dataContent = await apiCall('files/load', { folder, file: data.file, format: handler.format });
}
const connProps: any = {};
let tooltip = undefined; let tooltip = undefined;
const connProps: any = {};
if (handler.currentConnection) { if (handler.currentConnection) {
const connection = _.get($currentDatabase, 'connection') || {}; const connection = _.get($currentDatabase, 'connection') || {};
@@ -270,10 +299,12 @@
savedFile: data.file, savedFile: data.file,
savedFolder: handler.folder, savedFolder: handler.folder,
savedFormat: handler.format, savedFormat: handler.format,
savedCloudFolderId: data.folid,
savedCloudContentId: data.cntid,
...connProps, ...connProps,
}, },
}, },
{ editor: resp } { editor: dataContent }
); );
} }
</script> </script>

View File

@@ -0,0 +1,10 @@
<script lang="ts">
import { cloudConnectionsStore } from '../stores';
import SubDatabaseList from './SubDatabaseList.svelte';
export let data;
</script>
{#if data.conid && $cloudConnectionsStore[data.conid]}
<SubDatabaseList {...$$props} data={$cloudConnectionsStore[data.conid]} />
{/if}

View File

@@ -1,87 +0,0 @@
<script lang="ts" context="module">
import Chart from 'chart.js/auto';
import 'chartjs-adapter-moment';
import zoomPlugin from 'chartjs-plugin-zoom';
const getCurrentEditor = () => getActiveComponent('ChartCore');
registerCommand({
id: 'chart.export',
category: 'Chart',
toolbarName: 'Export',
name: 'Export chart',
icon: 'icon report',
toolbar: true,
isRelatedToTab: true,
onClick: () => getCurrentEditor().exportChart(),
testEnabled: () => getCurrentEditor() != null,
});
Chart.register(zoomPlugin);
</script>
<script lang="ts">
import { onMount, afterUpdate, onDestroy } from 'svelte';
import _ from 'lodash';
import registerCommand from '../commands/registerCommand';
import { apiCall } from '../utility/api';
import contextMenu, { getContextMenu, registerMenu } from '../utility/contextMenu';
import createActivator, { getActiveComponent } from '../utility/createActivator';
import { saveFileToDisk } from '../utility/exportFileTools';
export let data;
export let title;
export let type = 'line';
export let options = {};
// export let plugins = {};
// export let menu;
export const activator = createActivator('ChartCore', true);
let chart = null;
let domChart;
onMount(() => {
chart = new Chart(domChart, {
type,
data: data,
// options must be cloned, because chartjs modifies options object, without cloning fails passing options to electron invoke
options: _.cloneDeep(options),
});
});
afterUpdate(() => {
if (!chart) return;
chart.data = data;
chart.type = type;
chart.options = _.cloneDeep(options);
// chart.plugins = plugins;
chart.update();
});
onDestroy(() => {
chart = null;
});
export async function exportChart() {
saveFileToDisk(async filePath => {
await apiCall('files/export-chart', {
title,
filePath,
config: {
type,
data,
options,
},
image: domChart.toDataURL(),
});
});
}
registerMenu({ command: 'chart.export', tag: 'export' });
const menu = getContextMenu();
</script>
<canvas bind:this={domChart} {...$$restProps} use:contextMenu={menu} />

View File

@@ -1,170 +0,0 @@
<script lang="ts">
import FormProviderCore from '../forms/FormProviderCore.svelte';
import HorizontalSplitter from '../elements/HorizontalSplitter.svelte';
import WidgetColumnBar from '../widgets/WidgetColumnBar.svelte';
import WidgetColumnBarItem from '../widgets/WidgetColumnBarItem.svelte';
import ManagerInnerContainer from '../elements/ManagerInnerContainer.svelte';
import FormSelectField from '../forms/FormSelectField.svelte';
import FormTextField from '../forms/FormTextField.svelte';
import FormCheckboxField from '../forms/FormCheckboxField.svelte';
import FormFieldTemplateTiny from '../forms/FormFieldTemplateTiny.svelte';
import { getConnectionInfo } from '../utility/metadataLoaders';
import { findEngineDriver } from 'dbgate-tools';
import { extensions } from '../stores';
import { loadChartData, loadChartStructure } from './chartDataLoader';
import DataChart from './DataChart.svelte';
import _ from 'lodash';
import ErrorInfo from '../elements/ErrorInfo.svelte';
import FormColorField from '../forms/FormColorField.svelte';
export let data;
export let configStore;
export let conid;
export let database;
export let sql;
// export let menu;
let availableColumnNames = [];
let errorLoadingColumns = null;
let errorLoadingData = null;
let loadedData = null;
$: config = $configStore;
const getDriver = async () => {
const conn = await getConnectionInfo({ conid });
if (!conn) return;
const driver = findEngineDriver(conn, $extensions);
return driver;
};
const handleLoadColumns = async () => {
const driver = await getDriver();
if (!driver) return;
try {
errorLoadingColumns = null;
const columns = await loadChartStructure(driver, conid, database, sql);
availableColumnNames = columns;
// configStore.update(x => ({ ...x, labelColumn: availableColumnNames[0] }));
} catch (err) {
console.error(err);
errorLoadingColumns = err.message;
}
};
const handleLoadData = async () => {
const driver = await getDriver();
if (!driver) return;
try {
errorLoadingData = null;
const loaded = await loadChartData(driver, conid, database, sql, config);
if (!loaded) return;
const { columns, rows } = loaded;
loadedData = {
structure: columns,
rows,
};
} catch (err) {
console.error(err);
errorLoadingData = err.message;
}
};
$: {
$extensions;
if (sql && conid && database) {
handleLoadColumns();
}
}
$: {
if (data) {
availableColumnNames = data.structure.columns.map(x => x.columnName);
}
}
$: {
$extensions;
if (config.labelColumn && sql && conid && database) {
handleLoadData();
}
}
let managerSize;
</script>
<FormProviderCore values={configStore} template={FormFieldTemplateTiny}>
<HorizontalSplitter initialValue="300px" bind:size={managerSize}>
<div class="left" slot="1">
<WidgetColumnBar>
<WidgetColumnBarItem title="Style" name="style" height="40%">
<ManagerInnerContainer width={managerSize}>
<FormSelectField
label="Chart type"
name="chartType"
isNative
options={[
{ value: 'bar', label: 'Bar' },
{ value: 'line', label: 'Line' },
{ value: 'pie', label: 'Pie' },
{ value: 'polarArea', label: 'Polar area' },
]}
/>
<FormTextField label="Chart title" name="chartTitle" />
<FormSelectField
label="Truncate from"
name="truncateFrom"
isNative
options={[
{ value: 'begin', label: 'Begin' },
{ value: 'end', label: 'End (most recent data for datetime)' },
]}
/>
<FormTextField label="Truncate limit" name="truncateLimit" />
<FormCheckboxField label="Show relative values" name="showRelativeValues" />
{#if $configStore.chartType == 'line'}
<FormCheckboxField label="Fill" name="fillLineChart" defaultValue={true} />
{/if}
<FormTextField label="Color set" name="colorSeed" />
</ManagerInnerContainer>
</WidgetColumnBarItem>
<WidgetColumnBarItem title="Data" name="data">
<ManagerInnerContainer width={managerSize}>
{#if availableColumnNames.length > 0}
<FormSelectField
label="Label column"
name="labelColumn"
isNative
options={availableColumnNames.map(col => ({ value: col, label: col }))}
/>
{/if}
{#each availableColumnNames as col (col)}
<FormCheckboxField label={col} name={`dataColumn_${col}`} />
{#if config[`dataColumn_${col}`]}
<FormColorField label="Color" name={`dataColumnColor_${col}`} emptyLabel="Random" />
<FormTextField label="Label" name={`dataColumnLabel_${col}`} />
{/if}
{/each}
</ManagerInnerContainer>
</WidgetColumnBarItem>
</WidgetColumnBar>
</div>
<svelte:fragment slot="2">
{#if errorLoadingColumns}
<ErrorInfo message={errorLoadingColumns} alignTop />
{:else if errorLoadingData}
<ErrorInfo message={errorLoadingData} alignTop />
{:else}
<DataChart data={data || loadedData} />
{/if}
</svelte:fragment>
</HorizontalSplitter>
</FormProviderCore>
<style>
.left {
background-color: var(--theme-bg-0);
display: flex;
flex: 1;
}
</style>

View File

@@ -1,198 +0,0 @@
<script lang="ts" context="module">
function getTimeAxis(labels) {
const res = [];
for (const label of labels) {
const parsed = moment(label);
if (!parsed.isValid()) return null;
const iso = parsed.toISOString();
if (iso < '1850-01-01T00:00:00' || iso > '2150-01-01T00:00:00') return null;
res.push(parsed);
}
return res;
}
function getLabels(labelValues, timeAxis, chartType) {
if (!timeAxis) return labelValues;
if (chartType === 'line') return timeAxis.map(x => x.toDate());
return timeAxis.map(x => x.format('D. M. YYYY'));
}
function getOptions(timeAxis, chartType) {
const res = {
scales: {},
};
if (timeAxis && chartType === 'line') {
res.scales = {
x: {
type: 'time',
distribution: 'linear',
time: {
tooltipFormat: 'D. M. YYYY HH:mm',
displayFormats: {
millisecond: 'HH:mm:ss.SSS',
second: 'HH:mm:ss',
minute: 'HH:mm',
hour: 'D.M hA',
day: 'D. M.',
week: 'D. M. YYYY',
month: 'MM-YYYY',
quarter: '[Q]Q - YYYY',
year: 'YYYY',
},
},
},
};
}
return res;
}
function getPlugins(chartTitle) {
const res = {};
if (chartTitle) {
res['title'] = {
display: true,
text: chartTitle,
};
}
res['zoom'] = {
zoom: {
wheel: {
enabled: true,
},
pinch: {
enabled: true,
},
drag: {
enabled: true,
modifierKey: 'shift',
},
mode: 'x',
},
pan: {
enabled: true,
mode: 'x',
},
};
return res;
}
function createChartData(
freeData,
labelColumn,
dataColumns,
colorSeed,
chartType,
chartTitle,
fillLineChart,
dataColumnColors,
dataColumnLabels,
themeDef
) {
if (!freeData || !labelColumn || !dataColumns || !freeData.rows || dataColumns.length == 0) return null;
const palettes = themeDef?.themeType == 'dark' ? presetDarkPalettes : presetPalettes;
const colors = randomcolor({
count: _.max([freeData.rows.length, dataColumns.length, 1]),
seed: colorSeed,
});
let backgroundColor = null;
let borderColor = null;
const labelValues = freeData.rows.map(x => x[labelColumn]);
const timeAxis = getTimeAxis(labelValues);
const labels = getLabels(labelValues, timeAxis, chartType);
const res = {
labels,
datasets: dataColumns.map((dataColumn, columnIndex) => {
const label = dataColumnLabels[dataColumn];
if (chartType == 'line' || chartType == 'bar') {
const color = dataColumnColors[dataColumn];
if (color) {
backgroundColor = palettes[color][4] + '80';
borderColor = palettes[color][7];
} else {
backgroundColor = colors[columnIndex] + '80';
borderColor = colors[columnIndex];
}
} else {
backgroundColor = colors;
}
return {
label: label || dataColumn,
data: freeData.rows.map(row => row[dataColumn]),
backgroundColor,
borderColor,
borderWidth: 1,
fill: fillLineChart == false ? false : true,
};
}),
};
const options = getOptions(timeAxis, chartType);
const plugins = getPlugins(chartTitle);
// console.log('RES', res);
// console.log('OPTIONS', options);
return [res, options, plugins];
}
</script>
<script lang="ts">
import _ from 'lodash';
import randomcolor from 'randomcolor';
import moment from 'moment';
import ChartCore from './ChartCore.svelte';
import { getFormContext } from '../forms/FormProviderCore.svelte';
import { generate, presetPalettes, presetDarkPalettes, presetPrimaryColors } from '@ant-design/colors';
import { extractDataColumnColors, extractDataColumnLabels, extractDataColumns } from './chartDataLoader';
import { currentThemeDefinition } from '../stores';
export let data;
// export let menu;
const { values } = getFormContext();
let clientWidth;
let clientHeight;
$: dataColumns = extractDataColumns($values);
$: dataColumnColors = extractDataColumnColors($values, dataColumns);
$: dataColumnLabels = extractDataColumnLabels($values, dataColumns);
$: chartData = createChartData(
data,
$values.labelColumn,
dataColumns,
$values.colorSeed || '5',
$values.chartType,
$values.chartTitle,
$values.fillLineChart,
dataColumnColors,
dataColumnLabels,
$currentThemeDefinition
);
</script>
<div class="wrapper" bind:clientWidth bind:clientHeight>
{#if chartData}
{#key `${$values.chartType}|${clientWidth}|${clientHeight}`}
<ChartCore
width={clientWidth}
height={clientHeight}
data={chartData[0]}
type={$values.chartType}
title={$values.chartTitle}
options={{ ...chartData[1], plugins: chartData[2] }}
/>
{/key}
{/if}
</div>
<style>
.wrapper {
flex: 1;
overflow: hidden;
}
</style>

View File

@@ -1,133 +0,0 @@
import type { Select } from 'dbgate-sqltree';
import type { EngineDriver } from 'dbgate-types';
import _ from 'lodash';
import { apiCall } from '../utility/api';
export async function loadChartStructure(driver: EngineDriver, conid, database, sql) {
const select: Select = {
commandType: 'select',
selectAll: true,
topRecords: 1,
from: {
subQueryString: sql,
alias: 'subq',
},
};
const resp = await apiCall('database-connections/sql-select', { conid, database, select });
if (resp.errorMessage) throw new Error(resp.errorMessage);
return resp.columns.map(x => x.columnName);
}
export async function loadChartData(driver: EngineDriver, conid, database, sql, config) {
const dataColumns = extractDataColumns(config);
const { labelColumn, truncateFrom, truncateLimit, showRelativeValues } = config;
if (!labelColumn || !dataColumns || dataColumns.length == 0) return null;
const select: Select = {
commandType: 'select',
columns: [
{
exprType: 'column',
source: { alias: 'subq' },
columnName: labelColumn,
alias: labelColumn,
},
// @ts-ignore
...dataColumns.map(columnName => ({
exprType: 'call',
func: 'SUM',
args: [
{
exprType: 'column',
columnName,
source: { alias: 'subq' },
},
],
alias: columnName,
})),
],
topRecords: truncateLimit || 100,
from: {
subQueryString: sql,
alias: 'subq',
},
groupBy: [
{
exprType: 'column',
source: { alias: 'subq' },
columnName: labelColumn,
},
],
orderBy: [
{
exprType: 'column',
source: { alias: 'subq' },
columnName: labelColumn,
direction: truncateFrom == 'end' ? 'DESC' : 'ASC',
},
],
};
const resp = await apiCall('database-connections/sql-select', { conid, database, select });
let { rows, columns, errorMessage } = resp;
if (errorMessage) {
throw new Error(errorMessage);
}
if (truncateFrom == 'end' && rows) {
rows = _.reverse([...rows]);
}
if (showRelativeValues) {
const maxValues = dataColumns.map(col => _.max(rows.map(row => row[col])));
for (const [col, max] of _.zip(dataColumns, maxValues)) {
if (!max) continue;
if (!_.isNumber(max)) continue;
if (!(max > 0)) continue;
rows = rows.map(row => ({
...row,
[col]: (row[col] / max) * 100,
}));
// columns = columns.map((x) => {
// if (x.columnName == col) {
// return { columnName: `${col} %` };
// }
// return x;
// });
}
}
console.log('Loaded chart data', { columns, rows });
return {
columns,
rows,
};
}
export function extractDataColumns(values) {
const dataColumns = [];
for (const key in values) {
if (key.startsWith('dataColumn_') && values[key]) {
dataColumns.push(key.substring('dataColumn_'.length));
}
}
return dataColumns;
}
export function extractDataColumnColors(values, dataColumns) {
const res = {};
for (const column of dataColumns) {
const color = values[`dataColumnColor_${column}`];
if (color) res[column] = color;
}
return res;
}
export function extractDataColumnLabels(values, dataColumns) {
const res = {};
for (const column of dataColumns) {
const label = values[`dataColumnLabel_${column}`];
if (label) res[column] = label;
}
return res;
}

View File

@@ -164,109 +164,175 @@
}} }}
data-testid='CommandPalette_main' data-testid='CommandPalette_main'
> >
<div class="pages"> <div
<div class="overlay"
class="page" on:click={() => {
class:selected={$visibleCommandPalette == 'menu'} $visibleCommandPalette = null;
on:click={() => { }}
$visibleCommandPalette = 'menu'; />
domInput.focus(); <div class="palette">
}} <div class="pages">
> <div
<FontIcon icon="icon menu" /> Commands class="page"
class:selected={$visibleCommandPalette == 'menu'}
on:click={() => {
$visibleCommandPalette = 'menu';
domInput.focus();
}}
>
<FontIcon icon="icon menu" /> Commands
</div>
<div
class="page"
class:selected={$visibleCommandPalette == 'database'}
on:click={() => {
$visibleCommandPalette = 'database';
domInput.focus();
}}
>
<FontIcon icon="icon database" /> Database
</div>
</div> </div>
<div <div class="mainInner">
class="page" <div class="search">
class:selected={$visibleCommandPalette == 'database'} <input
on:click={() => { type="text"
$visibleCommandPalette = 'database'; bind:this={domInput}
domInput.focus(); bind:value={filter}
}} on:keydown={handleKeyDown}
> placeholder={parentCommand?.text ||
<FontIcon icon="icon database" /> Database ($visibleCommandPalette == 'database' ? 'Search in database' : 'Search in commands')}
</div> />
</div> </div>
<div class="mainInner"> <div class="content">
<div class="search"> {#each filteredItems as command, index}
<input <div
type="text" class="command"
bind:this={domInput} class:selected={index == selectedIndex}
bind:value={filter} on:click={() => handleCommand(command)}
on:keydown={handleKeyDown} bind:this={domItems[index]}
placeholder={parentCommand?.text || >
($visibleCommandPalette == 'database' ? 'Search in database' : 'Search in commands')} <div>
/> {#if command.icon}
</div> <span class="mr-1"><FontIcon icon={command.icon} /></span>
<div class="content"> {/if}
{#each filteredItems as command, index} {@html command.text}
<div </div>
class="command" {#if command.keyText}
class:selected={index == selectedIndex} <div class="shortcut">{formatKeyText(command.keyText)}</div>
on:click={() => handleCommand(command)}
bind:this={domItems[index]}
>
<div>
{#if command.icon}
<span class="mr-1"><FontIcon icon={command.icon} /></span>
{/if} {/if}
{@html command.text}
</div> </div>
{#if command.keyText} {/each}
<div class="shortcut">{formatKeyText(command.keyText)}</div> </div>
{/if}
</div>
{/each}
</div> </div>
</div> </div>
</div> </div>
<style> <style>
.main { .main {
width: 500px; position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: flex-start;
padding-top: 100px;
}
.overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.4);
z-index: 1;
}
.palette {
position: relative;
z-index: 2;
width: 600px;
background: var(--theme-bg-2); background: var(--theme-bg-2);
border-radius: 4px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
} }
.mainInner { .mainInner {
padding: 5px; padding: 8px;
} }
.content { .content {
max-height: 400px; max-height: 500px;
overflow-y: scroll; overflow-y: auto;
margin-top: 8px;
} }
.search { .search {
display: flex; display: flex;
margin-bottom: 8px;
} }
input { input {
width: 100%; width: 100%;
padding: 8px;
background: var(--theme-bg-1);
border: 1px solid var(--theme-border);
border-radius: 4px;
color: var(--theme-font);
font-size: 14px;
} }
input:focus {
outline: none;
border-color: var(--theme-accent);
}
.command { .command {
padding: 5px; padding: 8px 12px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center;
cursor: pointer;
border-radius: 4px;
} }
.command:hover { .command:hover {
background: var(--theme-bg-3); background: var(--theme-bg-3);
} }
.command.selected { .command.selected {
background: var(--theme-bg-selected); background: var(--theme-bg-selected);
} }
.shortcut { .shortcut {
background: var(--theme-bg-3); background: var(--theme-bg-3);
padding: 2px 6px;
border-radius: 3px;
font-size: 12px;
color: var(--theme-font-dimmed);
} }
.pages { .pages {
display: flex; display: flex;
border-bottom: 1px solid var(--theme-border);
} }
.page { .page {
padding: 5px; padding: 8px 16px;
border: 1px solid var(--theme-border);
cursor: pointer; cursor: pointer;
color: var(--theme-font-dimmed);
border-bottom: 2px solid transparent;
} }
.page:hover { .page:hover {
color: var(--theme-font-hover); color: var(--theme-font);
} }
.page.selected { .page.selected {
background: var(--theme-bg-1); color: var(--theme-font);
border-bottom-color: var(--theme-accent);
} }
</style> </style>

View File

@@ -1,4 +1,5 @@
import { import {
cloudSigninTokenHolder,
currentDatabase, currentDatabase,
currentTheme, currentTheme,
emptyConnectionGroupNames, emptyConnectionGroupNames,
@@ -123,6 +124,27 @@ registerCommand({
}, },
}); });
registerCommand({
id: 'new.connectionOnCloud',
toolbar: true,
icon: 'img cloud-connection',
toolbarName: 'Add connection on cloud',
category: 'New',
toolbarOrder: 1,
name: 'Connection on Cloud',
testEnabled: () => !getCurrentConfig()?.runAsPortal && !getCurrentConfig()?.storageDatabase && isProApp(),
onClick: () => {
openNewTab({
title: 'New Connection on Cloud',
icon: 'img cloud-connection',
tabComponent: 'ConnectionTab',
props: {
saveOnCloud: true,
},
});
},
});
registerCommand({ registerCommand({
id: 'new.connection.folder', id: 'new.connection.folder',
toolbar: true, toolbar: true,
@@ -535,7 +557,7 @@ registerCommand({
id: 'app.exportConnections', id: 'app.exportConnections',
category: 'Settings', category: 'Settings',
name: 'Export connections', name: 'Export connections',
testEnabled: () => getElectron() != null, testEnabled: () => !getCurrentConfig()?.runAsPortal && !getCurrentConfig()?.storageDatabase,
onClick: () => { onClick: () => {
showModal(ExportImportConnectionsModal, { showModal(ExportImportConnectionsModal, {
mode: 'export', mode: 'export',
@@ -547,7 +569,7 @@ registerCommand({
id: 'app.importConnections', id: 'app.importConnections',
category: 'Settings', category: 'Settings',
name: 'Import connections', name: 'Import connections',
testEnabled: () => getElectron() != null, testEnabled: () => !getCurrentConfig()?.runAsPortal && !getCurrentConfig()?.storageDatabase,
onClick: async () => { onClick: async () => {
const files = await electron.showOpenDialog({ const files = await electron.showOpenDialog({
properties: ['showHiddenFiles', 'openFile'], properties: ['showHiddenFiles', 'openFile'],
@@ -662,6 +684,15 @@ if (hasPermission('settings/change')) {
}); });
} }
registerCommand({
id: 'cloud.logout',
category: 'Cloud',
name: 'Logout',
onClick: () => {
cloudSigninTokenHolder.set(null);
},
});
registerCommand({ registerCommand({
id: 'file.exit', id: 'file.exit',
category: 'File', category: 'File',
@@ -929,9 +960,17 @@ registerCommand({
id: 'app.openSponsoring', id: 'app.openSponsoring',
category: 'Application', category: 'Application',
name: 'Become sponsor', name: 'Become sponsor',
testEnabled: () => !isProApp(),
onClick: () => openWebLink('https://opencollective.com/dbgate'), onClick: () => openWebLink('https://opencollective.com/dbgate'),
}); });
registerCommand({
id: 'app.giveFeedback',
category: 'Application',
name: 'Give us feedback',
onClick: () => openWebLink('https://dbgate.org/feedback'),
});
registerCommand({ registerCommand({
id: 'app.zoomIn', id: 'app.zoomIn',
category: 'Application', category: 'Application',

View File

@@ -16,6 +16,8 @@
{ useThousandsSeparator: getBoolSettingsValue('dataGrid.thousandsSeparator', false) }, { useThousandsSeparator: getBoolSettingsValue('dataGrid.thousandsSeparator', false) },
jsonParsedValue jsonParsedValue
); );
// $: console.log('CellValue', value, stringified);
</script> </script>
{#if rowData == null} {#if rowData == null}

View File

@@ -54,7 +54,8 @@
$: style = computeStyle(maxWidth, col); $: style = computeStyle(maxWidth, col);
$: isJson = _.isPlainObject(value) && !(value?.type == 'Buffer' && _.isArray(value.data)) && !value.$oid; $: isJson =
_.isPlainObject(value) && !(value?.type == 'Buffer' && _.isArray(value.data)) && !value.$oid && !value.$bigint;
// don't parse JSON for explicit data types // don't parse JSON for explicit data types
$: jsonParsedValue = !editorTypes?.explicitDataType && isJsonLikeLongString(value) ? safeJsonParse(value) : null; $: jsonParsedValue = !editorTypes?.explicitDataType && isJsonLikeLongString(value) ? safeJsonParse(value) : null;

View File

@@ -261,13 +261,6 @@
testEnabled: () => getCurrentDataGrid() != null, testEnabled: () => getCurrentDataGrid() != null,
onClick: () => getCurrentDataGrid().openFreeTable(), onClick: () => getCurrentDataGrid().openFreeTable(),
}); });
registerCommand({
id: 'dataGrid.openChartFromSelection',
category: 'Data grid',
name: 'Open chart from selection',
testEnabled: () => getCurrentDataGrid() != null,
onClick: () => getCurrentDataGrid().openChartFromSelection(),
});
registerCommand({ registerCommand({
id: 'dataGrid.newJson', id: 'dataGrid.newJson',
category: 'Data grid', category: 'Data grid',
@@ -469,6 +462,7 @@
export let hideGridLeftColumn = false; export let hideGridLeftColumn = false;
export let overlayDefinition = null; export let overlayDefinition = null;
export let onGetSelectionMenu = null; export let onGetSelectionMenu = null;
export let onOpenChart = null;
export const activator = createActivator('DataGridCore', false); export const activator = createActivator('DataGridCore', false);
@@ -715,23 +709,6 @@
openJsonLinesData(getSelectedFreeDataRows()); openJsonLinesData(getSelectedFreeDataRows());
} }
export function openChartFromSelection() {
openNewTab(
{
title: 'Chart #',
icon: 'img chart',
tabComponent: 'ChartTab',
props: {},
},
{
editor: {
data: getSelectedFreeData(),
config: { chartType: 'bar' },
},
}
);
}
export function viewJsonDocumentEnabled() { export function viewJsonDocumentEnabled() {
return isDynamicStructure && _.uniq(selectedCells.map(x => x[0])).length == 1; return isDynamicStructure && _.uniq(selectedCells.map(x => x[0])).length == 1;
} }
@@ -1869,9 +1846,13 @@
// ], // ],
// }, // },
isProApp() && { command: 'dataGrid.sendToDataDeploy' }, isProApp() && { command: 'dataGrid.sendToDataDeploy' },
isProApp() &&
onOpenChart && {
text: 'Open chart',
onClick: () => onOpenChart(),
},
{ command: 'dataGrid.generateSqlFromData' }, { command: 'dataGrid.generateSqlFromData' },
{ command: 'dataGrid.openFreeTable' }, { command: 'dataGrid.openFreeTable' },
{ command: 'dataGrid.openChartFromSelection' },
{ command: 'dataGrid.openSelectionInMap', hideDisabled: true }, { command: 'dataGrid.openSelectionInMap', hideDisabled: true },
{ placeTag: 'chart' } { placeTag: 'chart' }
); );

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { createGridCache, createGridConfig, FreeTableGridDisplay } from 'dbgate-datalib';
import { writable } from 'svelte/store';
import DataGridCore from './DataGridCore.svelte';
import RowsArrayGrider from './RowsArrayGrider';
import ErrorInfo from '../elements/ErrorInfo.svelte';
import LoadingInfo from '../elements/LoadingInfo.svelte';
export let model;
let errorMessage = null;
const config = writable(createGridConfig());
const cache = writable(createGridCache());
$: grider = new RowsArrayGrider(model.rows);
$: display = new FreeTableGridDisplay(model, $config, config.update, $cache, cache.update);
</script>
{#if !model}
<LoadingInfo wrapper message="Loading data" />
{:else if errorMessage}
<ErrorInfo message={errorMessage} />
{:else if grider}
<DataGridCore {...$$props} {grider} {display} />
{/if}

View File

@@ -1,14 +1,6 @@
<script context="module" lang="ts"> <script context="module" lang="ts">
const getCurrentEditor = () => getActiveComponent('SqlDataGridCore'); const getCurrentEditor = () => getActiveComponent('SqlDataGridCore');
registerCommand({
id: 'sqlDataGrid.openActiveChart',
category: 'Data grid',
name: 'Open active chart',
testEnabled: () => getCurrentEditor() != null && hasPermission('dbops/charts'),
onClick: () => getCurrentEditor().openActiveChart(),
});
registerCommand({ registerCommand({
id: 'sqlDataGrid.openQuery', id: 'sqlDataGrid.openQuery',
category: 'Data grid', category: 'Data grid',
@@ -190,28 +182,6 @@
openQuery(display.getPageQueryText(0, getIntSettingsValue('dataGrid.pageSize', 100, 5, 1000))); openQuery(display.getPageQueryText(0, getIntSettingsValue('dataGrid.pageSize', 100, 5, 1000)));
} }
export function openActiveChart() {
openNewTab(
{
title: 'Chart #',
icon: 'img chart',
tabComponent: 'ChartTab',
props: {
conid,
database,
},
},
{
editor: {
config: { chartType: 'bar' },
sql: display.getExportQuery(select => {
select.orderBy = null;
}),
},
}
);
}
const quickExportHandler = fmt => async () => { const quickExportHandler = fmt => async () => {
const coninfo = await getConnectionInfo({ conid }); const coninfo = await getConnectionInfo({ conid });
exportQuickExportFile( exportQuickExportFile(

View File

@@ -72,6 +72,7 @@ export function countColumnSizes(grider: Grider, columns, containerWidth, displa
let text = value; let text = value;
if (_.isArray(value)) text = `[${value.length} items]`; if (_.isArray(value)) text = `[${value.length} items]`;
else if (value?.$oid) text = `ObjectId("${value.$oid}")`; else if (value?.$oid) text = `ObjectId("${value.$oid}")`;
else if (value?.$bigint) text = value.$bigint;
else if (isJsonLikeLongString(value) && safeJsonParse(value)) text = '(JSON)'; else if (isJsonLikeLongString(value) && safeJsonParse(value)) text = '(JSON)';
const width = context.measureText(text).width + 8; const width = context.measureText(text).width + 8;
// console.log('colName', colName, text, width); // console.log('colName', colName, text, width);

View File

@@ -39,7 +39,7 @@
$: size = computeSplitterSize(initialValue, clientWidth, customRatio, initialSizeRight); $: size = computeSplitterSize(initialValue, clientWidth, customRatio, initialSizeRight);
$: if (onChangeSize) onChangeSize(size); $: if (onChangeSize) onChangeSize(size, clientWidth - size);
</script> </script>
<div class="container" bind:clientWidth> <div class="container" bind:clientWidth>

View File

@@ -1,4 +1,8 @@
<div><slot /></div> <script lang="ts">
export let noMargin = false;
</script>
<div class:noMargin><slot /></div>
<style> <style>
div { div {
@@ -6,4 +10,8 @@
border-bottom: 1px solid var(--theme-border); border-bottom: 1px solid var(--theme-border);
margin-bottom: 5px; margin-bottom: 5px;
} }
div.noMargin {
margin-bottom: 0;
}
</style> </style>

View File

@@ -18,6 +18,7 @@
export let flex1 = true; export let flex1 = true;
export let contentTestId = undefined; export let contentTestId = undefined;
export let inlineTabs = false; export let inlineTabs = false;
export let onUserChange = null;
export function setValue(index) { export function setValue(index) {
value = index; value = index;
@@ -30,8 +31,16 @@
<div class="main" class:flex1> <div class="main" class:flex1>
<div class="tabs" class:inlineTabs> <div class="tabs" class:inlineTabs>
{#each _.compact(tabs) as tab, index} {#each _.compact(tabs) as tab, index}
<div class="tab-item" class:selected={value == index} on:click={() => (value = index)} data-testid={tab.testid}> <div
<span class="ml-2"> class="tab-item"
class:selected={value == index}
on:click={() => {
value = index;
onUserChange?.(index);
}}
data-testid={tab.testid}
>
<span class="ml-2 noselect">
{tab.label} {tab.label}
</span> </span>
</div> </div>
@@ -139,5 +148,4 @@
.container.isInline:not(.tabVisible) { .container.isInline:not(.tabVisible) {
display: none; display: none;
} }
</style> </style>

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { useCloudContentList } from '../utility/metadataLoaders';
import FormSelectField from './FormSelectField.svelte';
export let name;
export let requiredRoleVariants = ['read', 'write', 'admin'];
export let prependFolders = [];
const cloudContentList = useCloudContentList();
$: folderOptions = [
...prependFolders.map(folder => ({
value: folder.folid,
label: folder.name,
})),
...($cloudContentList || [])
.filter(folder => requiredRoleVariants.find(role => folder.role == role))
.map(folder => ({
value: folder.folid,
label: folder.name,
})),
];
</script>
<FormSelectField {...$$props} options={folderOptions} />

View File

@@ -39,6 +39,9 @@
'icon minus-thick': 'mdi mdi-minus-thick', 'icon minus-thick': 'mdi mdi-minus-thick',
'icon invisible-box': 'mdi mdi-minus-box-outline icon-invisible', 'icon invisible-box': 'mdi mdi-minus-box-outline icon-invisible',
'icon cloud-upload': 'mdi mdi-cloud-upload', 'icon cloud-upload': 'mdi mdi-cloud-upload',
'icon cloud': 'mdi mdi-cloud',
'icon cloud-public': 'mdi mdi-cloud-search',
'icon cloud-private': 'mdi mdi-cloud-key',
'icon import': 'mdi mdi-application-import', 'icon import': 'mdi mdi-application-import',
'icon export': 'mdi mdi-application-export', 'icon export': 'mdi mdi-application-export',
'icon new-connection': 'mdi mdi-database-plus', 'icon new-connection': 'mdi mdi-database-plus',
@@ -68,6 +71,7 @@
'icon trigger': 'mdi mdi-lightning-bolt', 'icon trigger': 'mdi mdi-lightning-bolt',
'icon scheduler-event': 'mdi mdi-calendar-blank', 'icon scheduler-event': 'mdi mdi-calendar-blank',
'icon arrow-link': 'mdi mdi-arrow-top-right-thick', 'icon arrow-link': 'mdi mdi-arrow-top-right-thick',
'icon reset': 'mdi mdi-cancel',
'icon window-restore': 'mdi mdi-window-restore', 'icon window-restore': 'mdi mdi-window-restore',
'icon window-maximize': 'mdi mdi-window-maximize', 'icon window-maximize': 'mdi mdi-window-maximize',
@@ -112,6 +116,9 @@
'icon square': 'mdi mdi-square', 'icon square': 'mdi mdi-square',
'icon data-deploy': 'mdi mdi-database-settings', 'icon data-deploy': 'mdi mdi-database-settings',
'icon cloud-account': 'mdi mdi-account-remove-outline',
'icon cloud-account-connected': 'mdi mdi-account-check-outline',
'icon edit': 'mdi mdi-pencil', 'icon edit': 'mdi mdi-pencil',
'icon delete': 'mdi mdi-delete', 'icon delete': 'mdi mdi-delete',
'icon arrow-up': 'mdi mdi-arrow-up', 'icon arrow-up': 'mdi mdi-arrow-up',
@@ -151,6 +158,7 @@
'icon text': 'mdi mdi-text', 'icon text': 'mdi mdi-text',
'icon ai': 'mdi mdi-head-lightbulb', 'icon ai': 'mdi mdi-head-lightbulb',
'icon wait': 'mdi mdi-timer-sand', 'icon wait': 'mdi mdi-timer-sand',
'icon more': 'mdi mdi-more',
'icon run': 'mdi mdi-play', 'icon run': 'mdi mdi-play',
'icon chevron-down': 'mdi mdi-chevron-down', 'icon chevron-down': 'mdi mdi-chevron-down',
@@ -222,6 +230,7 @@
'icon premium': 'mdi mdi-star', 'icon premium': 'mdi mdi-star',
'icon upload': 'mdi mdi-upload', 'icon upload': 'mdi mdi-upload',
'icon limit': 'mdi mdi-car-speed-limiter',
'img ok': 'mdi mdi-check-circle color-icon-green', 'img ok': 'mdi mdi-check-circle color-icon-green',
'img ok-inv': 'mdi mdi-check-circle color-icon-inv-green', 'img ok-inv': 'mdi mdi-check-circle color-icon-inv-green',
@@ -262,6 +271,7 @@
'img role': 'mdi mdi-account-group color-icon-blue', 'img role': 'mdi mdi-account-group color-icon-blue',
'img admin': 'mdi mdi-security color-icon-blue', 'img admin': 'mdi mdi-security color-icon-blue',
'img auth': 'mdi mdi-account-key color-icon-blue', 'img auth': 'mdi mdi-account-key color-icon-blue',
'img cloud-connection': 'mdi mdi-cloud-lock color-icon-blue',
'img add': 'mdi mdi-plus-circle color-icon-green', 'img add': 'mdi mdi-plus-circle color-icon-green',
'img minus': 'mdi mdi-minus-circle color-icon-red', 'img minus': 'mdi mdi-minus-circle color-icon-red',

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import FormStyledButton from '../buttons/FormStyledButton.svelte';
import FormCloudFolderSelect from '../forms/FormCloudFolderSelect.svelte';
import FormProvider from '../forms/FormProvider.svelte';
import FormSubmit from '../forms/FormSubmit.svelte';
import { useCloudContentList } from '../utility/metadataLoaders';
import ModalBase from './ModalBase.svelte';
import { closeCurrentModal } from './modalTools';
export let message = '';
export let onConfirm;
export let requiredRoleVariants;
const cloudContentList = useCloudContentList();
</script>
{#if $cloudContentList}
<FormProvider initialValues={{ cloudFolder: $cloudContentList?.find(x => x.isPrivate)?.folid }}>
<ModalBase {...$$restProps}>
<svelte:fragment slot="header">Choose cloud folder</svelte:fragment>
<div>{message}</div>
<FormCloudFolderSelect label="Cloud folder" name="cloudFolder" isNative {requiredRoleVariants} />
<svelte:fragment slot="footer">
<FormSubmit
value="OK"
on:click={e => {
closeCurrentModal();
console.log('onConfirm', e.detail);
onConfirm(e.detail.cloudFolder);
}}
/>
<FormStyledButton type="button" value="Close" on:click={closeCurrentModal} />
</svelte:fragment>
</ModalBase>
</FormProvider>
{/if}

View File

@@ -0,0 +1,71 @@
<script>
import FormStyledButton from '../buttons/FormStyledButton.svelte';
import FormProvider from '../forms/FormProvider.svelte';
import FormSubmit from '../forms/FormSubmit.svelte';
import FontIcon from '../icons/FontIcon.svelte';
import { isProApp } from '../utility/proTools';
import { openWebLink } from '../utility/simpleTools';
import ModalBase from './ModalBase.svelte';
import { closeCurrentModal } from './modalTools';
export let message;
export let licenseLimits;
</script>
<FormProvider>
<ModalBase {...$$restProps}>
<div slot="header">License limit error</div>
<div class="wrapper">
<div class="icon">
<FontIcon icon="img error" />
</div>
<div data-testid="LicenseLimitMessageModal_message">
<p>
Cloud operation ended with error:<br />
{message}
</p>
<p>
This is a limitation of the free version of DbGate. To continue using cloud operations, please {#if !isProApp()}download
and{/if} purchase DbGate Premium.
</p>
<p>Free version limit:</p>
<ul>
{#each licenseLimits || [] as limit}
<li>{limit}</li>
{/each}
</ul>
</div>
</div>
<div slot="footer">
<FormSubmit value="Close" on:click={closeCurrentModal} data-testid="LicenseLimitMessageModal_closeButton" />
{#if !isProApp()}
<FormStyledButton
value="Download DbGate Premium"
on:click={() => openWebLink('https://dbgate.io/download/')}
skipWidth
/>
{/if}
<FormStyledButton
value="Purchase DbGate Premium"
on:click={() => openWebLink('https://dbgate.io/purchase/premium/')}
skipWidth
/>
</div>
</ModalBase>
</FormProvider>
<style>
.wrapper {
display: flex;
}
.icon {
margin-right: 10px;
font-size: 20pt;
padding-top: 30px;
}
</style>

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import FormStyledButton from '../buttons/FormStyledButton.svelte';
import FormProvider from '../forms/FormProvider.svelte';
import FormSubmit from '../forms/FormSubmit.svelte';
import FormTextField from '../forms/FormTextField.svelte';
import ModalBase from './ModalBase.svelte';
import { closeCurrentModal } from './modalTools';
export let value;
export let onConfirm;
const handleSubmit = async value => {
closeCurrentModal();
onConfirm(value);
};
</script>
<FormProvider initialValues={{ value }}>
<ModalBase {...$$restProps}>
<svelte:fragment slot="header">Rows limit</svelte:fragment>
<FormTextField
label="Return only N rows from query"
name="value"
focused
data-testid="RowsLimitModal_value"
placeholder="(No rows limit)"
/>
<svelte:fragment slot="footer">
<FormSubmit
value="OK"
on:click={e => handleSubmit(parseInt(e.detail.value) || null)}
data-testid="RowsLimitModal_setLimit"
/>
<FormStyledButton value="Set no limit" on:click={e => handleSubmit(null)} data-testid="RowsLimitModal_setNoLimit" />
<FormStyledButton type="button" value="Cancel" on:click={closeCurrentModal} data-testid="RowsLimitModal_cancel" />
</svelte:fragment>
</ModalBase>
</FormProvider>

View File

@@ -1,15 +1,18 @@
<script lang="ts"> <script lang="ts">
import FormStyledButton from '../buttons/FormStyledButton.svelte'; import FormStyledButton from '../buttons/FormStyledButton.svelte';
import FormProvider from '../forms/FormProvider.svelte'; import FormProviderCore from '../forms/FormProviderCore.svelte';
import FormSubmit from '../forms/FormSubmit.svelte'; import FormSubmit from '../forms/FormSubmit.svelte';
import FormTextField from '../forms/FormTextField.svelte'; import FormTextField from '../forms/FormTextField.svelte';
import { cloudSigninTokenHolder } from '../stores';
import { _t } from '../translations'; import { _t } from '../translations';
import { apiCall } from '../utility/api'; import { apiCall } from '../utility/api';
import { writable } from 'svelte/store';
import getElectron from '../utility/getElectron'; import getElectron from '../utility/getElectron';
import ModalBase from './ModalBase.svelte'; import ModalBase from './ModalBase.svelte';
import { closeCurrentModal } from './modalTools'; import { closeCurrentModal, showModal } from './modalTools';
import FormCloudFolderSelect from '../forms/FormCloudFolderSelect.svelte';
export let data; export let data;
export let name; export let name;
@@ -18,19 +21,48 @@
export let fileExtension; export let fileExtension;
export let filePath; export let filePath;
export let onSave = undefined; export let onSave = undefined;
export let folid;
// export let cntid;
const values = writable({ name, cloudFolder: folid ?? '__local' });
const electron = getElectron(); const electron = getElectron();
const handleSubmit = async e => { const handleSubmit = async e => {
const { name } = e.detail; const { name, cloudFolder } = e.detail;
await apiCall('files/save', { folder, file: name, data, format }); if (cloudFolder === '__local') {
closeCurrentModal(); await apiCall('files/save', { folder, file: name, data, format });
if (onSave) { closeCurrentModal();
onSave(name, { if (onSave) {
savedFile: name, onSave(name, {
savedFolder: folder, savedFile: name,
savedFilePath: null, savedFolder: folder,
savedFilePath: null,
savedCloudFolderId: null,
savedCloudContentId: null,
});
}
} else {
const resp = await apiCall('cloud/save-file', {
folid: cloudFolder,
fileName: name,
data,
contentFolder: folder,
format,
// cntid,
}); });
if (resp.cntid) {
closeCurrentModal();
if (onSave) {
onSave(name, {
savedFile: name,
savedFolder: folder,
savedFilePath: null,
savedCloudFolderId: cloudFolder,
// savedCloudContentId: resp.cntid,
});
}
}
} }
}; };
@@ -47,15 +79,32 @@
savedFile: null, savedFile: null,
savedFolder: null, savedFolder: null,
savedFilePath: filePath, savedFilePath: filePath,
savedCloudFolderId: null,
savedCloudContentId: null,
}); });
} }
}; };
</script> </script>
<FormProvider initialValues={{ name }}> <FormProviderCore {values}>
<ModalBase {...$$restProps}> <ModalBase {...$$restProps}>
<svelte:fragment slot="header">Save file</svelte:fragment> <svelte:fragment slot="header">Save file</svelte:fragment>
<FormTextField label="File name" name="name" focused /> <FormTextField label="File name" name="name" focused />
{#if $cloudSigninTokenHolder}
<FormCloudFolderSelect
label="Choose cloud folder"
name="cloudFolder"
isNative
requiredRoleVariants={['write', 'admin']}
prependFolders={[
{
folid: '__local',
name: "Local folder (don't store on cloud)",
},
]}
/>
{/if}
<svelte:fragment slot="footer"> <svelte:fragment slot="footer">
<FormSubmit value={_t('common.save', { defaultMessage: 'Save' })} on:click={handleSubmit} /> <FormSubmit value={_t('common.save', { defaultMessage: 'Save' })} on:click={handleSubmit} />
{#if electron} {#if electron}
@@ -79,4 +128,4 @@
{/if} {/if}
</svelte:fragment> </svelte:fragment>
</ModalBase> </ModalBase>
</FormProvider> </FormProviderCore>

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import _ from 'lodash'; import _, { result } from 'lodash';
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
@@ -9,6 +9,7 @@
import { apiOff, apiOn } from '../utility/api'; import { apiOff, apiOn } from '../utility/api';
import useEffect from '../utility/useEffect'; import useEffect from '../utility/useEffect';
import AllResultsTab from './AllResultsTab.svelte'; import AllResultsTab from './AllResultsTab.svelte';
import JslChart from '../charts/JslChart.svelte';
export let tabs = []; export let tabs = [];
export let sessionId; export let sessionId;
@@ -16,6 +17,8 @@
export let driver; export let driver;
export let resultCount; export let resultCount;
export let onSetFrontMatterField;
export let onGetFrontMatter;
onMount(() => { onMount(() => {
allResultsInOneTab = $allResultsInOneTabDefault; allResultsInOneTab = $allResultsInOneTabDefault;
@@ -23,6 +26,7 @@
let allResultsInOneTab = null; let allResultsInOneTab = null;
let resultInfos = []; let resultInfos = [];
let charts = [];
let domTabs; let domTabs;
$: resultCount = resultInfos.length; $: resultCount = resultInfos.length;
@@ -35,6 +39,23 @@
if (!currentTab?.isResult) domTabs.setValue(_.findIndex(allTabs, x => x.isResult)); if (!currentTab?.isResult) domTabs.setValue(_.findIndex(allTabs, x => x.isResult));
}; };
const handleCharts = async props => {
charts = [
...charts,
{
jslid: props.jslid,
charts: props.charts,
resultIndex: props.resultIndex,
},
];
const selectedChart = onGetFrontMatter?.()?.['selected-chart'];
await tick();
if (selectedChart && props.resultIndex == selectedChart - 1) {
domTabs.setValue(_.findIndex(allTabs, x => x.isChart && x.resultIndex === props.resultIndex));
}
// console.log('Charts received for jslid:', props.jslid, 'Charts:', props.charts);
};
$: oneTab = allResultsInOneTab ?? $allResultsInOneTabDefault; $: oneTab = allResultsInOneTab ?? $allResultsInOneTabDefault;
$: allTabs = [ $: allTabs = [
@@ -55,13 +76,27 @@
label: `Result ${index + 1}`, label: `Result ${index + 1}`,
isResult: true, isResult: true,
component: JslDataGrid, component: JslDataGrid,
props: { jslid: info.jslid, driver }, props: { jslid: info.jslid, driver, onOpenChart: () => handleOpenChart(info.resultIndex) },
}))), }))),
...charts.map((info, index) => ({
label: `Chart ${info.resultIndex + 1}`,
isChart: true,
resultIndex: info.resultIndex,
component: JslChart,
props: {
jslid: info.jslid,
initialCharts: info.charts,
onEditDefinition: definition => {
onSetFrontMatterField?.(`chart-${info.resultIndex + 1}`, definition ?? undefined);
},
},
})),
]; ];
$: { $: {
if (executeNumber >= 0) { if (executeNumber >= 0) {
resultInfos = []; resultInfos = [];
charts = [];
if (domTabs) domTabs.setValue(0); if (domTabs) domTabs.setValue(0);
} }
} }
@@ -72,8 +107,10 @@
function onSession(sid) { function onSession(sid) {
if (sid) { if (sid) {
apiOn(`session-recordset-${sid}`, handleResultSet); apiOn(`session-recordset-${sid}`, handleResultSet);
apiOn(`session-charts-${sid}`, handleCharts);
return () => { return () => {
apiOff(`session-recordset-${sid}`, handleResultSet); apiOff(`session-recordset-${sid}`, handleResultSet);
apiOff(`session-charts-${sid}`, handleCharts);
}; };
} }
return () => {}; return () => {};
@@ -84,6 +121,25 @@
allResultsInOneTab = value; allResultsInOneTab = value;
$allResultsInOneTabDefault = value; $allResultsInOneTabDefault = value;
} }
async function handleOpenChart(resultIndex) {
const chartTab = _.find(allTabs, x => x.isChart && x.resultIndex === resultIndex);
if (chartTab) {
domTabs.setValue(_.findIndex(allTabs, x => x.isChart && x.resultIndex === resultIndex));
} else {
charts = [
...charts,
{
jslid: resultInfos[resultIndex].jslid,
charts: [],
resultIndex,
},
];
await tick();
domTabs.setValue(_.findIndex(allTabs, x => x.isChart && x.resultIndex === resultIndex));
}
onSetFrontMatterField?.('selected-chart', resultIndex + 1);
}
</script> </script>
<TabControl <TabControl
@@ -94,6 +150,13 @@
? { text: 'Every result in single tab', onClick: () => setOneTabValue(false) } ? { text: 'Every result in single tab', onClick: () => setOneTabValue(false) }
: { text: 'All results in one tab', onClick: () => setOneTabValue(true) }, : { text: 'All results in one tab', onClick: () => setOneTabValue(true) },
]} ]}
onUserChange={value => {
if (allTabs[value].isChart) {
onSetFrontMatterField?.(`selected-chart`, allTabs[value].resultIndex + 1);
} else {
onSetFrontMatterField?.(`selected-chart`, undefined);
}
}}
> >
<slot name="0" slot="0" /> <slot name="0" slot="0" />
<slot name="1" slot="1" /> <slot name="1" slot="1" />

View File

@@ -227,6 +227,12 @@ ORDER BY
</FormFieldTemplateLarge> </FormFieldTemplateLarge>
</div> </div>
</div> </div>
<FormTextField
name="sqlEditor.limitRows"
label="Return only N rows from query"
placeholder="(No rows limit)"
/>
</svelte:fragment> </svelte:fragment>
<svelte:fragment slot="2"> <svelte:fragment slot="2">
<div class="heading">Connection</div> <div class="heading">Connection</div>

View File

@@ -182,6 +182,10 @@ export const focusedConnectionOrDatabase = writable<{ conid: string; database?:
export const focusedTreeDbKey = writable<{ key: string; root: string; type: string; text: string }>(null); export const focusedTreeDbKey = writable<{ key: string; root: string; type: string; text: string }>(null);
export const cloudSigninTokenHolder = writableSettingsValue(null, 'cloudSigninTokenHolder');
export const cloudConnectionsStore = writable({});
export const DEFAULT_OBJECT_SEARCH_SETTINGS = { export const DEFAULT_OBJECT_SEARCH_SETTINGS = {
pureName: true, pureName: true,
schemaName: false, schemaName: false,
@@ -453,4 +457,10 @@ focusedTreeDbKey.subscribe(value => {
}); });
export const getFocusedTreeDbKey = () => focusedTreeDbKeyValue; export const getFocusedTreeDbKey = () => focusedTreeDbKeyValue;
let cloudConnectionsStoreValue = {};
cloudConnectionsStore.subscribe(value => {
cloudConnectionsStoreValue = value;
});
export const getCloudConnectionsStore = () => cloudConnectionsStoreValue;
window['__changeCurrentTheme'] = theme => currentTheme.set(theme); window['__changeCurrentTheme'] = theme => currentTheme.set(theme);

View File

@@ -7,9 +7,10 @@
import LoadingInfo from '../elements/LoadingInfo.svelte'; import LoadingInfo from '../elements/LoadingInfo.svelte';
import Markdown from '../elements/Markdown.svelte'; import Markdown from '../elements/Markdown.svelte';
import { apiCall } from '../utility/api'; import { apiCall } from '../utility/api';
import _ from 'lodash';
let isLoading = false; let isLoading = false;
let text = null; let text = '';
const handleLoad = async () => { const handleLoad = async () => {
isLoading = true; isLoading = true;
@@ -27,7 +28,7 @@
<LoadingInfo message="Loading changelog" /> <LoadingInfo message="Loading changelog" />
{:else} {:else}
<div> <div>
<Markdown source={text || ''} /> <Markdown source={_.isString(text) ? text: ''} />
</div> </div>
{/if} {/if}

View File

@@ -1,118 +0,0 @@
<script lang="ts" context="module">
const getCurrentEditor = () => getActiveComponent('ChartTab');
registerFileCommands({
idPrefix: 'chart',
category: 'Chart',
getCurrentEditor,
folder: 'charts',
format: 'json',
fileExtension: 'chart',
undoRedo: true,
});
</script>
<script lang="ts">
import _ from 'lodash';
import { derived } from 'svelte/store';
import ChartEditor from '../charts/ChartEditor.svelte';
import invalidateCommands from '../commands/invalidateCommands';
import registerCommand from '../commands/registerCommand';
import { registerFileCommands } from '../commands/stdCommands';
import ErrorInfo from '../elements/ErrorInfo.svelte';
import LoadingInfo from '../elements/LoadingInfo.svelte';
import useEditorData from '../query/useEditorData';
import { getContextMenu, registerMenu } from '../utility/contextMenu';
import createActivator, { getActiveComponent } from '../utility/createActivator';
import createUndoReducer from '../utility/createUndoReducer';
import resolveApi from '../utility/resolveApi';
export let tabid;
export let conid;
export let database;
export const activator = createActivator('ChartTab', true);
export function getData() {
return $editorState.value || '';
}
const { editorState, editorValue, setEditorData } = useEditorData({
tabid,
onInitialData: value => {
dispatchModel({ type: 'reset', value });
},
});
const [modelState, dispatchModel] = createUndoReducer({
tables: [],
references: [],
columns: [],
});
$: setEditorData($modelState.value);
$: {
$modelState;
invalidateCommands();
}
const setConfig = config =>
// @ts-ignore
dispatchModel({
type: 'compute',
compute: v => ({ ...v, config: _.isFunction(config) ? config(v.config) : config }),
});
const configDerivedStore = derived(modelState, ($modelState: any) =>
$modelState.value ? $modelState.value.config || {} : {}
);
const configStore = {
...configDerivedStore,
update: setConfig,
set: setConfig,
};
export function canUndo() {
return $modelState.canUndo;
}
export function undo() {
dispatchModel({ type: 'undo' });
}
export function canRedo() {
return $modelState.canRedo;
}
export function redo() {
dispatchModel({ type: 'redo' });
}
registerMenu(
{ command: 'chart.save' },
{ command: 'chart.saveAs' },
{ placeTag: 'export' },
{ divider: true },
{ command: 'chart.undo' },
{ command: 'chart.redo' }
);
</script>
{#if $editorState.isLoading}
<LoadingInfo wrapper message="Loading data" />
{:else if $editorState.errorMessage}
<ErrorInfo message={$editorState.errorMessage} />
{:else}
<ChartEditor
data={$modelState.value && $modelState.value.data}
{configStore}
sql={$modelState.value && $modelState.value.sql}
{conid}
{database}
/>
{/if}

View File

@@ -36,6 +36,7 @@
import ConnectionAdvancedDriverFields from '../settings/ConnectionAdvancedDriverFields.svelte'; import ConnectionAdvancedDriverFields from '../settings/ConnectionAdvancedDriverFields.svelte';
import DatabaseLoginModal from '../modals/DatabaseLoginModal.svelte'; import DatabaseLoginModal from '../modals/DatabaseLoginModal.svelte';
import { _t } from '../translations'; import { _t } from '../translations';
import ChooseCloudFolderModal from '../modals/ChooseCloudFolderModal.svelte';
export let connection; export let connection;
export let tabid; export let tabid;
@@ -44,6 +45,7 @@
export let inlineTabs = false; export let inlineTabs = false;
export let onlyTestButton; export let onlyTestButton;
export let saveOnCloud = false;
let isTesting; let isTesting;
let sqlConnectResult; let sqlConnectResult;
@@ -157,43 +159,96 @@
$: currentConnection = getCurrentConnectionCore($values, driver); $: currentConnection = getCurrentConnectionCore($values, driver);
async function handleSave() { async function handleSave() {
let connection = getCurrentConnection(); if (saveOnCloud && !getCurrentConnection()?._id) {
connection = { showModal(ChooseCloudFolderModal, {
...connection, requiredRoleVariants: ['write', 'admin'],
unsaved: false, message: 'Choose cloud folder to saved connection',
}; onConfirm: async folid => {
const saved = await apiCall('connections/save', connection); let connection = getCurrentConnection();
$values = { const saved = await apiCall('cloud/save-connection', { folid, connection });
...$values, if (saved?._id) {
_id: saved._id, $values = {
unsaved: false, ...$values,
}; _id: saved._id,
changeTab(tabid, tab => ({ unsaved: false,
...tab, };
title: getConnectionLabel(saved), changeTab(tabid, tab => ({
props: { ...tab,
...tab.props, title: getConnectionLabel(saved),
conid: saved._id, props: {
}, ...tab.props,
})); conid: saved._id,
showSnackbarSuccess('Connection saved'); },
}));
showSnackbarSuccess('Connection saved');
}
},
});
} else if (
// @ts-ignore
getCurrentConnection()?._id?.startsWith('cloud://')
) {
let connection = getCurrentConnection();
const resp = await apiCall('cloud/save-connection', { connection });
if (resp?._id) {
showSnackbarSuccess('Connection saved');
changeTab(tabid, tab => ({
...tab,
title: getConnectionLabel(connection),
}));
}
} else {
let connection = getCurrentConnection();
connection = {
...connection,
unsaved: false,
};
const saved = await apiCall('connections/save', connection);
$values = {
...$values,
_id: saved._id,
unsaved: false,
};
changeTab(tabid, tab => ({
...tab,
title: getConnectionLabel(saved),
props: {
...tab.props,
conid: saved._id,
},
}));
showSnackbarSuccess('Connection saved');
}
} }
async function handleConnect() { async function handleConnect() {
let connection = getCurrentConnection(); let connection = getCurrentConnection();
if (!connection._id) {
connection = { if (
...connection, // @ts-ignore
unsaved: true, connection?._id?.startsWith('cloud://')
) {
const saved = await apiCall('cloud/save-connection', { connection });
changeTab(tabid, tab => ({
...tab,
title: getConnectionLabel(connection),
}));
openConnection(saved);
} else {
if (!connection._id) {
connection = {
...connection,
unsaved: true,
};
}
const saved = await apiCall('connections/save', connection);
$values = {
...$values,
unsaved: connection.unsaved,
_id: saved._id,
}; };
openConnection(saved);
} }
const saved = await apiCall('connections/save', connection);
$values = {
...$values,
unsaved: connection.unsaved,
_id: saved._id,
};
openConnection(saved);
// closeMultipleTabs(x => x.tabid == tabid, true); // closeMultipleTabs(x => x.tabid == tabid, true);
} }
@@ -287,7 +342,9 @@
{:else if isConnected} {:else if isConnected}
<FormButton value="Disconnect" on:click={handleDisconnect} data-testid="ConnectionTab_buttonDisconnect" /> <FormButton value="Disconnect" on:click={handleDisconnect} data-testid="ConnectionTab_buttonDisconnect" />
{:else} {:else}
<FormButton value="Connect" on:click={handleConnect} data-testid="ConnectionTab_buttonConnect" /> {#if $values._id || !saveOnCloud}
<FormButton value="Connect" on:click={handleConnect} data-testid="ConnectionTab_buttonConnect" />
{/if}
{#if isTesting} {#if isTesting}
<FormButton value="Cancel test" on:click={handleCancelTest} /> <FormButton value="Cancel test" on:click={handleCancelTest} />
{:else} {:else}

View File

@@ -1,250 +0,0 @@
<script lang="ts" context="module">
const getCurrentEditor = () => getActiveComponent('ProfilerTab');
registerCommand({
id: 'profiler.start',
category: 'Profiler',
name: 'Start profiling',
icon: 'icon play',
testEnabled: () => getCurrentEditor()?.startProfilingEnabled(),
onClick: () => getCurrentEditor().startProfiling(),
});
registerCommand({
id: 'profiler.stop',
category: 'Profiler',
name: 'Stop profiling',
icon: 'icon play-stop',
testEnabled: () => getCurrentEditor()?.stopProfilingEnabled(),
onClick: () => getCurrentEditor().stopProfiling(),
});
registerCommand({
id: 'profiler.save',
category: 'Profiler',
name: 'Save',
icon: 'icon save',
testEnabled: () => getCurrentEditor()?.saveEnabled(),
onClick: () => getCurrentEditor().save(),
});
</script>
<script>
import { findEngineDriver } from 'dbgate-tools';
import { onDestroy, onMount } from 'svelte';
import ToolStripCommandButton from '../buttons/ToolStripCommandButton.svelte';
import ToolStripContainer from '../buttons/ToolStripContainer.svelte';
import invalidateCommands from '../commands/invalidateCommands';
import registerCommand from '../commands/registerCommand';
import JslDataGrid from '../datagrid/JslDataGrid.svelte';
import ErrorInfo from '../elements/ErrorInfo.svelte';
import VerticalSplitter from '../elements/VerticalSplitter.svelte';
import { showModal } from '../modals/modalTools';
import SaveArchiveModal from '../modals/SaveArchiveModal.svelte';
import { currentArchive, selectedWidget } from '../stores';
import { apiCall } from '../utility/api';
import createActivator, { getActiveComponent } from '../utility/createActivator';
import { useConnectionInfo } from '../utility/metadataLoaders';
import { extensions } from '../stores';
import ChartCore from '../charts/ChartCore.svelte';
import LoadingInfo from '../elements/LoadingInfo.svelte';
import randomcolor from 'randomcolor';
export const activator = createActivator('ProfilerTab', true);
export let conid;
export let database;
export let engine;
export let jslidLoad;
let jslidSession;
let isProfiling = false;
let sessionId;
let isLoadingChart = false;
let intervalId;
let chartData;
$: connection = useConnectionInfo({ conid });
$: driver = findEngineDriver(engine || $connection, $extensions);
$: jslid = jslidSession || jslidLoad;
onMount(() => {
intervalId = setInterval(() => {
if (sessionId) {
apiCall('sessions/ping', {
sesid: sessionId,
});
}
}, 15 * 1000);
});
$: {
if (jslidLoad && driver) {
loadChart();
}
}
onDestroy(() => {
clearInterval(intervalId);
});
export async function startProfiling() {
isProfiling = true;
let sesid = sessionId;
if (!sesid) {
const resp = await apiCall('sessions/create', {
conid,
database,
});
sesid = resp.sesid;
sessionId = sesid;
}
const resp = await apiCall('sessions/start-profiler', {
sesid,
});
jslidSession = resp.jslid;
invalidateCommands();
}
export function startProfilingEnabled() {
return conid && database && !isProfiling;
}
async function loadChart() {
isLoadingChart = true;
const colors = randomcolor({
count: driver.profilerChartMeasures.length,
seed: 5,
});
const data = await apiCall('jsldata/extract-timeline-chart', {
jslid,
timestampFunction: driver.profilerTimestampFunction,
aggregateFunction: driver.profilerChartAggregateFunction,
measures: driver.profilerChartMeasures,
});
chartData = {
...data,
labels: data.labels.map(x => new Date(x)),
datasets: data.datasets.map((x, i) => ({
...x,
borderColor: colors[i],
})),
};
isLoadingChart = false;
}
export async function stopProfiling() {
isProfiling = false;
await apiCall('sessions/stop-profiler', { sesid: sessionId });
await apiCall('sessions/kill', { sesid: sessionId });
sessionId = null;
invalidateCommands();
loadChart();
}
export function stopProfilingEnabled() {
return conid && database && isProfiling;
}
export function saveEnabled() {
return !!jslidSession;
}
async function doSave(folder, file) {
await apiCall('archive/save-jsl-data', { folder, file, jslid });
currentArchive.set(folder);
selectedWidget.set('archive');
}
export function save() {
showModal(SaveArchiveModal, {
// folder: archiveFolder,
// file: archiveFile,
onSave: doSave,
});
}
// const data = [
// { year: 2010, count: 10 },
// { year: 2011, count: 20 },
// { year: 2012, count: 15 },
// { year: 2013, count: 25 },
// { year: 2014, count: 22 },
// { year: 2015, count: 30 },
// { year: 2016, count: 28 },
// ];
// {
// labels: data.map(row => row.year),
// datasets: [
// {
// label: 'Acquisitions by year',
// data: data.map(row => row.count),
// },
// ],
// }
</script>
<ToolStripContainer>
{#if jslid}
<VerticalSplitter allowCollapseChild1 allowCollapseChild2>
<svelte:fragment slot="1">
{#key jslid}
<JslDataGrid {jslid} listenInitializeFile formatterFunction={driver?.profilerFormatterFunction} />
{/key}
</svelte:fragment>
<svelte:fragment slot="2">
{#if isLoadingChart}
<LoadingInfo wrapper message="Loading chart" />
{:else}
<ChartCore
title="Profile data"
data={chartData}
options={{
maintainAspectRatio: false,
scales: {
x: {
type: 'time',
distribution: 'linear',
time: {
tooltipFormat: 'D. M. YYYY HH:mm',
displayFormats: {
millisecond: 'HH:mm:ss.SSS',
second: 'HH:mm:ss',
minute: 'HH:mm',
hour: 'D.M hA',
day: 'D. M.',
week: 'D. M. YYYY',
month: 'MM-YYYY',
quarter: '[Q]Q - YYYY',
year: 'YYYY',
},
},
},
},
}}
/>
{/if}
</svelte:fragment>
</VerticalSplitter>
{:else}
<ErrorInfo message="Profiler not yet started" alignTop />
{/if}
<svelte:fragment slot="toolstrip">
<ToolStripCommandButton command="profiler.start" />
<ToolStripCommandButton command="profiler.stop" />
<ToolStripCommandButton command="profiler.save" />
</svelte:fragment>
</ToolStripContainer>

View File

@@ -1,6 +1,7 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
import registerCommand from '../commands/registerCommand'; import registerCommand from '../commands/registerCommand';
import { copyTextToClipboard } from '../utility/clipboard'; import { copyTextToClipboard } from '../utility/clipboard';
import yaml from 'js-yaml';
const getCurrentEditor = () => getActiveComponent('QueryTab'); const getCurrentEditor = () => getActiveComponent('QueryTab');
@@ -60,6 +61,13 @@
getCurrentEditor() != null && !getCurrentEditor()?.isBusy() && getCurrentEditor()?.hasConnection(), getCurrentEditor() != null && !getCurrentEditor()?.isBusy() && getCurrentEditor()?.hasConnection(),
onClick: () => getCurrentEditor().executeCurrent(), onClick: () => getCurrentEditor().executeCurrent(),
}); });
registerCommand({
id: 'query.toggleAutoExecute',
category: 'Query',
name: 'Toggle auto execute',
testEnabled: () => getCurrentEditor() != null,
onClick: () => getCurrentEditor().toggleAutoExecute(),
});
registerCommand({ registerCommand({
id: 'query.beginTransaction', id: 'query.beginTransaction',
category: 'Query', category: 'Query',
@@ -126,7 +134,7 @@
import InsertJoinModal from '../modals/InsertJoinModal.svelte'; import InsertJoinModal from '../modals/InsertJoinModal.svelte';
import useTimerLabel from '../utility/useTimerLabel'; import useTimerLabel from '../utility/useTimerLabel';
import createActivator, { getActiveComponent } from '../utility/createActivator'; import createActivator, { getActiveComponent } from '../utility/createActivator';
import { findEngineDriver, safeJsonParse } from 'dbgate-tools'; import { findEngineDriver, getSqlFrontMatter, safeJsonParse, setSqlFrontMatter } from 'dbgate-tools';
import AceEditor from '../query/AceEditor.svelte'; import AceEditor from '../query/AceEditor.svelte';
import StatusBarTabItem from '../widgets/StatusBarTabItem.svelte'; import StatusBarTabItem from '../widgets/StatusBarTabItem.svelte';
import { showSnackbarError } from '../utility/snackbar'; import { showSnackbarError } from '../utility/snackbar';
@@ -144,6 +152,10 @@
import HorizontalSplitter from '../elements/HorizontalSplitter.svelte'; import HorizontalSplitter from '../elements/HorizontalSplitter.svelte';
import QueryAiAssistant from '../query/QueryAiAssistant.svelte'; import QueryAiAssistant from '../query/QueryAiAssistant.svelte';
import uuidv1 from 'uuid/v1'; import uuidv1 from 'uuid/v1';
import ToolStripButton from '../buttons/ToolStripButton.svelte';
import { getIntSettingsValue } from '../settings/settingsTools';
import RowsLimitModal from '../modals/RowsLimitModal.svelte';
import _ from 'lodash';
export let tabid; export let tabid;
export let conid; export let conid;
@@ -196,6 +208,22 @@
let domAiAssistant; let domAiAssistant;
let isInTransaction = false; let isInTransaction = false;
let isAutocommit = false; let isAutocommit = false;
let splitterInitialValue = undefined;
const queryRowsLimitLocalStorageKey = `tabdata_limitRows_${tabid}`;
function getInitialRowsLimit() {
const storageValue = localStorage.getItem(queryRowsLimitLocalStorageKey);
if (storageValue == 'nolimit') {
return null;
}
if (storageValue) {
return parseInt(storageValue) ?? null;
}
return getIntSettingsValue('sqlEditor.limitRows', null, 1);
}
let queryRowsLimit = getInitialRowsLimit();
$: localStorage.setItem(queryRowsLimitLocalStorageKey, queryRowsLimit ? queryRowsLimit.toString() : 'nolimit');
onMount(() => { onMount(() => {
intervalId = setInterval(() => { intervalId = setInterval(() => {
@@ -332,6 +360,7 @@
executeStartLine = startLine; executeStartLine = startLine;
executeNumber++; executeNumber++;
visibleResultTabs = true; visibleResultTabs = true;
const frontMatter = getSqlFrontMatter($editorValue, yaml);
busy = true; busy = true;
timerLabel.start(); timerLabel.start();
@@ -362,6 +391,8 @@
sesid, sesid,
sql, sql,
autoCommit: driver?.implicitTransactions && isAutocommit, autoCommit: driver?.implicitTransactions && isAutocommit,
limitRows: queryRowsLimit ? queryRowsLimit : undefined,
frontMatter,
}); });
} }
await apiCall('query-history/write', { await apiCall('query-history/write', {
@@ -531,12 +562,47 @@
initialArgs && initialArgs.scriptTemplate initialArgs && initialArgs.scriptTemplate
? () => applyScriptTemplate(initialArgs.scriptTemplate, $extensions, $$props) ? () => applyScriptTemplate(initialArgs.scriptTemplate, $extensions, $$props)
: null, : null,
onInitialData: value => {
const frontMatter = getSqlFrontMatter(value, yaml);
if (frontMatter?.autoExecute) {
executeCore(value, 0);
}
if (frontMatter?.splitterInitialValue) {
splitterInitialValue = frontMatter.splitterInitialValue;
}
},
}); });
function handleChangeErrors(errors) { function handleChangeErrors(errors) {
errorMessages = errors; errorMessages = errors;
} }
function handleSetFrontMatterField(field, value) {
const text = $editorValue;
setEditorData(
setSqlFrontMatter(
text,
{
...getSqlFrontMatter(text, yaml),
[field]: value,
},
yaml
)
);
}
export function toggleAutoExecute() {
const frontMatter = getSqlFrontMatter($editorValue, yaml);
setEditorData(
setSqlFrontMatter(
$editorValue,
{ ...frontMatter, autoExecute: frontMatter?.autoExecute ? undefined : true },
yaml
)
);
}
async function handleKeyDown(event) { async function handleKeyDown(event) {
if (isProApp()) { if (isProApp()) {
if (event.code == 'Space' && event.shiftKey && event.ctrlKey && !isAiAssistantVisible) { if (event.code == 'Space' && event.shiftKey && event.ctrlKey && !isAiAssistantVisible) {
@@ -565,6 +631,7 @@
{ command: 'query.execute' }, { command: 'query.execute' },
{ command: 'query.executeCurrent' }, { command: 'query.executeCurrent' },
{ command: 'query.kill' }, { command: 'query.kill' },
{ command: 'query.toggleAutoExecute' },
{ divider: true }, { divider: true },
{ command: 'query.toggleComment' }, { command: 'query.toggleComment' },
{ command: 'query.formatCode' }, { command: 'query.formatCode' },
@@ -606,7 +673,7 @@
<ToolStripContainer bind:this={domToolStrip}> <ToolStripContainer bind:this={domToolStrip}>
<HorizontalSplitter isSplitter={isAiAssistantVisible} initialSizeRight={300}> <HorizontalSplitter isSplitter={isAiAssistantVisible} initialSizeRight={300}>
<svelte:fragment slot="1"> <svelte:fragment slot="1">
<VerticalSplitter isSplitter={visibleResultTabs}> <VerticalSplitter isSplitter={visibleResultTabs} initialValue={splitterInitialValue}>
<svelte:fragment slot="1"> <svelte:fragment slot="1">
{#if driver?.databaseEngineTypes?.includes('sql')} {#if driver?.databaseEngineTypes?.includes('sql')}
<SqlEditor <SqlEditor
@@ -659,7 +726,15 @@
{/if} {/if}
</svelte:fragment> </svelte:fragment>
<svelte:fragment slot="2"> <svelte:fragment slot="2">
<ResultTabs tabs={[{ label: 'Messages', slot: 0 }]} {sessionId} {executeNumber} bind:resultCount {driver}> <ResultTabs
tabs={[{ label: 'Messages', slot: 0 }]}
{sessionId}
{executeNumber}
bind:resultCount
{driver}
onSetFrontMatterField={handleSetFrontMatterField}
onGetFrontMatter={() => getSqlFrontMatter($editorValue, yaml)}
>
<svelte:fragment slot="0"> <svelte:fragment slot="0">
<SocketMessageView <SocketMessageView
eventName={sessionId ? `session-info-${sessionId}` : null} eventName={sessionId ? `session-info-${sessionId}` : null}
@@ -713,6 +788,20 @@
<ToolStripCommandButton command="query.kill" data-testid="QueryTab_killButton" /> <ToolStripCommandButton command="query.kill" data-testid="QueryTab_killButton" />
<ToolStripSaveButton idPrefix="query" /> <ToolStripSaveButton idPrefix="query" />
<ToolStripCommandButton command="query.formatCode" /> <ToolStripCommandButton command="query.formatCode" />
{#if !driver?.singleConnectionOnly}
<ToolStripButton
icon="icon limit"
on:click={() =>
showModal(RowsLimitModal, {
value: queryRowsLimit,
onConfirm: value => {
queryRowsLimit = value;
},
})}
>
{queryRowsLimit ? `Limit ${queryRowsLimit} rows` : 'Unlimited rows'}</ToolStripButton
>
{/if}
{#if resultCount == 1} {#if resultCount == 1}
<ToolStripExportButton command="jslTableGrid.export" {quickExportHandlerRef} label="Export result" /> <ToolStripExportButton command="jslTableGrid.export" {quickExportHandlerRef} label="Export result" />
{/if} {/if}

View File

@@ -6,7 +6,6 @@ import * as QueryTab from './QueryTab.svelte';
import * as ShellTab from './ShellTab.svelte'; import * as ShellTab from './ShellTab.svelte';
import * as ArchiveFileTab from './ArchiveFileTab.svelte'; import * as ArchiveFileTab from './ArchiveFileTab.svelte';
import * as PluginTab from './PluginTab.svelte'; import * as PluginTab from './PluginTab.svelte';
import * as ChartTab from './ChartTab.svelte';
import * as MarkdownEditorTab from './MarkdownEditorTab.svelte'; import * as MarkdownEditorTab from './MarkdownEditorTab.svelte';
import * as MarkdownViewTab from './MarkdownViewTab.svelte'; import * as MarkdownViewTab from './MarkdownViewTab.svelte';
import * as MarkdownPreviewTab from './MarkdownPreviewTab.svelte'; import * as MarkdownPreviewTab from './MarkdownPreviewTab.svelte';
@@ -23,7 +22,6 @@ import * as QueryDataTab from './QueryDataTab.svelte';
import * as ConnectionTab from './ConnectionTab.svelte'; import * as ConnectionTab from './ConnectionTab.svelte';
import * as MapTab from './MapTab.svelte'; import * as MapTab from './MapTab.svelte';
import * as ServerSummaryTab from './ServerSummaryTab.svelte'; import * as ServerSummaryTab from './ServerSummaryTab.svelte';
import * as ProfilerTab from './ProfilerTab.svelte';
import * as ImportExportTab from './ImportExportTab.svelte'; import * as ImportExportTab from './ImportExportTab.svelte';
import * as SqlObjectTab from './SqlObjectTab.svelte'; import * as SqlObjectTab from './SqlObjectTab.svelte';
@@ -38,7 +36,6 @@ export default {
ShellTab, ShellTab,
ArchiveFileTab, ArchiveFileTab,
PluginTab, PluginTab,
ChartTab,
MarkdownEditorTab, MarkdownEditorTab,
MarkdownViewTab, MarkdownViewTab,
MarkdownPreviewTab, MarkdownPreviewTab,
@@ -55,7 +52,6 @@ export default {
ConnectionTab, ConnectionTab,
MapTab, MapTab,
ServerSummaryTab, ServerSummaryTab,
ProfilerTab,
ImportExportTab, ImportExportTab,
SqlObjectTab, SqlObjectTab,
...protabs, ...protabs,

View File

@@ -13,6 +13,9 @@ import { callServerPing } from './connectionsPinger';
import { batchDispatchCacheTriggers, dispatchCacheChange } from './cache'; import { batchDispatchCacheTriggers, dispatchCacheChange } from './cache';
import { isAdminPage, isOneOfPage } from './pageDefs'; import { isAdminPage, isOneOfPage } from './pageDefs';
import { openWebLink } from './simpleTools'; import { openWebLink } from './simpleTools';
import { serializeJsTypesReplacer } from 'dbgate-tools';
import { cloudSigninTokenHolder } from '../stores';
import LicenseLimitMessageModal from '../modals/LicenseLimitMessageModal.svelte';
export const strmid = uuidv1(); export const strmid = uuidv1();
@@ -119,7 +122,14 @@ async function processApiResponse(route, args, resp) {
// missingCredentials: true, // missingCredentials: true,
// }; // };
} else if (resp?.apiErrorMessage) { } else if (resp?.apiErrorMessage) {
showSnackbarError('API error:' + resp?.apiErrorMessage); if (resp?.apiErrorIsLicenseLimit) {
showModal(LicenseLimitMessageModal, {
message: resp.apiErrorMessage,
licenseLimits: resp.apiErrorLimitedLicenseLimits,
});
} else {
showSnackbarError('API error:' + resp?.apiErrorMessage);
}
return { return {
errorMessage: resp.apiErrorMessage, errorMessage: resp.apiErrorMessage,
}; };
@@ -177,7 +187,7 @@ export async function apiCall(
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...resolveApiHeaders(), ...resolveApiHeaders(),
}, },
body: JSON.stringify(args), body: JSON.stringify(args, serializeJsTypesReplacer),
}); });
if (resp.status == 401 && !apiDisabled) { if (resp.status == 401 && !apiDisabled) {
@@ -278,6 +288,13 @@ export function installNewVolatileConnectionListener() {
}); });
} }
export function installNewCloudTokenListener() {
apiOn('got-cloud-token', async tokenHolder => {
console.log('HOLDER', tokenHolder);
cloudSigninTokenHolder.set(tokenHolder);
});
}
export function getAuthCategory(config) { export function getAuthCategory(config) {
if (config.isBasicAuth) { if (config.isBasicAuth) {
return 'basic'; return 'basic';
@@ -291,6 +308,15 @@ export function getAuthCategory(config) {
return 'token'; return 'token';
} }
export function refreshPublicCloudFiles() {
if (sessionStorage.getItem('publicCloudFilesLoaded')) {
return;
}
apiCall('cloud/refresh-public-files');
sessionStorage.setItem('publicCloudFilesLoaded', 'true');
}
function enableApiLog() { function enableApiLog() {
apiLogging = true; apiLogging = true;
console.log('API loggin enabled'); console.log('API loggin enabled');

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