Merge branch 'feature/cloud'

This commit is contained in:
SPRINX0\prochazka
2025-05-29 12:49:01 +02:00
49 changed files with 2043 additions and 200 deletions

View File

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

View File

@@ -13,6 +13,8 @@ const {
} = require('../auth/authProvider');
const storage = require('./storage');
const { decryptPasswordString } = require('../utility/crypting');
const { createDbGateIdentitySession, startCloudTokenChecking } = require('../utility/cloudIntf');
const socket = require('../utility/socket');
const logger = getLogger('auth');
@@ -135,5 +137,14 @@ module.exports = {
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,
};

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

@@ -239,6 +239,19 @@ module.exports = {
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({ connection, requestDbList = false }) {
const subprocess = fork(
@@ -410,6 +423,13 @@ module.exports = {
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 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);
if (existing) return existing;
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') {
throw new MissingCredentialsError({ conid, passwordMode: connection.passwordMode });
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -36,6 +36,10 @@ async function callRefactorSqlQueryApi(query, task, structure, dialect) {
return null;
}
function getLicenseHttpHeaders() {
return {};
}
module.exports = {
isAuthProxySupported,
authProxyGetRedirectUrl,
@@ -47,4 +51,5 @@ module.exports = {
callTextToSqlApi,
callCompleteOnCursorApi,
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;
}
function encryptObjectPasswordField(obj, field) {
function encryptObjectPasswordField(obj, field, encryptor = null) {
if (obj && obj[field] && !obj[field].startsWith('crypt:')) {
return {
...obj,
[field]: 'crypt:' + getInternalEncryptor().encrypt(obj[field]),
[field]: 'crypt:' + (encryptor || getInternalEncryptor()).encrypt(obj[field]),
};
}
return obj;
@@ -101,11 +101,11 @@ function decryptObjectPasswordField(obj, field) {
return obj;
}
function encryptConnection(connection) {
function encryptConnection(connection, encryptor = null) {
if (connection.passwordMode != 'saveRaw') {
connection = encryptObjectPasswordField(connection, 'password');
connection = encryptObjectPasswordField(connection, 'sshPassword');
connection = encryptObjectPasswordField(connection, 'sshKeyfilePassword');
connection = encryptObjectPasswordField(connection, 'password', encryptor);
connection = encryptObjectPasswordField(connection, 'sshPassword', encryptor);
connection = encryptObjectPasswordField(connection, 'sshKeyfilePassword', encryptor);
}
return connection;
}

View File

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

View File

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

View File

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

View File

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

View File

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

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 AppObjectCore from './AppObjectCore.svelte';
import {
cloudSigninTokenHolder,
currentDatabase,
DEFAULT_CONNECTION_SEARCH_SETTINGS,
expandedConnections,
@@ -160,7 +161,7 @@
const handleOpenConnectionTab = () => {
openNewTab({
title: getConnectionLabel(data),
icon: 'img connection',
icon: data._id.startsWith('cloud://') ? 'img cloud-connection' : 'img connection',
tabComponent: 'ConnectionTab',
props: {
conid: data._id,
@@ -261,11 +262,15 @@
});
};
const handleDuplicate = () => {
apiCall('connections/save', {
...data,
_id: undefined,
displayName: `${getConnectionLabel(data)} - copy`,
});
if (data._id.startsWith('cloud://')) {
apiCall('cloud/duplicate-connection', { conid: data._id });
} else {
apiCall('connections/save', {
...data,
_id: undefined,
displayName: `${getConnectionLabel(data)} - copy`,
});
}
};
const handleCreateDatabase = () => {
showModal(InputTextModal, {
@@ -332,6 +337,19 @@
text: _t('connection.duplicate', { defaultMessage: 'Duplicate' }),
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 },
!data.singleDatabase && [
@@ -416,7 +434,7 @@
{...$$restProps}
{data}
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
? $currentDatabase?.connection?._id == data._id && $currentDatabase?.name == data.defaultDatabase
: $currentDatabase?.connection?._id == data._id}

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

@@ -206,7 +206,14 @@
showModal(ConfirmModal, {
message: `Really delete file ${data.file}?`,
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 +224,15 @@
label: 'New file name',
header: 'Rename file',
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 +241,17 @@
showModal(InputTextModal, {
value: data.file,
label: 'New file name',
header: 'Rename file',
header: 'Copy file',
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 +259,38 @@
const handleDownload = () => {
saveFileToDisk(
async filePath => {
await apiCall('files/export-file', {
folder,
file: data.file,
filePath,
});
if (data.folid && data.cntid) {
await apiCall('cloud/export-file', {
folid: data.folid,
cntid: data.cntid,
filePath,
});
} else {
await apiCall('files/export-file', {
folder,
file: data.file,
filePath,
});
}
},
{ formatLabel: handler.label, formatExtension: handler.format, defaultFileName: data.file }
);
};
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;
const connProps: any = {};
if (handler.currentConnection) {
const connection = _.get($currentDatabase, 'connection') || {};
@@ -270,10 +310,12 @@
savedFile: data.file,
savedFolder: handler.folder,
savedFormat: handler.format,
savedCloudFolderId: data.folid,
savedCloudContentId: data.cntid,
...connProps,
},
},
{ editor: resp }
{ editor: dataContent }
);
}
</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,4 +1,5 @@
import {
cloudSigninTokenHolder,
currentDatabase,
currentTheme,
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({
id: 'new.connection.folder',
toolbar: true,
@@ -662,6 +684,15 @@ if (hasPermission('settings/change')) {
});
}
registerCommand({
id: 'cloud.logout',
category: 'Cloud',
name: 'Logout',
onClick: () => {
cloudSigninTokenHolder.set(null);
},
});
registerCommand({
id: 'file.exit',
category: 'File',

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 invisible-box': 'mdi mdi-minus-box-outline icon-invisible',
'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 export': 'mdi mdi-application-export',
'icon new-connection': 'mdi mdi-database-plus',
@@ -112,6 +115,9 @@
'icon square': 'mdi mdi-square',
'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 delete': 'mdi mdi-delete',
'icon arrow-up': 'mdi mdi-arrow-up',
@@ -264,6 +270,7 @@
'img role': 'mdi mdi-account-group color-icon-blue',
'img admin': 'mdi mdi-security 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 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

@@ -1,15 +1,18 @@
<script lang="ts">
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 FormTextField from '../forms/FormTextField.svelte';
import { cloudSigninTokenHolder } from '../stores';
import { _t } from '../translations';
import { apiCall } from '../utility/api';
import { writable } from 'svelte/store';
import getElectron from '../utility/getElectron';
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 name;
@@ -18,19 +21,48 @@
export let fileExtension;
export let filePath;
export let onSave = undefined;
export let folid;
// export let cntid;
const values = writable({ name, cloudFolder: folid ?? '__local' });
const electron = getElectron();
const handleSubmit = async e => {
const { name } = e.detail;
await apiCall('files/save', { folder, file: name, data, format });
closeCurrentModal();
if (onSave) {
onSave(name, {
savedFile: name,
savedFolder: folder,
savedFilePath: null,
const { name, cloudFolder } = e.detail;
if (cloudFolder === '__local') {
await apiCall('files/save', { folder, file: name, data, format });
closeCurrentModal();
if (onSave) {
onSave(name, {
savedFile: name,
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,
savedFolder: null,
savedFilePath: filePath,
savedCloudFolderId: null,
savedCloudContentId: null,
});
}
};
</script>
<FormProvider initialValues={{ name }}>
<FormProviderCore {values}>
<ModalBase {...$$restProps}>
<svelte:fragment slot="header">Save file</svelte:fragment>
<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">
<FormSubmit value={_t('common.save', { defaultMessage: 'Save' })} on:click={handleSubmit} />
{#if electron}
@@ -79,4 +128,4 @@
{/if}
</svelte:fragment>
</ModalBase>
</FormProvider>
</FormProviderCore>

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 cloudSigninTokenHolder = writableSettingsValue(null, 'cloudSigninTokenHolder');
export const cloudConnectionsStore = writable({});
export const DEFAULT_OBJECT_SEARCH_SETTINGS = {
pureName: true,
schemaName: false,
@@ -453,4 +457,10 @@ focusedTreeDbKey.subscribe(value => {
});
export const getFocusedTreeDbKey = () => focusedTreeDbKeyValue;
let cloudConnectionsStoreValue = {};
cloudConnectionsStore.subscribe(value => {
cloudConnectionsStoreValue = value;
});
export const getCloudConnectionsStore = () => cloudConnectionsStoreValue;
window['__changeCurrentTheme'] = theme => currentTheme.set(theme);

View File

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

View File

@@ -14,6 +14,8 @@ import { batchDispatchCacheTriggers, dispatchCacheChange } from './cache';
import { isAdminPage, isOneOfPage } from './pageDefs';
import { openWebLink } from './simpleTools';
import { serializeJsTypesReplacer } from 'dbgate-tools';
import { cloudSigninTokenHolder } from '../stores';
import LicenseLimitMessageModal from '../modals/LicenseLimitMessageModal.svelte';
export const strmid = uuidv1();
@@ -120,7 +122,14 @@ async function processApiResponse(route, args, resp) {
// missingCredentials: true,
// };
} 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 {
errorMessage: resp.apiErrorMessage,
};
@@ -279,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) {
if (config.isBasicAuth) {
return 'basic';
@@ -292,6 +308,15 @@ export function getAuthCategory(config) {
return 'token';
}
export function refreshPublicCloudFiles() {
if (sessionStorage.getItem('publicCloudFilesLoaded')) {
return;
}
apiCall('cloud/refresh-public-files');
sessionStorage.setItem('publicCloudFilesLoaded', 'true');
}
function enableApiLog() {
apiLogging = true;
console.log('API loggin enabled');

View File

@@ -0,0 +1,57 @@
import { derived } from 'svelte/store';
import {
cloudConnectionsStore,
currentDatabase,
getCloudConnectionsStore,
openedConnections,
openedSingleDatabaseConnections,
} from '../stores';
import { apiCall, apiOn } from './api';
import _ from 'lodash';
export const possibleCloudConnectionSources = derived(
[currentDatabase, openedSingleDatabaseConnections, openedConnections],
([$currentDatabase, $openedSingleDatabaseConnections, $openedConnections]) => {
const conids = new Set<string>();
if ($currentDatabase?.connection?._id) {
conids.add($currentDatabase.connection._id);
}
$openedSingleDatabaseConnections.forEach(x => conids.add(x));
$openedConnections.forEach(x => conids.add(x));
return Array.from(conids).filter(x => x?.startsWith('cloud://'));
}
);
async function loadCloudConnection(conid) {
const conn = await apiCall('connections/get', { conid });
cloudConnectionsStore.update(store => ({
...store,
[conid]: conn,
}));
}
function ensureCloudConnectionsLoaded(...conids) {
const conns = getCloudConnectionsStore();
cloudConnectionsStore.update(store => _.pick(store, conids));
conids.forEach(conid => {
if (!conns[conid]) {
loadCloudConnection(conid);
}
});
}
export function installCloudListeners() {
possibleCloudConnectionSources.subscribe(conids => {
ensureCloudConnectionsLoaded(...conids);
});
apiOn('cloud-content-updated', () => {
const conids = Object.keys(getCloudConnectionsStore());
cloudConnectionsStore.set({});
for (const conn of conids) {
loadCloudConnection(conn);
}
});
}

View File

@@ -166,6 +166,17 @@ const authTypesLoader = ({ engine }) => ({
errorValue: null,
});
const publicCloudFilesLoader = () => ({
url: 'cloud/public-files',
params: {},
reloadTrigger: { key: `public-cloud-changed` },
});
const cloudContentListLoader = () => ({
url: 'cloud/content-list',
params: {},
reloadTrigger: { key: `cloud-content-changed` },
});
async function getCore(loader, args) {
const { url, params, reloadTrigger, transform, onLoaded, errorValue } = loader(args);
const key = stableStringify({ url, ...params });
@@ -456,3 +467,17 @@ export function getSchemaList(args) {
export function useSchemaList(args) {
return useCore(schemaListLoader, args);
}
export function getPublicCloudFiles(args) {
return getCore(publicCloudFilesLoader, args);
}
export function usePublicCloudFiles(args = {}) {
return useCore(publicCloudFilesLoader, args);
}
export function getCloudContentList(args) {
return getCore(cloudContentListLoader, args);
}
export function useCloudContentList(args = {}) {
return useCore(cloudContentListLoader, args);
}

View File

@@ -111,6 +111,8 @@ async function openSavedElectronFile(filePath, parsed, folder) {
props: {
savedFile: null,
savedFolder: null,
savedCloudFolderId: null,
savedCloudContentId: null,
savedFilePath: filePath,
savedFormat: handler.format,
...connProps,

View File

@@ -30,7 +30,8 @@ export default async function openNewTab(newTab, initialData: any = undefined, o
};
}
const { savedFile, savedFolder, savedFilePath, conid, database } = newTab.props || {};
const { savedFile, savedFolder, savedFilePath, savedCloudFolderId, savedCloudContentId, conid, database } =
newTab.props || {};
if (conid && database) {
const connection = await getConnectionInfo({ conid });
@@ -49,7 +50,9 @@ export default async function openNewTab(newTab, initialData: any = undefined, o
x.closedTime == null &&
x.props.savedFile == savedFile &&
x.props.savedFolder == savedFolder &&
x.props.savedFilePath == savedFilePath
x.props.savedFilePath == savedFilePath &&
x.props.savedCloudFolderId == savedCloudFolderId &&
x.props.savedCloudContentId == savedCloudContentId
);
}

View File

@@ -15,16 +15,31 @@ export default async function saveTabFile(editor, saveMode, folder, format, file
const tabs = get(openedTabs);
const tabid = editor.activator.tabid;
const data = editor.getData();
const { savedFile, savedFilePath, savedFolder } = tabs.find(x => x.tabid == tabid).props || {};
const { savedFile, savedFilePath, savedFolder, savedCloudFolderId, savedCloudContentId } =
tabs.find(x => x.tabid == tabid).props || {};
const handleSave = async () => {
if (savedFile) {
await apiCall('files/save', { folder: savedFolder || folder, file: savedFile, data, format });
if (savedCloudFolderId && savedCloudContentId) {
const resp = await apiCall('cloud/save-file', {
folid: savedCloudFolderId,
fileName: savedFile,
data,
contentFolder: folder,
format,
cntid: savedCloudContentId,
});
if (resp.cntid) {
markTabSaved(tabid);
}
} else {
if (savedFile) {
await apiCall('files/save', { folder: savedFolder || folder, file: savedFile, data, format });
}
if (savedFilePath) {
await apiCall('files/save-as', { filePath: savedFilePath, data, format });
}
markTabSaved(tabid);
}
if (savedFilePath) {
await apiCall('files/save-as', { filePath: savedFilePath, data, format });
}
markTabSaved(tabid);
};
const onSave = (title, newProps) => {
@@ -60,6 +75,8 @@ export default async function saveTabFile(editor, saveMode, folder, format, file
savedFile: null,
savedFolder: null,
savedFilePath: file,
savedCloudFolderId: null,
savedCloudContentId: null,
});
}
} else if ((savedFile || savedFilePath) && saveMode == 'save') {
@@ -73,6 +90,8 @@ export default async function saveTabFile(editor, saveMode, folder, format, file
name: savedFile || 'newFile',
filePath: savedFilePath,
onSave,
folid: savedCloudFolderId,
// cntid: savedCloudContentId,
});
}
}

View File

@@ -1,11 +1,40 @@
import getElectron from './getElectron';
export function openWebLink(href) {
export function openWebLink(href, usePopup = false) {
const electron = getElectron();
if (electron) {
electron.send('open-link', href);
} else {
window.open(href, '_blank');
if (usePopup) {
const w = 500;
const h = 650;
const dualScreenLeft = window.screenLeft ?? window.screenX; // X of parent
const dualScreenTop = window.screenTop ?? window.screenY; // Y of parent
// 2. How big is the parent window?
const parentWidth = window.outerWidth;
const parentHeight = window.outerHeight;
// 3. Centre the popup inside that rectangle
const left = dualScreenLeft + (parentWidth - w) / 2;
const top = dualScreenTop + (parentHeight - h) / 2;
const features = [
`width=${w}`,
`height=${h}`,
`left=${left}`,
`top=${top}`,
'scrollbars=yes',
'resizable=yes',
'noopener',
'noreferrer',
];
window.open(href, 'dbgateCloudLoginPopup', features.join(','));
} else {
window.open(href, '_blank');
}
}
}

View File

@@ -351,6 +351,7 @@
isExpandable={data => $openedConnections.includes(data._id) && !data.singleDatabase}
{filter}
passProps={{
...passProps,
connectionColorFactory: $connectionColorFactory,
showPinnedInsteadOfUnpin: true,
searchSettings: $connectionAppObjectSearchSettings,

View File

@@ -1,31 +1,20 @@
<script lang="ts">
import { findEngineDriver } from 'dbgate-tools';
import { currentDatabase, extensions, pinnedDatabases, pinnedTables } from '../stores';
import { useConfig, useConnectionInfo } from '../utility/metadataLoaders';
import { useCloudContentList, useConfig, useConnectionInfo } from '../utility/metadataLoaders';
import ConnectionList from './ConnectionList.svelte';
import PinnedObjectsList from './PinnedObjectsList.svelte';
import ErrorInfo from '../elements/ErrorInfo.svelte';
import WidgetsInnerContainer from './WidgetsInnerContainer.svelte';
import WidgetColumnBar from './WidgetColumnBar.svelte';
import WidgetColumnBarItem from './WidgetColumnBarItem.svelte';
import SqlObjectList from './SqlObjectList.svelte';
import DbKeysTree from './DbKeysTree.svelte';
import SingleConnectionDatabaseList from './SingleConnectionDatabaseList.svelte';
import _ from 'lodash';
import FocusedConnectionInfoWidget from './FocusedConnectionInfoWidget.svelte';
import { _t } from '../translations';
import DatabaseWidgetDetailContent from './DatabaseWidgetDetailContent.svelte';
export let hidden = false;
let domSqlObjectList = null;
$: conid = $currentDatabase?.connection?._id;
$: connection = useConnectionInfo({ conid });
$: driver = findEngineDriver($connection, $extensions);
$: config = useConfig();
$: singleDatabase = $currentDatabase?.connection?.singleDatabase;
$: database = $currentDatabase?.name;
$: cloudContentList = useCloudContentList();
</script>
<WidgetColumnBar {hidden}>
@@ -45,69 +34,14 @@
height="35%"
storageName="connectionsWidget"
>
<ConnectionList passProps={{ onFocusSqlObjectList: () => domSqlObjectList.focus() }} />
<ConnectionList
passProps={{
onFocusSqlObjectList: () => domSqlObjectList.focus(),
cloudContentList: $cloudContentList,
}}
/>
</WidgetColumnBarItem>
{/if}
<WidgetColumnBarItem
title={_t('widget.pinned', { defaultMessage: 'Pinned' })}
name="pinned"
height="15%"
storageName="pinnedItemsWidget"
skip={!_.compact($pinnedDatabases).length &&
!$pinnedTables.some(x => x && x.conid == conid && x.database == $currentDatabase?.name)}
>
<PinnedObjectsList />
</WidgetColumnBarItem>
<WidgetColumnBarItem
title={driver?.databaseEngineTypes?.includes('document')
? (driver?.collectionPluralLabel ?? 'Collections/containers')
: _t('widget.tablesViewsFunctions', { defaultMessage: 'Tables, views, functions' })}
name="dbObjects"
storageName="dbObjectsWidget"
skip={!(
conid &&
(database || singleDatabase) &&
(driver?.databaseEngineTypes?.includes('sql') || driver?.databaseEngineTypes?.includes('document'))
)}
>
<SqlObjectList {conid} {database} bind:this={domSqlObjectList} />
</WidgetColumnBarItem>
<WidgetColumnBarItem
title={_t('widget.keys', { defaultMessage: 'Keys' })}
name="dbObjects"
storageName="dbObjectsWidget"
skip={!(conid && (database || singleDatabase) && driver?.databaseEngineTypes?.includes('keyvalue'))}
>
<DbKeysTree {conid} {database} treeKeySeparator={$connection?.treeKeySeparator || ':'} />
</WidgetColumnBarItem>
<WidgetColumnBarItem
title={_t('widget.databaseContent', { defaultMessage: 'Database content' })}
name="dbObjects"
storageName="dbObjectsWidget"
skip={conid && (database || singleDatabase)}
>
<WidgetsInnerContainer>
<FocusedConnectionInfoWidget {conid} {database} connection={$connection} />
<ErrorInfo message="Database not selected" icon="img alert" />
</WidgetsInnerContainer>
</WidgetColumnBarItem>
<WidgetColumnBarItem
title={_t('widget.databaseContent', { defaultMessage: 'Database content' })}
name="dbObjects"
storageName="dbObjectsWidget"
skip={!(conid && (database || singleDatabase) && !driver)}
>
<WidgetsInnerContainer>
<FocusedConnectionInfoWidget {conid} {database} connection={$connection} />
<ErrorInfo
message={_t('error.driverNotFound', { defaultMessage: 'Invalid database connection, driver not found' })}
/>
</WidgetsInnerContainer>
</WidgetColumnBarItem>
<DatabaseWidgetDetailContent bind:domSqlObjectList showCloudConnection={false} />
</WidgetColumnBar>

View File

@@ -0,0 +1,135 @@
<script lang="ts">
import { findEngineDriver } from 'dbgate-tools';
import { currentDatabase, extensions, pinnedDatabases, pinnedTables, selectedWidget } from '../stores';
import { useConnectionInfo } from '../utility/metadataLoaders';
import PinnedObjectsList from './PinnedObjectsList.svelte';
import ErrorInfo from '../elements/ErrorInfo.svelte';
import WidgetsInnerContainer from './WidgetsInnerContainer.svelte';
import WidgetColumnBarItem from './WidgetColumnBarItem.svelte';
import SqlObjectList from './SqlObjectList.svelte';
import DbKeysTree from './DbKeysTree.svelte';
import _ from 'lodash';
import FocusedConnectionInfoWidget from './FocusedConnectionInfoWidget.svelte';
import { _t } from '../translations';
import FormStyledButton from '../buttons/FormStyledButton.svelte';
export let domSqlObjectList = null;
export let showCloudConnection;
$: conid = $currentDatabase?.connection?._id;
$: connection = useConnectionInfo({ conid });
$: driver = findEngineDriver($connection, $extensions);
$: singleDatabase = $currentDatabase?.connection?.singleDatabase;
$: database = $currentDatabase?.name;
$: correctCloudStatus =
!conid ||
(!showCloudConnection && !conid?.startsWith('cloud://')) ||
(showCloudConnection && conid?.startsWith('cloud://'));
</script>
<WidgetColumnBarItem
title={_t('widget.pinned', { defaultMessage: 'Pinned' })}
name="pinned"
height="15%"
storageName="pinnedItemsWidget"
skip={!_.compact($pinnedDatabases).length &&
!$pinnedTables.some(x => x && x.conid == conid && x.database == $currentDatabase?.name)}
positiveCondition={correctCloudStatus}
>
<PinnedObjectsList />
</WidgetColumnBarItem>
<WidgetColumnBarItem
title={driver?.databaseEngineTypes?.includes('document')
? (driver?.collectionPluralLabel ?? 'Collections/containers')
: _t('widget.tablesViewsFunctions', { defaultMessage: 'Tables, views, functions' })}
name="dbObjects"
storageName="dbObjectsWidget"
skip={!(
conid &&
(database || singleDatabase) &&
(driver?.databaseEngineTypes?.includes('sql') || driver?.databaseEngineTypes?.includes('document'))
)}
positiveCondition={correctCloudStatus}
>
<SqlObjectList {conid} {database} bind:this={domSqlObjectList} />
</WidgetColumnBarItem>
<WidgetColumnBarItem
title={_t('widget.keys', { defaultMessage: 'Keys' })}
name="dbObjects"
storageName="dbObjectsWidget"
skip={!(conid && (database || singleDatabase) && driver?.databaseEngineTypes?.includes('keyvalue'))}
positiveCondition={correctCloudStatus}
>
<DbKeysTree {conid} {database} treeKeySeparator={$connection?.treeKeySeparator || ':'} />
</WidgetColumnBarItem>
<WidgetColumnBarItem
title={_t('widget.databaseContent', { defaultMessage: 'Database content' })}
name="dbObjects"
storageName="dbObjectsWidget"
skip={conid && (database || singleDatabase)}
positiveCondition={correctCloudStatus}
>
<WidgetsInnerContainer>
<FocusedConnectionInfoWidget {conid} {database} connection={$connection} />
<ErrorInfo message="Database not selected" icon="img alert" />
</WidgetsInnerContainer>
</WidgetColumnBarItem>
<WidgetColumnBarItem
title={_t('widget.databaseContent', { defaultMessage: 'Database content' })}
name="dbObjects"
storageName="dbObjectsWidget"
skip={!(conid && (database || singleDatabase) && !driver)}
positiveCondition={correctCloudStatus}
>
<WidgetsInnerContainer>
<FocusedConnectionInfoWidget {conid} {database} connection={$connection} />
<ErrorInfo
message={_t('error.driverNotFound', { defaultMessage: 'Invalid database connection, driver not found' })}
/>
</WidgetsInnerContainer>
</WidgetColumnBarItem>
<WidgetColumnBarItem
title={_t('widget.databaseContent', { defaultMessage: 'Database content' })}
name="incorrectClaudStatus"
height="15%"
storageName="incorrectClaudStatusWidget"
skip={correctCloudStatus}
>
<WidgetsInnerContainer>
<ErrorInfo
message={showCloudConnection
? _t('error.selectedNotCloudConnection', { defaultMessage: 'Selected connection is not from DbGate cloud' })
: _t('error.selectedCloudConnection', { defaultMessage: 'Selected connection is from DbGate cloud' })}
/>
<div class="incorrect-cloud-status-wrapper">
<FormStyledButton
value={`Show database content`}
skipWidth
on:click={() => {
$selectedWidget = conid?.startsWith('cloud://') ? 'cloud-private' : 'database';
}}
/>
</div>
</WidgetsInnerContainer>
</WidgetColumnBarItem>
<style>
.incorrect-cloud-status-wrapper {
flex: 1;
display: flex;
flex-direction: column;
align-items: stretch;
margin-top: 10px;
}
</style>

View File

@@ -0,0 +1,249 @@
<script lang="ts">
import WidgetColumnBar from './WidgetColumnBar.svelte';
import WidgetColumnBarItem from './WidgetColumnBarItem.svelte';
import AppObjectList from '../appobj/AppObjectList.svelte';
import * as cloudContentAppObject from '../appobj/CloudContentAppObject.svelte';
import { useCloudContentList, usePublicCloudFiles, useServerStatus } from '../utility/metadataLoaders';
import { _t } from '../translations';
import WidgetsInnerContainer from './WidgetsInnerContainer.svelte';
import SearchBoxWrapper from '../elements/SearchBoxWrapper.svelte';
import SearchInput from '../elements/SearchInput.svelte';
import CloseSearchButton from '../buttons/CloseSearchButton.svelte';
import InlineButton from '../buttons/InlineButton.svelte';
import FontIcon from '../icons/FontIcon.svelte';
import { apiCall } from '../utility/api';
import {
cloudConnectionsStore,
cloudSigninTokenHolder,
currentDatabase,
expandedConnections,
openedConnections,
openedSingleDatabaseConnections,
} from '../stores';
import _ from 'lodash';
import { plusExpandIcon } from '../icons/expandIcons';
import { volatileConnectionMapStore } from '../utility/api';
import SubCloudItemsList from '../appobj/SubCloudItemsList.svelte';
import DatabaseWidgetDetailContent from './DatabaseWidgetDetailContent.svelte';
import { onMount } from 'svelte';
import DropDownButton from '../buttons/DropDownButton.svelte';
import { showModal } from '../modals/modalTools';
import InputTextModal from '../modals/InputTextModal.svelte';
import ConfirmModal from '../modals/ConfirmModal.svelte';
import { showSnackbarInfo } from '../utility/snackbar';
let filter = '';
let domSqlObjectList = null;
const cloudContentList = useCloudContentList();
const serverStatus = useServerStatus();
$: emptyCloudContent = ($cloudContentList || []).filter(x => !x.items?.length).map(x => x.folid);
$: cloudContentFlat = _.sortBy(
($cloudContentList || [])
.flatMap(fld => fld.items ?? [])
.map(data => {
if (data.type == 'connection') {
const conid = `cloud://${data.folid}/${data.cntid}`;
const status = $serverStatus ? $serverStatus[$volatileConnectionMapStore[conid] || conid] : undefined;
return {
...data,
conid,
status,
};
}
return data;
}),
'name'
);
$: contentGroupMap = _.keyBy($cloudContentList || [], x => x.folid);
// $: console.log('cloudContentFlat', cloudContentFlat);
// $: console.log('contentGroupMap', contentGroupMap);
async function handleRefreshContent() {
await apiCall('cloud/refresh-content');
}
// async function loadCloudConnection(conid) {
// const conn = await apiCall('connections/get', { conid });
// $cloudConnectionsStore = {
// ...$cloudConnectionsStore,
// [conid]: conn,
// };
// }
// function ensureCloudConnectionsLoaded(...conids) {
// _.uniq(conids).forEach(conid => {
// if (conid?.startsWith('cloud://') && !$cloudConnectionsStore[conid]) {
// loadCloudConnection(conid);
// }
// });
// }
// $: ensureCloudConnectionsLoaded(
// $currentDatabase?.connection?._id,
// ...$openedSingleDatabaseConnections,
// ...$openedConnections
// );
// onMount(() => {
// const currentConid = $currentDatabase?.connection?._id;
// if (currentConid?.startsWith('cloud://') && !$cloudConnectionsStore[currentConid]) {
// loadCloudConnection(currentConid);
// }
// });
function createAddMenu() {
return [
{
text: 'New shared folder',
onClick: () => {
showModal(InputTextModal, {
label: 'New folder name',
header: 'New shared folder',
onConfirm: async newFolder => {
apiCall('cloud/create-folder', {
name: newFolder,
});
},
});
},
},
{
text: 'Add existing shared folder',
onClick: () => {
showModal(InputTextModal, {
label: 'Your invite link (in form dbgate://folder/xxx)',
header: 'Add existing shared folder',
onConfirm: async newFolder => {
apiCall('cloud/grant-folder', {
inviteLink: newFolder,
});
},
});
},
},
{
command: 'new.connectionOnCloud',
},
];
}
function createGroupContextMenu(folder) {
const handleRename = () => {
showModal(InputTextModal, {
value: contentGroupMap[folder]?.name,
label: 'New folder name',
header: 'Rename folder',
onConfirm: async name => {
apiCall('cloud/rename-folder', {
folid: folder,
name,
});
},
});
};
const handleDelete = () => {
showModal(ConfirmModal, {
message: `Really delete folder ${contentGroupMap[folder]?.name}? All folder content will be deleted!`,
header: 'Delete folder',
onConfirm: () => {
apiCall('cloud/delete-folder', {
folid: folder,
});
},
});
};
const handleCopyInviteLink = async role => {
const { inviteToken } = await apiCall(`cloud/get-invite-token`, {
folid: folder,
role,
});
const inviteLink = `dbgate://folder/v1/${inviteToken}?mode=${role}`;
navigator.clipboard.writeText(inviteLink);
showSnackbarInfo(`Invite link (${role}) copied to clipboard`);
};
return [
contentGroupMap[folder]?.role == 'admin' && [
{ text: 'Rename', onClick: handleRename },
{ text: 'Delete', onClick: handleDelete },
],
contentGroupMap[folder]?.role == 'admin' &&
!contentGroupMap[folder]?.isPrivate && {
text: 'Copy invite link',
submenu: [
{
text: 'Admin',
onClick: () => handleCopyInviteLink('admin'),
},
{
text: 'Write',
onClick: () => handleCopyInviteLink('write'),
},
{
text: 'Read',
onClick: () => handleCopyInviteLink('read'),
},
],
},
];
}
</script>
<WidgetColumnBar>
<WidgetColumnBarItem
title="DbGate Cloud"
name="privateCloud"
height="50%"
storageName="privateCloudItems"
skip={!$cloudSigninTokenHolder}
>
<SearchBoxWrapper>
<SearchInput placeholder="Search cloud connections and files" bind:value={filter} />
<CloseSearchButton bind:filter />
<DropDownButton icon="icon plus-thick" menu={createAddMenu} />
<InlineButton
on:click={handleRefreshContent}
title="Refresh files"
data-testid="CloudItemsWidget_buttonRefreshContent"
>
<FontIcon icon="icon refresh" />
</InlineButton>
</SearchBoxWrapper>
<WidgetsInnerContainer>
<AppObjectList
list={cloudContentFlat || []}
module={cloudContentAppObject}
emptyGroupNames={emptyCloudContent}
groupFunc={data => data.folid}
mapGroupTitle={folid => `${contentGroupMap[folid]?.name} - ${contentGroupMap[folid]?.role}`}
{filter}
subItemsComponent={() => SubCloudItemsList}
expandIconFunc={plusExpandIcon}
isExpandable={data =>
data.conid &&
$cloudConnectionsStore[data.conid] &&
!$cloudConnectionsStore[data.conid].singleDatabase &&
$openedConnections.includes(data.conid)}
getIsExpanded={data => $expandedConnections.includes(data.conid)}
setIsExpanded={(data, value) => {
expandedConnections.update(old => (value ? [...old, data.conid] : old.filter(x => x != data.conid)));
}}
passProps={{
onFocusSqlObjectList: () => domSqlObjectList.focus(),
}}
groupContextMenu={createGroupContextMenu}
/>
</WidgetsInnerContainer>
</WidgetColumnBarItem>
<DatabaseWidgetDetailContent bind:domSqlObjectList showCloudConnection={true} />
</WidgetColumnBar>

View File

@@ -0,0 +1,53 @@
<script lang="ts">
import SavedFilesList from './SavedFilesList.svelte';
import WidgetColumnBar from './WidgetColumnBar.svelte';
import WidgetColumnBarItem from './WidgetColumnBarItem.svelte';
import AppObjectList from '../appobj/AppObjectList.svelte';
import * as publicCloudFileAppObject from '../appobj/PublicCloudFileAppObject.svelte';
import * as cloudContentAppObject from '../appobj/CloudContentAppObject.svelte';
import { useCloudContentList, usePublicCloudFiles, useServerStatus } from '../utility/metadataLoaders';
import { _t } from '../translations';
import WidgetsInnerContainer from './WidgetsInnerContainer.svelte';
import SearchBoxWrapper from '../elements/SearchBoxWrapper.svelte';
import SearchInput from '../elements/SearchInput.svelte';
import CloseSearchButton from '../buttons/CloseSearchButton.svelte';
import InlineButton from '../buttons/InlineButton.svelte';
import FontIcon from '../icons/FontIcon.svelte';
import { apiCall } from '../utility/api';
import _ from 'lodash';
let filter = '';
const publicFiles = usePublicCloudFiles();
async function handleRefreshPublic() {
await apiCall('cloud/refresh-public-files?isRefresh=1');
}
</script>
<WidgetColumnBar>
<WidgetColumnBarItem title="Public Knowledge Base" name="publicCloud" storageName="publicCloudItems">
<WidgetsInnerContainer>
<SearchBoxWrapper>
<SearchInput placeholder="Search public files" bind:value={filter} />
<CloseSearchButton bind:filter />
<InlineButton
on:click={handleRefreshPublic}
title="Refresh files"
data-testid="CloudItemsWidget_buttonRefreshPublic"
>
<FontIcon icon="icon refresh" />
</InlineButton>
</SearchBoxWrapper>
<AppObjectList
list={$publicFiles || []}
module={publicCloudFileAppObject}
groupFunc={data => data.folder || undefined}
{filter}
/>
</WidgetsInnerContainer>
</WidgetColumnBarItem>
</WidgetColumnBar>

View File

@@ -74,24 +74,24 @@
}
</script>
<WidgetsInnerContainer>
<SearchBoxWrapper>
<SearchInput placeholder="Search saved files" bind:value={filter} />
<CloseSearchButton bind:filter />
<InlineUploadButton
filters={[
{
name: `All supported files`,
extensions: ['sql'],
},
{ name: `SQL files`, extensions: ['sql'] },
]}
onProcessFile={handleUploadedFile}
/>
<InlineButton on:click={handleRefreshFiles} title="Refresh files" data-testid="SavedFileList_buttonRefresh">
<FontIcon icon="icon refresh" />
</InlineButton>
</SearchBoxWrapper>
<SearchBoxWrapper>
<SearchInput placeholder="Search saved files" bind:value={filter} />
<CloseSearchButton bind:filter />
<InlineUploadButton
filters={[
{
name: `All supported files`,
extensions: ['sql'],
},
{ name: `SQL files`, extensions: ['sql'] },
]}
onProcessFile={handleUploadedFile}
/>
<InlineButton on:click={handleRefreshFiles} title="Refresh files" data-testid="SavedFileList_buttonRefresh">
<FontIcon icon="icon refresh" />
</InlineButton>
</SearchBoxWrapper>
<WidgetsInnerContainer>
<AppObjectList list={files} module={savedFileAppObject} groupFunc={data => dataFolderTitle(data.folder)} {filter} />
</WidgetsInnerContainer>

View File

@@ -163,6 +163,8 @@
($focusedConnectionOrDatabase.conid != conid ||
($focusedConnectionOrDatabase?.database &&
$focusedConnectionOrDatabase?.database != extractDbNameFromComposite(database)));
// $: console.log('STATUS', $status);
</script>
{#if $status && $status.name == 'error'}

View File

@@ -9,6 +9,7 @@
import {
activeTabId,
appUpdateStatus,
cloudSigninTokenHolder,
currentArchive,
currentDatabase,
currentThemeDefinition,
@@ -154,7 +155,7 @@
</div>
</div>
{/if}
{#if $currentArchive}
{#if $currentArchive && $currentArchive != 'default'}
<div
class="item flex clickable"
title="Current archive"
@@ -184,6 +185,13 @@
</div>
{/if}
{#if $cloudSigninTokenHolder?.email}
<div class="item">
<FontIcon icon="icon cloud" padRight />
{$cloudSigninTokenHolder?.email}
</div>
{/if}
{#if $appUpdateStatus}
<div class="item">
<FontIcon icon={$appUpdateStatus.icon} padRight />

View File

@@ -29,7 +29,7 @@
const visibleItemsCount = defs.filter(x => !x.collapsed && !x.skip).length;
for (let index = 0; index < defs.length; index++) {
const definition = defs[index];
const splitterVisible = !!defs.slice(index + 1).find(x => x && !x.collapsed && !x.skip);
const splitterVisible = !!defs.slice(index + 1).find(x => x && !x.collapsed && !x.skip && x.positiveCondition);
dynamicPropsCollection[index].set({ splitterVisible, visibleItemsCount });
}
}

View File

@@ -12,6 +12,7 @@
export let title;
export let name;
export let skip = false;
export let positiveCondition = true;
export let height = null;
export let collapsed = null;
@@ -33,11 +34,12 @@
collapsed,
height,
skip,
positiveCondition,
},
dynamicProps
);
$: updateWidgetItemDefinition(widgetItemIndex, { collapsed: !visible, height, skip });
$: updateWidgetItemDefinition(widgetItemIndex, { collapsed: !visible, height, skip, positiveCondition });
$: setInitialSize(height, $widgetColumnBarHeight);
@@ -67,7 +69,7 @@
$: collapsible = $dynamicProps.visibleItemsCount != 1 || !visible;
</script>
{#if !skip}
{#if !skip && positiveCondition}
<WidgetTitle
clickable={collapsible}
on:click={collapsible ? () => (visible = !visible) : null}

View File

@@ -9,6 +9,8 @@
import AppWidget from './AppWidget.svelte';
import AdminMenuWidget from './AdminMenuWidget.svelte';
import AdminPremiumPromoWidget from './AdminPremiumPromoWidget.svelte';
import PublicCloudWidget from './PublicCloudWidget.svelte';
import PrivateCloudWidget from './PrivateCloudWidget.svelte';
</script>
<DatabaseWidget hidden={$visibleSelectedWidget != 'database'} />
@@ -37,3 +39,9 @@
{#if $visibleSelectedWidget == 'premium'}
<AdminPremiumPromoWidget />
{/if}
{#if $visibleSelectedWidget == 'cloud-public'}
<PublicCloudWidget />
{/if}
{#if $visibleSelectedWidget == 'cloud-private'}
<PrivateCloudWidget />
{/if}

View File

@@ -9,12 +9,17 @@
visibleHamburgerMenuWidget,
lockedDatabaseMode,
getCurrentConfig,
cloudSigninTokenHolder,
} from '../stores';
import mainMenuDefinition from '../../../../app/src/mainMenuDefinition';
import hasPermission from '../utility/hasPermission';
import { isProApp } from '../utility/proTools';
import { openWebLink } from '../utility/simpleTools';
import { apiCall } from '../utility/api';
import getElectron from '../utility/getElectron';
let domSettings;
let domCloudAccount;
let domMainMenu;
const widgets = [
@@ -28,6 +33,12 @@
name: 'database',
title: 'Database connections',
},
{
name: 'cloud-private',
title: 'DbGate Cloud',
icon: 'icon cloud-private',
},
// {
// icon: 'fa-table',
// name: 'table',
@@ -58,9 +69,9 @@
title: 'Selected cell data detail view',
},
{
icon: 'icon app',
name: 'app',
title: 'Application layers',
name: 'cloud-public',
title: 'DbGate Cloud',
icon: 'icon cloud-public',
},
{
icon: 'icon premium',
@@ -92,7 +103,26 @@
const rect = domSettings.getBoundingClientRect();
const left = rect.right;
const top = rect.bottom;
const items = [{ command: 'settings.show' }, { command: 'theme.changeTheme' }, { command: 'settings.commands' }];
const items = [
{ command: 'settings.show' },
{ command: 'theme.changeTheme' },
{ command: 'settings.commands' },
{
text: 'View applications',
onClick: () => {
$selectedWidget = 'app';
$visibleWidgetSideBar = true;
},
},
];
currentDropDownMenu.set({ left, top, items });
}
function handleCloudAccountMenu() {
const rect = domCloudAccount.getBoundingClientRect();
const left = rect.right;
const top = rect.bottom;
const items = [{ command: 'cloud.logout' }];
currentDropDownMenu.set({ left, top, items });
}
@@ -103,6 +133,11 @@
const items = mainMenuDefinition({ editMenu: false });
currentDropDownMenu.set({ left, top, items });
}
async function handleOpenCloudLogin() {
const { url, sid } = await apiCall('auth/create-cloud-login-session', { client: getElectron() ? 'app' : 'web' });
openWebLink(url, true);
}
</script>
<div class="main">
@@ -113,7 +148,8 @@
{/if}
{#each widgets
.filter(x => x && hasPermission(`widgets/${x.name}`))
.filter(x => !x.isPremiumPromo || !isProApp()) as item}
.filter(x => !x.isPremiumPromo || !isProApp())
.filter(x => x.name != 'cloud-private' || $cloudSigninTokenHolder) as item}
<div
class="wrapper"
class:selected={item.name == $visibleSelectedWidget}
@@ -129,7 +165,7 @@
<div class="flex1">&nbsp;</div>
<div
<!-- <div
class="wrapper"
title={`Toggle whether tabs from all databases are visible. Currently - ${$lockedDatabaseMode ? 'NO' : 'YES'}`}
on:click={() => {
@@ -138,7 +174,22 @@
data-testid="WidgetIconPanel_lockDb"
>
<FontIcon icon={$lockedDatabaseMode ? 'icon locked-database-mode' : 'icon unlocked-database-mode'} />
</div>
</div> -->
{#if $cloudSigninTokenHolder}
<div
class="wrapper"
on:click={handleCloudAccountMenu}
bind:this={domCloudAccount}
data-testid="WidgetIconPanel_cloudAccount"
>
<FontIcon icon="icon cloud-account-connected" />
</div>
{:else}
<div class="wrapper" on:click={handleOpenCloudLogin} data-testid="WidgetIconPanel_cloudAccount">
<FontIcon icon="icon cloud-account" />
</div>
{/if}
<div class="wrapper" on:click={handleSettingsMenu} bind:this={domSettings} data-testid="WidgetIconPanel_settings">
<FontIcon icon="icon settings" />
@@ -147,8 +198,8 @@
<style>
.wrapper {
font-size: 23pt;
height: 60px;
font-size: 20pt;
height: 50px;
display: flex;
align-items: center;
justify-content: center;