feat: cancel button when waiting to connect database, closes #830

This commit is contained in:
Fabio Di Stasio 2024-08-28 18:05:04 +02:00
parent 97279742e9
commit b6a7124f33
10 changed files with 308 additions and 141 deletions

View File

@ -18,7 +18,7 @@ export type Importer = MySQLImporter | PostgreSQLImporter
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export interface IpcResponse<T = any> { export interface IpcResponse<T = any> {
status: 'success' | 'error'; status: 'success' | 'error' | 'abort';
response?: T; response?: T;
} }

View File

@ -5,11 +5,21 @@ import { SslOptions } from 'mysql2';
import { ClientsFactory } from '../libs/ClientsFactory'; import { ClientsFactory } from '../libs/ClientsFactory';
import { validateSender } from '../libs/misc/validateSender'; import { validateSender } from '../libs/misc/validateSender';
const isAborting: Record<string, boolean> = {};
export default (connections: Record<string, antares.Client>) => { export default (connections: Record<string, antares.Client>) => {
ipcMain.handle('test-connection', async (event, conn: antares.ConnectionParams) => { ipcMain.handle('test-connection', async (event, conn: antares.ConnectionParams) => {
if (!validateSender(event.senderFrame)) return { status: 'error', response: 'Unauthorized process' }; if (!validateSender(event.senderFrame)) return { status: 'error', response: 'Unauthorized process' };
let isLocalAborted = false;
const abortChecker = setInterval(() => { // Intercepts abort request
if (isAborting[conn.uid]) {
isAborting[conn.uid] = false;
isLocalAborted = true;
clearInterval(abortChecker);
}
}, 50);
const params = { const params = {
host: conn.host, host: conn.host,
port: +conn.port, port: +conn.port,
@ -65,19 +75,27 @@ export default (connections: Record<string, antares.Client>) => {
client: conn.client, client: conn.client,
params params
}); });
await connection.connect();
if (conn.client === 'firebird') await connection.connect();
connection.raw('SELECT rdb$get_context(\'SYSTEM\', \'DB_NAME\') FROM rdb$database'); if (isLocalAborted) {
else connection.destroy();
await connection.select('1+1').run(); return;
}
await connection.ping();
connection.destroy(); connection.destroy();
clearInterval(abortChecker);
return { status: 'success' }; return { status: 'success' };
} }
catch (err) { catch (err) {
clearInterval(abortChecker);
if (!isLocalAborted)
return { status: 'error', response: err.toString() }; return { status: 'error', response: err.toString() };
else
return { status: 'abort', response: 'Connection aborted' };
} }
}); });
@ -88,6 +106,15 @@ export default (connections: Record<string, antares.Client>) => {
ipcMain.handle('connect', async (event, conn: antares.ConnectionParams) => { ipcMain.handle('connect', async (event, conn: antares.ConnectionParams) => {
if (!validateSender(event.senderFrame)) return { status: 'error', response: 'Unauthorized process' }; if (!validateSender(event.senderFrame)) return { status: 'error', response: 'Unauthorized process' };
let isLocalAborted = false;
const abortChecker = setInterval(() => { // Intercepts abort request
if (isAborting[conn.uid]) {
isAborting[conn.uid] = false;
isLocalAborted = true;
clearInterval(abortChecker);
}
}, 50);
const params = { const params = {
host: conn.host, host: conn.host,
port: +conn.port, port: +conn.port,
@ -150,18 +177,36 @@ export default (connections: Record<string, antares.Client>) => {
}); });
await connection.connect(); await connection.connect();
if (isLocalAborted) {
connection.destroy();
return { status: 'abort', response: 'Connection aborted' };
}
const structure = await connection.getStructure(new Set()); const structure = await connection.getStructure(new Set());
if (isLocalAborted) {
connection.destroy();
return { status: 'abort', response: 'Connection aborted' };
}
connections[conn.uid] = connection; connections[conn.uid] = connection;
clearInterval(abortChecker);
return { status: 'success', response: structure }; return { status: 'success', response: structure };
} }
catch (err) { catch (err) {
clearInterval(abortChecker);
if (!isLocalAborted)
return { status: 'error', response: err.toString() }; return { status: 'error', response: err.toString() };
else
return { status: 'abort', response: 'Connection aborted' };
} }
}); });
ipcMain.on('abort-connection', (event, uid) => {
isAborting[uid] = true;
});
ipcMain.handle('disconnect', (event, uid) => { ipcMain.handle('disconnect', (event, uid) => {
if (!validateSender(event.senderFrame)) return { status: 'error', response: 'Unauthorized process' }; if (!validateSender(event.senderFrame)) return { status: 'error', response: 'Unauthorized process' };

View File

@ -109,6 +109,10 @@ export class FirebirdSQLClient extends BaseClient {
return firebird.pool(this._poolSize, { ...this._params, blobAsText: true }); return firebird.pool(this._poolSize, { ...this._params, blobAsText: true });
} }
ping () {
return this.raw('SELECT rdb$get_context(\'SYSTEM\', \'DB_NAME\') FROM rdb$database');
}
destroy () { destroy () {
if (this._poolSize) if (this._poolSize)
return (this._connection as firebird.ConnectionPool).destroy(); return (this._connection as firebird.ConnectionPool).destroy();

View File

@ -214,6 +214,10 @@ export class MySQLClient extends BaseClient {
} }
} }
ping () {
return this.select('1+1').run();
}
destroy () { destroy () {
this._connection.end(); this._connection.end();
clearInterval(this._keepaliveTimer); clearInterval(this._keepaliveTimer);

View File

@ -243,6 +243,10 @@ export class PostgreSQLClient extends BaseClient {
return connection; return connection;
} }
ping () {
return this.select('1+1').run();
}
destroy () { destroy () {
this._connection.end(); this._connection.end();
clearInterval(this._keepaliveTimer); clearInterval(this._keepaliveTimer);

View File

@ -35,6 +35,10 @@ export class SQLiteClient extends BaseClient {
}); });
} }
ping () {
return this.select('1+1').run();
}
destroy () { destroy () {
this._connection.close(); this._connection.close();
} }

