diff --git a/client/package-lock.json b/client/package-lock.json index 659c49beb..44e7e0840 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -37,6 +37,7 @@ "mdi-react": "^7.4.0", "mitt": "^2.1.0", "parse-link-header": "^1.0.1", + "prism-react-renderer": "^1.2.0", "prop-types": "^15.7.2", "query-string": "^6.13.8", "react": "^17.0.1", @@ -3576,6 +3577,14 @@ "node": ">= 10" } }, + "node_modules/prism-react-renderer": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-1.2.0.tgz", + "integrity": "sha512-GHqzxLYImx1iKN1jJURcuRoA/0ygCcNhfGw1IT8nPIMzarmKQ3Nc+JcG0gi8JXQzuh0C5ShE4npMIoqNin40hg==", + "peerDependencies": { + "react": ">=0.14.9" + } + }, "node_modules/progress": { "version": "2.0.3", "dev": true, @@ -7221,6 +7230,12 @@ "react-is": "^17.0.1" } }, + "prism-react-renderer": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-1.2.0.tgz", + "integrity": "sha512-GHqzxLYImx1iKN1jJURcuRoA/0ygCcNhfGw1IT8nPIMzarmKQ3Nc+JcG0gi8JXQzuh0C5ShE4npMIoqNin40hg==", + "requires": {} + }, "progress": { "version": "2.0.3", "dev": true diff --git a/client/package.json b/client/package.json index d198f0459..5ee20533b 100644 --- a/client/package.json +++ b/client/package.json @@ -32,6 +32,7 @@ "mdi-react": "^7.4.0", "mitt": "^2.1.0", "parse-link-header": "^1.0.1", + "prism-react-renderer": "^1.2.0", "prop-types": "^15.7.2", "query-string": "^6.13.8", "react": "^17.0.1", diff --git a/client/src/common/Code.module.css b/client/src/common/Code.module.css index b3d8d89f7..4d91428fa 100644 --- a/client/src/common/Code.module.css +++ b/client/src/common/Code.module.css @@ -1,4 +1,4 @@ -pre { +.pre { font-family: 'Courier 10 Pitch', Courier, monospace; font-size: 95%; line-height: 140%; @@ -6,7 +6,7 @@ pre { word-wrap: break-word; } -code { +.code { font-family: Monaco, Consolas, 'Andale Mono', 'DejaVu Sans Mono', monospace; font-size: 95%; line-height: 140%; diff --git a/client/src/common/Code.tsx b/client/src/common/Code.tsx index 0fbc96cfe..8b8bb6029 100644 --- a/client/src/common/Code.tsx +++ b/client/src/common/Code.tsx @@ -6,7 +6,7 @@ export interface Props extends React.HTMLAttributes { } const Code = ({ children, className, type, ...rest }: Props) => { - const cs = []; + const cs = [styles.code]; if (className) { cs.push(className); @@ -17,7 +17,7 @@ const Code = ({ children, className, type, ...rest }: Props) => { } return ( -
+    
       
         {children}
       
diff --git a/client/src/css/index.css b/client/src/css/index.css
index e59799ee4..771b91fa9 100644
--- a/client/src/css/index.css
+++ b/client/src/css/index.css
@@ -139,6 +139,28 @@ input {
   text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
 }
 
+.sp-error {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  overflow: auto;
+
+  font-size: 1.3rem;
+  padding: 16px;
+  text-align: center;
+  color: hsl(323, 100%, 42%);
+}
+
+.sp-info {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+
+  font-size: 1.3rem;
+  padding: 16px;
+  text-align: center;
+}
+
 /* Might be trying out more of just plain css, less of css.modules */
 .sp-error-block {
   height: 100%;
@@ -163,7 +185,7 @@ input {
   align-items: center;
 
   font-size: 1.3rem;
-  padding: 24px;
+  padding: 16px;
   text-align: center;
 }
 
diff --git a/client/src/queryEditor/HistoryDrawer.tsx b/client/src/queryEditor/HistoryDrawer.tsx
new file mode 100644
index 000000000..4b741a3c6
--- /dev/null
+++ b/client/src/queryEditor/HistoryDrawer.tsx
@@ -0,0 +1,194 @@
+import humanizeDuration from 'humanize-duration';
+import capitalize from 'lodash/capitalize';
+import Highlight, { defaultProps } from 'prism-react-renderer';
+import theme from 'prism-react-renderer/themes/vsLight';
+import React, { useEffect, useRef } from 'react';
+import Button from '../common/Button';
+import Drawer from '../common/Drawer';
+import ErrorBlock from '../common/ErrorBlock';
+import InfoBlock from '../common/InfoBlock';
+import SpinKitCube from '../common/SpinKitCube';
+import { setEditorBatchHistoryItem } from '../stores/editor-actions';
+import { useSessionQueryId, useSessionQueryName } from '../stores/editor-store';
+import { api } from '../utilities/api';
+import styles from './StatementsTable.module.css';
+
+type Props = {
+  visible?: boolean;
+  onClose: (...args: any[]) => any;
+};
+
+function HistoryDrawer({ onClose, visible }: Props) {
+  const bottomEl = useRef(null);
+  const containerEl = useRef(null);
+
+  const queryId = useSessionQueryId() || 'null';
+  const queryName = useSessionQueryName() || 'unsaved queries';
+
+  const {
+    data: queryBatches,
+    error: queryBatchHistoryError,
+  } = api.useQueryBatchHistory(queryId);
+
+  const fetching = !queryBatches;
+
+  const batchLength = queryBatches ? queryBatches.length : 0;
+  useEffect(() => {
+    if (visible && batchLength > 0) {
+      // Without setTimeout this fires too soon? This is sort of hacky and could be fragile
+      setTimeout(() => {
+        bottomEl && bottomEl.current && bottomEl.current.scrollIntoView(false);
+      }, 50);
+    }
+  }, [batchLength, visible]);
+
+  let content = null;
+
+  if (fetching) {
+    content = (
+      
+ +
+ ); + } else if (queryBatchHistoryError) { + content = Error getting execution history; + } else if (queryBatches) { + if (queryBatches.length === 0) { + content = ( +
+ No run history found for this query. +
+ ); + } else { + content = queryBatches + .map((batch) => { + return ( +
+

{batch.createdAtCalendar}

+
+ {capitalize(batch.status)} in{' '} + {humanizeDuration(batch.durationMs)} +
+ + + {({ + className, + style, + tokens, + getLineProps, + getTokenProps, + }) => ( +
+                    {tokens.map((line, i) => (
+                      
+ {line.map((token, key) => ( + + ))} +
+ ))} +
+ )} +
+ {!batch.statements && + (batch.status === 'finished' || batch.status === 'error') ? ( +
+ Query results purged from storage +
+ ) : null} + {(batch.statements || []).map((statement) => { + if (statement.error) { + return ( +
+ {statement.error.title} +
+ ); + } + + if (statement.status !== 'finished') { + return null; + } + + return ( + + + + {(statement?.columns || []).map((column) => ( + + ))} + + + + + +
{column.name}
+ + {statement.rowCount}{' '} + {statement.rowCount === 1 ? 'row' : 'rows'} + {statement.incomplete ? '(incomplete)' : ''} + +
+ ); + })} +
+ ); + }) + .concat([
]); + } + } + + return ( + +
+ {content} +
+
+ ); +} + +export default React.memo(HistoryDrawer); diff --git a/client/src/queryEditor/Toolbar.tsx b/client/src/queryEditor/Toolbar.tsx index e136d5328..e58af8ec9 100644 --- a/client/src/queryEditor/Toolbar.tsx +++ b/client/src/queryEditor/Toolbar.tsx @@ -2,6 +2,7 @@ import React from 'react'; import ConnectionDropDown from './ConnectionDropdown'; import ToolbarChartButton from './ToolbarChartButton'; import ToolbarConnectionClientButton from './ToolbarConnectionClientButton'; +import ToolbarHistoryButton from './ToolbarHistoryButton'; import ToolbarQueryName from './ToolbarQueryName'; import ToolbarRunButton from './ToolbarRunButton'; import ToolbarSpacer from './ToolbarSpacer'; @@ -27,6 +28,7 @@ function Toolbar() { +
diff --git a/client/src/queryEditor/ToolbarHistoryButton.tsx b/client/src/queryEditor/ToolbarHistoryButton.tsx new file mode 100644 index 000000000..693ecc6bf --- /dev/null +++ b/client/src/queryEditor/ToolbarHistoryButton.tsx @@ -0,0 +1,22 @@ +import HistoryIcon from 'mdi-react/HistoryIcon'; +import React, { useState } from 'react'; +import IconButton from '../common/IconButton'; +import HistoryDrawer from './HistoryDrawer'; + +function ToolbarHistoryButton() { + const [show, setShow] = useState(false); + + return ( + <> + setShow(true)} + > + + + {show && setShow(false)} />} + + ); +} + +export default React.memo(ToolbarHistoryButton); diff --git a/client/src/stores/editor-actions.ts b/client/src/stores/editor-actions.ts index bd742bef8..2fd163a23 100644 --- a/client/src/stores/editor-actions.ts +++ b/client/src/stores/editor-actions.ts @@ -5,6 +5,7 @@ import { ACLRecord, AppInfo, Batch, + BatchHistoryItem, ChartFields, Connection, ConnectionClient, @@ -463,6 +464,35 @@ export const runQuery = async () => { }); }; +export const setEditorBatchHistoryItem = async ( + batchHistoryItem: BatchHistoryItem +) => { + const { focusedSessionId } = getState(); + + // Statements might not exist if query result data is purged + // In that case, just restore the SQL and chart config and similar + // clear out batchId/selectedStatementId + const hasStatements = batchHistoryItem.statements; + + setSession(focusedSessionId, { + queryName: batchHistoryItem.name, + queryText: batchHistoryItem.batchText, + chartType: batchHistoryItem.chart?.chartType, + chartFields: batchHistoryItem.chart?.fields, + connectionId: batchHistoryItem.connectionId, + connectionClient: undefined, + selectedText: '', + batchId: hasStatements ? batchHistoryItem.id : undefined, + selectedStatementId: undefined, + isRunning: false, + runQueryStartTime: batchHistoryItem.startTime, + }); + + if (hasStatements) { + setBatch(focusedSessionId, batchHistoryItem.id, batchHistoryItem); + } +}; + export const saveQuery = async (additionalUpdates?: Partial) => { const { focusedSessionId } = getState(); const session = getState().getFocusedSession(); diff --git a/client/src/types.ts b/client/src/types.ts index cacb0e66a..5300060d8 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -76,6 +76,12 @@ export interface Batch { updatedAt: string | Date; } +export interface BatchHistoryItem extends Batch { + startTimeCalendar: string | Date; + stopTimeCalendar: string | Date; + createdAtCalendar: string | Date; +} + export type ConnectionFields = Record; export interface ConnectionDetail extends ConnectionFields { diff --git a/client/src/utilities/api.ts b/client/src/utilities/api.ts index 21969d8fc..51cbfc7a6 100644 --- a/client/src/utilities/api.ts +++ b/client/src/utilities/api.ts @@ -5,6 +5,7 @@ import message from '../common/message'; import { AppInfo, Batch, + BatchHistoryItem, Connection, ConnectionAccess, ConnectionDetail, @@ -129,6 +130,12 @@ export const api = { return useSWR(`/api/batches/${batchId}`); }, + useQueryBatchHistory(queryId: string) { + return useSWR( + `/api/batches?queryId=${queryId}&includeStatements=true` + ); + }, + getStatementResults(statementId: string) { return this.get(`/api/statements/${statementId}/results`); }, diff --git a/server/models/batches.js b/server/models/batches.js index 9a67dafeb..4745b109c 100644 --- a/server/models/batches.js +++ b/server/models/batches.js @@ -1,4 +1,5 @@ const sqlLimiter = require('sql-limiter'); +const _ = require('lodash'); const ensureJson = require('./ensure-json'); class Batches { @@ -41,6 +42,47 @@ class Batches { return items; } + /** + * Get all batches and more for user and query id + * @param {object} user + * @param {string} [queryId] + * @param {boolean} [includeStatements] + * @param {number} [limit] + */ + async findAllForUserQuery( + user, + queryId = null, + includeStatements = false, + limit = 40 + ) { + let batches = await this.sequelizeDb.Batches.findAll({ + where: { userId: user.id, queryId }, + limit, + order: [['createdAt', 'DESC']], + }); + batches = batches.map((item) => item.toJSON()); + + if (includeStatements) { + const batchIds = batches.map((batch) => batch.id); + let statements = await this.sequelizeDb.Statements.findAll({ + where: { batchId: batchIds }, + }); + statements = statements.map((statement) => statement.toJSON()); + const statementsByBatchId = _.groupBy(statements, 'batchId'); + batches.forEach((batch) => { + batch.statements = statementsByBatchId[batch.id]; + if (batch.statements) { + _.sortBy(batch.statements, ['sequence']); + } + }); + } + + // Results are in desc order, but we'll return them in ascending + batches = _.sortBy(batches, ['createdAt']); + + return batches; + } + /** * Create a new batch (and statements) * selectedText is parsed out into statements diff --git a/server/routes/batches.js b/server/routes/batches.js index a7c28b717..ec7b69a2b 100644 --- a/server/routes/batches.js +++ b/server/routes/batches.js @@ -1,4 +1,5 @@ require('../typedefs'); +const moment = require('moment'); const router = require('express').Router(); const mustBeAuthenticated = require('../middleware/must-be-authenticated.js'); const executeBatch = require('../lib/execute-batch'); @@ -66,8 +67,33 @@ router.post( * @param {Res} res */ async function list(req, res) { - const { models, user } = req; - const batches = await models.batches.findAllForUser(user); + const { models, user, query } = req; + const { queryId, includeStatements } = query; + + let batches; + if (queryId) { + const cleanedQueryId = queryId === 'null' ? null : queryId; + let cleanedIncludeStatements = false; + if (includeStatements) { + cleanedIncludeStatements = + includeStatements.toString().toLowerCase().trim() === 'true'; + } + + batches = await models.batches.findAllForUserQuery( + user, + cleanedQueryId, + cleanedIncludeStatements + ); + } else { + batches = await models.batches.findAllForUser(user); + } + + batches.forEach((batch) => { + batch.startTimeCalendar = moment(batch.startTime).calendar(); + batch.stopTimeCalendar = moment(batch.stopTime).calendar(); + batch.createdAtCalendar = moment(batch.createdAt).calendar(); + }); + return res.utils.data(batches); } diff --git a/server/test/api/batches.js b/server/test/api/batches.js index cbb3c8155..e7b3eafbe 100644 --- a/server/test/api/batches.js +++ b/server/test/api/batches.js @@ -24,6 +24,7 @@ describe('api/batches', function () { let query; let connection; let batch; + let batchWithoutQueryId; let statement1; let statement2; @@ -72,6 +73,51 @@ describe('api/batches', function () { assert.equal(batch.status, 'started'); }); + it('creates batch without query id', async function () { + batchWithoutQueryId = await utils.post('admin', `/api/batches`, { + connectionId: connection.id, + batchText: queryText, + selectedText: queryText, + }); + assert(batchWithoutQueryId.id); + }); + + it('gets list for query id including statements', async function () { + const batches = await utils.get( + 'admin', + `/api/batches?queryId=${query.id}&includeStatements=true` + ); + const foundBatch = batches.find((b) => b.id === batch.id); + assert.strictEqual(foundBatch.statements.length, 2); + }); + + it('gets list for query id without statements', async function () { + const batches = await utils.get( + 'admin', + `/api/batches?queryId=${query.id}&includeStatements=false` + ); + const foundBatch = batches.find((b) => b.id === batch.id); + assert(!foundBatch.statements); + }); + + it('gets list for no queryId including statements', async function () { + const batches = await utils.get( + 'admin', + `/api/batches?queryId=null&includeStatements=true` + ); + const foundBatch = batches.find((b) => b.id === batchWithoutQueryId.id); + assert.strictEqual(foundBatch.statements.length, 2); + }); + + it('gets list for no query id without statements', async function () { + const batches = await utils.get( + 'admin', + `/api/batches?queryId=null&includeStatements=false` + ); + const foundBatch = batches.find((b) => b.id === batchWithoutQueryId.id); + assert(!foundBatch.statements); + }); + it('GETs finished result', async function () { batch = await utils.get('admin', `/api/batches/${batch.id}`); while (batch.status !== 'finished' && batch.status !== 'errored') { @@ -177,7 +223,7 @@ describe('api/batches', function () { it('Only batch creator can view batch', async function () { const adminBatches = await utils.get('admin', `/api/batches`); - assert.equal(adminBatches.length, 1); + assert.equal(adminBatches.length, 2); const editorBatches = await utils.get('editor', `/api/batches`); assert.equal(editorBatches.length, 0); await utils.get('editor', `/api/batches/${batch.id}`, 403);