diff --git a/README.md b/README.md index 98437a7abbf4fa5609eaa0552d874e3acbd85d7e..b3c55e775da481c2f923429ae5409789f2e53ff5 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,12 @@ Metabase is the easy, open source way for everyone in your company to ask questi # Features - 5 minute [setup](http://www.metabase.com/docs/latest/setting-up-metabase) (We're not kidding) -- Let anyone on your team [ask questions](http://www.metabase.com/docs/latest/users-guide/03-asking-questions) without knowing SQL -- Rich beautiful [dashboards](http://www.metabase.com/docs/latest/users-guide/05-sharing-answers) with auto refresh and fullscreen +- Let anyone on your team [ask questions](http://www.metabase.com/docs/latest/users-guide/04-asking-questions) without knowing SQL +- Rich beautiful [dashboards](http://www.metabase.com/docs/latest/users-guide/06-sharing-answers) with auto refresh and fullscreen - SQL Mode for analysts and data pros - Create canonical [segments and metrics](http://www.metabase.com/docs/latest/administration-guide/07-segments-and-metrics) for your team to use -- Send data to Slack or email on a schedule with [Pulses](http://www.metabase.com/docs/latest/users-guide/09-pulses) -- View data in Slack anytime with [MetaBot](http://www.metabase.com/docs/latest/users-guide/10-metabot) +- Send data to Slack or email on a schedule with [Pulses](http://www.metabase.com/docs/latest/users-guide/10-pulses) +- View data in Slack anytime with [MetaBot](http://www.metabase.com/docs/latest/users-guide/11-metabot) - [Humanize data](http://www.metabase.com/docs/latest/administration-guide/03-metadata-editing) for your team by renaming, annotating and hiding fields For more information check out [metabase.com](http://www.metabase.com) diff --git a/bin/osx-release b/bin/osx-release index 868747e5ccc48f7c21adce59a7819fe7b1f42743..0165e4b915613d628c3e1b25a8c609e8d930525e 100755 --- a/bin/osx-release +++ b/bin/osx-release @@ -187,8 +187,10 @@ sub create_dmg_from_source_dir { '-fs', 'HFS+', '-fsargs', '-c c=64,a=16,e=16', '-format', 'UDRW', - '-size', '256MB', # it looks like this can be whatever size we want; compression slims it down + '-size', '512MB', # has to be big enough to hold everything uncompressed, but doesn't matter if there's extra space -- compression slims it down $dmg_filename) == 0 or die $!; + + announce "$dmg_filename created."; } # Mount the disk image, return the device name diff --git a/bin/version b/bin/version index aa97490e03a8c5e395fc40901d99fe812929133a..8f8564b7acdd9298a03c847856b3540774b54092 100755 --- a/bin/version +++ b/bin/version @@ -1,6 +1,6 @@ #!/usr/bin/env bash -VERSION="v0.24.1" +VERSION="v0.25.0-snapshot" # dynamically pull more interesting stuff from latest git commit HASH=$(git show-ref --head --hash=7 head) # first 7 letters of hash should be enough; that's what GitHub uses diff --git a/frontend/src/metabase/admin/settings/components/SettingsSetupList.jsx b/frontend/src/metabase/admin/settings/components/SettingsSetupList.jsx index b6b58ff722f001194d5e491afccd265dc3065511..8730041bb3bb9a60234ceaaae3efb12acb4c06d1 100644 --- a/frontend/src/metabase/admin/settings/components/SettingsSetupList.jsx +++ b/frontend/src/metabase/admin/settings/components/SettingsSetupList.jsx @@ -2,6 +2,7 @@ import React, { Component } from "react"; import { Link } from "react-router"; import Icon from "metabase/components/Icon.jsx"; import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper.jsx"; +import { SetupApi } from "metabase/services"; const TaskList = ({tasks}) => <ol> @@ -57,11 +58,11 @@ export default class SettingsSetupList extends Component { } async componentWillMount() { - let response = await fetch("/api/setup/admin_checklist", { credentials: 'same-origin' }); - if (response.status !== 200) { - this.setState({ error: await response.json() }) - } else { - this.setState({ tasks: await response.json() }); + try { + const tasks = await SetupApi.admin_checklist(); + this.setState({ tasks: tasks }); + } catch (e) { + this.setState({ error: e }); } } diff --git a/frontend/src/metabase/admin/settings/components/SettingsSlackForm.jsx b/frontend/src/metabase/admin/settings/components/SettingsSlackForm.jsx index fb111c719574dc4977fa6a391735cc6b18ef4e56..221d5e6514ec57ed6526949da21fcfcdf2accf7d 100644 --- a/frontend/src/metabase/admin/settings/components/SettingsSlackForm.jsx +++ b/frontend/src/metabase/admin/settings/components/SettingsSlackForm.jsx @@ -200,7 +200,7 @@ export default class SettingsSlackForm extends Component { Metabase <RetinaImage className="mx1" - src="/app/img/slack_emoji.png" + src="app/assets/img/slack_emoji.png" width={79} forceOriginalDimensions={false /* broken in React v0.13 */} /> diff --git a/frontend/src/metabase/admin/settings/components/widgets/CustomGeoJSONWidget.jsx b/frontend/src/metabase/admin/settings/components/widgets/CustomGeoJSONWidget.jsx index 5308fcd1cd408de0d28ea7ed4a9d872750b7403c..c60d36ce788cb8a551b088a4b3a9f9a6e7f3835b 100644 --- a/frontend/src/metabase/admin/settings/components/widgets/CustomGeoJSONWidget.jsx +++ b/frontend/src/metabase/admin/settings/components/widgets/CustomGeoJSONWidget.jsx @@ -11,8 +11,9 @@ import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper.j import SettingHeader from "../SettingHeader.jsx"; +import { SettingsApi, GeoJSONApi } from "metabase/services"; + import cx from "classnames"; -import fetch from 'isomorphic-fetch'; import LeafletChoropleth from "metabase/visualizations/components/LeafletChoropleth.jsx"; @@ -52,11 +53,9 @@ export default class CustomGeoJSONWidget extends Component { delete value[id]; } - await fetch("/api/setting/custom-geojson", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ value }), - credentials: "same-origin", + await SettingsApi.put({ + key: "custom-geojson", + value: value }); await this.props.reloadSettings(); @@ -88,11 +87,9 @@ export default class CustomGeoJSONWidget extends Component { geoJsonError: null, }); await this._saveMap(map.id, map); - let geoJsonResponse = await fetch("/api/geojson/" + map.id, { - credentials: "same-origin" - }); + let geoJson = await GeoJSONApi.get({ id: map.id }); this.setState({ - geoJson: await geoJsonResponse.json(), + geoJson: geoJson, geoJsonLoading: false, geoJsonError: null, }); diff --git a/frontend/src/metabase/admin/settings/components/widgets/PublicLinksListing.jsx b/frontend/src/metabase/admin/settings/components/widgets/PublicLinksListing.jsx index 892ba549ca6aeb38110a651b9fa711153f6b7e2c..49f5dfc9e885c0c2b04a0cc467443bc1df27f61d 100644 --- a/frontend/src/metabase/admin/settings/components/widgets/PublicLinksListing.jsx +++ b/frontend/src/metabase/admin/settings/components/widgets/PublicLinksListing.jsx @@ -152,7 +152,7 @@ export const PublicLinksDashboardListing = () => revoke={DashboardApi.deletePublicLink} type='Public Dashboard Listing' getUrl={({ id }) => Urls.dashboard(id)} - getPublicUrl={({ public_uuid }) => window.location.origin + Urls.publicDashboard(public_uuid)} + getPublicUrl={({ public_uuid }) => Urls.publicDashboard(public_uuid)} noLinksMessage="No dashboards have been publicly shared yet." />; @@ -162,7 +162,7 @@ export const PublicLinksQuestionListing = () => revoke={CardApi.deletePublicLink} type='Public Card Listing' getUrl={({ id }) => Urls.question(id)} - getPublicUrl={({ public_uuid }) => window.location.origin + Urls.publicCard(public_uuid)} + getPublicUrl={({ public_uuid }) => Urls.publicCard(public_uuid)} noLinksMessage="No questions have been publicly shared yet." />; diff --git a/frontend/src/metabase/app-main.js b/frontend/src/metabase/app-main.js index fa077d93f3d26c873c6f552bf80bfe6b9a032961..169ad090a05c8749f8c21dff82506369a2b95353 100644 --- a/frontend/src/metabase/app-main.js +++ b/frontend/src/metabase/app-main.js @@ -26,7 +26,7 @@ const WHITELIST_FORBIDDEN_URLS = [ init(reducers, getRoutes, (store) => { // received a 401 response api.on("401", (url) => { - if (url === "/api/user/current") { + if (url.indexOf("/api/user/current") >= 0) { return } store.dispatch(clearCurrentUser()); diff --git a/frontend/src/metabase/app.js b/frontend/src/metabase/app.js index 57b95bb27fba89bca34c9f20f7aaf8d944443b6a..5cefe8294d390108453703d02b6d256ff8d348cf 100644 --- a/frontend/src/metabase/app.js +++ b/frontend/src/metabase/app.js @@ -10,13 +10,24 @@ import { Provider } from 'react-redux' import MetabaseAnalytics, { registerAnalyticsClickListener } from "metabase/lib/analytics"; import MetabaseSettings from "metabase/lib/settings"; +import api from "metabase/lib/api"; + import { getStore } from './store' import { refreshSiteSettings } from "metabase/redux/settings"; -import { Router, browserHistory } from "react-router"; -import { syncHistoryWithStore } from 'react-router-redux' +import { Router, useRouterHistory } from "react-router"; +import { createHistory } from 'history' +import { syncHistoryWithStore } from 'react-router-redux'; + +// remove trailing slash +const BASENAME = window.MetabaseRoot.replace(/\/+$/, ""); + +api.basename = BASENAME; +const browserHistory = useRouterHistory(createHistory)({ + basename: BASENAME +}); function _init(reducers, getRoutes, callback) { const store = getStore(reducers, browserHistory); diff --git a/frontend/src/metabase/components/DatabaseDetailsForm.jsx b/frontend/src/metabase/components/DatabaseDetailsForm.jsx index c72bc0c2ec44840c164e09edb6458a2e46843165..3adc7254bd39e3781c24c8a4a222c92e28608f24 100644 --- a/frontend/src/metabase/components/DatabaseDetailsForm.jsx +++ b/frontend/src/metabase/components/DatabaseDetailsForm.jsx @@ -165,7 +165,7 @@ export default class DatabaseDetailsForm extends Component { <div style={{maxWidth: "40rem"}} className="pt1"> Some database installations can only be accessed by connecting through an SSH bastion host. This option also provides an extra layer of security when a VPN is not available. - Enabling this is usually slower than a dirrect connection. + Enabling this is usually slower than a direct connection. </div> </div> </div> diff --git a/frontend/src/metabase/components/Logs.jsx b/frontend/src/metabase/components/Logs.jsx index eb1521b1b217a7412375917ad9f1f5a9b0fc110d..c6acd12eec6228c4d9c8e3961f1cc27bd2304ab8 100644 --- a/frontend/src/metabase/components/Logs.jsx +++ b/frontend/src/metabase/components/Logs.jsx @@ -1,6 +1,7 @@ import React, { Component } from "react"; import ReactDOM from "react-dom"; -import fetch from 'isomorphic-fetch'; + +import { UtilApi } from "metabase/services"; import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper.jsx"; @@ -32,7 +33,7 @@ export default class Logs extends Component { componentWillMount() { this.timer = setInterval(async () => { - let response = await fetch("/api/util/logs", { credentials: 'same-origin' }); + let response = await UtilApi.logs(); let logs = await response.json() this.setState({ logs: logs.reverse() }) }, 1000); diff --git a/frontend/src/metabase/css/query_builder.css b/frontend/src/metabase/css/query_builder.css index f298eff4caf824a7d90164dc5ec9cfb288b2c88e..b85584090cd85144d7b1c0b46300bc51eb79320e 100644 --- a/frontend/src/metabase/css/query_builder.css +++ b/frontend/src/metabase/css/query_builder.css @@ -243,25 +243,25 @@ .QueryError-image--noRows { width: 120px; height: 120px; - background-image: url('/app/img/no_results.svg'); + background-image: url('../assets/img/no_results.svg'); } .QueryError-image--queryError { width: 120px; height: 120px; - background-image: url('/app/img/no_understand.svg'); + background-image: url('../assets/img/no_understand.svg'); } .QueryError-image--serverError { width: 120px; height: 148px; - background-image: url('/app/img/blown_up.svg'); + background-image: url('../assets/img/blown_up.svg'); } .QueryError-image--timeout { width: 120px; height: 120px; - background-image: url('/app/img/stopwatch.svg'); + background-image: url('../assets/img/stopwatch.svg'); } .QueryError-message { diff --git a/frontend/src/metabase/dashboard/components/DashCard.jsx b/frontend/src/metabase/dashboard/components/DashCard.jsx index b24fd3a65369c5882233f8ab2d57214271d541df..b6b0fc202a3908d23b6c564f91e9eb3883a1fc1c 100644 --- a/frontend/src/metabase/dashboard/components/DashCard.jsx +++ b/frontend/src/metabase/dashboard/components/DashCard.jsx @@ -168,7 +168,7 @@ const ChartSettingsButton = ({ series, onReplaceAllVisualizationSettings }) => </ModalWithTrigger> const RemoveButton = ({ onRemove }) => - <a className="text-grey-2 text-grey-4-hover " data-metabase-event="Dashboard;Remove Card Modal" href="#" onClick={onRemove} style={HEADER_ACTION_STYLE}> + <a className="text-grey-2 text-grey-4-hover " data-metabase-event="Dashboard;Remove Card Modal" onClick={onRemove} style={HEADER_ACTION_STYLE}> <Icon name="close" size={HEADER_ICON_SIZE} /> </a> diff --git a/frontend/src/metabase/dashboard/containers/DashboardEmbedWidget.jsx b/frontend/src/metabase/dashboard/containers/DashboardEmbedWidget.jsx index cd76a516159bb1b31fe14cd12ee93e2eaf917491..72dd16754d8024a047a8b6f72fb33fcfabac24c6 100644 --- a/frontend/src/metabase/dashboard/containers/DashboardEmbedWidget.jsx +++ b/frontend/src/metabase/dashboard/containers/DashboardEmbedWidget.jsx @@ -32,7 +32,7 @@ export default class DashboardEmbedWidget extends Component { onDisablePublicLink={() => deletePublicLink(dashboard)} onUpdateEnableEmbedding={(enableEmbedding) => updateEnableEmbedding(dashboard, enableEmbedding)} onUpdateEmbeddingParams={(embeddingParams) => updateEmbeddingParams(dashboard, embeddingParams)} - getPublicUrl={({ public_uuid }) => window.location.origin + Urls.publicDashboard(public_uuid)} + getPublicUrl={({ public_uuid }) => Urls.publicDashboard(public_uuid)} /> ); } diff --git a/frontend/src/metabase/home/components/NewUserOnboardingModal.jsx b/frontend/src/metabase/home/components/NewUserOnboardingModal.jsx index 12e9b1c812fda901b8595a2b0df77ce893a4150f..00f10e96a74480fd75e9c2cef36e2f0ce1fc7a37 100644 --- a/frontend/src/metabase/home/components/NewUserOnboardingModal.jsx +++ b/frontend/src/metabase/home/components/NewUserOnboardingModal.jsx @@ -64,11 +64,11 @@ export default class NewUserOnboardingModal extends Component { <div className="pl4 pr4 pt4 pb1 border-bottom"> <h2>Just 3 things worth knowing</h2> - <p className="clearfix pt1"><img className="float-left mr2" width="40" height="40" src="/app/home/partials/onboarding_illustration_tables.png" />All of your data is organized in Tables. Think of them in terms of Excel spreadsheets with columns and rows.</p> + <p className="clearfix pt1"><img className="float-left mr2" width="40" height="40" src="app/assets/img/onboarding_illustration_tables.png" />All of your data is organized in Tables. Think of them in terms of Excel spreadsheets with columns and rows.</p> - <p className="clearfix"><img className="float-left mr2" width="40" height="40" src="/app/home/partials/onboarding_illustration_questions.png" />To get answers, you Ask Questions by picking a table and a few other parameters. You can visualize the answer in many ways, including cool charts.</p> + <p className="clearfix"><img className="float-left mr2" width="40" height="40" src="app/assets/img/onboarding_illustration_questions.png" />To get answers, you Ask Questions by picking a table and a few other parameters. You can visualize the answer in many ways, including cool charts.</p> - <p className="clearfix"><img className="float-left mr2" width="40" height="40" src="/app/home/partials/onboarding_illustration_dashboards.png" />You (and anyone on your team) can save answers in Dashboards, so you can check them often. It's a great way to quickly see a snapshot of your business.</p> + <p className="clearfix"><img className="float-left mr2" width="40" height="40" src="app/assets/img/onboarding_illustration_dashboards.png" />You (and anyone on your team) can save answers in Dashboards, so you can check them often. It's a great way to quickly see a snapshot of your business.</p> </div> <div className="px4 py2 text-grey-2 flex align-center"> {this.renderStep()} diff --git a/frontend/src/metabase/home/components/NextStep.jsx b/frontend/src/metabase/home/components/NextStep.jsx index 95424080c7806ae205ac52eedfcc2c450582d26b..7f90bec8f3b6deaa6441360ec63cb9978718ad74 100644 --- a/frontend/src/metabase/home/components/NextStep.jsx +++ b/frontend/src/metabase/home/components/NextStep.jsx @@ -1,6 +1,6 @@ import React, { Component } from "react"; import { Link } from "react-router"; -import fetch from 'isomorphic-fetch'; +import { SetupApi } from "metabase/services"; import SidebarSection from "./SidebarSection.jsx"; @@ -13,15 +13,12 @@ export default class NextStep extends Component { } async componentWillMount() { - let response = await fetch("/api/setup/admin_checklist", { credentials: 'same-origin' }); - if (response.status === 200) { - let sections = await response.json(); - for (let section of sections) { - for (let task of section.tasks) { - if (task.is_next_step) { - this.setState({ next: task }); - break; - } + const sections = await SetupApi.admin_checklist(null, { noEvent: true }); + for (let section of sections) { + for (let task of section.tasks) { + if (task.is_next_step) { + this.setState({ next: task }); + break; } } } diff --git a/frontend/src/metabase/home/components/Smile.jsx b/frontend/src/metabase/home/components/Smile.jsx index 1ab7fffc833e42bcba8a42943b20c8d0ee23c8e2..fedae4489c98220381f5452158b4a14cd0eed310 100644 --- a/frontend/src/metabase/home/components/Smile.jsx +++ b/frontend/src/metabase/home/components/Smile.jsx @@ -5,7 +5,7 @@ export default class Smile extends Component { const styles = { width: '48px', height: '48px', - backgroundImage: 'url("app/components/icons/assets/smile.svg")', + backgroundImage: 'url("app/assets/img/smile.svg")', } return <div style={styles}></div> } diff --git a/frontend/src/metabase/icon_paths.js b/frontend/src/metabase/icon_paths.js index 6b55243deca7e0866774fdaae008857f769b3725..5bfb5e3f3ff3e0d1508bc9753d0cf0cbd4c7ca76 100644 --- a/frontend/src/metabase/icon_paths.js +++ b/frontend/src/metabase/icon_paths.js @@ -207,7 +207,7 @@ export var ICON_PATHS = { x: 'm11.271709,16 l-3.19744231e-13,4.728291 l4.728291,0 l16,11.271709 l27.271709,2.39808173e-13 l32,4.728291 l20.728291,16 l31.1615012,26.4332102 l26.4332102,31.1615012 l16,20.728291 l5.56678976,31.1615012 l0.838498756,26.4332102 l11.271709,16 z', zoom: 'M12.416 12.454V8.37h3.256v4.083h4.07v3.266h-4.07v4.083h-3.256V15.72h-4.07v-3.266h4.07zm10.389 13.28c-5.582 4.178-13.543 3.718-18.632-1.37-5.58-5.581-5.595-14.615-.031-20.179 5.563-5.563 14.597-5.55 20.178.031 5.068 5.068 5.545 12.985 1.422 18.563l5.661 5.661a2.08 2.08 0 0 1 .003 2.949 2.085 2.085 0 0 1-2.95-.003l-5.651-5.652zm-1.486-4.371c3.895-3.895 3.885-10.218-.021-14.125-3.906-3.906-10.23-3.916-14.125-.021-3.894 3.894-3.885 10.218.022 14.124 3.906 3.907 10.23 3.916 14.124.022z', "slack": { - img: "/app/img/slack.png" + img: "app/assets/img/slack.png" } }; diff --git a/frontend/src/metabase/lib/api.js b/frontend/src/metabase/lib/api.js index 53369187fac44790b8b79d009a1e9b01b8b2909e..9c88348055f62b042448f1c59a4ba534a70ff9d7 100644 --- a/frontend/src/metabase/lib/api.js +++ b/frontend/src/metabase/lib/api.js @@ -4,88 +4,115 @@ import querystring from "querystring"; import EventEmitter from "events"; -let events = new EventEmitter(); - -type ParamsMap = { [key:string]: any }; type TransformFn = (o: any) => any; -function makeMethod(method: string, hasBody: boolean = false) { - return function( - urlTemplate: string, - params: ParamsMap|TransformFn = {}, - transformResponse: TransformFn = (o) => o - ) { - if (typeof params === "function") { - transformResponse = params; - params = {}; - } - return function( - data?: { [key:string]: any }, - options?: { [key:string]: any } = {} - ): Promise<any> { - let url = urlTemplate; - data = { ...data }; - for (let tag of (url.match(/:\w+/g) || [])) { - let value = data[tag.slice(1)]; - if (value === undefined) { - console.warn("Warning: calling", method, "without", tag); - value = ""; - } - url = url.replace(tag, encodeURIComponent(data[tag.slice(1)])) - delete data[tag.slice(1)]; - } +type Options = { + noEvent?: boolean, + transformResponse?: TransformFn, + cancelled?: Promise<any> +} +type Data = { + [key:string]: any +}; + +const DEFAULT_OPTIONS: Options = { + noEvent: false, + transformResponse: (o) => o +} - let headers: { [key:string]: string } = { - "Accept": "application/json", - }; +class Api extends EventEmitter { + basename: ""; - let body; - if (hasBody) { - headers["Content-Type"] = "application/json"; - body = JSON.stringify(data); - } else { - let qs = querystring.stringify(data); - if (qs) { - url += (url.indexOf("?") >= 0 ? "&" : "?") + qs; - } - } + GET: (t: string, o?: Options|TransformFn) => (d?: Data, o?: Options) => Promise<any>; + POST: (t: string, o?: Options|TransformFn) => (d?: Data, o?: Options) => Promise<any>; + PUT: (t: string, o?: Options|TransformFn) => (d?: Data, o?: Options) => Promise<any>; + DELETE: (t: string, o?: Options|TransformFn) => (d?: Data, o?: Options) => Promise<any>; - return new Promise((resolve, reject) => { - let xhr = new XMLHttpRequest(); - xhr.open(method, url); - for (let headerName in headers) { - xhr.setRequestHeader(headerName, headers[headerName]) - } - xhr.onreadystatechange = function() { - // $FlowFixMe - if (xhr.readyState === XMLHttpRequest.DONE) { - let body = xhr.responseText; - try { body = JSON.parse(body); } catch (e) {} - if (xhr.status >= 200 && xhr.status <= 299) { - resolve(transformResponse(body, { data })); - } else { - reject({ - status: xhr.status, - data: body - }); - } - events.emit(xhr.status, url); + constructor() { + super(); + this.GET = this._makeMethod("GET").bind(this); + this.DELETE = this._makeMethod("DELETE").bind(this); + this.POST = this._makeMethod("POST", true).bind(this); + this.PUT = this._makeMethod("PUT", true).bind(this); + } + + _makeMethod(method: string, hasBody: boolean = false) { + return ( + urlTemplate: string, + methodOptions?: Options|TransformFn = {} + ) => { + if (typeof methodOptions === "function") { + methodOptions = { transformResponse: methodOptions }; + } + const defaultOptions = { ...DEFAULT_OPTIONS, ...methodOptions }; + return ( + data?: Data, + invocationOptions?: Options = {} + ): Promise<any> => { + const options: Options = { ...defaultOptions, ...invocationOptions }; + let url = urlTemplate; + data = { ...data }; + for (let tag of (url.match(/:\w+/g) || [])) { + let value = data[tag.slice(1)]; + if (value === undefined) { + console.warn("Warning: calling", method, "without", tag); + value = ""; } + url = url.replace(tag, encodeURIComponent(data[tag.slice(1)])) + delete data[tag.slice(1)]; } - xhr.send(body); - if (options.cancelled) { - options.cancelled.then(() => xhr.abort()); + let headers: { [key:string]: string } = { + "Accept": "application/json", + }; + + let body; + if (hasBody) { + headers["Content-Type"] = "application/json"; + body = JSON.stringify(data); + } else { + let qs = querystring.stringify(data); + if (qs) { + url += (url.indexOf("?") >= 0 ? "&" : "?") + qs; + } } - }) + return new Promise((resolve, reject) => { + let xhr = new XMLHttpRequest(); + xhr.open(method, this.basename + url); + for (let headerName in headers) { + xhr.setRequestHeader(headerName, headers[headerName]) + } + xhr.onreadystatechange = () => { + // $FlowFixMe + if (xhr.readyState === XMLHttpRequest.DONE) { + let body = xhr.responseText; + try { body = JSON.parse(body); } catch (e) {} + if (xhr.status >= 200 && xhr.status <= 299) { + if (options.transformResponse) { + body = options.transformResponse(body, { data }); + } + resolve(body); + } else { + reject({ + status: xhr.status, + data: body + }); + } + if (!options.noEvent) { + this.emit(xhr.status, url); + } + } + } + xhr.send(body); + + if (options.cancelled) { + options.cancelled.then(() => xhr.abort()); + } + }); + } } } } -export const GET = makeMethod("GET"); -export const DELETE = makeMethod("DELETE"); -export const POST = makeMethod("POST", true); -export const PUT = makeMethod("PUT", true); - -export default events; +export default new Api(); diff --git a/frontend/src/metabase/lib/cookies.js b/frontend/src/metabase/lib/cookies.js index 083ba20d512bc6e0f5b40cf481f9b1a5e3d8d943..705fd7bfd5a02882b4c02a7a32150e1570223c3d 100644 --- a/frontend/src/metabase/lib/cookies.js +++ b/frontend/src/metabase/lib/cookies.js @@ -10,7 +10,7 @@ var MetabaseCookies = { // set the session cookie. if sessionId is null, clears the cookie setSessionCookie: function(sessionId) { const options = { - path: '/', + path: window.MetabaseRoot || '/', expires: 14, secure: window.location.protocol === "https:" }; diff --git a/frontend/src/metabase/lib/urls.js b/frontend/src/metabase/lib/urls.js index f63f47403069f448d5f6a7e1a4da1c7669420d5a..3a83c230e064af2ca68e7209d85f102577e56c48 100644 --- a/frontend/src/metabase/lib/urls.js +++ b/frontend/src/metabase/lib/urls.js @@ -1,4 +1,5 @@ import { serializeCardForUrl } from "metabase/lib/card"; +import MetabaseSettings from "metabase/lib/settings" // provides functions for building urls to things we care about @@ -69,11 +70,13 @@ export function label(label) { } export function publicCard(uuid, type = null) { - return `/public/question/${uuid}` + (type ? `.${type}` : ``); + const siteUrl = MetabaseSettings.get("site-url"); + return `${siteUrl}/public/question/${uuid}` + (type ? `.${type}` : ``); } export function publicDashboard(uuid) { - return `/public/dashboard/${uuid}`; + const siteUrl = MetabaseSettings.get("site-url"); + return `${siteUrl}/public/dashboard/${uuid}`; } export function embedCard(token, type = null) { diff --git a/frontend/src/metabase/public/components/widgets/SharingPane.jsx b/frontend/src/metabase/public/components/widgets/SharingPane.jsx index 783a46001e5756099bcd572225f91f5b5a60d2c6..5e89500a23e8d6a05c620ad4fea74cd95f930579 100644 --- a/frontend/src/metabase/public/components/widgets/SharingPane.jsx +++ b/frontend/src/metabase/public/components/widgets/SharingPane.jsx @@ -115,7 +115,7 @@ export default class SharingPane extends Component<*, Props, State> { <div className={cx("mb4 flex align-center", { disabled: !resource.public_uuid })}> <RetinaImage width={98} - src="/app/img/simple_embed.png" + src="app/assets/img/simple_embed.png" forceOriginalDimensions={false} /> <div className="ml2 flex-full"> @@ -131,7 +131,7 @@ export default class SharingPane extends Component<*, Props, State> { > <RetinaImage width={100} - src="/app/img/secure_embed.png" + src="app/assets/img/secure_embed.png" forceOriginalDimensions={false} /> <div className="ml2 flex-full"> diff --git a/frontend/src/metabase/pulse/components/WhatsAPulse.jsx b/frontend/src/metabase/pulse/components/WhatsAPulse.jsx index 0d44604a99758b1ed9ba13be70b6a39cde1619f4..85ec9f8cef1816e9d139f6badfba3db75d329b4a 100644 --- a/frontend/src/metabase/pulse/components/WhatsAPulse.jsx +++ b/frontend/src/metabase/pulse/components/WhatsAPulse.jsx @@ -17,7 +17,7 @@ export default class WhatsAPulse extends Component { <div className="mx4"> <RetinaImage width={574} - src="/app/img/pulse_empty_illustration.png" + src="app/assets/img/pulse_empty_illustration.png" forceOriginalDimensions={false} /> </div> diff --git a/frontend/src/metabase/query_builder/components/QueryDownloadWidget.jsx b/frontend/src/metabase/query_builder/components/QueryDownloadWidget.jsx index 2f9a29423a9c4f6cdcecf43b341e5268b132288e..b5532b94c8b21313e686cbe5b336dbb52764ccff 100644 --- a/frontend/src/metabase/query_builder/components/QueryDownloadWidget.jsx +++ b/frontend/src/metabase/query_builder/components/QueryDownloadWidget.jsx @@ -52,7 +52,7 @@ const QueryDownloadWidget = ({ className, card, result, uuid, token }) => const UnsavedQueryButton = ({ className, type, result: { json_query }, card }) => <DownloadButton className={className} - url={`/api/dataset/${type}`} + url={`api/dataset/${type}`} params={{ query: JSON.stringify(_.omit(json_query, "constraints")) }} extensions={[type]} > @@ -62,7 +62,7 @@ const UnsavedQueryButton = ({ className, type, result: { json_query }, card }) = const SavedQueryButton = ({ className, type, result: { json_query }, card }) => <DownloadButton className={className} - url={`/api/card/${card.id}/query/${type}`} + url={`api/card/${card.id}/query/${type}`} params={{ parameters: JSON.stringify(json_query.parameters) }} extensions={[type]} > diff --git a/frontend/src/metabase/query_builder/containers/QuestionEmbedWidget.jsx b/frontend/src/metabase/query_builder/containers/QuestionEmbedWidget.jsx index 072267f530518a6f57963585944334e61723a1eb..a96a68b57e4481a3d00510dfa422304e0e2f1470 100644 --- a/frontend/src/metabase/query_builder/containers/QuestionEmbedWidget.jsx +++ b/frontend/src/metabase/query_builder/containers/QuestionEmbedWidget.jsx @@ -32,7 +32,7 @@ export default class QuestionEmbedWidget extends Component { onDisablePublicLink={() => deletePublicLink(card)} onUpdateEnableEmbedding={(enableEmbedding) => updateEnableEmbedding(card, enableEmbedding)} onUpdateEmbeddingParams={(embeddingParams) => updateEmbeddingParams(card, embeddingParams)} - getPublicUrl={({ public_uuid }, extension) => window.location.origin + Urls.publicCard(public_uuid, extension)} + getPublicUrl={({ public_uuid }, extension) => Urls.publicCard(public_uuid, extension)} extensions={["csv", "xlsx", "json"]} /> ); diff --git a/frontend/src/metabase/reference/containers/ReferenceGettingStartedGuide.jsx b/frontend/src/metabase/reference/containers/ReferenceGettingStartedGuide.jsx index 346b08be2b47f5f40374b039263e8016f9f93d43..d0a637dcb108746d81f88279a5188131d8cfd869 100644 --- a/frontend/src/metabase/reference/containers/ReferenceGettingStartedGuide.jsx +++ b/frontend/src/metabase/reference/containers/ReferenceGettingStartedGuide.jsx @@ -285,7 +285,7 @@ export default class ReferenceGettingStartedGuide extends Component { collapsedTitle="Do you have any commonly referenced metrics?" collapsedIcon="ruler" linkMessage="Learn how to define a metric" - link="http://www.metabase.com/docs/latest/administration-guide/05-segments-and-metrics#creating-a-metric" + link="http://www.metabase.com/docs/latest/administration-guide/07-segments-and-metrics.html#creating-a-metric" expand={() => important_metrics.addField({id: null, caveats: null, points_of_interest: null, important_fields: null})} > <div className="my2"> @@ -338,7 +338,7 @@ export default class ReferenceGettingStartedGuide extends Component { collapsedTitle="Do you have any commonly referenced segments or tables?" collapsedIcon="table2" linkMessage="Learn how to create a segment" - link="http://www.metabase.com/docs/latest/administration-guide/05-segments-and-metrics#creating-a-segment" + link="http://www.metabase.com/docs/latest/administration-guide/07-segments-and-metrics.html#creating-a-segment" expand={() => important_segments_and_tables.addField({id: null, type: null, caveats: null, points_of_interest: null})} > <div> diff --git a/frontend/src/metabase/reference/selectors.js b/frontend/src/metabase/reference/selectors.js index f9f1a17b2f9401827d830793fc57ef6ab6bd5b2e..e8bc19961dbdf7191258f23e9e654afc8cdedcad 100644 --- a/frontend/src/metabase/reference/selectors.js +++ b/frontend/src/metabase/reference/selectors.js @@ -50,7 +50,7 @@ const referenceSections = { title: "Metrics are the official numbers that your team cares about", adminMessage: "Defining common metrics for your team makes it even easier to ask questions", message: "Metrics will appear here once your admins have created some", - image: "/app/img/metrics-list", + image: "app/assets/img/metrics-list", adminAction: "Learn how to create metrics", adminLink: "http://www.metabase.com/docs/latest/administration-guide/06-segments-and-metrics.html" }, @@ -70,7 +70,7 @@ const referenceSections = { title: "Segments are interesting subsets of tables", adminMessage: "Defining common segments for your team makes it even easier to ask questions", message: "Segments will appear here once your admins have created some", - image: "/app/img/segments-list", + image: "app/assets/img/segments-list", adminAction: "Learn how to create segments", adminLink: "http://www.metabase.com/docs/latest/administration-guide/06-segments-and-metrics.html" }, @@ -89,7 +89,7 @@ const referenceSections = { title: "Metabase is no fun without any data", adminMessage: "Your databses will appear here once you connect one", message: "Databases will appear here once your admins have added some", - image: "/app/img/databases-list", + image: "app/assets/img/databases-list", adminAction: "Connect a database", adminLink: "/admin/databases/create" }, diff --git a/frontend/src/metabase/routes.jsx b/frontend/src/metabase/routes.jsx index 3f227810f1a29b63227ea86c35f77158d845d779..2ef7fbfb05c7e85b604686a1e6d0bff60298d5c8 100644 --- a/frontend/src/metabase/routes.jsx +++ b/frontend/src/metabase/routes.jsx @@ -15,7 +15,8 @@ import App from "metabase/App.jsx"; // auth containers import ForgotPasswordApp from "metabase/auth/containers/ForgotPasswordApp.jsx"; import LoginApp from "metabase/auth/containers/LoginApp.jsx"; -import LogoutApp from "metabase/auth/containers/LogoutApp.jsx"; import PasswordResetApp from "metabase/auth/containers/PasswordResetApp.jsx"; +import LogoutApp from "metabase/auth/containers/LogoutApp.jsx"; +import PasswordResetApp from "metabase/auth/containers/PasswordResetApp.jsx"; import GoogleNoAccount from "metabase/auth/components/GoogleNoAccount.jsx"; // main app containers diff --git a/frontend/src/metabase/services.js b/frontend/src/metabase/services.js index 89c74c9b490eb9ce141df480b8d49f6a3d6f8bec..dcac3910d58d2fc00017a8d0e6a8976396f69db6 100644 --- a/frontend/src/metabase/services.js +++ b/frontend/src/metabase/services.js @@ -1,6 +1,7 @@ /* @flow */ -import { GET, PUT, POST, DELETE } from "metabase/lib/api"; +import api from "metabase/lib/api"; +const { GET, PUT, POST, DELETE } = api; import { IS_EMBED_PREVIEW } from "metabase/lib/embed"; @@ -208,6 +209,7 @@ export const GettingStartedApi = { export const SetupApi = { create: POST("/api/setup"), validate_db: POST("/api/setup/validate"), + admin_checklist: GET("/api/setup/admin_checklist"), }; export const UserApi = { @@ -225,6 +227,11 @@ export const UserApi = { export const UtilApi = { password_check: POST("/api/util/password_check"), random_token: GET("/api/util/random_token"), + logs: GET("/api/util/logs"), +}; + +export const GeoJSONApi = { + get: GET("/api/geojson/:id"), }; global.services = exports; diff --git a/frontend/src/metabase/setup/components/DatabaseStep.jsx b/frontend/src/metabase/setup/components/DatabaseStep.jsx index 9ec39c01afd0de087400196c1d138cb70e9317f8..d847d74c6f11164cebf17d047b70ae3b40e441f5 100644 --- a/frontend/src/metabase/setup/components/DatabaseStep.jsx +++ b/frontend/src/metabase/setup/components/DatabaseStep.jsx @@ -151,7 +151,7 @@ export default class DatabaseStep extends Component { : null } <div className="Form-field Form-offset"> - <a className="link" href="#" onClick={this.skipDatabase.bind(this)}>I'll add my data later</a> + <a className="link" onClick={this.skipDatabase.bind(this)}>I'll add my data later</a> </div> </div> </section> diff --git a/frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx b/frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx index 24638a3850d7ec602f9cc23d1af9a0047c4473cc..2dc826cae1a60e15f0ba3adaec321a47e2e396b7 100644 --- a/frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx +++ b/frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx @@ -11,7 +11,7 @@ const QUERY_BUILDER_STEPS = [ getPortalTarget: () => qs(".GuiBuilder"), getModal: (props) => <div className="text-centered"> - <RetinaImage className="mb2" forceOriginalDimensions={false} src="/app/img/qb_tutorial/question_builder.png" width={186} /> + <RetinaImage className="mb2" forceOriginalDimensions={false} src="app/assets/img/qb_tutorial/question_builder.png" width={186} /> <h3>Welcome to the Query Builder!</h3> <p>The Query Builder lets you assemble questions (or "queries") to ask about your data.</p> <a className="Button Button--primary" onClick={props.onNext}>Tell me more</a> @@ -22,7 +22,7 @@ const QUERY_BUILDER_STEPS = [ getModalTarget: () => qs(".GuiBuilder-data"), getModal: (props) => <div className="text-centered"> - <RetinaImage id="QB-TutorialTableImg" className="mb2" forceOriginalDimensions={false} src="/app/img/qb_tutorial/table.png" width={157} /> + <RetinaImage id="QB-TutorialTableImg" className="mb2" forceOriginalDimensions={false} src="app/assets/img/qb_tutorial/table.png" width={157} /> <h3>Start by picking the table with the data that you have a question about.</h3> <p>Go ahead and select the "Orders" table from the dropdown menu.</p> </div>, @@ -48,7 +48,7 @@ const QUERY_BUILDER_STEPS = [ className="mb2" forceOriginalDimensions={false} id="QB-TutorialFunnelImg" - src="/app/img/qb_tutorial/funnel.png" + src="app/assets/img/qb_tutorial/funnel.png" width={135} /> <h3>Filter your data to get just what you want.</h3> @@ -81,7 +81,7 @@ const QUERY_BUILDER_STEPS = [ className="mb2" forceOriginalDimensions={false} id="QB-TutorialCalculatorImg" - src="/app/img/qb_tutorial/calculator.png" + src="app/assets/img/qb_tutorial/calculator.png" width={115} /> <h3>Here's where you can choose to add or average your data, count the number of rows in the table, or just view the raw data.</h3> @@ -103,7 +103,7 @@ const QUERY_BUILDER_STEPS = [ className="mb2" forceOriginalDimensions={false} id="QB-TutorialBananaImg" - src="/app/img/qb_tutorial/banana.png" + src="app/assets/img/qb_tutorial/banana.png" width={232} /> <h3>Add a grouping to break out your results by category, day, month, and more.</h3> @@ -131,7 +131,7 @@ const QUERY_BUILDER_STEPS = [ className="mb2" forceOriginalDimensions={false} id="QB-TutorialRocketImg" - src="/app/img/qb_tutorial/rocket.png" + src="app/assets/img/qb_tutorial/rocket.png" width={217} /> <h3>Run Your Query.</h3> @@ -148,7 +148,7 @@ const QUERY_BUILDER_STEPS = [ className="mb2" forceOriginalDimensions={false} id="QB-TutorialChartImg" - src="/app/img/qb_tutorial/chart.png" + src="app/assets/img/qb_tutorial/chart.png" width={160} /> <h3>You can view your results as a chart instead of a table.</h3> @@ -169,7 +169,7 @@ const QUERY_BUILDER_STEPS = [ className="mb2" forceOriginalDimensions={false} id="QB-TutorialBoatImg" - src="/app/img/qb_tutorial/boat.png" width={190} + src="app/assets/img/qb_tutorial/boat.png" width={190} /> <h3>Well done!</h3> <p>That's all! If you still have questions, check out our <a className="link" target="_blank" href="http://www.metabase.com/docs/latest/users-guide/start">User's Guide</a>. Have fun exploring your data!</p> diff --git a/frontend/src/metabase/visualizations/components/ChoroplethMap.jsx b/frontend/src/metabase/visualizations/components/ChoroplethMap.jsx index d685c95e81c535cbe0aafa3ba32783b0b8adf5f1..a415d447fca4cefd734e833f8860dea050075168 100644 --- a/frontend/src/metabase/visualizations/components/ChoroplethMap.jsx +++ b/frontend/src/metabase/visualizations/components/ChoroplethMap.jsx @@ -92,7 +92,7 @@ export default class ChoroplethMap extends Component { if (details.builtin) { geoJsonPath = details.url; } else { - geoJsonPath = "/api/geojson/" + nextProps.settings["map.region"] + geoJsonPath = "api/geojson/" + nextProps.settings["map.region"] } if (this.state.geoJsonPath !== geoJsonPath) { this.setState({ diff --git a/frontend/src/metabase/visualizations/components/LeafletMarkerPinMap.jsx b/frontend/src/metabase/visualizations/components/LeafletMarkerPinMap.jsx index fabd69a39fdf6b55a855ab6ea5593271ca5a7028..3518c697998b14156dee653bb525f5181569196c 100644 --- a/frontend/src/metabase/visualizations/components/LeafletMarkerPinMap.jsx +++ b/frontend/src/metabase/visualizations/components/LeafletMarkerPinMap.jsx @@ -7,7 +7,7 @@ import L from "leaflet"; import { formatValue } from "metabase/lib/formatting"; const MARKER_ICON = L.icon({ - iconUrl: "/app/img/pin.png", + iconUrl: "app/assets/img/pin.png", iconSize: [28, 32], iconAnchor: [15, 24], popupAnchor: [0, -13] diff --git a/frontend/src/metabase/visualizations/components/LeafletTilePinMap.jsx b/frontend/src/metabase/visualizations/components/LeafletTilePinMap.jsx index 9bcf98d86b3422035472c58b3a7969285904b916..d1862f14adf5bcf57b939465bc3dd5eed42df7d4 100644 --- a/frontend/src/metabase/visualizations/components/LeafletTilePinMap.jsx +++ b/frontend/src/metabase/visualizations/components/LeafletTilePinMap.jsx @@ -36,7 +36,7 @@ export default class LeafletTilePinMap extends LeafletMap { return; } - return '/api/tiles/' + zoom + '/' + coord.x + '/' + coord.y + '/' + + return 'api/tiles/' + zoom + '/' + coord.x + '/' + coord.y + '/' + latitudeField.id + '/' + longitudeField.id + '/' + latitudeIndex + '/' + longitudeIndex + '/' + '?query=' + encodeURIComponent(JSON.stringify(dataset_query)) diff --git a/frontend/src/metabase/visualizations/components/Visualization.jsx b/frontend/src/metabase/visualizations/components/Visualization.jsx index c740ea684b457633a6dfa912e29a1abdc9985c49..f81cef63a66e7fb26828ba62f603f21ad3430c98 100644 --- a/frontend/src/metabase/visualizations/components/Visualization.jsx +++ b/frontend/src/metabase/visualizations/components/Visualization.jsx @@ -349,7 +349,7 @@ export default class Visualization extends Component<*, Props, State> { : isDashboard && noResults ? <div className={"flex-full px1 pb1 text-centered flex flex-column layout-centered " + (isDashboard ? "text-slate-light" : "text-slate")}> <Tooltip tooltip="No results!" isEnabled={small}> - <img src="/app/img/no_results.svg" /> + <img src="app/assets/img/no_results.svg" /> </Tooltip> { !small && <span className="h4 text-bold"> diff --git a/package.json b/package.json index b95511323528d9bffedd33103c45169ce39ef74d..b2c69f1d26fcc5b155d5aa77dcc6bbb23f2523fd 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "d3": "^3.5.17", "dc": "^2.0.0", "diff": "^3.2.0", - "history": "^4.5.0", + "history": "3", "humanize-plus": "^1.8.1", "icepick": "^1.1.0", "iframe-resizer": "^3.5.11", diff --git a/project.clj b/project.clj index d0ac83ab26ed9259318b337b7e43c3b3a7282461..db982ea4083b96cf23faf6cec520072d08129564 100644 --- a/project.clj +++ b/project.clj @@ -76,7 +76,8 @@ [postgresql "9.3-1102.jdbc41"] ; Postgres driver [io.crate/crate-jdbc "2.1.6"] ; Crate JDBC driver [prismatic/schema "1.1.5"] ; Data schema declaration and validation library - [ring/ring-jetty-adapter "1.5.1"] ; Ring adapter using Jetty webserver (used to run a Ring server for unit tests) + [ring/ring-core "1.6.0"] + [ring/ring-jetty-adapter "1.6.0"] ; Ring adapter using Jetty webserver (used to run a Ring server for unit tests) [ring/ring-json "0.4.0"] ; Ring middleware for reading/writing JSON automatically [stencil "0.5.0"] ; Mustache templates for Clojure [toucan "1.0.3" ; Model layer, hydration, and DB utilities diff --git a/resources/frontend_client/app/charts/us-states.json b/resources/frontend_client/app/assets/geojson/us-states.json similarity index 100% rename from resources/frontend_client/app/charts/us-states.json rename to resources/frontend_client/app/assets/geojson/us-states.json diff --git a/resources/frontend_client/app/charts/world.json b/resources/frontend_client/app/assets/geojson/world.json similarity index 100% rename from resources/frontend_client/app/charts/world.json rename to resources/frontend_client/app/assets/geojson/world.json diff --git a/resources/frontend_client/app/img/.gitkeep b/resources/frontend_client/app/assets/img/.gitkeep similarity index 100% rename from resources/frontend_client/app/img/.gitkeep rename to resources/frontend_client/app/assets/img/.gitkeep diff --git a/resources/frontend_client/app/img/blown_up.svg b/resources/frontend_client/app/assets/img/blown_up.svg similarity index 100% rename from resources/frontend_client/app/img/blown_up.svg rename to resources/frontend_client/app/assets/img/blown_up.svg diff --git a/resources/frontend_client/app/components/icons/assets/dash_empty_state.svg b/resources/frontend_client/app/assets/img/dash_empty_state.svg similarity index 100% rename from resources/frontend_client/app/components/icons/assets/dash_empty_state.svg rename to resources/frontend_client/app/assets/img/dash_empty_state.svg diff --git a/resources/frontend_client/app/img/databases-list.png b/resources/frontend_client/app/assets/img/databases-list.png similarity index 100% rename from resources/frontend_client/app/img/databases-list.png rename to resources/frontend_client/app/assets/img/databases-list.png diff --git a/resources/frontend_client/app/img/databases-list@2x.png b/resources/frontend_client/app/assets/img/databases-list@2x.png similarity index 100% rename from resources/frontend_client/app/img/databases-list@2x.png rename to resources/frontend_client/app/assets/img/databases-list@2x.png diff --git a/resources/frontend_client/app/img/disconnect.svg b/resources/frontend_client/app/assets/img/disconnect.svg similarity index 100% rename from resources/frontend_client/app/img/disconnect.svg rename to resources/frontend_client/app/assets/img/disconnect.svg diff --git a/resources/frontend_client/app/img/external_link.png b/resources/frontend_client/app/assets/img/external_link.png similarity index 100% rename from resources/frontend_client/app/img/external_link.png rename to resources/frontend_client/app/assets/img/external_link.png diff --git a/resources/frontend_client/app/img/external_link@2x.png b/resources/frontend_client/app/assets/img/external_link@2x.png similarity index 100% rename from resources/frontend_client/app/img/external_link@2x.png rename to resources/frontend_client/app/assets/img/external_link@2x.png diff --git a/resources/frontend_client/app/components/icons/assets/illustration_ask_question.png b/resources/frontend_client/app/assets/img/illustration_ask_question.png similarity index 100% rename from resources/frontend_client/app/components/icons/assets/illustration_ask_question.png rename to resources/frontend_client/app/assets/img/illustration_ask_question.png diff --git a/resources/frontend_client/app/components/icons/assets/illustration_dashboard.png b/resources/frontend_client/app/assets/img/illustration_dashboard.png similarity index 100% rename from resources/frontend_client/app/components/icons/assets/illustration_dashboard.png rename to resources/frontend_client/app/assets/img/illustration_dashboard.png diff --git a/resources/frontend_client/app/components/icons/assets/illustration_home.png b/resources/frontend_client/app/assets/img/illustration_home.png similarity index 100% rename from resources/frontend_client/app/components/icons/assets/illustration_home.png rename to resources/frontend_client/app/assets/img/illustration_home.png diff --git a/resources/frontend_client/app/components/icons/assets/illustration_question.png b/resources/frontend_client/app/assets/img/illustration_question.png similarity index 100% rename from resources/frontend_client/app/components/icons/assets/illustration_question.png rename to resources/frontend_client/app/assets/img/illustration_question.png diff --git a/resources/frontend_client/app/components/icons/assets/illustration_tables.png b/resources/frontend_client/app/assets/img/illustration_tables.png similarity index 100% rename from resources/frontend_client/app/components/icons/assets/illustration_tables.png rename to resources/frontend_client/app/assets/img/illustration_tables.png diff --git a/resources/frontend_client/app/components/icons/assets/illustration_visualization_area.png b/resources/frontend_client/app/assets/img/illustration_visualization_area.png similarity index 100% rename from resources/frontend_client/app/components/icons/assets/illustration_visualization_area.png rename to resources/frontend_client/app/assets/img/illustration_visualization_area.png diff --git a/resources/frontend_client/app/components/icons/assets/illustration_visualization_bar.png b/resources/frontend_client/app/assets/img/illustration_visualization_bar.png similarity index 100% rename from resources/frontend_client/app/components/icons/assets/illustration_visualization_bar.png rename to resources/frontend_client/app/assets/img/illustration_visualization_bar.png diff --git a/resources/frontend_client/app/components/icons/assets/illustration_visualization_country.png b/resources/frontend_client/app/assets/img/illustration_visualization_country.png similarity index 100% rename from resources/frontend_client/app/components/icons/assets/illustration_visualization_country.png rename to resources/frontend_client/app/assets/img/illustration_visualization_country.png diff --git a/resources/frontend_client/app/components/icons/assets/illustration_visualization_line.png b/resources/frontend_client/app/assets/img/illustration_visualization_line.png similarity index 100% rename from resources/frontend_client/app/components/icons/assets/illustration_visualization_line.png rename to resources/frontend_client/app/assets/img/illustration_visualization_line.png diff --git a/resources/frontend_client/app/components/icons/assets/illustration_visualization_pie.png b/resources/frontend_client/app/assets/img/illustration_visualization_pie.png similarity index 100% rename from resources/frontend_client/app/components/icons/assets/illustration_visualization_pie.png rename to resources/frontend_client/app/assets/img/illustration_visualization_pie.png diff --git a/resources/frontend_client/app/components/icons/assets/illustration_visualization_scalar.png b/resources/frontend_client/app/assets/img/illustration_visualization_scalar.png similarity index 100% rename from resources/frontend_client/app/components/icons/assets/illustration_visualization_scalar.png rename to resources/frontend_client/app/assets/img/illustration_visualization_scalar.png diff --git a/resources/frontend_client/app/components/icons/assets/illustration_visualization_state.png b/resources/frontend_client/app/assets/img/illustration_visualization_state.png similarity index 100% rename from resources/frontend_client/app/components/icons/assets/illustration_visualization_state.png rename to resources/frontend_client/app/assets/img/illustration_visualization_state.png diff --git a/resources/frontend_client/app/components/icons/assets/illustration_visualization_table.png b/resources/frontend_client/app/assets/img/illustration_visualization_table.png similarity index 100% rename from resources/frontend_client/app/components/icons/assets/illustration_visualization_table.png rename to resources/frontend_client/app/assets/img/illustration_visualization_table.png diff --git a/resources/frontend_client/app/img/lightbulb.png b/resources/frontend_client/app/assets/img/lightbulb.png similarity index 100% rename from resources/frontend_client/app/img/lightbulb.png rename to resources/frontend_client/app/assets/img/lightbulb.png diff --git a/resources/frontend_client/app/img/lightbulb@2x.png b/resources/frontend_client/app/assets/img/lightbulb@2x.png similarity index 100% rename from resources/frontend_client/app/img/lightbulb@2x.png rename to resources/frontend_client/app/assets/img/lightbulb@2x.png diff --git a/resources/frontend_client/app/img/metrics-list.png b/resources/frontend_client/app/assets/img/metrics-list.png similarity index 100% rename from resources/frontend_client/app/img/metrics-list.png rename to resources/frontend_client/app/assets/img/metrics-list.png diff --git a/resources/frontend_client/app/img/metrics-list@2x.png b/resources/frontend_client/app/assets/img/metrics-list@2x.png similarity index 100% rename from resources/frontend_client/app/img/metrics-list@2x.png rename to resources/frontend_client/app/assets/img/metrics-list@2x.png diff --git a/resources/frontend_client/app/img/no_results.svg b/resources/frontend_client/app/assets/img/no_results.svg similarity index 100% rename from resources/frontend_client/app/img/no_results.svg rename to resources/frontend_client/app/assets/img/no_results.svg diff --git a/resources/frontend_client/app/img/no_understand.svg b/resources/frontend_client/app/assets/img/no_understand.svg similarity index 100% rename from resources/frontend_client/app/img/no_understand.svg rename to resources/frontend_client/app/assets/img/no_understand.svg diff --git a/resources/frontend_client/app/home/partials/onboarding_illustration_dashboards.png b/resources/frontend_client/app/assets/img/onboarding_illustration_dashboards.png similarity index 100% rename from resources/frontend_client/app/home/partials/onboarding_illustration_dashboards.png rename to resources/frontend_client/app/assets/img/onboarding_illustration_dashboards.png diff --git a/resources/frontend_client/app/home/partials/onboarding_illustration_questions.png b/resources/frontend_client/app/assets/img/onboarding_illustration_questions.png similarity index 100% rename from resources/frontend_client/app/home/partials/onboarding_illustration_questions.png rename to resources/frontend_client/app/assets/img/onboarding_illustration_questions.png diff --git a/resources/frontend_client/app/home/partials/onboarding_illustration_tables.png b/resources/frontend_client/app/assets/img/onboarding_illustration_tables.png similarity index 100% rename from resources/frontend_client/app/home/partials/onboarding_illustration_tables.png rename to resources/frontend_client/app/assets/img/onboarding_illustration_tables.png diff --git a/resources/frontend_client/app/img/pin.png b/resources/frontend_client/app/assets/img/pin.png similarity index 100% rename from resources/frontend_client/app/img/pin.png rename to resources/frontend_client/app/assets/img/pin.png diff --git a/resources/frontend_client/app/img/pulse_empty_illustration.png b/resources/frontend_client/app/assets/img/pulse_empty_illustration.png similarity index 100% rename from resources/frontend_client/app/img/pulse_empty_illustration.png rename to resources/frontend_client/app/assets/img/pulse_empty_illustration.png diff --git a/resources/frontend_client/app/img/pulse_empty_illustration@2x.png b/resources/frontend_client/app/assets/img/pulse_empty_illustration@2x.png similarity index 100% rename from resources/frontend_client/app/img/pulse_empty_illustration@2x.png rename to resources/frontend_client/app/assets/img/pulse_empty_illustration@2x.png diff --git a/resources/frontend_client/app/img/pulse_no_results.png b/resources/frontend_client/app/assets/img/pulse_no_results.png similarity index 100% rename from resources/frontend_client/app/img/pulse_no_results.png rename to resources/frontend_client/app/assets/img/pulse_no_results.png diff --git a/resources/frontend_client/app/img/pulse_no_results@2x.png b/resources/frontend_client/app/assets/img/pulse_no_results@2x.png similarity index 100% rename from resources/frontend_client/app/img/pulse_no_results@2x.png rename to resources/frontend_client/app/assets/img/pulse_no_results@2x.png diff --git a/resources/frontend_client/app/img/qb_tutorial/banana.png b/resources/frontend_client/app/assets/img/qb_tutorial/banana.png similarity index 100% rename from resources/frontend_client/app/img/qb_tutorial/banana.png rename to resources/frontend_client/app/assets/img/qb_tutorial/banana.png diff --git a/resources/frontend_client/app/img/qb_tutorial/banana@2x.png b/resources/frontend_client/app/assets/img/qb_tutorial/banana@2x.png similarity index 100% rename from resources/frontend_client/app/img/qb_tutorial/banana@2x.png rename to resources/frontend_client/app/assets/img/qb_tutorial/banana@2x.png diff --git a/resources/frontend_client/app/img/qb_tutorial/boat.png b/resources/frontend_client/app/assets/img/qb_tutorial/boat.png similarity index 100% rename from resources/frontend_client/app/img/qb_tutorial/boat.png rename to resources/frontend_client/app/assets/img/qb_tutorial/boat.png diff --git a/resources/frontend_client/app/img/qb_tutorial/boat@2x.png b/resources/frontend_client/app/assets/img/qb_tutorial/boat@2x.png similarity index 100% rename from resources/frontend_client/app/img/qb_tutorial/boat@2x.png rename to resources/frontend_client/app/assets/img/qb_tutorial/boat@2x.png diff --git a/resources/frontend_client/app/img/qb_tutorial/calculator.png b/resources/frontend_client/app/assets/img/qb_tutorial/calculator.png similarity index 100% rename from resources/frontend_client/app/img/qb_tutorial/calculator.png rename to resources/frontend_client/app/assets/img/qb_tutorial/calculator.png diff --git a/resources/frontend_client/app/img/qb_tutorial/calculator@2x.png b/resources/frontend_client/app/assets/img/qb_tutorial/calculator@2x.png similarity index 100% rename from resources/frontend_client/app/img/qb_tutorial/calculator@2x.png rename to resources/frontend_client/app/assets/img/qb_tutorial/calculator@2x.png diff --git a/resources/frontend_client/app/img/qb_tutorial/chart.png b/resources/frontend_client/app/assets/img/qb_tutorial/chart.png similarity index 100% rename from resources/frontend_client/app/img/qb_tutorial/chart.png rename to resources/frontend_client/app/assets/img/qb_tutorial/chart.png diff --git a/resources/frontend_client/app/img/qb_tutorial/chart@2x.png b/resources/frontend_client/app/assets/img/qb_tutorial/chart@2x.png similarity index 100% rename from resources/frontend_client/app/img/qb_tutorial/chart@2x.png rename to resources/frontend_client/app/assets/img/qb_tutorial/chart@2x.png diff --git a/resources/frontend_client/app/img/qb_tutorial/funnel.png b/resources/frontend_client/app/assets/img/qb_tutorial/funnel.png similarity index 100% rename from resources/frontend_client/app/img/qb_tutorial/funnel.png rename to resources/frontend_client/app/assets/img/qb_tutorial/funnel.png diff --git a/resources/frontend_client/app/img/qb_tutorial/funnel@2x.png b/resources/frontend_client/app/assets/img/qb_tutorial/funnel@2x.png similarity index 100% rename from resources/frontend_client/app/img/qb_tutorial/funnel@2x.png rename to resources/frontend_client/app/assets/img/qb_tutorial/funnel@2x.png diff --git a/resources/frontend_client/app/img/qb_tutorial/question_builder.png b/resources/frontend_client/app/assets/img/qb_tutorial/question_builder.png similarity index 100% rename from resources/frontend_client/app/img/qb_tutorial/question_builder.png rename to resources/frontend_client/app/assets/img/qb_tutorial/question_builder.png diff --git a/resources/frontend_client/app/img/qb_tutorial/question_builder@2x.png b/resources/frontend_client/app/assets/img/qb_tutorial/question_builder@2x.png similarity index 100% rename from resources/frontend_client/app/img/qb_tutorial/question_builder@2x.png rename to resources/frontend_client/app/assets/img/qb_tutorial/question_builder@2x.png diff --git a/resources/frontend_client/app/img/qb_tutorial/rocket.png b/resources/frontend_client/app/assets/img/qb_tutorial/rocket.png similarity index 100% rename from resources/frontend_client/app/img/qb_tutorial/rocket.png rename to resources/frontend_client/app/assets/img/qb_tutorial/rocket.png diff --git a/resources/frontend_client/app/img/qb_tutorial/rocket@2x.png b/resources/frontend_client/app/assets/img/qb_tutorial/rocket@2x.png similarity index 100% rename from resources/frontend_client/app/img/qb_tutorial/rocket@2x.png rename to resources/frontend_client/app/assets/img/qb_tutorial/rocket@2x.png diff --git a/resources/frontend_client/app/img/qb_tutorial/table.png b/resources/frontend_client/app/assets/img/qb_tutorial/table.png similarity index 100% rename from resources/frontend_client/app/img/qb_tutorial/table.png rename to resources/frontend_client/app/assets/img/qb_tutorial/table.png diff --git a/resources/frontend_client/app/img/qb_tutorial/table@2x.png b/resources/frontend_client/app/assets/img/qb_tutorial/table@2x.png similarity index 100% rename from resources/frontend_client/app/img/qb_tutorial/table@2x.png rename to resources/frontend_client/app/assets/img/qb_tutorial/table@2x.png diff --git a/resources/frontend_client/app/img/secure_embed.png b/resources/frontend_client/app/assets/img/secure_embed.png similarity index 100% rename from resources/frontend_client/app/img/secure_embed.png rename to resources/frontend_client/app/assets/img/secure_embed.png diff --git a/resources/frontend_client/app/img/secure_embed@2x.png b/resources/frontend_client/app/assets/img/secure_embed@2x.png similarity index 100% rename from resources/frontend_client/app/img/secure_embed@2x.png rename to resources/frontend_client/app/assets/img/secure_embed@2x.png diff --git a/resources/frontend_client/app/img/segments-list.png b/resources/frontend_client/app/assets/img/segments-list.png similarity index 100% rename from resources/frontend_client/app/img/segments-list.png rename to resources/frontend_client/app/assets/img/segments-list.png diff --git a/resources/frontend_client/app/img/segments-list@2x.png b/resources/frontend_client/app/assets/img/segments-list@2x.png similarity index 100% rename from resources/frontend_client/app/img/segments-list@2x.png rename to resources/frontend_client/app/assets/img/segments-list@2x.png diff --git a/resources/frontend_client/app/img/simple_embed.png b/resources/frontend_client/app/assets/img/simple_embed.png similarity index 100% rename from resources/frontend_client/app/img/simple_embed.png rename to resources/frontend_client/app/assets/img/simple_embed.png diff --git a/resources/frontend_client/app/img/simple_embed@2x.png b/resources/frontend_client/app/assets/img/simple_embed@2x.png similarity index 100% rename from resources/frontend_client/app/img/simple_embed@2x.png rename to resources/frontend_client/app/assets/img/simple_embed@2x.png diff --git a/resources/frontend_client/app/img/slack.png b/resources/frontend_client/app/assets/img/slack.png similarity index 100% rename from resources/frontend_client/app/img/slack.png rename to resources/frontend_client/app/assets/img/slack.png diff --git a/resources/frontend_client/app/img/slack@2x.png b/resources/frontend_client/app/assets/img/slack@2x.png similarity index 100% rename from resources/frontend_client/app/img/slack@2x.png rename to resources/frontend_client/app/assets/img/slack@2x.png diff --git a/resources/frontend_client/app/img/slack_emoji.png b/resources/frontend_client/app/assets/img/slack_emoji.png similarity index 100% rename from resources/frontend_client/app/img/slack_emoji.png rename to resources/frontend_client/app/assets/img/slack_emoji.png diff --git a/resources/frontend_client/app/img/slack_emoji@2x.png b/resources/frontend_client/app/assets/img/slack_emoji@2x.png similarity index 100% rename from resources/frontend_client/app/img/slack_emoji@2x.png rename to resources/frontend_client/app/assets/img/slack_emoji@2x.png diff --git a/resources/frontend_client/app/components/icons/assets/smile.svg b/resources/frontend_client/app/assets/img/smile.svg similarity index 100% rename from resources/frontend_client/app/components/icons/assets/smile.svg rename to resources/frontend_client/app/assets/img/smile.svg diff --git a/resources/frontend_client/app/img/stopwatch.svg b/resources/frontend_client/app/assets/img/stopwatch.svg similarity index 100% rename from resources/frontend_client/app/img/stopwatch.svg rename to resources/frontend_client/app/assets/img/stopwatch.svg diff --git a/resources/frontend_client/app/img/test/pin-map-reference-image1.png b/resources/frontend_client/app/img/test/pin-map-reference-image1.png deleted file mode 100644 index 4b6bee6d07bd367b580d99af04312152501b5f04..0000000000000000000000000000000000000000 Binary files a/resources/frontend_client/app/img/test/pin-map-reference-image1.png and /dev/null differ diff --git a/resources/frontend_client/index_template.html b/resources/frontend_client/index_template.html index 573608b6bd9945517b89250107362f6c3607076a..c2c0924cc3ddadc6bb3b0702e99f00e40767b781 100644 --- a/resources/frontend_client/index_template.html +++ b/resources/frontend_client/index_template.html @@ -11,8 +11,34 @@ <title>Metabase</title> + <base href={{{base_href}}} /> + <script type="text/javascript"> - window.MetabaseBootstrap = {{{bootstrap_json}}}; + (function() { + window.MetabaseBootstrap = {{{bootstrap_json}}}; + + var configuredRoot = {{{base_href}}}; + var actualRoot = "/"; + + // Add trailing slashes + var backendPathname = {{{uri}}}.replace(/\/*$/, "/"); + // e.x. "/questions/" + var frontendPathname = window.location.pathname.replace(/\/*$/, "/"); + // e.x. "/metabase/questions/" + if (backendPathname === frontendPathname.slice(-backendPathname.length)) { + // Remove the backend pathname from the end of the frontend pathname + actualRoot = frontendPathname.slice(0, -backendPathname.length) + "/"; + // e.x. "/metabase/" + } + + if (actualRoot !== configuredRoot) { + console.warn("Warning: the Metabase site URL basename \"" + configuredRoot + "\" does not match the actual basename \"" + actualRoot + "\"."); + console.warn("You probably want to update the Site URL setting to \"" + window.location.origin + actualRoot + "\""); + document.getElementsByTagName("base")[0].href = actualRoot; + } + + window.MetabaseRoot = actualRoot; + })(); </script> </head> diff --git a/src/metabase/api/card.clj b/src/metabase/api/card.clj index 5863d4ebf813d986a1ba031eadd01c61f3696c35..12541c5dd35fff4213d8750dabf0008e5f72cc60 100644 --- a/src/metabase/api/card.clj +++ b/src/metabase/api/card.clj @@ -5,6 +5,7 @@ [compojure.core :refer [DELETE GET POST PUT]] [metabase [events :as events] + [middleware :as middleware] [public-settings :as public-settings] [query-processor :as qp] [util :as u]] @@ -12,6 +13,7 @@ [common :as api] [dataset :as dataset-api] [label :as label-api]] + [metabase.api.common.internal :refer [route-fn-name]] [metabase.models [card :as card :refer [Card]] [card-favorite :refer [CardFavorite]] @@ -467,5 +469,5 @@ (api/check-embedding-enabled) (db/select [Card :name :id], :enable_embedding true, :archived false)) - -(api/define-routes) +(api/define-routes + (middleware/streaming-json-response (route-fn-name 'POST "/:card-id/query"))) diff --git a/src/metabase/api/common.clj b/src/metabase/api/common.clj index 0e18189aa8075b6306801d0ece1df95d38337e2a..4b8514c881880868c17a05d7c98940a6083f1f18 100644 --- a/src/metabase/api/common.clj +++ b/src/metabase/api/common.clj @@ -264,7 +264,7 @@ (s/replace #"^metabase\." "") (s/replace #"\." "/")) (u/pprint-to-str (concat api-routes additional-routes)))) - ~@api-routes ~@additional-routes))) + ~@additional-routes ~@api-routes))) ;;; ------------------------------------------------------------ PERMISSIONS CHECKING HELPER FNS ------------------------------------------------------------ diff --git a/src/metabase/api/dataset.clj b/src/metabase/api/dataset.clj index 548e15d97b8309c874967b1843ee4b2d1dcf9d1b..7666d83e7fd55382770e080c7339684a33fe9961 100644 --- a/src/metabase/api/dataset.clj +++ b/src/metabase/api/dataset.clj @@ -6,9 +6,11 @@ [compojure.core :refer [POST]] [dk.ative.docjure.spreadsheet :as spreadsheet] [metabase + [middleware :as middleware] [query-processor :as qp] [util :as u]] [metabase.api.common :as api] + [metabase.api.common.internal :refer [route-fn-name]] [metabase.models [database :refer [Database]] [query :as query]] @@ -124,5 +126,5 @@ (qp/dataset-query (dissoc query :constraints) {:executed-by api/*current-user-id*, :context (export-format->context export-format)})))) - -(api/define-routes) +(api/define-routes + (middleware/streaming-json-response (route-fn-name 'POST "/"))) diff --git a/src/metabase/api/geojson.clj b/src/metabase/api/geojson.clj index 1fc5e31143155fcb38318576a0c550c2806bd0bd..19494cd7be689705ed1926c8444477f5f043a851 100644 --- a/src/metabase/api/geojson.clj +++ b/src/metabase/api/geojson.clj @@ -17,11 +17,11 @@ true) (defn- valid-json-resource? - "Does this RELATIVE-PATH point to a valid local JSON resource? (RELATIVE-PATH is something like \"app/charts/us-states.json\".)" + "Does this RELATIVE-PATH point to a valid local JSON resource? (RELATIVE-PATH is something like \"app/assets/geojson/us-states.json\".)" [relative-path] (when-let [^java.net.URI uri (u/ignore-exceptions (java.net.URI. relative-path))] (when-not (.isAbsolute uri) - (valid-json? (io/resource (str "frontend_client" uri)))))) + (valid-json? (io/resource (str "frontend_client/" uri)))))) (defn- valid-json-url? "Is URL a valid HTTP URL and does it point to valid JSON?" @@ -47,12 +47,12 @@ (def ^:private ^:const builtin-geojson {:us_states {:name "United States" - :url "/app/charts/us-states.json" + :url "app/assets/geojson/us-states.json" :region_key "name" :region_name "name" :builtin true} :world_countries {:name "World" - :url "/app/charts/world.json" + :url "app/assets/geojson/world.json" :region_key "ISO_A2" :region_name "NAME" :builtin true}}) diff --git a/src/metabase/core.clj b/src/metabase/core.clj index c36cfe2750630b5b58c2ce441bb1605d12bcbe9e..16cc4c3c09a137e5a9ce566e60b7cc9b4344dcbc 100644 --- a/src/metabase/core.clj +++ b/src/metabase/core.clj @@ -37,7 +37,7 @@ (def ^:private app "The primary entry point to the Ring HTTP server." - (-> routes/routes + (-> #'routes/routes ; the #' is to allow tests to redefine endpoints mb-middleware/log-api-call mb-middleware/add-security-headers ; Add HTTP headers to API responses to prevent them from being cached (wrap-json-body ; extracts json POST body and makes it avaliable on request diff --git a/src/metabase/middleware.clj b/src/metabase/middleware.clj index a521f291aeee14404af9ccd4c6c5a8b1d2a0d07f..5e2467e44bff706c5269cb29b60395e045bfa05a 100644 --- a/src/metabase/middleware.clj +++ b/src/metabase/middleware.clj @@ -1,6 +1,10 @@ (ns metabase.middleware "Metabase-specific middleware functions & configuration." - (:require [cheshire.generate :refer [add-encoder encode-nil encode-str]] + (:require [cheshire + [core :as json] + [generate :refer [add-encoder encode-nil encode-str]]] + [clojure.core.async :as async] + [clojure.java.io :as io] [clojure.tools.logging :as log] [metabase [config :as config] @@ -15,10 +19,13 @@ [setting :refer [defsetting]] [user :as user :refer [User]]] monger.json + [ring.core.protocols :as protocols] + [ring.util.response :as response] [toucan [db :as db] [models :as models]]) - (:import com.fasterxml.jackson.core.JsonGenerator)) + (:import com.fasterxml.jackson.core.JsonGenerator + java.io.OutputStream)) ;;; # ------------------------------------------------------------ UTIL FNS ------------------------------------------------------------ @@ -354,3 +361,75 @@ (handler request)) (catch Throwable e {:status 400, :body (.getMessage e)})))) + +;;; ------------------------------------------------------------ EXCEPTION HANDLING ------------------------------------------------------------ + +(def ^:private ^:const streaming-response-keep-alive-interval-ms + "Interval between sending newline characters to keep Heroku from terminating + requests like queries that take a long time to complete." + (* 1 1000)) + +;; Handle ring response maps that contain a core.async chan in the :body key: +;; +;; {:status 200 +;; :body (async/chan)} +;; +;; and send each string sent to that queue back to the browser as it arrives +;; this avoids output buffering in the default stream handling which was not sending +;; any responses until ~5k characters where in the queue. +(extend-protocol protocols/StreamableResponseBody + clojure.core.async.impl.channels.ManyToManyChannel + (write-body-to-stream [output-queue _ ^OutputStream output-stream] + (log/debug (u/format-color 'green "starting streaming request")) + (with-open [out (io/writer output-stream)] + (loop [chunk (async/<!! output-queue)] + (when-not (= chunk ::EOF) + (.write out (str chunk)) + (try + (.flush out) + (catch org.eclipse.jetty.io.EofException e + (log/info (u/format-color 'yellow "connection closed, canceling request %s" (type e))) + (async/close! output-queue) + (throw e))) + (recur (async/<!! output-queue))))))) + +(defn streaming-json-response + "This midelware assumes handlers fail early or return success + Run the handler in a future and send newlines to keep the connection open + and help detect when the browser is no longer listening for the response. + Waits for one second to see if the handler responds immediately, If it does + then there is no need to stream the response and it is sent back directly. + In cases where it takes longer than a second, assume the eventual result will + be a success and start sending newlines to keep the connection open." + [handler] + (fn [request] + (let [response (future (handler request)) + optimistic-response (deref response streaming-response-keep-alive-interval-ms ::no-immediate-response)] + (if (= optimistic-response ::no-immediate-response) + ;; if we didn't get a normal response in the first poling interval assume it's going to be slow + ;; and start sending keepalive packets. + (let [output (async/chan 1)] + ;; the output channel will be closed by the adapter when the incoming connection is closed. + (future + (loop [] + (Thread/sleep streaming-response-keep-alive-interval-ms) + (when-not (realized? response) + (log/debug (u/format-color 'blue "Response not ready, writing one byte & sleeping...")) + ;; a newline padding character is used because it forces output flushing in jetty. + ;; if sending this character fails because the connection is closed, the chan will then close. + ;; Newlines are no-ops when reading JSON which this depends upon. + (when-not (async/>!! output "\n") + (log/info (u/format-color 'yellow "canceled request %s" (future-cancel response))) + (future-cancel response)) ;; try our best to kill the thread running the query. + (recur)))) + (future + (try + ;; This is the part where we make this assume it's a JSON response we are sending. + (async/>!! output (json/encode (:body @response))) + (finally + (async/>!! output ::EOF) + (async/close! response)))) + ;; here we assume a successful response will be written to the output channel. + (assoc (response/response output) + :content-type "applicaton/json")) + optimistic-response)))) diff --git a/src/metabase/pulse/render.clj b/src/metabase/pulse/render.clj index e57965c17478cf74bcebafcec6a79d6ce22d9731..aeda60eff850892edae154ced2919ea14e104df0 100644 --- a/src/metabase/pulse/render.clj +++ b/src/metabase/pulse/render.clj @@ -376,7 +376,7 @@ (defn- render:empty [_ _] [:div {:style (style {:text-align :center})} [:img {:style (style {:width :104px}) - :src (render-image-with-filename "frontend_client/app/img/pulse_no_results@2x.png")}] + :src (render-image-with-filename "frontend_client/app/assets/img/pulse_no_results@2x.png")}] [:div {:style (style {:margin-top :8px :color color-gray-4})} "No results"]]) @@ -426,7 +426,7 @@ (when *include-buttons* [:img {:style (style {:width :16px}) :width 16 - :src (render-image-with-filename "frontend_client/app/img/external_link.png")}])]]]]) + :src (render-image-with-filename "frontend_client/app/assets/img/external_link.png")}])]]]]) (try (when error (throw (Exception. (str "Card has errors: " error)))) diff --git a/src/metabase/routes.clj b/src/metabase/routes.clj index bfd8eb001f4efba61ccb817cc2f946de868f3f8a..469c73e3002cc7741c7cc8820ac843c87acb46fa 100644 --- a/src/metabase/routes.clj +++ b/src/metabase/routes.clj @@ -1,6 +1,7 @@ (ns metabase.routes (:require [cheshire.core :as json] [clojure.java.io :as io] + [clojure.string :as str] [compojure [core :refer [context defroutes GET]] [route :as route]] @@ -15,6 +16,14 @@ [ring.util.response :as resp] [stencil.core :as stencil])) +(defn- base-href [] + (str (.getPath (io/as-url (public-settings/site-url))) "/")) + +(defn- escape-script [s] + ;; Escapes text to be included in an inline <script> tag, in particular the string '</script' + ;; https://stackoverflow.com/questions/14780858/escape-in-script-tag-contents/23983448#23983448 + (str/replace s #"</script" "</scr\\\\ipt")) + (defn- load-file-at-path [path] (slurp (or (io/resource path) (throw (Exception. (str "Cannot find '" path "'. Did you remember to build the Metabase frontend?")))))) @@ -25,7 +34,9 @@ (defn- entrypoint [entry embeddable? {:keys [uri]}] (-> (if (init-status/complete?) (load-template (str "frontend_client/" entry ".html") - {:bootstrap_json (json/generate-string (public-settings/public-settings)) + {:bootstrap_json (escape-script (json/generate-string (public-settings/public-settings))) + :uri (escape-script (json/generate-string uri)) + :base_href (escape-script (json/generate-string (base-href))) :embed_code (when embeddable? (embed/head uri))}) (load-file-at-path "frontend_client/init.html")) resp/response diff --git a/test/metabase/api/geojson_test.clj b/test/metabase/api/geojson_test.clj index ad30b2e38fc4222169e7c4c874fc5fffd8c90018..72a6d767b7f61681b2b1886fb11f117be0972440 100644 --- a/test/metabase/api/geojson_test.clj +++ b/test/metabase/api/geojson_test.clj @@ -31,7 +31,7 @@ ;;; test valid-json-resource? (expect - (valid-json-resource? "/app/charts/us-states.json")) + (valid-json-resource? "app/assets/geojson/us-states.json")) ;;; test the CustomGeoJSON schema diff --git a/test/metabase/middleware_test.clj b/test/metabase/middleware_test.clj index e67b97392181bfdeaf83a484eb69696b59158206..90175af670f6a37eb34c3cf14472ce4a174e0676 100644 --- a/test/metabase/middleware_test.clj +++ b/test/metabase/middleware_test.clj @@ -1,13 +1,20 @@ (ns metabase.middleware-test (:require [cheshire.core :as json] + [clojure.core.async :as async] + [clojure.java.io :as io] + [clojure.tools.logging :as log] + [compojure.core :refer [GET]] [expectations :refer :all] [metabase - [middleware :refer :all] + [config :as config] + [middleware :as middleware :refer :all] + [routes :as routes] [util :as u]] [metabase.api.common :refer [*current-user* *current-user-id*]] [metabase.models.session :refer [Session]] [metabase.test.data.users :refer :all] [ring.mock.request :as mock] + [ring.util.response :as resp] [toucan.db :as db])) ;; =========================== TEST wrap-session-id middleware =========================== @@ -176,3 +183,95 @@ (expect "{\"my-bytes\":\"0xC42360D7\"}" (json/generate-string {:my-bytes (byte-array [196 35 96 215 8 106 108 248 183 215 244 143 17 160 53 186 213 30 116 25 87 31 123 172 207 108 47 107 191 215 76 92])})) +;;; stuff here + +(defn- streaming-fast-success [_] + (resp/response {:success true})) + +(defn- streaming-fast-failure [_] + (throw (Exception. "immediate failure"))) + +(defn- streaming-slow-success [_] + (Thread/sleep 7000) + (resp/response {:success true})) + +(defn- streaming-slow-failure [_] + (Thread/sleep 7000) + (throw (Exception. "delayed failure"))) + +(defn- test-streaming-endpoint [handler] + (let [path (str handler)] + (with-redefs [metabase.routes/routes (compojure.core/routes + (GET (str "/" path) [] (middleware/streaming-json-response + handler)))] + (let [connection (async/chan 1000) + reader (io/input-stream (str "http://localhost:" (config/config-int :mb-jetty-port) "/" path))] + (async/go-loop [next-char (.read reader)] + (if (pos? next-char) + (do + (async/>! connection (char next-char)) + (recur (.read reader))) + (async/close! connection))) + (let [_ (Thread/sleep 1500) + first-second (async/poll! connection) + _ (Thread/sleep 1000) + second-second (async/poll! connection) + eventually (apply str (async/<!! (async/into [] connection)))] + [first-second second-second eventually]))))) + + +;;slow success +(expect + [\newline \newline "\n\n\n{\"success\":true}"] + (test-streaming-endpoint streaming-slow-success)) + +;; immediate success should have no padding +(expect + [\{ \" "success\":true}"] + (test-streaming-endpoint streaming-fast-success)) + +;; we know delayed failures (exception thrown) will just drop the connection +(expect + [\newline \newline "\n\n\n"] + (test-streaming-endpoint streaming-slow-failure)) + +;; immediate failures (where an exception is thown will return a 500 +(expect + #"Server returned HTTP response code: 500 for URL:.*" + (try + (test-streaming-endpoint streaming-fast-failure) + (catch java.io.IOException e + (.getMessage e)))) + +;; test that handler is killed when connection closes +(def test-slow-handler-state (atom :unset)) + +(defn- test-slow-handler [_] + (log/debug (u/format-color 'yellow "starting test-slow-handler")) + (Thread/sleep 7000) ;; this is somewhat long to make sure the keepalive polling has time to kill it. + (reset! test-slow-handler-state :ran-to-compleation) + (log/debug (u/format-color 'yellow "finished test-slow-handler")) + (resp/response {:success true})) + +(defn- start-and-maybe-kill-test-request [kill?] + (reset! test-slow-handler-state :initial-state) + (let [path "test-slow-handler"] + (with-redefs [metabase.routes/routes (compojure.core/routes + (GET (str "/" path) [] (middleware/streaming-json-response + test-slow-handler)))] + (let [reader (io/input-stream (str "http://localhost:" (config/config-int :mb-jetty-port) "/" path))] + (Thread/sleep 1500) + (when kill? + (.close reader)) + (Thread/sleep 10000)))) ;; this is long enough to ensure that the handler has run to completion if it was not killed. + @test-slow-handler-state) + +;; In this first test we will close the connection before the test handler gets to change the state +(expect + :initial-state + (start-and-maybe-kill-test-request true)) + +;; and to make sure this test actually works, run the same test again and let it change the state. +(expect + :ran-to-compleation + (start-and-maybe-kill-test-request false)) diff --git a/webpack.config.js b/webpack.config.js index 1783c3ad8019320d11bfa160bc120de31f9c115f..4c0d5703592b660695dab359421ab2fdf83e941a 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -69,6 +69,7 @@ var CSS_CONFIG = { "[hash:base64:5]", restructuring: false, compatibility: true, + url: false, // disabled because we need to use relative url() importLoaders: 1 } @@ -89,7 +90,7 @@ var config = module.exports = { path: BUILD_PATH + '/app/dist', // NOTE: the filename on disk won't include "?[chunkhash]" but the URL in index.html generated by HtmlWebpackPlugin will: filename: '[name].bundle.js?[hash]', - publicPath: '/app/dist/' + publicPath: 'app/dist/' }, module: { @@ -200,7 +201,7 @@ if (NODE_ENV === "hot") { config.output.filename = "[name].hot.bundle.js?[hash]"; // point the publicPath (inlined in index.html by HtmlWebpackPlugin) to the hot-reloading server - config.output.publicPath = "http://localhost:8080" + config.output.publicPath; + config.output.publicPath = "http://localhost:8080/" + config.output.publicPath; config.module.loaders.unshift({ test: /\.jsx$/, diff --git a/yarn.lock b/yarn.lock index eac22ac2eea290c33f0e1bcad970bb3dc95bfe1d..dd1addc4e96816f9480a59cdc9f0feec58bffe54 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3313,7 +3313,7 @@ he@1.1.x: version "1.1.1" resolved "https://registry.yarnpkg.com/he/-/he-1.1.1.tgz#93410fd21b009735151f8868c2f271f3427e23fd" -history@^3.0.0: +history@3, history@^3.0.0: version "3.3.0" resolved "https://registry.yarnpkg.com/history/-/history-3.3.0.tgz#fcedcce8f12975371545d735461033579a6dae9c" dependencies: @@ -3322,16 +3322,6 @@ history@^3.0.0: query-string "^4.2.2" warning "^3.0.0" -history@^4.5.0: - version "4.6.1" - resolved "https://registry.yarnpkg.com/history/-/history-4.6.1.tgz#911cf8eb65728555a94f2b12780a0c531a14d2fd" - dependencies: - invariant "^2.2.1" - loose-envify "^1.2.0" - resolve-pathname "^2.0.0" - value-equal "^0.2.0" - warning "^3.0.0" - hmac-drbg@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" @@ -6686,10 +6676,6 @@ resolve-from@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-1.0.1.tgz#26cbfe935d1aeeeabb29bc3fe5aeb01e93d44226" -resolve-pathname@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/resolve-pathname/-/resolve-pathname-2.0.2.tgz#e55c016eb2e9df1de98e85002282bfb38c630436" - resolve@1.1.7: version "1.1.7" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b" @@ -7598,10 +7584,6 @@ validate-npm-package-license@^3.0.1: spdx-correct "~1.0.0" spdx-expression-parse "~1.0.0" -value-equal@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-0.2.0.tgz#4f41c60a3fc011139a2ec3d3340a8998ae8b69c0" - vary@~1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.1.tgz#67535ebb694c1d52257457984665323f587e8d37"