diff --git a/frontend/src/metabase/admin/databases/containers/DatabaseEditApp.jsx b/frontend/src/metabase/admin/databases/containers/DatabaseEditApp.jsx index 9e0ea5935a0b4138b308ba1d027510ac17300d38..3ca8885c245588226e7234bd0d2eb3524aef093c 100644 --- a/frontend/src/metabase/admin/databases/containers/DatabaseEditApp.jsx +++ b/frontend/src/metabase/admin/databases/containers/DatabaseEditApp.jsx @@ -5,6 +5,7 @@ import { connect } from "react-redux"; import { t } from "ttag"; import _ from "underscore"; +import { updateIn } from "icepick"; import title from "metabase/hoc/Title"; @@ -118,6 +119,14 @@ class DatabaseEditApp extends Component { [addingNewDatabase ? t`Add Database` : database.name], ]; + const handleSubmit = async database => { + try { + await this.props.saveDatabase(database); + } catch (error) { + throw getSubmitError(error); + } + }; + return ( <DatabaseEditRoot> <Breadcrumbs className="py4" crumbs={crumbs} /> @@ -134,7 +143,7 @@ class DatabaseEditApp extends Component { database={database} form={Databases.forms.details} formName={DATABASE_FORM_NAME} - onSubmit={this.props.saveDatabase} + onSubmit={handleSubmit} submitTitle={addingNewDatabase ? t`Save` : t`Save changes`} submitButtonComponent={Button} > @@ -204,6 +213,16 @@ class DatabaseEditApp extends Component { } } +const getSubmitError = error => { + if (_.isObject(error?.data?.errors)) { + return updateIn(error, ["data", "errors"], errors => ({ + details: errors, + })); + } + + return error; +}; + export default _.compose( connect(mapStateToProps, mapDispatchToProps), title(({ database }) => database && database.name), diff --git a/frontend/src/metabase/components/form/CustomForm/CustomFormSubmit.tsx b/frontend/src/metabase/components/form/CustomForm/CustomFormSubmit.tsx index 27392ffcdcddf5e58b91b9e5c71c4f067e2eee67..52300bff31f666d30dc9150ab4cef312b1108bf3 100644 --- a/frontend/src/metabase/components/form/CustomForm/CustomFormSubmit.tsx +++ b/frontend/src/metabase/components/form/CustomForm/CustomFormSubmit.tsx @@ -6,7 +6,7 @@ import ActionButton from "metabase/components/ActionButton"; import { FormLegacyContext, LegacyContextTypes } from "./types"; -interface CustomFormSubmitProps { +export interface CustomFormSubmitProps { children: React.ReactNode; // ActionButton props diff --git a/frontend/src/metabase/components/form/FormField/FormField.tsx b/frontend/src/metabase/components/form/FormField/FormField.tsx index 4ca46489ae5f11604430f77be374924246c15046..e0586248812db6b014d1dcb325c0a8ded09e2b88 100644 --- a/frontend/src/metabase/components/form/FormField/FormField.tsx +++ b/frontend/src/metabase/components/form/FormField/FormField.tsx @@ -93,8 +93,8 @@ function FormField({ ...props, }; - const shouldHideError = !visited || active; - const error = shouldHideError ? undefined : errorProp; + const shouldShowError = visited && !active; + const error = !shouldShowError ? undefined : errorProp; return ( <FormFieldView diff --git a/frontend/src/metabase/containers/Form.jsx b/frontend/src/metabase/containers/Form.jsx index 63d7e69bd15f56c776d77bc0d2e19905e1a9593f..2d7621af14c6a20db3a4e267cb6971b22704e841 100644 --- a/frontend/src/metabase/containers/Form.jsx +++ b/frontend/src/metabase/containers/Form.jsx @@ -227,7 +227,10 @@ class Form extends React.Component { const errorNames = Object.keys(error.data.errors); const hasUnknownFields = errorNames.some(name => !fieldNames.has(name)); throw { - _error: hasUnknownFields ? t`An error occurred` : null, + _error: + error.data?.message || + error.message || + (hasUnknownFields ? t`An error occurred` : null), ...error.data.errors, }; } else if (error) { diff --git a/frontend/src/metabase/setup/components/DatabaseStep/DatabaseStep.styled.tsx b/frontend/src/metabase/setup/components/DatabaseStep/DatabaseStep.styled.tsx index d3fdda13c8402d09944a9f6a656923b8e6feb3aa..bcc4259d61742a7e76a001a2d4d2ae98eae9b5d1 100644 --- a/frontend/src/metabase/setup/components/DatabaseStep/DatabaseStep.styled.tsx +++ b/frontend/src/metabase/setup/components/DatabaseStep/DatabaseStep.styled.tsx @@ -29,3 +29,9 @@ export const StepFormGroup = styled.div` grid-template-columns: repeat(2, 1fr); gap: 1rem; `; + +export const FormActions = styled.div` + display: flex; + align-items: center; + justify-content: flex-end; +`; diff --git a/frontend/src/metabase/setup/components/DatabaseStep/DatabaseStep.tsx b/frontend/src/metabase/setup/components/DatabaseStep/DatabaseStep.tsx index 1add100567619ab36a971aaa1e989e4d1379d5ab..d18365d7ce4be8cf54fcf8ca6483fe8661bdfa0c 100644 --- a/frontend/src/metabase/setup/components/DatabaseStep/DatabaseStep.tsx +++ b/frontend/src/metabase/setup/components/DatabaseStep/DatabaseStep.tsx @@ -2,6 +2,7 @@ import React from "react"; import { t } from "ttag"; import _ from "underscore"; import { updateIn } from "icepick"; +import Button from "metabase/core/components/Button"; import Users from "metabase/entities/users"; import Databases from "metabase/entities/databases"; import DriverWarning from "metabase/containers/DriverWarning"; @@ -14,6 +15,7 @@ import { StepDescription, StepFormGroup, StepButton, + FormActions, } from "./DatabaseStep.styled"; import { FormProps } from "./types"; @@ -78,12 +80,8 @@ const DatabaseStep = ({ engine={engine} onSubmit={onDatabaseSubmit} onEngineChange={onEngineChange} + onSkip={handleCancel} /> - <StepActions> - <StepButton onClick={handleCancel}> - {t`I'll add my data later`} - </StepButton> - </StepActions> {isEmailConfigured && ( <SetupSection title={t`Need help connecting to your data?`} @@ -101,6 +99,7 @@ interface DatabaseFormProps { engine?: string; onSubmit: (database: DatabaseInfo) => void; onEngineChange: (engine?: string) => void; + onSkip: () => void; } const DatabaseForm = ({ @@ -108,6 +107,7 @@ const DatabaseForm = ({ engine, onSubmit, onEngineChange, + onSkip, }: DatabaseFormProps): JSX.Element => { const handleSubmit = async (database: DatabaseInfo) => { try { @@ -127,14 +127,17 @@ const DatabaseForm = ({ formName="database" database={database} onSubmit={handleSubmit} + submitTitle={t`Connect database`} > {({ Form, FormField, - FormFooter, + FormSubmit, + FormMessage, formFields, values, onChangeField, + submitTitle, }: FormProps) => ( <Form> <FormField name="engine" onChange={handleEngineChange} /> @@ -145,7 +148,19 @@ const DatabaseForm = ({ {_.reject(formFields, { name: "engine" }).map(({ name }) => ( <FormField key={name} name={name} /> ))} - {engine && <FormFooter submitTitle={t`Next`} />} + {engine ? ( + <FormActions> + <FormMessage noPadding /> + <Button type="button" onClick={onSkip}>{t`Skip`}</Button> + <FormSubmit className="ml2">{submitTitle}</FormSubmit> + </FormActions> + ) : ( + <StepActions> + <StepButton onClick={onSkip}> + {t`I'll add my data later`} + </StepButton> + </StepActions> + )} </Form> )} </Databases.Form> diff --git a/frontend/src/metabase/setup/components/DatabaseStep/types.ts b/frontend/src/metabase/setup/components/DatabaseStep/types.ts index 13ee3e11d662a36bf7e0ce6e0a2cf0d7001feee4..35ee7b5033c4285664091bf8ab92bd7b03869ba1 100644 --- a/frontend/src/metabase/setup/components/DatabaseStep/types.ts +++ b/frontend/src/metabase/setup/components/DatabaseStep/types.ts @@ -1,5 +1,9 @@ import { ComponentType } from "react"; +import { CustomFormMessageProps } from "metabase/components/form/CustomForm/CustomFormMessage"; +import { CustomFormSubmitProps } from "metabase/components/form/CustomForm/CustomFormSubmit"; +import { OptionalFormViewProps } from "metabase/components/form/CustomForm/types"; + export interface FormField { name: string; } @@ -8,9 +12,15 @@ export interface FormProps { Form: ComponentType; FormField: ComponentType<FormFieldProps>; FormFooter: ComponentType<FormFooterProps>; + FormSubmit: React.ComponentType< + CustomFormSubmitProps & OptionalFormViewProps + >; + FormMessage: React.ComponentType<CustomFormMessageProps>; formFields: FormField[]; values: FormValues; onChangeField: (field: string, value: unknown) => void; + submitTitle: string; + error: string; } export interface FormValues { diff --git a/frontend/test/metabase/scenarios/onboarding/setup/setup.cy.spec.js b/frontend/test/metabase/scenarios/onboarding/setup/setup.cy.spec.js index d8fa167a5fb5a7ec28a447027fa81aaf414de634..c4190ca23fcc21e151f7e664f352e1a330a8c52e 100644 --- a/frontend/test/metabase/scenarios/onboarding/setup/setup.cy.spec.js +++ b/frontend/test/metabase/scenarios/onboarding/setup/setup.cy.spec.js @@ -121,14 +121,14 @@ describe("scenarios > setup", () => { cy.findByText("Show more options").click(); cy.findByText("H2").click(); cy.findByLabelText("Display name").type("Metabase H2"); - cy.findByText("Next") + cy.findByText("Connect database") .closest("button") .should("be.disabled"); const dbFilename = "frontend/test/__runner__/empty.db"; const dbPath = Cypress.config("fileServerFolder") + "/" + dbFilename; cy.findByLabelText("Connection String").type(`file:${dbPath}`); - cy.findByText("Next") + cy.findByText("Connect database") .closest("button") .should("not.be.disabled") .click(); diff --git a/frontend/test/metabase/scenarios/smoketest/admin.cy.spec.js b/frontend/test/metabase/scenarios/smoketest/admin.cy.spec.js index 783fac8be76f2d8e7f9ea5af9294660f5a8f5d28..5fda0455ada90e92a2d49938726d7892e36e55e0 100644 --- a/frontend/test/metabase/scenarios/smoketest/admin.cy.spec.js +++ b/frontend/test/metabase/scenarios/smoketest/admin.cy.spec.js @@ -60,7 +60,7 @@ describe("metabase-smoketest > admin", () => { const dbFilename = "frontend/test/__runner__/empty.db"; const dbPath = Cypress.config("fileServerFolder") + "/" + dbFilename; cy.findByLabelText("Connection String").type(`file:${dbPath}`); - cy.findByText("Next").click(); + cy.findByText("Connect database").click(); // Turns off anonymous data collection cy.findByLabelText( diff --git a/modules/drivers/mongo/src/metabase/driver/mongo.clj b/modules/drivers/mongo/src/metabase/driver/mongo.clj index 43239f9b3435fe473dca0845ddadd715ed07f463..0684cbbb7f4b5a9f9cde109b02f87ef7b58dcf58 100644 --- a/modules/drivers/mongo/src/metabase/driver/mongo.clj +++ b/modules/drivers/mongo/src/metabase/driver/mongo.clj @@ -59,25 +59,25 @@ [_ message] (condp re-matches message #"^Timed out after \d+ ms while waiting for a server .*$" - (driver.common/connection-error-messages :cannot-connect-check-host-and-port) + :cannot-connect-check-host-and-port #"^host and port should be specified in host:port format$" - (driver.common/connection-error-messages :invalid-hostname) + :invalid-hostname #"^Password can not be null when the authentication mechanism is unspecified$" - (driver.common/connection-error-messages :password-required) + :password-required #"^org.apache.sshd.common.SshException: No more authentication methods available$" - (driver.common/connection-error-messages :ssh-tunnel-auth-fail) + :ssh-tunnel-auth-fail #"^java.net.ConnectException: Connection refused$" - (driver.common/connection-error-messages :ssh-tunnel-connection-fail) + :ssh-tunnel-connection-fail #".*javax.net.ssl.SSLHandshakeException: PKIX path building failed.*" - (driver.common/connection-error-messages :certificate-not-trusted) + :certificate-not-trusted #".*MongoSocketReadException: Prematurely reached end of stream.*" - (driver.common/connection-error-messages :requires-ssl) + :requires-ssl #".*" ; default message)) diff --git a/modules/drivers/presto/src/metabase/driver/presto.clj b/modules/drivers/presto/src/metabase/driver/presto.clj index 4b99ea5b41bfadbabf1c8b3d6935f5cfbdd5acc1..8bebb1abe85e9d316ac269a8b9efc3319452eb48 100644 --- a/modules/drivers/presto/src/metabase/driver/presto.clj +++ b/modules/drivers/presto/src/metabase/driver/presto.clj @@ -7,7 +7,6 @@ [clojure.tools.logging :as log] [medley.core :as m] [metabase.driver :as driver] - [metabase.driver.common :as driver.common] [metabase.driver.presto-common :as presto-common] [metabase.driver.sql-jdbc.sync.describe-database :as sql-jdbc.describe-database] [metabase.driver.sql.util :as sql.u] @@ -249,13 +248,13 @@ [_ message] (condp re-matches message #"^java.net.ConnectException: Connection refused.*$" - (driver.common/connection-error-messages :cannot-connect-check-host-and-port) + :cannot-connect-check-host-and-port #"^clojure.lang.ExceptionInfo: Catalog .* does not exist.*$" - (driver.common/connection-error-messages :database-name-incorrect) + :database-name-incorrect #"^java.net.UnknownHostException.*$" - (driver.common/connection-error-messages :invalid-hostname) + :invalid-hostname #".*" ; default message)) diff --git a/modules/drivers/snowflake/src/metabase/driver/snowflake.clj b/modules/drivers/snowflake/src/metabase/driver/snowflake.clj index 62ea99a08c55aa1f955746e9a5113b830d850c24..9e0aea04082225445cc3ee54583331abee619ca8 100644 --- a/modules/drivers/snowflake/src/metabase/driver/snowflake.clj +++ b/modules/drivers/snowflake/src/metabase/driver/snowflake.clj @@ -35,7 +35,7 @@ (log/spy :error (type message)) (condp re-matches message #"(?s).*Object does not exist.*$" - (driver.common/connection-error-messages :database-name-incorrect) + :database-name-incorrect #"(?s).*" ; default - the Snowflake errors have a \n in them message)) diff --git a/src/metabase/api/database.clj b/src/metabase/api/database.clj index 567a84ddea4769afe28015a868387c39cebc0f86..6cb115ca8db42c2db0d79894e94f65283386d5b8 100644 --- a/src/metabase/api/database.clj +++ b/src/metabase/api/database.clj @@ -499,18 +499,11 @@ ;;; ----------------------------------------------- POST /api/database ----------------------------------------------- -(defn- invalid-connection-response [field m] - ;; work with the new {:field error-message} format but be backwards-compatible with the UI as it exists right now - {:valid false - field m - :message m}) - (defn test-database-connection "Try out the connection details for a database and useful error message if connection fails, returns `nil` if connection succeeds." - [engine {:keys [host port] :as details}, & {:keys [invalid-response-handler log-exception] - :or {invalid-response-handler invalid-connection-response - log-exception true}}] + [engine {:keys [host port] :as details}, & {:keys [log-exception] + :or {log-exception true}}] {:pre [(some? engine)]} (let [engine (keyword engine) details (assoc details :engine engine)] @@ -520,22 +513,26 @@ nil (and host port (u/host-port-up? host port)) - (invalid-response-handler :dbname (tru "Connection to ''{0}:{1}'' successful, but could not connect to DB." - host port)) + {:message (tru "Connection to ''{0}:{1}'' successful, but could not connect to DB." + host port)} (and host (u/host-up? host)) - (invalid-response-handler :port (tru "Connection to host ''{0}'' successful, but port {1} is invalid." - host port)) + {:message (tru "Connection to host ''{0}'' successful, but port {1} is invalid." + host port) + :errors {:port (deferred-tru "check your port settings")}} host - (invalid-response-handler :host (tru "Host ''{0}'' is not reachable" host)) + {:message (tru "Host ''{0}'' is not reachable" host) + :errors {:host (deferred-tru "check your host settings")}} :else - (invalid-response-handler :db (tru "Unable to connect to database."))) + {:message (tru "Unable to connect to database.")}) (catch Throwable e (when (and log-exception (not (some->> e ex-cause ex-data ::driver/can-connect-message?))) (log/error e (trs "Cannot connect to Database"))) - (invalid-response-handler :dbname (.getMessage e)))))) + (if (-> e ex-data :message) + (ex-data e) + {:message (.getMessage e)}))))) ;; TODO - Just make `:ssl` a `feature` (defn- supports-ssl? @@ -565,7 +562,8 @@ ;; Opportunistic SSL details-with-ssl ;; Try with original parameters - (test-database-connection engine details) + (some-> (test-database-connection engine details) + (assoc :valid false)) details))) (api/defendpoint POST "/" @@ -612,7 +610,7 @@ api/*current-user-id* {:database engine, :source :setup}) {:status 400 - :body details-or-error})))) + :body (dissoc details-or-error :valid)})))) (api/defendpoint POST "/validate" "Validate that we can connect to a database given a set of details." diff --git a/src/metabase/api/setup.clj b/src/metabase/api/setup.clj index 8d152ff748c4044f27bd4eb2aa2d063711af24b8..098743ea72ec695b4d1f273d9d08c703e10cd79e 100644 --- a/src/metabase/api/setup.clj +++ b/src/metabase/api/setup.clj @@ -172,16 +172,14 @@ [:as {{{:keys [engine details]} :details, token :token} :body}] {token SetupToken engine DBEngineString} - (let [engine (keyword engine) - invalid-response (fn [field m] {:status 400, :body (if (#{:dbname :port :host} field) - {:errors {field m}} - {:message m})}) - error-or-nil (api.database/test-database-connection engine details :invalid-response-handler invalid-response)] + (let [engine (keyword engine) + error-or-nil (api.database/test-database-connection engine details)] (when error-or-nil (snowplow/track-event! ::snowplow/database-connection-failed nil {:database engine, :source :setup}) - error-or-nil))) + {:status 400 + :body error-or-nil}))) ;;; Admin Checklist diff --git a/src/metabase/driver.clj b/src/metabase/driver.clj index ba7ae9d66bfbc28d1ddd94b98119d3bc1d913a36..0a059649b919e0d2b4b56bb3183dc0b2ece28036 100644 --- a/src/metabase/driver.clj +++ b/src/metabase/driver.clj @@ -511,9 +511,10 @@ (defmulti humanize-connection-error-message "Return a humanized (user-facing) version of an connection error message. - Generic error messages are provided in [[metabase.driver.common/connection-error-messages]]; return one of these - whenever possible. - Error messages can be strings, or localized strings, as returned by [[metabase.util.i18n/trs]] and + Generic error messages provided in [[metabase.driver.util/connection-error-messages]]; should be returned + as keywords whenever possible. This provides for both unified error messages and categories which let us point + users to the erroneous input fields. + Error messages can also be strings, or localized strings, as returned by [[metabase.util.i18n/trs]] and `metabase.util.i18n/tru`." {:arglists '([this message])} dispatch-on-initialized-driver diff --git a/src/metabase/driver/common.clj b/src/metabase/driver/common.clj index 75e19acc79fed86a3b1931ea30871c061daf0eec..bd70a52c702a694136629e39f020ca018b4d735f 100644 --- a/src/metabase/driver/common.clj +++ b/src/metabase/driver/common.clj @@ -17,50 +17,6 @@ org.joda.time.DateTime org.joda.time.format.DateTimeFormatter)) -(def connection-error-messages - "Generic error messages that drivers should return in their implementation - of [[metabase.driver/humanize-connection-error-message]]." - {:cannot-connect-check-host-and-port - (str (deferred-tru "Hmm, we couldn''t connect to the database.") - " " - (deferred-tru "Make sure your host and port settings are correct")) - - :ssh-tunnel-auth-fail - (str (deferred-tru "We couldn''t connect to the ssh tunnel host.") - " " - (deferred-tru "Check the username, password.")) - - :ssh-tunnel-connection-fail - (str (deferred-tru "We couldn''t connect to the ssh tunnel host.") - " " - (deferred-tru "Check the hostname and port.")) - - :database-name-incorrect - (deferred-tru "Looks like the database name is incorrect.") - - :invalid-hostname - (str (deferred-tru "It looks like your host is invalid.") - " " - (deferred-tru "Please double-check it and try again.")) - - :password-incorrect - (deferred-tru "Looks like your password is incorrect.") - - :password-required - (deferred-tru "Looks like you forgot to enter your password.") - - :username-incorrect - (deferred-tru "Looks like your username is incorrect.") - - :username-or-password-incorrect - (deferred-tru "Looks like the username or password is incorrect.") - - :certificate-not-trusted - (deferred-tru "Server certificate not trusted - did you specify the correct SSL certificate chain?") - - :requires-ssl - (deferred-tru "Server appears to require SSL - please enable SSL above")}) - ;; TODO - we should rename these from `default-*-details` to `default-*-connection-property` (def default-host-details diff --git a/src/metabase/driver/h2.clj b/src/metabase/driver/h2.clj index d6ad5dc56cf16462c4e95fc9bc444bdcbdfb0cd9..10b9807212abf2af241848d63b79fd3e0a523167 100644 --- a/src/metabase/driver/h2.clj +++ b/src/metabase/driver/h2.clj @@ -108,13 +108,13 @@ [_ message] (condp re-matches message #"^A file path that is implicitly relative to the current working directory is not allowed in the database URL .*$" - (driver.common/connection-error-messages :cannot-connect-check-host-and-port) + :cannot-connect-check-host-and-port #"^Database .* not found .*$" - (driver.common/connection-error-messages :cannot-connect-check-host-and-port) + :cannot-connect-check-host-and-port #"^Wrong user name or password .*$" - (driver.common/connection-error-messages :username-or-password-incorrect) + :username-or-password-incorrect #".*" ; default message)) diff --git a/src/metabase/driver/mysql.clj b/src/metabase/driver/mysql.clj index c830f00e50c121869af5d53bdc719672152139d8..3357324c8a89d838cbb3623394527bb208875e40 100644 --- a/src/metabase/driver/mysql.clj +++ b/src/metabase/driver/mysql.clj @@ -123,16 +123,16 @@ [_ message] (condp re-matches message #"^Communications link failure\s+The last packet sent successfully to the server was 0 milliseconds ago. The driver has not received any packets from the server.$" - (driver.common/connection-error-messages :cannot-connect-check-host-and-port) + :cannot-connect-check-host-and-port #"^Unknown database .*$" - (driver.common/connection-error-messages :database-name-incorrect) + :database-name-incorrect #"Access denied for user.*$" - (driver.common/connection-error-messages :username-or-password-incorrect) + :username-or-password-incorrect #"Must specify port after ':' in connection string" - (driver.common/connection-error-messages :invalid-hostname) + :invalid-hostname #".*" ; default message)) diff --git a/src/metabase/driver/postgres.clj b/src/metabase/driver/postgres.clj index fa58664044c6e29324788951ec53a0f7fcb3defa..93407c9d1a844f537a4b7af1ce61c022cfaa957b 100644 --- a/src/metabase/driver/postgres.clj +++ b/src/metabase/driver/postgres.clj @@ -71,19 +71,19 @@ [_ message] (condp re-matches message #"^FATAL: database \".*\" does not exist$" - (driver.common/connection-error-messages :database-name-incorrect) + :database-name-incorrect #"^No suitable driver found for.*$" - (driver.common/connection-error-messages :invalid-hostname) + :invalid-hostname #"^Connection refused. Check that the hostname and port are correct and that the postmaster is accepting TCP/IP connections.$" - (driver.common/connection-error-messages :cannot-connect-check-host-and-port) + :cannot-connect-check-host-and-port #"^FATAL: role \".*\" does not exist$" - (driver.common/connection-error-messages :username-incorrect) + :username-incorrect #"^FATAL: password authentication failed for user.*$" - (driver.common/connection-error-messages :password-incorrect) + :password-incorrect #"^FATAL: .*$" ; all other FATAL messages: strip off the 'FATAL' part, capitalize, and add a period (let [[_ message] (re-matches #"^FATAL: (.*$)" message)] diff --git a/src/metabase/driver/util.clj b/src/metabase/driver/util.clj index 3e20771d0a49278d119c5fab5321562915aafa8d..877639718a6a1f2d1e341f2d767b4dad989a23af 100644 --- a/src/metabase/driver/util.clj +++ b/src/metabase/driver/util.clj @@ -11,7 +11,7 @@ [metabase.public-settings.premium-features :as premium-features] [metabase.query-processor.error-type :as qp.error-type] [metabase.util :as u] - [metabase.util.i18n :refer [trs]] + [metabase.util.i18n :refer [deferred-tru trs]] [toucan.db :as db]) (:import java.io.ByteArrayInputStream [java.security.cert CertificateFactory X509Certificate] @@ -20,6 +20,75 @@ javax.net.SocketFactory [javax.net.ssl SSLContext TrustManagerFactory X509TrustManager])) +(def ^:private connection-error-messages + "Generic error messages that drivers should return in their implementation + of [[metabase.driver/humanize-connection-error-message]]." + {:cannot-connect-check-host-and-port + {:message [(deferred-tru "Hmm, we couldn''t connect to the database.") + " " + (deferred-tru "Make sure your Host and Port settings are correct")] + :errors {:host (deferred-tru "check your host settings") + :port (deferred-tru "check your port settings")}} + + :ssh-tunnel-auth-fail + {:message [(deferred-tru "We couldn''t connect to the SSH tunnel host.") + " " + (deferred-tru "Check the Username and Password.")] + :errors {:tunnel-user (deferred-tru "check your username") + :tunnel-pass (deferred-tru "check your password")}} + + :ssh-tunnel-connection-fail + {:message [(deferred-tru "We couldn''t connect to the SSH tunnel host.") + " " + (deferred-tru "Check the Host and Port.")] + :errors {:tunnel-host (deferred-tru "check your host settings") + :tunnel-port (deferred-tru "check your port settings")}} + + :database-name-incorrect + {:message (deferred-tru "Looks like the Database name is incorrect.") + :errors {:dbname (deferred-tru "check your database name settings")}} + + :invalid-hostname + {:message [(deferred-tru "It looks like your Host is invalid.") + " " + (deferred-tru "Please double-check it and try again.")] + :errors {:host (deferred-tru "check your host settings")}} + + :password-incorrect + {:message (deferred-tru "Looks like your Password is incorrect.") + :errors {:password (deferred-tru "check your password")}} + + :password-required + {:message (deferred-tru "Looks like you forgot to enter your Password.") + :errors {:password (deferred-tru "check your password")}} + + :username-incorrect + {:message (deferred-tru "Looks like your Username is incorrect.") + :errors {:user (deferred-tru "check your username")}} + + :username-or-password-incorrect + {:message (deferred-tru "Looks like the Username or Password is incorrect.") + :errors {:user (deferred-tru "check your username") + :password (deferred-tru "check your password")}} + + :certificate-not-trusted + {:message (deferred-tru "Server certificate not trusted - did you specify the correct SSL certificate chain?")} + + :requires-ssl + {:message (deferred-tru "Server appears to require SSL - please enable SSL below") + :errors {:ssl (deferred-tru "please enable SSL")}}}) + +(defn- force-tr [text-or-vector] + (if (vector? text-or-vector) + (apply str text-or-vector) + (str text-or-vector))) + +(defn- tr-connection-error-messages [error-type-kw] + (when-let [message (connection-error-messages error-type-kw)] + (cond-> message + (contains? message :message) (update :message force-tr) + (contains? message :errors) (update :errors update-vals force-tr)))) + (comment mdb.connection/keep-me) ; used for [[memoize/ttl]] ;; This is normally set via the env var `MB_DB_CONNECTION_TIMEOUT_MS` @@ -36,6 +105,11 @@ 3000 10000)) +(defn- connection-error? [^Throwable throwable] + (and (some? throwable) + (or (instance? java.net.ConnectException throwable) + (recur (.getCause throwable))))) + (defn can-connect-with-details? "Check whether we can connect to a database with `driver` and `details-map` and perform a basic query such as `SELECT 1`. Specify optional param `throw-exceptions` if you want to handle any exceptions thrown yourself (e.g., so you @@ -52,10 +126,18 @@ ;; actually if we are going to `throw-exceptions` we'll rethrow the original but attempt to humanize the message ;; first (catch Throwable e - (throw (if-let [message (some->> (.getMessage e) - (driver/humanize-connection-error-message driver) - str)] - (Exception. message e) + (throw (if-let [humanized-message (some->> (.getMessage e) + (driver/humanize-connection-error-message driver))] + (let [error-data (cond + (keyword? humanized-message) + (tr-connection-error-messages humanized-message) + + (connection-error? e) + (tr-connection-error-messages :cannot-connect-check-host-and-port) + + :else + {:message humanized-message})] + (ex-info (str (:message error-data)) error-data e)) e)))) (try (can-connect-with-details? driver details-map :throw-exceptions) diff --git a/test/metabase/api/database_test.clj b/test/metabase/api/database_test.clj index dfe0e9ae6116b9236e6331e93f795770de039b8c..b1ec229c41dff5849cd7524cad85aaa5512d834c 100644 --- a/test/metabase/api/database_test.clj +++ b/test/metabase/api/database_test.clj @@ -174,7 +174,29 @@ :cache_field_values monthly-schedule}})] (is (:let-user-control-scheduling details)) (is (= "monthly" (-> cache_field_values_schedule u.cron/cron-string->schedule-map :schedule_type))) - (is (= "monthly" (-> metadata_sync_schedule u.cron/cron-string->schedule-map :schedule_type))))))) + (is (= "monthly" (-> metadata_sync_schedule u.cron/cron-string->schedule-map :schedule_type))))) + (testing "well known connection errors are reported properly" + (let [dbname (mt/random-name) + exception (Exception. (format "FATAL: database \"%s\" does not exist" dbname))] + (is (= {:errors {:dbname "check your database name settings"}, + :message "Looks like the Database name is incorrect."} + (with-redefs [driver/can-connect? (fn [& _] (throw exception))] + (mt/user-http-request :crowberto :post 400 "database" + {:name dbname + :engine "postgres" + :details {:host "localhost", :port 5432 + :dbname "fakedb", :user "rastacan"}})))))) + (testing "unknown connection errors are reported properly" + (let [exception (Exception. "Unknown driver message" (java.net.ConnectException. "Failed!"))] + (is (= {:errors {:host "check your host settings" + :port "check your port settings"} + :message "Hmm, we couldn't connect to the database. Make sure your Host and Port settings are correct"} + (with-redefs [driver/available? (constantly true) + driver/can-connect? (fn [& _] (throw exception))] + (mt/user-http-request :crowberto :post 400 "database" + {:name (mt/random-name) + :engine (u/qualified-name ::test-driver) + :details {:db "my_db"}})))))))) (deftest delete-database-test (testing "DELETE /api/database/:id" @@ -199,8 +221,7 @@ update! (fn [expected-status-code] (mt/user-http-request :crowberto :put expected-status-code (format "database/%d" db-id) updates))] (testing "Should check that connection details are valid on save" - (is (= false - (:valid (update! 400))))) + (is (string? (:message (update! 400))))) (testing "If connection details are valid, we should be able to update the Database" (with-redefs [driver/can-connect? (constantly true)] (is (= nil @@ -772,8 +793,9 @@ (testing "invalid database connection details" (testing "calling test-connection-details directly" - (is (= {:dbname "Hmm, we couldn't connect to the database. Make sure your host and port settings are correct" - :message "Hmm, we couldn't connect to the database. Make sure your host and port settings are correct" + (is (= {:errors {:host "check your host settings" + :port "check your port settings"} + :message "Hmm, we couldn't connect to the database. Make sure your Host and Port settings are correct" :valid false} (#'api.database/test-connection-details "h2" {:db "ABC"})))) diff --git a/test/metabase/api/setup_test.clj b/test/metabase/api/setup_test.clj index 23203a92df257bd8ec8b6da8a75428d01ebfaf2e..8e097481424407367fe0005f0429e5ac844ba42c 100644 --- a/test/metabase/api/setup_test.clj +++ b/test/metabase/api/setup_test.clj @@ -371,7 +371,9 @@ (client/client :post 400 "setup/validate" {:token (setup/setup-token)})))) (testing "should validate that database connection works" - (is (= {:errors {:dbname "Hmm, we couldn't connect to the database. Make sure your host and port settings are correct"}} + (is (= {:errors {:host "check your host settings" + :port "check your port settings"} + :message "Hmm, we couldn't connect to the database. Make sure your Host and Port settings are correct"} (client/client :post 400 "setup/validate" {:token (setup/setup-token) :details {:engine "h2" :details {:db "file:///tmp/fake.db"}}}))))