diff --git a/packages/api/src/controllers/serverConnections.js b/packages/api/src/controllers/serverConnections.js index 7dd16f226..aeaf1fdb0 100644 --- a/packages/api/src/controllers/serverConnections.js +++ b/packages/api/src/controllers/serverConnections.js @@ -1,24 +1,31 @@ const connections = require('./connections'); const socket = require('../utility/socket'); const { fork } = require('child_process'); +const _ = require('lodash'); module.exports = { opened: [], + closed: [], handle_databases(conid, { databases }) { - const existing = this.opened.find(x => x.conid == conid); + const existing = this.opened.find((x) => x.conid == conid); if (!existing) return; existing.databases = databases; socket.emitChanged(`database-list-changed-${conid}`); }, + handle_status(conid, { status }) { + const existing = this.opened.find((x) => x.conid == conid); + if (!existing) return; + existing.status = status; + socket.emitChanged(`server-status-changed`); + }, handle_error(conid, { error }) { console.log(`Error in server connection ${conid}: ${error}`); }, - handle_ping() { - }, + handle_ping() {}, async ensureOpened(conid) { - const existing = this.opened.find(x => x.conid == conid); + const existing = this.opened.find((x) => x.conid == conid); if (existing) return existing; const connection = await connections.get({ conid }); const subprocess = fork(process.argv[1], ['serverConnectionProcess']); @@ -27,19 +34,75 @@ module.exports = { subprocess, databases: [], connection, + status: { + name: 'pending', + }, + disconnected: false, }; this.opened.push(newOpened); + this.closed = this.closed.filter((x) => x != conid); + socket.emitChanged(`server-status-changed`); // @ts-ignore subprocess.on('message', ({ msgtype, ...message }) => { + if (newOpened.disconnected) return; this[`handle_${msgtype}`](conid, message); }); + subprocess.on('exit', () => { + if (newOpened.disconnected) return; + this.opened = this.opened.filter((x) => x.conid != conid); + this.closed.push(conid); + socket.emitChanged(`server-status-changed`); + }); subprocess.send({ msgtype: 'connect', ...connection }); return newOpened; }, + close(conid) { + const existing = this.opened.find((x) => x.conid == conid); + if (existing) { + existing.disconnected = true; + existing.subprocess.kill(); + this.opened = this.opened.filter((x) => x.conid != conid); + this.closed.push(conid); + } + }, + listDatabases_meta: 'get', async listDatabases({ conid }) { const opened = await this.ensureOpened(conid); return opened.databases; }, + + serverStatus_meta: 'get', + async serverStatus() { + return { + ...this.closed.reduce( + (res, conid) => ({ + ...res, + [conid]: { + name: 'error', + }, + }), + {} + ), + ..._.mapValues(_.keyBy(this.opened, 'conid'), 'status'), + }; + }, + + ping_meta: 'post', + async ping({ connections }) { + for (const conid of connections) { + const opened = await this.ensureOpened(conid); + opened.subprocess.send({ msgtype: 'ping' }); + } + return { status: 'ok' }; + }, + + refresh_meta: 'post', + async refresh({ conid }) { + this.close(conid); + + await this.ensureOpened(conid); + return { status: 'ok' }; + }, }; diff --git a/packages/api/src/proc/serverConnectionProcess.js b/packages/api/src/proc/serverConnectionProcess.js index 13ee8e087..745875ac6 100644 --- a/packages/api/src/proc/serverConnectionProcess.js +++ b/packages/api/src/proc/serverConnectionProcess.js @@ -1,27 +1,67 @@ const engines = require('@dbgate/engines'); const driverConnect = require('../utility/driverConnect'); const childProcessChecker = require('../utility/childProcessChecker'); +const stableStringify = require('json-stable-stringify'); let systemConnection; let storedConnection; +let lastDatabases = null; +let lastStatus = null; +let lastPing = null; -async function handleRefreshDatabases() { +async function handleRefresh() { const driver = engines(storedConnection); - const databases = await driver.listDatabases(systemConnection); - process.send({ msgtype: 'databases', databases }); + try { + const databases = await driver.listDatabases(systemConnection); + setStatusName('ok'); + const databasesString = stableStringify(databases); + if (lastDatabases != databasesString) { + process.send({ msgtype: 'databases', databases }); + lastDatabases = databasesString; + } + } catch (err) { + setStatusName('error'); + console.error(err); + process.exit(1); + } +} + +function setStatus(status) { + const statusString = stableStringify(status); + if (lastStatus != statusString) { + process.send({ msgtype: 'status', status }); + lastStatus = statusString; + } +} + +function setStatusName(name) { + setStatus({ name }); } async function handleConnect(connection) { storedConnection = connection; + setStatusName('pending'); + lastPing = new Date().getTime(); const driver = engines(storedConnection); - systemConnection = await driverConnect(driver, storedConnection); - handleRefreshDatabases(); - setInterval(handleRefreshDatabases, 30 * 1000); + try { + systemConnection = await driverConnect(driver, storedConnection); + handleRefresh(); + setInterval(handleRefresh, 30 * 1000); + } catch (err) { + setStatusName('error'); + console.error(err); + process.exit(1); + } +} + +function handlePing() { + lastPing = new Date().getTime(); } const messageHandlers = { connect: handleConnect, + ping: handlePing, }; async function handleMessage({ msgtype, ...other }) { @@ -31,6 +71,14 @@ async function handleMessage({ msgtype, ...other }) { function start() { childProcessChecker(); + + setInterval(() => { + const time = new Date().getTime(); + if (time - lastPing > 60 * 1000) { + process.exit(0); + } + }, 60 * 1000); + process.on('message', async (message) => { try { await handleMessage(message); diff --git a/packages/web/src/App.js b/packages/web/src/App.js index 9e862b759..fdbf5e5a5 100644 --- a/packages/web/src/App.js +++ b/packages/web/src/App.js @@ -6,8 +6,10 @@ import { CurrentDatabaseProvider, OpenedTabsProvider, SavedSqlFilesProvider, + OpenedConnectionsProvider, } from './utility/globalState'; import { SocketProvider } from './utility/SocketProvider'; +import OpenedConnectionsPinger from './utility/OpnedConnectionsPinger'; function App() { return ( @@ -16,7 +18,11 @@ function App() { - + + + + + diff --git a/packages/web/src/appobj/AppObjectList.js b/packages/web/src/appobj/AppObjectList.js index e388300a7..c36da4cbf 100644 --- a/packages/web/src/appobj/AppObjectList.js +++ b/packages/web/src/appobj/AppObjectList.js @@ -38,7 +38,11 @@ function AppObjectListItem({ makeAppObj, data, filter, appobj, onObjectClick, Su // if (matcher && !matcher(filter)) return null; if (onObjectClick) appobj.onClick = onObjectClick; if (SubItems) { - appobj.onClick = () => setIsExpanded(!isExpanded); + const oldClick = appobj.onClick; + appobj.onClick = () => { + if (oldClick) oldClick(); + setIsExpanded(!isExpanded); + }; } let res = ( @@ -51,7 +55,11 @@ function AppObjectListItem({ makeAppObj, data, filter, appobj, onObjectClick, Su prefix={ SubItems ? ( - + {appobj.isExpandable ? ( + + ) : ( + + )} ) : null } @@ -83,9 +91,9 @@ function AppObjectGroup({ group, items }) { - {group} {items && `(${items.filter(x => x.component).length})`} + {group} {items && `(${items.filter((x) => x.component).length})`} - {isExpanded && items.map(x => x.component)} + {isExpanded && items.map((x) => x.component)} ); } @@ -115,7 +123,7 @@ export function AppObjectList({ if (groupFunc) { const listGrouped = _.compact( - (list || []).map(data => { + (list || []).map((data) => { const appobj = makeAppObj(data, appObjectParams); const { matcher } = appobj; if (matcher && !matcher(filter)) return null; @@ -125,12 +133,12 @@ export function AppObjectList({ }) ); const groups = _.groupBy(listGrouped, 'group'); - return (groupOrdered || _.keys(groups)).map(group => ( + return (groupOrdered || _.keys(groups)).map((group) => ( )); } - return (list || []).map(data => { + return (list || []).map((data) => { const appobj = makeAppObj(data, appObjectParams); const { matcher } = appobj; if (matcher && !matcher(filter)) return null; diff --git a/packages/web/src/appobj/AppObjects.js b/packages/web/src/appobj/AppObjects.js index 1c49f680f..e52281c49 100644 --- a/packages/web/src/appobj/AppObjects.js +++ b/packages/web/src/appobj/AppObjects.js @@ -5,6 +5,7 @@ import React from 'react'; import styled from 'styled-components'; import { showMenu } from '../modals/DropDownMenu'; import { useSetOpenedTabs, useAppObjectParams } from '../utility/globalState'; +import { FontIcon } from '../icons'; const AppObjectDiv = styled.div` padding: 5px; @@ -25,6 +26,10 @@ const IconWrap = styled.span` margin-right: 10px; `; +const StatusIconWrap = styled.span` + margin-left: 5px; +`; + export function AppObjectCore({ title, Icon, @@ -36,6 +41,7 @@ export function AppObjectCore({ isBusy, component = 'div', prefix = null, + statusIcon, ...other }) { const appObjectParams = useAppObjectParams(); @@ -63,6 +69,11 @@ export function AppObjectCore({ {prefix} {isBusy ? : } {title} + {statusIcon && ( + + + + )} ); } diff --git a/packages/web/src/appobj/connectionAppObject.js b/packages/web/src/appobj/connectionAppObject.js index 75e2cb2b3..f51b6b0de 100644 --- a/packages/web/src/appobj/connectionAppObject.js +++ b/packages/web/src/appobj/connectionAppObject.js @@ -7,34 +7,67 @@ import ConnectionModal from '../modals/ConnectionModal'; import axios from '../utility/axios'; import { filterName } from '@dbgate/datalib'; -function Menu({ data, makeAppObj }) { +function Menu({ data, setOpenedConnections }) { const handleEdit = () => { - showModal(modalState => ); + showModal((modalState) => ); }; const handleDelete = () => { axios.post('connections/delete', data); }; + const handleRefresh = () => { + axios.post('server-connections/refresh', { conid: data._id }); + }; + const handleDisconnect = () => { + setOpenedConnections((list) => list.filter((x) => x != data._id)); + }; return ( <> Edit Delete + Refresh + Disconnect ); } -const connectionAppObject = flags => ({ _id, server, displayName, engine }) => { +const connectionAppObject = (flags) => ( + { _id, server, displayName, engine, status }, + { openedConnections, setOpenedConnections } +) => { const title = displayName || server; const key = _id; + const isExpandable = openedConnections.includes(_id); const Icon = getEngineIcon(engine); - const matcher = filter => filterName(filter, displayName, server); + const matcher = (filter) => filterName(filter, displayName, server); const { boldCurrentDatabase } = flags || {}; const isBold = boldCurrentDatabase ? ({ currentDatabase }) => { return _.get(currentDatabase, 'connection._id') == _id; } : null; + const onClick = () => setOpenedConnections((c) => [...c, _id]); - return { title, key, Icon, Menu, matcher, isBold }; + // let isBusy = false; + let statusIcon = null; + if (openedConnections.includes(_id)) { + if (!status) statusIcon = 'fas fa-spinner fa-spin'; + else if (status.name == 'pending') statusIcon = 'fas fa-spinner fa-spin'; + else if (status.name == 'ok') statusIcon = 'fas fa-check-circle green'; + else statusIcon = 'fas fa-times-circle red'; + } + + return { + title, + key, + Icon, + Menu, + matcher, + isBold, + isExpandable, + onClick, + // isBusy, + statusIcon, + }; }; export default connectionAppObject; diff --git a/packages/web/src/icons.js b/packages/web/src/icons.js index 37c9b7385..2748bd9f7 100644 --- a/packages/web/src/icons.js +++ b/packages/web/src/icons.js @@ -27,7 +27,7 @@ export function FontIcon({ icon, ...props }) { let className = props.className || ''; // if (_.startsWith(name, 'bs-')) className += ` glyphicon glyphicon-${name.substr(3)}`; - if (type == 'fas' || type == 'far') className += ` ${type} ${name}`; + if (type == 'fas' || type == 'far') className += ` ${type} ${name} ${parts.join(' ')}`; if (_.includes(parts, 'spin')) className += ' fa-spin'; @@ -41,67 +41,73 @@ export function FontIcon({ icon, ...props }) { return ; } -export function ExpandIcon({ isBlank = false, isExpanded = false, isSelected = false, ...other }) { +export function ExpandIcon({ + isBlank = false, + isExpanded = false, + isSelected = false, + blankColor = 'white', + ...other +}) { if (isBlank) { - return ; + return ; } return ; } -export const TableIcon = props => getIconImage('table2.svg', props); -export const ViewIcon = props => getIconImage('view2.svg', props); -export const DatabaseIcon = props => getIconImage('database.svg', props); -export const ServerIcon = props => getIconImage('server.svg', props); +export const TableIcon = (props) => getIconImage('table2.svg', props); +export const ViewIcon = (props) => getIconImage('view2.svg', props); +export const DatabaseIcon = (props) => getIconImage('database.svg', props); +export const ServerIcon = (props) => getIconImage('server.svg', props); -export const MicrosoftIcon = props => getIconImage('microsoft.svg', props); -export const MySqlIcon = props => getIconImage('mysql.svg', props); -export const PostgreSqlIcon = props => getIconImage('postgresql.svg', props); -export const SqliteIcon = props => getIconImage('sqlite.svg', props); +export const MicrosoftIcon = (props) => getIconImage('microsoft.svg', props); +export const MySqlIcon = (props) => getIconImage('mysql.svg', props); +export const PostgreSqlIcon = (props) => getIconImage('postgresql.svg', props); +export const SqliteIcon = (props) => getIconImage('sqlite.svg', props); -export const ProcedureIcon = props => getIconImage('procedure2.svg', props); -export const FunctionIcon = props => getIconImage('function.svg', props); -export const TriggerIcon = props => getIconImage('trigger.svg', props); +export const ProcedureIcon = (props) => getIconImage('procedure2.svg', props); +export const FunctionIcon = (props) => getIconImage('function.svg', props); +export const TriggerIcon = (props) => getIconImage('trigger.svg', props); -export const HomeIcon = props => getIconImage('home.svg', props); -export const PrimaryKeyIcon = props => getIconImage('primarykey.svg', props); -export const ForeignKeyIcon = props => getIconImage('foreignkey.svg', props); -export const ComplexKeyIcon = props => getIconImage('complexkey.svg', props); -export const VariableIcon = props => getIconImage('variable.svg', props); -export const UniqueIcon = props => getIconImage('unique.svg', props); -export const IndexIcon = props => getIconImage('index.svg', props); +export const HomeIcon = (props) => getIconImage('home.svg', props); +export const PrimaryKeyIcon = (props) => getIconImage('primarykey.svg', props); +export const ForeignKeyIcon = (props) => getIconImage('foreignkey.svg', props); +export const ComplexKeyIcon = (props) => getIconImage('complexkey.svg', props); +export const VariableIcon = (props) => getIconImage('variable.svg', props); +export const UniqueIcon = (props) => getIconImage('unique.svg', props); +export const IndexIcon = (props) => getIconImage('index.svg', props); -export const StartIcon = props => getIconImage('start.svg', props); -export const DownCircleIcon = props => getIconImage('down_circle.svg', props); +export const StartIcon = (props) => getIconImage('start.svg', props); +export const DownCircleIcon = (props) => getIconImage('down_circle.svg', props); -export const ColumnIcon = props => getIconImage('column.svg', props); +export const ColumnIcon = (props) => getIconImage('column.svg', props); -export const SqlIcon = props => getIconImage('sql.svg', props); -export const ExcelIcon = props => getIconImage('excel.svg', props); -export const DiagramIcon = props => getIconImage('diagram.svg', props); -export const QueryDesignIcon = props => getIconImage('querydesign.svg', props); -export const LocalDbIcon = props => getIconImage('localdb.svg', props); -export const CsvIcon = props => getIconImage('csv.svg', props); -export const ChangeSetIcon = props => getIconImage('changeset.svg', props); -export const BinaryFileIcon = props => getIconImage('binaryfile.svg', props); +export const SqlIcon = (props) => getIconImage('sql.svg', props); +export const ExcelIcon = (props) => getIconImage('excel.svg', props); +export const DiagramIcon = (props) => getIconImage('diagram.svg', props); +export const QueryDesignIcon = (props) => getIconImage('querydesign.svg', props); +export const LocalDbIcon = (props) => getIconImage('localdb.svg', props); +export const CsvIcon = (props) => getIconImage('csv.svg', props); +export const ChangeSetIcon = (props) => getIconImage('changeset.svg', props); +export const BinaryFileIcon = (props) => getIconImage('binaryfile.svg', props); -export const ReferenceIcon = props => getIconImage('reference.svg', props); -export const LinkIcon = props => getIconImage('link.svg', props); +export const ReferenceIcon = (props) => getIconImage('reference.svg', props); +export const LinkIcon = (props) => getIconImage('link.svg', props); -export const SequenceIcon = props => getIconImage('sequence.svg', props); -export const CheckIcon = props => getIconImage('check.svg', props); +export const SequenceIcon = (props) => getIconImage('sequence.svg', props); +export const CheckIcon = (props) => getIconImage('check.svg', props); -export const LinkedServerIcon = props => getIconImage('linkedserver.svg', props); +export const LinkedServerIcon = (props) => getIconImage('linkedserver.svg', props); -export const EmptyIcon = props => getIconImage('data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=', props); +export const EmptyIcon = (props) => getIconImage('data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=', props); -export const TimesRedIcon = props => ; -export const TimesGreenCircleIcon = props => ; -export const GrayFilterIcon = props => ; -export const ExclamationTriangleIcon = props => ; -export const HourGlassIcon = props => ; -export const InfoBlueCircleIcon = props => ; +export const TimesRedIcon = (props) => ; +export const TimesGreenCircleIcon = (props) => ; +export const GrayFilterIcon = (props) => ; +export const ExclamationTriangleIcon = (props) => ; +export const HourGlassIcon = (props) => ; +export const InfoBlueCircleIcon = (props) => ; -export const SpinnerIcon = props => ; +export const SpinnerIcon = (props) => ; export function getEngineIcon(engine) { switch (engine) { diff --git a/packages/web/src/utility/OpnedConnectionsPinger.js b/packages/web/src/utility/OpnedConnectionsPinger.js new file mode 100644 index 000000000..a2c9a371f --- /dev/null +++ b/packages/web/src/utility/OpnedConnectionsPinger.js @@ -0,0 +1,14 @@ +import React from 'react'; +import { useOpenedConnections } from './globalState'; +import axios from './axios'; + +export default function OpenedConnectionsPinger({ children }) { + const openedConnections = useOpenedConnections(); + React.useEffect(() => { + const handle = window.setInterval(() => { + axios.post('server-connections/ping', { connections: openedConnections }); + }, 30 * 1000); + return () => window.clearInterval(handle); + }, [openedConnections]); + return children; +} diff --git a/packages/web/src/utility/globalState.js b/packages/web/src/utility/globalState.js index 26d34b04a..39b3dc972 100644 --- a/packages/web/src/utility/globalState.js +++ b/packages/web/src/utility/globalState.js @@ -84,15 +84,23 @@ export function useAppObjectParams() { const newQuery = useNewQuery(); const openedTabs = useOpenedTabs(); const setSavedSqlFiles = useSetSavedSqlFiles(); - + const openedConnections = useOpenedConnections(); + const setOpenedConnections = useSetOpenedConnections(); + return { setOpenedTabs, currentDatabase, newQuery, openedTabs, setSavedSqlFiles, + openedConnections, + setOpenedConnections, }; } const [SavedSqlFilesProvider, useSavedSqlFiles, useSetSavedSqlFiles] = createStorageState('savedSqlFiles', []); export { SavedSqlFilesProvider, useSavedSqlFiles, useSetSavedSqlFiles }; + +const [OpenedConnectionsProvider, useOpenedConnections, useSetOpenedConnections] = createGlobalState([]); + +export { OpenedConnectionsProvider, useOpenedConnections, useSetOpenedConnections }; diff --git a/packages/web/src/utility/metadataLoaders.js b/packages/web/src/utility/metadataLoaders.js index 64d446360..2616ea25c 100644 --- a/packages/web/src/utility/metadataLoaders.js +++ b/packages/web/src/utility/metadataLoaders.js @@ -33,6 +33,12 @@ const databaseListLoader = ({ conid }) => ({ reloadTrigger: `database-list-changed-${conid}`, }); +const serverStatusLoader = () => ({ + url: 'server-connections/server-status', + params: {}, + reloadTrigger: `server-status-changed`, +}); + const connectionListLoader = () => ({ url: 'connections/list', params: {}, @@ -126,6 +132,13 @@ export function useDatabaseList(args) { return useCore(databaseListLoader, args); } +export function getServerStatus() { + return getCore(serverStatusLoader, {}); +} +export function useServerStatus() { + return useCore(serverStatusLoader, {}); +} + export function getConnectionList() { return getCore(connectionListLoader, {}); } diff --git a/packages/web/src/widgets/DatabaseWidget.js b/packages/web/src/widgets/DatabaseWidget.js index 3186783fb..50c699804 100644 --- a/packages/web/src/widgets/DatabaseWidget.js +++ b/packages/web/src/widgets/DatabaseWidget.js @@ -7,7 +7,7 @@ import databaseAppObject from '../appobj/databaseAppObject'; import { useSetCurrentDatabase, useCurrentDatabase } from '../utility/globalState'; import InlineButton from './InlineButton'; import databaseObjectAppObject from '../appobj/databaseObjectAppObject'; -import { useSqlObjectList, useDatabaseList, useConnectionList } from '../utility/metadataLoaders'; +import { useSqlObjectList, useDatabaseList, useConnectionList, useServerStatus } from '../utility/metadataLoaders'; import { SearchBoxWrapper, InnerContainer, Input, MainContainer, OuterContainer, WidgetTitle } from './WidgetStyles'; function SubDatabaseList({ data }) { @@ -31,6 +31,9 @@ function SubDatabaseList({ data }) { function ConnectionList() { const connections = useConnectionList(); + const serverStatus = useServerStatus(); + const connectionsWithStatus = + connections && serverStatus && connections.map((conn) => ({ ...conn, status: serverStatus[conn._id] })); const [filter, setFilter] = React.useState(''); return ( @@ -43,7 +46,7 @@ function ConnectionList() {