diff --git a/app/src/api/api.js b/app/src/api/api.js index c9bf6932..90aada73 100644 --- a/app/src/api/api.js +++ b/app/src/api/api.js @@ -4,7 +4,7 @@ */ import store from '@/store' -import { handleResponse, handleError } from './handlers' +import { handleResponse } from './handlers' import { objectToParams } from '@/helpers/commons' /** @@ -63,6 +63,8 @@ export default { const localeQs = `${uri.includes('?') ? '&' : '?'}locale=${store.getters.locale}` return fetch('/yunohost/api/' + uri + localeQs, this.options) } + + store.dispatch('WAITING_FOR_RESPONSE', [uri, method]) return fetch('/yunohost/api/' + uri, { ...this.options, method, @@ -77,7 +79,7 @@ export default { * @return {Promise} Promise that resolve the api response as an object, a string or as an error. */ get (uri) { - return this.fetch('GET', uri).then(handleResponse) + return this.fetch('GET', uri).then(response => handleResponse(response, 'GET')) }, /** @@ -98,8 +100,7 @@ export default { * @return {Promise} Promise that resolve the api responses as an array. */ post (uri, data = {}) { - store.dispatch('WAITING_FOR_RESPONSE', [uri, 'POST']) - return this.fetch('POST', uri, data).then(handleResponse) + return this.fetch('POST', uri, data).then(response => handleResponse(response, 'POST')) }, /** @@ -110,8 +111,7 @@ export default { * @return {Promise} Promise that resolve the api responses as an array. */ put (uri, data = {}) { - store.dispatch('WAITING_FOR_RESPONSE', [uri, 'PUT']) - return this.fetch('PUT', uri, data).then(handleResponse) + return this.fetch('PUT', uri, data).then(response => handleResponse(response, 'PUT')) }, /** @@ -122,10 +122,6 @@ export default { * @return {Promise<('ok'|Error)>} Promise that resolve the api responses as an array. */ delete (uri, data = {}) { - store.dispatch('WAITING_FOR_RESPONSE', [uri, 'DELETE']) - return this.fetch('DELETE', uri, data).then(response => { - store.dispatch('SERVER_RESPONDED') - return response.ok ? 'ok' : handleError(response) - }) + return this.fetch('DELETE', uri, data).then(response => handleResponse(response, 'DELETE')) } } diff --git a/app/src/api/errors.js b/app/src/api/errors.js new file mode 100644 index 00000000..7763015f --- /dev/null +++ b/app/src/api/errors.js @@ -0,0 +1,86 @@ +/** + * API errors definitionss. + * @module api/errors + */ + +import i18n from '@/i18n' + +class APIError extends Error { + constructor (method, { url, status, statusText }, message) { + super(message || i18n.t('error_server_unexpected')) + this.uri = new URL(url).pathname.replace('/yunohost', '') + this.method = method + this.code = status + this.status = statusText + this.name = 'APIError' + } + + print () { + console.error(`${this.name} (${this.code}): ${this.uri}\n${this.message}`) + } +} + +// 401 — Unauthorized +class APIUnauthorizedError extends APIError { + constructor (method, response, message) { + super(method, response, i18n.t('unauthorized')) + this.name = 'APIUnauthorizedError' + } +} + +// 400 — Bad Request +class APIBadRequestError extends APIError { + constructor (method, response, message) { + super(method, response, message) + this.name = 'APIBadRequestError' + } +} + +// 500 — Server Internal Error +class APIInternalError extends APIError { + constructor (method, response, data) { + // not tested (message should be json but in ) + const traceback = typeof data === 'object' ? data.traceback : null + super(method, response, 'none') + if (traceback) { + this.traceback = traceback + } + this.name = 'APIInternalError' + } +} + +// 502 — Bad gateway (means API is down) +class APINotRespondingError extends APIError { + constructor (method, response) { + super(method, response, i18n.t('api_not_responding')) + this.name = 'APINotRespondingError' + } +} + +// 0 — (means "the connexion has been closed" apparently) +class APIConnexionError extends APIError { + constructor (method, response) { + super(method, response, i18n.t('error_connection_interrupted')) + this.name = 'APIConnexionError' + } +} + +// Temp factory +const errors = { + [undefined]: APIError, + 0: APIConnexionError, + 400: APIBadRequestError, + 401: APIUnauthorizedError, + 500: APIInternalError, + 502: APINotRespondingError +} + +export { + errors as default, + APIError, + APIUnauthorizedError, + APIBadRequestError, + APIInternalError, + APINotRespondingError, + APIConnexionError +} diff --git a/app/src/api/handlers.js b/app/src/api/handlers.js index 6f8bbec3..b6e71596 100644 --- a/app/src/api/handlers.js +++ b/app/src/api/handlers.js @@ -1,15 +1,20 @@ +/** + * API handlers. + * @module api/handlers + */ + import store from '@/store' +import errors from './errors' /** - * Handler for API responses. + * Try to get response content as json and if it's not as text. * * @param {Response} response - A fetch `Response` object. * @return {(Object|String)} Parsed response's json or response's text. */ -async function handleResponse (response) { - store.dispatch('SERVER_RESPONDED') - if (!response.ok) return handleError(response) - // FIXME the api should always return json objects + +async function _getResponseContent (response) { + // FIXME the api should always return json as response const responseText = await response.text() try { return JSON.parse(responseText) @@ -18,23 +23,42 @@ async function handleResponse (response) { } } +/** + * Handler for API responses. + * + * @param {Response} response - A fetch `Response` object. + * @return {(Object|String)} Parsed response's json, response's text or an error. + */ +export function handleResponse (response, method) { + store.dispatch('SERVER_RESPONDED', response.ok) + if (!response.ok) return handleError(response, method) + // FIXME the api should always return json objects + return _getResponseContent(response) +} + /** * Handler for API errors. * * @param {Response} response - A fetch `Response` object. - * @throws Will throw an error with the API response text or custom message. + * @throws Will throw a custom error with response data. */ -async function handleError (response) { - if (response.status === 401) { - store.dispatch('DISCONNECT') - throw new Error('Unauthorized') - } else if (response.status === 400) { - const message = await response.text() - throw new Error(message) - } -} +export async function handleError (response, method) { + console.log(response.url) + const message = await _getResponseContent(response) + const errorCode = response.status in errors ? response.status : undefined + const error = new errors[errorCode](method, response, message) -export { - handleResponse, - handleError + if (error.code === 401) { + store.dispatch('DISCONNECT') + } else if (error.code === 400) { + // FIXME for now while in form, the error is catched by the caller and displayed in the form + // Hide the waiting screen + store.dispatch('SERVER_RESPONDED', true) + } else { + store.dispatch('DISPATCH_ERROR', error) + } + + // error.print() + + throw error } diff --git a/app/src/components/ApiWaitOverlay.vue b/app/src/components/ApiWaitOverlay.vue index 2c1988c8..9f6e9cb4 100644 --- a/app/src/components/ApiWaitOverlay.vue +++ b/app/src/components/ApiWaitOverlay.vue @@ -8,12 +8,16 @@ @@ -43,12 +51,13 @@ @@ -71,7 +84,6 @@ export default { position: sticky; top: 5vh; margin: 0 5%; - padding: 3rem 0; @include media-breakpoint-up(md) { margin: 0 10%; @@ -83,6 +95,8 @@ export default { .card-body { padding-bottom: 2rem; + max-height: 50vh; + overflow-y: auto; } .progress { diff --git a/app/src/i18n/locales/en.json b/app/src/i18n/locales/en.json index 50370391..67bd480b 100644 --- a/app/src/i18n/locales/en.json +++ b/app/src/i18n/locales/en.json @@ -5,6 +5,18 @@ "advanced": "Advanced", "administration_password": "Administration password", "all": "All", + "api_error": { + "help": "You should look for help on the forum or the chat to fix the situation, or report the bug on the bugtracker.", + "info": "The following information might be useful for the person helping you:", + "sorry": "Really sorry about that." + }, + "api_errors_titles": { + "APIError": "Yunohost encountered an unexpected error", + "APIBadRequestError": "Yunohost encountered an error", + "APIInternalError": "Yunohost encountered an internal error", + "APINotRespondingError": "Yunohost API is not responding", + "APIConnexionError": "Yunohost encountered an connexion error" + }, "all_apps": "All apps", "api_not_responding": "The YunoHost API is not responding. Maybe 'yunohost-api' is down or got restarted?", "api_waiting": "Waiting for the server's response...", @@ -122,6 +134,7 @@ "domains": "Domains", "enable": "Enable", "enabled": "Enabled", + "error": "Error", "error_modify_something": "You should modify something", "error_retrieve_feed": "Could not retrieve feed: %s. You might have a plugin prevent your browser from performing this request (or the website is down).", "error_select_domain": "You should indicate a domain", @@ -371,6 +384,7 @@ "transitions": "Page transition animations" }, "tools_webadmin_settings": "Web-admin settings", + "traceback": "Traceback", "udp": "UDP", "unauthorized": "Unauthorized", "unignore": "Unignore", diff --git a/app/src/router/routes.js b/app/src/router/routes.js index 04a94c45..0b8a029c 100644 --- a/app/src/router/routes.js +++ b/app/src/router/routes.js @@ -3,8 +3,14 @@ * @module router/routes */ +// Simple views are normally imported and will be included into the main webpack entry. +// Others will be chunked by webpack so they can be lazy loaded. +// Webpack chunk syntax is: +// `() => import(/* webpackChunkName: "views/:nameOfWantedFile" */ '@/views/:ViewComponent')` + import Home from '@/views/Home' import Login from '@/views/Login' +import ErrorPage from '@/views/ErrorPage' import ToolList from '@/views/tool/ToolList' const routes = [ @@ -23,6 +29,18 @@ const routes = [ meta: { noAuth: true, breadcrumb: [] } }, + /* ────────╮ + │ ERROR │ + ╰──────── */ + { + name: 'error', + path: '/error/:type', + component: ErrorPage, + props: true, + // Leave the breadcrumb + meta: { noAuth: true, breadcrumb: [] } + }, + /* ───────────────╮ │ POST INSTALL │ ╰─────────────── */ diff --git a/app/src/store/info.js b/app/src/store/info.js index 6e33cdd2..d9e71a34 100644 --- a/app/src/store/info.js +++ b/app/src/store/info.js @@ -8,6 +8,7 @@ export default { host: window.location.host, connected: localStorage.getItem('connected') === 'true', yunohost: null, // yunohost app infos: Object {version, repo} + error: null, waiting: false, history: [] }, @@ -36,6 +37,10 @@ export default { 'UPDATE_PROGRESS' (state, progress) { Vue.set(state.history[state.history.length - 1], 'progress', progress) + }, + + 'SET_ERROR' (state, error) { + state.error = error } }, @@ -55,11 +60,14 @@ export default { }) }, - 'DISCONNECT' ({ commit }, route) { + 'RESET_CONNECTED' ({ commit }) { commit('SET_CONNECTED', false) commit('SET_YUNOHOST_INFOS', null) - // Do not redirect if the current route needs to display an error. - if (['login', 'tool-adminpw'].includes(router.currentRoute.name)) return + }, + + 'DISCONNECT' ({ dispatch }, route) { + dispatch('RESET_CONNECTED') + if (router.currentRoute.name === 'login') return router.push({ name: 'login', // Add a redirect query if next route is not unknown (like `logout`) or `login` @@ -100,9 +108,10 @@ export default { commit('ADD_HISTORY_ENTRY', [uri, method, Date.now()]) }, - 'SERVER_RESPONDED' ({ state, dispatch, commit }) { - if (!state.waiting) return - commit('UPDATE_WAITING', false) + 'SERVER_RESPONDED' ({ state, dispatch, commit }, responseIsOk) { + if (responseIsOk) { + commit('UPDATE_WAITING', false) + } }, 'DISPATCH_MESSAGE' ({ commit }, messages) { @@ -126,6 +135,14 @@ export default { commit('ADD_MESSAGE', message) } } + }, + + 'DISPATCH_ERROR' ({ state, commit }, error) { + commit('SET_ERROR', error) + if (error.method === 'GET') { + router.push({ name: 'error', params: { type: error.code } }) + } + // else the waiting screen will display the error } }, @@ -133,6 +150,7 @@ export default { host: state => state.host, connected: state => (state.connected), yunohost: state => (state.yunohost), + error: state => state.error, waiting: state => state.waiting, history: state => state.history, lastAction: state => state.history[state.history.length - 1] diff --git a/app/src/views/ErrorPage.vue b/app/src/views/ErrorPage.vue new file mode 100644 index 00000000..c73b5c77 --- /dev/null +++ b/app/src/views/ErrorPage.vue @@ -0,0 +1,42 @@ + + + + +