add event triggers (#329)

This commit is contained in:
Tirumarai Selvan 2018-09-05 16:56:46 +05:30 committed by Shahidh K Muhammed
parent d397d932d6
commit 82e09efce6
90 changed files with 10924 additions and 89 deletions

View File

@ -32,6 +32,7 @@ var testMetadata = map[string][]byte{
tables:
- array_relationships: []
delete_permissions: []
event_triggers: []
insert_permissions: []
object_relationships: []
select_permissions: []

View File

@ -0,0 +1,20 @@
export const baseUrl = Cypress.config('baseUrl');
export const queryTypes = ['insert', 'update', 'delete'];
export const getTriggerName = (i, testName = '') =>
`apic_test_trigger_${testName}_${i}`;
export const getTableName = (i, testName = '') =>
`apic_test_table_${testName}_${i}`;
export const getWebhookURL = () => 'http://httpbin.org/post';
export const getNoOfRetries = () => '5';
export const getIntervalSeconds = () => '10';
export const getElementFromAlias = alias => `[data-test=${alias}]`;
export const makeDataAPIUrl = dataApiUrl => `${dataApiUrl}/v1/query`;
export const makeDataAPIOptions = (dataApiUrl, key, body) => ({
method: 'POST',
url: makeDataAPIUrl(dataApiUrl),
headers: {
'x-hasura-access-key': key,
},
body,
failOnStatusCode: false,
});

View File

@ -13,7 +13,7 @@ import { testPermissions, permRemove, createView, trackView } from './utils';
const testName = 'perm';
export const passPTCreateTable = () => {
// Click on create tabel
// Click on create table
cy.get(getElementFromAlias('data-create-table')).click();
// Match the URL
cy.url().should('eq', `${baseUrl}/data/schema/public/table/add`);

View File

@ -0,0 +1,188 @@
import {
getElementFromAlias,
getTableName,
getTriggerName,
getWebhookURL,
getNoOfRetries,
getIntervalSeconds,
baseUrl,
} from '../../../helpers/eventHelpers';
import { getColName } from '../../../helpers/dataHelpers';
import {
setMetaData,
validateCT,
validateCTrigger,
validateInsert,
} from '../../validators/validators';
const testName = 'ctr'; // create trigger
export const visitEventsManagePage = () => {
cy.visit('/events/manage');
};
export const passPTCreateTable = () => {
// Click on create table
cy.get(getElementFromAlias('data-create-table')).click();
// Match the URL
cy.url().should('eq', `${baseUrl}/data/schema/public/table/add`);
// Type table name
cy.get(getElementFromAlias('tableName')).type(getTableName(0, testName));
// Set first column
cy.get(getElementFromAlias('column-0')).type(getColName(0));
cy.get(getElementFromAlias('col-type-0')).select('serial');
// Set second column
cy.get(getElementFromAlias('column-1')).type(getColName(1));
cy.get(getElementFromAlias('col-type-1')).select('integer');
// Set third column
cy.get(getElementFromAlias('column-2')).type(getColName(2));
cy.get(getElementFromAlias('col-type-2')).select('text');
// Set primary key
cy.get(getElementFromAlias('primary-key-select-0')).select('0');
// Create
cy.get(getElementFromAlias('table-create')).click();
cy.wait(7000);
cy.url().should(
'eq',
`${baseUrl}/data/schema/public/tables/${getTableName(0, testName)}/modify`
);
};
export const checkCreateTriggerRoute = () => {
// Click on the create trigger button
cy.visit('/events/manage');
cy.wait(15000);
cy.get(getElementFromAlias('data-create-trigger')).click();
// Match the URL
cy.url().should('eq', `${baseUrl}/events/manage/triggers/add`);
};
export const failCTWithoutData = () => {
// Type trigger name
cy.get(getElementFromAlias('trigger-name')).type(getTriggerName(0, testName));
// Click on create
cy.get(getElementFromAlias('trigger-create')).click();
// Check if the route didn't change
cy.url().should('eq', `${baseUrl}/events/manage/triggers/add`);
// Validate
validateCT(getTriggerName(0, testName), 'failure');
};
export const passCT = () => {
// Set trigger name and select table
cy.get(getElementFromAlias('trigger-name'))
.clear()
.type(getTriggerName(0, testName));
cy.get(getElementFromAlias('select-table')).select(getTableName(0, testName));
// operations
cy.get(getElementFromAlias('insert-operation')).check();
cy.get(getElementFromAlias('update-operation')).check();
cy.get(getElementFromAlias('delete-operation')).check();
// webhook url
cy.get(getElementFromAlias('webhook'))
.clear()
.type(getWebhookURL());
// advanced settings
cy.get(getElementFromAlias('advanced-settings')).click();
// retry configuration
cy.get(getElementFromAlias('no-of-retries')).type(getNoOfRetries());
cy.get(getElementFromAlias('interval-seconds')).type(getIntervalSeconds());
// Click on create
cy.get(getElementFromAlias('trigger-create')).click();
cy.wait(10000);
// Check if the trigger got created and navigated to processed events page
cy.url().should(
'eq',
`${baseUrl}/events/manage/triggers/${getTriggerName(0, testName)}/processed`
);
cy.get(getElementFromAlias(getTriggerName(0, testName)));
// Validate
validateCTrigger(getTriggerName(0, testName), 'success');
};
export const failCTDuplicateTrigger = () => {
// Visit create trigger page
cy.visit('/events/manage/triggers/add');
// trigger and table name
cy.get(getElementFromAlias('trigger-name'))
.clear()
.type(getTriggerName(0, testName));
cy.get(getElementFromAlias('select-table')).select(getTableName(0, testName));
// operations
cy.get(getElementFromAlias('insert-operation')).check();
cy.get(getElementFromAlias('update-operation')).check();
cy.get(getElementFromAlias('delete-operation')).check();
// webhook url
cy.get(getElementFromAlias('webhook'))
.clear()
.type(getWebhookURL());
// click on create
cy.get(getElementFromAlias('trigger-create')).click();
cy.wait(5000);
// should be on the same URL
cy.url().should('eq', `${baseUrl}/events/manage/triggers/add`);
};
export const insertTableRow = () => {
// visit insert row page
cy.visit(`/data/schema/public/tables/${getTableName(0, testName)}/insert`);
// one serial column. so insert a row directly.
cy.get(getElementFromAlias(`typed-input-${1}`)).type('123');
cy.get(getElementFromAlias(`typed-input-${2}`)).type('Some text');
cy.get(getElementFromAlias('insert-save-button')).click();
cy.wait(300);
validateInsert(getTableName(0, testName), 1);
// now it should invoke the trigger to webhook
cy.wait(10000);
// check if processed events has a row and it is a successful response
cy.visit(`/events/manage/triggers/${getTriggerName(0, testName)}/processed`);
cy.get(getElementFromAlias('trigger-processed-events')).contains('1');
};
export const deleteCTTestTrigger = () => {
// Go to the settings section of the trigger
cy.visit(`/events/manage/triggers/${getTriggerName(0, testName)}/processed`);
// click on settings tab
cy.get(getElementFromAlias('trigger-settings')).click();
// Click on delete
cy.get(getElementFromAlias('delete-trigger')).click();
// Confirm
cy.on('window:confirm', str => {
expect(str === 'Are you sure?').to.be.true;
return true;
});
cy.wait(7000);
// Match the URL
cy.url().should('eq', `${baseUrl}/events/manage/triggers`);
// Validate
validateCTrigger(getTriggerName(0, testName), 'success');
};
export const deleteCTTestTable = () => {
// Go to the modify section of the table
cy.visit(`/data/schema/public/tables/${getTableName(0, testName)}/modify`);
// Click on delete
cy.get(getElementFromAlias('delete-table')).click();
// Confirm
cy.on('window:confirm', str => {
expect(str === 'Are you sure?').to.be.true;
return true;
});
cy.wait(7000);
// Match the URL
cy.url().should('eq', `${baseUrl}/data/schema/public`);
// Validate
validateCT(getTableName(0, testName), 'failure');
};
export const setValidationMetaData = () => {
setMetaData();
};

View File

@ -0,0 +1,51 @@
/* eslint no-unused-vars: 0 */
/* eslint import/prefer-default-export: 0 */
import { testMode } from '../../../helpers/common';
import { setMetaData } from '../../validators/validators';
import {
passPTCreateTable,
visitEventsManagePage,
checkCreateTriggerRoute,
failCTWithoutData,
passCT,
failCTDuplicateTrigger,
failAddExistingTrigger,
insertTableRow,
deleteCTTestTrigger,
deleteCTTestTable,
} from './spec';
const setup = () => {
describe('Check Data Tab', () => {
it('Clicking on Data tab opens the correct route', () => {
// Visit the index route
cy.visit('/data/schema/public');
cy.wait(7000);
// Get and set validation metadata
setMetaData();
});
});
};
export const runCreateTriggerTests = () => {
describe('Create Trigger', () => {
it('Create table to use in triggers', passPTCreateTable);
it('Visit events manage page', visitEventsManagePage);
it(
'Create trigger button opens the correct route',
checkCreateTriggerRoute
);
it('Fails to create trigger without data', failCTWithoutData);
it('Successfuly creates trigger', passCT);
it('Fails to create duplicate trigger', failCTDuplicateTrigger);
it('Insert a row and invoke trigger', insertTableRow);
it('Delete off the test trigger', deleteCTTestTrigger);
it('Delete off the test table', deleteCTTestTable);
});
};
if (testMode !== 'cli') {
setup();
runCreateTriggerTests();
}

View File

@ -10,6 +10,8 @@ import { runViewsTest } from './data/views/test';
import { runRawSQLTests } from './data/raw-sql/test';
import { run404Test } from './data/404/test';
import { runCreateTriggerTests } from './events/create-trigger/test';
import { runApiExplorerTests } from './api-explorer/graphql/test';
const setup = () => {
@ -28,6 +30,8 @@ describe('Setup route', setup);
runMigrationModeTests();
runCreateTriggerTests();
runCreateTableTests();
runInsertBrowseTests();

View File

@ -216,3 +216,24 @@ export const validateMigrationMode = mode => {
expect(response.body.migration_mode == mode.toString()).to.be.true; // eslint-disable-line
});
};
// ****************** Trigger Validator *********************
export const validateCTrigger = (triggerName, result) => {
const reqBody = {
type: 'select',
args: {
table: { name: 'event_triggers', schema: 'hdb_catalog' },
columns: ['table_name'],
where: { name: triggerName },
},
};
const requestOptions = makeDataAPIOptions(dataApiUrl, accessKey, reqBody);
cy.request(requestOptions).then(response => {
if (result === 'success') {
expect(response.status === 200).to.be.true;
} else {
expect(response.status === 200).to.be.false;
}
});
};

View File

@ -466,9 +466,13 @@ input {
{
margin-bottom: 20px;
}
.add_mar_bottom_mid
{
margin-bottom: 10px;
}
.add_mar_right
{
margin-right: 20px;
margin-right: 20px !important;
}
.add_mar_right_small
{

View File

@ -163,6 +163,27 @@ class Main extends React.Component {
</Link>
</li>
</OverlayTrigger>
<OverlayTrigger placement="right" overlay={tooltip.events}>
<li>
<Link
className={
currentActiveBlock === 'events'
? styles.navSideBarActive
: ''
}
to={appPrefix + '/events'}
>
<div className={styles.iconCenter}>
<i
title="Events"
className="fa fa-cloud"
aria-hidden="true"
/>
</div>
<p>Events</p>
</Link>
</li>
</OverlayTrigger>
</ul>
</div>
<div className={styles.clusterInfoWrapper}>{accessKeyHtml}</div>

View File

@ -9,6 +9,10 @@ export const apiexplorer = (
<Tooltip id="tooltip-api-explorer">Test the GraphQL APIs</Tooltip>
);
export const events = (
<Tooltip id="tooltip-events">Manage Event Triggers</Tooltip>
);
export const secureEndpoint = (
<Tooltip id="tooltip-secure-endpoint">
This graphql endpoint is public and you should add an access key

View File

@ -257,7 +257,7 @@ class AddTable extends Component {
if ('default' in column) {
defValue = column.default.value;
}
let defPlaceholder = '';
let defPlaceholder = 'default_value';
if (column.type === 'timestamptz') {
defPlaceholder = 'example: now()';
} else if (column.type === 'date') {
@ -281,7 +281,9 @@ class AddTable extends Component {
/>
<select
value={column.type}
className={`${styles.select} form-control ${styles.add_pad_left}`}
className={`${styles.select} ${styles.selectWidth} form-control ${
styles.add_pad_left
}`}
onChange={e => {
dispatch(
setColType(e.target.value, i, this.refs[`nullable${i}`].checked)
@ -324,6 +326,24 @@ class AddTable extends Component {
</span>
) : null}
*/}
<input
placeholder={defPlaceholder}
type="text"
value={defValue}
className={`${styles.inputDefault} ${
styles.defaultWidth
} form-control ${styles.add_pad_left}`}
onChange={e => {
dispatch(
setColDefault(
e.target.value,
i,
this.refs[`nullable${i}`].checked
)
);
}}
data-test={`col-default-${i}`}
/>{' '}
<input
className={`${styles.inputCheckbox} form-control `}
checked={columns[i].nullable}
@ -346,25 +366,6 @@ class AddTable extends Component {
data-test={`unique-${i.toString()}`}
/>{' '}
<label>Unique</label>
<input
placeholder={defPlaceholder}
type="text"
value={defValue}
className={`${styles.inputDefault} form-control ${
styles.add_pad_left
}`}
onChange={e => {
dispatch(
setColDefault(
e.target.value,
i,
this.refs[`nullable${i}`].checked
)
);
}}
data-test={`col-default-${i}`}
/>{' '}
<label>Default</label>
{removeIcon}
</div>
);
@ -407,42 +408,14 @@ class AddTable extends Component {
</div>
);
});
let alert = null;
let createBtnText = 'Create';
if (ongoingRequest) {
alert = (
<div className="hidden col-xs-8">
<div className="alert alert-warning" role="alert">
Creating...
</div>
</div>
);
createBtnText = 'Creating...';
} else if (lastError) {
alert = (
<div className="hidden col-xs-8">
<div className="alert alert-danger" role="alert">
Error: {JSON.stringify(lastError)}
</div>
</div>
);
createBtnText = 'Creating Failed. Try again';
} else if (internalError) {
alert = (
<div className="hidden col-xs-8">
<div className="alert alert-danger" role="alert">
Validation Error: {internalError}
</div>
</div>
);
createBtnText = 'Creating Failed. Try again';
} else if (lastSuccess) {
alert = (
<div className="hidden col-xs-8">
<div className="alert alert-success" role="alert">
Created! Redirecting...
</div>
</div>
);
createBtnText = 'Created! Redirecting...';
}
@ -459,7 +432,6 @@ class AddTable extends Component {
</div>
<br />
<div className={`container-fluid ${styles.padd_left_remove}`}>
{alert}
<div
className={`${styles.addCol} col-xs-12 ${styles.padd_left_remove}`}
>

View File

@ -129,6 +129,13 @@ a.expanded {
width: 300px;
height: 34px;
}
.selectWidth {
width: 200px;
}
.defaultWidth {
width: 200px;
margin-right: 0px;
}
i:hover {
cursor: pointer;
color: #B85C27;

View File

@ -0,0 +1,296 @@
import Endpoints, { globalCookiePolicy } from '../../../../Endpoints';
import dataHeaders from '../Common/Headers';
import requestAction from '../../../../utils/requestAction';
import defaultState from './AddState';
import _push from '../push';
import {
loadTriggers,
makeMigrationCall,
setTrigger,
loadProcessedEvents,
} from '../EventActions';
import { showSuccessNotification } from '../Notification';
import { UPDATE_MIGRATION_STATUS_ERROR } from '../../../Main/Actions';
const SET_DEFAULTS = 'AddTrigger/SET_DEFAULTS';
const SET_TRIGGERNAME = 'AddTrigger/SET_TRIGGERNAME';
const SET_TABLENAME = 'AddTrigger/SET_TABLENAME';
const SET_SCHEMANAME = 'AddTrigger/SET_SCHEMANAME';
const SET_WEBHOOK_URL = 'AddTrigger/SET_WEBHOOK_URL';
const SET_RETRY_NUM = 'AddTrigger/SET_RETRY_NUM';
const SET_RETRY_INTERVAL = 'AddTrigger/SET_RETRY_INTERVAL';
const MAKING_REQUEST = 'AddTrigger/MAKING_REQUEST';
const REQUEST_SUCCESS = 'AddTrigger/REQUEST_SUCCESS';
const REQUEST_ERROR = 'AddTrigger/REQUEST_ERROR';
const VALIDATION_ERROR = 'AddTrigger/VALIDATION_ERROR';
const UPDATE_TABLE_LIST = 'AddTrigger/UPDATE_TABLE_LIST';
const TOGGLE_COLUMNS = 'AddTrigger/TOGGLE_COLUMNS';
const TOGGLE_QUERY_TYPE_SELECTED = 'AddTrigger/TOGGLE_QUERY_TYPE_SELECTED';
const TOGGLE_QUERY_TYPE_DESELECTED = 'AddTrigger/TOGGLE_QUERY_TYPE_DESELECTED';
const setTriggerName = value => ({ type: SET_TRIGGERNAME, value });
const setTableName = value => ({ type: SET_TABLENAME, value });
const setSchemaName = value => ({ type: SET_SCHEMANAME, value });
const setWebhookURL = value => ({ type: SET_WEBHOOK_URL, value });
const setRetryNum = value => ({ type: SET_RETRY_NUM, value });
const setRetryInterval = value => ({ type: SET_RETRY_INTERVAL, value });
const setDefaults = () => ({ type: SET_DEFAULTS });
// General error during validation.
// const validationError = (error) => ({type: VALIDATION_ERROR, error: error});
const validationError = error => {
alert(error);
return { type: VALIDATION_ERROR, error };
};
const createTrigger = () => {
return (dispatch, getState) => {
dispatch({ type: MAKING_REQUEST });
dispatch(showSuccessNotification('Creating Trigger...'));
const currentState = getState().addTrigger;
const currentSchema = currentState.schemaName;
const triggerName = currentState.triggerName;
const tableName = currentState.tableName;
const webhook = currentState.webhookURL;
// apply migrations
const migrationName = 'create_trigger_' + triggerName.trim();
const payload = {
type: 'create_event_trigger',
args: {
name: triggerName,
table: { name: tableName, schema: currentSchema },
webhook: webhook,
},
};
const downPayload = {
type: 'delete_event_trigger',
args: {
name: triggerName,
},
};
// operation definition
if (currentState.selectedOperations.insert) {
payload.args.insert = { columns: currentState.operations.insert };
}
if (currentState.selectedOperations.update) {
payload.args.update = { columns: currentState.operations.update };
}
if (currentState.selectedOperations.delete) {
payload.args.delete = { columns: currentState.operations.delete };
}
// retry logic
if (currentState.retryConf) {
payload.args.retry_conf = currentState.retryConf;
}
const upQueryArgs = [];
upQueryArgs.push(payload);
const downQueryArgs = [];
downQueryArgs.push(downPayload);
const upQuery = {
type: 'bulk',
args: upQueryArgs,
};
const downQuery = {
type: 'bulk',
args: downQueryArgs,
};
const requestMsg = 'Creating trigger...';
const successMsg = 'Trigger Created';
const errorMsg = 'Create trigger failed';
const customOnSuccess = () => {
// dispatch({ type: REQUEST_SUCCESS });
dispatch(setTrigger(triggerName.trim()));
dispatch(loadTriggers()).then(() => {
dispatch(loadProcessedEvents()).then(() => {
dispatch(
_push('/manage/triggers/' + triggerName.trim() + '/processed')
);
});
});
return;
};
const customOnError = err => {
dispatch({ type: REQUEST_ERROR, data: errorMsg });
dispatch({ type: UPDATE_MIGRATION_STATUS_ERROR, data: err });
return;
};
makeMigrationCall(
dispatch,
getState,
upQuery.args,
downQuery.args,
migrationName,
customOnSuccess,
customOnError,
requestMsg,
successMsg,
errorMsg
);
};
};
const fetchTableListBySchema = schemaName => (dispatch, getState) => {
const url = Endpoints.getSchema;
const options = {
credentials: globalCookiePolicy,
method: 'POST',
headers: dataHeaders(getState),
body: JSON.stringify({
type: 'select',
args: {
table: {
name: 'hdb_table',
schema: 'hdb_catalog',
},
columns: ['*.*'],
where: { table_schema: schemaName },
},
}),
};
return dispatch(requestAction(url, options)).then(
data => {
dispatch({ type: UPDATE_TABLE_LIST, data: data });
},
error => {
console.error('Failed to load triggers' + JSON.stringify(error));
}
);
};
const operationToggleColumn = (column, operation) => {
return (dispatch, getState) => {
const currentOperations = getState().addTrigger.operations;
const currentCols = currentOperations[operation];
// check if column is in currentCols. if not, push
const isExists = currentCols.includes(column);
let finalCols = currentCols;
if (isExists) {
finalCols = currentCols.filter(col => col !== column);
} else {
finalCols.push(column);
}
dispatch({ type: TOGGLE_COLUMNS, cols: finalCols, op: operation });
};
};
const operationToggleAllColumns = columns => {
return dispatch => {
dispatch({ type: TOGGLE_COLUMNS, cols: columns, op: 'insert' });
dispatch({ type: TOGGLE_COLUMNS, cols: columns, op: 'update' });
dispatch({ type: TOGGLE_COLUMNS, cols: columns, op: 'delete' });
};
};
const setOperationSelection = (type, isChecked) => {
return dispatch => {
if (isChecked) {
dispatch({ type: TOGGLE_QUERY_TYPE_SELECTED, data: type });
} else {
dispatch({ type: TOGGLE_QUERY_TYPE_DESELECTED, data: type });
}
};
};
const addTriggerReducer = (state = defaultState, action) => {
switch (action.type) {
case SET_DEFAULTS:
return {
...defaultState,
operations: {
...defaultState.operations,
insert: [],
update: [],
delete: [],
},
selectedOperations: {
...defaultState.selectedOperations,
insert: false,
update: false,
delete: false,
},
};
case MAKING_REQUEST:
return {
...state,
ongoingRequest: true,
lastError: null,
lastSuccess: null,
};
case REQUEST_SUCCESS:
return {
...state,
ongoingRequest: false,
lastError: null,
lastSuccess: true,
};
case REQUEST_ERROR:
return {
...state,
ongoingRequest: false,
lastError: action.data,
lastSuccess: null,
};
case VALIDATION_ERROR:
return { ...state, internalError: action.error, lastSuccess: null };
case SET_TRIGGERNAME:
return { ...state, triggerName: action.value };
case SET_WEBHOOK_URL:
return { ...state, webhookURL: action.value };
case SET_RETRY_NUM:
return {
...state,
retryConf: {
...state.retryConf,
num_retries: parseInt(action.value, 10),
},
};
case SET_RETRY_INTERVAL:
return {
...state,
retryConf: {
...state.retryConf,
interval_sec: parseInt(action.value, 10),
},
};
case SET_TABLENAME:
return { ...state, tableName: action.value };
case SET_SCHEMANAME:
return { ...state, schemaName: action.value };
case UPDATE_TABLE_LIST:
return { ...state, tableListBySchema: action.data };
case TOGGLE_COLUMNS:
const operations = state.operations;
operations[action.op] = action.cols;
return { ...state, operations: { ...operations } };
case TOGGLE_QUERY_TYPE_SELECTED:
const selectedOperations = state.selectedOperations;
selectedOperations[action.data] = true;
return { ...state, selectedOperations: { ...selectedOperations } };
case TOGGLE_QUERY_TYPE_DESELECTED:
const deselectedOperations = state.selectedOperations;
deselectedOperations[action.data] = false;
return { ...state, selectedOperations: { ...deselectedOperations } };
default:
return state;
}
};
export default addTriggerReducer;
export {
setTriggerName,
setTableName,
setSchemaName,
setWebhookURL,
setRetryNum,
setRetryInterval,
createTrigger,
fetchTableListBySchema,
operationToggleColumn,
operationToggleAllColumns,
setOperationSelection,
setDefaults,
};
export { validationError };

View File

@ -0,0 +1,16 @@
const defaultState = {
triggerName: '',
tableName: '',
schemaName: 'public',
tableListBySchema: [],
operations: { insert: [], update: [], delete: [] },
selectedOperations: { insert: false, update: false, delete: false },
webhookURL: '',
retryConf: null,
ongoingRequest: false,
lastError: null,
internalError: null,
lastSuccess: null,
};
export default defaultState;

View File

@ -0,0 +1,523 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Helmet from 'react-helmet';
import {
setTriggerName,
setTableName,
setSchemaName,
setWebhookURL,
setRetryNum,
setRetryInterval,
operationToggleColumn,
operationToggleAllColumns,
setOperationSelection,
setDefaults,
} from './AddActions';
import { showErrorNotification } from '../Notification';
import { createTrigger } from './AddActions';
import { fetchTableListBySchema } from './AddActions';
class AddTrigger extends Component {
constructor(props) {
super(props);
this.props.dispatch(fetchTableListBySchema('public'));
this.state = { advancedExpanded: false };
}
componentDidMount() {
// set defaults
this.props.dispatch(setDefaults());
}
componentWillUnmount() {
// set defaults
this.props.dispatch(setDefaults());
}
submitValidation(e) {
// validations
e.preventDefault();
let isValid = true;
let errorMsg = '';
let customMsg = '';
if (this.props.triggerName === '') {
isValid = false;
errorMsg = 'Trigger name cannot be empty';
customMsg = 'Trigger name cannot be empty. Please add a name';
} else if (!this.props.tableName) {
isValid = false;
errorMsg = 'Table cannot be empty';
customMsg = 'Please select a table name';
} else if (this.props.webhookURL === '') {
isValid = false;
errorMsg = 'Webhook URL cannot be empty';
customMsg = 'Webhook URL cannot be empty. Please add a valid URL';
} else if (this.props.retryConf) {
if (isNaN(parseInt(this.props.retryConf.num_retries, 10))) {
isValid = false;
errorMsg = 'Number of retries is not valid';
customMsg = 'Numer of retries cannot be empty and can only be numbers';
}
if (isNaN(parseInt(this.props.retryConf.interval_sec, 10))) {
isValid = false;
errorMsg = 'Retry interval is not valid';
customMsg = 'Retry interval cannot be empty and can only be numbers';
}
} else if (this.props.selectedOperations.insert) {
// check if columns are selected.
if (this.props.operations.insert.length === 0) {
isValid = false;
errorMsg = 'No columns selected for insert operation';
customMsg =
'Please select a minimum of one column for insert operation';
}
} else if (this.props.selectedOperations.update) {
// check if columns are selected.
if (this.props.operations.update.length === 0) {
isValid = false;
errorMsg = 'No columns selected for update operation';
customMsg =
'Please select a minimum of one column for update operation';
}
}
if (isValid) {
this.props.dispatch(createTrigger());
} else {
this.props.dispatch(
showErrorNotification('Error creating trigger!', errorMsg, '', {
custom: customMsg,
})
);
}
}
toggleAdvanced() {
this.setState({ advancedExpanded: !this.state.advancedExpanded });
}
render() {
const {
tableName,
tableListBySchema,
schemaName,
schemaList,
selectedOperations,
operations,
dispatch,
ongoingRequest,
lastError,
lastSuccess,
internalError,
} = this.props;
const styles = require('../TableCommon/Table.scss');
let createBtnText = 'Create';
if (ongoingRequest) {
createBtnText = 'Creating...';
} else if (lastError) {
createBtnText = 'Creating Failed. Try again';
} else if (internalError) {
createBtnText = 'Creating Failed. Try again';
} else if (lastSuccess) {
createBtnText = 'Created! Redirecting...';
}
const updateTableList = e => {
dispatch(setSchemaName(e.target.value));
dispatch(fetchTableListBySchema(e.target.value));
};
const updateTableSelection = e => {
dispatch(setTableName(e.target.value));
const tableSchema = tableListBySchema.find(
t => t.table_name === e.target.value
);
const columns = [];
if (tableSchema) {
tableSchema.columns.map(colObj => {
const column = colObj.column_name;
columns.push(column);
});
}
dispatch(operationToggleAllColumns(columns));
};
const handleOperationSelection = e => {
dispatch(setOperationSelection(e.target.value, e.target.checked));
};
const getColumnList = type => {
const dispatchToggleColumn = e => {
const column = e.target.value;
dispatch(operationToggleColumn(column, type));
};
const tableSchema = tableListBySchema.find(
t => t.table_name === tableName
);
if (tableSchema) {
return tableSchema.columns.map((colObj, i) => {
const column = colObj.column_name;
const checked = operations[type]
? operations[type].includes(column)
: false;
const isDisabled = false;
const inputHtml = (
<input
type="checkbox"
checked={checked}
value={column}
onChange={dispatchToggleColumn}
disabled={isDisabled}
/>
);
return (
<div
key={i}
className={styles.display_inline + ' ' + styles.add_mar_right}
>
<div className="checkbox">
<label>
{inputHtml}
{column}
</label>
</div>
</div>
);
});
}
return null;
};
return (
<div
className={`${styles.addTablesBody} ${styles.main_wrapper} ${
styles.padd_left
}`}
>
<Helmet title="Add Trigger - Events | Hasura" />
<div className={styles.subHeader}>
<h2 className={styles.heading_text}>Add a new trigger</h2>
<div className="clearfix" />
</div>
<br />
<div className={`container-fluid ${styles.padd_left_remove}`}>
<form onSubmit={this.submitValidation.bind(this)}>
<div
className={`${styles.addCol} col-xs-12 ${
styles.padd_left_remove
}`}
>
<h4 className={styles.subheading_text}>
Trigger Name &nbsp; &nbsp;
</h4>
<input
type="text"
data-test="trigger-name"
placeholder="trigger_name"
required
pattern="^\w+$"
className={`${styles.tableNameInput} form-control`}
onChange={e => {
dispatch(setTriggerName(e.target.value));
}}
/>
<hr />
<h4 className={styles.subheading_text}>
Schema/Table &nbsp; &nbsp;
</h4>
<select
onChange={updateTableList}
data-test="select-schema"
className={styles.selectTrigger + ' form-control'}
>
{schemaList.map(s => {
if (s.schema_name === schemaName) {
return (
<option
value={s.schema_name}
key={s.schema_name}
selected="selected"
>
{s.schema_name}
</option>
);
}
return (
<option value={s.schema_name} key={s.schema_name}>
{s.schema_name}
</option>
);
})}
</select>
<select
onChange={updateTableSelection}
data-test="select-table"
required
className={
styles.selectTrigger + ' form-control ' + styles.add_mar_left
}
>
<option value="">Select table</option>
{tableListBySchema.map(t => {
if (t.detail.table_type === 'BASE TABLE') {
return (
<option key={t.table_name} value={t.table_name}>
{t.table_name}
</option>
);
}
})}
</select>
<hr />
<div
className={
styles.add_mar_bottom + ' ' + styles.selectOperations
}
>
<h4 className={styles.subheading_text}>Operations</h4>
<div className={styles.display_inline}>
<label>
<input
onChange={handleOperationSelection}
data-test="insert-operation"
className={
styles.display_inline + ' ' + styles.add_mar_right
}
type="checkbox"
value="insert"
checked={selectedOperations.insert}
/>
Insert
</label>
</div>
<div
className={styles.display_inline + ' ' + styles.add_mar_left}
>
<label>
<input
onChange={handleOperationSelection}
data-test="update-operation"
className={
styles.display_inline + ' ' + styles.add_mar_right
}
type="checkbox"
value="update"
checked={selectedOperations.update}
/>
Update
</label>
</div>
<div
className={styles.display_inline + ' ' + styles.add_mar_left}
>
<label>
<input
onChange={handleOperationSelection}
data-test="delete-operation"
className={
styles.display_inline + ' ' + styles.add_mar_right
}
type="checkbox"
value="delete"
checked={selectedOperations.delete}
/>
Delete
</label>
</div>
</div>
<hr />
<div className={styles.add_mar_bottom}>
<h4 className={styles.subheading_text}>
Webhook URL &nbsp; &nbsp;
</h4>
<input
type="url"
required
data-test="webhook"
placeholder="webhook url"
className={`${styles.tableNameInput} form-control`}
onChange={e => {
dispatch(setWebhookURL(e.target.value));
}}
/>
</div>
<hr />
<button
onClick={this.toggleAdvanced.bind(this)}
data-test="advanced-settings"
type="button"
className={'btn btn-default ' + styles.advancedToggleBtn}
>
Advanced Settings
{this.state.advancedExpanded ? (
<i className={'fa fa-arrow-up'} />
) : (
<i className={'fa fa-arrow-down'} />
)}
</button>
{this.state.advancedExpanded ? (
<div
className={
styles.advancedOperations +
' ' +
styles.add_mar_bottom +
' ' +
styles.add_mar_top
}
>
{tableName ? (
<div>
<h4 className={styles.subheading_text}>
Advanced - Operation/Columns &nbsp; &nbsp;
</h4>
<div>
<div>
<label>
<input
onChange={handleOperationSelection}
className={
styles.display_inline +
' ' +
styles.add_mar_right
}
type="checkbox"
value="insert"
checked={selectedOperations.insert}
/>
Insert
</label>
</div>
{getColumnList('insert')}
</div>
<hr />
<div>
<div>
<label>
<input
onChange={handleOperationSelection}
className={
styles.display_inline +
' ' +
styles.add_mar_right
}
type="checkbox"
value="update"
checked={selectedOperations.update}
/>
Update
</label>
</div>
{getColumnList('update')}
</div>
<hr />
<div>
<div>
<label>
<input
onChange={handleOperationSelection}
className={
styles.display_inline +
' ' +
styles.add_mar_right
}
type="checkbox"
value="delete"
checked={selectedOperations.delete}
/>
Delete
</label>
</div>
{getColumnList('delete')}
</div>
</div>
) : null}
<div
className={styles.add_mar_bottom + ' ' + styles.add_mar_top}
>
<h4 className={styles.subheading_text}>Retry Logic</h4>
<div
className={
styles.display_inline + ' ' + styles.retrySection
}
>
<label
className={
styles.add_mar_right + ' ' + styles.retryLabel
}
>
Number of retries
</label>
<input
onChange={e => {
dispatch(setRetryNum(e.target.value));
}}
data-test="no-of-retries"
className={styles.display_inline + ' form-control'}
type="text"
placeholder="no of retries"
/>
</div>
<div
className={
styles.display_inline + ' ' + styles.retrySection
}
>
<label
className={
styles.add_mar_right + ' ' + styles.retryLabel
}
>
Retry Interval in seconds
</label>
<input
onChange={e => {
dispatch(setRetryInterval(e.target.value));
}}
data-test="interval-seconds"
className={styles.display_inline + ' form-control'}
type="text"
placeholder="interval time in seconds"
/>
</div>
</div>
</div>
) : null}
<hr />
<button
type="submit"
className={`btn ${styles.yellow_button}`}
data-test="trigger-create"
>
{createBtnText}
</button>
</div>
</form>
</div>
</div>
);
}
}
AddTrigger.propTypes = {
triggerName: PropTypes.string,
tableName: PropTypes.string,
schemaName: PropTypes.string,
schemaList: PropTypes.array,
tableListBySchema: PropTypes.array,
selectedOperations: PropTypes.object,
operations: PropTypes.object,
ongoingRequest: PropTypes.bool.isRequired,
lastError: PropTypes.object,
internalError: PropTypes.string,
lastSuccess: PropTypes.bool,
dispatch: PropTypes.func.isRequired,
};
const mapStateToProps = state => {
return {
...state.addTrigger,
schemaList: state.tables.schemaList,
};
};
const addTriggerConnector = connect => connect(mapStateToProps)(AddTrigger);
export default addTriggerConnector;

View File

@ -0,0 +1,82 @@
const dataTypes = [
{
name: 'Integer',
value: 'integer',
description: 'signed four-byte integer',
hasuraDatatype: 'integer',
},
{
name: 'Integer (auto-increment)',
value: 'serial',
description: 'autoincrementing four-byte integer',
hasuraDatatype: null,
},
{
name: 'UUID',
value: 'uuid',
description: 'universal unique identifier',
hasuraDatatype: 'uuid',
},
{
name: 'Big Integer',
value: 'bigint',
description: 'signed eight-byte integer',
hasuraDatatype: 'bigint',
},
{
name: 'Big Integer (auto-increment)',
value: 'bigserial',
description: 'autoincrementing eight-byte integer',
hasuraDatatype: null,
},
{
name: 'Text',
value: 'text',
description: 'variable-length character string',
hasuraDatatype: 'text',
},
{
name: 'Numeric',
value: 'numeric',
description: 'exact numeric of selected precision',
hasuraDatatype: 'numeric',
},
{
name: 'Date',
value: 'date',
description: 'calendar date (year, month, day)',
hasuraDatatype: 'date',
},
{
name: 'Timestamp',
value: 'timestamptz',
description: 'date and time, including time zone',
hasuraDatatype: 'timestamp with time zone',
},
{
name: 'Time',
value: 'timetz',
description: 'time of day (no time zone)',
hasuraDatatype: 'time with time zone',
},
{
name: 'Boolean',
value: 'boolean',
description: 'logical Boolean (true/false)',
hasuraDatatype: 'boolean',
},
{
name: 'JSON',
value: 'json',
description: 'textual JSON data',
hasuraDatatype: 'json',
},
{
name: 'JSONB',
value: 'jsonb',
description: 'binary format JSON data',
hasuraDatatype: 'jsonb',
},
];
export default dataTypes;

View File

@ -0,0 +1,34 @@
const gqlPattern = /^[_A-Za-z][_0-9A-Za-z]*$/;
const gqlTableErrorNotif = [
'Error creating table!',
'Table name cannot contain special characters',
'',
{
custom:
'Table name cannot contain special characters. It can have alphabets, numbers (cannot start with numbers) and _ (can start with _)',
},
];
const gqlColumnErrorNotif = [
'Error adding column!',
'Column name cannot contain special characters',
'',
{
custom:
'Column name cannot contain special characters. It can have alphabets, numbers (cannot start with numbers) and _ (can start with _)',
},
];
const gqlRelErrorNotif = [
'Error adding relationship!',
'Relationship name cannot contain special characters',
'',
{
custom:
'Relationship name cannot contain special characters. It can have alphabets, numbers (cannot start with numbers) and _ (can start with _)',
},
];
export default gqlPattern;
export { gqlTableErrorNotif, gqlColumnErrorNotif, gqlRelErrorNotif };

View File

@ -0,0 +1,4 @@
const dataHeaders = currentState => {
return currentState().tables.dataHeaders;
};
export default dataHeaders;

View File

@ -0,0 +1,18 @@
import Endpoints from '../../../../Endpoints';
import globals from '../../../../Globals';
const returnMigrateUrl = mode => {
if (globals.consoleMode === 'cli') {
return mode ? Endpoints.hasuractlMigrate : Endpoints.hasuractlMetadata;
} else if (globals.consoleMode === 'hasuradb') {
let finalUrl;
if (globals.nodeEnv === 'development') {
finalUrl = globals.devDataApiUrl + '/v1/query';
} else {
finalUrl = Endpoints.query;
}
return finalUrl;
}
};
export default returnMigrateUrl;

View File

@ -0,0 +1,490 @@
import Endpoints, { globalCookiePolicy } from '../../../Endpoints';
import requestAction from '../../../utils/requestAction';
import defaultState from './EventState';
import processedEventsReducer from './ProcessedEvents/ViewActions';
import pendingEventsReducer from './PendingEvents/ViewActions';
import runningEventsReducer from './RunningEvents/ViewActions';
import streamingLogsReducer from './StreamingLogs/LogActions';
import { showErrorNotification, showSuccessNotification } from './Notification';
import dataHeaders from './Common/Headers';
import { loadMigrationStatus } from '../../Main/Actions';
import returnMigrateUrl from './Common/getMigrateUrl';
import globals from '../../../Globals';
import { push } from 'react-router-redux';
const SET_TRIGGER = 'Event/SET_TRIGGER';
const LOAD_TRIGGER_LIST = 'Event/LOAD_TRIGGER_LIST';
const LOAD_PROCESSED_EVENTS = 'Event/LOAD_PROCESSED_EVENTS';
const LOAD_PENDING_EVENTS = 'Event/LOAD_PENDING_EVENTS';
const LOAD_RUNNING_EVENTS = 'Event/LOAD_RUNNING_EVENTS';
const ACCESS_KEY_ERROR = 'Event/ACCESS_KEY_ERROR';
const UPDATE_DATA_HEADERS = 'Event/UPDATE_DATA_HEADERS';
const LISTING_TRIGGER = 'Event/LISTING_TRIGGER';
const LOAD_EVENT_LOGS = 'Event/LOAD_EVENT_LOGS';
const MAKE_REQUEST = 'Event/MAKE_REQUEST';
const REQUEST_SUCCESS = 'Event/REQUEST_SUCCESS';
const REQUEST_ERROR = 'Event/REQUEST_ERROR';
/* ************ action creators *********************** */
const loadTriggers = () => (dispatch, getState) => {
const url = Endpoints.getSchema;
const options = {
credentials: globalCookiePolicy,
method: 'POST',
headers: dataHeaders(getState),
body: JSON.stringify({
type: 'select',
args: {
table: {
name: 'event_triggers',
schema: 'hdb_catalog',
},
columns: ['*'],
},
}),
};
return dispatch(requestAction(url, options)).then(
data => {
dispatch({ type: LOAD_TRIGGER_LIST, triggerList: data });
},
error => {
console.error('Failed to load triggers' + JSON.stringify(error));
}
);
};
const loadProcessedEvents = () => (dispatch, getState) => {
const url = Endpoints.getSchema;
const options = {
credentials: globalCookiePolicy,
method: 'POST',
headers: dataHeaders(getState),
body: JSON.stringify({
type: 'select',
args: {
table: {
name: 'event_triggers',
schema: 'hdb_catalog',
},
columns: [
'*',
{
name: 'events',
columns: [
'*',
{ name: 'logs', columns: ['*'], order_by: ['-created_at'] },
],
where: {
$or: [{ delivered: { $eq: true } }, { error: { $eq: true } }],
},
order_by: ['-created_at'],
limit: 10,
},
],
},
}),
};
return dispatch(requestAction(url, options)).then(
data => {
dispatch({ type: LOAD_PROCESSED_EVENTS, data: data });
},
error => {
console.error('Failed to load triggers' + JSON.stringify(error));
}
);
};
const loadPendingEvents = () => (dispatch, getState) => {
const url = Endpoints.getSchema;
const options = {
credentials: globalCookiePolicy,
method: 'POST',
headers: dataHeaders(getState),
body: JSON.stringify({
type: 'select',
args: {
table: {
name: 'event_triggers',
schema: 'hdb_catalog',
},
columns: [
'*',
{
name: 'events',
columns: [
'*',
{ name: 'logs', columns: ['*'], order_by: ['-created_at'] },
],
where: { delivered: false, error: false, tries: 0 },
order_by: ['-created_at'],
limit: 10,
},
],
},
}),
};
return dispatch(requestAction(url, options)).then(
data => {
dispatch({ type: LOAD_PENDING_EVENTS, data: data });
},
error => {
console.error('Failed to load triggers' + JSON.stringify(error));
}
);
};
const loadRunningEvents = () => (dispatch, getState) => {
const url = Endpoints.getSchema;
const options = {
credentials: globalCookiePolicy,
method: 'POST',
headers: dataHeaders(getState),
body: JSON.stringify({
type: 'select',
args: {
table: {
name: 'event_triggers',
schema: 'hdb_catalog',
},
columns: [
'*',
{
name: 'events',
columns: [
'*',
{ name: 'logs', columns: ['*'], order_by: ['-created_at'] },
],
where: { delivered: false, error: false, tries: { $gt: 0 } },
order_by: ['-created_at'],
limit: 10,
},
],
},
}),
};
return dispatch(requestAction(url, options)).then(
data => {
dispatch({ type: LOAD_RUNNING_EVENTS, data: data });
},
error => {
console.error('Failed to load triggers' + JSON.stringify(error));
}
);
};
const loadEventLogs = triggerName => (dispatch, getState) => {
const url = Endpoints.getSchema;
const options = {
credentials: globalCookiePolicy,
method: 'POST',
headers: dataHeaders(getState),
body: JSON.stringify({
type: 'select',
args: {
table: {
name: 'event_invocation_logs',
schema: 'hdb_catalog',
},
columns: [
'*',
{
name: 'event',
columns: ['*'],
},
],
where: { event: { trigger_name: triggerName } },
order_by: ['-created_at'],
limit: 20,
},
}),
};
return dispatch(requestAction(url, options)).then(
data => {
dispatch({ type: LOAD_EVENT_LOGS, data: data });
},
error => {
console.error('Failed to load triggers' + JSON.stringify(error));
}
);
};
const setTrigger = triggerName => ({ type: SET_TRIGGER, triggerName });
/* **********Shared functions between table actions********* */
const handleMigrationErrors = (title, errorMsg) => dispatch => {
const requestMsg = title;
if (globals.consoleMode === 'hasuradb') {
// handle errors for run_sql based workflow
dispatch(showErrorNotification(title, errorMsg.code, requestMsg, errorMsg));
} else if (errorMsg.code === 'migration_failed') {
dispatch(
showErrorNotification(title, 'Migration Failed', requestMsg, errorMsg)
);
} else if (errorMsg.code === 'data_api_error') {
const parsedErrorMsg = errorMsg;
parsedErrorMsg.message = JSON.parse(errorMsg.message);
dispatch(
showErrorNotification(
title,
parsedErrorMsg.message.error,
requestMsg,
parsedErrorMsg
)
);
} else {
// any other unhandled codes
const parsedErrorMsg = errorMsg;
parsedErrorMsg.message = JSON.parse(errorMsg.message);
dispatch(
showErrorNotification(title, errorMsg.code, requestMsg, parsedErrorMsg)
);
}
// dispatch(showErrorNotification(msg, firstDisplay, request, response));
};
const makeMigrationCall = (
dispatch,
getState,
upQueries,
downQueries,
migrationName,
customOnSuccess,
customOnError,
requestMsg,
successMsg,
errorMsg
) => {
const upQuery = {
type: 'bulk',
args: upQueries,
};
const downQuery = {
type: 'bulk',
args: downQueries,
};
const migrationBody = {
name: migrationName,
up: upQuery.args,
down: downQuery.args,
};
const currMigrationMode = getState().main.migrationMode;
const migrateUrl = returnMigrateUrl(currMigrationMode);
let finalReqBody;
if (globals.consoleMode === 'hasuradb') {
finalReqBody = upQuery;
} else if (globals.consoleMode === 'cli') {
finalReqBody = migrationBody;
}
const url = migrateUrl;
const options = {
method: 'POST',
credentials: globalCookiePolicy,
headers: dataHeaders(getState),
body: JSON.stringify(finalReqBody),
};
const onSuccess = () => {
if (globals.consoleMode === 'cli') {
dispatch(loadMigrationStatus()); // don't call for hasuradb mode
}
dispatch(loadTriggers());
customOnSuccess();
if (successMsg) {
dispatch(showSuccessNotification(successMsg));
}
};
const onError = err => {
customOnError(err);
dispatch(handleMigrationErrors(errorMsg, err));
};
dispatch({ type: MAKE_REQUEST });
dispatch(showSuccessNotification(requestMsg));
dispatch(requestAction(url, options, REQUEST_SUCCESS, REQUEST_ERROR)).then(
onSuccess,
onError
);
};
const deleteTrigger = triggerName => {
return (dispatch, getState) => {
dispatch(showSuccessNotification('Deleting Trigger...'));
const triggerList = getState().triggers.triggerList;
const currentTriggerInfo = triggerList.filter(
t => t.name === triggerName
)[0];
console.log(currentTriggerInfo);
// apply migrations
const migrationName = 'delete_trigger_' + triggerName.trim();
const payload = {
type: 'delete_event_trigger',
args: {
name: triggerName,
},
};
const downPayload = {
type: 'create_event_trigger',
args: {
name: triggerName,
table: {
name: currentTriggerInfo.table_name,
schema: currentTriggerInfo.schema_name,
},
webhook: currentTriggerInfo.webhook,
},
};
const upQueryArgs = [];
upQueryArgs.push(payload);
const downQueryArgs = [];
downQueryArgs.push(downPayload);
const upQuery = {
type: 'bulk',
args: upQueryArgs,
};
const downQuery = {
type: 'bulk',
args: downQueryArgs,
};
const requestMsg = 'Deleting trigger...';
const successMsg = 'Trigger deleted';
const errorMsg = 'Delete trigger failed';
const customOnSuccess = () => {
// dispatch({ type: REQUEST_SUCCESS });
dispatch(loadTriggers()).then(() => dispatch(push('/events/manage')));
return;
};
const customOnError = () => {
dispatch({ type: REQUEST_ERROR, data: errorMsg });
return;
};
makeMigrationCall(
dispatch,
getState,
upQuery.args,
downQuery.args,
migrationName,
customOnSuccess,
customOnError,
requestMsg,
successMsg,
errorMsg
);
};
};
/* ******************************************************* */
const eventReducer = (state = defaultState, action) => {
// eslint-disable-line no-unused-vars
if (action.type.indexOf('ProcessedEvents/') === 0) {
return {
...state,
view: processedEventsReducer(
state.currentTrigger,
state.triggerList,
state.view,
action
),
};
}
if (action.type.indexOf('PendingEvents/') === 0) {
return {
...state,
view: pendingEventsReducer(
state.currentTrigger,
state.triggerList,
state.view,
action
),
};
}
if (action.type.indexOf('RunningEvents/') === 0) {
return {
...state,
view: runningEventsReducer(
state.currentTrigger,
state.triggerList,
state.view,
action
),
};
}
if (action.type.indexOf('StreamingLogs/') === 0) {
return {
...state,
log: streamingLogsReducer(
state.currentTrigger,
state.triggerList,
state.log,
action
),
};
}
switch (action.type) {
case LOAD_TRIGGER_LIST:
return {
...state,
triggerList: action.triggerList,
listingTrigger: action.triggerList,
};
case LISTING_TRIGGER:
return {
...state,
listingTrigger: action.updatedList,
};
case LOAD_PROCESSED_EVENTS:
return {
...state,
processedEvents: action.data,
};
case LOAD_PENDING_EVENTS:
return {
...state,
pendingEvents: action.data,
};
case LOAD_RUNNING_EVENTS:
return {
...state,
runningEvents: action.data,
};
case LOAD_EVENT_LOGS:
return {
...state,
log: { ...state.log, rows: action.data, count: action.data.length },
};
case SET_TRIGGER:
return { ...state, currentTrigger: action.triggerName };
case ACCESS_KEY_ERROR:
return { ...state, accessKeyError: action.data };
case UPDATE_DATA_HEADERS:
return { ...state, dataHeaders: action.data };
default:
return state;
}
};
export default eventReducer;
export {
setTrigger,
loadTriggers,
deleteTrigger,
loadProcessedEvents,
loadPendingEvents,
loadRunningEvents,
loadEventLogs,
handleMigrationErrors,
makeMigrationCall,
ACCESS_KEY_ERROR,
UPDATE_DATA_HEADERS,
LISTING_TRIGGER,
};

View File

@ -0,0 +1,71 @@
import React from 'react';
import { Link } from 'react-router';
import Helmet from 'react-helmet';
import PageContainer from './PageContainer/PageContainer';
const appPrefix = '/events';
const EventHeader = ({
schema,
currentSchema,
children,
location,
dispatch,
}) => {
const styles = require('../Data/TableCommon/Table.scss');
const currentLocation = location.pathname;
return (
<div>
<Helmet title={'Events | Hasura'} />
<div className={styles.wd20 + ' ' + styles.align_left}>
<div
className={styles.pageSidebar + ' col-xs-12 ' + styles.padd_remove}
>
<div>
<ul>
<li
role="presentation"
className={
currentLocation.indexOf('schema') !== -1 ? styles.active : ''
}
>
<div className={styles.schemaWrapper}>
<div
className={styles.schemaSidebarSection}
data-test="schema"
>
<Link
className={styles.schemaBorder}
to={appPrefix + '/manage'}
>
Manage
</Link>
</div>
</div>
<PageContainer
location={location}
schema={schema}
currentSchema={currentSchema}
dispatch={dispatch}
/>
</li>
</ul>
</div>
</div>
</div>
<div className={styles.wd80}>{children}</div>
</div>
);
};
const mapStateToProps = state => {
return {
schema: state.tables.allSchemas,
schemaList: state.tables.schemaList,
currentSchema: state.tables.currentSchema,
};
};
const eventHeaderConnector = connect => connect(mapStateToProps)(EventHeader);
export default eventHeaderConnector;

View File

@ -0,0 +1,9 @@
import eventTriggerReducer from './EventActions';
import addTriggerReducer from './Add/AddActions';
const eventReducer = {
triggers: eventTriggerReducer,
addTrigger: addTriggerReducer,
};
export default eventReducer;

View File

@ -0,0 +1,192 @@
import React from 'react';
// import {push} fropm 'react-router-redux';
import { Route, IndexRedirect } from 'react-router';
import globals from '../../../Globals';
import {
schemaConnector,
schemaContainerConnector,
addTriggerConnector,
processedEventsConnector,
pendingEventsConnector,
runningEventsConnector,
eventHeaderConnector,
settingsConnector,
streamingLogsConnector,
} from '.';
import {
loadTriggers,
loadProcessedEvents,
loadPendingEvents,
loadRunningEvents,
} from '../EventTrigger/EventActions';
const makeEventRouter = (
connect,
store,
composeOnEnterHooks,
requireSchema,
requireProcessedEvents,
requirePendingEvents,
requireRunningEvents,
migrationRedirects
) => {
return (
<Route
path="events"
component={eventHeaderConnector(connect)}
onEnter={composeOnEnterHooks([requireSchema])}
>
<IndexRedirect to="manage" />
<Route path="manage" component={schemaContainerConnector(connect)}>
<IndexRedirect to="triggers" />
<Route path="triggers" component={schemaConnector(connect)} />
<Route
path="triggers/:trigger/processed"
component={processedEventsConnector(connect)}
onEnter={composeOnEnterHooks([requireProcessedEvents])}
/>
<Route
path="triggers/:trigger/pending"
component={pendingEventsConnector(connect)}
onEnter={composeOnEnterHooks([requirePendingEvents])}
/>
<Route
path="triggers/:trigger/running"
component={runningEventsConnector(connect)}
onEnter={composeOnEnterHooks([requireRunningEvents])}
/>
<Route
path="triggers/:trigger/settings"
component={settingsConnector(connect)}
/>
<Route
path="triggers/:trigger/logs"
component={streamingLogsConnector(connect)}
/>
</Route>
<Route
path="manage/triggers/add"
onEnter={composeOnEnterHooks([migrationRedirects])}
component={addTriggerConnector(connect)}
/>
</Route>
);
};
const eventRouter = (connect, store, composeOnEnterHooks) => {
const requireSchema = (nextState, replaceState, cb) => {
// check if access key is available in localstorage. if so use that.
// if localstorage access key didn't work, redirect to login (meaning value has changed)
// if access key is not available in localstorage, check if cli is giving it via window.__env
// if access key is not available in localstorage and cli, make a api call to data without access key.
// if the api fails, then redirect to login - this is a fresh user/browser flow
const {
triggers: { triggerList },
} = store.getState();
if (triggerList.length) {
cb();
return;
}
Promise.all([store.dispatch(loadTriggers())]).then(
() => {
cb();
},
() => {
// alert('Could not load schema.');
replaceState(globals.urlPrefix);
cb();
}
);
};
const requireProcessedEvents = (nextState, replaceState, cb) => {
const {
triggers: { processedEvents },
} = store.getState();
if (processedEvents.length) {
cb();
return;
}
Promise.all([store.dispatch(loadProcessedEvents())]).then(
() => {
cb();
},
() => {
// alert('Could not load schema.');
replaceState(globals.urlPrefix);
cb();
}
);
};
const requirePendingEvents = (nextState, replaceState, cb) => {
const {
triggers: { pendingEvents },
} = store.getState();
if (pendingEvents.length) {
cb();
return;
}
Promise.all([store.dispatch(loadPendingEvents())]).then(
() => {
cb();
},
() => {
// alert('Could not load schema.');
replaceState(globals.urlPrefix);
cb();
}
);
};
const requireRunningEvents = (nextState, replaceState, cb) => {
const {
triggers: { runningEvents },
} = store.getState();
if (runningEvents.length) {
cb();
return;
}
Promise.all([store.dispatch(loadRunningEvents())]).then(
() => {
cb();
},
() => {
// alert('Could not load schema.');
replaceState(globals.urlPrefix);
cb();
}
);
};
const migrationRedirects = (nextState, replaceState, cb) => {
const state = store.getState();
if (!state.main.migrationMode) {
replaceState(globals.urlPrefix + '/events/manage');
cb();
}
cb();
};
const consoleModeRedirects = (nextState, replaceState, cb) => {
if (globals.consoleMode === 'hasuradb') {
replaceState(globals.urlPrefix + '/events/manage');
cb();
}
cb();
};
return {
makeEventRouter: makeEventRouter(
connect,
store,
composeOnEnterHooks,
requireSchema,
requireProcessedEvents,
requirePendingEvents,
requireRunningEvents,
migrationRedirects,
consoleModeRedirects
),
requireSchema,
migrationRedirects,
};
};
export default eventRouter;

View File

@ -0,0 +1,75 @@
const defaultCurFilter = {
where: { $and: [{ '': { '': '' } }] },
limit: 10,
offset: 0,
order_by: [{ column: '', type: 'asc', nulls: 'last' }],
};
const defaultViewState = {
query: {
columns: [
'*',
{
name: 'events',
columns: [
'*',
{ name: 'logs', columns: ['*'], order_by: ['-created_at'] },
],
},
],
limit: 10,
offset: 0,
},
rows: [],
expandedRow: '',
count: 0,
curFilter: defaultCurFilter,
activePath: [],
ongoingRequest: false,
lastError: {},
lastSuccess: {},
};
const defaultLogState = {
query: {
columns: [
'*',
{
name: 'event',
columns: ['*'],
},
],
limit: 20,
offset: 0,
order_by: ['-created_at'],
},
rows: [],
expandedRow: '',
count: 0,
curFilter: defaultCurFilter,
activePath: [],
ongoingRequest: false,
lastError: {},
lastSuccess: {},
};
const defaultState = {
currentTrigger: null,
view: { ...defaultViewState },
log: { ...defaultLogState },
triggerList: [],
listingTrigger: [],
processedEvents: [],
pendingEvents: [],
runningEvents: [],
eventLogs: [],
schemaList: ['public'],
currentSchema: 'public',
accessKeyError: false,
dataHeaders: {
'Content-Type': 'application/json',
},
};
export default defaultState;
export { defaultViewState, defaultLogState, defaultCurFilter };

View File

@ -0,0 +1,71 @@
import React from 'react';
import PropTypes from 'prop-types';
import Helmet from 'react-helmet';
import 'brace/mode/sql';
import OverlayTrigger from 'react-bootstrap/lib/OverlayTrigger';
import Tooltip from 'react-bootstrap/lib/Tooltip';
import Toggle from 'react-toggle';
import { updateMigrationModeStatus } from '../../../Main/Actions';
import './ReactToggle.css';
const migrationTip = (
<Tooltip id="tooltip-migration">
Modifications to the underlying postgres schema should be tracked as
migrations.
</Tooltip>
);
const MigrationsHome = ({ dispatch, migrationMode }) => {
const styles = require('./Styles.scss');
const handleMigrationModeToggle = () => {
const isConfirm = window.confirm('Are you sure?');
if (isConfirm) {
dispatch(updateMigrationModeStatus());
}
};
return (
<div className={'container-fluid'}>
<Helmet title="Migrations - Data | Hasura" />
<div className={styles.add_mar_top}>
<OverlayTrigger placement="right" overlay={migrationTip}>
<i className={'fa fa-info-circle'} aria-hidden="true" />
</OverlayTrigger>
<div className={styles.migration_mode}>
<label>
<span> Allow postgres schema changes </span>
<Toggle
checked={migrationMode}
icons={false}
onChange={handleMigrationModeToggle}
/>
</label>
</div>
</div>
<div className={styles.add_mar_top}>
<b>Note</b>
<ul className={styles.remove_ul_left + ' ' + styles.add_mar_top_small}>
<li>
Recommend that you turn this off if you're working with an existing
app or database.
</li>
</ul>
</div>
<hr />
</div>
);
};
MigrationsHome.propTypes = {
dispatch: PropTypes.func.isRequired,
migrationMode: PropTypes.bool.isRequired,
};
const mapStateToProps = state => ({
...state.rawSQL,
migrationMode: state.main.migrationMode,
});
const migrationsConnector = connect => connect(mapStateToProps)(MigrationsHome);
export default migrationsConnector;

View File

@ -0,0 +1,13 @@
.react-toggle-track {
width: 40px;
height: 20px;
}
.react-toggle--checked .react-toggle-thumb {
left: 21px;
}
.react-toggle-thumb {
width: 18px;
height: 18px;
}

View File

@ -0,0 +1,13 @@
@import "../../../Common/Common.scss";
.migration_mode {
display: inline-block;
margin-left: 10px;
label {
display: flex;
align-items: center;
}
span {
margin-right: 10px;
font-weight: 700;
}
}

View File

@ -0,0 +1,160 @@
import React from 'react';
import AceEditor from 'react-ace';
import { showNotification, showTempNotification } from '../../App/Actions';
import { notifExpand, notifMsg } from '../../App/Actions';
const styles = require('./TableCommon/Table.scss');
const showErrorNotification = (title, message, reqBody, error) => {
let modMessage;
let refreshBtn;
if (
error &&
error.message &&
(error.message.error === 'postgres query error' ||
error.message.error === 'query execution failed')
) {
if (error.message.internal) {
modMessage =
error.message.code + ': ' + error.message.internal.error.message;
} else {
modMessage = error.code + ': ' + error.message.error;
}
} else if (error && 'info' in error) {
modMessage = error.info;
} else if (error && 'message' in error) {
if (error.code) {
if (error.message.error) {
modMessage = error.message.error.message;
} else {
modMessage = error.message;
}
} else if (error && error.message && 'code' in error.message) {
modMessage = error.message.code + ' : ' + message;
} else {
modMessage = error.code;
}
} else if (error && 'internal' in error) {
modMessage = error.code + ' : ' + error.internal.error.message;
} else if (error && 'custom' in error) {
modMessage = error.custom;
} else if (error && 'code' in error && 'error' in error && 'path' in error) {
// Data API error
modMessage = error.error;
} else {
modMessage = error ? error : message;
}
let finalJson = error ? error.message : '{}';
if (error && 'action' in error) {
refreshBtn = (
<button
className={styles.yellow_button + ' ' + styles.add_mar_top_small}
onClick={e => {
e.preventDefault();
window.location.reload();
}}
>
Refresh Console
</button>
);
finalJson = error.action;
} else if (error && 'internal' in error) {
finalJson = error.internal;
}
return dispatch => {
const expandClicked = finalMsg => {
// trigger a modal with a bigger view
dispatch(notifExpand(true));
dispatch(notifMsg(JSON.stringify(finalMsg, null, 4)));
};
dispatch(
showNotification({
level: 'error',
title,
message: modMessage,
action: reqBody
? {
label: 'Details',
callback: () => {
dispatch(
showNotification({
level: 'error',
title,
message: modMessage,
dismissible: 'button',
children: [
<div className={styles.aceBlock}>
<i
onClick={e => {
e.preventDefault();
expandClicked(finalJson);
}}
className={styles.aceBlockExpand + ' fa fa-expand'}
/>
<AceEditor
readOnly
showPrintMargin={false}
mode="json"
showGutter={false}
theme="github"
name="notification-response"
value={JSON.stringify(finalJson, null, 4)}
minLines={1}
maxLines={15}
width="100%"
/>
{refreshBtn}
</div>,
],
})
);
},
}
: null,
})
);
};
};
const showSuccessNotification = (title, message) => {
return dispatch => {
dispatch(
showNotification({
level: 'success',
title,
message: message ? message : null,
})
);
};
};
const showTempErrorNotification = (title, message) => {
return dispatch => {
dispatch(
showTempNotification({
level: 'error',
title,
message: message ? message : null,
autoDismiss: 3,
})
);
};
};
const showInfoNotification = title => {
return dispatch => {
dispatch(
showNotification({
title,
autoDismiss: 0,
})
);
};
};
export {
showErrorNotification,
showSuccessNotification,
showInfoNotification,
showTempErrorNotification,
};

View File

@ -0,0 +1,17 @@
const Operators = [
{ name: 'equals', value: '$eq' },
{ name: 'not equals', value: '$ne' },
{ name: 'in', value: '$in' },
{ name: 'not in', value: '$nin' },
{ name: '>', value: '$gt' },
{ name: '<', value: '$lt' },
{ name: '>=', value: '$gte' },
{ name: '<=', value: '$lte' },
{ name: 'like', value: '$like' },
{ name: 'not like', value: '$nlike' },
{ name: 'ilike', value: '$ilike' },
{ name: 'not ilike', value: '$nilike' },
{ name: 'similar', value: '$similar' },
{ name: 'not similar', value: '$nsimilar' },
];
export default Operators;

View File

@ -0,0 +1,32 @@
/* State
{
ongoingRequest : false, //true if request is going on
lastError : null OR <string>
lastSuccess: null OR <string>
}
*/
import defaultState from './State';
const SET_USERNAME = 'PageContainer/SET_USERNAME';
// HTML Component defines what state it needs
// HTML Component should be able to emit actions
// When an action happens, the state is modified (using the reducer function)
// When the state is modified, anybody dependent on the state is asked to update
// HTML Component is listening to state, hence re-renders
const homeReducer = (state = defaultState, action) => {
switch (action.type) {
case SET_USERNAME:
return { username: action.data };
default:
return state;
}
};
const setUsername = username => ({ type: SET_USERNAME, data: username });
export default homeReducer;
export { setUsername };

View File

@ -0,0 +1,144 @@
/* eslint-disable no-unused-vars */
import React from 'react';
import { connect } from 'react-redux';
import { Link } from 'react-router';
import globals from '../../../../Globals';
import { LISTING_TRIGGER } from '../EventActions';
const appPrefix = '/events';
const PageContainer = ({
currentTrigger,
triggerList,
listingTrigger,
migrationMode,
children,
dispatch,
location,
}) => {
const styles = require('./PageContainer.scss');
// Now schema might be null or an empty array
let triggerLinks = (
<li className={styles.noTables}>
<i>No triggers available</i>
</li>
);
const triggers = {};
listingTrigger.map(t => {
triggers[t.name] = t;
});
const currentLocation = location.pathname;
if (listingTrigger && listingTrigger.length) {
triggerLinks = Object.keys(triggers)
.sort()
.map((trigger, i) => {
let activeTableClass = '';
if (
trigger === currentTrigger &&
currentLocation.indexOf(currentTrigger) !== -1
) {
activeTableClass = styles.activeTable;
}
return (
<li className={activeTableClass} key={i}>
<Link
to={appPrefix + '/manage/triggers/' + trigger + '/processed'}
data-test={trigger}
>
<i
className={styles.tableIcon + ' fa fa-table'}
aria-hidden="true"
/>
{trigger}
</Link>
</li>
);
});
}
function triggerSearch(e) {
const searchTerm = e.target.value;
// form new schema
const matchedTables = [];
triggerList.map(trigger => {
if (trigger.name.indexOf(searchTerm) !== -1) {
matchedTables.push(trigger);
}
});
// update schema with matchedTables
dispatch({ type: LISTING_TRIGGER, updatedList: matchedTables });
}
return (
<div className={styles.schemaTableList}>
<div className={styles.display_flex + ' ' + styles.padd_top_medium}>
<div
className={
styles.sidebarSearch + ' form-group col-xs-12 ' + styles.padd_remove
}
>
<i className="fa fa-search" aria-hidden="true" />
<input
type="text"
onChange={triggerSearch.bind(this)}
className="form-control"
placeholder="search triggers"
data-test="search-triggers"
/>
</div>
</div>
<div>
<div className={styles.sidebarHeadingWrapper}>
<div
className={
'col-xs-8 ' +
styles.sidebarHeading +
' ' +
styles.padd_left_remove
}
>
Triggers ({triggerList.length})
</div>
{migrationMode ? (
<div
className={
'col-xs-4 text-center ' +
styles.padd_remove +
' ' +
styles.sidebarCreateTable
}
>
<Link
className={styles.padd_remove_full}
to={'/events/manage/triggers/add'}
>
<button
className={styles.add_mar_right + ' btn btn-xs btn-default'}
data-test="sidebar-add-table"
>
Add Trigger
</button>
</Link>
</div>
) : null}
</div>
<ul className={styles.schemaListUl} data-test="table-links">
{triggerLinks}
</ul>
</div>
</div>
);
};
const mapStateToProps = state => {
return {
currentTrigger: state.triggers.currentTrigger,
triggerList: state.triggers.triggerList,
listingTrigger: state.triggers.listingTrigger,
migrationMode: state.main.migrationMode,
};
};
export default connect(mapStateToProps)(PageContainer);

View File

@ -0,0 +1,169 @@
@import "~bootstrap-sass/assets/stylesheets/bootstrap/variables";
@import "../../../Common/Common.scss";
.container {
}
.displayFlexContainer
{
display: flex;
}
.flexRow {
display: flex;
margin-bottom: 20px;
}
.add_btn {
margin: 10px 0;
}
.account {
padding: 20px 0;
line-height: 26px;
}
.changeSchema {
margin-left: 10px;
width: auto;
}
.sidebar {
height: calc(100vh - 26px);
overflow: auto;
// background: #444;
// color: $navbar-inverse-color;
color: #333;
border: 1px solid #E5E5E5;
background-color: #F8F8F8;
/*
a,a:visited {
color: $navbar-inverse-link-color;
}
a:hover {
color: $navbar-inverse-link-hover-color;
}
*/
hr {
margin: 0;
border-color: $navbar-inverse-color;
}
ul {
list-style-type: none;
padding-top: 10px;
padding-left: 7px;
li {
padding: 7px 0;
transition: color 0.5s;
/*
a,a:visited {
color: $navbar-inverse-link-color;
}
a:hover {
color: $navbar-inverse-link-hover-color;
}
*/
a
{
color: #767E93;
word-wrap: break-word;
}
}
li:hover {
padding: 7px 0;
// color: $navbar-inverse-link-hover-color;
transition: color 0.5s;
pointer: cursor;
}
}
}
.main {
padding: 0;
height: $mainContainerHeight;
overflow: auto;
}
.sidebarSearch {
margin-right: 20px;
padding: 10px 0px;
padding-bottom: 0px;
position: relative;
i
{
position: absolute;
padding: 10px;
font-size: 14px;
padding-left: 8px;
color: #979797;
}
input
{
padding-left: 25px;
}
}
.sidebarHeadingWrapper
{
width: 100%;
float: left;
padding-bottom: 10px;
.sidebarHeading {
font-weight: bold;
display: inline-block;
color: #767E93;
font-size: 15px;
}
}
.schemaTableList {
// max-height: 300px;
// overflow-y: auto;
overflow: auto;
padding-left: 20px;
max-height: calc(100vh - 275px);
}
.schemaListUl {
padding-left: 5px;
padding-bottom: 10px;
li {
border-bottom: 0px !important;
padding: 0 0 !important;
a {
background: transparent !important;
padding: 5px 0px !important;
font-weight: 400 !important;
padding-left: 5px !important;
.tableIcon {
margin-right: 5px;
font-size: 12px;
}
}
}
.noTables {
font-weight: 400 !important;
padding-bottom: 10px !important;
color: #767E93 !important;
}
li:first-child {
padding-top: 15px !important;
}
}
.heading_tooltip {
display: inline-block;
padding-right: 10px;
}
.addAllBtn {
margin-left: 15px;
}
.activeTable {
a
{
// border-left: 4px solid #FFC627;
color: #FD9540!important;
}
}
.floatRight {
float: right;
margin-right: 20px;
}

View File

@ -0,0 +1,5 @@
const defaultState = {
username: 'Guest User',
};
export default defaultState;

View File

@ -0,0 +1,246 @@
import { defaultCurFilter } from '../EventState';
import { vMakeRequest } from './ViewActions';
const LOADING = 'PendingEvents/FilterQuery/LOADING';
const SET_DEFQUERY = 'PendingEvents/FilterQuery/SET_DEFQUERY';
const SET_FILTERCOL = 'PendingEvents/FilterQuery/SET_FILTERCOL';
const SET_FILTEROP = 'PendingEvents/FilterQuery/SET_FILTEROP';
const SET_FILTERVAL = 'PendingEvents/FilterQuery/SET_FILTERVAL';
const ADD_FILTER = 'PendingEvents/FilterQuery/ADD_FILTER';
const REMOVE_FILTER = 'PendingEvents/FilterQuery/REMOVE_FILTER';
const SET_ORDERCOL = 'PendingEvents/FilterQuery/SET_ORDERCOL';
const SET_ORDERTYPE = 'PendingEvents/FilterQuery/SET_ORDERTYPE';
const ADD_ORDER = 'PendingEvents/FilterQuery/ADD_ORDER';
const REMOVE_ORDER = 'PendingEvents/FilterQuery/REMOVE_ORDER';
const SET_LIMIT = 'PendingEvents/FilterQuery/SET_LIMIT';
const SET_OFFSET = 'PendingEvents/FilterQuery/SET_OFFSET';
const SET_NEXTPAGE = 'PendingEvents/FilterQuery/SET_NEXTPAGE';
const SET_PREVPAGE = 'PendingEvents/FilterQuery/SET_PREVPAGE';
const setLoading = () => ({ type: LOADING, data: true });
const unsetLoading = () => ({ type: LOADING, data: false });
const setDefaultQuery = curQuery => ({ type: SET_DEFQUERY, curQuery });
const setFilterCol = (name, index) => ({ type: SET_FILTERCOL, name, index });
const setFilterOp = (opName, index) => ({ type: SET_FILTEROP, opName, index });
const setFilterVal = (val, index) => ({ type: SET_FILTERVAL, val, index });
const addFilter = () => ({ type: ADD_FILTER });
const removeFilter = index => ({ type: REMOVE_FILTER, index });
const setOrderCol = (name, index) => ({ type: SET_ORDERCOL, name, index });
const setOrderType = (order, index) => ({ type: SET_ORDERTYPE, order, index });
const addOrder = () => ({ type: ADD_ORDER });
const removeOrder = index => ({ type: REMOVE_ORDER, index });
const setLimit = limit => ({ type: SET_LIMIT, limit });
const setOffset = offset => ({ type: SET_OFFSET, offset });
const setNextPage = () => ({ type: SET_NEXTPAGE });
const setPrevPage = () => ({ type: SET_PREVPAGE });
const runQuery = triggerSchema => {
return (dispatch, getState) => {
const state = getState().triggers.view.curFilter;
const finalWhereClauses = state.where.$and.filter(w => {
const colName = Object.keys(w)[0].trim();
if (colName === '') {
return false;
}
const opName = Object.keys(w[colName])[0].trim();
if (opName === '') {
return false;
}
return true;
});
const newQuery = {
where: { $and: finalWhereClauses },
limit: state.limit,
offset: state.offset,
order_by: state.order_by.filter(w => w.column.trim() !== ''),
};
if (newQuery.where.$and.length === 0) {
delete newQuery.where;
}
if (newQuery.order_by.length === 0) {
delete newQuery.order_by;
}
dispatch({ type: 'PendingEvents/V_SET_QUERY_OPTS', queryStuff: newQuery });
dispatch(vMakeRequest());
};
};
const pendingFilterReducer = (state = defaultCurFilter, action) => {
const i = action.index;
const newFilter = {};
switch (action.type) {
case SET_DEFQUERY:
const q = action.curQuery;
if (
'order_by' in q ||
'limit' in q ||
'offset' in q ||
('where' in q && '$and' in q.where)
) {
const newCurFilterQ = {};
newCurFilterQ.where =
'where' in q && '$and' in q.where
? { $and: [...q.where.$and, { '': { '': '' } }] }
: { ...defaultCurFilter.where };
newCurFilterQ.order_by =
'order_by' in q
? [...q.order_by, ...defaultCurFilter.order_by]
: [...defaultCurFilter.order_by];
newCurFilterQ.limit = 'limit' in q ? q.limit : defaultCurFilter.limit;
newCurFilterQ.offset =
'offset' in q ? q.offset : defaultCurFilter.offset;
return newCurFilterQ;
}
return defaultCurFilter;
case SET_FILTERCOL:
const oldColName = Object.keys(state.where.$and[i])[0];
newFilter[action.name] = { ...state.where.$and[i][oldColName] };
return {
...state,
where: {
$and: [
...state.where.$and.slice(0, i),
newFilter,
...state.where.$and.slice(i + 1),
],
},
};
case SET_FILTEROP:
const colName = Object.keys(state.where.$and[i])[0];
const oldOp = Object.keys(state.where.$and[i][colName])[0];
newFilter[colName] = {};
newFilter[colName][action.opName] = state.where.$and[i][colName][oldOp];
return {
...state,
where: {
$and: [
...state.where.$and.slice(0, i),
newFilter,
...state.where.$and.slice(i + 1),
],
},
};
case SET_FILTERVAL:
const colName1 = Object.keys(state.where.$and[i])[0];
const opName = Object.keys(state.where.$and[i][colName1])[0];
newFilter[colName1] = {};
newFilter[colName1][opName] = action.val;
return {
...state,
where: {
$and: [
...state.where.$and.slice(0, i),
newFilter,
...state.where.$and.slice(i + 1),
],
},
};
case ADD_FILTER:
return {
...state,
where: {
$and: [...state.where.$and, { '': { '': '' } }],
},
};
case REMOVE_FILTER:
const newFilters = [
...state.where.$and.slice(0, i),
...state.where.$and.slice(i + 1),
];
return {
...state,
where: { $and: newFilters },
};
case SET_ORDERCOL:
const oldOrder = state.order_by[i];
return {
...state,
order_by: [
...state.order_by.slice(0, i),
{ ...oldOrder, column: action.name },
...state.order_by.slice(i + 1),
],
};
case SET_ORDERTYPE:
const oldOrder1 = state.order_by[i];
return {
...state,
order_by: [
...state.order_by.slice(0, i),
{ ...oldOrder1, type: action.order },
...state.order_by.slice(i + 1),
],
};
case REMOVE_ORDER:
return {
...state,
order_by: [
...state.order_by.slice(0, i),
...state.order_by.slice(i + 1),
],
};
case ADD_ORDER:
return {
...state,
order_by: [
...state.order_by,
{ column: '', type: 'asc', nulls: 'last' },
],
};
case SET_LIMIT:
return {
...state,
limit: action.limit,
};
case SET_OFFSET:
return {
...state,
offset: action.offset,
};
case SET_NEXTPAGE:
return {
...state,
offset: state.offset + state.limit,
};
case SET_PREVPAGE:
const newOffset = state.offset - state.limit;
return {
...state,
offset: newOffset < 0 ? 0 : newOffset,
};
case LOADING:
return {
...state,
loading: action.data,
};
default:
return state;
}
};
export default pendingFilterReducer;
export {
setFilterCol,
setFilterOp,
setFilterVal,
addFilter,
removeFilter,
setOrderCol,
setOrderType,
addOrder,
removeOrder,
setLimit,
setOffset,
setNextPage,
setPrevPage,
setDefaultQuery,
setLoading,
unsetLoading,
runQuery,
};

View File

@ -0,0 +1,265 @@
/*
Use state exactly the way columns in create table do.
dispatch actions using a given function,
but don't listen to state.
derive everything through viewtable as much as possible.
*/
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Operators from '../Operators';
import {
setFilterCol,
setFilterOp,
setFilterVal,
addFilter,
removeFilter,
} from './FilterActions.js';
import {
setOrderCol,
setOrderType,
addOrder,
removeOrder,
} from './FilterActions.js';
import { setDefaultQuery, runQuery } from './FilterActions';
import { vMakeRequest } from './ViewActions';
const renderCols = (colName, triggerSchema, onChange, usage, key) => {
const columns = ['id', 'delivered', 'created_at'];
return (
<select
className="form-control"
onChange={onChange}
value={colName.trim()}
data-test={
usage === 'sort' ? `sort-column-${key}` : `filter-column-${key}`
}
>
{colName.trim() === '' ? (
<option disabled value="">
-- column --
</option>
) : null}
{columns.map((c, i) => (
<option key={i} value={c}>
{c}
</option>
))}
</select>
);
};
const renderOps = (opName, onChange, key) => (
<select
className="form-control"
onChange={onChange}
value={opName.trim()}
data-test={`filter-op-${key}`}
>
{opName.trim() === '' ? (
<option disabled value="">
-- op --
</option>
) : null}
{Operators.map((o, i) => (
<option key={i} value={o.value}>
{o.value}
</option>
))}
</select>
);
const renderWheres = (whereAnd, triggerSchema, dispatch) => {
const styles = require('./FilterQuery.scss');
return whereAnd.map((clause, i) => {
const colName = Object.keys(clause)[0];
const opName = Object.keys(clause[colName])[0];
const dSetFilterCol = e => {
dispatch(setFilterCol(e.target.value, i));
};
const dSetFilterOp = e => {
dispatch(setFilterOp(e.target.value, i));
};
let removeIcon = null;
if (i + 1 < whereAnd.length) {
removeIcon = (
<i
className="fa fa-times"
onClick={() => {
dispatch(removeFilter(i));
}}
data-test={`clear-filter-${i}`}
/>
);
}
return (
<div key={i} className={`${styles.inputRow} row`}>
<div className="col-xs-4">
{renderCols(colName, triggerSchema, dSetFilterCol, 'filter', i)}
</div>
<div className="col-xs-3">{renderOps(opName, dSetFilterOp, i)}</div>
<div className="col-xs-4">
<input
className="form-control"
placeholder="-- value --"
value={clause[colName][opName]}
onChange={e => {
dispatch(setFilterVal(e.target.value, i));
if (i + 1 === whereAnd.length) {
dispatch(addFilter());
}
}}
data-test={`filter-value-${i}`}
/>
</div>
<div className="text-center col-xs-1">{removeIcon}</div>
</div>
);
});
};
const renderSorts = (orderBy, triggerSchema, dispatch) => {
const styles = require('./FilterQuery.scss');
return orderBy.map((c, i) => {
const dSetOrderCol = e => {
dispatch(setOrderCol(e.target.value, i));
if (i + 1 === orderBy.length) {
dispatch(addOrder());
}
};
let removeIcon = null;
if (i + 1 < orderBy.length) {
removeIcon = (
<i
className="fa fa-times"
onClick={() => {
dispatch(removeOrder(i));
}}
data-test={`clear-sorts-${i}`}
/>
);
}
return (
<div key={i} className={`${styles.inputRow} row`}>
<div className="col-xs-6">
{renderCols(c.column, triggerSchema, dSetOrderCol, 'sort', i)}
</div>
<div className="col-xs-5">
<select
value={c.type}
className="form-control"
onChange={e => {
dispatch(setOrderType(e.target.value, i));
}}
data-test={`sort-order-${i}`}
>
<option value="asc">Asc</option>
<option value="desc">Desc</option>
</select>
</div>
<div className="col-xs-1 text-center">{removeIcon}</div>
</div>
);
});
};
class FilterQuery extends Component {
constructor(props) {
super(props);
this.state = { isWatching: false, intervalId: null };
this.refreshData = this.refreshData.bind(this);
}
componentDidMount() {
const dispatch = this.props.dispatch;
dispatch(setDefaultQuery(this.props.curQuery));
}
componentWillUnmount() {
clearInterval(this.state.intervalId);
}
watchChanges() {
// set state on watch
this.setState({ isWatching: !this.state.isWatching });
if (this.state.isWatching) {
clearInterval(this.state.intervalId);
} else {
const intervalId = setInterval(this.refreshData, 2000);
this.setState({ intervalId: intervalId });
}
}
refreshData() {
this.props.dispatch(vMakeRequest());
}
render() {
const { dispatch, whereAnd, triggerSchema, orderBy } = this.props; // eslint-disable-line no-unused-vars
const styles = require('./FilterQuery.scss');
return (
<div className={styles.filterOptions}>
<form
onSubmit={e => {
e.preventDefault();
dispatch(runQuery(triggerSchema));
}}
>
<div className="">
<div
className={`${styles.queryBox} col-xs-6 ${
styles.padd_left_remove
}`}
>
<span className={styles.subheading_text}>Filter</span>
{renderWheres(whereAnd, triggerSchema, dispatch)}
</div>
<div
className={`${styles.queryBox} col-xs-6 ${
styles.padd_left_remove
}`}
>
<b className={styles.subheading_text}>Sort</b>
{renderSorts(orderBy, triggerSchema, dispatch)}
</div>
</div>
<div className={`${styles.padd_right} ${styles.clear_fix}`}>
<button
type="submit"
className={`btn ${styles.yellow_button}`}
data-test="run-query"
>
Run query
</button>
<button
onClick={this.watchChanges.bind(this)}
className={styles.add_mar_left + ' btn btn-default'}
data-test="run-query"
>
{this.state.isWatching ? (
<span>
Watching <i className={'fa fa-spinner fa-spin'} />
</span>
) : (
'Watch'
)}
</button>
</div>
</form>
</div>
);
}
}
FilterQuery.propTypes = {
curQuery: PropTypes.object.isRequired,
triggerSchema: PropTypes.object.isRequired,
whereAnd: PropTypes.array.isRequired,
orderBy: PropTypes.array.isRequired,
limit: PropTypes.number.isRequired,
count: PropTypes.number,
triggerName: PropTypes.string,
offset: PropTypes.number.isRequired,
dispatch: PropTypes.func.isRequired,
};
export default FilterQuery;

View File

@ -0,0 +1,95 @@
@import "../../../Common/Common.scss";
$bgColor: #f9f9f9;
.container {
margin: 10px 0;
}
.count {
display: inline-block;
max-height: 34px;
padding: 5px 20px;
margin-left: 10px;
border-radius: 4px;
}
.queryBox {
box-sizing: border-box;
position: relative;
min-height: 30px;
.inputRow {
margin: 20px 0;
div[class^=col-xs-] {
padding-left: 0;
padding-right: 2.5px;
}
:global(.form-control) {
height: 35px;
padding: 0 12px;
}
:global(.form-control):focus {
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 0px rgba(102, 175, 233, 0.6);
}
:global(.fa) {
padding-top: 8px;
font-size: 0.8em;
}
i:hover {
cursor: pointer;
color: #888;
}
.descending {
padding-left: 10px;
input {
margin-right: 5px;
}
margin-right: 10px;
}
}
}
.inline {
display: inline-block;
}
.runQuery {
margin-left: 0px;
margin-bottom: 20px;
:global(.form-control):focus {
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 0px rgba(102, 175, 233, 0.6);
}
:global(.input-group) {
margin-top: -24px;
margin-right: 10px;
input {
max-width: 60px;
}
}
nav {
display: inline-block;
}
button {
margin-top: -24px;
margin-right: 15px;
}
}
.filterOptions {
margin-top: 10px;
background: $bgColor;
// padding: 20px;
}
.pagination {
margin: 0;
}
.boxHeading {
top: -0.55em;
line-height: 1.1em;
background: $bgColor;
display: inline-block;
position: absolute;
padding: 0 10px;
color: #929292;
font-weight: normal;
}

View File

@ -0,0 +1,473 @@
import { defaultViewState } from '../EventState';
import Endpoints, { globalCookiePolicy } from '../../../../Endpoints';
import requestAction from 'utils/requestAction';
import pendingFilterReducer from './FilterActions';
import { findTableFromRel } from '../utils';
import dataHeaders from '../Common/Headers';
/* ****************** View actions *************/
const V_SET_DEFAULTS = 'PendingEvents/V_SET_DEFAULTS';
const V_REQUEST_SUCCESS = 'PendingEvents/V_REQUEST_SUCCESS';
const V_REQUEST_ERROR = 'PendingEvents/V_REQUEST_ERROR';
const V_EXPAND_REL = 'PendingEvents/V_EXPAND_REL';
const V_CLOSE_REL = 'PendingEvents/V_CLOSE_REL';
const V_SET_ACTIVE = 'PendingEvents/V_SET_ACTIVE';
const V_SET_QUERY_OPTS = 'PendingEvents/V_SET_QUERY_OPTS';
const V_REQUEST_PROGRESS = 'PendingEvents/V_REQUEST_PROGRESS';
const V_EXPAND_ROW = 'PendingEvents/V_EXPAND_ROW';
const V_COLLAPSE_ROW = 'PendingEvents/V_COLLAPSE_ROW';
/* ****************** action creators *************/
const vExpandRow = rowKey => ({
type: V_EXPAND_ROW,
data: rowKey,
});
const vCollapseRow = () => ({
type: V_COLLAPSE_ROW,
});
const vSetDefaults = () => ({ type: V_SET_DEFAULTS });
const vMakeRequest = () => {
return (dispatch, getState) => {
const state = getState();
const url = Endpoints.query;
const originalTrigger = getState().triggers.currentTrigger;
dispatch({ type: V_REQUEST_PROGRESS, data: true });
const currentQuery = JSON.parse(JSON.stringify(state.triggers.view.query));
// count query
const countQuery = JSON.parse(JSON.stringify(state.triggers.view.query));
countQuery.columns = ['id'];
// delivered = false and error = false
// where clause for relationship
const currentWhereClause = state.triggers.view.query.where;
if (currentWhereClause && currentWhereClause.$and) {
// make filter for events
const finalAndClause = currentQuery.where.$and;
finalAndClause.push({ delivered: false });
finalAndClause.push({ error: false });
finalAndClause.push({ tries: 0 });
currentQuery.columns[1].where = { $and: finalAndClause };
currentQuery.where = { name: state.triggers.currentTrigger };
countQuery.where.$and.push({
trigger_name: state.triggers.currentTrigger,
});
} else {
// reset where for events
if (currentQuery.columns[1]) {
currentQuery.columns[1].where = {
delivered: false,
error: false,
tries: 0,
};
}
currentQuery.where = { name: state.triggers.currentTrigger };
countQuery.where = {
trigger_name: state.triggers.currentTrigger,
delivered: false,
error: false,
tries: 0,
};
}
// order_by for relationship
const currentOrderBy = state.triggers.view.query.order_by;
if (currentOrderBy) {
currentQuery.columns[1].order_by = currentOrderBy;
// reset order_by
delete currentQuery.order_by;
} else {
// reset order by for events
if (currentQuery.columns[1]) {
delete currentQuery.columns[1].order_by;
currentQuery.columns[1].order_by = ['-created_at'];
}
delete currentQuery.order_by;
}
// limit and offset for relationship
const currentLimit = state.triggers.view.query.limit;
const currentOffset = state.triggers.view.query.offset;
currentQuery.columns[1].limit = currentLimit;
currentQuery.columns[1].offset = currentOffset;
// reset limit and offset for parent
delete currentQuery.limit;
delete currentQuery.offset;
delete countQuery.limit;
delete countQuery.offset;
const requestBody = {
type: 'bulk',
args: [
{
type: 'select',
args: {
...currentQuery,
table: {
name: 'event_triggers',
schema: 'hdb_catalog',
},
},
},
{
type: 'count',
args: {
...countQuery,
table: {
name: 'event_log',
schema: 'hdb_catalog',
},
},
},
],
};
const options = {
method: 'POST',
body: JSON.stringify(requestBody),
headers: dataHeaders(getState),
credentials: globalCookiePolicy,
};
return dispatch(requestAction(url, options)).then(
data => {
const currentTrigger = getState().triggers.currentTrigger;
if (originalTrigger === currentTrigger) {
Promise.all([
dispatch({
type: V_REQUEST_SUCCESS,
data: data[0],
count: data[1].count,
}),
dispatch({ type: V_REQUEST_PROGRESS, data: false }),
]);
}
},
error => {
dispatch({ type: V_REQUEST_ERROR, data: error });
}
);
};
};
const vExpandRel = (path, relname, pk) => {
return dispatch => {
// Modify the query (UI will automatically change)
dispatch({ type: V_EXPAND_REL, path, relname, pk });
// Make a request
return dispatch(vMakeRequest());
};
};
const vCloseRel = (path, relname) => {
return dispatch => {
// Modify the query (UI will automatically change)
dispatch({ type: V_CLOSE_REL, path, relname });
// Make a request
return dispatch(vMakeRequest());
};
};
/* ************ helpers ************************/
const defaultSubQuery = (relname, tableSchema) => {
return {
name: relname,
columns: tableSchema.columns.map(c => c.column_name),
};
};
const expandQuery = (
curQuery,
curTable,
pk,
curPath,
relname,
schemas,
isObjRel = false
) => {
if (curPath.length === 0) {
const rel = curTable.relationships.find(r => r.rel_name === relname);
const childTableSchema = findTableFromRel(schemas, curTable, rel);
const newColumns = [
...curQuery.columns,
defaultSubQuery(relname, childTableSchema),
];
if (isObjRel) {
return { ...curQuery, columns: newColumns };
}
// If there's already oldStuff then don't reset it
if ('oldStuff' in curQuery) {
return { ...curQuery, where: pk, columns: newColumns };
}
// If there's no oldStuff then set it
const oldStuff = {};
['where', 'limit', 'offset'].map(k => {
if (k in curQuery) {
oldStuff[k] = curQuery[k];
}
});
return { name: curQuery.name, where: pk, columns: newColumns, oldStuff };
}
const curRelName = curPath[0];
const curRel = curTable.relationships.find(r => r.rel_name === curRelName);
const childTableSchema = findTableFromRel(schemas, curTable, curRel);
const curRelColIndex = curQuery.columns.findIndex(c => c.name === curRelName);
return {
...curQuery,
columns: [
...curQuery.columns.slice(0, curRelColIndex),
expandQuery(
curQuery.columns[curRelColIndex],
childTableSchema,
pk,
curPath.slice(1),
relname,
schemas,
curRel.rel_type === 'object'
),
...curQuery.columns.slice(curRelColIndex + 1),
],
};
};
const closeQuery = (curQuery, curTable, curPath, relname, schemas) => {
// eslint-disable-line no-unused-vars
if (curPath.length === 0) {
const expandedIndex = curQuery.columns.findIndex(c => c.name === relname);
const newColumns = [
...curQuery.columns.slice(0, expandedIndex),
...curQuery.columns.slice(expandedIndex + 1),
];
const newStuff = {};
newStuff.columns = newColumns;
if ('name' in curQuery) {
newStuff.name = curQuery.name;
}
// If no other expanded columns are left
if (!newColumns.find(c => typeof c === 'object')) {
if (curQuery.oldStuff) {
['where', 'limit', 'order_by', 'offset'].map(k => {
if (k in curQuery.oldStuff) {
newStuff[k] = curQuery.oldStuff[k];
}
});
}
return { ...newStuff };
}
return { ...curQuery, ...newStuff };
}
const curRelName = curPath[0];
const curRel = curTable.relationships.find(r => r.rel_name === curRelName);
const childTableSchema = findTableFromRel(schemas, curTable, curRel);
const curRelColIndex = curQuery.columns.findIndex(c => c.name === curRelName);
return {
...curQuery,
columns: [
...curQuery.columns.slice(0, curRelColIndex),
closeQuery(
curQuery.columns[curRelColIndex],
childTableSchema,
curPath.slice(1),
relname,
schemas
),
...curQuery.columns.slice(curRelColIndex + 1),
],
};
};
const setActivePath = (activePath, curPath, relname, query) => {
const basePath = relname
? [activePath[0], ...curPath, relname]
: [activePath[0], ...curPath];
// Now check if there are any more children on this path.
// If there are, then we should expand them by default
let subQuery = query;
let subBase = basePath.slice(1);
while (subBase.length > 0) {
subQuery = subQuery.columns.find(c => c.name === subBase[0]); // eslint-disable-line no-loop-func
subBase = subBase.slice(1);
}
subQuery = subQuery.columns.find(c => typeof c === 'object');
while (subQuery) {
basePath.push(subQuery.name);
subQuery = subQuery.columns.find(c => typeof c === 'object');
}
return basePath;
};
const updateActivePathOnClose = (
activePath,
tableName,
curPath,
relname,
query
) => {
const basePath = [tableName, ...curPath, relname];
let subBase = [...basePath];
let subActive = [...activePath];
let matchingFound = false;
let commonIndex = 0;
subBase = subBase.slice(1);
subActive = subActive.slice(1);
while (subActive.length > 0) {
if (subBase[0] === subActive[0]) {
matchingFound = true;
break;
}
subBase = subBase.slice(1);
subActive = subActive.slice(1);
commonIndex += 1;
}
if (matchingFound) {
const newActivePath = activePath.slice(0, commonIndex + 1);
return setActivePath(
newActivePath,
newActivePath.slice(1, -1),
null,
query
);
}
return [...activePath];
};
const addQueryOptsActivePath = (query, queryStuff, activePath) => {
let curPath = activePath.slice(1);
const newQuery = { ...query };
let curQuery = newQuery;
while (curPath.length > 0) {
curQuery = curQuery.columns.find(c => c.name === curPath[0]); // eslint-disable-line no-loop-func
curPath = curPath.slice(1);
}
['where', 'order_by', 'limit', 'offset'].map(k => {
delete curQuery[k];
});
for (const k in queryStuff) {
if (queryStuff.hasOwnProperty(k)) {
curQuery[k] = queryStuff[k];
}
}
return newQuery;
};
/* ****************** reducer ******************/
const pendingEventsReducer = (triggerName, triggerList, viewState, action) => {
if (action.type.indexOf('PendingEvents/FilterQuery/') === 0) {
return {
...viewState,
curFilter: pendingFilterReducer(viewState.curFilter, action),
};
}
const tableSchema = triggerList.find(x => x.name === triggerName);
switch (action.type) {
case V_SET_DEFAULTS:
return {
...defaultViewState,
query: {
columns: [
'*',
{
name: 'events',
columns: [
'*',
{ name: 'logs', columns: ['*'], order_by: ['-created_at'] },
],
where: { delivered: false, error: false },
},
],
limit: 10,
},
activePath: [triggerName],
rows: [],
count: null,
};
case V_SET_QUERY_OPTS:
return {
...viewState,
query: addQueryOptsActivePath(
viewState.query,
action.queryStuff,
viewState.activePath
),
};
case V_EXPAND_REL:
return {
...viewState,
query: expandQuery(
viewState.query,
tableSchema,
action.pk,
action.path,
action.relname,
triggerList
),
activePath: [...viewState.activePath, action.relname],
};
case V_CLOSE_REL:
const _query = closeQuery(
viewState.query,
tableSchema,
action.path,
action.relname,
triggerList
);
return {
...viewState,
query: _query,
activePath: updateActivePathOnClose(
viewState.activePath,
triggerName,
action.path,
action.relname,
_query
),
};
case V_SET_ACTIVE:
return {
...viewState,
activePath: setActivePath(
viewState.activePath,
action.path,
action.relname,
viewState.query
),
};
case V_REQUEST_SUCCESS:
return { ...viewState, rows: action.data, count: action.count };
case V_REQUEST_PROGRESS:
return { ...viewState, isProgressing: action.data };
case V_EXPAND_ROW:
return {
...viewState,
expandedRow: action.data,
};
case V_COLLAPSE_ROW:
return {
...viewState,
expandedRow: '',
};
default:
return viewState;
}
};
export default pendingEventsReducer;
export {
vSetDefaults,
vMakeRequest,
vExpandRel,
vCloseRel,
vExpandRow,
vCollapseRow,
V_SET_ACTIVE,
};

View File

@ -0,0 +1,403 @@
import React from 'react';
import ReactTable from 'react-table';
import AceEditor from 'react-ace';
import 'brace/mode/json';
import Tabs from 'react-bootstrap/lib/Tabs';
import Tab from 'react-bootstrap/lib/Tab';
import 'react-table/react-table.css';
import { deleteItem, vExpandRow, vCollapseRow } from './ViewActions'; // eslint-disable-line no-unused-vars
import FilterQuery from './FilterQuery';
import {
setOrderCol,
setOrderType,
removeOrder,
runQuery,
setOffset,
setLimit,
addOrder,
} from './FilterActions';
import { ordinalColSort } from '../utils';
import Spinner from '../../../Common/Spinner/Spinner';
import '../TableCommon/ReactTableFix.css';
const ViewRows = ({
curTriggerName,
curQuery,
curFilter,
curRows,
curPath,
curDepth,
activePath,
triggerList,
dispatch,
isProgressing,
isView,
count,
expandedRow,
}) => {
const styles = require('../TableCommon/Table.scss');
const triggerSchema = triggerList.find(x => x.name === curTriggerName);
const curRelName = curPath.length > 0 ? curPath.slice(-1)[0] : null;
// Am I a single row display
const isSingleRow = false;
// Get the headings
const tableHeadings = [];
const gridHeadings = [];
const eventLogColumns = ['id', 'delivered', 'created_at'];
const sortedColumns = eventLogColumns.sort(ordinalColSort);
sortedColumns.map((column, i) => {
tableHeadings.push(<th key={i}>{column}</th>);
gridHeadings.push({
Header: column,
accessor: column,
});
});
const hasPrimaryKeys = true;
/*
let editButton;
let deleteButton;
*/
const newCurRows = [];
if (curRows && curRows[0] && curRows[0].events) {
curRows[0].events.forEach((row, rowIndex) => {
const newRow = {};
const pkClause = {};
if (!isView && hasPrimaryKeys) {
pkClause.id = row.id;
} else {
triggerSchema.map(k => {
pkClause[k] = row[k];
});
}
/*
if (!isSingleRow && !isView && hasPrimaryKeys) {
deleteButton = (
<button
className={`${styles.add_mar_right_small} btn btn-xs btn-default`}
onClick={() => {
dispatch(deleteItem(pkClause));
}}
data-test={`row-delete-button-${rowIndex}`}
>
Delete
</button>
);
}
const buttonsDiv = (
<div className={styles.tableCellCenterAligned}>
{editButton}
{deleteButton}
</div>
);
*/
// Insert Edit, Delete, Clone in a cell
// newRow.actions = buttonsDiv;
// Insert cells corresponding to all rows
sortedColumns.forEach(col => {
const getCellContent = () => {
let conditionalClassname = styles.tableCellCenterAligned;
const cellIndex = `${curTriggerName}-${col}-${rowIndex}`;
if (expandedRow === cellIndex) {
conditionalClassname = styles.tableCellCenterAlignedExpanded;
}
if (row[col] === null) {
return (
<div className={conditionalClassname}>
<i>NULL</i>
</div>
);
}
let content = row[col] === undefined ? 'NULL' : row[col].toString();
if (col === 'created_at') {
content = new Date(row[col]).toUTCString();
}
return <div className={conditionalClassname}>{content}</div>;
};
newRow[col] = getCellContent();
});
newCurRows.push(newRow);
});
}
// Is this ViewRows visible
let isVisible = false;
if (!curRelName) {
isVisible = true;
} else if (curRelName === activePath[curDepth]) {
isVisible = true;
}
let filterQuery = null;
if (!isSingleRow) {
if (curRelName === activePath[curDepth] || curDepth === 0) {
// Rendering only if this is the activePath or this is the root
let wheres = [{ '': { '': '' } }];
if ('where' in curFilter && '$and' in curFilter.where) {
wheres = [...curFilter.where.$and];
}
let orderBy = [{ column: '', type: 'asc', nulls: 'last' }];
if ('order_by' in curFilter) {
orderBy = [...curFilter.order_by];
}
const limit = 'limit' in curFilter ? curFilter.limit : 10;
const offset = 'offset' in curFilter ? curFilter.offset : 0;
filterQuery = (
<FilterQuery
curQuery={curQuery}
whereAnd={wheres}
triggerSchema={triggerSchema}
orderBy={orderBy}
limit={limit}
dispatch={dispatch}
count={count}
triggerName={curTriggerName}
offset={offset}
/>
);
}
}
const sortByColumn = col => {
// Remove all the existing order_bys
const numOfOrderBys = curFilter.order_by.length;
for (let i = 0; i < numOfOrderBys - 1; i++) {
dispatch(removeOrder(1));
}
// Go back to the first page
dispatch(setOffset(0));
// Set the filter and run query
dispatch(setOrderCol(col, 0));
if (
curFilter.order_by.length !== 0 &&
curFilter.order_by[0].column === col &&
curFilter.order_by[0].type === 'asc'
) {
dispatch(setOrderType('desc', 0));
} else {
dispatch(setOrderType('asc', 0));
}
dispatch(runQuery(triggerSchema));
// Add a new empty filter
dispatch(addOrder());
};
const changePage = page => {
if (curFilter.offset !== page * curFilter.limit) {
dispatch(setOffset(page * curFilter.limit));
dispatch(runQuery(triggerSchema));
}
};
const changePageSize = size => {
if (curFilter.size !== size) {
dispatch(setLimit(size));
dispatch(runQuery(triggerSchema));
}
};
const renderTableBody = () => {
if (isProgressing) {
return (
<div>
{' '}
<Spinner />{' '}
</div>
);
} else if (count === 0) {
return <div> No rows found. </div>;
}
let shouldSortColumn = true;
const invocationColumns = ['status', 'id', 'created_at'];
const invocationGridHeadings = [];
invocationColumns.map(column => {
invocationGridHeadings.push({
Header: column,
accessor: column,
});
});
return (
<ReactTable
className="-highlight"
data={newCurRows}
columns={gridHeadings}
resizable
manual
sortable={false}
minRows={0}
getTheadThProps={(finalState, some, column) => ({
onClick: () => {
if (
column.Header &&
shouldSortColumn &&
column.Header !== 'Actions'
) {
sortByColumn(column.Header);
}
shouldSortColumn = true;
},
})}
getResizerProps={(finalState, none, column, ctx) => ({
onMouseDown: e => {
shouldSortColumn = false;
ctx.resizeColumnStart(e, column, false);
},
})}
showPagination={count > curFilter.limit}
defaultPageSize={Math.min(curFilter.limit, count)}
pages={Math.ceil(count / curFilter.limit)}
onPageChange={changePage}
onPageSizeChange={changePageSize}
page={Math.floor(curFilter.offset / curFilter.limit)}
SubComponent={row => {
const currentIndex = row.index;
const currentRow = curRows[0].events[currentIndex];
const invocationRowsData = [];
currentRow.logs.map((r, rowIndex) => {
const newRow = {};
const status =
r.status === 200 ? (
<i className={styles.invocationSuccess + ' fa fa-check'} />
) : (
<i className={styles.invocationFailure + ' fa fa-times'} />
);
// Insert cells corresponding to all rows
invocationColumns.forEach(col => {
const getCellContent = () => {
let conditionalClassname = styles.tableCellCenterAligned;
const cellIndex = `${curTriggerName}-${col}-${rowIndex}`;
if (expandedRow === cellIndex) {
conditionalClassname = styles.tableCellCenterAlignedExpanded;
}
if (r[col] === null) {
return (
<div className={conditionalClassname}>
<i>NULL</i>
</div>
);
}
if (col === 'status') {
return status;
}
if (col === 'created_at') {
const formattedDate = new Date(r.created_at).toUTCString();
return formattedDate;
}
const content =
r[col] === undefined ? 'NULL' : r[col].toString();
return <div className={conditionalClassname}>{content}</div>;
};
newRow[col] = getCellContent();
});
invocationRowsData.push(newRow);
});
return (
<div style={{ padding: '20px' }}>
<em>Recent Invocations</em>
<div
className={styles.invocationsSection + ' invocationsSection'}
>
{invocationRowsData.length ? (
<ReactTable
data={invocationRowsData}
columns={invocationGridHeadings}
defaultPageSize={currentRow.logs.length}
showPagination={false}
SubComponent={logRow => {
const finalIndex = logRow.index;
const currentPayload = JSON.stringify(
currentRow.payload,
null,
4
);
const finalRow = currentRow.logs[finalIndex];
const finalResponse = JSON.parse(
JSON.stringify(finalRow.response, null, 4)
);
return (
<div style={{ padding: '20px' }}>
<Tabs
animation={false}
defaultActiveKey={1}
id="requestResponseTab"
>
<Tab eventKey={1} title="Request">
<div className={styles.add_mar_top}>
<div className={styles.subheading_text}>
Request
</div>
<AceEditor
mode="json"
theme="github"
name="payload"
value={currentPayload}
minLines={4}
maxLines={100}
width="100%"
showPrintMargin={false}
showGutter={false}
/>
</div>
</Tab>
<Tab eventKey={2} title="Response">
<div className={styles.add_mar_top}>
<div className={styles.subheading_text}>
Response
</div>
<AceEditor
mode="json"
theme="github"
name="response"
value={finalResponse}
minLines={4}
maxLines={100}
width="100%"
showPrintMargin={false}
showGutter={false}
/>
</div>
</Tab>
</Tabs>
</div>
);
}}
/>
) : (
<div className={styles.add_mar_top}>No data available</div>
)}
</div>
<br />
<br />
</div>
);
}}
/>
);
};
return (
<div className={isVisible ? '' : 'hide '}>
{filterQuery}
<hr />
<div className="row">
<div className="col-xs-12">
<div className={styles.tableContainer + ' eventsTableBody'}>
{renderTableBody()}
</div>
<br />
<br />
</div>
</div>
</div>
);
};
export default ViewRows;

View File

@ -0,0 +1,214 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { vSetDefaults, vMakeRequest, vExpandHeading } from './ViewActions'; // eslint-disable-line no-unused-vars
import { setTrigger } from '../EventActions';
import TableHeader from '../TableCommon/TableHeader';
import ViewRows from './ViewRows';
import { replace } from 'react-router-redux';
const genHeadings = headings => {
if (headings.length === 0) {
return [];
}
const heading = headings[0];
if (typeof heading === 'string') {
return [heading, ...genHeadings(headings.slice(1))];
}
if (typeof heading === 'object') {
if (!heading._expanded) {
const headingName =
heading.type === 'obj_rel' ? heading.lcol : heading.relname;
return [
{ name: headingName, type: heading.type },
...genHeadings(headings.slice(1)),
];
}
if (heading.type === 'obj_rel') {
const subheadings = genHeadings(heading.headings).map(h => {
if (typeof h === 'string') {
return heading.relname + '.' + h;
}
return heading.relname + '.' + h.name;
});
return [...subheadings, ...genHeadings(headings.slice(1))];
}
}
throw 'Incomplete pattern match'; // eslint-disable-line no-throw-literal
};
const genRow = (row, headings) => {
if (headings.length === 0) {
return [];
}
const heading = headings[0];
if (typeof heading === 'string') {
return [row[heading], ...genRow(row, headings.slice(1))];
}
if (typeof heading === 'object') {
if (!heading._expanded) {
const rowVal = heading.type === 'obj_rel' ? row[heading.lcol] : '[...]';
return [rowVal, ...genRow(row, headings.slice(1))];
}
if (heading.type === 'obj_rel') {
const subrow = genRow(row[heading.relname], heading.headings);
return [...subrow, ...genRow(row, headings.slice(1))];
}
}
throw 'Incomplete pattern match'; // eslint-disable-line no-throw-literal
};
class ViewTable extends Component {
constructor(props) {
super(props);
// Initialize this table
this.state = {
dispatch: props.dispatch,
triggerName: props.triggerName,
};
// this.state.dispatch = props.dispatch;
// this.state.triggerName = props.triggerName;
const dispatch = this.props.dispatch;
Promise.all([
dispatch(setTrigger(this.props.triggerName)),
dispatch(vSetDefaults(this.props.triggerName)),
dispatch(vMakeRequest()),
]);
}
componentWillReceiveProps(nextProps) {
const dispatch = this.props.dispatch;
if (nextProps.triggerName !== this.props.triggerName) {
dispatch(setTrigger(nextProps.triggerName));
dispatch(vSetDefaults(nextProps.triggerName));
dispatch(vMakeRequest());
}
}
shouldComponentUpdate(nextProps) {
return (
this.props.triggerName === null ||
nextProps.triggerName === this.props.triggerName
);
}
componentWillUpdate() {
this.shouldScrollBottom =
window.innerHeight ===
document.body.offsetHeight - document.body.scrollTop;
}
componentDidUpdate() {
if (this.shouldScrollBottom) {
document.body.scrollTop = document.body.offsetHeight - window.innerHeight;
}
}
componentWillUnmount() {
// Remove state data beloging to this table
const dispatch = this.props.dispatch;
dispatch(vSetDefaults(this.props.triggerName));
}
render() {
const {
triggerName,
triggerList,
query,
curFilter,
rows,
count, // eslint-disable-line no-unused-vars
activePath,
migrationMode,
ongoingRequest,
isProgressing,
lastError,
lastSuccess,
dispatch,
expandedRow,
} = this.props; // eslint-disable-line no-unused-vars
// check if table exists
const currentTrigger = triggerList.find(s => s.name === triggerName);
if (!currentTrigger) {
// dispatch a 404 route
dispatch(replace('/404'));
}
// Is this a view
const isView = false;
// Are there any expanded columns
const viewRows = (
<ViewRows
curTriggerName={triggerName}
curQuery={query}
curFilter={curFilter}
curPath={[]}
curRows={rows}
isView={isView}
parentTableName={null}
activePath={activePath}
ongoingRequest={ongoingRequest}
isProgressing={isProgressing}
lastError={lastError}
lastSuccess={lastSuccess}
triggerList={triggerList}
curDepth={0}
count={count}
dispatch={dispatch}
expandedRow={expandedRow}
/>
);
// Choose the right nav bar header thing
const header = (
<TableHeader
count={count}
dispatch={dispatch}
triggerName={triggerName}
tabName="pending"
migrationMode={migrationMode}
/>
);
return (
<div>
{header}
<div>{viewRows}</div>
</div>
);
}
}
ViewTable.propTypes = {
triggerName: PropTypes.string.isRequired,
triggerList: PropTypes.array.isRequired,
activePath: PropTypes.array.isRequired,
query: PropTypes.object.isRequired,
curFilter: PropTypes.object.isRequired,
migrationMode: PropTypes.bool.isRequired,
ongoingRequest: PropTypes.bool.isRequired,
isProgressing: PropTypes.bool.isRequired,
rows: PropTypes.array.isRequired,
expandedRow: PropTypes.string.isRequired,
count: PropTypes.number,
lastError: PropTypes.object.isRequired,
lastSuccess: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
};
const mapStateToProps = (state, ownProps) => {
return {
triggerName: ownProps.params.trigger,
triggerList: state.triggers.pendingEvents,
migrationMode: state.main.migrationMode,
...state.triggers.view,
};
};
const pendingEventsConnector = connect => connect(mapStateToProps)(ViewTable);
export default pendingEventsConnector;

View File

@ -0,0 +1,246 @@
import { defaultCurFilter } from '../EventState';
import { vMakeRequest } from './ViewActions';
const LOADING = 'ProcessedEvents/FilterQuery/LOADING';
const SET_DEFQUERY = 'ProcessedEvents/FilterQuery/SET_DEFQUERY';
const SET_FILTERCOL = 'ProcessedEvents/FilterQuery/SET_FILTERCOL';
const SET_FILTEROP = 'ProcessedEvents/FilterQuery/SET_FILTEROP';
const SET_FILTERVAL = 'ProcessedEvents/FilterQuery/SET_FILTERVAL';
const ADD_FILTER = 'ProcessedEvents/FilterQuery/ADD_FILTER';
const REMOVE_FILTER = 'ProcessedEvents/FilterQuery/REMOVE_FILTER';
const SET_ORDERCOL = 'ProcessedEvents/FilterQuery/SET_ORDERCOL';
const SET_ORDERTYPE = 'ProcessedEvents/FilterQuery/SET_ORDERTYPE';
const ADD_ORDER = 'ProcessedEvents/FilterQuery/ADD_ORDER';
const REMOVE_ORDER = 'ProcessedEvents/FilterQuery/REMOVE_ORDER';
const SET_LIMIT = 'ProcessedEvents/FilterQuery/SET_LIMIT';
const SET_OFFSET = 'ProcessedEvents/FilterQuery/SET_OFFSET';
const SET_NEXTPAGE = 'ProcessedEvents/FilterQuery/SET_NEXTPAGE';
const SET_PREVPAGE = 'ProcessedEvents/FilterQuery/SET_PREVPAGE';
const setLoading = () => ({ type: LOADING, data: true });
const unsetLoading = () => ({ type: LOADING, data: false });
const setDefaultQuery = curQuery => ({ type: SET_DEFQUERY, curQuery });
const setFilterCol = (name, index) => ({ type: SET_FILTERCOL, name, index });
const setFilterOp = (opName, index) => ({ type: SET_FILTEROP, opName, index });
const setFilterVal = (val, index) => ({ type: SET_FILTERVAL, val, index });
const addFilter = () => ({ type: ADD_FILTER });
const removeFilter = index => ({ type: REMOVE_FILTER, index });
const setOrderCol = (name, index) => ({ type: SET_ORDERCOL, name, index });
const setOrderType = (order, index) => ({ type: SET_ORDERTYPE, order, index });
const addOrder = () => ({ type: ADD_ORDER });
const removeOrder = index => ({ type: REMOVE_ORDER, index });
const setLimit = limit => ({ type: SET_LIMIT, limit });
const setOffset = offset => ({ type: SET_OFFSET, offset });
const setNextPage = () => ({ type: SET_NEXTPAGE });
const setPrevPage = () => ({ type: SET_PREVPAGE });
const runQuery = triggerSchema => {
return (dispatch, getState) => {
const state = getState().triggers.view.curFilter;
const finalWhereClauses = state.where.$and.filter(w => {
const colName = Object.keys(w)[0].trim();
if (colName === '') {
return false;
}
const opName = Object.keys(w[colName])[0].trim();
if (opName === '') {
return false;
}
return true;
});
const newQuery = {
where: { $and: finalWhereClauses },
limit: state.limit,
offset: state.offset,
order_by: state.order_by.filter(w => w.column.trim() !== ''),
};
if (newQuery.where.$and.length === 0) {
delete newQuery.where;
}
if (newQuery.order_by.length === 0) {
delete newQuery.order_by;
}
dispatch({ type: 'ProcessedEvents/V_SET_QUERY_OPTS', queryStuff: newQuery });
dispatch(vMakeRequest());
};
};
const processedFilterReducer = (state = defaultCurFilter, action) => {
const i = action.index;
const newFilter = {};
switch (action.type) {
case SET_DEFQUERY:
const q = action.curQuery;
if (
'order_by' in q ||
'limit' in q ||
'offset' in q ||
('where' in q && '$and' in q.where)
) {
const newCurFilterQ = {};
newCurFilterQ.where =
'where' in q && '$and' in q.where
? { $and: [...q.where.$and, { '': { '': '' } }] }
: { ...defaultCurFilter.where };
newCurFilterQ.order_by =
'order_by' in q
? [...q.order_by, ...defaultCurFilter.order_by]
: [...defaultCurFilter.order_by];
newCurFilterQ.limit = 'limit' in q ? q.limit : defaultCurFilter.limit;
newCurFilterQ.offset =
'offset' in q ? q.offset : defaultCurFilter.offset;
return newCurFilterQ;
}
return defaultCurFilter;
case SET_FILTERCOL:
const oldColName = Object.keys(state.where.$and[i])[0];
newFilter[action.name] = { ...state.where.$and[i][oldColName] };
return {
...state,
where: {
$and: [
...state.where.$and.slice(0, i),
newFilter,
...state.where.$and.slice(i + 1),
],
},
};
case SET_FILTEROP:
const colName = Object.keys(state.where.$and[i])[0];
const oldOp = Object.keys(state.where.$and[i][colName])[0];
newFilter[colName] = {};
newFilter[colName][action.opName] = state.where.$and[i][colName][oldOp];
return {
...state,
where: {
$and: [
...state.where.$and.slice(0, i),
newFilter,
...state.where.$and.slice(i + 1),
],
},
};
case SET_FILTERVAL:
const colName1 = Object.keys(state.where.$and[i])[0];
const opName = Object.keys(state.where.$and[i][colName1])[0];
newFilter[colName1] = {};
newFilter[colName1][opName] = action.val;
return {
...state,
where: {
$and: [
...state.where.$and.slice(0, i),
newFilter,
...state.where.$and.slice(i + 1),
],
},
};
case ADD_FILTER:
return {
...state,
where: {
$and: [...state.where.$and, { '': { '': '' } }],
},
};
case REMOVE_FILTER:
const newFilters = [
...state.where.$and.slice(0, i),
...state.where.$and.slice(i + 1),
];
return {
...state,
where: { $and: newFilters },
};
case SET_ORDERCOL:
const oldOrder = state.order_by[i];
return {
...state,
order_by: [
...state.order_by.slice(0, i),
{ ...oldOrder, column: action.name },
...state.order_by.slice(i + 1),
],
};
case SET_ORDERTYPE:
const oldOrder1 = state.order_by[i];
return {
...state,
order_by: [
...state.order_by.slice(0, i),
{ ...oldOrder1, type: action.order },
...state.order_by.slice(i + 1),
],
};
case REMOVE_ORDER:
return {
...state,
order_by: [
...state.order_by.slice(0, i),
...state.order_by.slice(i + 1),
],
};
case ADD_ORDER:
return {
...state,
order_by: [
...state.order_by,
{ column: '', type: 'asc', nulls: 'last' },
],
};
case SET_LIMIT:
return {
...state,
limit: action.limit,
};
case SET_OFFSET:
return {
...state,
offset: action.offset,
};
case SET_NEXTPAGE:
return {
...state,
offset: state.offset + state.limit,
};
case SET_PREVPAGE:
const newOffset = state.offset - state.limit;
return {
...state,
offset: newOffset < 0 ? 0 : newOffset,
};
case LOADING:
return {
...state,
loading: action.data,
};
default:
return state;
}
};
export default processedFilterReducer;
export {
setFilterCol,
setFilterOp,
setFilterVal,
addFilter,
removeFilter,
setOrderCol,
setOrderType,
addOrder,
removeOrder,
setLimit,
setOffset,
setNextPage,
setPrevPage,
setDefaultQuery,
setLoading,
unsetLoading,
runQuery,
};

View File

@ -0,0 +1,265 @@
/*
Use state exactly the way columns in create table do.
dispatch actions using a given function,
but don't listen to state.
derive everything through viewtable as much as possible.
*/
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Operators from '../Operators';
import {
setFilterCol,
setFilterOp,
setFilterVal,
addFilter,
removeFilter,
} from './FilterActions.js';
import {
setOrderCol,
setOrderType,
addOrder,
removeOrder,
} from './FilterActions.js';
import { setDefaultQuery, runQuery } from './FilterActions';
import { vMakeRequest } from './ViewActions';
const renderCols = (colName, triggerSchema, onChange, usage, key) => {
const columns = ['id', 'delivered', 'created_at'];
return (
<select
className="form-control"
onChange={onChange}
value={colName.trim()}
data-test={
usage === 'sort' ? `sort-column-${key}` : `filter-column-${key}`
}
>
{colName.trim() === '' ? (
<option disabled value="">
-- column --
</option>
) : null}
{columns.map((c, i) => (
<option key={i} value={c}>
{c}
</option>
))}
</select>
);
};
const renderOps = (opName, onChange, key) => (
<select
className="form-control"
onChange={onChange}
value={opName.trim()}
data-test={`filter-op-${key}`}
>
{opName.trim() === '' ? (
<option disabled value="">
-- op --
</option>
) : null}
{Operators.map((o, i) => (
<option key={i} value={o.value}>
{o.value}
</option>
))}
</select>
);
const renderWheres = (whereAnd, triggerSchema, dispatch) => {
const styles = require('./FilterQuery.scss');
return whereAnd.map((clause, i) => {
const colName = Object.keys(clause)[0];
const opName = Object.keys(clause[colName])[0];
const dSetFilterCol = e => {
dispatch(setFilterCol(e.target.value, i));
};
const dSetFilterOp = e => {
dispatch(setFilterOp(e.target.value, i));
};
let removeIcon = null;
if (i + 1 < whereAnd.length) {
removeIcon = (
<i
className="fa fa-times"
onClick={() => {
dispatch(removeFilter(i));
}}
data-test={`clear-filter-${i}`}
/>
);
}
return (
<div key={i} className={`${styles.inputRow} row`}>
<div className="col-xs-4">
{renderCols(colName, triggerSchema, dSetFilterCol, 'filter', i)}
</div>
<div className="col-xs-3">{renderOps(opName, dSetFilterOp, i)}</div>
<div className="col-xs-4">
<input
className="form-control"
placeholder="-- value --"
value={clause[colName][opName]}
onChange={e => {
dispatch(setFilterVal(e.target.value, i));
if (i + 1 === whereAnd.length) {
dispatch(addFilter());
}
}}
data-test={`filter-value-${i}`}
/>
</div>
<div className="text-center col-xs-1">{removeIcon}</div>
</div>
);
});
};
const renderSorts = (orderBy, triggerSchema, dispatch) => {
const styles = require('./FilterQuery.scss');
return orderBy.map((c, i) => {
const dSetOrderCol = e => {
dispatch(setOrderCol(e.target.value, i));
if (i + 1 === orderBy.length) {
dispatch(addOrder());
}
};
let removeIcon = null;
if (i + 1 < orderBy.length) {
removeIcon = (
<i
className="fa fa-times"
onClick={() => {
dispatch(removeOrder(i));
}}
data-test={`clear-sorts-${i}`}
/>
);
}
return (
<div key={i} className={`${styles.inputRow} row`}>
<div className="col-xs-6">
{renderCols(c.column, triggerSchema, dSetOrderCol, 'sort', i)}
</div>
<div className="col-xs-5">
<select
value={c.type}
className="form-control"
onChange={e => {
dispatch(setOrderType(e.target.value, i));
}}
data-test={`sort-order-${i}`}
>
<option value="asc">Asc</option>
<option value="desc">Desc</option>
</select>
</div>
<div className="col-xs-1 text-center">{removeIcon}</div>
</div>
);
});
};
class FilterQuery extends Component {
constructor(props) {
super(props);
this.state = { isWatching: false, intervalId: null };
this.refreshData = this.refreshData.bind(this);
}
componentDidMount() {
const dispatch = this.props.dispatch;
dispatch(setDefaultQuery(this.props.curQuery));
}
componentWillUnmount() {
clearInterval(this.state.intervalId);
}
watchChanges() {
// set state on watch
this.setState({ isWatching: !this.state.isWatching });
if (this.state.isWatching) {
clearInterval(this.state.intervalId);
} else {
const intervalId = setInterval(this.refreshData, 2000);
this.setState({ intervalId: intervalId });
}
}
refreshData() {
this.props.dispatch(vMakeRequest());
}
render() {
const { dispatch, whereAnd, triggerSchema, orderBy } = this.props; // eslint-disable-line no-unused-vars
const styles = require('./FilterQuery.scss');
return (
<div className={styles.filterOptions}>
<form
onSubmit={e => {
e.preventDefault();
dispatch(runQuery(triggerSchema));
}}
>
<div className="">
<div
className={`${styles.queryBox} col-xs-6 ${
styles.padd_left_remove
}`}
>
<span className={styles.subheading_text}>Filter</span>
{renderWheres(whereAnd, triggerSchema, dispatch)}
</div>
<div
className={`${styles.queryBox} col-xs-6 ${
styles.padd_left_remove
}`}
>
<b className={styles.subheading_text}>Sort</b>
{renderSorts(orderBy, triggerSchema, dispatch)}
</div>
</div>
<div className={`${styles.padd_right} ${styles.clear_fix}`}>
<button
type="submit"
className={`btn ${styles.yellow_button}`}
data-test="run-query"
>
Run query
</button>
<button
onClick={this.watchChanges.bind(this)}
className={styles.add_mar_left + ' btn btn-default'}
data-test="run-query"
>
{this.state.isWatching ? (
<span>
Watching <i className={'fa fa-spinner fa-spin'} />
</span>
) : (
'Watch'
)}
</button>
</div>
</form>
</div>
);
}
}
FilterQuery.propTypes = {
curQuery: PropTypes.object.isRequired,
triggerSchema: PropTypes.object.isRequired,
whereAnd: PropTypes.array.isRequired,
orderBy: PropTypes.array.isRequired,
limit: PropTypes.number.isRequired,
count: PropTypes.number,
triggerName: PropTypes.string,
offset: PropTypes.number.isRequired,
dispatch: PropTypes.func.isRequired,
};
export default FilterQuery;

View File

@ -0,0 +1,95 @@
@import "../../../Common/Common.scss";
$bgColor: #f9f9f9;
.container {
margin: 10px 0;
}
.count {
display: inline-block;
max-height: 34px;
padding: 5px 20px;
margin-left: 10px;
border-radius: 4px;
}
.queryBox {
box-sizing: border-box;
position: relative;
min-height: 30px;
.inputRow {
margin: 20px 0;
div[class^=col-xs-] {
padding-left: 0;
padding-right: 2.5px;
}
:global(.form-control) {
height: 35px;
padding: 0 12px;
}
:global(.form-control):focus {
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 0px rgba(102, 175, 233, 0.6);
}
:global(.fa) {
padding-top: 8px;
font-size: 0.8em;
}
i:hover {
cursor: pointer;
color: #888;
}
.descending {
padding-left: 10px;
input {
margin-right: 5px;
}
margin-right: 10px;
}
}
}
.inline {
display: inline-block;
}
.runQuery {
margin-left: 0px;
margin-bottom: 20px;
:global(.form-control):focus {
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 0px rgba(102, 175, 233, 0.6);
}
:global(.input-group) {
margin-top: -24px;
margin-right: 10px;
input {
max-width: 60px;
}
}
nav {
display: inline-block;
}
button {
margin-top: -24px;
margin-right: 15px;
}
}
.filterOptions {
margin-top: 10px;
background: $bgColor;
// padding: 20px;
}
.pagination {
margin: 0;
}
.boxHeading {
top: -0.55em;
line-height: 1.1em;
background: $bgColor;
display: inline-block;
position: absolute;
padding: 0 10px;
color: #929292;
font-weight: normal;
}

View File

@ -0,0 +1,540 @@
import { defaultViewState } from '../EventState';
import Endpoints, { globalCookiePolicy } from '../../../../Endpoints';
import requestAction from 'utils/requestAction';
import processedFilterReducer from './FilterActions';
import { findTableFromRel } from '../utils';
import {
showSuccessNotification,
showErrorNotification,
} from '../Notification';
import dataHeaders from '../Common/Headers';
/* ****************** View actions *************/
const V_SET_DEFAULTS = 'ProcessedEvents/V_SET_DEFAULTS';
const V_REQUEST_SUCCESS = 'ProcessedEvents/V_REQUEST_SUCCESS';
const V_REQUEST_ERROR = 'ProcessedEvents/V_REQUEST_ERROR';
const V_EXPAND_REL = 'ProcessedEvents/V_EXPAND_REL';
const V_CLOSE_REL = 'ProcessedEvents/V_CLOSE_REL';
const V_SET_ACTIVE = 'ProcessedEvents/V_SET_ACTIVE';
const V_SET_QUERY_OPTS = 'ProcessedEvents/V_SET_QUERY_OPTS';
const V_REQUEST_PROGRESS = 'ProcessedEvents/V_REQUEST_PROGRESS';
const V_EXPAND_ROW = 'ProcessedEvents/V_EXPAND_ROW';
const V_COLLAPSE_ROW = 'ProcessedEvents/V_COLLAPSE_ROW';
// const V_ADD_WHERE;
// const V_REMOVE_WHERE;
// const V_SET_LIMIT;
// const V_SET_OFFSET;
// const V_ADD_SORT;
// const V_REMOVE_SORT;
/* ****************** action creators *************/
const vExpandRow = rowKey => ({
type: V_EXPAND_ROW,
data: rowKey,
});
const vCollapseRow = () => ({
type: V_COLLAPSE_ROW,
});
const vSetDefaults = () => ({ type: V_SET_DEFAULTS });
const vMakeRequest = () => {
return (dispatch, getState) => {
const state = getState();
const url = Endpoints.query;
const originalTrigger = getState().triggers.currentTrigger;
dispatch({ type: V_REQUEST_PROGRESS, data: true });
const currentQuery = JSON.parse(JSON.stringify(state.triggers.view.query));
// count query
const countQuery = JSON.parse(JSON.stringify(state.triggers.view.query));
countQuery.columns = ['id'];
// delivered = true || error = true
// where clause for relationship
const currentWhereClause = state.triggers.view.query.where;
if (currentWhereClause && currentWhereClause.$and) {
// make filter for events
const finalAndClause = currentQuery.where.$and;
finalAndClause.push({
$or: [{ delivered: { $eq: true } }, { error: { $eq: true } }],
});
currentQuery.columns[1].where = { $and: finalAndClause };
currentQuery.where = { name: state.triggers.currentTrigger };
countQuery.where.$and.push({
trigger_name: state.triggers.currentTrigger,
});
} else {
// reset where for events
if (currentQuery.columns[1]) {
currentQuery.columns[1].where = {
$or: [{ delivered: { $eq: true } }, { error: { $eq: true } }],
};
}
currentQuery.where = { name: state.triggers.currentTrigger };
countQuery.where = {
$and: [
{ trigger_name: state.triggers.currentTrigger },
{ $or: [{ delivered: { $eq: true } }, { error: { $eq: true } }] },
],
};
}
// order_by for relationship
const currentOrderBy = state.triggers.view.query.order_by;
if (currentOrderBy) {
currentQuery.columns[1].order_by = currentOrderBy;
// reset order_by
delete currentQuery.order_by;
} else {
// reset order by for events
if (currentQuery.columns[1]) {
delete currentQuery.columns[1].order_by;
currentQuery.columns[1].order_by = ['-created_at'];
}
delete currentQuery.order_by;
}
// limit and offset for relationship
const currentLimit = state.triggers.view.query.limit;
const currentOffset = state.triggers.view.query.offset;
currentQuery.columns[1].limit = currentLimit;
currentQuery.columns[1].offset = currentOffset;
// reset limit and offset for parent
delete currentQuery.limit;
delete currentQuery.offset;
delete countQuery.limit;
delete countQuery.offset;
const requestBody = {
type: 'bulk',
args: [
{
type: 'select',
args: {
...currentQuery,
table: {
name: 'event_triggers',
schema: 'hdb_catalog',
},
},
},
{
type: 'count',
args: {
...countQuery,
table: {
name: 'event_log',
schema: 'hdb_catalog',
},
},
},
],
};
const options = {
method: 'POST',
body: JSON.stringify(requestBody),
headers: dataHeaders(getState),
credentials: globalCookiePolicy,
};
return dispatch(requestAction(url, options)).then(
data => {
const currentTrigger = getState().triggers.currentTrigger;
if (originalTrigger === currentTrigger) {
Promise.all([
dispatch({
type: V_REQUEST_SUCCESS,
data: data[0],
count: data[1].count,
}),
dispatch({ type: V_REQUEST_PROGRESS, data: false }),
]);
}
},
error => {
dispatch({ type: V_REQUEST_ERROR, data: error });
}
);
};
};
const deleteItem = pkClause => {
return (dispatch, getState) => {
const isOk = confirm('Permanently delete this row?');
if (!isOk) {
return;
}
const state = getState();
const url = Endpoints.query;
const reqBody = {
type: 'delete',
args: {
table: {
name: state.triggers.currentTrigger,
schema: 'hdb_catalog',
},
where: pkClause,
},
};
const options = {
method: 'POST',
body: JSON.stringify(reqBody),
headers: dataHeaders(getState),
credentials: globalCookiePolicy,
};
dispatch(requestAction(url, options)).then(
data => {
dispatch(vMakeRequest());
dispatch(
showSuccessNotification(
'Row deleted!',
'Affected rows: ' + data.affected_rows
)
);
},
err => {
dispatch(
showErrorNotification('Deleting row failed!', err.error, reqBody, err)
);
}
);
};
};
const vExpandRel = (path, relname, pk) => {
return dispatch => {
// Modify the query (UI will automatically change)
dispatch({ type: V_EXPAND_REL, path, relname, pk });
// Make a request
return dispatch(vMakeRequest());
};
};
const vCloseRel = (path, relname) => {
return dispatch => {
// Modify the query (UI will automatically change)
dispatch({ type: V_CLOSE_REL, path, relname });
// Make a request
return dispatch(vMakeRequest());
};
};
/* ************ helpers ************************/
const defaultSubQuery = (relname, tableSchema) => {
return {
name: relname,
columns: tableSchema.columns.map(c => c.column_name),
};
};
const expandQuery = (
curQuery,
curTable,
pk,
curPath,
relname,
schemas,
isObjRel = false
) => {
if (curPath.length === 0) {
const rel = curTable.relationships.find(r => r.rel_name === relname);
const childTableSchema = findTableFromRel(schemas, curTable, rel);
const newColumns = [
...curQuery.columns,
defaultSubQuery(relname, childTableSchema),
];
if (isObjRel) {
return { ...curQuery, columns: newColumns };
}
// If there's already oldStuff then don't reset it
if ('oldStuff' in curQuery) {
return { ...curQuery, where: pk, columns: newColumns };
}
// If there's no oldStuff then set it
const oldStuff = {};
['where', 'limit', 'offset'].map(k => {
if (k in curQuery) {
oldStuff[k] = curQuery[k];
}
});
return { name: curQuery.name, where: pk, columns: newColumns, oldStuff };
}
const curRelName = curPath[0];
const curRel = curTable.relationships.find(r => r.rel_name === curRelName);
const childTableSchema = findTableFromRel(schemas, curTable, curRel);
const curRelColIndex = curQuery.columns.findIndex(c => c.name === curRelName);
return {
...curQuery,
columns: [
...curQuery.columns.slice(0, curRelColIndex),
expandQuery(
curQuery.columns[curRelColIndex],
childTableSchema,
pk,
curPath.slice(1),
relname,
schemas,
curRel.rel_type === 'object'
),
...curQuery.columns.slice(curRelColIndex + 1),
],
};
};
const closeQuery = (curQuery, curTable, curPath, relname, schemas) => {
// eslint-disable-line no-unused-vars
if (curPath.length === 0) {
const expandedIndex = curQuery.columns.findIndex(c => c.name === relname);
const newColumns = [
...curQuery.columns.slice(0, expandedIndex),
...curQuery.columns.slice(expandedIndex + 1),
];
const newStuff = {};
newStuff.columns = newColumns;
if ('name' in curQuery) {
newStuff.name = curQuery.name;
}
// If no other expanded columns are left
if (!newColumns.find(c => typeof c === 'object')) {
if (curQuery.oldStuff) {
['where', 'limit', 'order_by', 'offset'].map(k => {
if (k in curQuery.oldStuff) {
newStuff[k] = curQuery.oldStuff[k];
}
});
}
return { ...newStuff };
}
return { ...curQuery, ...newStuff };
}
const curRelName = curPath[0];
const curRel = curTable.relationships.find(r => r.rel_name === curRelName);
const childTableSchema = findTableFromRel(schemas, curTable, curRel);
const curRelColIndex = curQuery.columns.findIndex(c => c.name === curRelName);
return {
...curQuery,
columns: [
...curQuery.columns.slice(0, curRelColIndex),
closeQuery(
curQuery.columns[curRelColIndex],
childTableSchema,
curPath.slice(1),
relname,
schemas
),
...curQuery.columns.slice(curRelColIndex + 1),
],
};
};
const setActivePath = (activePath, curPath, relname, query) => {
const basePath = relname
? [activePath[0], ...curPath, relname]
: [activePath[0], ...curPath];
// Now check if there are any more children on this path.
// If there are, then we should expand them by default
let subQuery = query;
let subBase = basePath.slice(1);
while (subBase.length > 0) {
subQuery = subQuery.columns.find(c => c.name === subBase[0]); // eslint-disable-line no-loop-func
subBase = subBase.slice(1);
}
subQuery = subQuery.columns.find(c => typeof c === 'object');
while (subQuery) {
basePath.push(subQuery.name);
subQuery = subQuery.columns.find(c => typeof c === 'object');
}
return basePath;
};
const updateActivePathOnClose = (
activePath,
tableName,
curPath,
relname,
query
) => {
const basePath = [tableName, ...curPath, relname];
let subBase = [...basePath];
let subActive = [...activePath];
let matchingFound = false;
let commonIndex = 0;
subBase = subBase.slice(1);
subActive = subActive.slice(1);
while (subActive.length > 0) {
if (subBase[0] === subActive[0]) {
matchingFound = true;
break;
}
subBase = subBase.slice(1);
subActive = subActive.slice(1);
commonIndex += 1;
}
if (matchingFound) {
const newActivePath = activePath.slice(0, commonIndex + 1);
return setActivePath(
newActivePath,
newActivePath.slice(1, -1),
null,
query
);
}
return [...activePath];
};
const addQueryOptsActivePath = (query, queryStuff, activePath) => {
let curPath = activePath.slice(1);
const newQuery = { ...query };
let curQuery = newQuery;
while (curPath.length > 0) {
curQuery = curQuery.columns.find(c => c.name === curPath[0]); // eslint-disable-line no-loop-func
curPath = curPath.slice(1);
}
['where', 'order_by', 'limit', 'offset'].map(k => {
delete curQuery[k];
});
for (const k in queryStuff) {
if (queryStuff.hasOwnProperty(k)) {
curQuery[k] = queryStuff[k];
}
}
return newQuery;
};
/* ****************** reducer ******************/
const processedEventsReducer = (
triggerName,
triggerList,
viewState,
action
) => {
if (action.type.indexOf('ProcessedEvents/FilterQuery/') === 0) {
return {
...viewState,
curFilter: processedFilterReducer(viewState.curFilter, action),
};
}
const tableSchema = triggerList.find(x => x.name === triggerName);
switch (action.type) {
case V_SET_DEFAULTS:
// check if table exists and then process.
/*
const currentTrigger = triggerList.find(t => t.name === triggerName);
let currentColumns = [];
if (currentTrigger) {
currentColumns = currentTrigger.map(c => c.column_name);
}
*/
return {
...defaultViewState,
query: {
columns: [
'*',
{
name: 'events',
columns: [
'*',
{ name: 'logs', columns: ['*'], order_by: ['-created_at'] },
],
where: {
$or: [{ delivered: { $eq: true } }, { error: { $eq: true } }],
},
},
],
limit: 10,
},
activePath: [triggerName],
rows: [],
count: null,
};
case V_SET_QUERY_OPTS:
return {
...viewState,
query: addQueryOptsActivePath(
viewState.query,
action.queryStuff,
viewState.activePath
),
};
case V_EXPAND_REL:
return {
...viewState,
query: expandQuery(
viewState.query,
tableSchema,
action.pk,
action.path,
action.relname,
triggerList
),
activePath: [...viewState.activePath, action.relname],
};
case V_CLOSE_REL:
const _query = closeQuery(
viewState.query,
tableSchema,
action.path,
action.relname,
triggerList
);
return {
...viewState,
query: _query,
activePath: updateActivePathOnClose(
viewState.activePath,
triggerName,
action.path,
action.relname,
_query
),
};
case V_SET_ACTIVE:
return {
...viewState,
activePath: setActivePath(
viewState.activePath,
action.path,
action.relname,
viewState.query
),
};
case V_REQUEST_SUCCESS:
return { ...viewState, rows: action.data, count: action.count };
case V_REQUEST_PROGRESS:
return { ...viewState, isProgressing: action.data };
case V_EXPAND_ROW:
return {
...viewState,
expandedRow: action.data,
};
case V_COLLAPSE_ROW:
return {
...viewState,
expandedRow: '',
};
default:
return viewState;
}
};
export default processedEventsReducer;
export {
vSetDefaults,
vMakeRequest,
vExpandRel,
vCloseRel,
vExpandRow,
vCollapseRow,
V_SET_ACTIVE,
deleteItem,
};

View File

@ -0,0 +1,407 @@
import React from 'react';
import ReactTable from 'react-table';
import AceEditor from 'react-ace';
import Tabs from 'react-bootstrap/lib/Tabs';
import Tab from 'react-bootstrap/lib/Tab';
import 'brace/mode/json';
import 'react-table/react-table.css';
import { deleteItem, vExpandRow, vCollapseRow } from './ViewActions'; // eslint-disable-line no-unused-vars
import FilterQuery from './FilterQuery';
import {
setOrderCol,
setOrderType,
removeOrder,
runQuery,
setOffset,
setLimit,
addOrder,
} from './FilterActions';
import { ordinalColSort } from '../utils';
import Spinner from '../../../Common/Spinner/Spinner';
import '../TableCommon/ReactTableFix.css';
const ViewRows = ({
curTriggerName,
curQuery,
curFilter,
curRows,
curPath,
curDepth,
activePath,
triggerList,
dispatch,
isProgressing,
isView,
count,
expandedRow,
}) => {
const styles = require('../TableCommon/Table.scss');
const triggerSchema = triggerList.find(x => x.name === curTriggerName);
const curRelName = curPath.length > 0 ? curPath.slice(-1)[0] : null;
// Am I a single row display
const isSingleRow = false;
// Get the headings
const tableHeadings = [];
const gridHeadings = [];
const eventLogColumns = ['id', 'delivered', 'created_at'];
const sortedColumns = eventLogColumns.sort(ordinalColSort);
sortedColumns.map((column, i) => {
tableHeadings.push(<th key={i}>{column}</th>);
gridHeadings.push({
Header: column,
accessor: column,
});
});
const hasPrimaryKeys = true;
/*
let editButton;
let deleteButton;
*/
const newCurRows = [];
if (curRows && curRows[0] && curRows[0].events) {
curRows[0].events.forEach((row, rowIndex) => {
const newRow = {};
const pkClause = {};
if (!isView && hasPrimaryKeys) {
pkClause.id = row.id;
} else {
triggerSchema.map(k => {
pkClause[k] = row[k];
});
}
/*
if (!isSingleRow && !isView && hasPrimaryKeys) {
deleteButton = (
<button
className={`${styles.add_mar_right_small} btn btn-xs btn-default`}
onClick={() => {
dispatch(deleteItem(pkClause));
}}
data-test={`row-delete-button-${rowIndex}`}
>
Delete
</button>
);
}
const buttonsDiv = (
<div className={styles.tableCellCenterAligned}>
{editButton}
{deleteButton}
</div>
);
*/
// Insert Edit, Delete, Clone in a cell
// newRow.actions = buttonsDiv;
// Insert cells corresponding to all rows
sortedColumns.forEach(col => {
const getCellContent = () => {
let conditionalClassname = styles.tableCellCenterAligned;
const cellIndex = `${curTriggerName}-${col}-${rowIndex}`;
if (expandedRow === cellIndex) {
conditionalClassname = styles.tableCellCenterAlignedExpanded;
}
if (row[col] === null) {
return (
<div className={conditionalClassname}>
<i>NULL</i>
</div>
);
}
let content = row[col] === undefined ? 'NULL' : row[col].toString();
if (col === 'created_at') {
content = new Date(row[col]).toUTCString();
}
return <div className={conditionalClassname}>{content}</div>;
};
newRow[col] = getCellContent();
});
newCurRows.push(newRow);
});
}
// Is this ViewRows visible
let isVisible = false;
if (!curRelName) {
isVisible = true;
} else if (curRelName === activePath[curDepth]) {
isVisible = true;
}
let filterQuery = null;
if (!isSingleRow) {
if (curRelName === activePath[curDepth] || curDepth === 0) {
// Rendering only if this is the activePath or this is the root
let wheres = [{ '': { '': '' } }];
if ('where' in curFilter && '$and' in curFilter.where) {
wheres = [...curFilter.where.$and];
}
let orderBy = [{ column: '', type: 'asc', nulls: 'last' }];
if ('order_by' in curFilter) {
orderBy = [...curFilter.order_by];
}
const limit = 'limit' in curFilter ? curFilter.limit : 10;
const offset = 'offset' in curFilter ? curFilter.offset : 0;
filterQuery = (
<FilterQuery
curQuery={curQuery}
whereAnd={wheres}
triggerSchema={triggerSchema}
orderBy={orderBy}
limit={limit}
dispatch={dispatch}
count={count}
triggerName={curTriggerName}
offset={offset}
/>
);
}
}
const sortByColumn = col => {
// Remove all the existing order_bys
const numOfOrderBys = curFilter.order_by.length;
for (let i = 0; i < numOfOrderBys - 1; i++) {
dispatch(removeOrder(1));
}
// Go back to the first page
dispatch(setOffset(0));
// Set the filter and run query
dispatch(setOrderCol(col, 0));
if (
curFilter.order_by.length !== 0 &&
curFilter.order_by[0].column === col &&
curFilter.order_by[0].type === 'asc'
) {
dispatch(setOrderType('desc', 0));
} else {
dispatch(setOrderType('asc', 0));
}
dispatch(runQuery(triggerSchema));
// Add a new empty filter
dispatch(addOrder());
};
const changePage = page => {
if (curFilter.offset !== page * curFilter.limit) {
dispatch(setOffset(page * curFilter.limit));
dispatch(runQuery(triggerSchema));
}
};
const changePageSize = size => {
if (curFilter.size !== size) {
dispatch(setLimit(size));
dispatch(runQuery(triggerSchema));
}
};
const renderTableBody = () => {
if (count === 0) {
return <div> No rows found. </div>;
}
let shouldSortColumn = true;
const invocationColumns = ['status', 'id', 'created_at'];
const invocationGridHeadings = [];
invocationColumns.map(column => {
invocationGridHeadings.push({
Header: column,
accessor: column,
});
});
return (
<ReactTable
className="-highlight"
data={newCurRows}
columns={gridHeadings}
resizable
manual
sortable={false}
minRows={0}
getTheadThProps={(finalState, some, column) => ({
onClick: () => {
if (
column.Header &&
shouldSortColumn &&
column.Header !== 'Actions'
) {
sortByColumn(column.Header);
}
shouldSortColumn = true;
},
})}
getResizerProps={(finalState, none, column, ctx) => ({
onMouseDown: e => {
shouldSortColumn = false;
ctx.resizeColumnStart(e, column, false);
},
})}
showPagination={count > curFilter.limit}
defaultPageSize={Math.min(curFilter.limit, count)}
pages={Math.ceil(count / curFilter.limit)}
onPageChange={changePage}
onPageSizeChange={changePageSize}
page={Math.floor(curFilter.offset / curFilter.limit)}
SubComponent={row => {
const currentIndex = row.index;
const currentRow = curRows[0].events[currentIndex];
const invocationRowsData = [];
currentRow.logs.map((r, rowIndex) => {
const newRow = {};
const status =
r.status === 200 ? (
<i className={styles.invocationSuccess + ' fa fa-check'} />
) : (
<i className={styles.invocationFailure + ' fa fa-times'} />
);
// Insert cells corresponding to all rows
invocationColumns.forEach(col => {
const getCellContent = () => {
let conditionalClassname = styles.tableCellCenterAligned;
const cellIndex = `${curTriggerName}-${col}-${rowIndex}`;
if (expandedRow === cellIndex) {
conditionalClassname = styles.tableCellCenterAlignedExpanded;
}
if (r[col] === null) {
return (
<div className={conditionalClassname}>
<i>NULL</i>
</div>
);
}
if (col === 'status') {
return status;
}
if (col === 'created_at') {
const formattedDate = new Date(r.created_at).toUTCString();
return formattedDate;
}
const content =
r[col] === undefined ? 'NULL' : r[col].toString();
return <div className={conditionalClassname}>{content}</div>;
};
newRow[col] = getCellContent();
});
invocationRowsData.push(newRow);
});
return (
<div style={{ padding: '20px' }}>
<em>Recent Invocations</em>
<div
className={styles.invocationsSection + ' invocationsSection'}
>
{invocationRowsData.length ? (
<ReactTable
data={invocationRowsData}
columns={invocationGridHeadings}
defaultPageSize={currentRow.logs.length}
showPagination={false}
SubComponent={logRow => {
const finalIndex = logRow.index;
const currentPayload = JSON.stringify(
currentRow.payload,
null,
4
);
const finalRow = currentRow.logs[finalIndex];
// check if response is type JSON
let finalResponse = finalRow.response;
try {
finalResponse = JSON.parse(finalRow.response);
finalResponse = JSON.stringify(finalResponse, null, 4);
} catch (e) {
console.error(e);
}
return (
<div style={{ padding: '20px' }}>
<Tabs
animation={false}
defaultActiveKey={1}
id="requestResponseTab"
>
<Tab eventKey={1} title="Request">
<div className={styles.add_mar_top}>
<div className={styles.subheading_text}>
Request
</div>
<AceEditor
mode="json"
theme="github"
name="payload"
value={currentPayload}
minLines={4}
maxLines={100}
width="100%"
showPrintMargin={false}
showGutter={false}
/>
</div>
</Tab>
<Tab eventKey={2} title="Response">
<div className={styles.add_mar_top}>
<div className={styles.subheading_text}>
Response
</div>
<AceEditor
mode="json"
theme="github"
name="response"
value={finalResponse}
minLines={4}
maxLines={100}
width="100%"
showPrintMargin={false}
showGutter={false}
/>
</div>
</Tab>
</Tabs>
</div>
);
}}
/>
) : (
<div className={styles.add_mar_top}>No data available</div>
)}
</div>
<br />
<br />
</div>
);
}}
/>
);
};
return (
<div className={isVisible ? '' : 'hide '}>
{filterQuery}
<hr />
<div className="row">
<div className="col-xs-12">
<div className={styles.tableContainer + ' eventsTableBody'}>
{isProgressing ? (
<div>
{' '}
<Spinner />{' '}
</div>
) : null}
{renderTableBody()}
</div>
<br />
<br />
</div>
</div>
</div>
);
};
export default ViewRows;

View File

@ -0,0 +1,227 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { vSetDefaults, vMakeRequest, vExpandHeading } from './ViewActions'; // eslint-disable-line no-unused-vars
import { setTrigger } from '../EventActions';
import TableHeader from '../TableCommon/TableHeader';
import ViewRows from './ViewRows';
import { replace } from 'react-router-redux';
const genHeadings = headings => {
if (headings.length === 0) {
return [];
}
const heading = headings[0];
if (typeof heading === 'string') {
return [heading, ...genHeadings(headings.slice(1))];
}
if (typeof heading === 'object') {
if (!heading._expanded) {
const headingName =
heading.type === 'obj_rel' ? heading.lcol : heading.relname;
return [
{ name: headingName, type: heading.type },
...genHeadings(headings.slice(1)),
];
}
if (heading.type === 'obj_rel') {
const subheadings = genHeadings(heading.headings).map(h => {
if (typeof h === 'string') {
return heading.relname + '.' + h;
}
return heading.relname + '.' + h.name;
});
return [...subheadings, ...genHeadings(headings.slice(1))];
}
}
throw 'Incomplete pattern match'; // eslint-disable-line no-throw-literal
};
const genRow = (row, headings) => {
if (headings.length === 0) {
return [];
}
const heading = headings[0];
if (typeof heading === 'string') {
return [row[heading], ...genRow(row, headings.slice(1))];
}
if (typeof heading === 'object') {
if (!heading._expanded) {
const rowVal = heading.type === 'obj_rel' ? row[heading.lcol] : '[...]';
return [rowVal, ...genRow(row, headings.slice(1))];
}
if (heading.type === 'obj_rel') {
const subrow = genRow(row[heading.relname], heading.headings);
return [...subrow, ...genRow(row, headings.slice(1))];
}
}
throw 'Incomplete pattern match'; // eslint-disable-line no-throw-literal
};
class ViewTable extends Component {
constructor(props) {
super(props);
// Initialize this table
this.state = {
dispatch: props.dispatch,
triggerName: props.triggerName,
};
// this.state.dispatch = props.dispatch;
// this.state.triggerName = props.triggerName;
const dispatch = this.props.dispatch;
Promise.all([
dispatch(setTrigger(this.props.triggerName)),
dispatch(vSetDefaults(this.props.triggerName)),
dispatch(vMakeRequest()),
]);
}
componentDidMount() {
const dispatch = this.props.dispatch;
Promise.all([
dispatch(setTrigger(this.props.triggerName)),
dispatch(vSetDefaults(this.props.triggerName)),
dispatch(vMakeRequest()),
]);
}
componentWillReceiveProps(nextProps) {
const dispatch = this.props.dispatch;
if (nextProps.triggerName !== this.props.triggerName) {
Promise.all([
dispatch(setTrigger(nextProps.triggerName)),
dispatch(vSetDefaults(nextProps.triggerName)),
dispatch(vMakeRequest()),
]);
}
}
shouldComponentUpdate(nextProps) {
return (
this.props.triggerName === null ||
nextProps.triggerName === this.props.triggerName
);
}
componentWillUpdate() {
this.shouldScrollBottom =
window.innerHeight ===
document.body.offsetHeight - document.body.scrollTop;
}
componentDidUpdate() {
if (this.shouldScrollBottom) {
document.body.scrollTop = document.body.offsetHeight - window.innerHeight;
}
}
componentWillUnmount() {
// Remove state data beloging to this table
const dispatch = this.props.dispatch;
dispatch(vSetDefaults(this.props.triggerName));
}
render() {
const {
triggerName,
triggerList,
query,
curFilter,
rows,
count, // eslint-disable-line no-unused-vars
activePath,
migrationMode,
ongoingRequest,
isProgressing,
lastError,
lastSuccess,
dispatch,
expandedRow,
} = this.props; // eslint-disable-line no-unused-vars
// check if table exists
const currentTrigger = triggerList.find(s => s.name === triggerName);
if (!currentTrigger) {
// dispatch a 404 route
console.log(triggerName);
console.log(triggerList);
dispatch(replace('/404'));
}
// Is this a view
const isView = false;
// Are there any expanded columns
const viewRows = (
<ViewRows
curTriggerName={triggerName}
curQuery={query}
curFilter={curFilter}
curPath={[]}
curRows={rows}
isView={isView}
parentTableName={null}
activePath={activePath}
ongoingRequest={ongoingRequest}
isProgressing={isProgressing}
lastError={lastError}
lastSuccess={lastSuccess}
triggerList={triggerList}
curDepth={0}
count={count}
dispatch={dispatch}
expandedRow={expandedRow}
/>
);
// Choose the right nav bar header thing
const header = (
<TableHeader
count={count}
dispatch={dispatch}
triggerName={triggerName}
tabName="processed"
migrationMode={migrationMode}
/>
);
return (
<div>
{header}
<div>{viewRows}</div>
</div>
);
}
}
ViewTable.propTypes = {
triggerName: PropTypes.string.isRequired,
triggerList: PropTypes.array.isRequired,
activePath: PropTypes.array.isRequired,
query: PropTypes.object.isRequired,
curFilter: PropTypes.object.isRequired,
migrationMode: PropTypes.bool.isRequired,
ongoingRequest: PropTypes.bool.isRequired,
isProgressing: PropTypes.bool.isRequired,
rows: PropTypes.array.isRequired,
expandedRow: PropTypes.string.isRequired,
count: PropTypes.number,
lastError: PropTypes.object.isRequired,
lastSuccess: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
};
const mapStateToProps = (state, ownProps) => {
return {
triggerName: ownProps.params.trigger,
triggerList: state.triggers.processedEvents,
migrationMode: state.main.migrationMode,
...state.triggers.view,
};
};
const processedEventsConnector = connect => connect(mapStateToProps)(ViewTable);
export default processedEventsConnector;

View File

@ -0,0 +1,246 @@
import { defaultCurFilter } from '../EventState';
import { vMakeRequest } from './ViewActions';
const LOADING = 'RunningEvents/FilterQuery/LOADING';
const SET_DEFQUERY = 'RunningEvents/FilterQuery/SET_DEFQUERY';
const SET_FILTERCOL = 'RunningEvents/FilterQuery/SET_FILTERCOL';
const SET_FILTEROP = 'RunningEvents/FilterQuery/SET_FILTEROP';
const SET_FILTERVAL = 'RunningEvents/FilterQuery/SET_FILTERVAL';
const ADD_FILTER = 'RunningEvents/FilterQuery/ADD_FILTER';
const REMOVE_FILTER = 'RunningEvents/FilterQuery/REMOVE_FILTER';
const SET_ORDERCOL = 'RunningEvents/FilterQuery/SET_ORDERCOL';
const SET_ORDERTYPE = 'RunningEvents/FilterQuery/SET_ORDERTYPE';
const ADD_ORDER = 'RunningEvents/FilterQuery/ADD_ORDER';
const REMOVE_ORDER = 'RunningEvents/FilterQuery/REMOVE_ORDER';
const SET_LIMIT = 'RunningEvents/FilterQuery/SET_LIMIT';
const SET_OFFSET = 'RunningEvents/FilterQuery/SET_OFFSET';
const SET_NEXTPAGE = 'RunningEvents/FilterQuery/SET_NEXTPAGE';
const SET_PREVPAGE = 'RunningEvents/FilterQuery/SET_PREVPAGE';
const setLoading = () => ({ type: LOADING, data: true });
const unsetLoading = () => ({ type: LOADING, data: false });
const setDefaultQuery = curQuery => ({ type: SET_DEFQUERY, curQuery });
const setFilterCol = (name, index) => ({ type: SET_FILTERCOL, name, index });
const setFilterOp = (opName, index) => ({ type: SET_FILTEROP, opName, index });
const setFilterVal = (val, index) => ({ type: SET_FILTERVAL, val, index });
const addFilter = () => ({ type: ADD_FILTER });
const removeFilter = index => ({ type: REMOVE_FILTER, index });
const setOrderCol = (name, index) => ({ type: SET_ORDERCOL, name, index });
const setOrderType = (order, index) => ({ type: SET_ORDERTYPE, order, index });
const addOrder = () => ({ type: ADD_ORDER });
const removeOrder = index => ({ type: REMOVE_ORDER, index });
const setLimit = limit => ({ type: SET_LIMIT, limit });
const setOffset = offset => ({ type: SET_OFFSET, offset });
const setNextPage = () => ({ type: SET_NEXTPAGE });
const setPrevPage = () => ({ type: SET_PREVPAGE });
const runQuery = triggerSchema => {
return (dispatch, getState) => {
const state = getState().triggers.view.curFilter;
const finalWhereClauses = state.where.$and.filter(w => {
const colName = Object.keys(w)[0].trim();
if (colName === '') {
return false;
}
const opName = Object.keys(w[colName])[0].trim();
if (opName === '') {
return false;
}
return true;
});
const newQuery = {
where: { $and: finalWhereClauses },
limit: state.limit,
offset: state.offset,
order_by: state.order_by.filter(w => w.column.trim() !== ''),
};
if (newQuery.where.$and.length === 0) {
delete newQuery.where;
}
if (newQuery.order_by.length === 0) {
delete newQuery.order_by;
}
dispatch({ type: 'RunningEvents/V_SET_QUERY_OPTS', queryStuff: newQuery });
dispatch(vMakeRequest());
};
};
const pendingFilterReducer = (state = defaultCurFilter, action) => {
const i = action.index;
const newFilter = {};
switch (action.type) {
case SET_DEFQUERY:
const q = action.curQuery;
if (
'order_by' in q ||
'limit' in q ||
'offset' in q ||
('where' in q && '$and' in q.where)
) {
const newCurFilterQ = {};
newCurFilterQ.where =
'where' in q && '$and' in q.where
? { $and: [...q.where.$and, { '': { '': '' } }] }
: { ...defaultCurFilter.where };
newCurFilterQ.order_by =
'order_by' in q
? [...q.order_by, ...defaultCurFilter.order_by]
: [...defaultCurFilter.order_by];
newCurFilterQ.limit = 'limit' in q ? q.limit : defaultCurFilter.limit;
newCurFilterQ.offset =
'offset' in q ? q.offset : defaultCurFilter.offset;
return newCurFilterQ;
}
return defaultCurFilter;
case SET_FILTERCOL:
const oldColName = Object.keys(state.where.$and[i])[0];
newFilter[action.name] = { ...state.where.$and[i][oldColName] };
return {
...state,
where: {
$and: [
...state.where.$and.slice(0, i),
newFilter,
...state.where.$and.slice(i + 1),
],
},
};
case SET_FILTEROP:
const colName = Object.keys(state.where.$and[i])[0];
const oldOp = Object.keys(state.where.$and[i][colName])[0];
newFilter[colName] = {};
newFilter[colName][action.opName] = state.where.$and[i][colName][oldOp];
return {
...state,
where: {
$and: [
...state.where.$and.slice(0, i),
newFilter,
...state.where.$and.slice(i + 1),
],
},
};
case SET_FILTERVAL:
const colName1 = Object.keys(state.where.$and[i])[0];
const opName = Object.keys(state.where.$and[i][colName1])[0];
newFilter[colName1] = {};
newFilter[colName1][opName] = action.val;
return {
...state,
where: {
$and: [
...state.where.$and.slice(0, i),
newFilter,
...state.where.$and.slice(i + 1),
],
},
};
case ADD_FILTER:
return {
...state,
where: {
$and: [...state.where.$and, { '': { '': '' } }],
},
};
case REMOVE_FILTER:
const newFilters = [
...state.where.$and.slice(0, i),
...state.where.$and.slice(i + 1),
];
return {
...state,
where: { $and: newFilters },
};
case SET_ORDERCOL:
const oldOrder = state.order_by[i];
return {
...state,
order_by: [
...state.order_by.slice(0, i),
{ ...oldOrder, column: action.name },
...state.order_by.slice(i + 1),
],
};
case SET_ORDERTYPE:
const oldOrder1 = state.order_by[i];
return {
...state,
order_by: [
...state.order_by.slice(0, i),
{ ...oldOrder1, type: action.order },
...state.order_by.slice(i + 1),
],
};
case REMOVE_ORDER:
return {
...state,
order_by: [
...state.order_by.slice(0, i),
...state.order_by.slice(i + 1),
],
};
case ADD_ORDER:
return {
...state,
order_by: [
...state.order_by,
{ column: '', type: 'asc', nulls: 'last' },
],
};
case SET_LIMIT:
return {
...state,
limit: action.limit,
};
case SET_OFFSET:
return {
...state,
offset: action.offset,
};
case SET_NEXTPAGE:
return {
...state,
offset: state.offset + state.limit,
};
case SET_PREVPAGE:
const newOffset = state.offset - state.limit;
return {
...state,
offset: newOffset < 0 ? 0 : newOffset,
};
case LOADING:
return {
...state,
loading: action.data,
};
default:
return state;
}
};
export default pendingFilterReducer;
export {
setFilterCol,
setFilterOp,
setFilterVal,
addFilter,
removeFilter,
setOrderCol,
setOrderType,
addOrder,
removeOrder,
setLimit,
setOffset,
setNextPage,
setPrevPage,
setDefaultQuery,
setLoading,
unsetLoading,
runQuery,
};

View File

@ -0,0 +1,265 @@
/*
Use state exactly the way columns in create table do.
dispatch actions using a given function,
but don't listen to state.
derive everything through viewtable as much as possible.
*/
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Operators from '../Operators';
import {
setFilterCol,
setFilterOp,
setFilterVal,
addFilter,
removeFilter,
} from './FilterActions.js';
import {
setOrderCol,
setOrderType,
addOrder,
removeOrder,
} from './FilterActions.js';
import { setDefaultQuery, runQuery } from './FilterActions';
import { vMakeRequest } from './ViewActions';
const renderCols = (colName, triggerSchema, onChange, usage, key) => {
const columns = ['id', 'delivered', 'created_at'];
return (
<select
className="form-control"
onChange={onChange}
value={colName.trim()}
data-test={
usage === 'sort' ? `sort-column-${key}` : `filter-column-${key}`
}
>
{colName.trim() === '' ? (
<option disabled value="">
-- column --
</option>
) : null}
{columns.map((c, i) => (
<option key={i} value={c}>
{c}
</option>
))}
</select>
);
};
const renderOps = (opName, onChange, key) => (
<select
className="form-control"
onChange={onChange}
value={opName.trim()}
data-test={`filter-op-${key}`}
>
{opName.trim() === '' ? (
<option disabled value="">
-- op --
</option>
) : null}
{Operators.map((o, i) => (
<option key={i} value={o.value}>
{o.value}
</option>
))}
</select>
);
const renderWheres = (whereAnd, triggerSchema, dispatch) => {
const styles = require('./FilterQuery.scss');
return whereAnd.map((clause, i) => {
const colName = Object.keys(clause)[0];
const opName = Object.keys(clause[colName])[0];
const dSetFilterCol = e => {
dispatch(setFilterCol(e.target.value, i));
};
const dSetFilterOp = e => {
dispatch(setFilterOp(e.target.value, i));
};
let removeIcon = null;
if (i + 1 < whereAnd.length) {
removeIcon = (
<i
className="fa fa-times"
onClick={() => {
dispatch(removeFilter(i));
}}
data-test={`clear-filter-${i}`}
/>
);
}
return (
<div key={i} className={`${styles.inputRow} row`}>
<div className="col-xs-4">
{renderCols(colName, triggerSchema, dSetFilterCol, 'filter', i)}
</div>
<div className="col-xs-3">{renderOps(opName, dSetFilterOp, i)}</div>
<div className="col-xs-4">
<input
className="form-control"
placeholder="-- value --"
value={clause[colName][opName]}
onChange={e => {
dispatch(setFilterVal(e.target.value, i));
if (i + 1 === whereAnd.length) {
dispatch(addFilter());
}
}}
data-test={`filter-value-${i}`}
/>
</div>
<div className="text-center col-xs-1">{removeIcon}</div>
</div>
);
});
};
const renderSorts = (orderBy, triggerSchema, dispatch) => {
const styles = require('./FilterQuery.scss');
return orderBy.map((c, i) => {
const dSetOrderCol = e => {
dispatch(setOrderCol(e.target.value, i));
if (i + 1 === orderBy.length) {
dispatch(addOrder());
}
};
let removeIcon = null;
if (i + 1 < orderBy.length) {
removeIcon = (
<i
className="fa fa-times"
onClick={() => {
dispatch(removeOrder(i));
}}
data-test={`clear-sorts-${i}`}
/>
);
}
return (
<div key={i} className={`${styles.inputRow} row`}>
<div className="col-xs-6">
{renderCols(c.column, triggerSchema, dSetOrderCol, 'sort', i)}
</div>
<div className="col-xs-5">
<select
value={c.type}
className="form-control"
onChange={e => {
dispatch(setOrderType(e.target.value, i));
}}
data-test={`sort-order-${i}`}
>
<option value="asc">Asc</option>
<option value="desc">Desc</option>
</select>
</div>
<div className="col-xs-1 text-center">{removeIcon}</div>
</div>
);
});
};
class FilterQuery extends Component {
constructor(props) {
super(props);
this.state = { isWatching: false, intervalId: null };
this.refreshData = this.refreshData.bind(this);
}
componentDidMount() {
const dispatch = this.props.dispatch;
dispatch(setDefaultQuery(this.props.curQuery));
}
componentWillUnmount() {
clearInterval(this.state.intervalId);
}
watchChanges() {
// set state on watch
this.setState({ isWatching: !this.state.isWatching });
if (this.state.isWatching) {
clearInterval(this.state.intervalId);
} else {
const intervalId = setInterval(this.refreshData, 2000);
this.setState({ intervalId: intervalId });
}
}
refreshData() {
this.props.dispatch(vMakeRequest());
}
render() {
const { dispatch, whereAnd, triggerSchema, orderBy } = this.props; // eslint-disable-line no-unused-vars
const styles = require('./FilterQuery.scss');
return (
<div className={styles.filterOptions}>
<form
onSubmit={e => {
e.preventDefault();
dispatch(runQuery(triggerSchema));
}}
>
<div className="">
<div
className={`${styles.queryBox} col-xs-6 ${
styles.padd_left_remove
}`}
>
<span className={styles.subheading_text}>Filter</span>
{renderWheres(whereAnd, triggerSchema, dispatch)}
</div>
<div
className={`${styles.queryBox} col-xs-6 ${
styles.padd_left_remove
}`}
>
<b className={styles.subheading_text}>Sort</b>
{renderSorts(orderBy, triggerSchema, dispatch)}
</div>
</div>
<div className={`${styles.padd_right} ${styles.clear_fix}`}>
<button
type="submit"
className={`btn ${styles.yellow_button}`}
data-test="run-query"
>
Run query
</button>
<button
onClick={this.watchChanges.bind(this)}
className={styles.add_mar_left + ' btn btn-default'}
data-test="run-query"
>
{this.state.isWatching ? (
<span>
Watching <i className={'fa fa-spinner fa-spin'} />
</span>
) : (
'Watch'
)}
</button>
</div>
</form>
</div>
);
}
}
FilterQuery.propTypes = {
curQuery: PropTypes.object.isRequired,
triggerSchema: PropTypes.object.isRequired,
whereAnd: PropTypes.array.isRequired,
orderBy: PropTypes.array.isRequired,
limit: PropTypes.number.isRequired,
count: PropTypes.number,
triggerName: PropTypes.string,
offset: PropTypes.number.isRequired,
dispatch: PropTypes.func.isRequired,
};
export default FilterQuery;

View File

@ -0,0 +1,95 @@
@import "../../../Common/Common.scss";
$bgColor: #f9f9f9;
.container {
margin: 10px 0;
}
.count {
display: inline-block;
max-height: 34px;
padding: 5px 20px;
margin-left: 10px;
border-radius: 4px;
}
.queryBox {
box-sizing: border-box;
position: relative;
min-height: 30px;
.inputRow {
margin: 20px 0;
div[class^=col-xs-] {
padding-left: 0;
padding-right: 2.5px;
}
:global(.form-control) {
height: 35px;
padding: 0 12px;
}
:global(.form-control):focus {
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 0px rgba(102, 175, 233, 0.6);
}
:global(.fa) {
padding-top: 8px;
font-size: 0.8em;
}
i:hover {
cursor: pointer;
color: #888;
}
.descending {
padding-left: 10px;
input {
margin-right: 5px;
}
margin-right: 10px;
}
}
}
.inline {
display: inline-block;
}
.runQuery {
margin-left: 0px;
margin-bottom: 20px;
:global(.form-control):focus {
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 0px rgba(102, 175, 233, 0.6);
}
:global(.input-group) {
margin-top: -24px;
margin-right: 10px;
input {
max-width: 60px;
}
}
nav {
display: inline-block;
}
button {
margin-top: -24px;
margin-right: 15px;
}
}
.filterOptions {
margin-top: 10px;
background: $bgColor;
// padding: 20px;
}
.pagination {
margin: 0;
}
.boxHeading {
top: -0.55em;
line-height: 1.1em;
background: $bgColor;
display: inline-block;
position: absolute;
padding: 0 10px;
color: #929292;
font-weight: normal;
}

View File

@ -0,0 +1,472 @@
import { defaultViewState } from '../EventState';
import Endpoints, { globalCookiePolicy } from '../../../../Endpoints';
import requestAction from 'utils/requestAction';
import pendingFilterReducer from './FilterActions';
import { findTableFromRel } from '../utils';
import dataHeaders from '../Common/Headers';
/* ****************** View actions *************/
const V_SET_DEFAULTS = 'RunningEvents/V_SET_DEFAULTS';
const V_REQUEST_SUCCESS = 'RunningEvents/V_REQUEST_SUCCESS';
const V_REQUEST_ERROR = 'RunningEvents/V_REQUEST_ERROR';
const V_EXPAND_REL = 'RunningEvents/V_EXPAND_REL';
const V_CLOSE_REL = 'RunningEvents/V_CLOSE_REL';
const V_SET_ACTIVE = 'RunningEvents/V_SET_ACTIVE';
const V_SET_QUERY_OPTS = 'RunningEvents/V_SET_QUERY_OPTS';
const V_REQUEST_PROGRESS = 'RunningEvents/V_REQUEST_PROGRESS';
const V_EXPAND_ROW = 'RunningEvents/V_EXPAND_ROW';
const V_COLLAPSE_ROW = 'RunningEvents/V_COLLAPSE_ROW';
/* ****************** action creators *************/
const vExpandRow = rowKey => ({
type: V_EXPAND_ROW,
data: rowKey,
});
const vCollapseRow = () => ({
type: V_COLLAPSE_ROW,
});
const vSetDefaults = () => ({ type: V_SET_DEFAULTS });
const vMakeRequest = () => {
return (dispatch, getState) => {
const state = getState();
const url = Endpoints.query;
const originalTrigger = getState().triggers.currentTrigger;
dispatch({ type: V_REQUEST_PROGRESS, data: true });
const currentQuery = JSON.parse(JSON.stringify(state.triggers.view.query));
// count query
const countQuery = JSON.parse(JSON.stringify(state.triggers.view.query));
countQuery.columns = ['id'];
// delivered = false and error = false
// where clause for relationship
const currentWhereClause = state.triggers.view.query.where;
if (currentWhereClause && currentWhereClause.$and) {
// make filter for events
const finalAndClause = currentQuery.where.$and;
finalAndClause.push({ delivered: false });
finalAndClause.push({ error: false });
currentQuery.columns[1].where = { $and: finalAndClause };
currentQuery.where = { name: state.triggers.currentTrigger };
countQuery.where.$and.push({
trigger_name: state.triggers.currentTrigger,
});
} else {
// reset where for events
if (currentQuery.columns[1]) {
currentQuery.columns[1].where = {
delivered: false,
error: false,
tries: { $gt: 0 },
};
}
currentQuery.where = { name: state.triggers.currentTrigger };
countQuery.where = {
trigger_name: state.triggers.currentTrigger,
delivered: false,
error: false,
tries: { $gt: 0 },
};
}
// order_by for relationship
const currentOrderBy = state.triggers.view.query.order_by;
if (currentOrderBy) {
currentQuery.columns[1].order_by = currentOrderBy;
// reset order_by
delete currentQuery.order_by;
} else {
// reset order by for events
if (currentQuery.columns[1]) {
delete currentQuery.columns[1].order_by;
currentQuery.columns[1].order_by = ['-created_at'];
}
delete currentQuery.order_by;
}
// limit and offset for relationship
const currentLimit = state.triggers.view.query.limit;
const currentOffset = state.triggers.view.query.offset;
currentQuery.columns[1].limit = currentLimit;
currentQuery.columns[1].offset = currentOffset;
// reset limit and offset for parent
delete currentQuery.limit;
delete currentQuery.offset;
delete countQuery.limit;
delete countQuery.offset;
const requestBody = {
type: 'bulk',
args: [
{
type: 'select',
args: {
...currentQuery,
table: {
name: 'event_triggers',
schema: 'hdb_catalog',
},
},
},
{
type: 'count',
args: {
...countQuery,
table: {
name: 'event_log',
schema: 'hdb_catalog',
},
},
},
],
};
const options = {
method: 'POST',
body: JSON.stringify(requestBody),
headers: dataHeaders(getState),
credentials: globalCookiePolicy,
};
return dispatch(requestAction(url, options)).then(
data => {
const currentTrigger = getState().triggers.currentTrigger;
if (originalTrigger === currentTrigger) {
Promise.all([
dispatch({
type: V_REQUEST_SUCCESS,
data: data[0],
count: data[1].count,
}),
dispatch({ type: V_REQUEST_PROGRESS, data: false }),
]);
}
},
error => {
dispatch({ type: V_REQUEST_ERROR, data: error });
}
);
};
};
const vExpandRel = (path, relname, pk) => {
return dispatch => {
// Modify the query (UI will automatically change)
dispatch({ type: V_EXPAND_REL, path, relname, pk });
// Make a request
return dispatch(vMakeRequest());
};
};
const vCloseRel = (path, relname) => {
return dispatch => {
// Modify the query (UI will automatically change)
dispatch({ type: V_CLOSE_REL, path, relname });
// Make a request
return dispatch(vMakeRequest());
};
};
/* ************ helpers ************************/
const defaultSubQuery = (relname, tableSchema) => {
return {
name: relname,
columns: tableSchema.columns.map(c => c.column_name),
};
};
const expandQuery = (
curQuery,
curTable,
pk,
curPath,
relname,
schemas,
isObjRel = false
) => {
if (curPath.length === 0) {
const rel = curTable.relationships.find(r => r.rel_name === relname);
const childTableSchema = findTableFromRel(schemas, curTable, rel);
const newColumns = [
...curQuery.columns,
defaultSubQuery(relname, childTableSchema),
];
if (isObjRel) {
return { ...curQuery, columns: newColumns };
}
// If there's already oldStuff then don't reset it
if ('oldStuff' in curQuery) {
return { ...curQuery, where: pk, columns: newColumns };
}
// If there's no oldStuff then set it
const oldStuff = {};
['where', 'limit', 'offset'].map(k => {
if (k in curQuery) {
oldStuff[k] = curQuery[k];
}
});
return { name: curQuery.name, where: pk, columns: newColumns, oldStuff };
}
const curRelName = curPath[0];
const curRel = curTable.relationships.find(r => r.rel_name === curRelName);
const childTableSchema = findTableFromRel(schemas, curTable, curRel);
const curRelColIndex = curQuery.columns.findIndex(c => c.name === curRelName);
return {
...curQuery,
columns: [
...curQuery.columns.slice(0, curRelColIndex),
expandQuery(
curQuery.columns[curRelColIndex],
childTableSchema,
pk,
curPath.slice(1),
relname,
schemas,
curRel.rel_type === 'object'
),
...curQuery.columns.slice(curRelColIndex + 1),
],
};
};
const closeQuery = (curQuery, curTable, curPath, relname, schemas) => {
// eslint-disable-line no-unused-vars
if (curPath.length === 0) {
const expandedIndex = curQuery.columns.findIndex(c => c.name === relname);
const newColumns = [
...curQuery.columns.slice(0, expandedIndex),
...curQuery.columns.slice(expandedIndex + 1),
];
const newStuff = {};
newStuff.columns = newColumns;
if ('name' in curQuery) {
newStuff.name = curQuery.name;
}
// If no other expanded columns are left
if (!newColumns.find(c => typeof c === 'object')) {
if (curQuery.oldStuff) {
['where', 'limit', 'order_by', 'offset'].map(k => {
if (k in curQuery.oldStuff) {
newStuff[k] = curQuery.oldStuff[k];
}
});
}
return { ...newStuff };
}
return { ...curQuery, ...newStuff };
}
const curRelName = curPath[0];
const curRel = curTable.relationships.find(r => r.rel_name === curRelName);
const childTableSchema = findTableFromRel(schemas, curTable, curRel);
const curRelColIndex = curQuery.columns.findIndex(c => c.name === curRelName);
return {
...curQuery,
columns: [
...curQuery.columns.slice(0, curRelColIndex),
closeQuery(
curQuery.columns[curRelColIndex],
childTableSchema,
curPath.slice(1),
relname,
schemas
),
...curQuery.columns.slice(curRelColIndex + 1),
],
};
};
const setActivePath = (activePath, curPath, relname, query) => {
const basePath = relname
? [activePath[0], ...curPath, relname]
: [activePath[0], ...curPath];
// Now check if there are any more children on this path.
// If there are, then we should expand them by default
let subQuery = query;
let subBase = basePath.slice(1);
while (subBase.length > 0) {
subQuery = subQuery.columns.find(c => c.name === subBase[0]); // eslint-disable-line no-loop-func
subBase = subBase.slice(1);
}
subQuery = subQuery.columns.find(c => typeof c === 'object');
while (subQuery) {
basePath.push(subQuery.name);
subQuery = subQuery.columns.find(c => typeof c === 'object');
}
return basePath;
};
const updateActivePathOnClose = (
activePath,
tableName,
curPath,
relname,
query
) => {
const basePath = [tableName, ...curPath, relname];
let subBase = [...basePath];
let subActive = [...activePath];
let matchingFound = false;
let commonIndex = 0;
subBase = subBase.slice(1);
subActive = subActive.slice(1);
while (subActive.length > 0) {
if (subBase[0] === subActive[0]) {
matchingFound = true;
break;
}
subBase = subBase.slice(1);
subActive = subActive.slice(1);
commonIndex += 1;
}
if (matchingFound) {
const newActivePath = activePath.slice(0, commonIndex + 1);
return setActivePath(
newActivePath,
newActivePath.slice(1, -1),
null,
query
);
}
return [...activePath];
};
const addQueryOptsActivePath = (query, queryStuff, activePath) => {
let curPath = activePath.slice(1);
const newQuery = { ...query };
let curQuery = newQuery;
while (curPath.length > 0) {
curQuery = curQuery.columns.find(c => c.name === curPath[0]); // eslint-disable-line no-loop-func
curPath = curPath.slice(1);
}
['where', 'order_by', 'limit', 'offset'].map(k => {
delete curQuery[k];
});
for (const k in queryStuff) {
if (queryStuff.hasOwnProperty(k)) {
curQuery[k] = queryStuff[k];
}
}
return newQuery;
};
/* ****************** reducer ******************/
const RunningEventsReducer = (triggerName, triggerList, viewState, action) => {
if (action.type.indexOf('RunningEvents/FilterQuery/') === 0) {
return {
...viewState,
curFilter: pendingFilterReducer(viewState.curFilter, action),
};
}
const tableSchema = triggerList.find(x => x.name === triggerName);
switch (action.type) {
case V_SET_DEFAULTS:
return {
...defaultViewState,
query: {
columns: [
'*',
{
name: 'events',
columns: [
'*',
{ name: 'logs', columns: ['*'], order_by: ['-created_at'] },
],
where: { delivered: false, error: false },
},
],
limit: 10,
},
activePath: [triggerName],
rows: [],
count: null,
};
case V_SET_QUERY_OPTS:
return {
...viewState,
query: addQueryOptsActivePath(
viewState.query,
action.queryStuff,
viewState.activePath
),
};
case V_EXPAND_REL:
return {
...viewState,
query: expandQuery(
viewState.query,
tableSchema,
action.pk,
action.path,
action.relname,
triggerList
),
activePath: [...viewState.activePath, action.relname],
};
case V_CLOSE_REL:
const _query = closeQuery(
viewState.query,
tableSchema,
action.path,
action.relname,
triggerList
);
return {
...viewState,
query: _query,
activePath: updateActivePathOnClose(
viewState.activePath,
triggerName,
action.path,
action.relname,
_query
),
};
case V_SET_ACTIVE:
return {
...viewState,
activePath: setActivePath(
viewState.activePath,
action.path,
action.relname,
viewState.query
),
};
case V_REQUEST_SUCCESS:
return { ...viewState, rows: action.data, count: action.count };
case V_REQUEST_PROGRESS:
return { ...viewState, isProgressing: action.data };
case V_EXPAND_ROW:
return {
...viewState,
expandedRow: action.data,
};
case V_COLLAPSE_ROW:
return {
...viewState,
expandedRow: '',
};
default:
return viewState;
}
};
export default RunningEventsReducer;
export {
vSetDefaults,
vMakeRequest,
vExpandRel,
vCloseRel,
vExpandRow,
vCollapseRow,
V_SET_ACTIVE,
};

View File

@ -0,0 +1,403 @@
import React from 'react';
import ReactTable from 'react-table';
import AceEditor from 'react-ace';
import 'brace/mode/json';
import Tabs from 'react-bootstrap/lib/Tabs';
import Tab from 'react-bootstrap/lib/Tab';
import 'react-table/react-table.css';
import { deleteItem, vExpandRow, vCollapseRow } from './ViewActions'; // eslint-disable-line no-unused-vars
import FilterQuery from './FilterQuery';
import {
setOrderCol,
setOrderType,
removeOrder,
runQuery,
setOffset,
setLimit,
addOrder,
} from './FilterActions';
import { ordinalColSort } from '../utils';
import Spinner from '../../../Common/Spinner/Spinner';
import '../TableCommon/ReactTableFix.css';
const ViewRows = ({
curTriggerName,
curQuery,
curFilter,
curRows,
curPath,
curDepth,
activePath,
triggerList,
dispatch,
isProgressing,
isView,
count,
expandedRow,
}) => {
const styles = require('../TableCommon/Table.scss');
const triggerSchema = triggerList.find(x => x.name === curTriggerName);
const curRelName = curPath.length > 0 ? curPath.slice(-1)[0] : null;
// Am I a single row display
const isSingleRow = false;
// Get the headings
const tableHeadings = [];
const gridHeadings = [];
const eventLogColumns = ['id', 'delivered', 'created_at'];
const sortedColumns = eventLogColumns.sort(ordinalColSort);
sortedColumns.map((column, i) => {
tableHeadings.push(<th key={i}>{column}</th>);
gridHeadings.push({
Header: column,
accessor: column,
});
});
const hasPrimaryKeys = true;
/*
let editButton;
let deleteButton;
*/
const newCurRows = [];
if (curRows && curRows[0] && curRows[0].events) {
curRows[0].events.forEach((row, rowIndex) => {
const newRow = {};
const pkClause = {};
if (!isView && hasPrimaryKeys) {
pkClause.id = row.id;
} else {
triggerSchema.map(k => {
pkClause[k] = row[k];
});
}
/*
if (!isSingleRow && !isView && hasPrimaryKeys) {
deleteButton = (
<button
className={`${styles.add_mar_right_small} btn btn-xs btn-default`}
onClick={() => {
dispatch(deleteItem(pkClause));
}}
data-test={`row-delete-button-${rowIndex}`}
>
Delete
</button>
);
}
const buttonsDiv = (
<div className={styles.tableCellCenterAligned}>
{editButton}
{deleteButton}
</div>
);
*/
// Insert Edit, Delete, Clone in a cell
// newRow.actions = buttonsDiv;
// Insert cells corresponding to all rows
sortedColumns.forEach(col => {
const getCellContent = () => {
let conditionalClassname = styles.tableCellCenterAligned;
const cellIndex = `${curTriggerName}-${col}-${rowIndex}`;
if (expandedRow === cellIndex) {
conditionalClassname = styles.tableCellCenterAlignedExpanded;
}
if (row[col] === null) {
return (
<div className={conditionalClassname}>
<i>NULL</i>
</div>
);
}
let content = row[col] === undefined ? 'NULL' : row[col].toString();
if (col === 'created_at') {
content = new Date(row[col]).toUTCString();
}
return <div className={conditionalClassname}>{content}</div>;
};
newRow[col] = getCellContent();
});
newCurRows.push(newRow);
});
}
// Is this ViewRows visible
let isVisible = false;
if (!curRelName) {
isVisible = true;
} else if (curRelName === activePath[curDepth]) {
isVisible = true;
}
let filterQuery = null;
if (!isSingleRow) {
if (curRelName === activePath[curDepth] || curDepth === 0) {
// Rendering only if this is the activePath or this is the root
let wheres = [{ '': { '': '' } }];
if ('where' in curFilter && '$and' in curFilter.where) {
wheres = [...curFilter.where.$and];
}
let orderBy = [{ column: '', type: 'asc', nulls: 'last' }];
if ('order_by' in curFilter) {
orderBy = [...curFilter.order_by];
}
const limit = 'limit' in curFilter ? curFilter.limit : 10;
const offset = 'offset' in curFilter ? curFilter.offset : 0;
filterQuery = (
<FilterQuery
curQuery={curQuery}
whereAnd={wheres}
triggerSchema={triggerSchema}
orderBy={orderBy}
limit={limit}
dispatch={dispatch}
count={count}
triggerName={curTriggerName}
offset={offset}
/>
);
}
}
const sortByColumn = col => {
// Remove all the existing order_bys
const numOfOrderBys = curFilter.order_by.length;
for (let i = 0; i < numOfOrderBys - 1; i++) {
dispatch(removeOrder(1));
}
// Go back to the first page
dispatch(setOffset(0));
// Set the filter and run query
dispatch(setOrderCol(col, 0));
if (
curFilter.order_by.length !== 0 &&
curFilter.order_by[0].column === col &&
curFilter.order_by[0].type === 'asc'
) {
dispatch(setOrderType('desc', 0));
} else {
dispatch(setOrderType('asc', 0));
}
dispatch(runQuery(triggerSchema));
// Add a new empty filter
dispatch(addOrder());
};
const changePage = page => {
if (curFilter.offset !== page * curFilter.limit) {
dispatch(setOffset(page * curFilter.limit));
dispatch(runQuery(triggerSchema));
}
};
const changePageSize = size => {
if (curFilter.size !== size) {
dispatch(setLimit(size));
dispatch(runQuery(triggerSchema));
}
};
const renderTableBody = () => {
if (isProgressing) {
return (
<div>
{' '}
<Spinner />{' '}
</div>
);
} else if (count === 0) {
return <div> No rows found. </div>;
}
let shouldSortColumn = true;
const invocationColumns = ['status', 'id', 'created_at'];
const invocationGridHeadings = [];
invocationColumns.map(column => {
invocationGridHeadings.push({
Header: column,
accessor: column,
});
});
return (
<ReactTable
className="-highlight"
data={newCurRows}
columns={gridHeadings}
resizable
manual
sortable={false}
minRows={0}
getTheadThProps={(finalState, some, column) => ({
onClick: () => {
if (
column.Header &&
shouldSortColumn &&
column.Header !== 'Actions'
) {
sortByColumn(column.Header);
}
shouldSortColumn = true;
},
})}
getResizerProps={(finalState, none, column, ctx) => ({
onMouseDown: e => {
shouldSortColumn = false;
ctx.resizeColumnStart(e, column, false);
},
})}
showPagination={count > curFilter.limit}
defaultPageSize={Math.min(curFilter.limit, count)}
pages={Math.ceil(count / curFilter.limit)}
onPageChange={changePage}
onPageSizeChange={changePageSize}
page={Math.floor(curFilter.offset / curFilter.limit)}
SubComponent={row => {
const currentIndex = row.index;
const currentRow = curRows[0].events[currentIndex];
const invocationRowsData = [];
currentRow.logs.map((r, rowIndex) => {
const newRow = {};
const status =
r.status === 200 ? (
<i className={styles.invocationSuccess + ' fa fa-check'} />
) : (
<i className={styles.invocationFailure + ' fa fa-times'} />
);
// Insert cells corresponding to all rows
invocationColumns.forEach(col => {
const getCellContent = () => {
let conditionalClassname = styles.tableCellCenterAligned;
const cellIndex = `${curTriggerName}-${col}-${rowIndex}`;
if (expandedRow === cellIndex) {
conditionalClassname = styles.tableCellCenterAlignedExpanded;
}
if (r[col] === null) {
return (
<div className={conditionalClassname}>
<i>NULL</i>
</div>
);
}
if (col === 'status') {
return status;
}
if (col === 'created_at') {
const formattedDate = new Date(r.created_at).toUTCString();
return formattedDate;
}
const content =
r[col] === undefined ? 'NULL' : r[col].toString();
return <div className={conditionalClassname}>{content}</div>;
};
newRow[col] = getCellContent();
});
invocationRowsData.push(newRow);
});
return (
<div style={{ padding: '20px' }}>
<em>Recent Invocations</em>
<div
className={styles.invocationsSection + ' invocationsSection'}
>
{invocationRowsData.length ? (
<ReactTable
data={invocationRowsData}
columns={invocationGridHeadings}
defaultPageSize={currentRow.logs.length}
showPagination={false}
SubComponent={logRow => {
const finalIndex = logRow.index;
const currentPayload = JSON.stringify(
currentRow.payload,
null,
4
);
const finalRow = currentRow.logs[finalIndex];
const finalResponse = JSON.parse(
JSON.stringify(finalRow.response, null, 4)
);
return (
<div style={{ padding: '20px' }}>
<Tabs
animation={false}
defaultActiveKey={1}
id="requestResponseTab"
>
<Tab eventKey={1} title="Request">
<div className={styles.add_mar_top}>
<div className={styles.subheading_text}>
Request
</div>
<AceEditor
mode="json"
theme="github"
name="payload"
value={currentPayload}
minLines={4}
maxLines={100}
width="100%"
showPrintMargin={false}
showGutter={false}
/>
</div>
</Tab>
<Tab eventKey={2} title="Response">
<div className={styles.add_mar_top}>
<div className={styles.subheading_text}>
Response
</div>
<AceEditor
mode="json"
theme="github"
name="response"
value={finalResponse}
minLines={4}
maxLines={100}
width="100%"
showPrintMargin={false}
showGutter={false}
/>
</div>
</Tab>
</Tabs>
</div>
);
}}
/>
) : (
<div className={styles.add_mar_top}>No data available</div>
)}
</div>
<br />
<br />
</div>
);
}}
/>
);
};
return (
<div className={isVisible ? '' : 'hide '}>
{filterQuery}
<hr />
<div className="row">
<div className="col-xs-12">
<div className={styles.tableContainer + ' eventsTableBody'}>
{renderTableBody()}
</div>
<br />
<br />
</div>
</div>
</div>
);
};
export default ViewRows;

View File

@ -0,0 +1,214 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { vSetDefaults, vMakeRequest, vExpandHeading } from './ViewActions'; // eslint-disable-line no-unused-vars
import { setTrigger } from '../EventActions';
import TableHeader from '../TableCommon/TableHeader';
import ViewRows from './ViewRows';
import { replace } from 'react-router-redux';
const genHeadings = headings => {
if (headings.length === 0) {
return [];
}
const heading = headings[0];
if (typeof heading === 'string') {
return [heading, ...genHeadings(headings.slice(1))];
}
if (typeof heading === 'object') {
if (!heading._expanded) {
const headingName =
heading.type === 'obj_rel' ? heading.lcol : heading.relname;
return [
{ name: headingName, type: heading.type },
...genHeadings(headings.slice(1)),
];
}
if (heading.type === 'obj_rel') {
const subheadings = genHeadings(heading.headings).map(h => {
if (typeof h === 'string') {
return heading.relname + '.' + h;
}
return heading.relname + '.' + h.name;
});
return [...subheadings, ...genHeadings(headings.slice(1))];
}
}
throw 'Incomplete pattern match'; // eslint-disable-line no-throw-literal
};
const genRow = (row, headings) => {
if (headings.length === 0) {
return [];
}
const heading = headings[0];
if (typeof heading === 'string') {
return [row[heading], ...genRow(row, headings.slice(1))];
}
if (typeof heading === 'object') {
if (!heading._expanded) {
const rowVal = heading.type === 'obj_rel' ? row[heading.lcol] : '[...]';
return [rowVal, ...genRow(row, headings.slice(1))];
}
if (heading.type === 'obj_rel') {
const subrow = genRow(row[heading.relname], heading.headings);
return [...subrow, ...genRow(row, headings.slice(1))];
}
}
throw 'Incomplete pattern match'; // eslint-disable-line no-throw-literal
};
class ViewTable extends Component {
constructor(props) {
super(props);
// Initialize this table
this.state = {
dispatch: props.dispatch,
triggerName: props.triggerName,
};
// this.state.dispatch = props.dispatch;
// this.state.triggerName = props.triggerName;
const dispatch = this.props.dispatch;
Promise.all([
dispatch(setTrigger(this.props.triggerName)),
dispatch(vSetDefaults(this.props.triggerName)),
dispatch(vMakeRequest()),
]);
}
componentWillReceiveProps(nextProps) {
const dispatch = this.props.dispatch;
if (nextProps.triggerName !== this.props.triggerName) {
dispatch(setTrigger(nextProps.triggerName));
dispatch(vSetDefaults(nextProps.triggerName));
dispatch(vMakeRequest());
}
}
shouldComponentUpdate(nextProps) {
return (
this.props.triggerName === null ||
nextProps.triggerName === this.props.triggerName
);
}
componentWillUpdate() {
this.shouldScrollBottom =
window.innerHeight ===
document.body.offsetHeight - document.body.scrollTop;
}
componentDidUpdate() {
if (this.shouldScrollBottom) {
document.body.scrollTop = document.body.offsetHeight - window.innerHeight;
}
}
componentWillUnmount() {
// Remove state data beloging to this table
const dispatch = this.props.dispatch;
dispatch(vSetDefaults(this.props.triggerName));
}
render() {
const {
triggerName,
triggerList,
query,
curFilter,
rows,
count, // eslint-disable-line no-unused-vars
activePath,
migrationMode,
ongoingRequest,
isProgressing,
lastError,
lastSuccess,
dispatch,
expandedRow,
} = this.props; // eslint-disable-line no-unused-vars
// check if table exists
const currentTrigger = triggerList.find(s => s.name === triggerName);
if (!currentTrigger) {
// dispatch a 404 route
dispatch(replace('/404'));
}
// Is this a view
const isView = false;
// Are there any expanded columns
const viewRows = (
<ViewRows
curTriggerName={triggerName}
curQuery={query}
curFilter={curFilter}
curPath={[]}
curRows={rows}
isView={isView}
parentTableName={null}
activePath={activePath}
ongoingRequest={ongoingRequest}
isProgressing={isProgressing}
lastError={lastError}
lastSuccess={lastSuccess}
triggerList={triggerList}
curDepth={0}
count={count}
dispatch={dispatch}
expandedRow={expandedRow}
/>
);
// Choose the right nav bar header thing
const header = (
<TableHeader
count={count}
dispatch={dispatch}
triggerName={triggerName}
tabName="running"
migrationMode={migrationMode}
/>
);
return (
<div>
{header}
<div>{viewRows}</div>
</div>
);
}
}
ViewTable.propTypes = {
triggerName: PropTypes.string.isRequired,
triggerList: PropTypes.array.isRequired,
activePath: PropTypes.array.isRequired,
query: PropTypes.object.isRequired,
curFilter: PropTypes.object.isRequired,
migrationMode: PropTypes.bool.isRequired,
ongoingRequest: PropTypes.bool.isRequired,
isProgressing: PropTypes.bool.isRequired,
rows: PropTypes.array.isRequired,
expandedRow: PropTypes.string.isRequired,
count: PropTypes.number,
lastError: PropTypes.object.isRequired,
lastSuccess: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
};
const mapStateToProps = (state, ownProps) => {
return {
triggerName: ownProps.params.trigger,
triggerList: state.triggers.runningEvents,
migrationMode: state.main.migrationMode,
...state.triggers.view,
};
};
const runningEventsConnector = connect => connect(mapStateToProps)(ViewTable);
export default runningEventsConnector;

View File

@ -0,0 +1,134 @@
/* eslint-disable space-infix-ops */
/* eslint-disable no-loop-func */
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import {
autoTrackRelations,
autoAddRelName,
} from '../TableRelationships/Actions';
import { getRelationshipLine } from '../TableRelationships/Relationships';
import suggestedRelationshipsRaw from '../TableRelationships/autoRelations';
class AutoAddRelations extends Component {
trackAllRelations = () => {
this.props.dispatch(autoTrackRelations());
};
render() {
const { schema, untrackedRelations, dispatch } = this.props;
const styles = require('../PageContainer/PageContainer.scss');
const handleAutoAddIndivRel = obj => {
dispatch(autoAddRelName(obj));
};
if (untrackedRelations.length === 0) {
return (
<div
className={styles.display_inline + ' ' + styles.padd_bottom}
key="no-untracked-rel"
>
There are no untracked relations
</div>
);
}
const untrackedIndivHtml = [];
schema.map(table => {
const currentTable = table.table_name;
const currentTableRel = suggestedRelationshipsRaw(currentTable, schema);
currentTableRel.objectRel.map(obj => {
untrackedIndivHtml.push(
<div
className={styles.padd_top_medium}
key={'untrackedIndiv' + table.table_name}
>
<button
className={`${styles.display_inline} btn btn-xs btn-default`}
onClick={e => {
e.preventDefault();
handleAutoAddIndivRel(obj);
}}
>
Add
</button>
<div className={styles.display_inline + ' ' + styles.add_pad_left}>
<b>{obj.tableName}</b> -{' '}
{getRelationshipLine(
obj.isObjRel,
obj.lcol,
obj.rcol,
obj.rTable
)}
</div>
</div>
);
});
currentTableRel.arrayRel.map(obj => {
untrackedIndivHtml.push(
<div
className={styles.padd_top_medium}
key={'untrackedIndiv' + table.table_name}
>
<button
className={`${styles.display_inline} btn btn-xs btn-default`}
onClick={e => {
e.preventDefault();
handleAutoAddIndivRel(obj);
}}
>
Add
</button>
<div className={styles.display_inline + ' ' + styles.add_pad_left}>
<b>{obj.tableName}</b> -{' '}
{getRelationshipLine(
obj.isObjRel,
obj.lcol,
obj.rcol,
obj.rTable
)}
</div>
</div>
);
});
});
return (
<div>
{untrackedRelations.length === 0 ? (
<div
className={styles.display_inline + ' ' + styles.padd_bottom}
key="no-untracked-rel"
>
There are no untracked relations
</div>
) : (
<div
className={styles.display_inline + ' ' + styles.padd_bottom}
key="untracked-rel"
>
There are {untrackedRelations.length} untracked relations
</div>
)}
<button
onClick={this.trackAllRelations}
className={
styles.display_inline +
' btn btn-xs btn-default ' +
styles.add_mar_left
}
data-test="track-all-relationships"
>
Track All Relations
</button>
<div className={styles.padd_top_small}>{untrackedIndivHtml}</div>
</div>
);
}
}
AutoAddRelations.propTypes = {
untrackedRelations: PropTypes.array.isRequired,
schema: PropTypes.array.isRequired,
dispatch: PropTypes.func.isRequired,
};
export default AutoAddRelations;

View File

@ -0,0 +1,80 @@
/* eslint-disable space-infix-ops */
/* eslint-disable no-loop-func */
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Helmet from 'react-helmet';
import { push } from 'react-router-redux';
import { loadTriggers } from '../EventActions';
import globals from '../../../../Globals';
const appPrefix = globals.urlPrefix + '/events';
class Schema extends Component {
constructor(props) {
super(props);
// Initialize this table
const dispatch = this.props.dispatch;
dispatch(loadTriggers());
}
render() {
const { migrationMode, dispatch } = this.props;
const styles = require('../PageContainer/PageContainer.scss');
return (
<div
className={`${styles.padd_left_remove} container-fluid ${
styles.padd_top
}`}
>
<div className={styles.padd_left}>
<Helmet title="Event Triggers | Hasura" />
<div>
<h2 className={`${styles.heading_text} ${styles.inline_block}`}>
{' '}
Event Triggers{' '}
</h2>
{migrationMode ? (
<button
data-test="data-create-trigger"
className={styles.yellow_button}
onClick={e => {
e.preventDefault();
dispatch(push(`${appPrefix}/manage/triggers/add`));
}}
>
Create
</button>
) : null}
</div>
<hr />
</div>
</div>
);
}
}
Schema.propTypes = {
schema: PropTypes.array.isRequired,
untracked: PropTypes.array.isRequired,
untrackedRelations: PropTypes.array.isRequired,
migrationMode: PropTypes.bool.isRequired,
currentSchema: PropTypes.string.isRequired,
dispatch: PropTypes.func.isRequired,
};
const mapStateToProps = state => ({
schema: state.tables.allSchemas,
schemaList: state.tables.schemaList,
untracked: state.tables.untrackedSchemas,
migrationMode: state.main.migrationMode,
untrackedRelations: state.tables.untrackedRelations,
currentSchema: state.tables.currentSchema,
});
const schemaConnector = connect => connect(mapStateToProps)(Schema);
export default schemaConnector;

View File

@ -0,0 +1,35 @@
import React from 'react';
import Helmet from 'react-helmet';
// import PageContainer from '../PageContainer/PageContainer';
const SchemaContainer = ({ children }) => {
const styles = require('./SchemaContainer.scss');
return (
<div className={styles.container + ' container-fluid'}>
<Helmet title={'Schema | Data | Hasura'} />
<div className="row">
<div
className={
styles.main + ' ' + styles.padd_left_remove + ' ' + styles.padd_top
}
>
<div className={styles.rightBar + ' '}>
{children && React.cloneElement(children)}
</div>
</div>
</div>
</div>
);
};
const mapStateToProps = state => {
return {
schema: state.tables.allSchemas,
};
};
const schemaContainerConnector = connect =>
connect(mapStateToProps)(SchemaContainer);
export default schemaContainerConnector;

View File

@ -0,0 +1,63 @@
@import "~bootstrap-sass/assets/stylesheets/bootstrap/variables";
@import "../../../Common/Common.scss";
.flexRow {
display: flex;
}
.padd_left_remove
{
padding-left: 0;
}
.add_btn {
margin: 10px 0;
}
.account {
padding: 20px 0;
line-height: 26px;
}
.sidebar {
height: $mainContainerHeight;
overflow: auto;
background: #444;
color: $navbar-inverse-color;
hr {
margin: 0;
border-color: $navbar-inverse-color;
}
ul {
list-style-type: none;
padding-top: 10px;
padding-left: 7px;
li {
padding: 7px 0;
transition: color 0.5s;
a,a:visited {
color: $navbar-inverse-link-color;
}
a:hover {
color: $navbar-inverse-link-hover-color;
text-decoration: none;
}
}
li:hover {
padding: 7px 0;
color: $navbar-inverse-link-hover-color;
transition: color 0.5s;
pointer: cursor;
}
}
}
.main {
padding: 0;
padding-left: 15px;
height: $mainContainerHeight;
overflow: auto;
padding-right: 15px;
}
.rightBar {
padding-left: 15px;
}

View File

@ -0,0 +1,34 @@
import React from 'react';
import Tooltip from 'react-bootstrap/lib/Tooltip';
export const dataAPI = (
<Tooltip id="tooltip-data-service">
To use Data APIs for complex joins/queries, create a view and then add the
view here.
</Tooltip>
);
export const untrackedTip = (
<Tooltip id="tooltip-data-service">
Tables or views that are not exposed over GraphQL
</Tooltip>
);
export const untrackedRelTip = (
<Tooltip id="tooltip-data-rel-service">
Foreign keys between tracked tables that are not relationships
</Tooltip>
);
export const quickDefaultPublic = (
<Tooltip id="tooltip-permission-public">
The selected role can perform select, insert, update and delete on all rows
of the table.
</Tooltip>
);
export const quickDefaultReadOnly = (
<Tooltip id="tooltip-permission-read">
The selected role can perform select on all rows of the table.
</Tooltip>
);

View File

@ -0,0 +1,110 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import TableHeader from '../TableCommon/TableHeader';
import { deleteTrigger } from '../EventActions';
class Settings extends Component {
render() {
const {
triggerName,
triggerList,
migrationMode,
count,
dispatch,
} = this.props;
const styles = require('../TableCommon/Table.scss');
let triggerSchema = triggerList.filter(
elem => elem.name === triggerName
)[0];
triggerSchema = triggerSchema ? triggerSchema : {};
const handleDeleteTrigger = () => {
dispatch(deleteTrigger(triggerName));
};
return (
<div className={styles.container + ' container-fluid'}>
<TableHeader
count={count}
dispatch={dispatch}
triggerName={triggerName}
tabName="settings"
migrationMode={migrationMode}
/>
<br />
<div>
<div className={styles.settingsSection}>
<table className="table table-striped table-bordered">
<thead />
<tbody>
<tr>
<td>Webhook URL</td>
<td>{triggerSchema.webhook}</td>
</tr>
<tr>
<td>Table</td>
<td>{triggerSchema.table_name}</td>
</tr>
<tr>
<td>Schema</td>
<td>{triggerSchema.schema_name}</td>
</tr>
<tr>
<td>Event Type</td>
<td>{triggerSchema.type}</td>
</tr>
<tr>
<td>Number of Retries</td>
<td>{triggerSchema.num_retries}</td>
</tr>
<tr>
<td>Retry Interval</td>
<td>
{triggerSchema.retry_interval}{' '}
{triggerSchema.retry_interval > 1 ? 'seconds' : 'second'}
</td>
</tr>
<tr>
<td>Operation / Columns</td>
<td>{JSON.stringify(triggerSchema.definition, null, 4)}</td>
</tr>
</tbody>
</table>
</div>
<div className={styles.add_mar_bottom}>
<button
onClick={handleDeleteTrigger}
className={'btn btn-sm btn-danger'}
data-test="delete-trigger"
>
Delete Trigger
</button>
</div>
</div>
<br />
<br />
</div>
);
}
}
Settings.propTypes = {
tableName: PropTypes.string.isRequired,
triggerList: PropTypes.array,
migrationMode: PropTypes.bool.isRequired,
dispatch: PropTypes.func.isRequired,
};
const mapStateToProps = (state, ownProps) => {
return {
...state.triggers,
triggerName: ownProps.params.trigger,
migrationMode: state.main.migrationMode,
currentSchema: state.tables.currentSchema,
};
};
const settingsConnector = connect => connect(mapStateToProps)(Settings);
export default settingsConnector;

View File

@ -0,0 +1,115 @@
import { defaultLogState } from '../EventState';
import Endpoints, { globalCookiePolicy } from '../../../../Endpoints';
import requestAction from 'utils/requestAction';
import dataHeaders from '../Common/Headers';
/* ****************** View actions *************/
const V_SET_DEFAULTS = 'StreamingLogs/V_SET_DEFAULTS';
const V_REQUEST_SUCCESS = 'StreamingLogs/V_REQUEST_SUCCESS';
const V_REQUEST_ERROR = 'StreamingLogs/V_REQUEST_ERROR';
const V_REQUEST_PROGRESS = 'StreamingLogs/V_REQUEST_PROGRESS';
/* ****************** action creators *************/
const vSetDefaults = () => ({ type: V_SET_DEFAULTS });
const vMakeRequest = triggerName => {
return (dispatch, getState) => {
const state = getState();
const url = Endpoints.query;
const originalTrigger = getState().triggers.currentTrigger;
dispatch({ type: V_REQUEST_PROGRESS, data: true });
const currentQuery = JSON.parse(JSON.stringify(state.triggers.log.query));
// count query
const countQuery = JSON.parse(JSON.stringify(state.triggers.log.query));
countQuery.columns = ['id'];
currentQuery.where = { event: { trigger_name: triggerName } };
// order_by for relationship
currentQuery.order_by = ['-created_at'];
const requestBody = {
type: 'bulk',
args: [
{
type: 'select',
args: {
...currentQuery,
table: {
name: 'event_invocation_logs',
schema: 'hdb_catalog',
},
},
},
{
type: 'count',
args: {
...countQuery,
table: {
name: 'event_invocation_logs',
schema: 'hdb_catalog',
},
},
},
],
};
const options = {
method: 'POST',
body: JSON.stringify(requestBody),
headers: dataHeaders(getState),
credentials: globalCookiePolicy,
};
return dispatch(requestAction(url, options)).then(
data => {
const currentTrigger = getState().triggers.currentTrigger;
if (originalTrigger === currentTrigger) {
Promise.all([
dispatch({
type: V_REQUEST_SUCCESS,
data: data[0],
count: data[1].count,
}),
dispatch({ type: V_REQUEST_PROGRESS, data: false }),
]);
}
},
error => {
dispatch({ type: V_REQUEST_ERROR, data: error });
}
);
};
};
/* ****************** reducer ******************/
const streamingLogsReducer = (triggerName, triggerList, logState, action) => {
switch (action.type) {
case V_SET_DEFAULTS:
return {
...defaultLogState,
query: {
columns: [
'*',
{
name: 'event',
columns: ['*'],
},
],
limit: 20,
where: { event: { trigger_name: triggerName } },
},
activePath: [triggerName],
rows: [],
count: null,
};
case V_REQUEST_SUCCESS:
return { ...logState, rows: action.data, count: action.count };
case V_REQUEST_PROGRESS:
return { ...logState, isProgressing: action.data };
default:
return logState;
}
};
export default streamingLogsReducer;
export { vSetDefaults, vMakeRequest };

View File

@ -0,0 +1,214 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ReactTable from 'react-table';
import AceEditor from 'react-ace';
import Tabs from 'react-bootstrap/lib/Tabs';
import Tab from 'react-bootstrap/lib/Tab';
import TableHeader from '../TableCommon/TableHeader';
import { loadEventLogs } from '../EventActions';
import { vMakeRequest, vSetDefaults } from './LogActions';
class StreamingLogs extends Component {
constructor(props) {
super(props);
this.state = { isWatching: false, intervalId: null };
this.refreshData = this.refreshData.bind(this);
}
componentDidMount() {
this.props.dispatch(loadEventLogs(this.props.triggerName));
}
componentWillUnmount() {
this.props.dispatch(vSetDefaults());
}
watchChanges() {
// set state on watch
this.setState({ isWatching: !this.state.isWatching });
if (this.state.isWatching) {
clearInterval(this.state.intervalId);
} else {
const intervalId = setInterval(this.refreshData, 2000);
this.setState({ intervalId: intervalId });
}
}
refreshData() {
this.props.dispatch(vMakeRequest(this.props.triggerName));
}
render() {
const { triggerName, migrationMode, log, count, dispatch } = this.props;
const styles = require('../TableCommon/Table.scss');
const invocationColumns = [
'status',
'invocation_id',
'event_id',
'created_at',
];
const invocationGridHeadings = [];
invocationColumns.map(column => {
invocationGridHeadings.push({
Header: column,
accessor: column,
});
});
const invocationRowsData = [];
log.rows.map((r, rowIndex) => {
const newRow = {};
const status =
r.status === 200 ? (
<i className={styles.invocationSuccess + ' fa fa-check'} />
) : (
<i className={styles.invocationFailure + ' fa fa-times'} />
);
// Insert cells corresponding to all rows
invocationColumns.forEach(col => {
const getCellContent = () => {
const conditionalClassname = styles.tableCellCenterAligned;
if (r[col] === null) {
return (
<div className={conditionalClassname}>
<i>NULL</i>
</div>
);
}
if (col === 'status') {
return <div className={conditionalClassname}>{status}</div>;
}
if (col === 'invocation_id') {
return <div className={conditionalClassname}>{r.id}</div>;
}
if (col === 'created_at') {
const formattedDate = new Date(r.created_at).toUTCString();
return formattedDate;
}
const content = r[col] === undefined ? 'NULL' : r[col].toString();
return <div className={conditionalClassname}>{content}</div>;
};
newRow[col] = getCellContent();
});
invocationRowsData.push(newRow);
});
return (
<div className={styles.container + ' container-fluid'}>
<TableHeader
count={count}
dispatch={dispatch}
triggerName={triggerName}
tabName="logs"
migrationMode={migrationMode}
/>
<br />
<div>
<button
onClick={this.watchChanges.bind(this)}
className={' btn btn-default'}
data-test="run-query"
>
{this.state.isWatching ? (
<span>
Streaming... <i className={'fa fa-spinner fa-spin'} />
</span>
) : (
'Stream Logs'
)}
</button>
</div>
{invocationRowsData.length ? (
<div className={styles.streamingLogs + ' streamingLogs'}>
<ReactTable
data={invocationRowsData}
columns={invocationGridHeadings}
showPagination={false}
pageSize={invocationRowsData.length}
SubComponent={logRow => {
const finalIndex = logRow.index;
const finalRow = log.rows[finalIndex];
const currentPayload = JSON.stringify(
finalRow.event.payload,
null,
4
);
// check if response is type JSON
let finalResponse = finalRow.response;
try {
finalResponse = JSON.parse(finalRow.response);
finalResponse = JSON.stringify(finalResponse, null, 4);
} catch (e) {
console.error(e);
}
return (
<div style={{ padding: '20px' }}>
<Tabs
animation={false}
defaultActiveKey={1}
id="requestResponseTab"
>
<Tab eventKey={1} title="Request">
<div className={styles.add_mar_top}>
<div className={styles.subheading_text}>Request</div>
<AceEditor
mode="json"
theme="github"
name="payload"
value={currentPayload}
minLines={4}
maxLines={100}
width="100%"
showPrintMargin={false}
showGutter={false}
/>
</div>
</Tab>
<Tab eventKey={2} title="Response">
<div className={styles.add_mar_top}>
<div className={styles.subheading_text}>Response</div>
<AceEditor
mode="json"
theme="github"
name="response"
value={finalResponse}
minLines={4}
maxLines={100}
width="100%"
showPrintMargin={false}
showGutter={false}
/>
</div>
</Tab>
</Tabs>
</div>
);
}}
/>
</div>
) : (
<div className={styles.add_mar_top}>No data available</div>
)}
<br />
<br />
</div>
);
}
}
StreamingLogs.propTypes = {
log: PropTypes.object,
migrationMode: PropTypes.bool.isRequired,
dispatch: PropTypes.func.isRequired,
};
const mapStateToProps = (state, ownProps) => {
return {
...state.triggers,
triggerName: ownProps.params.trigger,
migrationMode: state.main.migrationMode,
currentSchema: state.tables.currentSchema,
};
};
const streamingLogsConnector = connect =>
connect(mapStateToProps)(StreamingLogs);
export default streamingLogsConnector;

View File

@ -0,0 +1,139 @@
.ReactTable .-pagination .-btn {
height: 35px !important;
width: 40%;
display: inline-block;
font-weight: bold;
}
.ReactTable .rt-thead {
font-size: 16px;
color: #333;
background-color: #ddd;
cursor: pointer;
}
.ReactTable .rt-thead .rt-resizable-header-content {
font-weight: bold;
}
.ReactTable .rt-thead.-header {
box-shadow: none;
}
.ReactTable .-pagination {
box-shadow: none;
}
.ReactTable .rt-thead .rt-th,
.ReactTable .rt-thead .rt-td {
padding: 10px !important;
}
.ReactTable .rt-table .rt-thead .rt-tr .rt-th {
background-color: #f2f2f2 !important;
color: #4d4d4d;
font-weight: 600 !important;
border-bottom: 2px solid #ddd;
}
.eventsTableBody .ReactTable .rt-table .rt-thead .rt-tr .rt-th:first-child {
flex: 24 0 auto !important;
min-width: 75px;
}
.eventsTableBody
.ReactTable
.rt-table
.rt-tbody
.rt-tr-group
.rt-tr.-odd
.rt-td:first-child {
flex: 24 0 auto !important;
min-width: 75px;
}
.eventsTableBody
.ReactTable
.rt-table
.rt-tbody
.rt-tr-group
.rt-tr.-even
.rt-td:first-child {
flex: 24 0 auto !important;
min-width: 75px;
}
.ReactTable .rt-table .rt-tbody .rt-tr-group .rt-tr.-odd:hover {
background-color: #ebf7de;
}
.ReactTable .rt-table .rt-tbody .rt-tr-group .rt-tr.-even:hover {
background-color: #ebf7de;
}
.ReactTable .rt-tbody {
overflow-x: hidden !important;
}
.ReactTable .rt-thead [role='columnheader'] {
outline: 0;
}
.invocationsSection .ReactTable .rt-tbody .rt-td {
text-align: center;
}
.invocationsSection .ReactTable .rt-table .rt-thead .rt-tr .rt-th:nth-child(2) {
width: 100px;
max-width: 100px;
}
.invocationsSection .ReactTable .rt-table .rt-td:nth-child(2) {
width: 100px;
max-width: 100px;
}
.invocationsSection .ReactTable {
width: 70%;
}
.invocationsSection .ReactTable .rt-thead.-header {
display: none;
}
.streamingLogs .ReactTable .rt-thead.-header {
// display: none;
}
.streamingLogs .ReactTable .rt-table .rt-thead .rt-tr .rt-th:nth-child(2) {
width: 100px;
max-width: 100px;
}
.streamingLogs .ReactTable .rt-table .rt-td:nth-child(2) {
width: 100px;
max-width: 100px;
}
.streamingLogs .ReactTable .rt-table .rt-thead .rt-tr .rt-th:first-child {
flex: 24 0 auto !important;
min-width: 75px;
max-width: 75px;
}
.streamingLogs
.ReactTable
.rt-table
.rt-tbody
.rt-tr-group
.rt-tr.-odd
.rt-td:first-child {
flex: 24 0 auto !important;
min-width: 75px;
max-width: 75px;
}
.streamingLogs
.ReactTable
.rt-table
.rt-tbody
.rt-tr-group
.rt-tr.-even
.rt-td:first-child {
flex: 24 0 auto !important;
min-width: 75px;
max-width: 75px;
}
#requestResponseTab ul li a {
color: black;
}

View File

@ -0,0 +1,259 @@
@import "../../../Common/Common.scss";
.container {
padding: 0;
}
.tableContainer {
overflow: auto;
//margin-top: 25px;
}
.schemaWrapper {
background: #FFF3D5;
width: 100%;
.schemaSidebarSection {
display: inline-block;
width: 40%;
}
}
.aceBlock {
max-height: 300px;
position: relative;
margin-top: 10px;
}
.aceBlockExpand {
position: absolute;
top: -25px;
right: 5px;
z-index: 1;
cursor: pointer;
}
.viewRowsContainer {
border-left: 1px solid #ddd;
}
.cellExpand {
transform: rotate(-45deg);
cursor: pointer;
top: -5px;
left: 0px;
right: 0px;
}
.cellCollapse {
cursor: pointer;
top: -5px;
left: 0px;
right: 0px;
padding-right: 5px;
}
.insertContainer {
button {
margin: 0 10px;
}
label.radioLabel {
padding-top: 0;
input[type="radio"] {
margin-top: 10px;
}
}
span.radioSpan {
line-height: 34px;
}
}
.table {
width: auto;
thead {
background: #333;
color: #ddd;
}
th {
min-width: 100px;
font-weight: 300;
}
td {
max-width: 300px;
white-space: pre;
overflow: hidden;
text-overflow: ellipsis;
}
}
.expandable {
color: #779ecb;
text-decoration: underline;
}
a.expanded {
color: red;
}
.expandable:hover {
background: #eee;
transition: 0.2s;
cursor: pointer;
}
.tableNameInput {
width: 300px;
}
.addCol {
.input {
width: 300px;
display: inline-block;
}
.inputCheckbox {
width: auto;
display: inline-block;
padding-left: 20px;
margin: 0px 20px;
box-shadow: none;
}
.inputDefault
{
width: 150px;
margin: 0px 20px;
display: inline-block;
}
.remove_ul_left
{
}
.select {
display: inline-block;
width: 300px;
height: 34px;
}
i:hover {
cursor: pointer;
color: #B85C27;
transition: 0.2s;
}
}
.insertBox {
min-width: 270px;
}
.insertBoxLabel {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sqlBody {
}
.tablesBody {
padding-top: 20px;
}
.count {
margin-top: 15px;
}
.addTablesBody {
padding-top: 20px;
padding-bottom: 30px;
}
.dataBreadCrumb {
padding-bottom: 10px;
}
.tableCellCenterAligned {
text-align: center;
}
.tableCellCenterAlignedExpanded {
text-align: center;
white-space: normal;
}
.selectTrigger {
width: 300px;
display: inline-block;
}
.settingsSection {
table {
width: 80%;
margin-top: 20px;
}
}
.advancedOperations {
hr {
margin-top: 10px;
margin-bottom: 10px;
}
margin-bottom: 10px;
label {
cursor: pointer;
}
}
.retryLabel {
display: block;
margin-bottom: 10px !important;
}
.retrySection {
width: 300px;
margin-right: 20px;
}
.invocationsSection {
margin-top: 20px;
li {
display: block;
padding: 5px 10px;
margin-right: -10px;
margin-left: -10px;
line-height: 23px;
border-bottom: 1px solid #e1e4e8;
}
.invocationSuccess {
color: #28a745
}
.invocationFailure {
color: red;
}
}
.streamingLogs {
margin-top: 20px;
li {
display: block;
padding: 5px 10px;
margin-right: -10px;
margin-left: -10px;
line-height: 23px;
border-bottom: 1px solid #e1e4e8;
}
.invocationSuccess {
color: #28a745;
}
.invocationFailure {
color: red;
}
}
.selectOperations {
label {
cursor: pointer;
}
}
.advancedToggleBtn {
width: 300px;
background: #f2f2f2 !important;
i {
padding-left: 5px;
}
}

View File

@ -0,0 +1,100 @@
import React from 'react';
import { Link } from 'react-router';
import Helmet from 'react-helmet';
const TableHeader = ({ triggerName, tabName, count }) => {
const styles = require('./Table.scss');
let capitalised = tabName;
capitalised = capitalised[0].toUpperCase() + capitalised.slice(1);
let showCount = '';
if (!(count === null || count === undefined)) {
showCount = '(' + count + ')';
}
let activeTab;
if (tabName === 'processed') {
activeTab = 'Processed';
} else if (tabName === 'pending') {
activeTab = 'Pending';
} else if (tabName === 'settings') {
activeTab = 'Settings';
}
return (
<div>
<Helmet
title={capitalised + ' - ' + triggerName + ' - Event Triggers | Hasura'}
/>
<div className={styles.subHeader}>
<div className={styles.dataBreadCrumb}>
You are here: <Link to={'/events'}>Events</Link>{' '}
<i className="fa fa-angle-right" aria-hidden="true" />{' '}
<Link to={'/events/manage'}>Manage</Link>{' '}
<i className="fa fa-angle-right" aria-hidden="true" />{' '}
<Link to={'/events/manage/triggers'}>Triggers</Link>{' '}
<i className="fa fa-angle-right" aria-hidden="true" />{' '}
<Link to={'/events/manage/triggers/' + triggerName + '/processed'}>
{triggerName}
</Link>{' '}
<i className="fa fa-angle-right" aria-hidden="true" /> {activeTab}
</div>
<h2 className={styles.heading_text}>{triggerName}</h2>
<div className={styles.nav}>
<ul className="nav nav-pills">
<li
role="presentation"
className={tabName === 'processed' ? styles.active : ''}
>
<Link
to={'/events/manage/triggers/' + triggerName + '/processed'}
data-test="trigger-processed-events"
>
Processed {tabName === 'processed' ? showCount : null}
</Link>
</li>
<li
role="presentation"
className={tabName === 'pending' ? styles.active : ''}
>
<Link
to={'/events/manage/triggers/' + triggerName + '/pending'}
data-test="trigger-pending-events"
>
Pending {tabName === 'pending' ? showCount : null}
</Link>
</li>
<li
role="presentation"
className={tabName === 'running' ? styles.active : ''}
>
<Link
to={'/events/manage/triggers/' + triggerName + '/running'}
data-test="trigger-running-events"
>
Running {tabName === 'running' ? showCount : null}
</Link>
</li>
<li
role="presentation"
className={tabName === 'logs' ? styles.active : 'hide'}
data-test="trigger-logs"
>
<Link to={'/events/manage/triggers/' + triggerName + '/logs'}>
Streaming Logs
</Link>
</li>
<li
role="presentation"
className={tabName === 'settings' ? styles.active : ''}
data-test="trigger-settings"
>
<Link to={'/events/manage/triggers/' + triggerName + '/settings'}>
Settings
</Link>
</li>
</ul>
</div>
<div className="clearfix" />
</div>
</div>
);
};
export default TableHeader;

View File

@ -0,0 +1,126 @@
.container {
padding: 0;
}
.header {
background: #eee;
h2 {
margin: 0;
padding: 26px;
float: left;
line-height: 26px;
}
.nav {
padding: 20px;
float: left;
}
}
.tableContainer {
overflow: auto;
margin-top: 25px;
}
.viewRowsContainer {
border-left: 1px solid #ddd;
}
.insertContainer {
button {
margin: 0 10px;
}
label.radioLabel {
padding-top: 0;
input[type="radio"] {
margin-top: 10px;
}
}
span.radioSpan {
line-height: 34px;
}
}
.table {
width: -webkit-fill-available;
// width: auto;
thead {
background: #333;
color: #ddd;
}
th {
min-width: 100px;
font-weight: 300;
}
tr {
cursor: pointer;
}
td {
width: 300px;
max-width: 300px;
white-space: pre;
overflow: hidden;
text-overflow: ellipsis;
}
}
.expandable {
color: #779ecb;
text-decoration: underline;
}
a.expanded {
color: red;
}
.expandable:hover {
background: #eee;
transition: 0.2s;
cursor: pointer;
}
.tableNameInput {
width: 300px;
}
.addCol {
.input {
width: 250px;
display: inline-block;
}
.select {
margin: 0 20px;
display: inline-block;
width: 120px;
}
i:hover {
cursor: pointer;
color: #B85C27;
transition: 0.2s;
}
}
.roleTag {
margin-right: 5px;
}
.resetPassForm {
margin-top: 10px;
border: 1px solid #eee;
border-radius: 2px;
box-shadow: 1px 1px 1px #eee;
padding: 8px;
}
.relationshipTable {
width: auto;
thead {
background: #333;
color: #ddd;
}
th {
min-width: 100px;
font-weight: 300;
}
}
.relationshipTopPadding {
padding: 10px 0;
}

View File

@ -0,0 +1,5 @@
const Integers = ['serial', 'integer', 'bigserial', 'smallint', 'bigint'];
const Reals = ['float4', 'float8', 'numeric'];
const Numerics = [...Integers, ...Reals];
export { Numerics, Integers, Reals };

View File

@ -0,0 +1,14 @@
export eventHeaderConnector from './EventHeader';
export eventRouter from './EventRouter';
export eventReducer from './EventReducer';
export addTriggerConnector from './Add/AddTrigger';
export processedEventsConnector from './ProcessedEvents/ViewTable';
export pendingEventsConnector from './PendingEvents/ViewTable';
export runningEventsConnector from './RunningEvents/ViewTable';
export settingsConnector from './Settings/Settings';
export streamingLogsConnector from './StreamingLogs/Logs';
export schemaConnector from './Schema/Schema';
export schemaContainerConnector from './Schema/SchemaContainer';
export migrationsConnector from './Migrations/MigrationsHome';

View File

@ -0,0 +1,11 @@
import { push } from 'react-router-redux';
import globals from '../../../Globals';
const urlPrefix = globals.urlPrefix;
const appPrefix = urlPrefix !== '/' ? urlPrefix + '/events' : '/events';
const _push = path => push(appPrefix + path);
export default _push;
export { appPrefix };

View File

@ -0,0 +1,132 @@
const ordinalColSort = (a, b) => {
if (a.ordinal_position < b.ordinal_position) {
return -1;
}
if (a.ordinal_position > b.ordinal_position) {
return 1;
}
return 0;
};
const findFKConstraint = (curTable, column) => {
const fkConstraints = curTable.foreign_key_constraints;
return fkConstraints.find(
fk =>
Object.keys(fk.column_mapping).length === 1 &&
Object.keys(fk.column_mapping)[0] === column
);
};
const findTableFromRel = (schemas, curTable, rel) => {
let rtable = null;
// for view
if (rel.rel_def.manual_configuration !== undefined) {
rtable = rel.rel_def.manual_configuration.remote_table;
if (rtable.schema) {
rtable = rtable.name;
}
}
// for table
if (rel.rel_def.foreign_key_constraint_on !== undefined) {
// for object relationship
if (rel.rel_type === 'object') {
const column = rel.rel_def.foreign_key_constraint_on;
const fkc = findFKConstraint(curTable, column);
if (fkc) {
rtable = fkc.ref_table;
}
}
// for array relationship
if (rel.rel_type === 'array') {
rtable = rel.rel_def.foreign_key_constraint_on.table;
if (rtable.schema) {
rtable = rtable.name;
}
}
}
return schemas.find(x => x.table_name === rtable);
};
const findAllFromRel = (schemas, curTable, rel) => {
let rtable = null;
let lcol;
let rcol;
const foreignKeyConstraintOn = rel.rel_def.foreign_key_constraint_on;
// for view
if (rel.rel_def.manual_configuration !== undefined) {
rtable = rel.rel_def.manual_configuration.remote_table;
if (rtable.schema) {
rtable = rtable.name;
}
const columnMapping = rel.rel_def.manual_configuration.column_mapping;
lcol = Object.keys(columnMapping)[0];
rcol = columnMapping[lcol];
}
// for table
if (foreignKeyConstraintOn !== undefined) {
// for object relationship
if (rel.rel_type === 'object') {
lcol = foreignKeyConstraintOn;
const fkc = findFKConstraint(curTable, lcol);
if (fkc) {
rtable = fkc.ref_table;
rcol = fkc.column_mapping[lcol];
}
}
// for array relationship
if (rel.rel_type === 'array') {
rtable = foreignKeyConstraintOn.table;
rcol = foreignKeyConstraintOn.column;
if (rtable.schema) {
// if schema exists, its not public schema
rtable = rtable.name;
}
const rtableSchema = schemas.find(x => x.table_name === rtable);
const rfkc = findFKConstraint(rtableSchema, rcol);
lcol = rfkc.column_mapping[rcol];
}
}
return { lcol, rtable, rcol };
};
const getIngForm = string => {
return (
(string[string.length - 1] === 'e'
? string.slice(0, string.length - 1)
: string) + 'ing'
);
};
const getEdForm = string => {
return (
(string[string.length - 1] === 'e'
? string.slice(0, string.length - 1)
: string) + 'ed'
);
};
const escapeRegExp = string => {
return string.replace(/([.*+?^${}()|[\]\\])/g, '\\$1');
};
export {
ordinalColSort,
findTableFromRel,
findAllFromRel,
getEdForm,
getIngForm,
escapeRegExp,
};

View File

@ -1,6 +1,7 @@
import { combineReducers } from 'redux';
import { routerReducer } from 'react-router-redux';
import { dataReducer } from './components/Services/Data';
import { eventReducer } from './components/Services/EventTrigger';
import mainReducer from './components/Main/Actions';
import apiExplorerReducer from 'components/ApiExplorer/Actions';
import progressBarReducer from 'components/App/Actions';
@ -9,6 +10,7 @@ import { reducer as notifications } from 'react-notification-system-redux';
const reducer = combineReducers({
...dataReducer,
...eventReducer,
progressBar: progressBarReducer,
apiexplorer: apiExplorerReducer,
main: mainReducer,

View File

@ -7,6 +7,8 @@ import { App, Main, PageNotFound } from 'components';
import { dataRouter } from './components/Services/Data';
import { eventRouter } from './components/Services/EventTrigger';
import { loadMigrationStatus } from './components/Main/Actions';
import { composeOnEnterHooks } from 'utils/router';
@ -39,8 +41,10 @@ const routes = store => {
// loads schema
const dataRouterUtils = dataRouter(connect, store, composeOnEnterHooks);
const eventRouterUtils = eventRouter(connect, store, composeOnEnterHooks);
const requireSchema = dataRouterUtils.requireSchema;
const makeDataRouter = dataRouterUtils.makeDataRouter;
const makeEventRouter = eventRouterUtils.makeEventRouter;
return (
<Route path="/" component={App}>
@ -57,6 +61,7 @@ const routes = store => {
component={generatedApiExplorer(connect)}
/>
{makeDataRouter}
{makeEventRouter}
</Route>
</Route>
<Route path="404" component={PageNotFound} status="404" />

View File

@ -96,6 +96,7 @@ library
, http-client
, http-client-tls
, connection
, retry
-- ordered map
, insert-ordered-containers
@ -105,6 +106,8 @@ library
-- Templating
, mustache
, ginger
, file-embed
--
, data-has
@ -145,6 +148,7 @@ library
, Hasura.RQL.Types.Permission
, Hasura.RQL.Types.Error
, Hasura.RQL.Types.DML
, Hasura.RQL.Types.Subscribe
, Hasura.RQL.DDL.Deps
, Hasura.RQL.DDL.Permission.Internal
, Hasura.RQL.DDL.Permission.Triggers
@ -155,6 +159,7 @@ library
, Hasura.RQL.DDL.Schema.Diff
, Hasura.RQL.DDL.Metadata
, Hasura.RQL.DDL.Utils
, Hasura.RQL.DDL.Subscribe
, Hasura.RQL.DML.Delete
, Hasura.RQL.DML.Explain
, Hasura.RQL.DML.Internal
@ -187,6 +192,9 @@ library
, Hasura.GraphQL.Resolve.Mutation
, Hasura.GraphQL.Resolve.Select
, Hasura.Events.Lib
, Hasura.Events.HTTP
, Data.Text.Extended
, Data.Sequence.NonEmpty
, Data.TByteString
@ -227,6 +235,9 @@ executable graphql-engine
, pg-client
, http-client
, http-client-tls
, stm
, wreq
, connection
other-modules: Ops
TH
@ -262,4 +273,4 @@ test-suite graphql-engine-test
, unordered-containers >= 0.2
, case-insensitive
other-modules: Spec
other-modules: Spec

View File

@ -5,6 +5,7 @@ module Main where
import Ops
import Control.Monad.STM (atomically)
import Data.Time.Clock (getCurrentTime)
import Options.Applicative
import System.Environment (lookupEnv)
@ -22,6 +23,7 @@ import qualified Network.HTTP.Client as HTTP
import qualified Network.HTTP.Client.TLS as HTTP
import qualified Network.Wai.Handler.Warp as Warp
import Hasura.Events.Lib
import Hasura.Logging (defaultLoggerSettings, mkLoggerCtx)
import Hasura.Prelude
import Hasura.RQL.DDL.Metadata (fetchMetadata)
@ -32,6 +34,8 @@ import Hasura.Server.CheckUpdates (checkForUpdates)
import Hasura.Server.Init
import qualified Database.PG.Query as Q
import qualified Network.HTTP.Client.TLS as TLS
import qualified Network.Wreq.Session as WrqS
data RavenOptions
= RavenOptions
@ -126,7 +130,8 @@ main = do
ci <- either ((>> exitFailure) . putStrLn . connInfoErrModifier)
return $ mkConnInfo mEnvDbUrl rci
printConnInfo ci
loggerCtx <- mkLoggerCtx defaultLoggerSettings
loggerCtx <- mkLoggerCtx $ defaultLoggerSettings True
hloggerCtx <- mkLoggerCtx $ defaultLoggerSettings False
httpManager <- HTTP.newManager HTTP.tlsManagerSettings
case ravenMode of
ROServe (ServeOptions port cp isoL mRootDir mAccessKey corsCfg mWebHook mJwtSecret enableConsole) -> do
@ -140,15 +145,24 @@ main = do
CorsConfigG finalCorsDomain $ ccDisabled corsCfg
initialise ci
migrate ci
prepareEvents ci
pool <- Q.initPGPool ci cp
putStrLn $ "server: running on port " ++ show port
app <- mkWaiApp isoL mRootDir loggerCtx pool httpManager am finalCorsCfg enableConsole
(app, cacheRef) <- mkWaiApp isoL mRootDir loggerCtx pool httpManager am finalCorsCfg enableConsole
let warpSettings = Warp.setPort port Warp.defaultSettings
-- Warp.setHost "*" Warp.defaultSettings
-- start a background thread to check for updates
void $ C.forkIO $ checkForUpdates loggerCtx httpManager
maxEvThrds <- getFromEnv defaultMaxEventThreads "HASURA_GRAPHQL_EVENTS_HTTP_POOL_SIZE"
evPollSec <- getFromEnv defaultPollingIntervalSec "HASURA_GRAPHQL_EVENTS_FETCH_INTERVAL"
eventEngineCtx <- atomically $ initEventEngineCtx maxEvThrds evPollSec
httpSession <- WrqS.newSessionControl Nothing TLS.tlsManagerSettings
void $ C.forkIO $ processEventQueue hloggerCtx httpSession pool cacheRef eventEngineCtx
Warp.runSettings warpSettings app
ROExport -> do
@ -176,6 +190,18 @@ main = do
currentTime <- getCurrentTime
res <- runTx ci $ migrateCatalog currentTime
either ((>> exitFailure) . printJSON) putStrLn res
prepareEvents ci = do
putStrLn "event_triggers: preparing data"
res <- runTx ci unlockAllEvents
either ((>> exitFailure) . printJSON) return res
getFromEnv :: (Read a) => a -> String -> IO a
getFromEnv defaults env = do
mEnv <- lookupEnv env
let mRes = case mEnv of
Nothing -> Just defaults
Just val -> readMaybe val
eRes = maybe (Left "HASURA_GRAPHQL_EVENTS_HTTP_POOL_SIZE is not an integer") Right mRes
either ((>> exitFailure) . putStrLn) return eRes
cleanSuccess = putStrLn "successfully cleaned graphql-engine related data"

View File

@ -0,0 +1,334 @@
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TemplateHaskell #-}
module Hasura.Events.HTTP
( HTTP(..)
, mkHTTP
, mkAnyHTTPPost
, mkHTTPMaybe
, HTTPErr(..)
, runHTTP
, default2xxParser
, noBody2xxParser
, defaultRetryPolicy
, defaultRetryFn
, defaultParser
, defaultParserMaybe
, isNetworkError
, isNetworkErrorHC
, HLogger
, mkHLogger
, ExtraContext(..)
) where
import qualified Control.Retry as R
import qualified Data.Aeson as J
import qualified Data.Aeson.Casing as J
import qualified Data.Aeson.TH as J
import qualified Data.ByteString.Lazy as B
import qualified Data.CaseInsensitive as CI
import qualified Data.TByteString as TBS
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.Text.Encoding.Error as TE
import qualified Data.Text.Lazy as TL
import qualified Data.Text.Lazy.Encoding as TLE
import qualified Data.Time.Clock as Time
import qualified Network.HTTP.Client as H
import qualified Network.HTTP.Types as N
import qualified Network.Wreq as W
import qualified Network.Wreq.Session as WS
import qualified System.Log.FastLogger as FL
import Control.Exception (try)
import Control.Lens
import Control.Monad.Except (MonadError, throwError)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Control.Monad.Reader (MonadReader)
import Data.Has
import Hasura.Logging
-- import Data.Monoid
import Hasura.Prelude
import Hasura.RQL.Types.Subscribe
-- import Context (HTTPSessionMgr (..))
-- import Log
type HLogger = (LogLevel, EngineLogType, J.Value) -> IO ()
data ExtraContext
= ExtraContext
{ elEventCreatedAt :: Time.UTCTime
, elEventId :: TriggerId
} deriving (Show, Eq)
$(J.deriveJSON (J.aesonDrop 2 J.snakeCase){J.omitNothingFields=True} ''ExtraContext)
data HTTPErr
= HClient !H.HttpException
| HParse !N.Status !String
| HStatus !N.Status TBS.TByteString
| HOther !String
deriving (Show)
instance J.ToJSON HTTPErr where
toJSON err = toObj $ case err of
(HClient e) -> ("client", J.toJSON $ show e)
(HParse st e) ->
( "parse"
, J.toJSON (N.statusCode st, show e)
)
(HStatus st resp) ->
("status", J.toJSON (N.statusCode st, resp))
(HOther e) -> ("internal", J.toJSON $ show e)
where
toObj :: (T.Text, J.Value) -> J.Value
toObj (k, v) = J.object [ "type" J..= k
, "detail" J..= v]
-- encapsulates a http operation
instance ToEngineLog HTTPErr where
toEngineLog err = (LevelError, "event-trigger", J.toJSON err )
data HTTP a
= HTTP
{ _hMethod :: !String
, _hUrl :: !String
, _hPayload :: !(Maybe J.Value)
, _hFormData :: !(Maybe [W.FormParam])
-- options modifier
, _hOptions :: W.Options -> W.Options
-- the response parser
, _hParser :: W.Response B.ByteString -> Either HTTPErr a
-- should the operation be retried
, _hRetryFn :: Either HTTPErr a -> Bool
-- the retry policy
, _hRetryPolicy :: R.RetryPolicyM IO
}
-- TODO. Why this istance?
-- instance Show (HTTP a) where
-- show (HTTP m u p _ _ _ _) = show m ++ " " ++ show u ++ " : " ++ show p
isNetworkError :: HTTPErr -> Bool
isNetworkError = \case
HClient he -> isNetworkErrorHC he
_ -> False
isNetworkErrorHC :: H.HttpException -> Bool
isNetworkErrorHC = \case
H.HttpExceptionRequest _ (H.ConnectionFailure _) -> True
H.HttpExceptionRequest _ H.ConnectionTimeout -> True
H.HttpExceptionRequest _ H.ResponseTimeout -> True
_ -> False
-- retries on the typical network errors
defaultRetryFn :: Either HTTPErr a -> Bool
defaultRetryFn = \case
Left e -> isNetworkError e
Right _ -> False
-- full jitter backoff
defaultRetryPolicy :: (MonadIO m) => R.RetryPolicyM m
defaultRetryPolicy =
R.capDelay (120 * 1000 * 1000) (R.fullJitterBackoff (2 * 1000 * 1000))
<> R.limitRetries 15
-- a helper function
respJson :: (J.FromJSON a) => W.Response B.ByteString -> Either HTTPErr a
respJson resp =
either (Left . HParse respCode) return $
J.eitherDecode respBody
where
respCode = resp ^. W.responseStatus
respBody = resp ^. W.responseBody
defaultParser :: (J.FromJSON a) => W.Response B.ByteString -> Either HTTPErr a
defaultParser resp = if
| respCode == N.status200 -> respJson resp
| otherwise -> do
let val = TBS.fromLBS $ resp ^. W.responseBody
throwError $ HStatus respCode val
where
respCode = resp ^. W.responseStatus
-- like default parser but turns 404 into maybe
defaultParserMaybe
:: (J.FromJSON a) => W.Response B.ByteString -> Either HTTPErr (Maybe a)
defaultParserMaybe resp = if
| respCode == N.status200 -> Just <$> respJson resp
| respCode == N.status404 -> return Nothing
| otherwise -> do
let val = TBS.fromLBS $ resp ^. W.responseBody
throwError $ HStatus respCode val
where
respCode = resp ^. W.responseStatus
-- default parser which allows all 2xx responses
default2xxParser :: (J.FromJSON a) => W.Response B.ByteString -> Either HTTPErr a
default2xxParser resp = if
| respCode >= N.status200 && respCode < N.status300 -> respJson resp
| otherwise -> do
let val = TBS.fromLBS $ resp ^. W.responseBody
throwError $ HStatus respCode val
where
respCode = resp ^. W.responseStatus
noBody2xxParser :: W.Response B.ByteString -> Either HTTPErr ()
noBody2xxParser resp = if
| respCode >= N.status200 && respCode < N.status300 -> return ()
| otherwise -> do
let val = TBS.fromLBS $ resp ^. W.responseBody
throwError $ HStatus respCode val
where
respCode = resp ^. W.responseStatus
anyBodyParser :: W.Response B.ByteString -> Either HTTPErr B.ByteString
anyBodyParser resp = if
| respCode >= N.status200 && respCode < N.status300 -> return $ resp ^. W.responseBody
| otherwise -> do
let val = TBS.fromLBS $ resp ^. W.responseBody
throwError $ HStatus respCode val
where
respCode = resp ^. W.responseStatus
mkHTTP :: (J.FromJSON a) => String -> String -> HTTP a
mkHTTP method url =
HTTP method url Nothing Nothing id defaultParser
defaultRetryFn defaultRetryPolicy
mkAnyHTTPPost :: String -> Maybe J.Value -> HTTP B.ByteString
mkAnyHTTPPost url payload =
HTTP "POST" url payload Nothing id anyBodyParser
defaultRetryFn defaultRetryPolicy
mkHTTPMaybe :: (J.FromJSON a) => String -> String -> HTTP (Maybe a)
mkHTTPMaybe method url =
HTTP method url Nothing Nothing id defaultParserMaybe
defaultRetryFn defaultRetryPolicy
-- internal logging related types
data HTTPReq
= HTTPReq
{ _hrqMethod :: !String
, _hrqUrl :: !String
, _hrqPayload :: !(Maybe J.Value)
, _hrqTry :: !Int
, _hrqDelay :: !(Maybe Int)
} deriving (Show, Eq)
$(J.deriveJSON (J.aesonDrop 4 J.snakeCase){J.omitNothingFields=True} ''HTTPReq)
instance ToEngineLog HTTPReq where
toEngineLog req = (LevelInfo, "event-trigger", J.toJSON req )
instance ToEngineLog HTTPResp where
toEngineLog resp = (LevelInfo, "event-trigger", J.toJSON resp )
data HTTPResp
= HTTPResp
{ _hrsStatus :: !Int
, _hrsHeaders :: ![T.Text]
, _hrsBody :: !TL.Text
} deriving (Show, Eq)
$(J.deriveJSON (J.aesonDrop 4 J.snakeCase){J.omitNothingFields=True} ''HTTPResp)
data HTTPRespExtra
= HTTPRespExtra
{ _hreResponse :: HTTPResp
, _hreContext :: Maybe ExtraContext
}
$(J.deriveJSON (J.aesonDrop 4 J.snakeCase){J.omitNothingFields=True} ''HTTPRespExtra)
instance ToEngineLog HTTPRespExtra where
toEngineLog resp = (LevelInfo, "event-trigger", J.toJSON resp )
mkHTTPResp :: W.Response B.ByteString -> HTTPResp
mkHTTPResp resp =
HTTPResp
(resp ^. W.responseStatus.W.statusCode)
(map decodeHeader $ resp ^. W.responseHeaders)
(decodeLBS $ resp ^. W.responseBody)
where
decodeBS = TE.decodeUtf8With TE.lenientDecode
decodeLBS = TLE.decodeUtf8With TE.lenientDecode
decodeHeader (hdrName, hdrVal)
= decodeBS (CI.original hdrName) <> " : " <> decodeBS hdrVal
runHTTP
:: ( MonadReader r m
, MonadError HTTPErr m
, MonadIO m
, Has WS.Session r
, Has HLogger r
)
=> W.Options -> HTTP a -> Maybe ExtraContext -> m a
runHTTP opts http exLog = do
-- try the http request
res <- R.retrying retryPol' retryFn' $ httpWithLogging opts http exLog
-- process the result
either throwError return res
where
retryPol' = R.RetryPolicyM $ liftIO . R.getRetryPolicyM (_hRetryPolicy http)
retryFn' _ = return . _hRetryFn http
httpWithLogging
:: ( MonadReader r m
, MonadIO m
, Has WS.Session r
, Has HLogger r
)
=> W.Options -> HTTP a -> Maybe ExtraContext -> R.RetryStatus -> m (Either HTTPErr a)
-- the actual http action
httpWithLogging opts (HTTP method url mPayload mFormParams optsMod bodyParser _ _) exLog retryStatus = do
(logF:: HLogger) <- asks getter
-- log the request
liftIO $ logF $ toEngineLog $ HTTPReq method url mPayload
(R.rsIterNumber retryStatus) (R.rsPreviousDelay retryStatus)
session <- asks getter
res <- finallyRunHTTPPlz session
case res of
Left e -> liftIO $ logF $ toEngineLog $ HClient e
Right resp ->
--liftIO $ print "=======================>"
liftIO $ logF $ toEngineLog $ HTTPRespExtra (mkHTTPResp resp) exLog
--liftIO $ print "<======================="
-- return the processed response
return $ either (Left . HClient) bodyParser res
where
-- set wreq options to ignore status code exceptions
ignoreStatusCodeExceptions _ _ = return ()
finalOpts = optsMod opts
& W.checkResponse ?~ ignoreStatusCodeExceptions
-- the actual function which makes the relevant Wreq calls
finallyRunHTTPPlz sessMgr =
liftIO $ try $
case (mPayload, mFormParams) of
(Just payload, _) -> WS.customPayloadMethodWith method finalOpts sessMgr url payload
(Nothing, Just fps) -> WS.customPayloadMethodWith method finalOpts sessMgr url fps
(Nothing, Nothing) -> WS.customMethodWith method finalOpts sessMgr url
mkHLogger :: LoggerCtx -> HLogger
mkHLogger (LoggerCtx loggerSet serverLogLevel timeGetter) (logLevel, logTy, logDet) = do
localTime <- timeGetter
when (logLevel >= serverLogLevel) $
FL.pushLogStrLn loggerSet $ FL.toLogStr $
J.encode $ EngineLog localTime logLevel logTy logDet

View File

@ -0,0 +1,329 @@
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TemplateHaskell #-}
module Hasura.Events.Lib
( initEventEngineCtx
, processEventQueue
, unlockAllEvents
, defaultMaxEventThreads
, defaultPollingIntervalSec
) where
import Control.Concurrent (threadDelay)
import Control.Concurrent.Async (async, waitAny)
import Control.Concurrent.STM.TVar
import Control.Monad.STM (STM, atomically, retry)
import Data.Aeson
import Data.Aeson.Casing
import Data.Aeson.TH
import Data.Either (isLeft)
import Data.Has
import Data.Int (Int64)
import Data.IORef (IORef, readIORef)
import Hasura.Events.HTTP
import Hasura.Prelude
import Hasura.RQL.Types
import Hasura.SQL.Types
import qualified Control.Concurrent.STM.TQueue as TQ
import qualified Control.Retry as R
import qualified Data.ByteString.Lazy as B
import qualified Data.HashMap.Strict as M
import qualified Data.TByteString as TBS
import qualified Data.Text as T
import qualified Data.Time.Clock as Time
import qualified Database.PG.Query as Q
import qualified Hasura.GraphQL.Schema as GS
import qualified Hasura.Logging as L
import qualified Network.HTTP.Types as N
import qualified Network.Wreq as W
import qualified Network.Wreq.Session as WS
type CacheRef = IORef (SchemaCache, GS.GCtxMap)
type UUID = T.Text
newtype EventInternalErr
= EventInternalErr QErr
deriving (Show, Eq)
instance L.ToEngineLog EventInternalErr where
toEngineLog (EventInternalErr qerr) = (L.LevelError, "event-trigger", toJSON qerr )
data TriggerMeta
= TriggerMeta
{ tmId :: TriggerId
, tmName :: TriggerName
} deriving (Show, Eq)
$(deriveJSON (aesonDrop 2 snakeCase){omitNothingFields=True} ''TriggerMeta)
data Event
= Event
{ eId :: UUID
, eTable :: QualifiedTable
, eTrigger :: TriggerMeta
, eEvent :: Value
-- , eDelivered :: Bool
-- , eError :: Bool
, eTries :: Int64
, eCreatedAt :: Time.UTCTime
} deriving (Show, Eq)
instance ToJSON Event where
toJSON (Event eid (QualifiedTable sn tn) trigger event _ created)=
object [ "id" .= eid
, "table" .= object [ "schema" .= sn
, "name" .= tn
]
, "trigger" .= trigger
, "event" .= event
, "created_at" .= created
]
$(deriveFromJSON (aesonDrop 1 snakeCase){omitNothingFields=True} ''Event)
data Invocation
= Invocation
{ iEventId :: UUID
, iStatus :: Int64
, iRequest :: Value
, iResponse :: TBS.TByteString
}
data EventEngineCtx
= EventEngineCtx
{ _eeCtxEventQueue :: TQ.TQueue Event
, _eeCtxEventThreads :: TVar Int
, _eeCtxMaxEventThreads :: Int
, _eeCtxPollingIntervalSec :: Int
}
defaultMaxEventThreads :: Int
defaultMaxEventThreads = 100
defaultPollingIntervalSec :: Int
defaultPollingIntervalSec = 1
initEventEngineCtx :: Int -> Int -> STM EventEngineCtx
initEventEngineCtx maxT pollI = do
q <- TQ.newTQueue
c <- newTVar 0
return $ EventEngineCtx q c maxT pollI
processEventQueue :: L.LoggerCtx -> WS.Session -> Q.PGPool -> CacheRef -> EventEngineCtx -> IO ()
processEventQueue logctx httpSess pool cacheRef eectx = do
putStrLn "event_trigger: starting workers"
threads <- mapM async [pollThread , consumeThread]
void $ waitAny threads
where
pollThread = pollEvents (mkHLogger logctx) pool eectx
consumeThread = consumeEvents (mkHLogger logctx) httpSess pool cacheRef eectx
pollEvents
:: HLogger -> Q.PGPool -> EventEngineCtx -> IO ()
pollEvents logger pool eectx = forever $ do
let EventEngineCtx q _ _ pollI = eectx
eventsOrError <- runExceptT $ Q.runTx pool (Q.RepeatableRead, Just Q.ReadWrite) fetchEvents
case eventsOrError of
Left err -> logger $ L.toEngineLog $ EventInternalErr err
Right events -> atomically $ mapM_ (TQ.writeTQueue q) events
threadDelay (pollI * 1000 * 1000)
consumeEvents
:: HLogger -> WS.Session -> Q.PGPool -> CacheRef -> EventEngineCtx -> IO ()
consumeEvents logger httpSess pool cacheRef eectx = forever $ do
event <- atomically $ do
let EventEngineCtx q _ _ _ = eectx
TQ.readTQueue q
async $ runReaderT (processEvent pool event) (logger, httpSess, cacheRef, eectx)
processEvent
:: ( MonadReader r m
, MonadIO m
, Has WS.Session r
, Has HLogger r
, Has CacheRef r
, Has EventEngineCtx r
)
=> Q.PGPool -> Event -> m ()
processEvent pool e = do
(logger:: HLogger) <- asks getter
retryPolicy <- getRetryPolicy e
res <- R.retrying retryPolicy shouldRetry $ tryWebhook pool e
liftIO $ either (errorFn logger) (void.return) res
unlockRes <- liftIO $ runExceptT $ runUnlockQ pool e
liftIO $ either (logQErr logger) (void.return ) unlockRes
where
shouldRetry :: (Monad m ) => R.RetryStatus -> Either HTTPErr a -> m Bool
shouldRetry _ eitherResp = return $ isLeft eitherResp
errorFn :: HLogger -> HTTPErr -> IO ()
errorFn logger err = do
logger $ L.toEngineLog err
errorRes <- runExceptT $ runErrorQ pool e
case errorRes of
Left err' -> logQErr logger err'
Right _ -> return ()
logQErr :: HLogger -> QErr -> IO ()
logQErr logger err = logger $ L.toEngineLog $ EventInternalErr err
getRetryPolicy
:: ( MonadReader r m
, MonadIO m
, Has WS.Session r
, Has HLogger r
, Has CacheRef r
, Has EventEngineCtx r
)
=> Event -> m (R.RetryPolicyM m)
getRetryPolicy e = do
cacheRef::CacheRef <- asks getter
(cache, _) <- liftIO $ readIORef cacheRef
let eti = getEventTriggerInfoFromEvent cache e
retryConfM = etiRetryConf <$> eti
retryConf = fromMaybe (RetryConf 0 10) retryConfM
let remainingRetries = max 0 $ fromIntegral (rcNumRetries retryConf) - getTries
delay = fromIntegral (rcIntervalSec retryConf) * 1000000
policy = R.constantDelay delay <> R.limitRetries remainingRetries
return policy
where
getTries :: Int
getTries = fromIntegral $ eTries e
tryWebhook
:: ( MonadReader r m
, MonadIO m
, Has WS.Session r
, Has HLogger r
, Has CacheRef r
, Has EventEngineCtx r
)
=> Q.PGPool -> Event -> R.RetryStatus -> m (Either HTTPErr B.ByteString)
tryWebhook pool e _ = do
logger:: HLogger <- asks getter
cacheRef::CacheRef <- asks getter
(cache, _) <- liftIO $ readIORef cacheRef
let eti = getEventTriggerInfoFromEvent cache e
case eti of
Nothing -> return $ Left $ HOther "table or event-trigger not found"
Just et -> do
let webhook = etiWebhook et
createdAt = eCreatedAt e
eventId = eId e
eeCtx <- asks getter
-- wait for counter and then increment beforing making http
liftIO $ atomically $ do
let EventEngineCtx _ c maxT _ = eeCtx
countThreads <- readTVar c
if countThreads >= maxT
then retry
else modifyTVar' c (+1)
eitherResp <- runExceptT $ runHTTP W.defaults (mkAnyHTTPPost (T.unpack webhook) (Just $ toJSON e)) (Just (ExtraContext createdAt eventId))
--decrement counter once http is done
liftIO $ atomically $ do
let EventEngineCtx _ c _ _ = eeCtx
modifyTVar' c (\v -> v - 1)
finally <- liftIO $ runExceptT $ case eitherResp of
Left err ->
case err of
HClient excp -> runFailureQ pool $ Invocation (eId e) 1000 (toJSON e) (TBS.fromLBS $ encode $ show excp)
HParse _ detail -> runFailureQ pool $ Invocation (eId e) 1001 (toJSON e) (TBS.fromLBS $ encode detail)
HStatus status detail -> runFailureQ pool $ Invocation (eId e) (fromIntegral $ N.statusCode status) (toJSON e) detail
HOther detail -> runFailureQ pool $ Invocation (eId e) 500 (toJSON e) (TBS.fromLBS $ encode detail)
Right resp -> runSuccessQ pool e $ Invocation (eId e) 200 (toJSON e) (TBS.fromLBS resp)
case finally of
Left err -> liftIO $ logger $ L.toEngineLog $ EventInternalErr err
Right _ -> return ()
return eitherResp
getEventTriggerInfoFromEvent :: SchemaCache -> Event -> Maybe EventTriggerInfo
getEventTriggerInfoFromEvent sc e = let table = eTable e
tableInfo = M.lookup table $ scTables sc
in M.lookup ( tmName $ eTrigger e) =<< (tiEventTriggerInfoMap <$> tableInfo)
fetchEvents :: Q.TxE QErr [Event]
fetchEvents =
map uncurryEvent <$> Q.listQE defaultTxErrorHandler [Q.sql|
UPDATE hdb_catalog.event_log
SET locked = 't'
WHERE id IN ( select id from hdb_catalog.event_log where delivered ='f' and error = 'f' and locked = 'f' LIMIT 100 )
RETURNING id, schema_name, table_name, trigger_id, trigger_name, payload::json, tries, created_at
|] () True
where uncurryEvent (id', sn, tn, trid, trn, Q.AltJ payload, tries, created) = Event id' (QualifiedTable sn tn) (TriggerMeta trid trn) payload tries created
insertInvocation :: Invocation -> Q.TxE QErr ()
insertInvocation invo = do
Q.unitQE defaultTxErrorHandler [Q.sql|
INSERT INTO hdb_catalog.event_invocation_logs (event_id, status, request, response)
VALUES ($1, $2, $3, $4)
|] (iEventId invo, iStatus invo, Q.AltJ $ toJSON $ iRequest invo, Q.AltJ $ toJSON $ iResponse invo) True
Q.unitQE defaultTxErrorHandler [Q.sql|
UPDATE hdb_catalog.event_log
SET tries = tries + 1
WHERE id = $1
|] (Identity $ iEventId invo) True
markDelivered :: Event -> Q.TxE QErr ()
markDelivered e =
Q.unitQE defaultTxErrorHandler [Q.sql|
UPDATE hdb_catalog.event_log
SET delivered = 't', error = 'f'
WHERE id = $1
|] (Identity $ eId e) True
markError :: Event -> Q.TxE QErr ()
markError e =
Q.unitQE defaultTxErrorHandler [Q.sql|
UPDATE hdb_catalog.event_log
SET error = 't'
WHERE id = $1
|] (Identity $ eId e) True
-- lockEvent :: Event -> Q.TxE QErr ()
-- lockEvent e =
-- Q.unitQE defaultTxErrorHandler [Q.sql|
-- UPDATE hdb_catalog.event_log
-- SET locked = 't'
-- WHERE id = $1
-- |] (Identity $ eId e) True
unlockEvent :: Event -> Q.TxE QErr ()
unlockEvent e =
Q.unitQE defaultTxErrorHandler [Q.sql|
UPDATE hdb_catalog.event_log
SET locked = 'f'
WHERE id = $1
|] (Identity $ eId e) True
unlockAllEvents :: Q.TxE QErr ()
unlockAllEvents =
Q.unitQE defaultTxErrorHandler [Q.sql|
UPDATE hdb_catalog.event_log
SET locked = 'f'
|] () False
runFailureQ :: Q.PGPool -> Invocation -> ExceptT QErr IO ()
runFailureQ pool invo = Q.runTx pool (Q.RepeatableRead, Just Q.ReadWrite) $ insertInvocation invo
runSuccessQ :: Q.PGPool -> Event -> Invocation -> ExceptT QErr IO ()
runSuccessQ pool e invo = Q.runTx pool (Q.RepeatableRead, Just Q.ReadWrite) $ do
insertInvocation invo
markDelivered e
runErrorQ :: Q.PGPool -> Event -> ExceptT QErr IO ()
runErrorQ pool e = Q.runTx pool (Q.RepeatableRead, Just Q.ReadWrite) $ markError e
-- runLockQ :: Q.PGPool -> Event -> ExceptT QErr IO ()
-- runLockQ pool e = Q.runTx pool (Q.RepeatableRead, Just Q.ReadWrite) $ lockEvent e
runUnlockQ :: Q.PGPool -> Event -> ExceptT QErr IO ()
runUnlockQ pool e = Q.runTx pool (Q.RepeatableRead, Just Q.ReadWrite) $ unlockEvent e

View File

@ -1069,9 +1069,8 @@ mkGCtxMapTable
=> TableCache
-> TableInfo
-> m (Map.HashMap RoleName (TyAgg, RootFlds))
mkGCtxMapTable tableCache (TableInfo tn _ fields rolePerms constraints pkeyCols) = do
m <- Map.traverseWithKey
(mkGCtxRole tableCache tn fields pkeyCols validConstraints) rolePerms
mkGCtxMapTable tableCache (TableInfo tn _ fields rolePerms constraints pkeyCols _) = do
m <- Map.traverseWithKey (mkGCtxRole tableCache tn fields pkeyCols validConstraints) rolePerms
let adminCtx = mkGCtxRole' tn (Just (colInfos, True))
(Just selFlds) (Just colInfos) (Just ())
pkeyColInfos validConstraints allCols

View File

@ -105,9 +105,9 @@ data LoggerSettings
, _lsLevel :: !LogLevel
} deriving (Show, Eq)
defaultLoggerSettings :: LoggerSettings
defaultLoggerSettings =
LoggerSettings True Nothing LevelInfo
defaultLoggerSettings :: Bool -> LoggerSettings
defaultLoggerSettings isCached =
LoggerSettings isCached Nothing LevelInfo
getFormattedTime :: Maybe Time.TimeZone -> IO FormattedTime
getFormattedTime tzM = do
@ -117,7 +117,7 @@ getFormattedTime tzM = do
return $ FormattedTime $ T.pack $ formatTime zt
where
formatTime = Format.formatTime Format.defaultTimeLocale format
format = "%FT%T%z"
format = "%FT%H:%M:%S%3Q%z"
-- format = Format.iso8601DateFormat (Just "%H:%M:%S")
mkLoggerCtx :: LoggerSettings -> IO LoggerCtx

View File

@ -49,6 +49,8 @@ import qualified Hasura.RQL.DDL.Permission as DP
import qualified Hasura.RQL.DDL.QueryTemplate as DQ
import qualified Hasura.RQL.DDL.Relationship as DR
import qualified Hasura.RQL.DDL.Schema.Table as DT
import qualified Hasura.RQL.DDL.Subscribe as DS
import qualified Hasura.RQL.Types.Subscribe as DTS
data TableMeta
= TableMeta
@ -59,11 +61,12 @@ data TableMeta
, _tmSelectPermissions :: ![DP.SelPermDef]
, _tmUpdatePermissions :: ![DP.UpdPermDef]
, _tmDeletePermissions :: ![DP.DelPermDef]
, _tmEventTriggers :: ![DTS.EventTriggerDef]
} deriving (Show, Eq, Lift)
mkTableMeta :: QualifiedTable -> TableMeta
mkTableMeta qt =
TableMeta qt [] [] [] [] [] []
TableMeta qt [] [] [] [] [] [] []
makeLenses ''TableMeta
@ -81,6 +84,7 @@ instance FromJSON TableMeta where
<*> o .:? spKey .!= []
<*> o .:? upKey .!= []
<*> o .:? dpKey .!= []
<*> o .:? etKey .!= []
where
tableKey = "table"
@ -90,13 +94,14 @@ instance FromJSON TableMeta where
spKey = "select_permissions"
upKey = "update_permissions"
dpKey = "delete_permissions"
etKey = "event_triggers"
unexpectedKeys =
HS.fromList (M.keys o) `HS.difference` expectedKeySet
expectedKeySet =
HS.fromList [ tableKey, orKey, arKey, ipKey
, spKey, upKey, dpKey
, spKey, upKey, dpKey, etKey
]
parseJSON _ =
@ -118,7 +123,7 @@ clearMetadata = Q.catchE defaultTxErrorHandler $ do
Q.unitQ "DELETE FROM hdb_catalog.hdb_permission WHERE is_system_defined <> 'true'" () False
Q.unitQ "DELETE FROM hdb_catalog.hdb_relationship WHERE is_system_defined <> 'true'" () False
Q.unitQ "DELETE FROM hdb_catalog.hdb_table WHERE is_system_defined <> 'true'" () False
Q.unitQ clearHdbViews () False
clearHdbViews
instance HDBQuery ClearMetadata where
@ -158,12 +163,14 @@ applyQP1 (ReplaceMetadata tables templates) = do
selPerms = map DP.pdRole $ table ^. tmSelectPermissions
updPerms = map DP.pdRole $ table ^. tmUpdatePermissions
delPerms = map DP.pdRole $ table ^. tmDeletePermissions
eventTriggers = map DTS.etdName $ table ^. tmEventTriggers
checkMultipleDecls "relationships" allRels
checkMultipleDecls "insert permissions" insPerms
checkMultipleDecls "select permissions" selPerms
checkMultipleDecls "update permissions" updPerms
checkMultipleDecls "delete permissions" delPerms
checkMultipleDecls "event triggers" eventTriggers
withPathK "queryTemplates" $
checkMultipleDecls "query templates" $ map DQ.cqtName templates
@ -214,6 +221,11 @@ applyQP2 (ReplaceMetadata tables templates) = do
withPathK "delete_permissions" $ processPerms tabInfo $
table ^. tmDeletePermissions
indexedForM_ tables $ \table -> do
withPathK "event_triggers" $
indexedForM_ (table ^. tmEventTriggers) $ \et ->
DS.subTableP2 (table ^. tmTable) et
-- query templates
withPathK "queryTemplates" $
indexedForM_ templates $ \template -> do
@ -276,6 +288,10 @@ fetchMetadata = do
qtDef <- decodeValue qtDefVal
return $ DQ.CreateQueryTemplate qtn qtDef mComment
-- Fetch all event triggers
eventTriggers <- Q.catchE defaultTxErrorHandler fetchEventTriggers
triggerMetaDefs <- mkTriggerMetaDefs eventTriggers
let (_, postRelMap) = flip runState tableMetaMap $ do
modMetaMap tmObjectRelationships objRelDefs
modMetaMap tmArrayRelationships arrRelDefs
@ -283,6 +299,7 @@ fetchMetadata = do
modMetaMap tmSelectPermissions selPermDefs
modMetaMap tmUpdatePermissions updPermDefs
modMetaMap tmDeletePermissions delPermDefs
modMetaMap tmEventTriggers triggerMetaDefs
return $ ReplaceMetadata (M.elems postRelMap) qTmpltDefs
@ -304,6 +321,12 @@ fetchMetadata = do
using <- decodeValue rDef
return (QualifiedTable sn tn, DR.RelDef rn using mComment)
mkTriggerMetaDefs = mapM trigRowToDef
trigRowToDef (sn, tn, trn, Q.AltJ tDefVal, webhook, nr, rint) = do
tDef <- decodeValue tDefVal
return (QualifiedTable sn tn, DTS.EventTriggerDef trn tDef webhook (RetryConf nr rint))
fetchTables =
Q.listQ [Q.sql|
SELECT table_schema, table_name from hdb_catalog.hdb_table
@ -330,6 +353,12 @@ fetchMetadata = do
FROM hdb_catalog.hdb_query_template
WHERE is_system_defined = 'false'
|] () False
fetchEventTriggers =
Q.listQ [Q.sql|
SELECT e.schema_name, e.table_name, e.name, e.definition::json, e.webhook, e.num_retries, e.retry_interval
FROM hdb_catalog.event_triggers e
|] () False
instance HDBQuery ExportMetadata where

View File

@ -17,6 +17,7 @@ import Hasura.RQL.DDL.Permission.Internal
import Hasura.RQL.DDL.QueryTemplate
import Hasura.RQL.DDL.Relationship
import Hasura.RQL.DDL.Schema.Diff
import Hasura.RQL.DDL.Subscribe
import Hasura.RQL.DDL.Utils
import Hasura.RQL.Types
import Hasura.SQL.Types
@ -141,6 +142,10 @@ purgeDep schemaObjId = case schemaObjId of
liftTx $ delQTemplateFromCatalog qtn
delQTemplateFromCache qtn
(SOTableObj qt (TOTrigger trn)) -> do
liftTx $ delEventTriggerFromCatalog trn
delEventTriggerFromCache qt trn
_ -> throw500 $
"unexpected dependent object : " <> reportSchemaObj schemaObjId
@ -199,6 +204,10 @@ processSchemaChanges schemaDiff = do
DELETE FROM "hdb_catalog"."hdb_permission"
WHERE table_schema = $1 AND table_name = $2
|] (sn, tn) False
Q.unitQ [Q.sql|
DELETE FROM "hdb_catalog"."event_triggers"
WHERE schema_name = $1 AND table_name = $2
|] (sn, tn) False
delTableFromCatalog qtn
delTableFromCache qtn
-- Get schema cache
@ -333,6 +342,14 @@ buildSchemaCache = flip execStateT emptySchemaCache $ do
qti <- liftP1 qCtx $ createQueryTemplateP1 $
CreateQueryTemplate qtn qtDef Nothing
addQTemplateToCache qti
eventTriggers <- lift $ Q.catchE defaultTxErrorHandler fetchEventTriggers
forM_ eventTriggers $ \(sn, tn, trid, trn, Q.AltJ tDefVal, webhook, nr, rint) -> do
tDef <- decodeValue tDefVal
addEventTriggerToCache (QualifiedTable sn tn) trid trn tDef (RetryConf nr rint) webhook
liftTx $ mkTriggerQ trid trn (QualifiedTable sn tn) tDef
where
permHelper sn tn rn pDef pa = do
qCtx <- mkAdminQCtx <$> get
@ -368,6 +385,12 @@ buildSchemaCache = flip execStateT emptySchemaCache $ do
SELECT template_name, template_defn :: json FROM hdb_catalog.hdb_query_template
|] () False
fetchEventTriggers =
Q.listQ [Q.sql|
SELECT e.schema_name, e.table_name, e.id, e.name, e.definition::json, e.webhook, e.num_retries, e.retry_interval
FROM hdb_catalog.event_triggers e
|] () False
data RunSQL
= RunSQL
{ rSql :: T.Text
@ -388,8 +411,7 @@ runSqlP2 :: (P2C m) => RunSQL -> m RespBody
runSqlP2 (RunSQL t cascade) = do
-- Drop hdb_views so no interference is caused to the sql query
liftTx $ Q.catchE defaultTxErrorHandler $
Q.unitQ clearHdbViews () False
liftTx $ Q.catchE defaultTxErrorHandler clearHdbViews
-- Get the metadata before the sql query, everything, need to filter this
oldMetaU <- liftTx $ Q.catchE defaultTxErrorHandler fetchTableMeta
@ -422,6 +444,16 @@ runSqlP2 (RunSQL t cascade) = do
forM_ (M.elems $ tiRolePermInfoMap ti) $ \rpi ->
maybe (return ()) (liftTx . buildInsInfra tn) $ _permIns rpi
--recreate triggers
forM_ (M.elems $ scTables postSc) $ \ti -> do
let tn = tiName ti
forM_ (M.toList $ tiEventTriggerInfoMap ti) $ \(trn, eti) -> do
let insert = otiCols <$> etiInsert eti
update = otiCols <$> etiUpdate eti
delete = otiCols <$> etiDelete eti
trid = etiId eti
liftTx $ mkTriggerQ trid trn tn (TriggerOpsDef insert update delete)
return $ encode (res :: RunSQLRes)
where

View File

@ -0,0 +1,195 @@
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeFamilies #-}
module Hasura.RQL.DDL.Subscribe where
import Data.Aeson
import Data.Int (Int64)
import Hasura.Prelude
import Hasura.RQL.Types
import Hasura.SQL.Types
import qualified Data.FileEmbed as FE
import qualified Data.HashMap.Strict as HashMap
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Database.PG.Query as Q
import qualified Text.Ginger as TG
data Ops = INSERT | UPDATE | DELETE deriving (Show)
data OpVar = OLD | NEW deriving (Show)
type GingerTmplt = TG.Template TG.SourcePos
defaultNumRetries :: Int64
defaultNumRetries = 0
defaultRetryInterval :: Int64
defaultRetryInterval = 10
parseGingerTmplt :: TG.Source -> Either String GingerTmplt
parseGingerTmplt src = either parseE Right res
where
res = runIdentity $ TG.parseGinger' parserOptions src
parserOptions = TG.mkParserOptions resolver
resolver = const $ return Nothing
parseE e = Left $ TG.formatParserError (Just "") e
triggerTmplt :: Maybe GingerTmplt
triggerTmplt = case parseGingerTmplt $(FE.embedStringFile "src-rsr/trigger.sql.j2") of
Left _ -> Nothing
Right tmplt -> Just tmplt
getDropFuncSql :: Ops -> TriggerName -> T.Text
getDropFuncSql op trn = "DROP FUNCTION IF EXISTS"
<> " hdb_views.notify_hasura_" <> trn <> "_" <> T.pack (show op) <> "()"
<> " CASCADE"
getTriggerSql :: Ops -> TriggerId -> TriggerName -> SchemaName -> TableName -> Maybe SubscribeOpSpec -> Maybe T.Text
getTriggerSql op trid trn sn tn spec =
let globalCtx = HashMap.fromList [
(T.pack "ID", trid)
, (T.pack "NAME", trn)
, (T.pack "SCHEMA_NAME", getSchemaTxt sn)
, (T.pack "TABLE_NAME", getTableTxt tn)]
opCtx = maybe HashMap.empty (createOpCtx op) spec
context = HashMap.union globalCtx opCtx
in
spec >> renderSql context <$> triggerTmplt
where
createOpCtx :: Ops -> SubscribeOpSpec -> HashMap.HashMap T.Text T.Text
createOpCtx op1 (SubscribeOpSpec columns) = HashMap.fromList [
(T.pack "OPERATION", T.pack $ show op1)
, (T.pack "OLD_DATA_EXPRESSION", renderOldDataExp op1 columns )
, (T.pack "NEW_DATA_EXPRESSION", renderNewDataExp op1 columns )]
renderOldDataExp :: Ops -> SubscribeColumns -> T.Text
renderOldDataExp op2 scs = case op2 of
INSERT -> "NULL"
UPDATE -> getRowExpression OLD scs
DELETE -> getRowExpression OLD scs
renderNewDataExp :: Ops -> SubscribeColumns -> T.Text
renderNewDataExp op2 scs = case op2 of
INSERT -> getRowExpression NEW scs
UPDATE -> getRowExpression NEW scs
DELETE -> "NULL"
getRowExpression :: OpVar -> SubscribeColumns -> T.Text
getRowExpression opVar scs = case scs of
SubCStar -> "row_to_json(" <> T.pack (show opVar) <> ")"
SubCArray cols -> "row_to_json((select r from (select " <> listcols cols opVar <> ") as r))"
where
listcols :: [PGCol] -> OpVar -> T.Text
listcols pgcols var = T.intercalate ", " $ fmap (mkQualified (T.pack $ show var).getPGColTxt) pgcols
mkQualified :: T.Text -> T.Text -> T.Text
mkQualified v col = v <> "." <> col
renderSql :: HashMap.HashMap T.Text T.Text -> GingerTmplt -> T.Text
renderSql = TG.easyRender
mkTriggerQ
:: TriggerId
-> TriggerName
-> QualifiedTable
-> TriggerOpsDef
-> Q.TxE QErr ()
mkTriggerQ trid trn (QualifiedTable sn tn) (TriggerOpsDef insert update delete) = do
let msql = getTriggerSql INSERT trid trn sn tn insert
<> getTriggerSql UPDATE trid trn sn tn update
<> getTriggerSql DELETE trid trn sn tn delete
case msql of
Just sql -> Q.multiQE defaultTxErrorHandler (Q.fromBuilder $ TE.encodeUtf8Builder sql)
Nothing -> throw500 "no trigger sql generated"
addEventTriggerToCatalog :: QualifiedTable -> EventTriggerDef
-> Q.TxE QErr TriggerId
addEventTriggerToCatalog (QualifiedTable sn tn) (EventTriggerDef name def webhook rconf) = do
ids <- map runIdentity <$> Q.listQE defaultTxErrorHandler [Q.sql|
INSERT into hdb_catalog.event_triggers (name, type, schema_name, table_name, definition, webhook, num_retries, retry_interval)
VALUES ($1, 'table', $2, $3, $4, $5, $6, $7)
RETURNING id
|] (name, sn, tn, Q.AltJ $ toJSON def, webhook, rcNumRetries rconf, rcIntervalSec rconf) True
trid <- getTrid ids
mkTriggerQ trid name (QualifiedTable sn tn) def
return trid
where
getTrid [] = throw500 "could not create event-trigger"
getTrid (x:_) = return x
delEventTriggerFromCatalog :: TriggerName -> Q.TxE QErr ()
delEventTriggerFromCatalog trn = do
Q.unitQE defaultTxErrorHandler [Q.sql|
DELETE FROM
hdb_catalog.event_triggers
WHERE name = $1
|] (Identity trn) True
mapM_ tx [INSERT, UPDATE, DELETE]
where
tx :: Ops -> Q.TxE QErr ()
tx op = Q.multiQE defaultTxErrorHandler (Q.fromBuilder $ TE.encodeUtf8Builder $ getDropFuncSql op trn)
fetchEventTrigger :: TriggerName -> Q.TxE QErr EventTrigger
fetchEventTrigger trn = do
triggers <- Q.listQE defaultTxErrorHandler [Q.sql|
SELECT e.schema_name, e.table_name, e.name, e.definition::json, e.webhook, e.num_retries, e.retry_interval
FROM hdb_catalog.event_triggers e
WHERE e.name = $1
|] (Identity trn) True
getTrigger triggers
where
getTrigger [] = throw400 NotExists ("could not find event trigger '" <> trn <> "'")
getTrigger (x:_) = return $ EventTrigger (QualifiedTable sn tn) trn' tDef webhook (RetryConf nr rint)
where (sn, tn, trn', Q.AltJ tDef, webhook, nr, rint) = x
subTableP1 :: (P1C m) => CreateEventTriggerQuery -> m (QualifiedTable, EventTriggerDef)
subTableP1 (CreateEventTriggerQuery name qt insert update delete retryConf webhook) = do
ti <- askTabInfo qt
assertCols ti insert
assertCols ti update
assertCols ti delete
let rconf = fromMaybe (RetryConf defaultNumRetries defaultRetryInterval) retryConf
return (qt, EventTriggerDef name (TriggerOpsDef insert update delete) webhook rconf)
where
assertCols _ Nothing = return ()
assertCols ti (Just sos) = do
let cols = sosColumns sos
case cols of
SubCStar -> return ()
SubCArray pgcols -> forM_ pgcols (assertPGCol (tiFieldInfoMap ti) "")
subTableP2 :: (P2C m) => QualifiedTable -> EventTriggerDef -> m ()
subTableP2 qt q@(EventTriggerDef name def webhook rconf) = do
trid <- liftTx $ addEventTriggerToCatalog qt q
addEventTriggerToCache qt trid name def rconf webhook
subTableP2shim :: (P2C m) => (QualifiedTable, EventTriggerDef) -> m RespBody
subTableP2shim (qt, etdef) = do
subTableP2 qt etdef
return successMsg
instance HDBQuery CreateEventTriggerQuery where
type Phase1Res CreateEventTriggerQuery = (QualifiedTable, EventTriggerDef)
phaseOne = subTableP1
phaseTwo _ = subTableP2shim
schemaCachePolicy = SCPReload
unsubTableP1 :: (P1C m) => DeleteEventTriggerQuery -> m ()
unsubTableP1 _ = return ()
unsubTableP2 :: (P2C m) => DeleteEventTriggerQuery -> m RespBody
unsubTableP2 (DeleteEventTriggerQuery name) = do
et <- liftTx $ fetchEventTrigger name
delEventTriggerFromCache (etTable et) name
liftTx $ delEventTriggerFromCatalog name
return successMsg
instance HDBQuery DeleteEventTriggerQuery where
type Phase1Res DeleteEventTriggerQuery = ()
phaseOne = unsubTableP1
phaseTwo q _ = unsubTableP2 q
schemaCachePolicy = SCPReload

View File

@ -2,14 +2,35 @@
module Hasura.RQL.DDL.Utils where
import qualified Database.PG.Query as Q
import qualified Data.ByteString.Builder as BB
import qualified Database.PG.Query as Q
import Hasura.Prelude ((<>))
clearHdbViews :: Q.Query
clearHdbViews =
clearHdbViews :: Q.Tx ()
clearHdbViews = Q.multiQ (Q.fromBuilder (clearHdbOnlyViews <> clearHdbViewsFunc))
clearHdbOnlyViews :: BB.Builder
clearHdbOnlyViews =
"DO $$ DECLARE \
\ r RECORD; \
\ BEGIN \
\ FOR r IN (SELECT viewname FROM pg_views WHERE schemaname = 'hdb_views') LOOP \
\ EXECUTE 'DROP VIEW IF EXISTS hdb_views.' || quote_ident(r.viewname) || ' CASCADE'; \
\ END LOOP; \
\ END $$ "
\ END $$; "
clearHdbViewsFunc :: BB.Builder
clearHdbViewsFunc =
"DO $$ DECLARE \
\ _sql text; \
\ BEGIN \
\ SELECT INTO _sql \
\ string_agg('DROP FUNCTION hdb_views.' || quote_ident(r.routine_name) || '() CASCADE;' \
\ , E'\n') \
\ FROM information_schema.routines r \
\ WHERE r.specific_schema = 'hdb_views'; \
\ IF _sql IS NOT NULL THEN \
\ EXECUTE _sql; \
\ END IF; \
\ END $$; "

View File

@ -53,6 +53,7 @@ import Hasura.RQL.Types.DML as R
import Hasura.RQL.Types.Error as R
import Hasura.RQL.Types.Permission as R
import Hasura.RQL.Types.SchemaCache as R
import Hasura.RQL.Types.Subscribe as R
import Hasura.SQL.Types
import qualified Database.PG.Query as Q

View File

@ -62,6 +62,12 @@ module Hasura.RQL.Types.SchemaCache
, delQTemplateFromCache
, TemplateParamInfo(..)
, addEventTriggerToCache
, delEventTriggerFromCache
, getOpInfo
, EventTriggerInfo(..)
, OpTriggerInfo(..)
, TableObjId(..)
, SchemaObjId(..)
, reportSchemaObj
@ -75,6 +81,7 @@ module Hasura.RQL.Types.SchemaCache
, getDependentObjsOfQTemplateCache
, getDependentPermsOfTable
, getDependentRelsOfTable
, getDependentTriggersOfTable
, isDependentOn
) where
@ -84,6 +91,7 @@ import Hasura.RQL.Types.Common
import Hasura.RQL.Types.DML
import Hasura.RQL.Types.Error
import Hasura.RQL.Types.Permission
import Hasura.RQL.Types.Subscribe
import qualified Hasura.SQL.DML as S
import Hasura.SQL.Types
@ -103,6 +111,7 @@ data TableObjId
| TORel !RelName
| TOCons !ConstraintName
| TOPerm !RoleName !PermType
| TOTrigger !TriggerName
deriving (Show, Eq, Generic)
instance Hashable TableObjId
@ -128,6 +137,8 @@ reportSchemaObj (SOTableObj tn (TOCons cn)) =
reportSchemaObj (SOTableObj tn (TOPerm rn pt)) =
"permission " <> qualTableToTxt tn <> "." <> getRoleTxt rn
<> "." <> permTypeToCode pt
reportSchemaObj (SOTableObj tn (TOTrigger trn )) =
"event-trigger " <> qualTableToTxt tn <> "." <> trn
reportSchemaObjs :: [SchemaObjId] -> T.Text
reportSchemaObjs = T.intercalate ", " . map reportSchemaObj
@ -323,6 +334,39 @@ makeLenses ''RolePermInfo
type RolePermInfoMap = M.HashMap RoleName RolePermInfo
data OpTriggerInfo
= OpTriggerInfo
{ otiTable :: !QualifiedTable
, otiTriggerName :: !TriggerName
, otiCols :: !SubscribeOpSpec
, otiDeps :: ![SchemaDependency]
} deriving (Show, Eq)
$(deriveToJSON (aesonDrop 3 snakeCase) ''OpTriggerInfo)
instance CachedSchemaObj OpTriggerInfo where
dependsOn = otiDeps
data EventTriggerInfo
= EventTriggerInfo
{ etiId :: !TriggerId
, etiName :: !TriggerName
, etiInsert :: !(Maybe OpTriggerInfo)
, etiUpdate :: !(Maybe OpTriggerInfo)
, etiDelete :: !(Maybe OpTriggerInfo)
, etiRetryConf :: !RetryConf
, etiWebhook :: !T.Text
} deriving (Show, Eq)
$(deriveToJSON (aesonDrop 3 snakeCase) ''EventTriggerInfo)
type EventTriggerInfoMap = M.HashMap TriggerName EventTriggerInfo
getTriggers :: EventTriggerInfoMap -> [OpTriggerInfo]
getTriggers etim = toOpTriggerInfo $ M.elems etim
where
toOpTriggerInfo etis = catMaybes $ foldl (\acc eti -> acc ++ [etiInsert eti, etiUpdate eti, etiDelete eti]) [] etis
data ConstraintType
= CTCHECK
| CTFOREIGNKEY
@ -367,20 +411,21 @@ isUniqueOrPrimary (TableConstraint ty _) = case ty of
data TableInfo
= TableInfo
{ tiName :: !QualifiedTable
, tiSystemDefined :: !Bool
, tiFieldInfoMap :: !FieldInfoMap
, tiRolePermInfoMap :: !RolePermInfoMap
, tiConstraints :: ![TableConstraint]
, tiPrimaryKeyCols :: ![PGCol]
{ tiName :: !QualifiedTable
, tiSystemDefined :: !Bool
, tiFieldInfoMap :: !FieldInfoMap
, tiRolePermInfoMap :: !RolePermInfoMap
, tiConstraints :: ![TableConstraint]
, tiPrimaryKeyCols :: ![PGCol]
, tiEventTriggerInfoMap :: !EventTriggerInfoMap
} deriving (Show, Eq)
$(deriveToJSON (aesonDrop 2 snakeCase) ''TableInfo)
mkTableInfo :: QualifiedTable -> Bool -> [(ConstraintType, ConstraintName)]
-> [(PGCol, PGColType, Bool)] -> [PGCol] -> TableInfo
mkTableInfo tn isSystemDefined rawCons cols =
TableInfo tn isSystemDefined colMap (M.fromList []) constraints
mkTableInfo tn isSystemDefined rawCons cols pcols =
TableInfo tn isSystemDefined colMap (M.fromList []) constraints pcols (M.fromList [])
where
constraints = flip map rawCons $ uncurry TableConstraint
colMap = M.fromList $ map f cols
@ -539,6 +584,43 @@ withPermType PTSelect f = f PASelect
withPermType PTUpdate f = f PAUpdate
withPermType PTDelete f = f PADelete
addEventTriggerToCache
:: (QErrM m, CacheRWM m)
=> QualifiedTable
-> TriggerId
-> TriggerName
-> TriggerOpsDef
-> RetryConf
-> T.Text
-> m ()
addEventTriggerToCache qt trid trn tdef rconf webhook =
modTableInCache modEventTriggerInfo qt
where
modEventTriggerInfo ti = do
let eti = EventTriggerInfo
trid
trn
(getOpInfo trn ti $ tdInsert tdef)
(getOpInfo trn ti $ tdUpdate tdef)
(getOpInfo trn ti $ tdDelete tdef)
rconf
webhook
etim = tiEventTriggerInfoMap ti
-- fail $ show (toJSON eti)
return $ ti { tiEventTriggerInfoMap = M.insert trn eti etim}
delEventTriggerFromCache
:: (QErrM m, CacheRWM m)
=> QualifiedTable
-> TriggerName
-> m ()
delEventTriggerFromCache qt trn =
modTableInCache modEventTriggerInfo qt
where
modEventTriggerInfo ti = do
let etim = tiEventTriggerInfoMap ti
return $ ti { tiEventTriggerInfoMap = M.delete trn etim }
addPermToCache
:: (QErrM m, CacheRWM m)
=> QualifiedTable
@ -622,27 +704,30 @@ getDependentObjsOfQTemplateCache objId qtc =
getDependentObjsOfTable :: SchemaObjId -> TableInfo -> [SchemaObjId]
getDependentObjsOfTable objId ti =
rels ++ perms
rels ++ perms ++ triggers
where
rels = getDependentRelsOfTable (const True) objId ti
perms = getDependentPermsOfTable (const True) objId ti
triggers = getDependentTriggersOfTable (const True) objId ti
getDependentObjsOfTableWith :: (T.Text -> Bool) -> SchemaObjId -> TableInfo -> [SchemaObjId]
getDependentObjsOfTableWith f objId ti =
rels ++ perms
rels ++ perms ++ triggers
where
rels = getDependentRelsOfTable f objId ti
perms = getDependentPermsOfTable f objId ti
triggers = getDependentTriggersOfTable f objId ti
getDependentRelsOfTable :: (T.Text -> Bool) -> SchemaObjId
-> TableInfo -> [SchemaObjId]
getDependentRelsOfTable rsnFn objId (TableInfo tn _ fim _ _ _) =
getDependentRelsOfTable rsnFn objId (TableInfo tn _ fim _ _ _ _) =
map (SOTableObj tn . TORel . riName) $
filter (isDependentOn rsnFn objId) $ getRels fim
getDependentPermsOfTable :: (T.Text -> Bool) -> SchemaObjId
-> TableInfo -> [SchemaObjId]
getDependentPermsOfTable rsnFn objId (TableInfo tn _ _ rpim _ _) =
getDependentPermsOfTable rsnFn objId (TableInfo tn _ _ rpim _ _ _) =
concat $ flip M.mapWithKey rpim $
\rn rpi -> map (SOTableObj tn . TOPerm rn) $ getDependentPerms' rsnFn objId rpi
@ -658,3 +743,23 @@ getDependentPerms' rsnFn objId (RolePermInfo mipi mspi mupi mdpi) =
toPermRow :: forall a. (CachedSchemaObj a) => PermType -> a -> Maybe PermType
toPermRow pt =
bool Nothing (Just pt) . isDependentOn rsnFn objId
getDependentTriggersOfTable :: (T.Text -> Bool) -> SchemaObjId
-> TableInfo -> [SchemaObjId]
getDependentTriggersOfTable rsnFn objId (TableInfo tn _ _ _ _ _ et) =
map (SOTableObj tn . TOTrigger . otiTriggerName ) $ filter (isDependentOn rsnFn objId) $ getTriggers et
getOpInfo :: TriggerName -> TableInfo -> Maybe SubscribeOpSpec -> Maybe OpTriggerInfo
getOpInfo trn ti mos= fromSubscrOpSpec <$> mos
where
fromSubscrOpSpec :: SubscribeOpSpec -> OpTriggerInfo
fromSubscrOpSpec os =
let qt = tiName ti
cols = getColsFromSub $ sosColumns os
schemaDeps = SchemaDependency (SOTable qt) "event trigger is dependent on table"
: map (\col -> SchemaDependency (SOTableObj qt (TOCol col)) "event trigger is dependent on column") (toList cols)
in OpTriggerInfo qt trn os schemaDeps
where
getColsFromSub sc = case sc of
SubCStar -> HS.fromList $ map pgiName $ getCols $ tiFieldInfoMap ti
SubCArray pgcols -> HS.fromList pgcols

View File

@ -0,0 +1,128 @@
{-# LANGUAGE DeriveLift #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TemplateHaskell #-}
module Hasura.RQL.Types.Subscribe
( CreateEventTriggerQuery(..)
, SubscribeOpSpec(..)
, SubscribeColumns(..)
, TriggerName
, TriggerId
, TriggerOpsDef(..)
, EventTrigger(..)
, EventTriggerDef(..)
, RetryConf(..)
, DeleteEventTriggerQuery(..)
) where
import Data.Aeson
import Data.Aeson.Casing
import Data.Aeson.TH
import Data.Int (Int64)
import Hasura.Prelude
import Hasura.SQL.Types
import Language.Haskell.TH.Syntax (Lift)
import Text.Regex (matchRegex, mkRegex)
import qualified Data.Text as T
type TriggerName = T.Text
type TriggerId = T.Text
data SubscribeColumns = SubCStar | SubCArray [PGCol] deriving (Show, Eq, Lift)
instance FromJSON SubscribeColumns where
parseJSON (String s) = case s of
"*" -> return SubCStar
_ -> fail "only * or [] allowed"
parseJSON v@(Array _) = SubCArray <$> parseJSON v
parseJSON _ = fail "unexpected columns"
instance ToJSON SubscribeColumns where
toJSON SubCStar = "*"
toJSON (SubCArray cols) = toJSON cols
data SubscribeOpSpec
= SubscribeOpSpec
{ sosColumns :: !SubscribeColumns
} deriving (Show, Eq, Lift)
$(deriveJSON (aesonDrop 3 snakeCase){omitNothingFields=True} ''SubscribeOpSpec)
data RetryConf
= RetryConf
{ rcNumRetries :: !Int64
, rcIntervalSec :: !Int64
} deriving (Show, Eq, Lift)
$(deriveJSON (aesonDrop 2 snakeCase){omitNothingFields=True} ''RetryConf)
data CreateEventTriggerQuery
= CreateEventTriggerQuery
{ cetqName :: !T.Text
, cetqTable :: !QualifiedTable
, cetqInsert :: !(Maybe SubscribeOpSpec)
, cetqUpdate :: !(Maybe SubscribeOpSpec)
, cetqDelete :: !(Maybe SubscribeOpSpec)
, cetqRetryConf :: !(Maybe RetryConf)
, cetqWebhook :: !T.Text
} deriving (Show, Eq, Lift)
instance FromJSON CreateEventTriggerQuery where
parseJSON (Object o) = do
name <- o .: "name"
table <- o .: "table"
insert <- o .:? "insert"
update <- o .:? "update"
delete <- o .:? "delete"
retryConf <- o .:? "retry_conf"
webhook <- o .: "webhook"
let regex = mkRegex "^\\w+$"
mName = matchRegex regex (T.unpack name)
case mName of
Just _ -> return ()
Nothing -> fail "only alphanumeric and underscore allowed for name"
case insert <|> update <|> delete of
Just _ -> return ()
Nothing -> fail "must provide operation spec(s)"
return $ CreateEventTriggerQuery name table insert update delete retryConf webhook
parseJSON _ = fail "expecting an object"
$(deriveToJSON (aesonDrop 4 snakeCase){omitNothingFields=True} ''CreateEventTriggerQuery)
data TriggerOpsDef
= TriggerOpsDef
{ tdInsert :: !(Maybe SubscribeOpSpec)
, tdUpdate :: !(Maybe SubscribeOpSpec)
, tdDelete :: !(Maybe SubscribeOpSpec)
} deriving (Show, Eq, Lift)
$(deriveJSON (aesonDrop 2 snakeCase){omitNothingFields=True} ''TriggerOpsDef)
data DeleteEventTriggerQuery
= DeleteEventTriggerQuery
{ detqName :: !T.Text
} deriving (Show, Eq, Lift)
$(deriveJSON (aesonDrop 4 snakeCase){omitNothingFields=True} ''DeleteEventTriggerQuery)
data EventTrigger
= EventTrigger
{ etTable :: !QualifiedTable
, etName :: !TriggerName
, etDefinition :: !TriggerOpsDef
, etWebhook :: !T.Text
, etRetryConf :: !RetryConf
}
$(deriveJSON (aesonDrop 2 snakeCase){omitNothingFields=True} ''EventTrigger)
data EventTriggerDef
= EventTriggerDef
{ etdName :: !TriggerName
, etdDefinition :: !TriggerOpsDef
, etdWebhook :: !T.Text
, etdRetryConf :: !RetryConf
} deriving (Show, Eq, Lift)
$(deriveJSON (aesonDrop 3 snakeCase){omitNothingFields=True} ''EventTriggerDef)

View File

@ -273,7 +273,7 @@ mkWaiApp
-> AuthMode
-> CorsConfig
-> Bool
-> IO Wai.Application
-> IO (Wai.Application, IORef (SchemaCache, GS.GCtxMap))
mkWaiApp isoLevel mRootDir loggerCtx pool httpManager mode corsCfg enableConsole = do
cacheRef <- do
pgResp <- liftIO $ runExceptT $ Q.runTx pool (Q.Serializable, Nothing) $ do
@ -295,7 +295,7 @@ mkWaiApp isoLevel mRootDir loggerCtx pool httpManager mode corsCfg enableConsole
wsServerEnv <- WS.createWSServerEnv (scLogger serverCtx) httpManager cacheRef runTx
let wsServerApp = WS.createWSServerApp mode wsServerEnv
return $ WS.websocketsOr WS.defaultConnectionOptions wsServerApp spockApp
return (WS.websocketsOr WS.defaultConnectionOptions wsServerApp spockApp, cacheRef)
httpApp :: Maybe String -> CorsConfig -> ServerCtx -> Bool -> SpockT IO ()
httpApp mRootDir corsCfg serverCtx enableConsole = do

View File

@ -32,7 +32,7 @@ initErrExit e = print e >> exitFailure
-- clear the hdb_views schema
initStateTx :: Q.Tx ()
initStateTx = Q.unitQ clearHdbViews () False
initStateTx = clearHdbViews
data RawConnInfo =
RawConnInfo

View File

@ -74,6 +74,9 @@ data RQLQuery
| RQCount !CountQuery
| RQBulk ![RQLQuery]
| RQCreateEventTrigger !CreateEventTriggerQuery
| RQDeleteEventTrigger !DeleteEventTriggerQuery
| RQCreateQueryTemplate !CreateQueryTemplate
| RQDropQueryTemplate !DropQueryTemplate
| RQExecuteQueryTemplate !ExecQueryTemplate
@ -168,6 +171,9 @@ queryNeedsReload qi = case qi of
RQDelete q -> queryModifiesSchema q
RQCount q -> queryModifiesSchema q
RQCreateEventTrigger q -> queryModifiesSchema q
RQDeleteEventTrigger q -> queryModifiesSchema q
RQCreateQueryTemplate q -> queryModifiesSchema q
RQDropQueryTemplate q -> queryModifiesSchema q
RQExecuteQueryTemplate q -> queryModifiesSchema q
@ -214,6 +220,9 @@ buildTxAny userInfo sc rq = case rq of
RQDelete q -> buildTx userInfo sc q
RQCount q -> buildTx userInfo sc q
RQCreateEventTrigger q -> buildTx userInfo sc q
RQDeleteEventTrigger q -> buildTx userInfo sc q
RQCreateQueryTemplate q -> buildTx userInfo sc q
RQDropQueryTemplate q -> buildTx userInfo sc q
RQExecuteQueryTemplate q -> buildTx userInfo sc q

View File

@ -179,3 +179,68 @@ args:
args:
schema: hdb_catalog
name: hdb_query_template
- type: add_existing_table_or_view
args:
schema: hdb_catalog
name: event_triggers
- type: add_existing_table_or_view
args:
schema: hdb_catalog
name: event_log
- type: add_existing_table_or_view
args:
schema: hdb_catalog
name: event_invocation_logs
- type: create_object_relationship
args:
name: trigger
table:
schema: hdb_catalog
name: event_log
using:
manual_configuration:
remote_table:
schema: hdb_catalog
name: event_triggers
column_mapping:
trigger_id : id
- type: create_array_relationship
args:
name: events
table:
schema: hdb_catalog
name: event_triggers
using:
manual_configuration:
remote_table:
schema: hdb_catalog
name: event_log
column_mapping:
id : trigger_id
- type: create_object_relationship
args:
name: event
table:
schema: hdb_catalog
name: event_invocation_logs
using:
foreign_key_constraint_on: event_id
- type: create_array_relationship
args:
name: logs
table:
schema: hdb_catalog
name: event_log
using:
foreign_key_constraint_on:
table:
schema: hdb_catalog
name: event_invocation_logs
column: event_id

View File

@ -179,3 +179,48 @@ LANGUAGE plpgsql AS $$
END LOOP;
END;
$$;
-- required for generating uuid
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE hdb_catalog.event_triggers
(
id TEXT DEFAULT gen_random_uuid() PRIMARY KEY,
name TEXT UNIQUE,
type TEXT NOT NULL,
schema_name TEXT NOT NULL,
table_name TEXT NOT NULL,
definition JSON,
query TEXT,
webhook TEXT NOT NULL,
num_retries INTEGER DEFAULT 0,
retry_interval INTEGER DEFAULT 10,
comment TEXT
);
CREATE TABLE hdb_catalog.event_log
(
id TEXT DEFAULT gen_random_uuid() PRIMARY KEY,
schema_name TEXT NOT NULL,
table_name TEXT NOT NULL,
trigger_id TEXT NOT NULL,
trigger_name TEXT NOT NULL,
payload JSONB NOT NULL,
delivered BOOLEAN NOT NULL DEFAULT FALSE,
error BOOLEAN NOT NULL DEFAULT FALSE,
tries INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
locked BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE hdb_catalog.event_invocation_logs
(
id TEXT DEFAULT gen_random_uuid() PRIMARY KEY,
event_id TEXT,
status INTEGER,
request JSON,
response JSON,
created_at TIMESTAMP DEFAULT NOW(),
FOREIGN KEY (event_id) REFERENCES hdb_catalog.event_log (id)
);

View File

@ -0,0 +1,26 @@
CREATE OR REPLACE function hdb_views.notify_hasura_{{NAME}}_{{OPERATION}}() RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
payload json;
_data json;
id text;
BEGIN
id := gen_random_uuid();
_data := json_build_object(
'old', {{OLD_DATA_EXPRESSION}},
'new', {{NEW_DATA_EXPRESSION}}
);
payload := json_build_object(
'op', TG_OP,
'data', _data
)::text;
INSERT INTO
hdb_catalog.event_log (id, schema_name, table_name, trigger_name, trigger_id, payload)
VALUES
(id, TG_TABLE_SCHEMA, TG_TABLE_NAME, '{{NAME}}', '{{ID}}', payload);
RETURN NULL;
END;
$$;
DROP TRIGGER IF EXISTS notify_hasura_{{NAME}}_{{OPERATION}} ON {{SCHEMA_NAME}}.{{TABLE_NAME}};
CREATE TRIGGER notify_hasura_{{NAME}}_{{OPERATION}} AFTER {{OPERATION}} ON {{SCHEMA_NAME}}.{{TABLE_NAME}} FOR EACH ROW EXECUTE PROCEDURE hdb_views.notify_hasura_{{NAME}}_{{OPERATION}}();

View File

@ -19,6 +19,8 @@ extra-deps:
commit: e61bc37794b4d9e281bad44b2d7c8d35f2dbc770
- git: https://github.com/hasura/graphql-parser-hs.git
commit: eae59812ec537b3756c3ddb5f59a7cc59508869b
- git: https://github.com/tdammers/ginger.git
commit: 435c2774963050da04ce9a3369755beac87fbb16
- Spock-core-0.13.0.0
- reroute-0.5.0.0

View File

@ -46,7 +46,9 @@ ravenApp loggerCtx pool = do
let corsCfg = CorsConfigG "*" False -- cors is enabled
httpManager <- HTTP.newManager HTTP.tlsManagerSettings
-- spockAsApp $ spockT id $ app Q.Serializable Nothing rlogger pool AMNoAuth corsCfg True -- no access key and no webhook
mkWaiApp Q.Serializable Nothing loggerCtx pool httpManager AMNoAuth corsCfg True -- no access key and no webhook
(app, _) <- mkWaiApp Q.Serializable Nothing loggerCtx pool httpManager AMNoAuth corsCfg True -- no access key and no webhook
return app
main :: IO ()
main = do
@ -63,7 +65,7 @@ main = do
liftIO $ initialise pool
-- generate the test specs
specs <- mkSpecs
loggerCtx <- L.mkLoggerCtx L.defaultLoggerSettings
loggerCtx <- L.mkLoggerCtx $ L.defaultLoggerSettings True
-- run the tests
withArgs [] $ hspecWith defaultConfig $ with (ravenApp loggerCtx pool) specs