diff --git a/.gitignore b/.gitignore
index 4c93a874b..eb93aa3c2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,6 +18,8 @@ dist
caravel.egg-info/
app.db
*.bak
+.idea
+*.sqllite
# Node.js, webpack artifacts
*.entry.js
diff --git a/MANIFEST.in b/MANIFEST.in
index 72c6b65f0..7a38c6240 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -3,6 +3,7 @@ recursive-include caravel/static *
recursive-exclude caravel/static/assets/node_modules *
recursive-include caravel/static/assets/node_modules/font-awesome *
recursive-exclude caravel/static/docs *
+recursive-exclude caravel/static/spec *
recursive-exclude tests *
recursive-include caravel/data *
recursive-include caravel/migrations *
diff --git a/caravel/assets/.eslintignore b/caravel/assets/.eslintignore
index 8999d0d1c..56f591640 100644
--- a/caravel/assets/.eslintignore
+++ b/caravel/assets/.eslintignore
@@ -1,5 +1,5 @@
node_modules/*
vendor/*
-javascripts/dist/*
+dist/*
stylesheets/*
spec/*
diff --git a/caravel/assets/javascripts/SqlLab/TODO.md b/caravel/assets/javascripts/SqlLab/TODO.md
new file mode 100644
index 000000000..db8f247ab
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/TODO.md
@@ -0,0 +1,19 @@
+
+# TODO
+* Figure out how to organize the left panel, integrate Search
+* collapse sql beyond 10 lines
+* Security per-database (dropdown)
+* Get a to work
+
+## Cosmetic
+* Result set font is too big
+* lmiit/timer/buttons wrap
+* table label is transparent
+* SqlEditor buttons
+* use react-bootstrap-prompt for query title input
+* Make tabs look great
+
+# PROJECT
+* Write Runbook
+* Confirm backups
+* merge chef branch
diff --git a/caravel/assets/javascripts/SqlLab/actions.js b/caravel/assets/javascripts/SqlLab/actions.js
new file mode 100644
index 000000000..69473b37b
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/actions.js
@@ -0,0 +1,116 @@
+export const RESET_STATE = 'RESET_STATE';
+export const ADD_QUERY_EDITOR = 'ADD_QUERY_EDITOR';
+export const REMOVE_QUERY_EDITOR = 'REMOVE_QUERY_EDITOR';
+export const ADD_TABLE = 'ADD_TABLE';
+export const REMOVE_TABLE = 'REMOVE_TABLE';
+export const START_QUERY = 'START_QUERY';
+export const STOP_QUERY = 'STOP_QUERY';
+export const END_QUERY = 'END_QUERY';
+export const REMOVE_QUERY = 'REMOVE_QUERY';
+export const EXPAND_TABLE = 'EXPAND_TABLE';
+export const COLLAPSE_TABLE = 'COLLAPSE_TABLE';
+export const QUERY_SUCCESS = 'QUERY_SUCCESS';
+export const QUERY_FAILED = 'QUERY_FAILED';
+export const QUERY_EDITOR_SETDB = 'QUERY_EDITOR_SETDB';
+export const QUERY_EDITOR_SET_SCHEMA = 'QUERY_EDITOR_SET_SCHEMA';
+export const QUERY_EDITOR_SET_TITLE = 'QUERY_EDITOR_SET_TITLE';
+export const QUERY_EDITOR_SET_AUTORUN = 'QUERY_EDITOR_SET_AUTORUN';
+export const QUERY_EDITOR_SET_SQL = 'QUERY_EDITOR_SET_SQL';
+export const SET_WORKSPACE_DB = 'SET_WORKSPACE_DB';
+export const ADD_WORKSPACE_QUERY = 'ADD_WORKSPACE_QUERY';
+export const REMOVE_WORKSPACE_QUERY = 'REMOVE_WORKSPACE_QUERY';
+export const SET_ACTIVE_QUERY_EDITOR = 'SET_ACTIVE_QUERY_EDITOR';
+export const ADD_ALERT = 'ADD_ALERT';
+export const REMOVE_ALERT = 'REMOVE_ALERT';
+export const REFRESH_QUERIES = 'REFRESH_QUERIES';
+
+export function resetState() {
+ return { type: RESET_STATE };
+}
+
+export function addQueryEditor(queryEditor) {
+ return { type: ADD_QUERY_EDITOR, queryEditor };
+}
+
+export function addAlert(alert) {
+ return { type: ADD_ALERT, alert };
+}
+
+export function removeAlert(alert) {
+ return { type: REMOVE_ALERT, alert };
+}
+
+export function setActiveQueryEditor(queryEditor) {
+ return { type: SET_ACTIVE_QUERY_EDITOR, queryEditor };
+}
+
+export function removeQueryEditor(queryEditor) {
+ return { type: REMOVE_QUERY_EDITOR, queryEditor };
+}
+
+export function removeQuery(query) {
+ return { type: REMOVE_QUERY, query };
+}
+
+export function queryEditorSetDb(queryEditor, dbId) {
+ return { type: QUERY_EDITOR_SETDB, queryEditor, dbId };
+}
+
+export function queryEditorSetSchema(queryEditor, schema) {
+ return { type: QUERY_EDITOR_SET_SCHEMA, queryEditor, schema };
+}
+
+export function queryEditorSetAutorun(queryEditor, autorun) {
+ return { type: QUERY_EDITOR_SET_AUTORUN, queryEditor, autorun };
+}
+
+export function queryEditorSetTitle(queryEditor, title) {
+ return { type: QUERY_EDITOR_SET_TITLE, queryEditor, title };
+}
+
+export function queryEditorSetSql(queryEditor, sql) {
+ return { type: QUERY_EDITOR_SET_SQL, queryEditor, sql };
+}
+
+export function addTable(table) {
+ return { type: ADD_TABLE, table };
+}
+
+export function expandTable(table) {
+ return { type: EXPAND_TABLE, table };
+}
+
+export function collapseTable(table) {
+ return { type: COLLAPSE_TABLE, table };
+}
+
+export function removeTable(table) {
+ return { type: REMOVE_TABLE, table };
+}
+
+export function startQuery(query) {
+ return { type: START_QUERY, query };
+}
+
+export function stopQuery(query) {
+ return { type: STOP_QUERY, query };
+}
+
+export function querySuccess(query, results) {
+ return { type: QUERY_SUCCESS, query, results };
+}
+
+export function queryFailed(query, msg) {
+ return { type: QUERY_FAILED, query, msg };
+}
+
+export function addWorkspaceQuery(query) {
+ return { type: ADD_WORKSPACE_QUERY, query };
+}
+
+export function removeWorkspaceQuery(query) {
+ return { type: REMOVE_WORKSPACE_QUERY, query };
+}
+export function refreshQueries(alteredQueries) {
+ return { type: REFRESH_QUERIES, alteredQueries };
+}
diff --git a/caravel/assets/javascripts/SqlLab/common.js b/caravel/assets/javascripts/SqlLab/common.js
new file mode 100644
index 000000000..4851d5dba
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/common.js
@@ -0,0 +1,6 @@
+export const STATE_BSSTYLE_MAP = {
+ failed: 'danger',
+ pending: 'info',
+ running: 'warning',
+ success: 'success',
+};
diff --git a/caravel/assets/javascripts/SqlLab/components/Alerts.jsx b/caravel/assets/javascripts/SqlLab/components/Alerts.jsx
new file mode 100644
index 000000000..0ad6d845e
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/components/Alerts.jsx
@@ -0,0 +1,41 @@
+import React from 'react';
+import { Alert } from 'react-bootstrap';
+import { connect } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import * as Actions from '../actions';
+
+class Alerts extends React.Component {
+ removeAlert(alert) {
+ this.props.actions.removeAlert(alert);
+ }
+ render() {
+ const alerts = this.props.alerts.map((alert) =>
+
+ {alert.msg}
+
+
+ );
+ return (
+
{alerts}
+ );
+ }
+}
+
+Alerts.propTypes = {
+ alerts: React.PropTypes.array,
+ actions: React.PropTypes.object,
+};
+
+function mapDispatchToProps(dispatch) {
+ return {
+ actions: bindActionCreators(Actions, dispatch),
+ };
+}
+export default connect(null, mapDispatchToProps)(Alerts);
diff --git a/caravel/assets/javascripts/SqlLab/components/ButtonWithTooltip.jsx b/caravel/assets/javascripts/SqlLab/components/ButtonWithTooltip.jsx
new file mode 100644
index 000000000..e98da5a9d
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/components/ButtonWithTooltip.jsx
@@ -0,0 +1,48 @@
+import React from 'react';
+import { Button, OverlayTrigger, Tooltip } from 'react-bootstrap';
+
+const ButtonWithTooltip = (props) => {
+ let tooltip = (
+
+ {props.tooltip}
+
+ );
+ return (
+
+
+ {props.children}
+
+
+ );
+};
+
+ButtonWithTooltip.defaultProps = {
+ onClick: () => {},
+ disabled: false,
+ placement: 'top',
+ bsStyle: 'default',
+};
+
+ButtonWithTooltip.propTypes = {
+ bsSize: React.PropTypes.string,
+ bsStyle: React.PropTypes.string,
+ children: React.PropTypes.element,
+ className: React.PropTypes.string,
+ disabled: React.PropTypes.bool,
+ onClick: React.PropTypes.func,
+ placement: React.PropTypes.string,
+ tooltip: React.PropTypes.string,
+};
+
+export default ButtonWithTooltip;
diff --git a/caravel/assets/javascripts/SqlLab/components/LeftPane.jsx b/caravel/assets/javascripts/SqlLab/components/LeftPane.jsx
new file mode 100644
index 000000000..fc2190a2b
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/components/LeftPane.jsx
@@ -0,0 +1,62 @@
+import React from 'react';
+import { Alert, Button } from 'react-bootstrap';
+import { connect } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import * as Actions from '../actions';
+import QueryLink from './QueryLink';
+
+import 'react-select/dist/react-select.css';
+
+const LeftPane = (props) => {
+ let queryElements;
+ if (props.workspaceQueries.length > 0) {
+ queryElements = props.workspaceQueries.map((q) => );
+ } else {
+ queryElements = (
+
+ Use the save button on the SQL editor to save a query
+ into this section for future reference.
+
+ );
+ }
+ return (
+
+
+
+
+ {queryElements}
+
+
+
+
+ Reset State
+
+
+ );
+};
+
+LeftPane.propTypes = {
+ workspaceQueries: React.PropTypes.array,
+ actions: React.PropTypes.object,
+};
+
+LeftPane.defaultProps = {
+ workspaceQueries: [],
+};
+
+function mapStateToProps(state) {
+ return {
+ workspaceQueries: state.workspaceQueries,
+ };
+}
+function mapDispatchToProps(dispatch) {
+ return {
+ actions: bindActionCreators(Actions, dispatch),
+ };
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(LeftPane);
diff --git a/caravel/assets/javascripts/SqlLab/components/Link.jsx b/caravel/assets/javascripts/SqlLab/components/Link.jsx
new file mode 100644
index 000000000..4fff9db7e
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/components/Link.jsx
@@ -0,0 +1,54 @@
+import React from 'react';
+import { OverlayTrigger, Tooltip } from 'react-bootstrap';
+
+
+class Link extends React.Component {
+ render() {
+ let tooltip = (
+
+ {this.props.tooltip}
+
+ );
+ const link = (
+
+ {this.props.children}
+
+ );
+ if (this.props.tooltip) {
+ return (
+
+ {link}
+
+ );
+ }
+ return link;
+ }
+}
+Link.propTypes = {
+ children: React.PropTypes.object,
+ className: React.PropTypes.string,
+ href: React.PropTypes.string,
+ onClick: React.PropTypes.func,
+ placement: React.PropTypes.string,
+ style: React.PropTypes.object,
+ tooltip: React.PropTypes.string,
+};
+Link.defaultProps = {
+ disabled: false,
+ href: '#',
+ tooltip: null,
+ placement: 'top',
+ onClick: () => {},
+};
+
+export default Link;
diff --git a/caravel/assets/javascripts/SqlLab/components/QueryAutoRefresh.jsx b/caravel/assets/javascripts/SqlLab/components/QueryAutoRefresh.jsx
new file mode 100644
index 000000000..a3cf3cce9
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/components/QueryAutoRefresh.jsx
@@ -0,0 +1,55 @@
+import React from 'react';
+import { bindActionCreators } from 'redux';
+import { connect } from 'react-redux';
+import * as Actions from '../actions';
+
+const $ = require('jquery');
+
+
+class QueryAutoRefresh extends React.Component {
+ componentWillMount() {
+ this.startTimer();
+ }
+ componentWillUnmount() {
+ this.stopTimer();
+ }
+ startTimer() {
+ if (!(this.timer)) {
+ this.timer = setInterval(this.stopwatch.bind(this), 5000);
+ }
+ }
+ stopTimer() {
+ clearInterval(this.timer);
+ this.timer = null;
+ }
+ stopwatch() {
+ const url = '/caravel/queries/0';
+ // No updates in case of failure.
+ $.getJSON(url, (data, status) => {
+ if (status === 'success') {
+ this.props.actions.refreshQueries(data);
+ }
+ });
+ }
+ render() {
+ return null;
+ }
+}
+QueryAutoRefresh.propTypes = {
+ actions: React.PropTypes.object,
+};
+QueryAutoRefresh.defaultProps = {
+ // queries: null,
+};
+
+function mapStateToProps() {
+ return {};
+}
+
+function mapDispatchToProps(dispatch) {
+ return {
+ actions: bindActionCreators(Actions, dispatch),
+ };
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(QueryAutoRefresh);
diff --git a/caravel/assets/javascripts/SqlLab/components/QueryHistory.jsx b/caravel/assets/javascripts/SqlLab/components/QueryHistory.jsx
new file mode 100644
index 000000000..3d6629677
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/components/QueryHistory.jsx
@@ -0,0 +1,58 @@
+import React from 'react';
+
+import { connect } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import * as Actions from '../actions';
+
+import QueryTable from './QueryTable';
+import { Alert } from 'react-bootstrap';
+
+const QueryHistory = (props) => {
+ const activeQeId = props.tabHistory[props.tabHistory.length - 1];
+ const queriesArray = [];
+ for (const id in props.queries) {
+ if (props.queries[id].sqlEditorId === activeQeId) {
+ queriesArray.push(props.queries[id]);
+ }
+ }
+ if (queriesArray.length > 0) {
+ return (
+
+ );
+ }
+ return (
+
+ No query history yet...
+
+ );
+};
+
+QueryHistory.defaultProps = {
+ queries: {},
+};
+
+QueryHistory.propTypes = {
+ queries: React.PropTypes.object,
+ tabHistory: React.PropTypes.array,
+ actions: React.PropTypes.object,
+};
+
+function mapStateToProps(state) {
+ return {
+ queries: state.queries,
+ tabHistory: state.tabHistory,
+ };
+}
+function mapDispatchToProps(dispatch) {
+ return {
+ actions: bindActionCreators(Actions, dispatch),
+ };
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(QueryHistory);
diff --git a/caravel/assets/javascripts/SqlLab/components/QueryLink.jsx b/caravel/assets/javascripts/SqlLab/components/QueryLink.jsx
new file mode 100644
index 000000000..f59390eff
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/components/QueryLink.jsx
@@ -0,0 +1,65 @@
+import React from 'react';
+import Link from './Link';
+import { connect } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import * as Actions from '../actions';
+import shortid from 'shortid';
+
+// CSS
+import 'react-select/dist/react-select.css';
+
+class QueryLink extends React.Component {
+ popTab() {
+ const qe = {
+ id: shortid.generate(),
+ title: this.props.query.title,
+ dbId: this.props.query.dbId,
+ autorun: false,
+ sql: this.props.query.sql,
+ };
+ this.props.actions.addQueryEditor(qe);
+ }
+ render() {
+ return (
+
+ );
+ }
+}
+
+QueryLink.propTypes = {
+ query: React.PropTypes.object,
+ actions: React.PropTypes.object,
+};
+
+QueryLink.defaultProps = {
+};
+
+function mapDispatchToProps(dispatch) {
+ return {
+ actions: bindActionCreators(Actions, dispatch),
+ };
+}
+export default connect(null, mapDispatchToProps)(QueryLink);
+
diff --git a/caravel/assets/javascripts/SqlLab/components/QuerySearch.jsx b/caravel/assets/javascripts/SqlLab/components/QuerySearch.jsx
new file mode 100644
index 000000000..d9a84a2e8
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/components/QuerySearch.jsx
@@ -0,0 +1,73 @@
+import React from 'react';
+import Select from 'react-select';
+import { Button } from 'react-bootstrap';
+
+import { connect } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import * as Actions from '../actions';
+import QueryTable from './QueryTable';
+
+class QuerySearch extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ queryText: '',
+ };
+ }
+ changeQueryText(value) {
+ this.setState({ queryText: value });
+ }
+ render() {
+ const queries = this.props.queries;
+ return (
+
+
+
+
+
+ Search Queries
+
+
+
+
+
+
+
+
+
+
+
+
Search!
+
+ );
+ }
+}
+QuerySearch.propTypes = {
+ queries: React.PropTypes.array,
+};
+QuerySearch.defaultProps = {
+ queries: [],
+};
+
+function mapStateToProps(state) {
+ return {
+ queries: state.queries,
+ };
+}
+function mapDispatchToProps(dispatch) {
+ return {
+ actions: bindActionCreators(Actions, dispatch),
+ };
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(QuerySearch);
diff --git a/caravel/assets/javascripts/SqlLab/components/QueryTable.jsx b/caravel/assets/javascripts/SqlLab/components/QueryTable.jsx
new file mode 100644
index 000000000..157242169
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/components/QueryTable.jsx
@@ -0,0 +1,142 @@
+import React from 'react';
+
+import { connect } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import * as Actions from '../actions';
+
+import moment from 'moment';
+import { Table } from 'reactable';
+import { ProgressBar } from 'react-bootstrap';
+
+import SyntaxHighlighter from 'react-syntax-highlighter';
+import { github } from 'react-syntax-highlighter/dist/styles';
+
+import Link from './Link';
+import VisualizeModal from './VisualizeModal';
+import { STATE_BSSTYLE_MAP } from '../common';
+import { fDuration } from '../../modules/dates';
+
+
+class QueryTable extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ showVisualizeModal: false,
+ activeQuery: null,
+ };
+ }
+ hideVisualizeModal() {
+ this.setState({ showVisualizeModal: false });
+ }
+ showVisualizeModal(query) {
+ this.setState({ showVisualizeModal: true });
+ this.setState({ activeQuery: query });
+ }
+ restoreSql(query) {
+ this.props.actions.queryEditorSetSql({ id: query.sqlEditorId }, query.sql);
+ }
+ notImplemented() {
+ alert('Not implemented yet!');
+ }
+ render() {
+ const data = this.props.queries.map((query) => {
+ const q = Object.assign({}, query);
+ if (q.endDttm) {
+ q.duration = fDuration(q.startDttm, q.endDttm);
+ }
+ q.started = moment.utc(q.startDttm).format('HH:mm:ss');
+ const source = q.ctas ? q.executedSql : q.sql;
+ q.sql = (
+
+ {source || ''}
+
+ );
+ q.output = q.tempTable;
+ q.progress = (
+
+ );
+ let errorTooltip;
+ if (q.errorMessage) {
+ errorTooltip = (
+
+
+
+ );
+ }
+ q.state = (
+
+
+ {q.state}
+
+ {errorTooltip}
+
+ );
+ q.actions = (
+
+
+
+
+
+
+ );
+
+ return q;
+ }).reverse();
+ return (
+
+ );
+ }
+}
+QueryTable.propTypes = {
+ columns: React.PropTypes.array,
+ actions: React.PropTypes.object,
+ queries: React.PropTypes.array,
+};
+QueryTable.defaultProps = {
+ columns: ['state', 'started', 'duration', 'progress', 'rows', 'sql', 'actions'],
+ queries: [],
+};
+
+function mapStateToProps() {
+ return {};
+}
+function mapDispatchToProps(dispatch) {
+ return {
+ actions: bindActionCreators(Actions, dispatch),
+ };
+}
+export default connect(mapStateToProps, mapDispatchToProps)(QueryTable);
diff --git a/caravel/assets/javascripts/SqlLab/components/ResultSet.jsx b/caravel/assets/javascripts/SqlLab/components/ResultSet.jsx
new file mode 100644
index 000000000..baa59fd79
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/components/ResultSet.jsx
@@ -0,0 +1,101 @@
+import React from 'react';
+import { Alert, Button, ButtonGroup } from 'react-bootstrap';
+import { Table } from 'reactable';
+
+import VisualizeModal from './VisualizeModal';
+
+
+class ResultSet extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ searchText: '',
+ showModal: false,
+ };
+ }
+ shouldComponentUpdate(nextProps, nextState) {
+ return (
+ this.state.searchText !== nextState.searchText ||
+ this.state.showModal !== nextState.showModal
+ );
+ }
+ changeSearch(event) {
+ this.setState({ searchText: event.target.value });
+ }
+ showModal() {
+ this.setState({ showModal: true });
+ }
+ hideModal() {
+ this.setState({ showModal: false });
+ }
+ render() {
+ const results = this.props.query.results;
+ let controls =
;
+ if (this.props.showControls) {
+ controls = (
+
+
+
+
+
+ Visualize
+
+
+ .CSV
+
+
+
+
+
+
+
+
+ );
+ }
+ if (results && results.data.length > 0) {
+ return (
+
+ );
+ }
+ return (The query returned no data );
+ }
+}
+ResultSet.propTypes = {
+ query: React.PropTypes.object,
+ showControls: React.PropTypes.boolean,
+ search: React.PropTypes.boolean,
+ searchText: React.PropTypes.string,
+};
+ResultSet.defaultProps = {
+ showControls: true,
+ search: true,
+ searchText: '',
+};
+
+export default ResultSet;
diff --git a/caravel/assets/javascripts/SqlLab/components/SouthPane.jsx b/caravel/assets/javascripts/SqlLab/components/SouthPane.jsx
new file mode 100644
index 000000000..4c078fbf4
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/components/SouthPane.jsx
@@ -0,0 +1,46 @@
+import { Alert, Panel, Tab, Tabs } from 'react-bootstrap';
+import QueryHistory from './QueryHistory';
+import ResultSet from './ResultSet';
+import React from 'react';
+
+const SouthPane = function (props) {
+ let results =
;
+ if (props.latestQuery) {
+ if (props.latestQuery.state === 'running') {
+ results = (
+
+ );
+ } else if (props.latestQuery.state === 'failed') {
+ results = {props.latestQuery.msg} ;
+ } else if (props.latestQuery.state === 'success') {
+ results = ;
+ }
+ } else {
+ results = Run a query to display results here ;
+ }
+ return (
+
+
+
+
+ {results}
+
+
+
+
+
+
+
+
+
+ );
+};
+
+SouthPane.propTypes = {
+ latestQuery: React.PropTypes.object,
+};
+
+SouthPane.defaultProps = {
+};
+
+export default SouthPane;
diff --git a/caravel/assets/javascripts/SqlLab/components/SqlEditor.jsx b/caravel/assets/javascripts/SqlLab/components/SqlEditor.jsx
new file mode 100644
index 000000000..0c3fab719
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/components/SqlEditor.jsx
@@ -0,0 +1,292 @@
+const $ = require('jquery');
+import { now } from '../../modules/dates';
+import React from 'react';
+import {
+ Button,
+ ButtonGroup,
+ FormGroup,
+ InputGroup,
+ Form,
+ FormControl,
+ DropdownButton,
+ Label,
+ MenuItem,
+ OverlayTrigger,
+ Tooltip,
+} from 'react-bootstrap';
+
+import AceEditor from 'react-ace';
+import 'brace/mode/sql';
+import 'brace/theme/github';
+import 'brace/ext/language_tools';
+
+import { bindActionCreators } from 'redux';
+import { connect } from 'react-redux';
+import * as Actions from '../actions';
+
+import shortid from 'shortid';
+import ButtonWithTooltip from './ButtonWithTooltip';
+import SouthPane from './SouthPane';
+import Timer from './Timer';
+
+import SqlEditorTopToolbar from './SqlEditorTopToolbar';
+
+// CSS
+import 'react-select/dist/react-select.css';
+
+class SqlEditor extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ autorun: props.queryEditor.autorun,
+ sql: props.queryEditor.sql,
+ ctas: '',
+ };
+ }
+ componentDidMount() {
+ this.onMount();
+ }
+ onMount() {
+ if (this.state.autorun) {
+ this.setState({ autorun: false });
+ this.props.actions.queryEditorSetAutorun(this.props.queryEditor, false);
+ this.startQuery();
+ }
+ }
+ runQuery() {
+ this.startQuery();
+ }
+ startQuery(runAsync = false, ctas = false) {
+ const that = this;
+ const query = {
+ dbId: this.props.queryEditor.dbId,
+ id: shortid.generate(),
+ progress: 0,
+ sql: this.props.queryEditor.sql,
+ sqlEditorId: this.props.queryEditor.id,
+ startDttm: now(),
+ state: 'running',
+ tab: this.props.queryEditor.title,
+ };
+ if (runAsync) {
+ query.state = 'pending';
+ }
+
+ // Execute the Query
+ that.props.actions.startQuery(query);
+
+ const sqlJsonUrl = '/caravel/sql_json/';
+ const sqlJsonRequest = {
+ async: runAsync,
+ client_id: query.id,
+ database_id: this.props.queryEditor.dbId,
+ json: true,
+ schema: this.props.queryEditor.schema,
+ select_as_cta: ctas,
+ sql: this.props.queryEditor.sql,
+ sql_editor_id: this.props.queryEditor.id,
+ tab: this.props.queryEditor.title,
+ tmp_table_name: this.state.ctas,
+ };
+ $.ajax({
+ type: 'POST',
+ dataType: 'json',
+ url: sqlJsonUrl,
+ data: sqlJsonRequest,
+ success(results) {
+ if (!runAsync) {
+ that.props.actions.querySuccess(query, results);
+ }
+ },
+ error(err) {
+ let msg;
+ try {
+ msg = err.responseJSON.error;
+ } catch (e) {
+ msg = (err.responseText) ? err.responseText : e;
+ }
+ if (typeof(msg) !== 'string') {
+ msg = JSON.stringify(msg);
+ }
+ that.props.actions.queryFailed(query, msg);
+ },
+ });
+ }
+ stopQuery() {
+ this.props.actions.stopQuery(this.props.latestQuery);
+ }
+ createTableAs() {
+ this.startQuery(true, true);
+ }
+ textChange(text) {
+ this.setState({ sql: text });
+ this.props.actions.queryEditorSetSql(this.props.queryEditor, text);
+ }
+ addWorkspaceQuery() {
+ this.props.actions.addWorkspaceQuery({
+ id: shortid.generate(),
+ sql: this.state.sql,
+ dbId: this.props.queryEditor.dbId,
+ schema: this.props.queryEditor.schema,
+ title: this.props.queryEditor.title,
+ });
+ }
+ ctasChange() {}
+ visualize() {}
+ ctasChanged(event) {
+ this.setState({ ctas: event.target.value });
+ }
+ render() {
+ let runButtons = (
+
+
+ Run Query
+
+
+ );
+ if (this.props.latestQuery && this.props.latestQuery.state === 'running') {
+ runButtons = (
+
+
+ Stop
+
+
+ );
+ }
+ const rightButtons = (
+
+
+
+
+ }
+ >
+
+ export to .csv
+
+
+ export to .json
+
+
+
+ );
+ let limitWarning = null;
+ const rowLimit = 1000;
+ if (this.props.latestQuery && this.props.latestQuery.rows === rowLimit) {
+ const tooltip = (
+
+ It appears that the number of rows in the query results displayed
+ was limited on the server side to the {rowLimit} limit.
+
+ );
+ limitWarning = (
+
+ LIMIT
+
+ );
+ }
+ const editorBottomBar = (
+
+
+
+
+
+ {limitWarning}
+
+ {rightButtons}
+
+
+ );
+ return (
+
+
+
+
+
+ {editorBottomBar}
+
+
+
+
+
+ );
+ }
+}
+
+SqlEditor.propTypes = {
+ queryEditor: React.PropTypes.object,
+ actions: React.PropTypes.object,
+ latestQuery: React.PropTypes.object,
+};
+
+SqlEditor.defaultProps = {
+};
+
+function mapStateToProps() {
+ return {};
+}
+
+function mapDispatchToProps(dispatch) {
+ return {
+ actions: bindActionCreators(Actions, dispatch),
+ };
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(SqlEditor);
diff --git a/caravel/assets/javascripts/SqlLab/components/SqlEditorTopToolbar.jsx b/caravel/assets/javascripts/SqlLab/components/SqlEditorTopToolbar.jsx
new file mode 100644
index 000000000..b1cff7f3f
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/components/SqlEditorTopToolbar.jsx
@@ -0,0 +1,271 @@
+const $ = window.$ = require('jquery');
+import React from 'react';
+import { Label, OverlayTrigger, Popover } from 'react-bootstrap';
+
+import { bindActionCreators } from 'redux';
+import { connect } from 'react-redux';
+import * as Actions from '../actions';
+import shortid from 'shortid';
+import Select from 'react-select';
+import Link from './Link';
+
+// CSS
+import 'react-select/dist/react-select.css';
+
+class SqlEditorTopToolbar extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ databaseLoading: false,
+ databaseOptions: [],
+ schemaLoading: false,
+ schemaOptions: [],
+ tableLoading: false,
+ tableOptions: [],
+ };
+ }
+ componentWillMount() {
+ this.fetchDatabaseOptions();
+ this.fetchSchemas();
+ this.fetchTables();
+ }
+ getSql(table) {
+ let cols = '';
+ table.columns.forEach(function (col, i) {
+ cols += col.name;
+ if (i < table.columns.length - 1) {
+ cols += ', ';
+ }
+ });
+ return `SELECT ${cols}\nFROM ${table.name}`;
+ }
+ selectStar(table) {
+ this.props.actions.queryEditorSetSql(this.props.queryEditor, this.getSql(table));
+ }
+ popTab(table) {
+ const qe = {
+ id: shortid.generate(),
+ title: table.name,
+ dbId: table.dbId,
+ schema: table.schema,
+ autorun: true,
+ sql: this.getSql(table),
+ };
+ this.props.actions.addQueryEditor(qe);
+ }
+ fetchTables(dbId, schema) {
+ const actualDbId = dbId || this.props.queryEditor.dbId;
+ if (actualDbId) {
+ const actualSchema = schema || this.props.queryEditor.schema;
+ this.setState({ tableLoading: true });
+ this.setState({ tableOptions: [] });
+ const url = `/caravel/tables/${actualDbId}/${actualSchema}`;
+ $.get(url, (data) => {
+ let tableOptions = data.tables.map((s) => ({ value: s, label: s }));
+ const views = data.views.map((s) => ({ value: s, label: '[view] ' + s }));
+ tableOptions = [...tableOptions, ...views];
+ this.setState({ tableOptions });
+ this.setState({ tableLoading: false });
+ });
+ }
+ }
+ changeSchema(schemaOpt) {
+ const schema = (schemaOpt) ? schemaOpt.value : null;
+ this.props.actions.queryEditorSetSchema(this.props.queryEditor, schema);
+ this.fetchTables(this.props.queryEditor.dbId, schema);
+ }
+ fetchSchemas(dbId) {
+ const actualDbId = dbId || this.props.queryEditor.dbId;
+ if (actualDbId) {
+ this.setState({ schemaLoading: true });
+ const url = `/databasetablesasync/api/read?_flt_0_id=${actualDbId}`;
+ $.get(url, (data) => {
+ const schemas = data.result[0].all_schema_names;
+ const schemaOptions = schemas.map((s) => ({ value: s, label: s }));
+ this.setState({ schemaOptions });
+ this.setState({ schemaLoading: false });
+ });
+ }
+ }
+ changeDb(db) {
+ const val = (db) ? db.value : null;
+ this.setState({ schemaOptions: [] });
+ this.props.actions.queryEditorSetDb(this.props.queryEditor, val);
+ if (!(db)) {
+ this.setState({ tableOptions: [] });
+ return;
+ }
+ this.fetchTables(val, this.props.queryEditor.schema);
+ this.fetchSchemas(val);
+ }
+ fetchDatabaseOptions() {
+ this.setState({ databaseLoading: true });
+ const url = '/databaseasync/api/read';
+ $.get(url, (data) => {
+ const options = data.result.map((db) => ({ value: db.id, label: db.database_name }));
+ this.setState({ databaseOptions: options });
+ this.setState({ databaseLoading: false });
+ });
+ }
+ closePopover(ref) {
+ this.refs[ref].hide();
+ }
+ changeTable(tableOpt) {
+ const tableName = tableOpt.value;
+ const qe = this.props.queryEditor;
+ const url = `/caravel/table/${qe.dbId}/${tableName}/${qe.schema}/`;
+ $.get(url, (data) => {
+ this.props.actions.addTable({
+ id: shortid.generate(),
+ dbId: this.props.queryEditor.dbId,
+ queryEditorId: this.props.queryEditor.id,
+ name: data.name,
+ schema: qe.schema,
+ columns: data.columns,
+ expanded: true,
+ showPopup: true,
+ });
+ })
+ .fail(() => {
+ this.props.actions.addAlert({
+ msg: 'Error occurred while fetching metadata',
+ bsStyle: 'danger',
+ });
+ });
+ }
+ render() {
+ const tables = this.props.tables.filter((t) => (t.queryEditorId === this.props.queryEditor.id));
+ const tablesEls = tables.map((table) => {
+ let cols = [];
+ if (table.columns) {
+ cols = table.columns.map((col) => (
+
+
{col.name}
+
{col.type}
+
+ ));
+ }
+ const popoverId = 'tblPopover_' + table.name;
+ const popoverTop = (
+
+ );
+ const popover = (
+
+ {cols}
+
+ );
+ return (
+
+
+
+ {table.name}
+
+
+
+
+ );
+ });
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {tablesEls}
+
+
+ );
+ }
+}
+
+SqlEditorTopToolbar.propTypes = {
+ queryEditor: React.PropTypes.object,
+ tables: React.PropTypes.array,
+ actions: React.PropTypes.object,
+};
+
+SqlEditorTopToolbar.defaultProps = {
+ tables: [],
+};
+
+function mapStateToProps(state) {
+ return {
+ tables: state.tables,
+ };
+}
+
+function mapDispatchToProps(dispatch) {
+ return {
+ actions: bindActionCreators(Actions, dispatch),
+ };
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(SqlEditorTopToolbar);
diff --git a/caravel/assets/javascripts/SqlLab/components/TabbedSqlEditors.jsx b/caravel/assets/javascripts/SqlLab/components/TabbedSqlEditors.jsx
new file mode 100644
index 000000000..83711331c
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/components/TabbedSqlEditors.jsx
@@ -0,0 +1,120 @@
+import React from 'react';
+import { DropdownButton, MenuItem, Tab, Tabs } from 'react-bootstrap';
+import { connect } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import * as Actions from '../actions';
+import SqlEditor from './SqlEditor';
+import shortid from 'shortid';
+
+let queryCount = 1;
+
+class QueryEditors extends React.Component {
+ renameTab(qe) {
+ const newTitle = prompt('Enter a new title for the tab');
+ if (newTitle) {
+ this.props.actions.queryEditorSetTitle(qe, newTitle);
+ }
+ }
+ activeQueryEditor() {
+ const qeid = this.props.tabHistory[this.props.tabHistory.length - 1];
+ for (let i = 0; i < this.props.queryEditors.length; i++) {
+ const qe = this.props.queryEditors[i];
+ if (qe.id === qeid) {
+ return qe;
+ }
+ }
+ return null;
+ }
+ newQueryEditor() {
+ queryCount++;
+ const activeQueryEditor = this.activeQueryEditor();
+ const qe = {
+ id: shortid.generate(),
+ title: `Untitled Query ${queryCount}`,
+ dbId: (activeQueryEditor) ? activeQueryEditor.dbId : null,
+ schema: (activeQueryEditor) ? activeQueryEditor.schema : null,
+ autorun: false,
+ sql: 'SELECT ...',
+ };
+ this.props.actions.addQueryEditor(qe);
+ }
+ handleSelect(key) {
+ if (key === 'add_tab') {
+ this.newQueryEditor();
+ } else {
+ this.props.actions.setActiveQueryEditor({ id: key });
+ }
+ }
+ render() {
+ const editors = this.props.queryEditors.map((qe, i) => {
+ let latestQuery = this.props.queries[qe.latestQueryId];
+ const state = (latestQuery) ? latestQuery.state : '';
+ const tabTitle = (
+
+
{qe.title} {' '}
+
+
+ close tab
+
+
+ rename tab
+
+
+
+ );
+ return (
+
+
+ );
+ });
+ return (
+
+ {editors}
+ } eventKey="add_tab" />
+
+ );
+ }
+}
+QueryEditors.propTypes = {
+ actions: React.PropTypes.object,
+ queries: React.PropTypes.object,
+ queryEditors: React.PropTypes.array,
+ tabHistory: React.PropTypes.array,
+};
+QueryEditors.defaultProps = {
+ tabHistory: [],
+ queryEditors: [],
+};
+
+function mapStateToProps(state) {
+ return {
+ queryEditors: state.queryEditors,
+ queries: state.queries,
+ tabHistory: state.tabHistory,
+ };
+}
+function mapDispatchToProps(dispatch) {
+ return {
+ actions: bindActionCreators(Actions, dispatch),
+ };
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(QueryEditors);
diff --git a/caravel/assets/javascripts/SqlLab/components/TableMetadata.jsx b/caravel/assets/javascripts/SqlLab/components/TableMetadata.jsx
new file mode 100644
index 000000000..75bbc6459
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/components/TableMetadata.jsx
@@ -0,0 +1,23 @@
+import React from 'react';
+import { BootstrapTable, TableHeaderColumn } from 'react-bootstrap-table';
+
+const TableMetadata = function (props) {
+ return (
+
+
+ id
+
+ Name
+ Type
+
+ );
+};
+
+TableMetadata.propTypes = {
+ table: React.PropTypes.object,
+};
+
+export default TableMetadata;
diff --git a/caravel/assets/javascripts/SqlLab/components/TableWorkspaceElement.jsx b/caravel/assets/javascripts/SqlLab/components/TableWorkspaceElement.jsx
new file mode 100644
index 000000000..d43229ad8
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/components/TableWorkspaceElement.jsx
@@ -0,0 +1,102 @@
+import React from 'react';
+import { ButtonGroup } from 'react-bootstrap';
+import Link from './Link';
+import { connect } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import * as Actions from '../actions';
+import shortid from 'shortid';
+
+// CSS
+import 'react-select/dist/react-select.css';
+
+class TableWorkspaceElement extends React.Component {
+ selectStar() {
+ let cols = '';
+ this.props.table.columns.forEach((col, i) => {
+ cols += col.name;
+ if (i < this.props.table.columns.length - 1) {
+ cols += ', ';
+ }
+ });
+ const sql = `SELECT ${cols}\nFROM ${this.props.table.name}`;
+ const qe = {
+ id: shortid.generate(),
+ title: this.props.table.name,
+ dbId: this.props.table.dbId,
+ autorun: true,
+ sql,
+ };
+ this.props.actions.addQueryEditor(qe);
+ }
+ render() {
+ let metadata = null;
+ let buttonToggle;
+ if (!this.props.table.expanded) {
+ buttonToggle = (
+
+ {this.props.table.name}
+
+ );
+ metadata = this.props.table.columns.map((col) =>
+
+ {col.name}
+ {col.type}
+
+ );
+ metadata = (
+ {metadata}
+ );
+ } else {
+ buttonToggle = (
+
+ {this.props.table.name}
+
+ );
+ }
+ return (
+
+ {buttonToggle}
+
+
+
+
+ {metadata}
+
+ );
+ }
+}
+TableWorkspaceElement.propTypes = {
+ table: React.PropTypes.object,
+ actions: React.PropTypes.object,
+};
+TableWorkspaceElement.defaultProps = {
+ table: null,
+};
+
+function mapDispatchToProps(dispatch) {
+ return {
+ actions: bindActionCreators(Actions, dispatch),
+ };
+}
+export default connect(null, mapDispatchToProps)(TableWorkspaceElement);
+
diff --git a/caravel/assets/javascripts/SqlLab/components/Timer.jsx b/caravel/assets/javascripts/SqlLab/components/Timer.jsx
new file mode 100644
index 000000000..a266bf05d
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/components/Timer.jsx
@@ -0,0 +1,61 @@
+import React from 'react';
+import { now, fDuration } from '../../modules/dates';
+
+import { STATE_BSSTYLE_MAP } from '../common.js';
+
+class Timer extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ clockStr: '',
+ };
+ }
+ componentWillMount() {
+ this.startTimer();
+ }
+ componentWillUnmount() {
+ this.stopTimer();
+ }
+ startTimer() {
+ if (!(this.timer)) {
+ this.timer = setInterval(this.stopwatch.bind(this), 30);
+ }
+ }
+ stopTimer() {
+ clearInterval(this.timer);
+ this.timer = null;
+ }
+ stopwatch() {
+ if (this.props && this.props.query) {
+ const since = (this.props.query.endDttm) ? this.props.query.endDttm : now();
+ const clockStr = fDuration(this.props.query.startDttm, since);
+ this.setState({ clockStr });
+ if (this.props.query.state !== 'running') {
+ this.stopTimer();
+ }
+ }
+ }
+ render() {
+ if (this.props.query && this.props.query.state === 'running') {
+ this.startTimer();
+ }
+ let timerSpan = null;
+ if (this.props && this.props.query) {
+ const bsStyle = STATE_BSSTYLE_MAP[this.props.query.state];
+ timerSpan = (
+
+ {this.state.clockStr}
+
+ );
+ }
+ return timerSpan;
+ }
+}
+Timer.propTypes = {
+ query: React.PropTypes.object,
+};
+Timer.defaultProps = {
+ query: null,
+};
+
+export default Timer;
diff --git a/caravel/assets/javascripts/SqlLab/components/VisualizeModal.jsx b/caravel/assets/javascripts/SqlLab/components/VisualizeModal.jsx
new file mode 100644
index 000000000..1cbf04158
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/components/VisualizeModal.jsx
@@ -0,0 +1,170 @@
+import React from 'react';
+import { Button, Col, Modal } from 'react-bootstrap';
+
+import { connect } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import * as Actions from '../actions';
+
+import Select from 'react-select';
+import { Table } from 'reactable';
+import shortid from 'shortid';
+
+class VisualizeModal extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ chartType: 'line',
+ datasourceName: shortid.generate(),
+ columns: {},
+ };
+ }
+ changeChartType(option) {
+ this.setState({ chartType: (option) ? option.value : null });
+ }
+ mergedColumns() {
+ const columns = Object.assign({}, this.state.columns);
+ if (this.props.query && this.props.query.results.columns) {
+ this.props.query.results.columns.forEach((col) => {
+ if (columns[col] === undefined) {
+ columns[col] = {};
+ }
+ });
+ }
+ return columns;
+ }
+ visualize() {
+ const vizOptions = {
+ chartType: this.state.chartType,
+ datasourceName: this.state.datasourceName,
+ columns: this.state.columns,
+ sql: this.props.query.sql,
+ dbId: this.props.query.dbId,
+ };
+ window.open('/caravel/sqllab_viz/?data=' + JSON.stringify(vizOptions));
+ }
+ changeDatasourceName(event) {
+ this.setState({ datasourceName: event.target.value });
+ }
+ changeCheckbox(attr, col, event) {
+ let columns = this.mergedColumns();
+ const column = Object.assign({}, columns[col], { [attr]: event.target.checked });
+ columns = Object.assign({}, columns, { [col]: column });
+ this.setState({ columns });
+ }
+ changeAggFunction(col, option) {
+ let columns = this.mergedColumns();
+ const val = (option) ? option.value : null;
+ const column = Object.assign({}, columns[col], { agg: val });
+ columns = Object.assign({}, columns, { [col]: column });
+ this.setState({ columns });
+ }
+ render() {
+ if (!(this.props.query)) {
+ return
;
+ }
+ const tableData = this.props.query.results.columns.map((col) => ({
+ column: col,
+ is_dimension: (
+
+ ),
+ is_date: (
+
+ ),
+ agg_func: (
+
+ ),
+ }));
+ const modal = (
+
+
+
+
+ Visualize under construction
+
+
+
+
+
+ Chart Type
+
+
+
+ Datasource Name
+
+
+
+
+
+
+ Visualize
+
+
+
+
+ );
+ return modal;
+ }
+}
+VisualizeModal.propTypes = {
+ query: React.PropTypes.object,
+ show: React.PropTypes.boolean,
+ onHide: React.PropTypes.function,
+};
+VisualizeModal.defaultProps = {
+ show: false,
+};
+
+function mapStateToProps() {
+ return {};
+}
+function mapDispatchToProps(dispatch) {
+ return {
+ actions: bindActionCreators(Actions, dispatch),
+ };
+}
+export default connect(mapStateToProps, mapDispatchToProps)(VisualizeModal);
diff --git a/caravel/assets/javascripts/SqlLab/index.jsx b/caravel/assets/javascripts/SqlLab/index.jsx
new file mode 100644
index 000000000..e10d8ca12
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/index.jsx
@@ -0,0 +1,74 @@
+const $ = window.$ = require('jquery');
+const jQuery = window.jQuery = $; // eslint-disable-line
+require('bootstrap');
+
+import React from 'react';
+import { render } from 'react-dom';
+import * as Actions from './actions';
+
+import LeftPane from './components/LeftPane';
+import TabbedSqlEditors from './components/TabbedSqlEditors';
+import QueryAutoRefresh from './components/QueryAutoRefresh';
+import Alerts from './components/Alerts';
+
+import { bindActionCreators, compose, createStore } from 'redux';
+import { connect, Provider } from 'react-redux';
+
+import { initialState, sqlLabReducer } from './reducers';
+import persistState from 'redux-localstorage';
+
+require('./main.css');
+
+let enhancer = compose(persistState());
+if (process.env.NODE_ENV === 'dev') {
+ enhancer = compose(
+ persistState(), window.devToolsExtension && window.devToolsExtension()
+ );
+}
+let store = createStore(sqlLabReducer, initialState, enhancer);
+
+// jquery hack to highlight the navbar menu
+$('a[href="/caravel/sqllab"]').parent().addClass('active');
+
+const App = function (props) {
+ return (
+
+ );
+};
+
+App.propTypes = {
+ alerts: React.PropTypes.array,
+};
+
+function mapStateToProps(state) {
+ return {
+ alerts: state.alerts,
+ };
+}
+function mapDispatchToProps(dispatch) {
+ return {
+ actions: bindActionCreators(Actions, dispatch),
+ };
+}
+
+const ReduxedApp = connect(mapStateToProps, mapDispatchToProps)(App);
+
+render(
+
+
+ ,
+ document.getElementById('app')
+);
diff --git a/caravel/assets/javascripts/SqlLab/main.css b/caravel/assets/javascripts/SqlLab/main.css
new file mode 100644
index 000000000..127b6c51b
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/main.css
@@ -0,0 +1,229 @@
+#app {
+ position: absolute;
+ top: 65;
+ left: 0;
+ right: 0;
+ bottom: 0;
+}
+.inlineBlock {
+ display: inline-block;
+}
+.valignTop {
+ vertical-align: top;
+}
+.inline {
+ display: inline;
+}
+.nopadding {
+ padding: 0px;
+}
+.panel.nopadding .panel-body {
+ padding: 0px;
+}
+
+.loading {
+ width: 50px;
+ margin-top: 15px;
+}
+.pane-cell {
+ padding: 10px;
+ overflow: auto;
+ width: 100%;
+ height: 100%;
+}
+.SqlEditor .header {
+ padding-top: 5px;
+ padding-bottom: 5px;
+}
+.Workspace .btn-sm {
+ box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.1);
+ margin-top: 2px;
+ padding: 4px;
+}
+.Workspace hr {
+ margin-top: 10px;
+ margin-bottom: 10px;
+}
+div.Workspace {
+ height: 100%;
+ margin: 0px;
+}
+.SqlEditor .clock {
+ background-color: orange;
+ padding: 5px;
+}
+
+.padded {
+ padding: 10px;
+}
+
+.p-t-10 {
+ padding-top: 10px;
+}
+.p-t-5 {
+ padding-top: 5px;
+}
+.m-r-5 {
+ margin-right: 5px;
+}
+.m-r-3 {
+ margin-right: 3px;
+}
+.m-l-1 {
+ margin-left: 1px;
+}
+.m-r-10 {
+ margin-right: 10px;
+}
+.m-b-10 {
+ margin-bottom: 10px;
+}
+.m-t-5 {
+ margin-top: 5px;
+}
+.m-t-10 {
+ margin-top: 10px;
+}
+.p-t-10 {
+ padding-top: 10px;
+}
+.sqllab-toolbar {
+ padding-top: 5px;
+ border-bottom: 1px solid #DDD;
+}
+.no-shadow {
+ box-shadow: none;
+ background-color: transparent;
+}
+.pane-west {
+ height: 100%;
+ overflow: auto;
+}
+.circle {
+ border-radius: 50%;
+ width: 10px;
+ height: 10px;
+ display: inline-block;
+ background-color: #ccc;
+}
+.Pane2 {
+ width: 0;
+}
+.running {
+ background-color: lime;
+ color: black;
+}
+.success {
+ background-color: green;
+}
+.failed {
+ background-color: red;
+}
+
+.handle {
+ cursor: move;
+}
+.window {
+ z-index: 1000;
+ position: absolute;
+ width: 300px;
+ opacity: 0.85;
+ border: 1px solid #AAA;
+ max-height: 600px;
+ box-shadow: rgba(0, 0, 0, 0.8) 5px 5px 25px
+}
+
+.SqlLab pre {
+ padding: 0px !important;
+ margin: 0px;
+ border: none;
+ font-size: 11px;
+ line-height: 125%;
+ background-color: transparent !important;
+}
+
+.Resizer {
+ background: #000;
+ opacity: .2;
+ z-index: 1;
+ -moz-box-sizing: border-box;
+ -webkit-box-sizing: border-box;
+ box-sizing: border-box;
+ -moz-background-clip: padding;
+ -webkit-background-clip: padding;
+ background-clip: padding-box;
+}
+
+.Resizer:hover {
+ -webkit-transition: all 2s ease;
+ transition: all 2s ease;
+}
+
+.Resizer.horizontal {
+ height: 10px;
+ margin: -5px 0;
+ border-top: 5px solid rgba(255, 255, 255, 0);
+ border-bottom: 5px solid rgba(255, 255, 255, 0);
+ cursor: row-resize;
+ width: 100%;
+ padding: 1px;
+}
+
+.Resizer.horizontal:hover {
+ border-top: 5px solid rgba(0, 0, 0, 0.5);
+ border-bottom: 5px solid rgba(0, 0, 0, 0.5);
+}
+
+.Resizer.vertical {
+ width: 9px;
+ margin: 0 -5px;
+ border-left: 5px solid rgba(255, 255, 255, 0);
+ border-right: 5px solid rgba(255, 255, 255, 0);
+ cursor: col-resize;
+}
+
+.Resizer.vertical:hover {
+ border-left: 5px solid rgba(0, 0, 0, 0.5);
+ border-right: 5px solid rgba(0, 0, 0, 0.5);
+}
+.Resizer.disabled {
+ cursor: not-allowed;
+}
+.Resizer.disabled:hover {
+ border-color: transparent;
+}
+
+.popover{
+ max-width:400px;
+}
+.Select-menu-outer {
+ z-index: 1000;
+}
+.table-label {
+ margin-top: 5px;
+ margin-right: 10px;
+ float: left;
+}
+div.tablePopover {
+ opacity: 0.7 !important;
+}
+div.tablePopover:hover {
+ opacity: 1 !important;
+}
+.ResultSetControls {
+ padding-bottom: 3px;
+ padding-top: 3px;
+}
+
+.ace_editor {
+ border: 1px solid #ccc;
+ margin: 10px 0;
+}
+
+.Select-menu-outer {
+ min-width: 100%;
+ width: inherit;
+}
+.ace_content {
+ background-color: #f4f4f4;
+}
diff --git a/caravel/assets/javascripts/SqlLab/reducers.js b/caravel/assets/javascripts/SqlLab/reducers.js
new file mode 100644
index 000000000..c355391b1
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/reducers.js
@@ -0,0 +1,186 @@
+import shortid from 'shortid';
+import * as actions from './actions';
+import { now } from '../modules/dates';
+
+const defaultQueryEditor = {
+ id: shortid.generate(),
+ title: 'Untitled Query',
+ sql: 'SELECT *\nFROM\nWHERE',
+ latestQueryId: null,
+ autorun: false,
+ dbId: null,
+};
+
+// TODO(bkyryliuk): document the object schemas
+export const initialState = {
+ alerts: [],
+ queries: {},
+ queryEditors: [defaultQueryEditor],
+ tabHistory: [defaultQueryEditor.id],
+ tables: [],
+ workspaceQueries: [],
+ queriesLastUpdate: 0,
+};
+
+function addToObject(state, arrKey, obj) {
+ const newObject = Object.assign({}, state[arrKey]);
+ const copiedObject = Object.assign({}, obj);
+
+ if (!copiedObject.id) {
+ copiedObject.id = shortid.generate();
+ }
+ newObject[copiedObject.id] = copiedObject;
+ return Object.assign({}, state, { [arrKey]: newObject });
+}
+
+function alterInObject(state, arrKey, obj, alterations) {
+ const newObject = Object.assign({}, state[arrKey]);
+ newObject[obj.id] = (Object.assign({}, newObject[obj.id], alterations));
+ return Object.assign({}, state, { [arrKey]: newObject });
+}
+
+function alterInArr(state, arrKey, obj, alterations) {
+ // Finds an item in an array in the state and replaces it with a
+ // new object with an altered property
+ const idKey = 'id';
+ const newArr = [];
+ state[arrKey].forEach((arrItem) => {
+ if (obj[idKey] === arrItem[idKey]) {
+ newArr.push(Object.assign({}, arrItem, alterations));
+ } else {
+ newArr.push(arrItem);
+ }
+ });
+ return Object.assign({}, state, { [arrKey]: newArr });
+}
+
+function removeFromArr(state, arrKey, obj, idKey = 'id') {
+ const newArr = [];
+ state[arrKey].forEach((arrItem) => {
+ if (!(obj[idKey] === arrItem[idKey])) {
+ newArr.push(arrItem);
+ }
+ });
+ return Object.assign({}, state, { [arrKey]: newArr });
+}
+
+function addToArr(state, arrKey, obj) {
+ const newObj = Object.assign({}, obj);
+ if (!newObj.id) {
+ newObj.id = shortid.generate();
+ }
+ const newState = {};
+ newState[arrKey] = [...state[arrKey], newObj];
+ return Object.assign({}, state, newState);
+}
+
+export const sqlLabReducer = function (state, action) {
+ const actionHandlers = {
+ [actions.ADD_QUERY_EDITOR]() {
+ const tabHistory = state.tabHistory.slice();
+ tabHistory.push(action.queryEditor.id);
+ const newState = Object.assign({}, state, { tabHistory });
+ return addToArr(newState, 'queryEditors', action.queryEditor);
+ },
+ [actions.REMOVE_QUERY_EDITOR]() {
+ let newState = removeFromArr(state, 'queryEditors', action.queryEditor);
+ // List of remaining queryEditor ids
+ const qeIds = newState.queryEditors.map((qe) => qe.id);
+ let th = state.tabHistory.slice();
+ th = th.filter((id) => qeIds.includes(id));
+ newState = Object.assign({}, newState, { tabHistory: th });
+ return newState;
+ },
+ [actions.REMOVE_QUERY]() {
+ const newQueries = Object.assign({}, state.queries);
+ delete newQueries[action.query.id];
+ return Object.assign({}, state, { queries: newQueries });
+ },
+ [actions.RESET_STATE]() {
+ return Object.assign({}, initialState);
+ },
+ [actions.ADD_TABLE]() {
+ return addToArr(state, 'tables', action.table);
+ },
+ [actions.EXPAND_TABLE]() {
+ return alterInArr(state, 'tables', action.table, { expanded: true });
+ },
+ [actions.COLLAPSE_TABLE]() {
+ return alterInArr(state, 'tables', action.table, { expanded: false });
+ },
+ [actions.REMOVE_TABLE]() {
+ return removeFromArr(state, 'tables', action.table);
+ },
+ [actions.START_QUERY]() {
+ const newState = addToObject(state, 'queries', action.query);
+ const sqlEditor = { id: action.query.sqlEditorId };
+ return alterInArr(newState, 'queryEditors', sqlEditor, { latestQueryId: action.query.id });
+ },
+ [actions.STOP_QUERY]() {
+ return alterInObject(state, 'queries', action.query, { state: 'stopped' });
+ },
+ [actions.QUERY_SUCCESS]() {
+ const alts = {
+ state: 'success',
+ results: action.results,
+ rows: action.results.data.length,
+ progress: 100,
+ endDttm: now(),
+ };
+ return alterInObject(state, 'queries', action.query, alts);
+ },
+ [actions.QUERY_FAILED]() {
+ const alts = { state: 'failed', msg: action.msg, endDttm: now() };
+ return alterInObject(state, 'queries', action.query, alts);
+ },
+ [actions.SET_ACTIVE_QUERY_EDITOR]() {
+ const qeIds = state.queryEditors.map((qe) => qe.id);
+ if (qeIds.includes(action.queryEditor.id)) {
+ const tabHistory = state.tabHistory.slice();
+ tabHistory.push(action.queryEditor.id);
+ return Object.assign({}, state, { tabHistory });
+ }
+ return state;
+ },
+ [actions.QUERY_EDITOR_SETDB]() {
+ return alterInArr(state, 'queryEditors', action.queryEditor, { dbId: action.dbId });
+ },
+ [actions.QUERY_EDITOR_SET_SCHEMA]() {
+ return alterInArr(state, 'queryEditors', action.queryEditor, { schema: action.schema });
+ },
+ [actions.QUERY_EDITOR_SET_TITLE]() {
+ return alterInArr(state, 'queryEditors', action.queryEditor, { title: action.title });
+ },
+ [actions.QUERY_EDITOR_SET_SQL]() {
+ return alterInArr(state, 'queryEditors', action.queryEditor, { sql: action.sql });
+ },
+ [actions.QUERY_EDITOR_SET_AUTORUN]() {
+ return alterInArr(state, 'queryEditors', action.queryEditor, { autorun: action.autorun });
+ },
+ [actions.ADD_WORKSPACE_QUERY]() {
+ return addToArr(state, 'workspaceQueries', action.query);
+ },
+ [actions.REMOVE_WORKSPACE_QUERY]() {
+ return removeFromArr(state, 'workspaceQueries', action.query);
+ },
+ [actions.ADD_ALERT]() {
+ return addToArr(state, 'alerts', action.alert);
+ },
+ [actions.REMOVE_ALERT]() {
+ return removeFromArr(state, 'alerts', action.alert);
+ },
+ [actions.REFRESH_QUERIES]() {
+ const newQueries = Object.assign({}, state.queries);
+ // Fetch the updates to the queries present in the store.
+ for (const queryId in state.queries) {
+ newQueries[queryId] = Object.assign(newQueries[queryId], action.alteredQueries[queryId]);
+ }
+ const queriesLastUpdate = now();
+ return Object.assign({}, state, { queries: newQueries, queriesLastUpdate });
+ },
+ };
+ if (action.type in actionHandlers) {
+ return actionHandlers[action.type]();
+ }
+ return state;
+};
diff --git a/caravel/assets/javascripts/modules/dates.js b/caravel/assets/javascripts/modules/dates.js
index a746ce102..f9781bf61 100644
--- a/caravel/assets/javascripts/modules/dates.js
+++ b/caravel/assets/javascripts/modules/dates.js
@@ -1,3 +1,5 @@
+import moment from 'moment';
+
const d3 = require('d3');
function UTC(dttm) {
@@ -72,3 +74,15 @@ export const timeFormatFactory = function (d3timeFormat) {
return f(d);
};
};
+export const fDuration = function (t1, t2, f = 'HH:mm:ss.SS') {
+ const diffSec = t2 - t1;
+ const duration = moment(new Date(diffSec));
+ duration.millisecond((diffSec - Math.round(diffSec)) * 1000);
+ return duration.utc().format(f);
+};
+
+export const now = function () {
+ // seconds from EPOCH as a float
+ return moment().utc().valueOf() / 1000.0;
+};
+
diff --git a/caravel/assets/javascripts/sql.js b/caravel/assets/javascripts/sql.js
deleted file mode 100644
index a746d3608..000000000
--- a/caravel/assets/javascripts/sql.js
+++ /dev/null
@@ -1,95 +0,0 @@
-const $ = window.$ = require('jquery');
-const jQuery = window.jQuery = require('jquery'); // eslint-disable-line
-const showModal = require('./modules/utils.js').showModal;
-require('./caravel-select2.js');
-require('datatables.net-bs');
-require('../node_modules/datatables-bootstrap3-plugin/media/css/datatables-bootstrap3.css');
-require('bootstrap');
-const ace = require('brace');
-require('brace/mode/sql');
-require('brace/theme/crimson_editor');
-require('../stylesheets/sql.css');
-$(document).ready(function () {
- function getParam(name) {
- /* eslint no-param-reassign: 0 */
- name = name.replace(/[\[]/, '\\[').replace(/[\]]/, '\\]');
- const regex = new RegExp('[\\?&]' + name + '=([^]*)');
- const results = regex.exec(location.search);
- return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
- }
- function initSqlEditorView() {
- const databaseId = $('#database_id').val();
- const editor = ace.edit('sql');
- editor.$blockScrolling = Infinity;
- editor.getSession().setUseWrapMode(true);
- $('#sql').hide();
- editor.setTheme('ace/theme/crimson_editor');
- editor.setOptions({
- minLines: 16,
- maxLines: Infinity,
- });
- editor.getSession().setMode('ace/mode/sql');
- editor.focus();
-
- $('select').select2({ dropdownAutoWidth: true });
-
- function showTableMetadata() {
- $('.metadata').load('/caravel/table/' + databaseId + '/' + $('#dbtable').val() + '/');
- }
- $('#dbtable').on('change', showTableMetadata);
- showTableMetadata();
- $('#create_view').click(function () {
- showModal({
- title: 'Error',
- body: 'Sorry, this feature is not yet implemented',
- });
- });
- $('.sqlcontent').show();
- function selectStarOnClick() {
- const url = '/caravel/select_star/' + databaseId + '/' + $('#dbtable').val() + '/';
- $.ajax(url).done(function (msg) {
- editor.setValue(msg);
- });
- }
- $('#select_star').click(selectStarOnClick);
- editor.setValue(getParam('sql'));
- $(window).bind('popstate', function () {
- // Could do something more lightweight here, but we're not optimizing
- // for the use of the back button anyways
- editor.setValue(getParam('sql'));
- $('#run').click();
- });
- $('#run').click(function () {
- $('#results').hide(0);
- $('#loading').show(0);
- history.pushState({}, document.title, '?sql=' + encodeURIComponent(editor.getValue()));
- $.ajax({
- type: 'POST',
- url: '/caravel/runsql/',
- data: {
- data: JSON.stringify({
- database_id: $('#database_id').val(),
- sql: editor.getSession().getValue(),
- }),
- },
- success(data) {
- $('#loading').hide(0);
- $('#results').show(0);
- $('#results').html(data);
- $('table.sql_results').DataTable({
- retrieve: true,
- paging: false,
- searching: true,
- aaSorting: [],
- });
- },
- error(err) {
- $('#loading').hide(0);
- $('#results').show(0);
- $('#results').html(err.responseText);
- },
- });
- });
- }
- initSqlEditorView();
-});
diff --git a/caravel/assets/package.json b/caravel/assets/package.json
index 97e23d071..fe5ef1c49 100644
--- a/caravel/assets/package.json
+++ b/caravel/assets/package.json
@@ -8,7 +8,7 @@
},
"scripts": {
"test": "npm run lint && mocha --compilers js:babel-core/register --required spec/helpers/browser.js spec/**/*_spec.*",
- "dev": "NODE_ENV=dev webpack -d --watch --colors",
+ "dev": "NODE_ENV=dev webpack -d --watch --colors --progress",
"prod": "NODE_ENV=production webpack -p --colors --progress",
"lint": "npm run --silent lint:js",
"lint:js": "eslint --ignore-path=.eslintignore --ext .js,.jsx ."
@@ -55,19 +55,29 @@
"jquery": "^2.2.1",
"jquery-ui": "1.10.5",
"mapbox-gl": "^0.20.0",
+ "moment": "^2.14.1",
+ "moments": "0.0.2",
"mustache": "^2.2.1",
"nvd3": "1.8.4",
"react": "^15.2.1",
- "react-bootstrap": "^0.28.3",
- "react-bootstrap-datetimepicker": "0.0.22",
- "react-bootstrap-table": "^2.3.7",
+ "react-ace": "^3.4.1",
+ "react-bootstrap": "^0.30.3",
+ "react-bootstrap-table": "^2.3.8",
"react-dom": "^0.14.8",
- "react-grid-layout": "^0.12.3",
+ "react-draggable": "^2.1.2",
+ "react-grid-layout": "^0.13.1",
"react-map-gl": "^1.0.0-beta-10",
+ "react-redux": "^4.4.5",
"react-resizable": "^1.3.3",
"react-select": "^1.0.0-beta14",
+ "react-syntax-highlighter": "^2.1.1",
+ "reactable": "^0.13.2",
+ "redux": "^3.5.2",
+ "redux-localstorage": "^0.4.1",
"select2": "3.5",
"select2-bootstrap-css": "^1.4.6",
+ "shortid": "^2.2.6",
+ "style-loader": "^0.13.0",
"supercluster": "https://github.com/georgeke/supercluster/tarball/ac3492737e7ce98e07af679623aad452373bbc40",
"topojson": "^1.6.22",
"viewport-mercator-project": "^2.1.0"
diff --git a/caravel/assets/stylesheets/caravel.css b/caravel/assets/stylesheets/caravel.css
index 03dc68129..94e701813 100644
--- a/caravel/assets/stylesheets/caravel.css
+++ b/caravel/assets/stylesheets/caravel.css
@@ -28,12 +28,6 @@ body {
input.form-control {
background-color: white;
}
-input.form-control[type=text], input.form-control[type=search] {
- border: 1px solid #ccc!important;
- box-shadow: none!important;
- padding: 2px;
- border-radius: 2px;
-}
.chart-header a.danger {
color: red;
@@ -193,5 +187,10 @@ div.widget .slice_container {
.navbar .alert {
padding: 5px 10px;
- margin-top: 15px;
+ margin-top: 8px;
+ margin-bottom: 0px
+}
+
+.table-condensed {
+ font-size: 12px;
}
diff --git a/caravel/assets/stylesheets/less/cosmo/bootswatch.less b/caravel/assets/stylesheets/less/cosmo/bootswatch.less
index 4e4cbf59b..95a140227 100644
--- a/caravel/assets/stylesheets/less/cosmo/bootswatch.less
+++ b/caravel/assets/stylesheets/less/cosmo/bootswatch.less
@@ -27,7 +27,8 @@
}
.navbar-inverse .navbar-nav > li > a:hover,
-.navbar-inverse .navbar-nav > li > a:focus {
+.navbar-inverse .navbar-nav > li > a:focus,
+.navbar-inverse .navbar-nav > li.active > a {
background: transparent;
border-bottom: 3px solid @brand-primary;
margin-bottom: -3px;
@@ -46,6 +47,58 @@
background-color: #efefef;
}
+.nav-tabs {
+ .dropdown-toggle.btn,
+ .dropdown-toggle.btn:hover,
+ .dropdown-toggle.btn:active,
+ .dropdown-toggle.btn:focus,
+ .btn-group.open .dropdown-toggle.btn,
+ .btn-group.open .dropdown-toggle.btn:hover,
+ .btn-group.open .dropdown-toggle.btn:active,
+ .btn-group.open .dropdown-toggle.btn:focus {
+ border-color: transparent;
+ background-color: transparent;
+ box-shadow: none;
+ }
+}
+
+.caret {
+ border: none;
+ color: @gray;
+}
+
+.caret:before {
+ font-family: "FontAwesome";
+ font-size: 10px;
+ content: "\f078";
+}
+
+.caret:hover {
+ color: @gray-darker;
+}
+
+.dropdown-toggle.btn .caret {
+ margin-left: 6px;
+ margin-top: -21px;
+ margin-right: 6px;
+}
+
+.nav-tabs .dropdown-toggle.btn .caret, {
+ margin-left: -12px;
+ margin-top: -10px;
+}
+
+.navbar-nav .caret,
+.panel-title .caret {
+ margin-left: 6px;
+ margin-top: -24px;
+ margin-right: 6px;
+}
+
+
+
+
+
// Typography =================================================================
body {
@@ -176,7 +229,7 @@ table,
.nav-pills {
& > li > a {
- border-radius: 0;
+ border-radius: 2px;
}
}
@@ -222,7 +275,7 @@ table,
}
.label {
- border-radius: 0;
+ border-radius: 2px;
}
label {
font-weight: normal;
@@ -231,11 +284,12 @@ label {
// Progress bars ==============================================================
.progress {
- height: 8px;
+ height: 14px;
.box-shadow(none);
.progress-bar {
- font-size: 8px;
- line-height: 8px;
+ font-size: 12px;
+ line-height: 12px;
+ padding-top: 1px;
}
}
@@ -321,10 +375,7 @@ a.list-group-item {
.nav-tabs > li.active > a, .nav-tabs > li.active > a:hover, .nav-tabs > li.active > a:focus {
background-color: #fff;
font-weight: bold;
-}
-
-.tab-content {
- padding-top: 20px;
+ border-top: 3px solid @brand-primary;
}
// Tables ==============================================================
@@ -343,3 +394,8 @@ a.list-group-item {
color: @state-success-text;
}
}
+
+// Utils ==============================================================
+hr {
+ margin: 10px 0;
+}
diff --git a/caravel/assets/stylesheets/less/cosmo/variables.less b/caravel/assets/stylesheets/less/cosmo/variables.less
index 93e4ed494..56ac8416f 100644
--- a/caravel/assets/stylesheets/less/cosmo/variables.less
+++ b/caravel/assets/stylesheets/less/cosmo/variables.less
@@ -426,15 +426,15 @@
@nav-disabled-link-hover-color: @gray-light;
//== Tabs
-@nav-tabs-border-color: #ddd;
+@nav-tabs-border-color: #bbb;
@nav-tabs-link-hover-border-color: @gray-lighter;
@nav-tabs-active-link-hover-bg: @body-bg;
@nav-tabs-active-link-hover-color: @gray;
-@nav-tabs-active-link-hover-border-color: #ddd;
+@nav-tabs-active-link-hover-border-color: #bbb;
-@nav-tabs-justified-link-border-color: #ddd;
+@nav-tabs-justified-link-border-color: #bbb;
@nav-tabs-justified-active-link-border-color: @body-bg;
//== Pills
diff --git a/caravel/assets/webpack.config.js b/caravel/assets/webpack.config.js
index b877d24df..79e5d780a 100644
--- a/caravel/assets/webpack.config.js
+++ b/caravel/assets/webpack.config.js
@@ -5,7 +5,7 @@ const path = require('path');
const APP_DIR = path.resolve(__dirname, './');
// output dir
-const BUILD_DIR = path.resolve(__dirname, './javascripts/dist');
+const BUILD_DIR = path.resolve(__dirname, './dist');
const config = {
entry: {
@@ -13,9 +13,9 @@ const config = {
dashboard: APP_DIR + '/javascripts/dashboard/Dashboard.jsx',
explore: APP_DIR + '/javascripts/explore/explore.jsx',
welcome: APP_DIR + '/javascripts/welcome.js',
- sql: APP_DIR + '/javascripts/sql.js',
standalone: APP_DIR + '/javascripts/standalone.js',
common: APP_DIR + '/javascripts/common.js',
+ sqllab: APP_DIR + '/javascripts/SqlLab/index.jsx',
},
output: {
path: BUILD_DIR,
diff --git a/caravel/bin/caravel b/caravel/bin/caravel
index c53cdc2b3..0c9428905 100755
--- a/caravel/bin/caravel
+++ b/caravel/bin/caravel
@@ -5,6 +5,8 @@ from __future__ import print_function
from __future__ import unicode_literals
import logging
+import celery
+from celery.bin import worker as celery_worker
from datetime import datetime
from subprocess import Popen
@@ -42,6 +44,7 @@ def runserver(debug, address, port, timeout, workers):
app.run(
host='0.0.0.0',
port=int(port),
+ threaded=True,
debug=True)
else:
cmd = (
@@ -126,5 +129,24 @@ def refresh_druid():
session.commit()
+@manager.command
+def worker():
+ """Starts a Caravel worker for async SQL query execution."""
+ # celery -A tasks worker --loglevel=info
+ print("Starting SQL Celery worker.")
+ if config.get('CELERY_CONFIG'):
+ print("Celery broker url: ")
+ print(config.get('CELERY_CONFIG').BROKER_URL)
+
+ application = celery.current_app._get_current_object()
+ c_worker = celery_worker.worker(app=application)
+ options = {
+ 'broker': config.get('CELERY_CONFIG').BROKER_URL,
+ 'loglevel': 'INFO',
+ 'traceback': True,
+ }
+ c_worker.run(**options)
+
+
if __name__ == "__main__":
manager.run()
diff --git a/caravel/config.py b/caravel/config.py
index f4285ebdd..233f99a6c 100644
--- a/caravel/config.py
+++ b/caravel/config.py
@@ -179,11 +179,32 @@ BACKUP_COUNT = 30
# Set this API key to enable Mapbox visualizations
MAPBOX_API_KEY = ""
+# Maximum number of rows returned in the SQL editor
+SQL_MAX_ROW = 1000
+
# If defined, shows this text in an alert-warning box in the navbar
# one example use case may be "STAGING" to make it clear that this is
# not the production version of the site.
WARNING_MSG = None
+# Default celery config is to use SQLA as a broker, in a production setting
+# you'll want to use a proper broker as specified here:
+# http://docs.celeryproject.org/en/latest/getting-started/brokers/index.html
+"""
+# Example:
+class CeleryConfig(object):
+ BROKER_URL = 'sqla+sqlite:///celerydb.sqlite'
+ CELERY_IMPORTS = ('caravel.tasks', )
+ CELERY_RESULT_BACKEND = 'db+sqlite:///celery_results.sqlite'
+ CELERY_ANNOTATIONS = {'tasks.add': {'rate_limit': '10/s'}}
+CELERY_CONFIG = CeleryConfig
+"""
+CELERY_CONFIG = None
+SQL_CELERY_DB_FILE_PATH = os.path.join(DATA_DIR, 'celerydb.sqlite')
+SQL_CELERY_RESULTS_DB_FILE_PATH = os.path.join(DATA_DIR, 'celery_results.sqlite')
+
+# The db id here results in selecting this one as a default in SQL Lab
+DEFAULT_DB_ID = None
try:
from caravel_config import * # noqa
diff --git a/caravel/extract_table_names.py b/caravel/extract_table_names.py
new file mode 100644
index 000000000..4bc570742
--- /dev/null
+++ b/caravel/extract_table_names.py
@@ -0,0 +1,60 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2016 Andi Albrecht, albrecht.andi@gmail.com
+#
+# This example is part of python-sqlparse and is released under
+# the BSD License: http://www.opensource.org/licenses/bsd-license.php
+#
+# This example illustrates how to extract table names from nested
+# SELECT statements.
+#
+# See:
+# http://groups.google.com/group/sqlparse/browse_thread/thread/b0bd9a022e9d4895
+
+import sqlparse
+from sqlparse.sql import IdentifierList, Identifier
+from sqlparse.tokens import Keyword, DML
+
+
+def is_subselect(parsed):
+ if not parsed.is_group():
+ return False
+ for item in parsed.tokens:
+ if item.ttype is DML and item.value.upper() == 'SELECT':
+ return True
+ return False
+
+
+def extract_from_part(parsed):
+ from_seen = False
+ for item in parsed.tokens:
+ if from_seen:
+ if is_subselect(item):
+ for x in extract_from_part(item):
+ yield x
+ elif item.ttype is Keyword:
+ raise StopIteration
+ else:
+ yield item
+ elif item.ttype is Keyword and item.value.upper() == 'FROM':
+ from_seen = True
+
+
+def extract_table_identifiers(token_stream):
+ for item in token_stream:
+ if isinstance(item, IdentifierList):
+ for identifier in item.get_identifiers():
+ yield identifier.get_name()
+ elif isinstance(item, Identifier):
+ yield item.get_name()
+ # It's a bug to check for Keyword here, but in the example
+ # above some tables names are identified as keywords...
+ elif item.ttype is Keyword:
+ yield item.value
+
+
+# TODO(bkyryliuk): add logic to support joins and unions.
+def extract_tables(sql):
+ stream = extract_from_part(sqlparse.parse(sql)[0])
+ return list(extract_table_identifiers(stream))
diff --git a/caravel/migrations/versions/3c3ffe173e4f_add_sql_string_to_table.py b/caravel/migrations/versions/3c3ffe173e4f_add_sql_string_to_table.py
new file mode 100644
index 000000000..5b64bc40d
--- /dev/null
+++ b/caravel/migrations/versions/3c3ffe173e4f_add_sql_string_to_table.py
@@ -0,0 +1,22 @@
+"""add_sql_string_to_table
+
+Revision ID: 3c3ffe173e4f
+Revises: ad82a75afd82
+Create Date: 2016-08-18 14:06:28.784699
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = '3c3ffe173e4f'
+down_revision = 'ad82a75afd82'
+
+from alembic import op
+import sqlalchemy as sa
+
+
+def upgrade():
+ op.add_column('tables', sa.Column('sql', sa.Text(), nullable=True))
+
+
+def downgrade():
+ op.drop_column('tables', 'sql')
diff --git a/caravel/migrations/versions/ad82a75afd82_add_query_model.py b/caravel/migrations/versions/ad82a75afd82_add_query_model.py
new file mode 100644
index 000000000..91959c6a7
--- /dev/null
+++ b/caravel/migrations/versions/ad82a75afd82_add_query_model.py
@@ -0,0 +1,56 @@
+"""Update models to support storing the queries.
+
+Revision ID: ad82a75afd82
+Revises: f162a1dea4c4
+Create Date: 2016-07-25 17:48:12.771103
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = 'ad82a75afd82'
+down_revision = 'f162a1dea4c4'
+
+from alembic import op
+import sqlalchemy as sa
+
+
+def upgrade():
+ op.create_table('query',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('client_id', sa.String(length=11), nullable=False),
+ sa.Column('database_id', sa.Integer(), nullable=False),
+ sa.Column('tmp_table_name', sa.String(length=256), nullable=True),
+ sa.Column('tab_name', sa.String(length=256),nullable=True),
+ sa.Column('sql_editor_id', sa.String(length=256), nullable=True),
+ sa.Column('user_id', sa.Integer(), nullable=True),
+ sa.Column('status', sa.String(length=16), nullable=True),
+ sa.Column('name', sa.String(length=256), nullable=True),
+ sa.Column('schema', sa.String(length=256), nullable=True),
+ sa.Column('sql', sa.Text(), nullable=True),
+ sa.Column('select_sql', sa.Text(), nullable=True),
+ sa.Column('executed_sql', sa.Text(), nullable=True),
+ sa.Column('limit', sa.Integer(), nullable=True),
+ sa.Column('limit_used', sa.Boolean(), nullable=True),
+ sa.Column('select_as_cta', sa.Boolean(), nullable=True),
+ sa.Column('select_as_cta_used', sa.Boolean(), nullable=True),
+ sa.Column('progress', sa.Integer(), nullable=True),
+ sa.Column('rows', sa.Integer(), nullable=True),
+ sa.Column('error_message', sa.Text(), nullable=True),
+ sa.Column('start_time', sa.Numeric(precision=20, scale=6), nullable=True),
+ sa.Column('changed_on', sa.DateTime(), nullable=True),
+ sa.Column('end_time', sa.Numeric(precision=20, scale=6), nullable=True),
+ sa.ForeignKeyConstraint(['database_id'], [u'dbs.id'], ),
+ sa.ForeignKeyConstraint(['user_id'], [u'ab_user.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.add_column('dbs', sa.Column('select_as_create_table_as', sa.Boolean(),
+ nullable=True))
+ op.create_index(
+ op.f('ti_user_id_changed_on'),
+ 'query', ['user_id', 'changed_on'], unique=False)
+
+
+def downgrade():
+ op.drop_table('query')
+ op.drop_column('dbs', 'select_as_create_table_as')
+
diff --git a/caravel/models.py b/caravel/models.py
index 5fe42729d..34834e9d8 100644
--- a/caravel/models.py
+++ b/caravel/models.py
@@ -16,6 +16,7 @@ import humanize
import pandas as pd
import requests
import sqlalchemy as sqla
+from sqlalchemy.engine.url import make_url
import sqlparse
from dateutil.parser import parse
@@ -30,13 +31,16 @@ from pydruid.utils.filters import Dimension, Filter
from pydruid.utils.postaggregator import Postaggregator
from pydruid.utils.having import Aggregation
from six import string_types
+
from sqlalchemy import (
- Column, Integer, String, ForeignKey, Text, Boolean, DateTime, Date,
- Table, create_engine, MetaData, desc, asc, select, and_, func)
-from sqlalchemy.engine import reflection
+ Column, Integer, String, ForeignKey, Text, Boolean,
+ DateTime, Date, Table, Numeric,
+ create_engine, MetaData, desc, asc, select, and_, func
+)
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.orm import relationship
from sqlalchemy.sql import table, literal_column, text, column
+from sqlalchemy.sql.expression import TextAsFrom
from sqlalchemy_utils import EncryptedType
import caravel
@@ -310,6 +314,11 @@ class Dashboard(Model, AuditMixinNullable):
else:
return {}
+ @property
+ def sqla_metadata(self):
+ metadata = MetaData(bind=self.get_sqla_engine())
+ return metadata.reflect()
+
def dashboard_link(self):
return '{obj.dashboard_title} '.format(obj=self)
@@ -372,6 +381,7 @@ class Database(Model, AuditMixinNullable):
sqlalchemy_uri = Column(String(1024))
password = Column(EncryptedType(String(1024), config.get('SECRET_KEY')))
cache_timeout = Column(Integer)
+ select_as_create_table_as = Column(Boolean, default=False)
extra = Column(Text, default=textwrap.dedent("""\
{
"metadata_params": {},
@@ -382,14 +392,69 @@ class Database(Model, AuditMixinNullable):
def __repr__(self):
return self.database_name
- def get_sqla_engine(self):
+ def get_sqla_engine(self, schema=None):
extra = self.get_extra()
params = extra.get('engine_params', {})
- return create_engine(self.sqlalchemy_uri_decrypted, **params)
+ url = make_url(self.sqlalchemy_uri_decrypted)
+ backend = url.get_backend_name()
+ if backend == 'presto' and schema:
+ if '/' in url.database:
+ url.database = url.database.split('/')[0] + '/' + schema
+ else:
+ url.database += '/' + schema
+ elif schema:
+ url.database = schema
+ return create_engine(url, **params)
+
+ def get_df(self, sql, schema):
+ eng = self.get_sqla_engine(schema=schema)
+ cur = eng.execute(sql, schema=schema)
+ cols = [col[0] for col in cur.cursor.description]
+ df = pd.DataFrame(cur.fetchall(), columns=cols)
+ return df
+
+ def compile_sqla_query(self, qry, schema=None):
+ eng = self.get_sqla_engine(schema=schema)
+ compiled = qry.compile(eng, compile_kwargs={"literal_binds": True})
+ return '{}'.format(compiled)
+
+ def select_star(self, table_name, schema=None, limit=1000):
+ """Generates a ``select *`` statement in the proper dialect"""
+ qry = select('*').select_from(text(table_name))
+ if limit:
+ qry = qry.limit(limit)
+ return self.compile_sqla_query(qry)
+
+ def wrap_sql_limit(self, sql, limit=1000):
+ qry = (
+ select('*')
+ .select_from(TextAsFrom(text(sql), ['*'])
+ .alias('inner_qry')).limit(limit)
+ )
+ return self.compile_sqla_query(qry)
def safe_sqlalchemy_uri(self):
return self.sqlalchemy_uri
+ @property
+ def inspector(self):
+ engine = self.get_sqla_engine()
+ return sqla.inspect(engine)
+
+ def all_table_names(self, schema=None):
+ return sorted(self.inspector.get_table_names(schema))
+
+ def all_view_names(self, schema=None):
+ views = []
+ try:
+ views = self.inspector.get_view_names(schema)
+ except Exception as e:
+ pass
+ return views
+
+ def all_schema_names(self):
+ return sorted(self.inspector.get_schema_names())
+
def grains(self):
"""Defines time granularity database-specific expressions.
@@ -508,10 +573,8 @@ class Database(Model, AuditMixinNullable):
autoload=True,
autoload_with=self.get_sqla_engine())
- def get_columns(self, table_name):
- engine = self.get_sqla_engine()
- insp = reflection.Inspector.from_engine(engine)
- return insp.get_columns(table_name)
+ def get_columns(self, table_name, schema=None):
+ return self.inspector.get_columns(table_name, schema)
@property
def sqlalchemy_uri_decrypted(self):
@@ -523,10 +586,6 @@ class Database(Model, AuditMixinNullable):
def sql_url(self):
return '/caravel/sql/{}/'.format(self.id)
- @property
- def sql_link(self):
- return 'SQL '.format(self.sql_url)
-
@property
def perm(self):
return (
@@ -554,6 +613,7 @@ class SqlaTable(Model, Queryable, AuditMixinNullable):
offset = Column(Integer, default=0)
cache_timeout = Column(Integer)
schema = Column(String(255))
+ sql = Column(Text)
table_columns = relationship("TableColumn", back_populates="table")
baselink = "tablemodelview"
@@ -632,10 +692,6 @@ class SqlaTable(Model, Queryable, AuditMixinNullable):
def sql_url(self):
return self.database.sql_url + "?table_name=" + str(self.table_name)
- @property
- def sql_link(self):
- return 'SQL '.format(self.sql_url)
-
def get_col(self, col_name):
columns = self.table_columns
for col in columns:
@@ -737,6 +793,10 @@ class SqlaTable(Model, Queryable, AuditMixinNullable):
if self.schema:
tbl.schema = self.schema
+ # Supporting arbitrary SQL statements in place of tables
+ if self.sql:
+ tbl = text('(' + self.sql + ') as expr_qry ')
+
if not columns:
qry = qry.group_by(*groupby_exprs)
@@ -1690,3 +1750,86 @@ class FavStar(Model):
class_name = Column(String(50))
obj_id = Column(Integer)
dttm = Column(DateTime, default=func.now())
+
+
+class QueryStatus:
+ CANCELLED = 'cancelled'
+ FAILED = 'failed'
+ PENDING = 'pending'
+ RUNNING = 'running'
+ SCHEDULED = 'scheduled'
+ SUCCESS = 'success'
+ TIMED_OUT = 'timed_out'
+
+
+class Query(Model):
+
+ """ORM model for SQL query"""
+
+ __tablename__ = 'query'
+ id = Column(Integer, primary_key=True)
+ client_id = Column(String(11))
+
+ database_id = Column(Integer, ForeignKey('dbs.id'), nullable=False)
+
+ # Store the tmp table into the DB only if the user asks for it.
+ tmp_table_name = Column(String(256))
+ user_id = Column(
+ Integer, ForeignKey('ab_user.id'), nullable=True)
+ status = Column(String(16), default=QueryStatus.PENDING)
+ name = Column(String(256))
+ tab_name = Column(String(256))
+ sql_editor_id = Column(String(256))
+ schema = Column(String(256))
+ sql = Column(Text)
+ # Query to retrieve the results,
+ # used only in case of select_as_cta_used is true.
+ select_sql = Column(Text)
+ executed_sql = Column(Text)
+ # Could be configured in the caravel config.
+ limit = Column(Integer)
+ limit_used = Column(Boolean, default=False)
+ select_as_cta = Column(Boolean)
+ select_as_cta_used = Column(Boolean, default=False)
+
+ progress = Column(Integer, default=0) # 1..100
+ # # of rows in the result set or rows modified.
+ rows = Column(Integer)
+ error_message = Column(Text)
+
+ # Using Numeric in place of DateTime for sub-second precision
+ # stored as seconds since epoch, allowing for milliseconds
+ start_time = Column(Numeric(precision=3))
+ end_time = Column(Numeric(precision=3))
+ changed_on = Column(
+ DateTime, default=datetime.now, onupdate=datetime.now, nullable=True)
+
+ database = relationship(
+ 'Database', foreign_keys=[database_id], backref='queries')
+
+ __table_args__ = (
+ sqla.Index('ti_user_id_changed_on', user_id, changed_on),
+ )
+
+ def to_dict(self):
+ return {
+ 'changedOn': self.changed_on,
+ 'dbId': self.database_id,
+ 'endDttm': self.end_time,
+ 'errorMessage': self.error_message,
+ 'executedSql': self.executed_sql,
+ 'id': self.client_id,
+ 'limit': self.limit,
+ 'progress': self.progress,
+ 'rows': self.rows,
+ 'schema': self.schema,
+ 'ctas': self.select_as_cta,
+ 'serverId': self.id,
+ 'sql': self.sql,
+ 'sqlEditorId': self.sql_editor_id,
+ 'startDttm': self.start_time,
+ 'state': self.status.lower(),
+ 'tab': self.tab_name,
+ 'tempTable': self.tmp_table_name,
+ 'userId': self.user_id,
+ }
diff --git a/caravel/sql_lab.py b/caravel/sql_lab.py
new file mode 100644
index 000000000..ffc4f4e82
--- /dev/null
+++ b/caravel/sql_lab.py
@@ -0,0 +1,136 @@
+import celery
+from datetime import datetime
+import pandas as pd
+import logging
+from caravel import app, db, models, utils
+import time
+
+QueryStatus = models.QueryStatus
+
+
+celery_app = celery.Celery(config_source=app.config.get('CELERY_CONFIG'))
+
+
+def is_query_select(sql):
+ return sql.upper().startswith('SELECT')
+
+def create_table_as(sql, table_name, override=False):
+ """Reformats the query into the create table as query.
+
+ Works only for the single select SQL statements, in all other cases
+ the sql query is not modified.
+ :param sql: string, sql query that will be executed
+ :param table_name: string, will contain the results of the query execution
+ :param override, boolean, table table_name will be dropped if true
+ :return: string, create table as query
+ """
+ # TODO(bkyryliuk): enforce that all the columns have names. Presto requires it
+ # for the CTA operation.
+ # TODO(bkyryliuk): drop table if allowed, check the namespace and
+ # the permissions.
+ # TODO raise if multi-statement
+ exec_sql = ''
+ if is_query_select(sql):
+ if override:
+ exec_sql = 'DROP TABLE IF EXISTS {};\n'.format(table_name)
+ exec_sql += "CREATE TABLE {table_name} AS \n{sql}"
+ else:
+ raise Exception("Could not generate CREATE TABLE statement")
+ return exec_sql.format(**locals())
+
+
+@celery_app.task
+def get_sql_results(query_id, return_results=True):
+ """Executes the sql query returns the results."""
+ db.session.commit() # HACK
+ q = db.session.query(models.Query).all()
+ query = db.session.query(models.Query).filter_by(id=query_id).one()
+ database = query.database
+ executed_sql = query.sql.strip().strip(';')
+
+ # Limit enforced only for retrieving the data, not for the CTA queries.
+ if is_query_select(executed_sql):
+ if query.select_as_cta:
+ if not query.tmp_table_name:
+ start_dttm = datetime.fromtimestamp(query.start_time)
+ query.tmp_table_name = 'tmp_{}_table_{}'.format(
+ query.user_id,
+ start_dttm.strftime('%Y_%m_%d_%H_%M_%S'))
+ executed_sql = create_table_as(executed_sql, query.tmp_table_name)
+ query.select_as_cta_used = True
+ elif query.limit:
+ executed_sql = database.wrap_sql_limit(executed_sql, query.limit)
+ query.limit_used = True
+ engine = database.get_sqla_engine(schema=query.schema)
+ try:
+ query.executed_sql = executed_sql
+ logging.info("Running query: \n{}".format(executed_sql))
+ result_proxy = engine.execute(query.executed_sql, schema=query.schema)
+ except Exception as e:
+ logging.exception(e)
+ query.error_message = utils.error_msg_from_exception(e)
+ query.status = QueryStatus.FAILED
+ query.tmp_table_name = None
+ db.session.commit()
+ raise Exception(query.error_message)
+
+ cursor = result_proxy.cursor
+ query.status = QueryStatus.RUNNING
+ db.session.flush()
+ if hasattr(cursor, "poll"):
+ polled = cursor.poll()
+ # poll returns dict -- JSON status information or ``None``
+ # if the query is done
+ # https://github.com/dropbox/PyHive/blob/
+ # b34bdbf51378b3979eaf5eca9e956f06ddc36ca0/pyhive/presto.py#L178
+ while polled:
+ # Update the object and wait for the kill signal.
+ stats = polled.get('stats', {})
+ if stats:
+ completed_splits = float(stats.get('completedSplits'))
+ total_splits = float(stats.get('totalSplits'))
+ if total_splits and completed_splits:
+ progress = 100 * (completed_splits / total_splits)
+ if progress > query.progress:
+ query.progress = progress
+ db.session.commit()
+ time.sleep(200)
+
+ polled = cursor.poll()
+ # TODO(b.kyryliuk): check for the kill signal.
+
+ columns = None
+ data = None
+ if result_proxy.cursor:
+ cols = [col[0] for col in result_proxy.cursor.description]
+ data = result_proxy.fetchall()
+ df = pd.DataFrame(data, columns=cols)
+ columns = [c for c in df.columns]
+ data = df.to_dict(orient='records')
+
+ query.rows = result_proxy.rowcount
+ query.progress = 100
+ query.status = QueryStatus.SUCCESS
+ if query.rows == -1 and data:
+ # Presto doesn't provide result_proxy.row_count
+ query.rows = len(data)
+
+ # CTAs queries result in 1 cell having the # of the added rows.
+ if query.select_as_cta:
+ query.select_sql = '{}'.format(database.select_star(
+ query.tmp_table_name, limit=query.limit))
+
+ query.end_time = utils.now_as_float()
+ db.session.commit()
+
+ payload = {
+ 'query_id': query.id,
+ 'status': query.status,
+ }
+ if query.status == models.QueryStatus.SUCCESS:
+ payload['data'] = data
+ payload['columns'] = columns
+ else:
+ payload['error'] = query.error_message
+ if return_results:
+ return payload
diff --git a/caravel/templates/caravel/base.html b/caravel/templates/caravel/base.html
index e2664e7fa..69a6d0a9b 100644
--- a/caravel/templates/caravel/base.html
+++ b/caravel/templates/caravel/base.html
@@ -8,10 +8,10 @@
{% block head_js %}
{{super()}}
-
+
{% endblock %}
{% block tail_js %}
{{super()}}
-
+
{% endblock %}
diff --git a/caravel/templates/caravel/basic.html b/caravel/templates/caravel/basic.html
index 07782418e..f2545ec73 100644
--- a/caravel/templates/caravel/basic.html
+++ b/caravel/templates/caravel/basic.html
@@ -13,7 +13,7 @@
{% endblock %}
{% block head_js %}
-
+
{% endblock %}
@@ -29,7 +29,7 @@
{% block body %}
{% include 'caravel/flash_wrapper.html' %}
- Oops! React.js is not working properly.
+
{% endblock %}
diff --git a/caravel/templates/caravel/dashboard.html b/caravel/templates/caravel/dashboard.html
index 94536cf2b..de59a5637 100644
--- a/caravel/templates/caravel/dashboard.html
+++ b/caravel/templates/caravel/dashboard.html
@@ -2,7 +2,7 @@
{% block head_js %}
{{ super() }}
-
+
{% endblock %}
{% block title %}[dashboard] {{ dashboard.dashboard_title }}{% endblock %}
{% block body %}
diff --git a/caravel/templates/caravel/explore.html b/caravel/templates/caravel/explore.html
index 846a26528..755689e4a 100644
--- a/caravel/templates/caravel/explore.html
+++ b/caravel/templates/caravel/explore.html
@@ -339,5 +339,5 @@
{% block tail_js %}
{{ super() }}
-
+
{% endblock %}
diff --git a/caravel/templates/caravel/index.html b/caravel/templates/caravel/index.html
index 1c82a1df8..015e4fb40 100644
--- a/caravel/templates/caravel/index.html
+++ b/caravel/templates/caravel/index.html
@@ -2,5 +2,5 @@
{% block tail_js %}
{{ super() }}
-
+
{% endblock %}
diff --git a/caravel/templates/caravel/paper-theme.html b/caravel/templates/caravel/paper-theme.html
new file mode 100644
index 000000000..7f00e2469
--- /dev/null
+++ b/caravel/templates/caravel/paper-theme.html
@@ -0,0 +1,577 @@
+{% extends "caravel/basic.html" %}
+
+{% block body %}
+
+
+
+
+
+
+
+
+
+
+
+
Theme example
+
This is a template showcasing the optional theme stylesheet included in Bootstrap. Use it as a starting point to create something more unique by building on or modifying it.
+
+
+
+
+
+ Default
+ Primary
+ Success
+ Info
+ Warning
+ Danger
+ Link
+
+
+ Default
+ Primary
+ Success
+ Info
+ Warning
+ Danger
+ Link
+
+
+ Default
+ Primary
+ Success
+ Info
+ Warning
+ Danger
+ Link
+
+
+ Default
+ Primary
+ Success
+ Info
+ Warning
+ Danger
+ Link
+
+
+
+
+
+
+
+
+
+ #
+ First Name
+ Last Name
+ Username
+
+
+
+
+ 1
+ Mark
+ Otto
+ @mdo
+
+
+ 2
+ Jacob
+ Thornton
+ @fat
+
+
+ 3
+ Larry
+ the Bird
+ @twitter
+
+
+
+
+
+
+
+
+ #
+ First Name
+ Last Name
+ Username
+
+
+
+
+ 1
+ Mark
+ Otto
+ @mdo
+
+
+ 2
+ Jacob
+ Thornton
+ @fat
+
+
+ 3
+ Larry
+ the Bird
+ @twitter
+
+
+
+
+
+
+
+
+
+
+
+ #
+ First Name
+ Last Name
+ Username
+
+
+
+
+ 1
+ Mark
+ Otto
+ @mdo
+
+
+ Mark
+ Otto
+ @TwBootstrap
+
+
+ 2
+ Jacob
+ Thornton
+ @fat
+
+
+ 3
+ Larry the Bird
+ @twitter
+
+
+
+
+
+
+
+
+ #
+ First Name
+ Last Name
+ Username
+
+
+
+
+ 1
+ Mark
+ Otto
+ @mdo
+
+
+ 2
+ Jacob
+ Thornton
+ @fat
+
+
+ 3
+ Larry the Bird
+ @twitter
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Default
+ Primary
+ Success
+ Info
+ Warning
+ Danger
+
+
+ Default
+ Primary
+ Success
+ Info
+ Warning
+ Danger
+
+
+ Default
+ Primary
+ Success
+ Info
+ Warning
+ Danger
+
+
+ Default
+ Primary
+ Success
+ Info
+ Warning
+ Danger
+
+
+ Default
+ Primary
+ Success
+ Info
+ Warning
+ Danger
+
+
+ Default
+ Primary
+ Success
+ Info
+ Warning
+ Danger
+
+
+ Default
+ Primary
+ Success
+ Info
+ Warning
+ Danger
+
+
+
+
+
+ 42
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Well done! You successfully read this important alert message. With a
link .
+
+
+ Heads up! This alert needs your attention, but it's not super important.
+
+
+ Warning! Best check yo self, you're not looking too good.
+
+
+ Oh snap! Change a few things up and try submitting again.
+
+
+
+
+
+
+
40% Complete (success)
+
+
+
+
60% Complete (warning)
+
+
+
80% Complete (danger)
+
+
+
+
35% Complete (success)
+
20% Complete (warning)
+
10% Complete (danger)
+
+
+
+
+
+
+
+ Cras justo odio
+ Dapibus ac facilisis in
+ Morbi leo risus
+ Porta ac consectetur ac
+ Vestibulum at eros
+
+
+
+
+
+
+
+
+
+
+
+
+
Panel title
+
+
+ Panel content
+
+
+
+
+
Panel title
+
+
+ Panel content
+
+
+
+
+
+
+
Panel title
+
+
+ Panel content
+
+
+
+
+
Panel title
+
+
+ Panel content
+
+
+
+
+
+
+
Panel title
+
+
+ Panel content
+
+
+
+
+
Panel title
+
+
+ Panel content
+
+
+
+
+
+
+
+
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas sed diam eget risus varius blandit sit amet non magna. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent commodo cursus magna, vel scelerisque nisl consectetur et. Cras mattis consectetur purus sit amet fermentum. Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit. Aenean lacinia bibendum nulla sed consectetur.
+
+
+ This is tabs above here
+
+
+
+
+{% endblock %}
diff --git a/caravel/templates/caravel/sql.html b/caravel/templates/caravel/sql.html
deleted file mode 100644
index 318b09746..000000000
--- a/caravel/templates/caravel/sql.html
+++ /dev/null
@@ -1,51 +0,0 @@
-{% extends "caravel/basic.html" %}
-
-
-{% block body %}
-
- {% include 'caravel/flash_wrapper.html' %}
-
-
db: [{{ db }}]
-
-
-
- {{_("Run!")}}
- {{_("Create View")}}
-
-
-
- {% for t in tables %}
-
- {{ t }}
-
- {% endfor %}
-
- SELECT *
-
-
-
-
-
-
-
-
-
-
-
-
-{% endblock %}
-
-{% block tail_js %}
-{{ super() }}
-
-{% endblock %}
diff --git a/caravel/templates/caravel/sqllab.html b/caravel/templates/caravel/sqllab.html
new file mode 100644
index 000000000..374deb64e
--- /dev/null
+++ b/caravel/templates/caravel/sqllab.html
@@ -0,0 +1,6 @@
+{% extends "caravel/basic.html" %}
+
+{% block tail_js %}
+ {{ super() }}
+
+{% endblock %}
diff --git a/caravel/templates/caravel/standalone.html b/caravel/templates/caravel/standalone.html
index a84221a92..bd12e54b2 100644
--- a/caravel/templates/caravel/standalone.html
+++ b/caravel/templates/caravel/standalone.html
@@ -23,7 +23,7 @@
-
-
+
+