View File

@ -386,7 +386,21 @@
</div> </div>
</div> </div>
<div class="panel-footer"> <div class="panel-footer">
<div
@mouseenter="setCancelTestButtonVisibility(true)"
@mouseleave="setCancelTestButtonVisibility(false)"
>
<button <button
v-if="showTestCancel && isTesting"
class="btn btn-gray mr-2 cancellable"
:title="t('general.cancel')"
@click="abortConnection()"
>
<BaseIcon icon-name="mdiWindowClose" :size="24" />
<span class="d-invisible pr-1">{{ t('connection.testConnection') }}</span>
</button>
<button
v-else
id="connection-test" id="connection-test"
class="btn btn-gray mr-2 d-flex" class="btn btn-gray mr-2 d-flex"
:class="{'loading': isTesting}" :class="{'loading': isTesting}"
@ -400,6 +414,7 @@
/> />
{{ t('connection.testConnection') }} {{ t('connection.testConnection') }}
</button> </button>
</div>
<button <button
id="connection-save" id="connection-save"
class="btn btn-primary mr-2 d-flex" class="btn btn-primary mr-2 d-flex"
@ -494,6 +509,8 @@ const firstInput: Ref<HTMLInputElement> = ref(null);
const isConnecting = ref(false); const isConnecting = ref(false);
const isTesting = ref(false); const isTesting = ref(false);
const isAsking = ref(false); const isAsking = ref(false);
const showTestCancel = ref(false);
const abortController: Ref<AbortController> = ref(new AbortController());
const selectedTab = ref('general'); const selectedTab = ref('general');
const clientCustomizations = computed(() => { const clientCustomizations = computed(() => {
@ -516,6 +533,10 @@ const setDefaults = () => {
connection.value.database = clientCustomizations.value.defaultDatabase; connection.value.database = clientCustomizations.value.defaultDatabase;
}; };
const setCancelTestButtonVisibility = (val: boolean) => {
showTestCancel.value = val;
};
const startTest = async () => { const startTest = async () => {
isTesting.value = true; isTesting.value = true;
@ -526,7 +547,7 @@ const startTest = async () => {
const res = await Connection.makeTest(connection.value); const res = await Connection.makeTest(connection.value);
if (res.status === 'error') if (res.status === 'error')
addNotification({ status: 'error', message: res.response.message || res.response.toString() }); addNotification({ status: 'error', message: res.response.message || res.response.toString() });
else else if (res.status === 'success')
addNotification({ status: 'success', message: t('connection.connectionSuccessfullyMade') }); addNotification({ status: 'success', message: t('connection.connectionSuccessfullyMade') });
} }
catch (err) { catch (err) {
@ -537,13 +558,21 @@ const startTest = async () => {
} }
}; };
const abortConnection = (): void => {
abortController.value.abort();
Connection.abortConnection(connection.value.uid);
isTesting.value = false;
isConnecting.value = false;
abortController.value = new AbortController();
};
const continueTest = async (credentials: { user: string; password: string }) => { // if "Ask for credentials" is true const continueTest = async (credentials: { user: string; password: string }) => { // if "Ask for credentials" is true
isAsking.value = false; isAsking.value = false;
const params = Object.assign({}, connection.value, credentials); const params = Object.assign({}, connection.value, credentials);
try { try {
if (isConnecting.value) { if (isConnecting.value) {
await connectWorkspace(params); await connectWorkspace(params, { signal: abortController.value.signal }).catch(() => undefined);
isConnecting.value = false; isConnecting.value = false;
} }
else { else {

View File

@ -387,7 +387,21 @@
</div> </div>
</div> </div>
<div class="panel-footer"> <div class="panel-footer">
<div
@mouseenter="setCancelTestButtonVisibility(true)"
@mouseleave="setCancelTestButtonVisibility(false)"
>
<button <button
v-if="showTestCancel && isTesting"
class="btn btn-gray mr-2 cancellable"
:title="t('general.cancel')"
@click="abortConnection()"
>
<BaseIcon icon-name="mdiWindowClose" :size="24" />
<span class="d-invisible pr-1">{{ t('connection.testConnection') }}</span>
</button>
<button
v-else
id="connection-test" id="connection-test"
class="btn btn-gray mr-2 d-flex" class="btn btn-gray mr-2 d-flex"
:class="{'loading': isTesting}" :class="{'loading': isTesting}"
@ -401,6 +415,7 @@
/> />
{{ t('connection.testConnection') }} {{ t('connection.testConnection') }}
</button> </button>
</div>
<button <button
id="connection-save" id="connection-save"
class="btn btn-primary mr-2 d-flex" class="btn btn-primary mr-2 d-flex"
@ -414,7 +429,21 @@
/> />
{{ t('general.save') }} {{ t('general.save') }}
</button> </button>
<div
@mouseenter="setCancelConnectButtonVisibility(true)"
@mouseleave="setCancelConnectButtonVisibility(false)"
>
<button <button
v-if="showConnectCancel && isConnecting"
class="btn btn-success cancellable"
:title="t('general.cancel')"
@click="abortConnection()"
>
<BaseIcon icon-name="mdiWindowClose" :size="24" />
<span class="d-invisible pr-1">{{ t('connection.connect') }}</span>
</button>
<button
v-else
id="connection-connect" id="connection-connect"
class="btn btn-success d-flex" class="btn btn-success d-flex"
:class="{'loading': isConnecting}" :class="{'loading': isConnecting}"
@ -430,6 +459,7 @@
</button> </button>
</div> </div>
</div> </div>
</div>
<ModalAskCredentials <ModalAskCredentials
v-if="isAsking" v-if="isAsking"
@close-asking="closeAsking" @close-asking="closeAsking"
@ -476,6 +506,9 @@ const localConnection: Ref<ConnectionParams & { pgConnString: string }> = ref(nu
const isConnecting = ref(false); const isConnecting = ref(false);
const isTesting = ref(false); const isTesting = ref(false);
const isAsking = ref(false); const isAsking = ref(false);
const showTestCancel = ref(false);
const showConnectCancel = ref(false);
const abortController: Ref<AbortController> = ref(new AbortController());
const selectedTab = ref('general'); const selectedTab = ref('general');
const clientCustomizations = computed(() => { const clientCustomizations = computed(() => {
@ -501,7 +534,7 @@ const startConnection = async () => {
if (localConnection.value.ask) if (localConnection.value.ask)
isAsking.value = true; isAsking.value = true;
else { else {
await connectWorkspace(localConnection.value); await connectWorkspace(localConnection.value, { signal: abortController.value.signal }).catch(() => undefined);
isConnecting.value = false; isConnecting.value = false;
} }
}; };
@ -516,7 +549,7 @@ const startTest = async () => {
const res = await Connection.makeTest(localConnection.value); const res = await Connection.makeTest(localConnection.value);
if (res.status === 'error') if (res.status === 'error')
addNotification({ status: 'error', message: res.response.message || res.response.toString() }); addNotification({ status: 'error', message: res.response.message || res.response.toString() });
else else if (res.status === 'success')
addNotification({ status: 'success', message: t('connection.connectionSuccessfullyMade') }); addNotification({ status: 'success', message: t('connection.connectionSuccessfullyMade') });
} }
catch (err) { catch (err) {
@ -527,20 +560,36 @@ const startTest = async () => {
} }
}; };
const setCancelTestButtonVisibility = (val: boolean) => {
showTestCancel.value = val;
};
const setCancelConnectButtonVisibility = (val: boolean) => {
showConnectCancel.value = val;
};
const abortConnection = (): void => {
abortController.value.abort();
Connection.abortConnection(localConnection.value.uid);
isTesting.value = false;
isConnecting.value = false;
abortController.value = new AbortController();
};
const continueTest = async (credentials: {user: string; password: string }) => { // if "Ask for credentials" is true const continueTest = async (credentials: {user: string; password: string }) => { // if "Ask for credentials" is true
isAsking.value = false; isAsking.value = false;
const params = Object.assign({}, localConnection.value, credentials); const params = Object.assign({}, localConnection.value, credentials);
try { try {
if (isConnecting.value) { if (isConnecting.value) {
const params = Object.assign({}, props.connection, credentials); const params = Object.assign({}, props.connection, credentials);
await connectWorkspace(params); await connectWorkspace(params, { signal: abortController.value.signal }).catch(() => undefined);
isConnecting.value = false; isConnecting.value = false;
} }
else { else {
const res = await Connection.makeTest(params); const res = await Connection.makeTest(params);
if (res.status === 'error') if (res.status === 'error')
addNotification({ status: 'error', message: res.response.message || res.response.toString() }); addNotification({ status: 'error', message: res.response.message || res.response.toString() });
else else if (res.status === 'success')
addNotification({ status: 'success', message: t('connection.connectionSuccessfullyMade') }); addNotification({ status: 'success', message: t('connection.connectionSuccessfullyMade') });
} }
} }

View File

@ -15,6 +15,10 @@ export default class {
return ipcRenderer.invoke('connect', unproxify(newParams)); return ipcRenderer.invoke('connect', unproxify(newParams));
} }
static abortConnection (uid: string): void {
ipcRenderer.send('abort-connection', uid);
}
static checkConnection (uid: string): Promise<boolean> { static checkConnection (uid: string): Promise<boolean> {
return ipcRenderer.invoke('check-connection', uid); return ipcRenderer.invoke('check-connection', uid);
} }

View File

@ -147,7 +147,7 @@ export const useWorkspacesStore = defineStore('workspaces', {
else else
this.selectedWorkspace = uid; this.selectedWorkspace = uid;
}, },
async connectWorkspace (connection: ConnectionParams & { pgConnString?: string }, mode?: string) { async connectWorkspace (connection: ConnectionParams & { pgConnString?: string }, args?: {mode?: string; signal?: AbortSignal}) {
this.workspaces = (this.workspaces as Workspace[]).map(workspace => workspace.uid === connection.uid this.workspaces = (this.workspaces as Workspace[]).map(workspace => workspace.uid === connection.uid
? { ? {
...workspace, ...workspace,
@ -155,14 +155,30 @@ export const useWorkspacesStore = defineStore('workspaces', {
breadcrumbs: {}, breadcrumbs: {},
loadedSchemas: new Set(), loadedSchemas: new Set(),
database: connection.database, database: connection.database,
connectionStatus: mode === 'switch' ? 'connected' : 'connecting' connectionStatus: args?.mode === 'switch' ? 'connected' : 'connecting'
} }
: workspace); : workspace);
const connectionsStore = useConnectionsStore(); const connectionsStore = useConnectionsStore();
const notificationsStore = useNotificationsStore(); const notificationsStore = useNotificationsStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
return new Promise((resolve, reject) => {
const abortHandler = () => {
this.workspaces = (this.workspaces as Workspace[]).map(workspace => workspace.uid === connection.uid
? {
...workspace,
structure: [],
breadcrumbs: {},
loadedSchemas: new Set(),
connectionStatus: 'disconnected'
}
: workspace);
return reject(new Error('Connection aborted by user'));
};
args?.signal?.addEventListener('abort', abortHandler);
(async () => {
try { try {
const { status, response } = await Connection.connect(connection); const { status, response } = await Connection.connect(connection);
@ -177,7 +193,11 @@ export const useWorkspacesStore = defineStore('workspaces', {
connectionStatus: 'failed' connectionStatus: 'failed'
} }
: workspace); : workspace);
return reject(new Error(response));
} }
else if (status === 'abort')
return reject(new Error('Connection aborted by user'));
else { else {
let clientCustomizations: Customizations; let clientCustomizations: Customizations;
const { updateLastConnection } = connectionsStore; const { updateLastConnection } = connectionsStore;
@ -252,15 +272,19 @@ export const useWorkspacesStore = defineStore('workspaces', {
} }
: workspace); : workspace);
args?.signal?.removeEventListener('abort', abortHandler);
this.refreshCollations(connection.uid); this.refreshCollations(connection.uid);
this.refreshVariables(connection.uid); this.refreshVariables(connection.uid);
this.refreshEngines(connection.uid); this.refreshEngines(connection.uid);
this.refreshUsers(connection.uid); this.refreshUsers(connection.uid);
resolve(true);
} }
} }
catch (err) { catch (err) {
notificationsStore.addNotification({ status: 'error', message: err.stack }); notificationsStore.addNotification({ status: 'error', message: err.stack });
} }
})();
});
}, },
async refreshStructure (uid: string) { async refreshStructure (uid: string) {
const notificationsStore = useNotificationsStore(); const notificationsStore = useNotificationsStore();
@ -405,7 +429,7 @@ export const useWorkspacesStore = defineStore('workspaces', {
}, },
async switchConnection (connection: ConnectionParams & { pgConnString?: string }) { async switchConnection (connection: ConnectionParams & { pgConnString?: string }) {
await Connection.disconnect(connection.uid); await Connection.disconnect(connection.uid);
return this.connectWorkspace(connection, 'switch'); return this.connectWorkspace(connection, { mode: 'switch' });
}, },
addWorkspace (uid: string) { addWorkspace (uid: string) {
const workspace: Workspace = { const workspace: Workspace = {