Merge pull request #330 from YunoHost/enh-error-handling

Enh error handling
This commit is contained in:
Alexandre Aubin 2021-02-24 17:07:25 +01:00 committed by GitHub
commit 91ed70ef8d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
60 changed files with 1554 additions and 1011 deletions

View file

@ -33,7 +33,7 @@
</header> </header>
<!-- MAIN --> <!-- MAIN -->
<api-wait-overlay> <view-lock-overlay>
<breadcrumb /> <breadcrumb />
<main id="main"> <main id="main">
@ -44,10 +44,10 @@
</transition> </transition>
<router-view v-else class="static" :key="$route.fullPath" /> <router-view v-else class="static" :key="$route.fullPath" />
</main> </main>
</api-wait-overlay> </view-lock-overlay>
<!-- CONSOLE/HISTORY --> <!-- HISTORY CONSOLE -->
<ynh-console /> <history-console />
<!-- FOOTER --> <!-- FOOTER -->
<footer class="py-3 mt-auto"> <footer class="py-3 mt-auto">
@ -76,12 +76,16 @@
<script> <script>
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
import ApiWaitOverlay from '@/components/ApiWaitOverlay' import { HistoryConsole, ViewLockOverlay } from '@/views/_partials'
import YnhConsole from '@/components/YnhConsole'
export default { export default {
name: 'App', name: 'App',
components: {
HistoryConsole,
ViewLockOverlay
},
data () { data () {
return { return {
transitionName: null transitionName: null
@ -109,11 +113,6 @@ export default {
} }
}, },
components: {
ApiWaitOverlay,
YnhConsole
},
// This hook is only triggered at page first load // This hook is only triggered at page first load
created () { created () {
// From this hook the value of `connected` always come from the localStorage. // From this hook the value of `connected` always come from the localStorage.

View file

@ -4,14 +4,33 @@
*/ */
import store from '@/store' import store from '@/store'
import { handleResponse } from './handlers' import { openWebSocket, getResponseData, handleError } from './handlers'
import { objectToParams } from '@/helpers/commons' import { objectToParams } from '@/helpers/commons'
/** /**
* A digested fetch response as an object, a string or an error. * Options available for an API call.
* @typedef {(Object|string|Error)} DigestedResponse *
* @typedef {Object} Options
* @property {Boolean} wait - If `true`, will display the waiting modal.
* @property {Boolean} websocket - if `true`, will open a websocket connection.
* @property {Boolean} initial - if `true` and an error occurs, the dismiss button will trigger a go back in history.
* @property {Boolean} noCache - if `true`, will disable the cache mecanism for this call.
* @property {Boolean} asFormData - if `true`, will send the data with a body encoded as `"multipart/form-data"` instead of `"x-www-form-urlencoded"`).
*/ */
/**
* Representation of an API call for `api.fetchAll`
*
* @typedef {Array} Query
* @property {String} 0 - "method"
* @property {String|Object} 1 - "uri", uri to call as string or as an object for cached uris.
* @property {Object|null} 2 - "data"
* @property {Options} 3 - "options"
*/
export default { export default {
options: { options: {
credentials: 'include', credentials: 'include',
@ -22,106 +41,124 @@ export default {
// Auto header is : // Auto header is :
// "Accept": "*/*", // "Accept": "*/*",
// Also is this still important ? (needed by back-end)
'X-Requested-With': 'XMLHttpRequest' 'X-Requested-With': 'XMLHttpRequest'
} }
}, },
/**
* Opens a WebSocket connection to the server in case it sends messages.
* Currently, the connection is closed by the server right after an API call so
* we have to open it for every calls.
* Messages are dispatch to the store so it can handle them.
*
* @return {Promise<Event>} Promise that resolve on websocket 'open' or 'error' event.
*/
openWebSocket () {
return new Promise(resolve => {
const ws = new WebSocket(`wss://${store.getters.host}/yunohost/api/messages`)
ws.onmessage = ({ data }) => store.dispatch('DISPATCH_MESSAGE', JSON.parse(data))
// ws.onclose = (e) => {}
ws.onopen = resolve
// Resolve also on error so the actual fetch may be called.
ws.onerror = resolve
})
},
/** /**
* Generic method to fetch the api without automatic response handling. * Generic method to fetch the api without automatic response handling.
* *
* @param {string} method - a method between 'GET', 'POST', 'PUT' and 'DELETE'. * @param {String} method - a method between 'GET', 'POST', 'PUT' and 'DELETE'.
* @param {string} uri * @param {String} uri
* @param {string} [data={}] - data to send as body for 'POST', 'PUT' and 'DELETE' methods. * @param {Object} [data={}] - data to send as body.
* @return {Promise<Response>} Promise that resolve a fetch `Response`. * @param {Options} [options={ wait = true, websocket = true, initial = false, asFormData = false }]
* @return {Promise<Object|Error>} Promise that resolve the api response data or an error.
*/ */
async fetch (method, uri, data = {}) { async fetch (method, uri, data = {}, { wait = true, websocket = true, initial = false, asFormData = false } = {}) {
// Open a websocket connection that will dispatch messages received. // `await` because Vuex actions returns promises by default.
// FIXME add ability to do not open it const request = await store.dispatch('INIT_REQUEST', { method, uri, initial, wait, websocket })
await this.openWebSocket()
if (method === 'GET') { if (websocket) {
const localeQs = `${uri.includes('?') ? '&' : '?'}locale=${store.getters.locale}` await openWebSocket(request)
return fetch('/yunohost/api/' + uri + localeQs, this.options)
} }
store.dispatch('WAITING_FOR_RESPONSE', [uri, method]) let options = this.options
return fetch('/yunohost/api/' + uri, { if (method === 'GET') {
...this.options, uri += `${uri.includes('?') ? '&' : '?'}locale=${store.getters.locale}`
method, } else {
body: objectToParams(data, { addLocale: true }) options = { ...options, method, body: objectToParams(data, { addLocale: true }) }
}) }
const response = await fetch('/yunohost/api/' + uri, options)
const responseData = await getResponseData(response)
store.dispatch('END_REQUEST', { request, success: response.ok, wait })
return response.ok ? responseData : handleError(request, response, responseData)
}, },
/**
* Api multiple queries helper.
* Those calls will act as one (declare optional waiting for one but still create history entries for each)
* Calls are synchronous since the API can't handle multiple calls.
*
* @param {Array<Query>} queries - An array of queries with special representation.
* @param {Object} [options={}]
* @param {Boolean}
* @return {Promise<Array|Error>} Promise that resolve the api responses data or an error.
*/
async fetchAll (queries, { wait, initial } = {}) {
const results = []
if (wait) store.commit('SET_WAITING', true)
try {
for (const [method, uri, data, options = {}] of queries) {
if (wait) options.wait = false
if (initial) options.initial = true
results.push(await this[method.toLowerCase()](uri, data, options))
}
} finally {
// Stop waiting even if there is an error.
if (wait) store.commit('SET_WAITING', false)
}
return results
},
/** /**
* Api get helper function. * Api get helper function.
* *
* @param {string} uri - the uri to call. * @param {String|Object} uri
* @return {Promise<module:api~DigestedResponse>} Promise that resolve the api response as an object, a string or as an error. * @param {null} [data=null] - for convenience in muliple calls, just pass null.
* @param {Options} [options={}] - options to apply to the call (default is `{ websocket: false, wait: false }`)
* @return {Promise<Object|Error>} Promise that resolve the api response data or an error.
*/ */
get (uri) { get (uri, data = null, options = {}) {
return this.fetch('GET', uri).then(response => handleResponse(response, 'GET')) options = { websocket: false, wait: false, ...options }
if (typeof uri === 'string') return this.fetch('GET', uri, null, options)
return store.dispatch('GET', { ...uri, options })
}, },
/**
* Api get helper function for multiple queries.
*
* @param {string} uri - the uri to call.
* @return {Promise<module:api~DigestedResponse[]>} Promise that resolve the api responses as an array.
*/
getAll (uris) {
return Promise.all(uris.map(uri => this.get(uri)))
},
/** /**
* Api post helper function. * Api post helper function.
* *
* @param {string} uri - the uri to call. * @param {String|Object} uri
* @param {string} [data={}] - data to send as body. * @param {String} [data={}] - data to send as body.
* @return {Promise<module:api~DigestedResponse>} Promise that resolve the api responses as an array. * @param {Options} [options={}] - options to apply to the call
* @return {Promise<Object|Error>} Promise that resolve the api response data or an error.
*/ */
post (uri, data = {}) { post (uri, data = {}, options = {}) {
return this.fetch('POST', uri, data).then(response => handleResponse(response, 'POST')) if (typeof uri === 'string') return this.fetch('POST', uri, data, options)
return store.dispatch('POST', { ...uri, data, options })
}, },
/** /**
* Api put helper function. * Api put helper function.
* *
* @param {string} uri - the uri to call. * @param {String|Object} uri
* @param {string} [data={}] - data to send as body. * @param {String} [data={}] - data to send as body.
* @return {Promise<module:api~DigestedResponse>} Promise that resolve the api responses as an array. * @param {Options} [options={}] - options to apply to the call
* @return {Promise<Object|Error>} Promise that resolve the api response data or an error.
*/ */
put (uri, data = {}) { put (uri, data = {}, options = {}) {
return this.fetch('PUT', uri, data).then(response => handleResponse(response, 'PUT')) if (typeof uri === 'string') return this.fetch('PUT', uri, data, options)
return store.dispatch('PUT', { ...uri, data, options })
}, },
/** /**
* Api delete helper function. * Api delete helper function.
* *
* @param {string} uri - the uri to call. * @param {String|Object} uri
* @param {string} [data={}] - data to send as body. * @param {String} [data={}] - data to send as body.
* @return {Promise<('ok'|Error)>} Promise that resolve the api responses as an array. * @param {Options} [options={}] - options to apply to the call (default is `{ websocket: false, wait: false }`)
* @return {Promise<Object|Error>} Promise that resolve the api response data or an error.
*/ */
delete (uri, data = {}) { delete (uri, data = {}, options = {}) {
return this.fetch('DELETE', uri, data).then(response => handleResponse(response, 'DELETE')) if (typeof uri === 'string') return this.fetch('DELETE', uri, data, options)
return store.dispatch('DELETE', { ...uri, data, options })
} }
} }

View file

@ -5,82 +5,111 @@
import i18n from '@/i18n' import i18n from '@/i18n'
class APIError extends Error { class APIError extends Error {
constructor (method, { url, status, statusText }, message) { constructor (request, { url, status, statusText }, errorData) {
super(message || i18n.t('error_server_unexpected')) super(errorData.error || i18n.t('error_server_unexpected'))
this.uri = new URL(url).pathname.replace('/yunohost', '') const urlObj = new URL(url)
this.method = method this.name = 'APIError'
this.code = status this.code = status
this.status = statusText this.status = statusText
this.name = 'APIError' this.method = request.method
this.request = request
this.path = urlObj.pathname + urlObj.search
} }
print () { log () {
console.error(`${this.name} (${this.code}): ${this.uri}\n${this.message}`) console.error(`${this.name} (${this.code}): ${this.uri}\n${this.message}`)
} }
} }
// 401 — Unauthorized // Log (Special error to trigger a redirect to a log page)
class APIUnauthorizedError extends APIError { class APIErrorLog extends APIError {
constructor (method, response, message) { constructor (method, response, errorData) {
super(method, response, i18n.t('unauthorized')) super(method, response, errorData)
this.name = 'APIUnauthorizedError' this.logRef = errorData.log_ref
this.name = 'APIErrorLog'
} }
} }
// 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) // 0 — (means "the connexion has been closed" apparently)
class APIConnexionError extends APIError { class APIConnexionError extends APIError {
constructor (method, response) { constructor (method, response) {
super(method, response, i18n.t('error_connection_interrupted')) super(method, response, { error: i18n.t('error_connection_interrupted') })
this.name = 'APIConnexionError' this.name = 'APIConnexionError'
} }
} }
// 400 — Bad Request
class APIBadRequestError extends APIError {
constructor (method, response, errorData) {
super(method, response, errorData)
this.name = 'APIBadRequestError'
}
}
// 401 — Unauthorized
class APIUnauthorizedError extends APIError {
constructor (method, response, errorData) {
super(method, response, { error: i18n.t('unauthorized') })
this.name = 'APIUnauthorizedError'
}
}
// 404 — Not Found
class APINotFoundError extends APIError {
constructor (method, response, errorData) {
errorData.error = i18n.t('api_not_found')
super(method, response, errorData)
this.name = 'APINotFoundError'
}
}
// 500 — Server Internal Error
class APIInternalError extends APIError {
constructor (method, response, errorData) {
super(method, response, errorData)
this.traceback = errorData.traceback || null
this.name = 'APIInternalError'
}
}
// 502 — Bad gateway (means API is down)
class APINotRespondingError extends APIError {
constructor (method, response) {
super(method, response, { error: i18n.t('api_not_responding') })
this.name = 'APINotRespondingError'
}
}
// Temp factory // Temp factory
const errors = { const errors = {
[undefined]: APIError, [undefined]: APIError,
log: APIErrorLog,
0: APIConnexionError, 0: APIConnexionError,
400: APIBadRequestError, 400: APIBadRequestError,
401: APIUnauthorizedError, 401: APIUnauthorizedError,
404: APINotFoundError,
500: APIInternalError, 500: APIInternalError,
502: APINotRespondingError 502: APINotRespondingError
} }
export { export {
errors as default, errors as default,
APIError, APIError,
APIUnauthorizedError, APIErrorLog,
APIBadRequestError, APIBadRequestError,
APIConnexionError,
APIInternalError, APIInternalError,
APINotFoundError,
APINotRespondingError, APINotRespondingError,
APIConnexionError APIUnauthorizedError
} }

View file

@ -4,8 +4,8 @@
*/ */
import store from '@/store' import store from '@/store'
import errors from './errors' import errors, { APIError } from './errors'
import router from '@/router'
/** /**
* Try to get response content as json and if it's not as text. * Try to get response content as json and if it's not as text.
@ -13,8 +13,7 @@ import router from '@/router'
* @param {Response} response - A fetch `Response` object. * @param {Response} response - A fetch `Response` object.
* @return {(Object|String)} Parsed response's json or response's text. * @return {(Object|String)} Parsed response's json or response's text.
*/ */
export async function getResponseData (response) {
async function _getResponseContent (response) {
// FIXME the api should always return json as response // FIXME the api should always return json as response
const responseText = await response.text() const responseText = await response.text()
try { try {
@ -24,43 +23,89 @@ async function _getResponseContent (response) {
} }
} }
/** /**
* Handler for API responses. * Opens a WebSocket connection to the server in case it sends messages.
* Currently, the connection is closed by the server right after an API call so
* we have to open it for every calls.
* Messages are dispatch to the store so it can handle them.
* *
* @param {Response} response - A fetch `Response` object. * @param {Object} request - Request info data.
* @return {(Object|String)} Parsed response's json, response's text or an error. * @return {Promise<Event>} Promise that resolve on websocket 'open' or 'error' event.
*/ */
export function handleResponse (response, method) { export function openWebSocket (request) {
if (method !== 'GET') { return new Promise(resolve => {
store.dispatch('SERVER_RESPONDED', response.ok) const ws = new WebSocket(`wss://${store.getters.host}/yunohost/api/messages`)
ws.onmessage = ({ data }) => {
store.dispatch('DISPATCH_MESSAGE', { request, messages: JSON.parse(data) })
} }
if (!response.ok) return handleError(response, method) // ws.onclose = (e) => {}
// FIXME the api should always return json objects ws.onopen = resolve
return _getResponseContent(response) // Resolve also on error so the actual fetch may be called.
ws.onerror = resolve
})
} }
/** /**
* Handler for API errors. * Handler for API errors.
* *
* @param {Response} response - A fetch `Response` object. * @param {Object} request - Request info data.
* @throws Will throw a custom error with response data. * @param {Response} response - A consumed fetch `Response` object.
* @param {Object|String} errorData - The response parsed json/text.
* @throws Will throw a `APIError` with request and response data.
*/ */
export async function handleError (response, method) { export async function handleError (request, response, errorData) {
const message = await _getResponseContent(response) let errorCode = response.status in errors ? response.status : undefined
const errorCode = response.status in errors ? response.status : undefined if (typeof errorData === 'string') {
const error = new errors[errorCode](method, response, message.error || message) // FIXME API: Patching errors that are plain text or html.
errorData = { error: errorData }
if (error.code === 401) {
store.dispatch('DISCONNECT')
} else if (error.code === 400) {
if (typeof message !== 'string' && 'log_ref' in message) {
router.push({ name: 'tool-log', params: { name: message.log_ref } })
} }
// Hide the waiting screen if ('log_ref' in errorData) {
store.dispatch('SERVER_RESPONDED', true) // Define a special error so it won't get caught as a `APIBadRequestError`.
} else { errorCode = 'log'
store.dispatch('DISPATCH_ERROR', error)
} }
throw error // This error can be catched by a view otherwise it will be catched by the `onUnhandledAPIError` handler.
throw new errors[errorCode](request, response, errorData)
}
/**
* If an APIError is not catched by a view it will be dispatched to the store so the
* error can be displayed in the error modal.
*
* @param {APIError} error
*/
export function onUnhandledAPIError (error) {
// In 'development', Babel seems to also catch the error so there's no need to log it twice.
if (process.env.NODE_ENV !== 'development') {
error.log()
}
store.dispatch('HANDLE_ERROR', error)
}
/**
* Global catching of unhandled promise's rejections.
* Those errors (thrown or rejected from inside a promise) can't be catched by
* `window.onerror`.
*/
export function registerGlobalErrorHandlers () {
window.addEventListener('unhandledrejection', e => {
const error = e.reason
if (error instanceof APIError) {
onUnhandledAPIError(error)
// Seems like there's a bug in Firefox and the error logging in not prevented.
e.preventDefault()
}
})
// Keeping this in case it is needed.
// Global catching of errors occuring inside vue components.
// Vue.config.errorHandler = (err, vm, info) => {}
// Global catching of regular js errors.
// window.onerror = (message, source, lineno, colno, error) => {}
} }

View file

@ -1,2 +1,2 @@
export { default } from './api' export { default } from './api'
export { handleResponse, handleError } from './handlers' export { handleError, registerGlobalErrorHandlers } from './handlers'

View file

@ -1,137 +0,0 @@
<template>
<b-overlay
variant="white" rounded="sm" opacity="0.5"
no-center
:show="waiting"
>
<slot name="default" />
<template v-slot:overlay>
<b-card no-body>
<div v-if="!error" class="mt-3 px-3">
<div class="custom-spinner" :class="spinner" />
</div>
<b-card-body v-if="error">
<error-page />
</b-card-body>
<b-card-body v-else class="pb-4">
<b-card-title class="text-center m-0" v-t="'api_waiting'" />
<!-- PROGRESS BAR -->
<b-progress
v-if="progress" class="mt-4"
:max="progress.max" height=".5rem"
>
<b-progress-bar variant="success" :value="progress.values[0]" />
<b-progress-bar variant="warning" :value="progress.values[1]" animated />
<b-progress-bar variant="secondary" :value="progress.values[2]" striped />
</b-progress>
</b-card-body>
<b-card-footer v-if="error" class="justify-content-end">
<b-button variant="primary" v-t="'ok'" @click="$store.dispatch('SERVER_RESPONDED', true)" />
</b-card-footer>
</b-card>
</template>
</b-overlay>
</template>
<script>
import { mapGetters } from 'vuex'
import ErrorPage from '@/views/ErrorPage'
export default {
name: 'ApiWaitOverlay',
computed: {
...mapGetters(['waiting', 'lastAction', 'error', 'spinner']),
progress () {
if (!this.lastAction) return null
const progress = this.lastAction.progress
if (!progress) return null
return {
values: progress, max: progress.reduce((sum, value) => (sum + value), 0)
}
}
},
components: {
ErrorPage
}
}
</script>
<style lang="scss" scoped>
.card {
position: sticky;
top: 5vh;
margin: 0 5%;
@include media-breakpoint-up(md) {
margin: 0 10%;
}
@include media-breakpoint-up(lg) {
margin: 0 20%;
}
}
.card-body {
padding-bottom: 2rem;
max-height: 50vh;
overflow-y: auto;
}
.progress {
margin-top: 2rem;
}
.custom-spinner {
animation: 4s linear infinite;
background-repeat: no-repeat;
&.pacman {
height: 24px;
width: 24px;
background-image: url('../assets/spinners/pacman.gif');
animation-name: back-and-forth-pacman;
@keyframes back-and-forth-pacman {
0%, 100% { transform: scale(1); margin-left: 0; }
49% { transform: scale(1); margin-left: calc(100% - 24px);}
50% { transform: scale(-1); margin-left: calc(100% - 24px);}
99% { transform: scale(-1); margin-left: 0;}
}
}
&.magikarp {
height: 32px;
width: 32px;
background-image: url('../assets/spinners/magikarp.gif');
animation-name: back-and-forth-magikarp;
@keyframes back-and-forth-magikarp {
0%, 100% { transform: scale(1, 1); margin-left: 0; }
49% { transform: scale(1, 1); margin-left: calc(100% - 32px);}
50% { transform: scale(-1, 1); margin-left: calc(100% - 32px);}
99% { transform: scale(-1, 1); margin-left: 0;}
}
}
&.nyancat {
height: 40px;
width: 100px;
background-image: url('../assets/spinners/nyancat.gif');
animation-name: back-and-forth-nyancat;
@keyframes back-and-forth-nyancat {
0%, 100% { transform: scale(1, 1); margin-left: 0; }
49% { transform: scale(1, 1); margin-left: calc(100% - 100px);}
50% { transform: scale(-1, 1); margin-left: calc(100% - 100px);}
99% { transform: scale(-1, 1); margin-left: 0;}
}
}
}
</style>

View file

@ -0,0 +1,65 @@
<template>
<b-list-group
v-bind="$attrs" ref="self"
flush :class="{ 'fixed-height': fixedHeight, 'bordered': bordered }"
>
<b-list-group-item v-for="({ color, text }, i) in messages" :key="i">
<span class="status" :class="'bg-' + color" />
<span v-html="text" />
</b-list-group-item>
</b-list-group>
</template>
<script>
export default {
name: 'MessageListGroup',
props: {
messages: { type: Array, required: true },
fixedHeight: { type: Boolean, default: false },
bordered: { type: Boolean, default: false },
autoScroll: { type: Boolean, default: false }
},
methods: {
scrollToEnd () {
this.$nextTick(() => {
const container = this.$refs.self
container.scrollTo(0, container.lastElementChild.offsetTop)
})
}
},
created () {
if (this.autoScroll) {
this.$watch('messages', this.scrollToEnd)
}
}
}
</script>
<style lang="scss" scoped>
.fixed-height {
max-height: 20vh;
overflow-y: auto;
}
.bordered {
border: $card-border-width solid $card-border-color;
@include border-radius($card-border-radius);
}
.list-group-item {
font-size: $font-size-sm;
padding: $tooltip-padding-y $tooltip-padding-x;
padding-left: 1rem;
}
.status {
position: absolute;
width: .4rem;
height: 100%;
top: 0;
left: 0;
}
</style>

View file

@ -0,0 +1,136 @@
<template>
<div class="query-header w-100" v-on="$listeners" v-bind="$attrs">
<!-- STATUS -->
<span class="status" :class="['bg-' + color, statusSize]" :aria-label="$t('api.query_status.' + request.status)" />
<!-- REQUEST DESCRIPTION -->
<strong class="request-desc">
{{ request.uri | readableUri }}
<small>({{ $t('history.methods.' + request.method) }})</small>
</strong>
<div v-if="request.errors || request.warnings">
<!-- WEBSOCKET ERRORS COUNT -->
<span class="count" v-if="request.errors">
{{ request.errors }}<icon iname="bug" class="text-danger ml-1" />
</span>
<!-- WEBSOCKET WARNINGS COUNT -->
<span class="count" v-if="request.warnings">
{{ request.warnings }}<icon iname="warning" class="text-warning ml-1" />
</span>
</div>
<!-- VIEW ERROR BUTTON -->
<b-button
v-if="showError && request.error"
size="sm" pill
class="error-btn ml-auto py-0"
variant="danger"
@click="reviewError"
>
<small v-t="'api_error.view_error'" />
</b-button>
<!-- TIME DISPLAY -->
<time v-if="showTime" :datetime="request.date | hour" :class="request.error ? 'ml-2' : 'ml-auto'">
{{ request.date | hour }}
</time>
</div>
</template>
<script>
export default {
name: 'QueryHeader',
props: {
request: { type: Object, required: true },
statusSize: { type: String, default: '' },
showTime: { type: Boolean, default: false },
showError: { type: Boolean, default: false }
},
computed: {
color () {
const statuses = {
pending: 'primary',
success: 'success',
warning: 'warning',
error: 'danger'
}
return statuses[this.request.status]
},
errorsCount () {
return this.request.messages.filter(({ type }) => type === 'danger').length
},
warningsCount () {
return this.request.messages.filter(({ type }) => type === 'warning').length
}
},
methods: {
reviewError () {
this.$store.dispatch('REVIEW_ERROR', this.request)
}
},
filters: {
readableUri (uri) {
return uri.split('?')[0].split('/').join(' > ') // replace('/', ' > ')
},
hour (date) {
return new Date(date).toLocaleTimeString()
}
}
}
</script>
<style lang="scss" scoped>
div {
display: flex;
align-items: center;
font-size: $font-size-sm;
}
.error-btn {
height: 1.25rem;
display: flex;
align-items: center;
min-width: 70px;
}
.status {
display: inline-block;
border-radius: 50%;
width: .75rem;
min-width: .75rem;
height: .75rem;
margin-right: .25rem;
&.lg {
width: 1rem;
height: 1rem;
margin-right: .5rem;
}
}
time {
min-width: 3.5rem;
text-align: right;
}
.count {
display: flex;
align-items: center;
margin-left: .5rem;
}
@include media-breakpoint-down(xs) {
.xs-hide .request-desc {
display: none;
}
}
</style>

View file

@ -1,237 +0,0 @@
<template>
<div id="console">
<b-list-group>
<!-- HISTORY BAR -->
<b-list-group-item
class="d-flex align-items-center"
:class="{ 'bg-best text-white': open }"
ref="history-button"
role="button" tabindex="0"
:aria-expanded="open ? 'true' : 'false'" aria-controls="console-collapse"
@mousedown.left.prevent="onHistoryBarClick"
@keyup.enter.space.prevent="open = !open"
>
<h6 class="m-0">
<icon iname="history" /> {{ $t('history.title') }}
</h6>
<div class="ml-auto">
<!-- LAST ACTION -->
<small v-if="lastAction">
<u v-t="'history.last_action'" />
{{ lastAction.uri | readableUri }} ({{ $t('history.methods.' + lastAction.method) }})
</small>
</div>
</b-list-group-item>
<!-- ACTION LIST -->
<b-collapse id="console-collapse" v-model="open">
<b-list-group-item
id="history" ref="history"
class="p-0" :class="{ 'show-last': openedByWaiting }"
>
<!-- ACTION -->
<b-list-group v-for="(action, i) in history" :key="i" flush>
<!-- ACTION DESC -->
<b-list-group-item class="sticky-top d-flex align-items-center">
<div>
<strong>{{ $t('action') }}:</strong>
{{ action.uri | readableUri }}
<small>({{ $t('history.methods.' + action.method) }})</small>
</div>
<time :datetime="action.date | hour" class="ml-auto">{{ action.date | hour }}</time>
</b-list-group-item>
<!-- ACTION MESSAGE -->
<b-list-group-item
v-for="({ type, text }, j) in action.messages" :key="j"
:variant="type" v-html="text"
/>
</b-list-group>
</b-list-group-item>
</b-collapse>
</b-list-group>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
name: 'YnhConsole',
props: {
value: { type: Boolean, default: false },
height: { type: [Number, String], default: 30 }
},
data () {
return {
open: false,
openedByWaiting: false
}
},
watch: {
open (value) {
// In case it is needed.
this.$emit('toggle', value)
if (value) {
// Wait for DOM update and scroll if needed.
this.$nextTick().then(this.scrollToLastAction)
}
},
'lastAction.messages' () {
if (!this.open) return
this.$nextTick(this.scrollToLastAction)
},
waiting (waiting) {
if (waiting && !this.open) {
// Open the history while waiting for the server's response to display WebSocket messages.
this.open = true
this.openedByWaiting = true
const history = this.$refs.history
this.$nextTick().then(() => {
history.style.height = ''
history.classList.add('with-max')
})
} else if (!waiting && this.openedByWaiting) {
// Automaticly close the history if it was not opened before the request
setTimeout(() => {
// Do not close it if the history was enlarged during the action
if (!history.style || history.style.height === '') {
this.open = false
}
this.openedByWaiting = false
}, 500)
}
}
},
computed: mapGetters(['history', 'lastAction', 'waiting']),
methods: {
scrollToLastAction () {
const historyElem = this.$refs.history
const lastActionGroup = historyElem.lastElementChild
if (lastActionGroup) {
const lastItem = lastActionGroup.lastElementChild || lastActionGroup
historyElem.scrollTop = lastItem.offsetTop
}
},
onHistoryBarClick (e) {
const history = this.$refs.history
let mousePos = e.clientY
const onMouseMove = ({ clientY }) => {
if (!this.open) {
history.style.height = '0px'
this.open = true
}
const currentHeight = history.offsetHeight
const move = mousePos - clientY
const nextSize = currentHeight + move
if (nextSize < 10 && nextSize < currentHeight) {
// Close the console and reset its size if the user reduce it to less than 10px.
mousePos = e.clientY
history.style.height = ''
onMouseUp()
} else {
history.style.height = nextSize + 'px'
// Simulate scroll when reducing the box so the content doesn't move
if (nextSize < currentHeight) {
history.scrollBy(0, -move)
}
mousePos = clientY
}
}
// Delay the mouse move listener to distinguish a click from a drag.
const listenToMouseMove = setTimeout(() => {
history.style.height = history.offsetHeight + 'px'
history.classList.remove('with-max')
window.addEventListener('mousemove', onMouseMove)
}, 200)
const onMouseUp = () => {
// Toggle opening if no mouse movement
if (mousePos === e.clientY) {
// add a max-height class if the box's height is not custom
if (!history.style.height) {
history.classList.add('with-max')
}
this.open = !this.open
}
clearTimeout(listenToMouseMove)
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
}
window.addEventListener('mouseup', onMouseUp)
}
},
filters: {
readableUri (uri) {
return uri.split('?')[0].replace('/', ' > ')
},
hour (date) {
return new Date(date).toLocaleTimeString()
}
}
}
</script>
<style lang="scss" scoped>
#console {
position: sticky;
z-index: 15;
bottom: 0;
margin-left: -1.5rem;
width: calc(100% + 3rem);
@include media-breakpoint-down(xs) {
margin-left: -15px;
width: calc(100% + 30px);
& > .list-group {
border-radius: 0;
}
}
}
#console-collapse {
// disable collapse animation
transition: none !important;
}
#history {
overflow-y: auto;
&.with-max {
max-height: 30vh;
}
// Used to display only the last message of the last action while an action is triggered
// and console was not opened.
&.with-max.show-last {
& > :not(:last-child) {
display: none;
}
& > :last-child > :not(:last-child) {
display: none;
}
}
}
.list-group-item {
font-size: $font-size-sm;
padding: $tooltip-padding-y $tooltip-padding-x;
}
</style>

View file

@ -89,4 +89,7 @@ export default {
margin-left: .5rem; margin-left: .5rem;
} }
} }
.collapse:not(.show) + .card-footer {
display: none;
}
</style> </style>

View file

@ -33,6 +33,7 @@ export default {
props: { props: {
queries: { type: Array, default: null }, queries: { type: Array, default: null },
queriesWait: { type: Boolean, default: false },
skeleton: { type: [String, Array], default: null }, skeleton: { type: [String, Array], default: null },
// Optional prop to take control of the loading value // Optional prop to take control of the loading value
loading: { type: Boolean, default: null } loading: { type: Boolean, default: null }
@ -61,16 +62,11 @@ export default {
this.fallback_loading = true this.fallback_loading = true
} }
const [apiQueries, storeQueries] = this.queries.reduce((types, query) => { api.fetchAll(
types[typeof query === 'string' ? 0 : 1].push(query) this.queries,
return types { wait: this.queriesWait, initial: true }
}, [[], []]) ).then(responses => {
this.$emit('queries-response', ...responses)
Promise.all([
api.getAll(apiQueries),
this.$store.dispatch('FETCH_ALL', storeQueries)
]).then(([apiResponses, storeResponses]) => {
this.$emit('queries-response', ...apiResponses, ...storeResponses)
this.fallback_loading = false this.fallback_loading = false
}) })
} }

View file

@ -1,2 +0,0 @@
export { default as PasswordForm } from './PasswordForm'
export { default as DomainForm } from './DomainForm'

View file

@ -41,7 +41,7 @@ const includes = items => item => helpers.withParams(
item => !helpers.req(item) || (items ? items.includes(item) : false) item => !helpers.req(item) || (items ? items.includes(item) : false)
)(item) )(item)
const name = helpers.regex('name', new RegExp(`^(?:[A-Za-z${nonAsciiWordCharacters}]{2,30}[ ,.'-]{0,3})+$`)) const name = helpers.regex('name', new RegExp(`^(?:[A-Za-z${nonAsciiWordCharacters}]{1,30}[ ,.'-]{0,3})+$`))
const unique = items => item => helpers.withParams( const unique = items => item => helpers.withParams(
{ type: 'unique', arg: items, value: item }, { type: 'unique', arg: items, value: item },

View file

@ -88,7 +88,11 @@ export function formatYunoHostArgument (arg) {
// Checkbox // Checkbox
} else if (arg.type === 'boolean') { } else if (arg.type === 'boolean') {
field.component = 'CheckboxItem' field.component = 'CheckboxItem'
if (typeof arg.default === 'number') {
value = arg.default === 1
} else {
value = arg.default || false value = arg.default || false
}
// Special (store related) // Special (store related)
} else if (['user', 'domain'].includes(arg.type)) { } else if (['user', 'domain'].includes(arg.type)) {
field.component = 'SelectItem' field.component = 'SelectItem'
@ -106,8 +110,8 @@ export function formatYunoHostArgument (arg) {
if (field.component !== 'CheckboxItem' && arg.optional !== true) { if (field.component !== 'CheckboxItem' && arg.optional !== true) {
validation.required = validators.required validation.required = validators.required
} }
// Default value // Default value if still `null`
if (arg.default) { if (value === null && arg.default) {
value = arg.default value = arg.default
} }
// Help message // Help message

View file

@ -13,19 +13,33 @@
}, },
"administration_password": "Administration password", "administration_password": "Administration password",
"all": "All", "all": "All",
"api": {
"processing": "The server is processing the action...",
"query_status": {
"error": "Unsuccessful",
"pending": "In progress",
"success": "Successfully completed",
"warning": "Successfully completed with errors or alerts"
}
},
"api_error": { "api_error": {
"error_message": "Error message:",
"help": "You should look for help on <a href=\"https://forum.yunohost.org/\">the forum</a> or <a href=\"https://chat.yunohost.org/\">the chat</a> to fix the situation, or report the bug on <a href=\"https://github.com/YunoHost/issues\">the bugtracker</a>.", "help": "You should look for help on <a href=\"https://forum.yunohost.org/\">the forum</a> or <a href=\"https://chat.yunohost.org/\">the chat</a> to fix the situation, or report the bug on <a href=\"https://github.com/YunoHost/issues\">the bugtracker</a>.",
"info": "The following information might be useful for the person helping you:", "info": "The following information might be useful for the person helping you:",
"sorry": "Really sorry about that." "server_said": "While processing the action the server said:",
"sorry": "Really sorry about that.",
"view_error": "View error"
}, },
"api_errors_titles": { "api_errors_titles": {
"APIError": "Yunohost encountered an unexpected error", "APIError": "Yunohost encountered an unexpected error",
"APIBadRequestError": "Yunohost encountered an error", "APIBadRequestError": "Yunohost encountered an error",
"APIInternalError": "Yunohost encountered an internal error", "APIInternalError": "Yunohost encountered an internal error",
"APINotFoundError": "Yunohost API could not find a route",
"APINotRespondingError": "Yunohost API is not responding", "APINotRespondingError": "Yunohost API is not responding",
"APIConnexionError": "Yunohost encountered an connexion error" "APIConnexionError": "Yunohost encountered a connexion error"
}, },
"all_apps": "All apps", "all_apps": "All apps",
"api_not_found": "Seems like the web-admin tryed to query something that doesn't exist.",
"api_not_responding": "The YunoHost API is not responding. Maybe 'yunohost-api' is down or got restarted?", "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...", "api_waiting": "Waiting for the server's response...",
"app_actions": "Actions", "app_actions": "Actions",
@ -168,6 +182,7 @@
}, },
"form_input_example": "Example: {example}", "form_input_example": "Example: {example}",
"from_to": "from {0} to {1}", "from_to": "from {0} to {1}",
"go_back": "Go back",
"good_practices_about_admin_password": "You are now about to define a new admin password. The password should be at least 8 characters - though it is good practice to use longer password (i.e. a passphrase) and/or to use various kind of characters (uppercase, lowercase, digits and special characters).", "good_practices_about_admin_password": "You are now about to define a new admin password. The password should be at least 8 characters - though it is good practice to use longer password (i.e. a passphrase) and/or to use various kind of characters (uppercase, lowercase, digits and special characters).",
"good_practices_about_user_password": "You are now about to define a new user password. The password should be at least 8 characters - though it is good practice to use longer password (i.e. a passphrase) and/or to use various kind of characters (uppercase, lowercase, digits and special characters).", "good_practices_about_user_password": "You are now about to define a new user password. The password should be at least 8 characters - though it is good practice to use longer password (i.e. a passphrase) and/or to use various kind of characters (uppercase, lowercase, digits and special characters).",
"group": "Group", "group": "Group",
@ -189,9 +204,10 @@
"title": "History", "title": "History",
"last_action": "Last action:", "last_action": "Last action:",
"methods": { "methods": {
"DELETE": "delete",
"GET": "read",
"POST": "create/execute", "POST": "create/execute",
"PUT": "modify", "PUT": "modify"
"DELETE": "delete"
} }
}, },
"home": "Home", "home": "Home",
@ -398,7 +414,8 @@
"warnings": "{count} warnings", "warnings": "{count} warnings",
"words": { "words": {
"collapse": "Collapse", "collapse": "Collapse",
"default": "Default" "default": "Default",
"dismiss": "Dismiss"
}, },
"wrong_password": "Wrong password", "wrong_password": "Wrong password",
"yes": "Yes", "yes": "Yes",

View file

@ -6,8 +6,12 @@ import i18n from './i18n'
import router from './router' import router from './router'
import store from './store' import store from './store'
import { registerGlobalErrorHandlers } from './api'
Vue.config.productionTip = false Vue.config.productionTip = false
// Styles are imported in `src/App.vue` <style> // Styles are imported in `src/App.vue` <style>
Vue.use(BootstrapVue, { Vue.use(BootstrapVue, {
BSkeleton: { animation: 'none' }, BSkeleton: { animation: 'none' },
@ -20,6 +24,7 @@ Vue.use(BootstrapVue, {
} }
}) })
// Ugly wrapper for `$bvModal.msgBoxConfirm` to set default i18n button titles // Ugly wrapper for `$bvModal.msgBoxConfirm` to set default i18n button titles
// FIXME find or wait for a better way // FIXME find or wait for a better way
Vue.prototype.$askConfirmation = function (message, props) { Vue.prototype.$askConfirmation = function (message, props) {
@ -30,6 +35,7 @@ Vue.prototype.$askConfirmation = function (message, props) {
}) })
} }
// Register global components // Register global components
const requireComponent = require.context('@/components/globals', true, /\.(js|vue)$/i) const requireComponent = require.context('@/components/globals', true, /\.(js|vue)$/i)
// For each matching file name... // For each matching file name...
@ -40,6 +46,10 @@ requireComponent.keys().forEach((fileName) => {
Vue.component(component.name, component) Vue.component(component.name, component)
}) })
registerGlobalErrorHandlers()
new Vue({ new Vue({
i18n, i18n,
router, router,

View file

@ -28,6 +28,9 @@ const router = new VueRouter({
}) })
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {
if (store.getters.error) {
store.dispatch('DISMISS_ERROR', true)
}
// Allow if connected or route is not protected // Allow if connected or route is not protected
if (store.getters.connected || to.meta.noAuth) { if (store.getters.connected || to.meta.noAuth) {
next() next()

View file

@ -10,7 +10,6 @@
import Home from '@/views/Home' import Home from '@/views/Home'
import Login from '@/views/Login' import Login from '@/views/Login'
import ErrorPage from '@/views/ErrorPage'
import ToolList from '@/views/tool/ToolList' import ToolList from '@/views/tool/ToolList'
const routes = [ const routes = [
@ -29,18 +28,6 @@ const routes = [
meta: { noAuth: true, breadcrumb: [] } meta: { noAuth: true, breadcrumb: [] }
}, },
/*
ERROR
*/
{
name: 'error',
path: '/error/:type',
component: ErrorPage,
props: true,
// Leave the breadcrumb
meta: { noAuth: true, breadcrumb: [] }
},
/* /*
POST INSTALL POST INSTALL
*/ */

View file

@ -90,40 +90,21 @@ export default {
}, },
actions: { actions: {
'FETCH' ({ state, commit, rootState }, { uri, param, storeKey = uri, cache = rootState.cache }) { 'GET' ({ state, commit, rootState }, { uri, param, storeKey = uri, options = {} }) {
const noCache = !rootState.cache || options.noCache || false
const currentState = param ? state[storeKey][param] : state[storeKey] const currentState = param ? state[storeKey][param] : state[storeKey]
// if data has already been queried, simply return // if data has already been queried, simply return
if (currentState !== undefined && cache) return currentState if (currentState !== undefined && !noCache) return currentState
return api.get(param ? `${uri}/${param}` : uri).then(responseData => { return api.fetch('GET', param ? `${uri}/${param}` : uri, null, options).then(responseData => {
const data = responseData[storeKey] ? responseData[storeKey] : responseData const data = responseData[storeKey] ? responseData[storeKey] : responseData
commit('SET_' + storeKey.toUpperCase(), param ? [param, data] : data) commit('SET_' + storeKey.toUpperCase(), param ? [param, data] : data)
return param ? state[storeKey][param] : state[storeKey] return param ? state[storeKey][param] : state[storeKey]
}) })
}, },
'FETCH_ALL' ({ state, commit, rootState }, queries) { 'POST' ({ state, commit }, { uri, storeKey = uri, data, options }) {
return Promise.all(queries.map(({ uri, param, storeKey = uri, cache = rootState.cache }) => { return api.fetch('POST', uri, data, options).then(responseData => {
const currentState = param ? state[storeKey][param] : state[storeKey]
// if data has already been queried, simply return the state as cached
if (currentState !== undefined && cache) {
return { cached: currentState }
}
return api.get(param ? `${uri}/${param}` : uri).then(responseData => {
return { storeKey, param, responseData }
})
})).then(responsesData => {
return responsesData.map(({ storeKey, param, responseData, cached = undefined }) => {
if (cached !== undefined) return cached
const data = responseData[storeKey] ? responseData[storeKey] : responseData
commit('SET_' + storeKey.toUpperCase(), param ? [param, data] : data)
return param ? state[storeKey][param] : state[storeKey]
})
})
},
'POST' ({ state, commit }, { uri, data, storeKey = uri }) {
return api.post(uri, data).then(responseData => {
// FIXME api/domains returns null // FIXME api/domains returns null
if (responseData === null) responseData = data if (responseData === null) responseData = data
responseData = responseData[storeKey] ? responseData[storeKey] : responseData responseData = responseData[storeKey] ? responseData[storeKey] : responseData
@ -132,16 +113,16 @@ export default {
}) })
}, },
'PUT' ({ state, commit }, { uri, param, data, storeKey = uri }) { 'PUT' ({ state, commit }, { uri, param, storeKey = uri, data, options }) {
return api.put(param ? `${uri}/${param}` : uri, data).then(responseData => { return api.fetch('PUT', param ? `${uri}/${param}` : uri, data, options).then(responseData => {
const data = responseData[storeKey] ? responseData[storeKey] : responseData const data = responseData[storeKey] ? responseData[storeKey] : responseData
commit('UPDATE_' + storeKey.toUpperCase(), param ? [param, data] : data) commit('UPDATE_' + storeKey.toUpperCase(), param ? [param, data] : data)
return param ? state[storeKey][param] : state[storeKey] return param ? state[storeKey][param] : state[storeKey]
}) })
}, },
'DELETE' ({ commit }, { uri, param, data = {}, storeKey = uri }) { 'DELETE' ({ commit }, { uri, param, storeKey = uri, data, options }) {
return api.delete(param ? `${uri}/${param}` : uri, data).then(() => { return api.fetch('DELETE', param ? `${uri}/${param}` : uri, data, options).then(() => {
commit('DEL_' + storeKey.toUpperCase(), param) commit('DEL_' + storeKey.toUpperCase(), param)
}) })
} }
@ -164,8 +145,7 @@ export default {
}) })
}, },
// not cached user: state => name => state.users_details[name], // not cached
user: state => name => state.users_details[name],
domains: state => state.domains, domains: state => state.domains,

View file

@ -5,91 +5,70 @@ import { timeout } from '@/helpers/commons'
export default { export default {
state: { state: {
host: window.location.host, host: window.location.host, // String
connected: localStorage.getItem('connected') === 'true', connected: localStorage.getItem('connected') === 'true', // Boolean
yunohost: null, // yunohost app infos: Object {version, repo} yunohost: null, // Object { version, repo }
error: null, waiting: false, // Boolean
waiting: false, history: [], // Array of `request`
history: [] requests: [], // Array of `request`
error: null // null || request
}, },
mutations: { mutations: {
'SET_CONNECTED' (state, connected) { 'SET_CONNECTED' (state, boolean) {
localStorage.setItem('connected', connected) localStorage.setItem('connected', boolean)
state.connected = connected state.connected = boolean
}, },
'SET_YUNOHOST_INFOS' (state, yunohost) { 'SET_YUNOHOST_INFOS' (state, yunohost) {
state.yunohost = yunohost state.yunohost = yunohost
}, },
'UPDATE_WAITING' (state, boolean) { 'SET_WAITING' (state, boolean) {
state.waiting = boolean state.waiting = boolean
}, },
'ADD_HISTORY_ENTRY' (state, [uri, method, date]) { 'ADD_REQUEST' (state, request) {
state.history.push({ uri, method, date, messages: [] }) if (state.requests.length > 10) {
// We do not remove requests right after it resolves since an error might bring
// one back to life but we can safely remove some here.
state.requests.shift()
}
state.requests.push(request)
}, },
'ADD_MESSAGE' (state, message) { 'UPDATE_REQUEST' (state, { request, key, value }) {
state.history[state.history.length - 1].messages.push(message) // This rely on data persistance and reactivity.
Vue.set(request, key, value)
}, },
'UPDATE_PROGRESS' (state, progress) { 'REMOVE_REQUEST' (state, request) {
Vue.set(state.history[state.history.length - 1], 'progress', progress) const index = state.requests.lastIndexOf(request)
state.requests.splice(index, 1)
}, },
'SET_ERROR' (state, error) { 'ADD_HISTORY_ACTION' (state, request) {
state.error = error state.history.push(request)
},
'ADD_MESSAGE' (state, { message, type }) {
const request = state.history[state.history.length - 1]
request.messages.push(message)
if (['error', 'warning'].includes(type)) {
request[type + 's']++
}
},
'SET_ERROR' (state, request) {
if (request) {
state.error = request
} else {
state.error = null
}
} }
}, },
actions: { actions: {
'LOGIN' ({ dispatch }, password) {
// Entering a wrong password will trigger a 401 api response.
// action `DISCONNECT` will then be triggered by the response handler but will not
// redirect to `/login` so the view can display the catched error.
return api.post('login', { password }).then(() => {
dispatch('CONNECT')
})
},
'LOGOUT' ({ dispatch }) {
return api.get('logout').then(() => {
dispatch('DISCONNECT')
})
},
'RESET_CONNECTED' ({ commit }) {
commit('SET_CONNECTED', false)
commit('SET_YUNOHOST_INFOS', null)
},
'DISCONNECT' ({ dispatch, commit }, route) {
dispatch('RESET_CONNECTED')
commit('UPDATE_WAITING', false)
if (router.currentRoute.name === 'login') return
router.push({
name: 'login',
// Add a redirect query if next route is not unknown (like `logout`) or `login`
query: route && !['login', null].includes(route.name)
? { redirect: route.path }
: {}
})
},
'CONNECT' ({ commit, dispatch }) {
commit('SET_CONNECTED', true)
dispatch('GET_YUNOHOST_INFOS')
router.push(router.currentRoute.query.redirect || { name: 'home' })
},
'GET_YUNOHOST_INFOS' ({ commit }) {
return api.get('versions').then(versions => {
commit('SET_YUNOHOST_INFOS', versions.yunohost)
})
},
'CHECK_INSTALL' ({ dispatch }, retry = 2) { 'CHECK_INSTALL' ({ dispatch }, retry = 2) {
// this action will try to query the `/installed` route 3 times every 5 s with // this action will try to query the `/installed` route 3 times every 5 s with
// a timeout of the same delay. // a timeout of the same delay.
@ -104,24 +83,85 @@ export default {
}) })
}, },
'WAITING_FOR_RESPONSE' ({ commit }, [uri, method]) { 'CONNECT' ({ commit, dispatch }) {
commit('UPDATE_WAITING', true) commit('SET_CONNECTED', true)
commit('ADD_HISTORY_ENTRY', [uri, method, Date.now()]) dispatch('GET_YUNOHOST_INFOS')
router.push(router.currentRoute.query.redirect || { name: 'home' })
}, },
'SERVER_RESPONDED' ({ state, dispatch, commit }, responseIsOk) { 'RESET_CONNECTED' ({ commit }) {
if (responseIsOk) { commit('SET_CONNECTED', false)
commit('UPDATE_WAITING', false) commit('SET_YUNOHOST_INFOS', null)
commit('SET_ERROR', '') },
'DISCONNECT' ({ dispatch }, route = router.currentRoute) {
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`
query: route && !['login', null].includes(route.name)
? { redirect: route.path }
: {}
})
},
'LOGIN' ({ dispatch }, password) {
return api.post('login', { password }, { websocket: false }).then(() => {
dispatch('CONNECT')
})
},
'LOGOUT' ({ dispatch }) {
dispatch('DISCONNECT')
return api.get('logout')
},
'GET_YUNOHOST_INFOS' ({ commit }) {
return api.get('versions').then(versions => {
commit('SET_YUNOHOST_INFOS', versions.yunohost)
})
},
'INIT_REQUEST' ({ commit }, { method, uri, initial, wait, websocket }) {
let request = { method, uri, initial, status: 'pending' }
if (websocket) {
request = { ...request, messages: [], date: Date.now(), warnings: 0, errors: 0 }
commit('ADD_HISTORY_ACTION', request)
}
commit('ADD_REQUEST', request)
if (wait) {
setTimeout(() => {
// Display the waiting modal only if the request takes some time.
if (request.status === 'pending') {
commit('SET_WAITING', true)
}
}, 400)
}
return request
},
'END_REQUEST' ({ commit }, { request, success, wait }) {
let status = success ? 'success' : 'error'
if (success && (request.warnings || request.errors)) {
status = 'warning'
}
commit('UPDATE_REQUEST', { request, key: 'status', value: status })
if (wait) {
// Remove the overlay after a short delay to allow an error to display withtout flickering.
setTimeout(() => {
commit('SET_WAITING', false)
}, 100)
} }
}, },
'DISPATCH_MESSAGE' ({ commit }, messages) { 'DISPATCH_MESSAGE' ({ commit }, { request, messages }) {
const typeToColor = { error: 'danger' }
for (const type in messages) { for (const type in messages) {
const message = { const message = {
text: messages[type], text: messages[type],
type: type in typeToColor ? typeToColor[type] : type color: type === 'error' ? 'danger' : type
} }
let progressBar = message.text.match(/^\[#*\+*\.*\] > /) let progressBar = message.text.match(/^\[#*\+*\.*\] > /)
if (progressBar) { if (progressBar) {
@ -131,30 +171,63 @@ export default {
for (const char of progressBar) { for (const char of progressBar) {
if (char in progress) progress[char] += 1 if (char in progress) progress[char] += 1
} }
commit('UPDATE_PROGRESS', Object.values(progress)) commit('UPDATE_REQUEST', { request, key: 'progress', value: Object.values(progress) })
} }
if (message.text) { if (message.text) {
commit('ADD_MESSAGE', message) commit('ADD_MESSAGE', { request, message, type })
} }
} }
}, },
'DISPATCH_ERROR' ({ state, commit }, error) { 'HANDLE_ERROR' ({ commit, dispatch }, error) {
commit('SET_ERROR', error) if (error.code === 401) {
if (error.method === 'GET') { // Unauthorized
router.push({ name: 'error', params: { type: error.code } }) dispatch('DISCONNECT')
} else if (error.logRef) {
// Errors that have produced logs
router.push({ name: 'tool-log', params: { name: error.logRef } })
} else {
// The request is temporarely stored in the error for reference, but we reverse
// the ownership to stay generic.
const request = error.request
delete error.request
Vue.set(request, 'error', error)
// Display the error in a modal on the current view.
commit('SET_ERROR', request)
} }
// else the waiting screen will display the error },
'REVIEW_ERROR' ({ commit }, request) {
request.review = true
commit('SET_ERROR', request)
},
'DISMISS_ERROR' ({ commit, state }, { initial, review = false }) {
if (initial && !review) {
// In case of an initial request (data that is needed by a view to render itself),
// try to go back so the user doesn't get stuck at a never ending skeleton view.
if (history.length > 2) {
history.back()
} else {
// if the url was opened in a new tab, return to home
router.push({ name: 'home' })
}
}
commit('SET_ERROR', null)
} }
}, },
getters: { getters: {
host: state => state.host, host: state => state.host,
connected: state => (state.connected), connected: state => state.connected,
yunohost: state => (state.yunohost), yunohost: state => state.yunohost,
error: state => state.error, error: state => state.error,
waiting: state => state.waiting, waiting: state => state.waiting,
history: state => state.history, history: state => state.history,
lastAction: state => state.history[state.history.length - 1] lastAction: state => state.history[state.history.length - 1],
currentRequest: state => {
const request = state.requests.find(({ status }) => status === 'pending')
return request || state.requests[state.requests.length - 1]
}
} }
} }

View file

@ -1,38 +0,0 @@
<template>
<div class="error mt-4 mb-5" v-if="error">
<h2>{{ $t('api_errors_titles.' + error.name) }} :/</h2>
<em v-t="'api_error.sorry'" />
<div class="alert alert-info mt-4">
<span v-html="$t('api_error.help')" />
<br>{{ $t('api_error.info') }}
</div>
<h5 v-t="'error'" />
<pre><code>"{{ error.code }}" {{ error.status }}</code></pre>
<h5 v-t="'action'" />
<pre><code>"{{ error.method }}" {{ error.uri }}</code></pre>
<h5>Message</h5>
<p v-html="error.message" />
<template v-if="error.traceback">
<h5 v-t="'traceback'" />
<pre><code class="text-dark">{{ error.traceback }}</code></pre>
</template>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
name: 'ErrorPage',
computed: mapGetters(['error'])
// FIXME add redirect if they're no error (if reload or route entered by hand)
}
</script>

View file

@ -1,11 +1,5 @@
<template> <template>
<div class="login">
<b-alert v-if="apiError" variant="danger">
<icon iname="exclamation-triangle" /> {{ $t(apiError) }}
</b-alert>
<b-form @submit.prevent="login"> <b-form @submit.prevent="login">
<!-- FIXME add hidden domain input ? -->
<b-input-group> <b-input-group>
<template v-slot:prepend> <template v-slot:prepend>
<b-input-group-text> <b-input-group-text>
@ -13,23 +7,25 @@
<icon iname="lock" class="sm" /> <icon iname="lock" class="sm" />
</b-input-group-text> </b-input-group-text>
</template> </template>
<b-form-input <b-form-input
id="input-password" id="input-password"
required type="password" required type="password"
v-model="password" :disabled="disabled" v-model="password"
:placeholder="$t('administration_password')" :state="isValid" :placeholder="$t('administration_password')" :state="isValid"
/> />
<template v-slot:append> <template v-slot:append>
<b-button type="submit" variant="success" :disabled="disabled"> <b-button type="submit" variant="success" :disabled="disabled">
{{ $t('login') }} {{ $t('login') }}
</b-button> </b-button>
</template> </template>
</b-input-group> </b-input-group>
<b-form-invalid-feedback :state="isValid"> <b-form-invalid-feedback :state="isValid">
{{ $t('wrong_password') }} {{ $t('wrong_password') }}
</b-form-invalid-feedback> </b-form-invalid-feedback>
</b-form> </b-form>
</div>
</template> </template>
<script> <script>
@ -38,7 +34,7 @@ export default {
data () { data () {
return { return {
disabled: false, disabled: true,
password: '', password: '',
isValid: null, isValid: null,
apiError: undefined apiError: undefined
@ -47,7 +43,8 @@ export default {
methods: { methods: {
login () { login () {
this.$store.dispatch('LOGIN', this.password).catch(() => { this.$store.dispatch('LOGIN', this.password).catch(err => {
if (err.name !== 'APIUnauthorizedError') throw err
this.isValid = false this.isValid = false
}) })
} }
@ -60,8 +57,6 @@ export default {
} else { } else {
this.$router.push({ name: 'post-install' }) this.$router.push({ name: 'post-install' })
} }
}).catch(err => {
this.apiError = err.message
}) })
} }
} }

View file

@ -48,28 +48,25 @@
<p class="alert alert-success"> <p class="alert alert-success">
<icon iname="thumbs-up" /> {{ $t('installation_complete') }} <icon iname="thumbs-up" /> {{ $t('installation_complete') }}
</p> </p>
<login-view /> <login />
</template> </template>
<!-- CONFIRM POST-INSTALL MODAL -->
<b-modal
ref="post-install-modal" id="post-install-modal" centered
body-bg-variant="danger" body-text-variant="light"
@ok="performPostInstall" hide-header
>
{{ $t('confirm_postinstall', { domain }) }}
</b-modal>
</div> </div>
</template> </template>
<script> <script>
import api from '@/api' import api from '@/api'
import { DomainForm, PasswordForm } from '@/components/reusableForms' import { DomainForm, PasswordForm } from '@/views/_partials'
import LoginView from '@/views/Login' import Login from '@/views/Login'
export default { export default {
name: 'PostInstall', name: 'PostInstall',
components: {
DomainForm,
PasswordForm,
Login
},
data () { data () {
return { return {
step: 'start', step: 'start',
@ -84,9 +81,13 @@ export default {
this.step = 'password' this.step = 'password'
}, },
setPassword ({ password }) { async setPassword ({ password }) {
this.password = password this.password = password
this.$refs['post-install-modal'].show() const confirmed = await this.$askConfirmation(
this.$i18n.t('confirm_postinstall', { domain: this.domain })
)
if (!confirmed) return
this.performPostInstall()
}, },
performPostInstall () { performPostInstall () {
@ -104,12 +105,6 @@ export default {
this.$router.push({ name: 'home' }) this.$router.push({ name: 'home' })
} }
}) })
},
components: {
DomainForm,
PasswordForm,
LoginView
} }
} }
</script> </script>

View file

@ -0,0 +1,89 @@
<template>
<!-- This card receives style from `ViewLockOverlay` if used inside it -->
<div>
<b-card-body>
<b-card-title v-t="'api_errors_titles.' + error.name" />
<em v-t="'api_error.sorry'" />
<div class="alert alert-info my-3">
<span v-html="$t('api_error.help')" />
<br>{{ $t('api_error.info') }}
</div>
<!-- FIXME USE DD DL DT -->
<p class="m-0">
<strong v-t="'error'" />: <code>"{{ error.code }}" {{ error.status }}</code>
</p>
<p>
<strong v-t="'action'" />: <code>"{{ error.method }}" {{ error.path }}</code>
</p>
<p>
<strong v-t="'api_error.error_message'" /> <span v-html="error.message" />
</p>
<template v-if="error.traceback">
<p>
<strong v-t="'traceback'" />
</p>
<pre><code>{{ error.traceback }}</code></pre>
</template>
<template v-if="messages">
<p class="my-2">
<strong v-t="'api_error.server_said'" />
</p>
<message-list-group :messages="messages" bordered />
</template>
</b-card-body>
<b-card-footer footer-bg-variant="danger">
<!-- TODO add copy error ? -->
<b-button
variant="light" size="sm"
v-t="'words.dismiss'" @click="dismiss"
/>
</b-card-footer>
</div>
</template>
<script>
import MessageListGroup from '@/components/MessageListGroup'
export default {
name: 'ErrorDisplay',
components: {
MessageListGroup
},
props: {
request: { type: [Object, null], default: null }
},
computed: {
error () {
return this.request.error
},
messages () {
const messages = this.request.messages
if (messages && messages.length > 0) return messages
return null
}
},
methods: {
dismiss () {
this.$store.dispatch('DISMISS_ERROR', this.request)
}
}
}
</script>
<style lang="scss" scoped>
code, pre code {
color: $black;
}
</style>

View file

@ -0,0 +1,256 @@
<template>
<b-card no-body id="console">
<!-- HISTORY BAR -->
<b-card-header
role="button" tabindex="0"
:aria-expanded="open ? 'true' : 'false'" aria-controls="console-collapse"
header-tag="header" :header-bg-variant="open ? 'best' : 'white'"
:class="{ 'text-white': open }"
class="d-flex align-items-center"
@mousedown.left.prevent="onHistoryBarClick"
@keyup.space.enter.prevent="onHistoryBarKey"
>
<h6 class="m-0">
<icon iname="history" /> <span class="d-none d-sm-inline">{{ $t('history.title') }}</span>
</h6>
<!-- CURRENT/LAST ACTION -->
<b-button
v-if="lastAction"
size="sm" pill
class="ml-auto py-0"
:variant="open ? 'light' : 'best'"
@click.prevent="onLastActionClick"
@keyup.enter.space.prevent="onLastActionClick"
>
<small>{{ $t('history.last_action') }}</small>
</b-button>
<query-header v-if="lastAction" :request="lastAction" class="w-auto ml-2 xs-hide" />
</b-card-header>
<b-collapse id="console-collapse" v-model="open">
<div
class="accordion" role="tablist"
id="history" ref="history"
>
<!-- ACTION LIST -->
<b-card
v-for="(action, i) in history" :key="i"
no-body class="rounded-0 rounded-top border-left-0 border-right-0"
>
<!-- ACTION -->
<b-card-header header-tag="header" header-bg-variant="white" class="sticky-top d-flex">
<!-- ACTION DESC -->
<query-header
role="tab" v-b-toggle="action.messages.length ? 'messages-collapse-' + i : false"
:request="action" show-time show-error
/>
</b-card-header>
<!-- ACTION MESSAGES -->
<b-collapse
v-if="action.messages.length"
:id="'messages-collapse-' + i" accordion="my-accordion"
role="tabpanel"
@shown="scrollToAction(i)"
@hide="scrollToAction(i)"
>
<message-list-group :messages="action.messages" flush />
</b-collapse>
</b-card>
</div>
</b-collapse>
</b-card>
</template>
<script>
import { mapGetters } from 'vuex'
import QueryHeader from '@/components/QueryHeader'
import MessageListGroup from '@/components/MessageListGroup'
export default {
name: 'HistoryConsole',
components: {
QueryHeader,
MessageListGroup
},
props: {
value: { type: Boolean, default: false },
height: { type: [Number, String], default: 30 }
},
data () {
return {
open: false
}
},
computed: {
...mapGetters(['history', 'lastAction', 'waiting', 'error'])
},
methods: {
scrollToAction (actionIndex) {
const actionCard = this.$el.querySelector('#messages-collapse-' + actionIndex).parentElement
const headerOffset = actionCard.firstElementChild.offsetHeight
// Can't use `scrollIntoView()` here since it will also scroll in the main content.
this.$refs.history.scrollTop = actionCard.offsetTop - headerOffset
},
async onLastActionClick () {
if (!this.open) {
this.open = true
await this.$nextTick()
}
const historyElem = this.$refs.history
const lastActionCard = historyElem.lastElementChild
const lastCollapsable = lastActionCard.querySelector('.collapse')
if (lastCollapsable && !lastCollapsable.classList.contains('show')) {
this.$root.$emit('bv::toggle::collapse', lastCollapsable.id)
// `scrollToAction` will be triggered and will handle the scrolling.
} else {
const headerOffset = lastActionCard.firstElementChild.offsetHeight
historyElem.scrollTop = lastActionCard.offsetTop - headerOffset
}
},
onHistoryBarKey (e) {
// FIXME interactive element in another is not valid, need to find another way.
if (e.target.nodeName === 'BUTTON' || e.target.parentElement.nodeName === 'BUTTON') return
this.open = !this.open
},
onHistoryBarClick (e) {
// FIXME interactive element in another is not valid, need to find another way.
if (e.target.nodeName === 'BUTTON' || e.target.parentElement.nodeName === 'BUTTON') return
const historyElem = this.$refs.history
let mousePos = e.clientY
const onMouseMove = ({ clientY }) => {
if (!this.open) {
historyElem.style.height = '0px'
this.open = true
}
const currentHeight = historyElem.offsetHeight
const move = mousePos - clientY
const nextSize = currentHeight + move
if (nextSize < 10 && nextSize < currentHeight) {
// Close the console and reset its size if the user reduce it to less than 10px.
mousePos = e.clientY
historyElem.style.height = ''
onMouseUp()
} else {
historyElem.style.height = nextSize + 'px'
// Simulate scroll when reducing the box so the content doesn't move.
if (nextSize < currentHeight) {
historyElem.scrollBy(0, -move)
}
mousePos = clientY
}
}
// Delay the mouse move listener to distinguish a click from a drag.
const listenToMouseMove = setTimeout(() => {
historyElem.style.height = historyElem.offsetHeight + 'px'
historyElem.classList.add('no-max')
window.addEventListener('mousemove', onMouseMove)
}, 200)
const onMouseUp = () => {
// Toggle opening if no mouse movement.
if (mousePos === e.clientY) {
// remove the free height class if the box's height is not custom
if (!historyElem.style.height) {
historyElem.classList.remove('no-max')
}
this.open = !this.open
}
clearTimeout(listenToMouseMove)
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
}
window.addEventListener('mouseup', onMouseUp)
}
}
}
</script>
<style lang="scss" scoped>
// reset default style
.card + .card {
margin-top: 0;
}
.card-header {
padding: $tooltip-padding-y $tooltip-padding-x;
}
#console {
position: sticky;
z-index: 15;
bottom: 0;
width: calc(100% + 3rem);
margin-left: -1.5rem;
border-bottom: 0;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
font-size: $font-size-sm;
& > header {
cursor: ns-resize;
}
.btn {
height: 1.25rem;
display: flex;
align-items: center;
}
@include media-breakpoint-down(xs) {
margin-left: -15px;
width: calc(100% + 30px);
& > .card-header {
border-radius: 0;
}
}
}
// Hacky disable of collapse animation
.collapsing {
transition: none !important;
height: auto !important;
display: block !important;
position: static !important;
}
#history {
overflow-y: auto;
max-height: 20vh;
&.no-max {
max-height: none;
}
> .card {
// reset bootstrap's `overflow: hidden` that prevent sticky headers to work properly.
overflow: visible;
&:first-of-type {
// hide first top border that conflicts with the console header's bottom border.
margin-top: -1px;
}
}
[aria-controls] {
cursor: pointer;
}
}
</style>

View file

@ -0,0 +1,77 @@
<template>
<b-overlay
variant="white" opacity="0.75"
no-center
:show="waiting || error !== null"
>
<slot name="default" />
<template v-slot:overlay>
<b-card no-body class="card-overlay">
<b-card-header header-bg-variant="white">
<query-header :request="error || currentRequest" status-size="lg" />
</b-card-header>
<component v-if="error" :is="'ErrorDisplay'" :request="error" />
<component v-else :is="'WaitingDisplay'" :request="currentRequest" />
</b-card>
</template>
</b-overlay>
</template>
<script>
import { mapGetters } from 'vuex'
import { ErrorDisplay, WaitingDisplay } from '@/views/_partials'
import QueryHeader from '@/components/QueryHeader'
export default {
name: 'ViewLockOverlay',
components: {
ErrorDisplay,
WaitingDisplay,
QueryHeader
},
computed: mapGetters(['waiting', 'error', 'currentRequest'])
}
</script>
<style lang="scss" scoped>
// Style for `ErrorDisplay` and `WaitingDisplay`'s cards
.card-overlay {
position: sticky;
top: 10vh;
margin: 0 5%;
@include media-breakpoint-up(md) {
margin: 0 10%;
}
@include media-breakpoint-up(lg) {
margin: 0 15%;
}
::v-deep {
.card-body {
padding: 1.5rem;
padding-bottom: 0;
max-height: 60vh;
overflow-y: auto;
& > :last-child {
margin-bottom: 1.5rem;
}
}
.card-footer {
padding: .5rem .75rem;
display: flex;
justify-content: flex-end;
}
}
.card-header {
padding: .5rem .75rem;
}
}
</style>

View file

@ -0,0 +1,107 @@
<template>
<!-- This card receives style from `ViewLockOverlay` if used inside it -->
<b-card-body>
<b-card-title class="text-center mt-4" v-t="hasMessages ? 'api.processing' : 'api_waiting'" />
<!-- PROGRESS BAR -->
<b-progress
v-if="progress" class="mt-4"
:max="progress.max" height=".5rem"
>
<b-progress-bar variant="success" :value="progress.values[0]" />
<b-progress-bar variant="warning" :value="progress.values[1]" animated />
<b-progress-bar variant="secondary" :value="progress.values[2]" striped />
</b-progress>
<!-- OR SPINNER -->
<div v-else class="custom-spinner my-4" :class="spinner" />
<message-list-group
v-if="hasMessages" :messages="request.messages"
bordered fixed-height auto-scroll
/>
</b-card-body>
</template>
<script>
import { mapGetters } from 'vuex'
import MessageListGroup from '@/components/MessageListGroup'
export default {
name: 'WaitingDisplay',
components: {
MessageListGroup
},
props: {
request: { type: Object, required: true }
},
computed: {
...mapGetters(['spinner']),
hasMessages () {
return this.request.messages && this.request.messages.length > 0
},
progress () {
const progress = this.request.progress
if (!progress) return null
return {
values: progress,
max: progress.reduce((sum, value) => (sum + value), 0)
}
}
}
}
</script>
<style lang="scss" scoped>
.custom-spinner {
animation: 4s linear infinite;
background-repeat: no-repeat;
&.pacman {
height: 24px;
width: 24px;
background-image: url('../../assets/spinners/pacman.gif');
animation-name: back-and-forth-pacman;
@keyframes back-and-forth-pacman {
0%, 100% { transform: scale(1); margin-left: 0; }
49% { transform: scale(1); margin-left: calc(100% - 24px);}
50% { transform: scale(-1); margin-left: calc(100% - 24px);}
99% { transform: scale(-1); margin-left: 0;}
}
}
&.magikarp {
height: 32px;
width: 32px;
background-image: url('../../assets/spinners/magikarp.gif');
animation-name: back-and-forth-magikarp;
@keyframes back-and-forth-magikarp {
0%, 100% { transform: scale(1, 1); margin-left: 0; }
49% { transform: scale(1, 1); margin-left: calc(100% - 32px);}
50% { transform: scale(-1, 1); margin-left: calc(100% - 32px);}
99% { transform: scale(-1, 1); margin-left: 0;}
}
}
&.nyancat {
height: 40px;
width: 100px;
background-image: url('../../assets/spinners/nyancat.gif');
animation-name: back-and-forth-nyancat;
@keyframes back-and-forth-nyancat {
0%, 100% { transform: scale(1, 1); margin-left: 0; }
49% { transform: scale(1, 1); margin-left: calc(100% - 100px);}
50% { transform: scale(-1, 1); margin-left: calc(100% - 100px);}
99% { transform: scale(-1, 1); margin-left: 0;}
}
}
}
</style>

View file

@ -0,0 +1,8 @@
export { default as ErrorDisplay } from './ErrorDisplay'
export { default as WaitingDisplay } from './WaitingDisplay'
export { default as HistoryConsole } from './HistoryConsole'
export { default as ViewLockOverlay } from './ViewLockOverlay'
export { default as DomainForm } from './DomainForm'
export { default as PasswordForm } from './PasswordForm'

View file

@ -1,6 +1,6 @@
<template> <template>
<view-base <view-base
:queries="queries" @queries-response="formatAppActions" :queries="queries" @queries-response="onQueriesResponse"
ref="view" skeleton="card-form-skeleton" ref="view" skeleton="card-form-skeleton"
> >
<template v-if="actions" #default> <template v-if="actions" #default>
@ -47,6 +47,8 @@ import { objectToParams } from '@/helpers/commons'
export default { export default {
name: 'AppActions', name: 'AppActions',
mixins: [validationMixin],
props: { props: {
id: { type: String, required: true } id: { type: String, required: true }
}, },
@ -54,10 +56,10 @@ export default {
data () { data () {
return { return {
queries: [ queries: [
`apps/${this.id}/actions`, ['GET', `apps/${this.id}/actions`],
{ uri: 'domains' }, ['GET', { uri: 'domains' }],
{ uri: 'domains/main', storeKey: 'main_domain' }, ['GET', { uri: 'domains/main', storeKey: 'main_domain' }],
{ uri: 'users' } ['GET', { uri: 'users' }]
], ],
actions: undefined actions: undefined
} }
@ -74,7 +76,7 @@ export default {
}, },
methods: { methods: {
formatAppActions (data) { onQueriesResponse (data) {
if (!data.actions) { if (!data.actions) {
this.actions = null this.actions = null
return return
@ -95,17 +97,16 @@ export default {
}, },
performAction (action) { performAction (action) {
// FIXME api expects at least one argument ?! (fake one given with { wut } ) // FIXME api expects at least one argument ?! (fake one given with { dontmindthis } )
const args = objectToParams(action.form ? formatFormData(action.form) : { wut: undefined }) const args = objectToParams(action.form ? formatFormData(action.form) : { dontmindthis: undefined })
api.put(`apps/${this.id}/actions/${action.id}`, { args }).then(response => { api.put(`apps/${this.id}/actions/${action.id}`, { args }).then(response => {
this.$refs.view.fetchQueries() this.$refs.view.fetchQueries()
}).catch(error => { }).catch(err => {
action.serverError = error.message if (err.name !== 'APIBadRequestError') throw err
action.serverError = err.message
}) })
} }
}, }
mixins: [validationMixin]
} }
</script> </script>

View file

@ -1,7 +1,7 @@
<template> <template>
<view-search <view-search
:items="apps" :filtered-items="filteredApps" items-name="apps" :items="apps" :filtered-items="filteredApps" items-name="apps"
:queries="queries" @queries-response="formatAppData" :queries="queries" @queries-response="onQueriesResponse"
> >
<template #top-bar> <template #top-bar>
<div id="view-top-bar"> <div id="view-top-bar">
@ -158,7 +158,9 @@ export default {
data () { data () {
return { return {
queries: ['appscatalog?full&with_categories'], queries: [
['GET', 'appscatalog?full&with_categories']
],
// Data // Data
apps: undefined, apps: undefined,
@ -280,7 +282,7 @@ export default {
return 'danger' return 'danger'
}, },
formatAppData (data) { onQueriesResponse (data) {
// APPS // APPS
const apps = [] const apps = []
for (const key in data.apps) { for (const key in data.apps) {

View file

@ -1,41 +1,30 @@
<template> <template>
<view-base :queries="queries" @queries-response="formatAppConfig" skeleton="card-form-skeleton"> <view-base :queries="queries" @queries-response="onQueriesResponse" skeleton="card-form-skeleton">
<template v-if="panels" #default> <template v-if="panels" #default>
<b-alert variant="warning" class="mb-4"> <b-alert variant="warning" class="mb-4">
<icon iname="exclamation-triangle" /> {{ $t('experimental_warning') }} <icon iname="exclamation-triangle" /> {{ $t('experimental_warning') }}
</b-alert> </b-alert>
<!-- FIXME Rework with components --> <card-form
<b-form id="config-form" @submit.prevent="applyConfig"> v-for="{ name, id: id_, sections, help, serverError } in panels" :key="id_"
<b-card no-body v-for="panel in panels" :key="panel.id"> :title="name" icon="wrench" title-tag="h4"
<b-card-header class="d-flex align-items-center"> :validation="$v.forms[id_]" :id="id_ + '-form'" :server-error="serverError"
<h2>{{ panel.name }} <small v-if="panel.help">{{ panel.help }}</small></h2> collapsable
@submit.prevent="applyConfig(id_)"
>
<template v-if="help" #disclaimer>
<div class="alert alert-info" v-html="help" />
</template>
<div class="ml-auto"> <div v-for="section in sections" :key="section.id" class="mb-5">
<b-button v-b-toggle="[panel.id + '-collapse', panel.id + '-collapse-footer']" size="sm" variant="outline-secondary">
<icon iname="chevron-right" /><span class="sr-only">{{ $t('words.collapse') }}</span>
</b-button>
</div>
</b-card-header>
<b-collapse :id="panel.id + '-collapse'" visible>
<b-card-body v-for="section in panel.sections" :key="section.id">
<b-card-title>{{ section.name }} <small v-if="section.help">{{ section.help }}</small></b-card-title> <b-card-title>{{ section.name }} <small v-if="section.help">{{ section.help }}</small></b-card-title>
<form-item-helper v-for="arg in section.args" :key="arg.name" v-bind="arg" /> <form-field
</b-card-body> v-for="(field, fname) in section.fields" :key="fname" label-cols="0"
</b-collapse> v-bind="field" v-model="forms[id_][fname]" :validation="$v.forms[id_][fname]"
<b-collapse :id="panel.id + '-collapse-footer'" visible>
<b-card-footer>
<b-button
type="submit" form="config-form"
variant="success" class="ml-auto" v-t="'save'"
/> />
</b-card-footer> </div>
</b-collapse> </card-form>
</b-card>
</b-form>
</template> </template>
<!-- if no config panel --> <!-- if no config panel -->
@ -46,14 +35,18 @@
</template> </template>
<script> <script>
import { validationMixin } from 'vuelidate'
// FIXME needs test and rework // FIXME needs test and rework
import api from '@/api' import api from '@/api'
import { formatI18nField, formatYunoHostArgument } from '@/helpers/yunohostArguments' import { formatI18nField, formatYunoHostArguments, formatFormData } from '@/helpers/yunohostArguments'
import { objectToParams } from '@/helpers/commons' import { objectToParams } from '@/helpers/commons'
export default { export default {
name: 'AppConfigPanel', name: 'AppConfigPanel',
mixins: [validationMixin],
props: { props: {
id: { type: String, required: true } id: { type: String, required: true }
}, },
@ -61,57 +54,61 @@ export default {
data () { data () {
return { return {
queries: [ queries: [
`apps/${this.id}/config-panel`, ['GET', `apps/${this.id}/config-panel`],
{ uri: 'domains' }, ['GET', { uri: 'domains' }],
{ uri: 'domains/main', storeKey: 'main_domain' }, ['GET', { uri: 'domains/main', storeKey: 'main_domain' }],
{ uri: 'users' } ['GET', { uri: 'users' }]
], ],
panels: undefined panels: undefined,
forms: undefined,
validations: null
} }
}, },
validations () {
return this.validations
},
methods: { methods: {
formatAppConfig (data) { onQueriesResponse (data) {
if (!data.config_panel || data.config_panel.length === 0) { if (!data.config_panel || data.config_panel.length === 0) {
this.panels = null this.panels = null
return return
} }
const forms = {}
const validations_ = {}
const panels_ = [] const panels_ = []
for (const { id, name, help, sections } of data.config_panel.panel) { for (const { id, name, help, sections } of data.config_panel.panel) {
const panel_ = { id, name, sections: [] } const panel_ = { id, name, sections: [] }
if (help) panel_.help = formatI18nField(help) if (help) panel_.help = formatI18nField(help)
forms[id] = {}
validations_[id] = {}
for (const { name, help, options } of sections) { for (const { name, help, options } of sections) {
const section_ = { name } const section_ = { name }
if (help) section_.help = formatI18nField(help) if (help) section_.help = formatI18nField(help)
section_.args = options.map(option => formatYunoHostArgument(option)) const { form, fields, validations } = formatYunoHostArguments(options)
panel_.sections.push(section_) Object.assign(forms[id], form)
Object.assign(validations_[id], validations)
panel_.sections.push({ name, fields })
} }
panels_.push(panel_) panels_.push(panel_)
} }
this.forms = forms
this.validations = { forms: validations_ }
this.panels = panels_ this.panels = panels_
}, },
applyConfig () { applyConfig (id_) {
// FIXME not tested const args = objectToParams(formatFormData(this.forms[id_]))
const args = {}
for (const panel of this.panels) {
for (const section of panel.sections) {
for (const arg of section.args) {
if (arg.component === 'CheckboxItem') {
args[arg.props.id] = arg.props.value ? 1 : 0
} else {
args[arg.props.id] = arg.props.value
}
}
}
}
// FIXME not tested at all, route is currently broken api.post(`apps/${this.id}/config`, { args }).then(response => {
api.post(`apps/${this.id}/config`, { args: objectToParams(args) }).then(response => {
console.log('SUCCESS', response) console.log('SUCCESS', response)
}).catch(err => { }).catch(err => {
console.log('ERROR', err) if (err.name !== 'APIBadRequestError') throw err
const panel = this.panels.find(({ id }) => id_ === id)
this.$set(panel, 'serverError', err.message)
}) })
} }
} }

View file

@ -1,5 +1,5 @@
<template> <template>
<view-base :queries="queries" @queries-response="formatAppData" ref="view"> <view-base :queries="queries" @queries-response="onQueriesResponse" ref="view">
<!-- BASIC INFOS --> <!-- BASIC INFOS -->
<card v-if="infos" :title="`${$t('infos')} — ${infos.label}`" icon="info-circle"> <card v-if="infos" :title="`${$t('infos')} — ${infos.label}`" icon="info-circle">
<b-row <b-row
@ -172,9 +172,9 @@ export default {
data () { data () {
return { return {
queries: [ queries: [
`apps/${this.id}?full`, ['GET', `apps/${this.id}?full`],
{ uri: 'users/permissions?full', storeKey: 'permissions' }, ['GET', { uri: 'users/permissions?full', storeKey: 'permissions' }],
{ uri: 'domains' } ['GET', { uri: 'domains' }]
], ],
infos: undefined, infos: undefined,
app: undefined, app: undefined,
@ -203,7 +203,7 @@ export default {
}, },
methods: { methods: {
formatAppData (app) { onQueriesResponse (app) {
const form = { labels: [] } const form = { labels: [] }
const mainPermission = app.permissions[this.id + '.main'] const mainPermission = app.permissions[this.id + '.main']
@ -263,7 +263,7 @@ export default {
api.put( api.put(
`apps/${this.id}/changeurl`, `apps/${this.id}/changeurl`,
{ domain, path: '/' + path } { domain, path: '/' + path }
).then(this.fetchData) ).then(this.$refs.view.fetchQueries)
}, },
async setAsDefaultDomain () { async setAsDefaultDomain () {

View file

@ -132,6 +132,7 @@ export default {
api.post('apps', data).then(response => { api.post('apps', data).then(response => {
this.$router.push({ name: 'app-list' }) this.$router.push({ name: 'app-list' })
}).catch(err => { }).catch(err => {
if (err.name !== 'APIBadRequestError') throw err
this.serverError = err.message this.serverError = err.message
}) })
} }
@ -141,10 +142,10 @@ export default {
const isCustom = this.$route.name === 'app-install-custom' const isCustom = this.$route.name === 'app-install-custom'
Promise.all([ Promise.all([
isCustom ? this.getExternalManifest() : this.getApiManifest(), isCustom ? this.getExternalManifest() : this.getApiManifest(),
this.$store.dispatch('FETCH_ALL', [ api.fetchAll([
{ uri: 'domains' }, ['GET', { uri: 'domains' }],
{ uri: 'domains/main', storeKey: 'main_domain' }, ['GET', { uri: 'domains/main', storeKey: 'main_domain' }],
{ uri: 'users' } ['GET', { uri: 'users' }]
]) ])
]).then((responses) => this.formatManifestData(responses[0])) ]).then((responses) => this.formatManifestData(responses[0]))
} }

View file

@ -5,7 +5,7 @@
:items="apps" :items="apps"
:filtered-items="filteredApps" :filtered-items="filteredApps"
:queries="queries" :queries="queries"
@queries-response="formatAppData" @queries-response="onQueriesResponse"
> >
<template #top-bar-buttons> <template #top-bar-buttons>
<b-button variant="success" :to="{ name: 'app-catalog' }"> <b-button variant="success" :to="{ name: 'app-catalog' }">
@ -42,7 +42,9 @@ export default {
data () { data () {
return { return {
queries: ['apps?full'], queries: [
['GET', 'apps?full']
],
search: '', search: '',
apps: undefined apps: undefined
} }
@ -60,7 +62,7 @@ export default {
}, },
methods: { methods: {
formatAppData ({ apps }) { onQueriesResponse ({ apps }) {
if (apps.length === 0) { if (apps.length === 0) {
this.apps = null this.apps = null
return return

View file

@ -1,5 +1,5 @@
<template> <template>
<view-base :queries="queries" @queries-response="formatData" skeleton="card-list-skeleton"> <view-base :queries="queries" @queries-response="onQueriesResponse" skeleton="card-list-skeleton">
<!-- FIXME switch to <card-form> ? --> <!-- FIXME switch to <card-form> ? -->
<card :title="$t('backup_create')" icon="archive" no-body> <card :title="$t('backup_create')" icon="archive" no-body>
<b-form-checkbox-group <b-form-checkbox-group
@ -104,7 +104,10 @@ export default {
data () { data () {
return { return {
queries: ['hooks/backup', 'apps?with_backup'], queries: [
['GET', 'hooks/backup'],
['GET', 'apps?with_backup']
],
selected: [], selected: [],
// api data // api data
system: undefined, system: undefined,
@ -131,7 +134,7 @@ export default {
return data return data
}, },
formatData ({ hooks }, { apps }) { onQueriesResponse ({ hooks }, { apps }) {
this.system = this.formatHooks(hooks) this.system = this.formatHooks(hooks)
// transform app array into literal object to match hooks data structure // transform app array into literal object to match hooks data structure
this.apps = apps.reduce((obj, app) => { this.apps = apps.reduce((obj, app) => {

View file

@ -1,5 +1,5 @@
<template> <template>
<view-base :queries="queries" @queries-response="formatBackupData"> <view-base :queries="queries" @queries-response="onQueriesResponse">
<!-- BACKUP INFO --> <!-- BACKUP INFO -->
<card :title="$t('infos')" icon="info-circle" button-unbreak="sm"> <card :title="$t('infos')" icon="info-circle" button-unbreak="sm">
<template #header-buttons> <template #header-buttons>
@ -131,7 +131,9 @@ export default {
data () { data () {
return { return {
queries: [`backup/archives/${this.name}?with_details`], queries: [
['GET', `backup/archives/${this.name}?with_details`]
],
selected: [], selected: [],
error: '', error: '',
isValid: null, isValid: null,
@ -169,7 +171,7 @@ export default {
return data return data
}, },
formatBackupData (data) { onQueriesResponse (data) {
this.infos = { this.infos = {
name: this.name, name: this.name,
created_at: data.created_at, created_at: data.created_at,
@ -211,6 +213,7 @@ export default {
api.post('backup/restore/' + this.name, data).then(response => { api.post('backup/restore/' + this.name, data).then(response => {
this.isValid = null this.isValid = null
}).catch(err => { }).catch(err => {
if (err.name !== 'APIBadRequestError') throw err
this.error = err.message this.error = err.message
this.isValid = false this.isValid = false
}) })

View file

@ -1,5 +1,5 @@
<template> <template>
<view-base :queries="queries" @queries-response="formatBackupList" skeleton="list-group-skeleton"> <view-base :queries="queries" @queries-response="onQueriesResponse" skeleton="list-group-skeleton">
<template #top> <template #top>
<top-bar :button="{ text: $t('backup_new'), icon: 'plus', to: { name: 'backup-create' } }" /> <top-bar :button="{ text: $t('backup_new'), icon: 'plus', to: { name: 'backup-create' } }" />
</template> </template>
@ -44,13 +44,15 @@ export default {
data () { data () {
return { return {
queries: ['backup/archives?with_info'], queries: [
['GET', 'backup/archives?with_info']
],
archives: undefined archives: undefined
} }
}, },
methods: { methods: {
formatBackupList (data) { onQueriesResponse (data) {
const archives = Object.entries(data.archives) const archives = Object.entries(data.archives)
if (archives.length) { if (archives.length) {
this.archives = archives.map(([name, infos]) => { this.archives = archives.map(([name, infos]) => {

View file

@ -1,7 +1,7 @@
<template> <template>
<view-base <view-base
:loading="loading" ref="view" :queries="queries" @queries-response="onQueriesResponse" queries-wait
:queries="queries" @queries-response="formatData" ref="view"
> >
<template #top-bar-group-right> <template #top-bar-group-right>
<b-button @click="shareLogs" variant="success"> <b-button @click="shareLogs" variant="success">
@ -11,10 +11,10 @@
<template #top> <template #top>
<div class="alert alert-info"> <div class="alert alert-info">
{{ $t(reports || loading ? 'diagnosis_explanation' : 'diagnosis_first_run') }} {{ $t(reports ? 'diagnosis_explanation' : 'diagnosis_first_run') }}
<b-button <b-button
v-if="reports === null" class="d-block mt-2" variant="info" v-if="reports === null" class="d-block mt-2" variant="info"
@click="runDiagnosis" @click="runDiagnosis()"
> >
<icon iname="stethoscope" /> {{ $t('run_first_diagnosis') }} <icon iname="stethoscope" /> {{ $t('run_first_diagnosis') }}
</b-button> </b-button>
@ -114,8 +114,10 @@ export default {
data () { data () {
return { return {
queries: ['diagnosis/show?full'], queries: [
loading: true, ['POST', 'diagnosis/run?except_if_never_ran_yet'],
['GET', 'diagnosis/show?full']
],
reports: undefined reports: undefined
} }
}, },
@ -149,14 +151,13 @@ export default {
item.icon = icon item.icon = icon
}, },
formatData (data) { onQueriesResponse (_, reportsData) {
if (data === null) { if (reportsData === null) {
this.reports = null this.reports = null
this.loading = false
return return
} }
const reports = data.reports const reports = reportsData.reports
for (const report of reports) { for (const report of reports) {
report.warnings = 0 report.warnings = 0
report.errors = 0 report.errors = 0
@ -168,7 +169,6 @@ export default {
report.noIssues = report.warnings + report.errors === 0 report.noIssues = report.warnings + report.errors === 0
} }
this.reports = reports this.reports = reports
this.loading = false
}, },
runDiagnosis (id = null) { runDiagnosis (id = null) {
@ -202,10 +202,6 @@ export default {
} }
}, },
created () {
api.post('diagnosis/run?except_if_never_ran_yet')
},
filters: { distanceToNow } filters: { distanceToNow }
} }
</script> </script>

View file

@ -8,30 +8,32 @@
</template> </template>
<script> <script>
import { DomainForm } from '@/components/reusableForms' import api from '@/api'
import { DomainForm } from '@/views/_partials'
export default { export default {
name: 'DomainAdd', name: 'DomainAdd',
data () { data () {
return { return {
queries: [{ uri: 'domains' }], queries: [
['GET', { uri: 'domains' }]
],
serverError: '' serverError: ''
} }
}, },
methods: { methods: {
onSubmit ({ domain, domainType }) { onSubmit ({ domain, domainType }) {
const query = { const uri = 'domains' + (domainType === 'dynDomain' ? '?dyndns' : '')
uri: 'domains' + (domainType === 'dynDomain' ? '?dyndns' : ''), api.post(
data: { domain }, { uri, storeKey: 'domains' },
storeKey: 'domains' { domain }
} ).then(() => {
this.$store.dispatch('POST', query).then(() => {
this.$router.push({ name: 'domain-list' }) this.$router.push({ name: 'domain-list' })
}).catch(error => { }).catch(err => {
this.serverError = error.message if (err.name !== 'APIBadRequestError') throw err
this.serverError = err.message
}) })
} }
}, },

View file

@ -1,5 +1,5 @@
<template> <template>
<view-base :queries="queries" @queries-response="formatCertData" ref="view"> <view-base :queries="queries" @queries-response="onQueriesResponse" ref="view">
<card v-if="cert" :title="$t('certificate_status')" icon="lock"> <card v-if="cert" :title="$t('certificate_status')" icon="lock">
<p :class="'alert alert-' + cert.alert.type"> <p :class="'alert alert-' + cert.alert.type">
<icon :iname="cert.alert.icon" /> {{ $t('certificate_alert_' + cert.alert.trad) }} <icon :iname="cert.alert.icon" /> {{ $t('certificate_alert_' + cert.alert.trad) }}
@ -83,7 +83,9 @@ export default {
data () { data () {
return { return {
queries: [`domains/cert-status/${this.name}?full`], queries: [
['GET', `domains/cert-status/${this.name}?full`]
],
cert: undefined, cert: undefined,
actionsEnabled: undefined actionsEnabled: undefined
} }
@ -106,7 +108,7 @@ export default {
} }
}, },
formatCertData (data) { onQueriesResponse (data) {
const certData = data.certificates[this.name] const certData = data.certificates[this.name]
const cert = { const cert = {

View file

@ -22,7 +22,9 @@ export default {
data () { data () {
return { return {
queries: [`domains/${this.name}/dns`], queries: [
['GET', `domains/${this.name}/dns`]
],
dnsConfig: '' dnsConfig: ''
} }
} }

View file

@ -48,6 +48,8 @@
<script> <script>
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
import api from '@/api'
export default { export default {
name: 'DomainInfo', name: 'DomainInfo',
@ -58,9 +60,11 @@ export default {
} }
}, },
data () { data: () => {
return { return {
queries: [{ uri: 'domains/main', storeKey: 'main_domain' }] queries: [
['GET', { uri: 'domains/main', storeKey: 'main_domain' }]
]
} }
}, },
@ -78,7 +82,7 @@ export default {
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_delete', { name: this.name })) const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_delete', { name: this.name }))
if (!confirmed) return if (!confirmed) return
this.$store.dispatch('DELETE', api.delete(
{ uri: 'domains', param: this.name } { uri: 'domains', param: this.name }
).then(() => { ).then(() => {
this.$router.push({ name: 'domain-list' }) this.$router.push({ name: 'domain-list' })
@ -89,10 +93,11 @@ export default {
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_change_maindomain')) const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_change_maindomain'))
if (!confirmed) return if (!confirmed) return
this.$store.dispatch('PUT', api.put(
{ uri: 'domains/main', data: { new_main_domain: this.name }, storeKey: 'main_domain' } { uri: 'domains/main', storeKey: 'main_domain' },
{ new_main_domain: this.name }
).then(() => { ).then(() => {
// Have to commit by hand here since the response is empty // FIXME Have to commit by hand here since the response is empty (should return the given name)
this.$store.commit('UPDATE_MAIN_DOMAIN', this.name) this.$store.commit('UPDATE_MAIN_DOMAIN', this.name)
}) })
} }

View file

@ -47,8 +47,8 @@ export default {
data () { data () {
return { return {
queries: [ queries: [
{ uri: 'domains/main', storeKey: 'main_domain' }, ['GET', { uri: 'domains/main', storeKey: 'main_domain' }],
{ uri: 'domains' } ['GET', { uri: 'domains' }]
], ],
search: '' search: ''
} }

View file

@ -12,6 +12,7 @@
<script> <script>
import { validationMixin } from 'vuelidate' import { validationMixin } from 'vuelidate'
import api from '@/api'
import { required, alphalownum_ } from '@/helpers/validators' import { required, alphalownum_ } from '@/helpers/validators'
export default { export default {
@ -42,13 +43,14 @@ export default {
methods: { methods: {
onSubmit () { onSubmit () {
this.$store.dispatch( api.post(
'POST', { uri: 'users/groups', data: this.form, storeKey: 'groups' } { uri: 'users/groups', storeKey: 'groups' },
this.form
).then(() => { ).then(() => {
this.$router.push({ name: 'group-list' }) this.$router.push({ name: 'group-list' })
}).catch(error => { }).catch(err => {
this.error.groupname = error.message if (err.name !== 'APIBadRequestError') throw err
this.isValid.groupname = false this.serverError = err.message
}) })
} }
}, },

View file

@ -5,7 +5,7 @@
:items="normalGroups" :items="normalGroups"
:filtered-items="filteredGroups" :filtered-items="filteredGroups"
:queries="queries" :queries="queries"
@queries-response="formatGroups" @queries-response="onQueriesResponse"
skeleton="card-form-skeleton" skeleton="card-form-skeleton"
> >
<template #top-bar-buttons> <template #top-bar-buttons>
@ -120,12 +120,17 @@ import BaseSelectize from '@/components/BaseSelectize'
export default { export default {
name: 'GroupList', name: 'GroupList',
components: {
ZoneSelectize,
BaseSelectize
},
data () { data () {
return { return {
queries: [ queries: [
{ uri: 'users' }, ['GET', { uri: 'users' }],
{ uri: 'users/groups?full&include_primary_groups', storeKey: 'groups' }, ['GET', { uri: 'users/groups?full&include_primary_groups', storeKey: 'groups' }],
{ uri: 'users/permissions?full', storeKey: 'permissions' } ['GET', { uri: 'users/permissions?full', storeKey: 'permissions' }]
], ],
search: '', search: '',
permissions: undefined, permissions: undefined,
@ -166,7 +171,7 @@ export default {
}, },
methods: { methods: {
formatGroups (users, allGroups, permissions) { onQueriesResponse (users, allGroups, permissions) {
// Do not use computed properties to get values from the store here to avoid auto // Do not use computed properties to get values from the store here to avoid auto
// updates while modifying values. // updates while modifying values.
const normalGroups = {} const normalGroups = {}
@ -247,17 +252,12 @@ export default {
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_delete', { name })) const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_delete', { name }))
if (!confirmed) return if (!confirmed) return
this.$store.dispatch('DELETE', api.delete(
{ uri: 'users/groups', param: name, storeKey: 'groups' } { uri: 'users/groups', param: name, storeKey: 'groups' }
).then(() => { ).then(() => {
Vue.delete(this.normalGroups, name) Vue.delete(this.normalGroups, name)
}) })
} }
},
components: {
ZoneSelectize,
BaseSelectize
} }
} }
</script> </script>

View file

@ -1,6 +1,6 @@
<template> <template>
<view-base <view-base
:queries="queries" @queries-response="formatServiceData" :queries="queries" @queries-response="onQueriesResponse"
ref="view" skeleton="card-info-skeleton" ref="view" skeleton="card-info-skeleton"
> >
<!-- INFO CARD --> <!-- INFO CARD -->
@ -82,8 +82,8 @@ export default {
data () { data () {
return { return {
queries: [ queries: [
'services/' + this.name, ['GET', 'services/' + this.name],
`services/${this.name}/log?number=50` ['GET', `services/${this.name}/log?number=50`]
], ],
// Service data // Service data
infos: undefined, infos: undefined,
@ -96,7 +96,7 @@ export default {
}, },
methods: { methods: {
formatServiceData ( onQueriesResponse (
// eslint-disable-next-line // eslint-disable-next-line
{ status, description, start_on_boot, last_state_change, configuration }, { status, description, start_on_boot, last_state_change, configuration },
logs logs
@ -126,7 +126,6 @@ export default {
? `services/${this.name}/restart` ? `services/${this.name}/restart`
: 'services/' + this.name : 'services/' + this.name
// FIXME API doesn't return anything to the PUT so => json err
api[method](uri).then(this.$refs.view.fetchQueries) api[method](uri).then(this.$refs.view.fetchQueries)
}, },

View file

@ -6,7 +6,7 @@
:filtered-items="filteredServices" :filtered-items="filteredServices"
items-name="services" items-name="services"
:queries="queries" :queries="queries"
@queries-response="formatServices" @queries-response="onQueriesResponse"
> >
<b-list-group> <b-list-group>
<b-list-group-item <b-list-group-item
@ -42,7 +42,9 @@ export default {
data () { data () {
return { return {
queries: ['services'], queries: [
['GET', 'services']
],
search: '', search: '',
services: undefined services: undefined
} }
@ -60,7 +62,7 @@ export default {
}, },
methods: { methods: {
formatServices (services) { onQueriesResponse (services) {
this.services = Object.keys(services).sort().map(name => { this.services = Object.keys(services).sort().map(name => {
const service = services[name] const service = services[name]
if (service.last_state_change === 'unknown') { if (service.last_state_change === 'unknown') {

View file

@ -11,7 +11,7 @@
import api from '@/api' import api from '@/api'
import { validationMixin } from 'vuelidate' import { validationMixin } from 'vuelidate'
import { PasswordForm } from '@/components/reusableForms' import { PasswordForm } from '@/views/_partials'
import { required, minLength } from '@/helpers/validators' import { required, minLength } from '@/helpers/validators'
export default { export default {
@ -40,20 +40,24 @@ export default {
}, },
methods: { methods: {
onSubmit ({ password, currentPassword }) { onSubmit ({ currentPassword, password }) {
this.serverError = '' this.serverError = ''
// Use `api.fetch` to avoid automatic redirect on 401 (Unauthorized).
api.fetch('POST', 'login', { password: currentPassword }).then(response => { api.fetchAll(
if (response.status === 401) { [['POST', 'login', { password: currentPassword }, { websocket: false }],
// Dispatch `SERVER_RESPONDED` to hide waiting overlay and display error. ['PUT', 'admisnpw', { new_password: password }]],
this.$store.dispatch('SERVER_RESPONDED', true) { wait: true }
this.serverError = this.$i18n.t('wrong_password') ).then(() => {
} else if (response.ok) {
api.put('adminpw', { new_password: password }).then(() => {
this.$store.dispatch('DISCONNECT') this.$store.dispatch('DISCONNECT')
}).catch(error => { }).catch(err => {
this.serverError = error.message if (err.name === 'APIUnauthorizedError') {
}) // Prevent automatic disconnect if error in current password.
this.serverError = this.$i18n.t('wrong_password')
} else if (err.name === 'APIBadRequestError') {
// Display form error
this.serverError = err.message
} else {
throw err
} }
}) })
} }

View file

@ -1,6 +1,6 @@
<template> <template>
<view-base <view-base
:queries="queries" @queries-response="formatFirewallData" :queries="queries" @queries-response="onQueriesResponse"
ref="view" skeleton="card-form-skeleton" ref="view" skeleton="card-form-skeleton"
> >
<!-- PORTS --> <!-- PORTS -->
@ -98,7 +98,9 @@ export default {
data () { data () {
return { return {
queries: ['/firewall?raw'], queries: [
['GET', '/firewall?raw']
],
serverError: '', serverError: '',
// Ports tables data // Ports tables data
@ -145,7 +147,7 @@ export default {
}, },
methods: { methods: {
formatFirewallData (data) { onQueriesResponse (data) {
const ports = Object.values(data).reduce((ports, protocols) => { const ports = Object.values(data).reduce((ports, protocols) => {
for (const type of ['TCP', 'UDP']) { for (const type of ['TCP', 'UDP']) {
for (const port of protocols[type]) { for (const port of protocols[type]) {
@ -181,7 +183,11 @@ export default {
).then(confirmed => { ).then(confirmed => {
if (confirmed) { if (confirmed) {
const method = action === 'open' ? 'post' : 'delete' const method = action === 'open' ? 'post' : 'delete'
api[method](`/firewall/port?${connection}_only`, { port, protocol }).then(() => { api[method](
`/firewall/port?${connection}_only`,
{ port, protocol },
{ wait: false }
).then(() => {
resolve(confirmed) resolve(confirmed)
}).catch(error => { }).catch(error => {
reject(error) reject(error)
@ -198,10 +204,11 @@ export default {
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_upnp_' + action)) const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_upnp_' + action))
if (!confirmed) return if (!confirmed) return
api.get('firewall/upnp?action=' + action).then(() => { api.get('firewall/upnp?action=' + action, null, { websocket: true, wait: true }).then(() => {
// FIXME Couldn't test when it works. // FIXME Couldn't test when it works.
this.$refs.view.fetchQueries() this.$refs.view.fetchQueries()
}).catch(err => { }).catch(err => {
if (err.name !== 'APIBadRequestError') throw err
this.upnpError = err.message this.upnpError = err.message
}) })
}, },

View file

@ -1,6 +1,6 @@
<template> <template>
<view-base <view-base
:queries="queries" @queries-response="formatLogData" :queries="queries" @queries-response="onQueriesResponse"
ref="view" skeleton="card-info-skeleton" ref="view" skeleton="card-info-skeleton"
> >
<!-- INFO CARD --> <!-- INFO CARD -->
@ -90,12 +90,12 @@ export default {
with_suboperations: '', with_suboperations: '',
number: this.numberOfLines number: this.numberOfLines
}) })
return [`logs/${this.name}?${queryString}`] return [['GET', `logs/${this.name}?${queryString}`]]
} }
}, },
methods: { methods: {
formatLogData (log) { onQueriesResponse (log) {
if (log.logs.length === this.numberOfLines) { if (log.logs.length === this.numberOfLines) {
this.moreLogsAvailable = true this.moreLogsAvailable = true
this.numberOfLines *= 10 this.numberOfLines *= 10
@ -125,7 +125,7 @@ export default {
}, },
shareLogs () { shareLogs () {
api.get(`/logs/${this.name}?share`).then(({ url }) => { api.get(`logs/${this.name}?share`, null, { websocket: true }).then(({ url }) => {
window.open(url, '_blank') window.open(url, '_blank')
}) })
} }

View file

@ -5,7 +5,7 @@
:filtered-items="filteredOperations" :filtered-items="filteredOperations"
items-name="logs" items-name="logs"
:queries="queries" :queries="queries"
@queries-response="formatLogsData" @queries-response="onQueriesResponse"
skeleton="card-list-skeleton" skeleton="card-list-skeleton"
> >
<card :title="$t('logs_operation')" icon="wrench" no-body> <card :title="$t('logs_operation')" icon="wrench" no-body>
@ -32,7 +32,9 @@ export default {
data () { data () {
return { return {
queries: [`logs?limit=${25}&with_details`], queries: [
['GET', `logs?limit=${25}&with_details`]
],
search: '', search: '',
operations: undefined operations: undefined
} }
@ -50,7 +52,7 @@ export default {
}, },
methods: { methods: {
formatLogsData ({ operation }) { onQueriesResponse ({ operation }) {
operation.forEach((log, index) => { operation.forEach((log, index) => {
if (log.success === '?') { if (log.success === '?') {
operation[index].icon = 'question' operation[index].icon = 'question'

View file

@ -1,5 +1,5 @@
<template> <template>
<view-base :queries="queries" @queries-response="formatMigrationsData" ref="view"> <view-base :queries="queries" @queries-response="onQueriesResponse" ref="view">
<!-- PENDING MIGRATIONS --> <!-- PENDING MIGRATIONS -->
<card :title="$t('migrations_pending')" icon="cogs" no-body> <card :title="$t('migrations_pending')" icon="cogs" no-body>
<template #header-buttons v-if="pending"> <template #header-buttons v-if="pending">
@ -90,8 +90,8 @@ export default {
data () { data () {
return { return {
queries: [ queries: [
'migrations?pending', ['GET', 'migrations?pending'],
'migrations?done' ['GET', 'migrations?done']
], ],
pending: undefined, pending: undefined,
done: undefined, done: undefined,
@ -100,7 +100,7 @@ export default {
}, },
methods: { methods: {
formatMigrationsData ({ migrations: pending }, { migrations: done }) { onQueriesResponse ({ migrations: pending }, { migrations: done }) {
this.done = done.length ? done.reverse() : null this.done = done.length ? done.reverse() : null
pending.forEach(migration => { pending.forEach(migration => {
if (migration.disclaimer) { if (migration.disclaimer) {

View file

@ -1,16 +1,17 @@
<template> <template>
<div> <div>
<div v-if="inProcess"> <template v-if="canReconnect">
<b-alert variant="success" v-t="'tools_power_up'" />
<login-view />
</template>
<div v-else-if="inProcess">
<b-alert variant="info" v-t="'tools_' + action + '_done'" /> <b-alert variant="info" v-t="'tools_' + action + '_done'" />
<b-alert variant="warning"> <b-alert variant="warning">
<icon :iname="action === 'reboot' ? 'refresh' : 'power-off'" /> <icon :iname="action === 'reboot' ? 'refresh' : 'power-off'" />
{{ $t(action === 'reboot' ? 'tools_rebooting' : 'tools_shuttingdown') }} {{ $t(action === 'reboot' ? 'tools_rebooting' : 'tools_shuttingdown') }}
</b-alert> </b-alert>
<template v-if="canReconnect">
<b-alert variant="success" v-t="'tools_power_up'" />
<login-view />
</template>
</div> </div>
<card v-else :title="$t('operations')" icon="wrench"> <card v-else :title="$t('operations')" icon="wrench">
@ -45,6 +46,10 @@ import LoginView from '@/views/Login'
export default { export default {
name: 'ToolPower', name: 'ToolPower',
components: {
LoginView
},
data () { data () {
return { return {
action: '', action: '',
@ -65,29 +70,29 @@ export default {
// Use 'RESET_CONNECTED' and not 'DISCONNECT' else user will be redirect to login // Use 'RESET_CONNECTED' and not 'DISCONNECT' else user will be redirect to login
this.$store.dispatch('RESET_CONNECTED') this.$store.dispatch('RESET_CONNECTED')
this.inProcess = true this.inProcess = true
this.tryToReconnect() return this.tryToReconnect(4000)
}).then(() => {
this.canReconnect = true
}) })
}, },
tryToReconnect () { tryToReconnect (delay = 2000) {
// FIXME need to be tested out of webpack-dev-server // FIXME need to be tested out of webpack-dev-server
return new Promise(resolve => {
setTimeout(() => { setTimeout(() => {
// Try to get a response from the server after boot/reboot // Try to get a response from the server after boot/reboot
// use `api.fetch` to not trigger base response handlers api.get('logout').catch(err => {
api.fetch('GET', 'logout').then(response => { if (err.name === 'APIUnauthorizedError') {
// Server responds with `Unauthorized`, we can display the login input // Means the server is accessible
if (response.status === 401) { resolve()
this.canReconnect = true
} else { } else {
this.tryToReconnect() // FIXME could be improved by checking error types since yunohost
resolve(this.tryToReconnect())
} }
}).catch(() => {
this.tryToReconnect()
}) })
}, 1000) }, delay)
})
}
} }
},
components: { LoginView }
} }
</script> </script>

View file

@ -1,5 +1,8 @@
<template> <template>
<view-base :loading="loading" skeleton="card-list-skeleton"> <view-base
:queries="queries" queries-wait @queries-response="onQueriesResponse"
skeleton="card-list-skeleton"
>
<!-- MIGRATIONS WARN --> <!-- MIGRATIONS WARN -->
<b-alert variant="warning" :show="migrationsNotDone"> <b-alert variant="warning" :show="migrationsNotDone">
<icon iname="exclamation-triangle" /> <span v-html="$t('pending_migrations')" /> <icon iname="exclamation-triangle" /> <span v-html="$t('pending_migrations')" />
@ -69,7 +72,10 @@ export default {
data () { data () {
return { return {
loading: true, queries: [
['GET', 'migrations?pending'],
['PUT', 'update']
],
// API data // API data
migrationsNotDone: undefined, migrationsNotDone: undefined,
system: undefined, system: undefined,
@ -78,6 +84,12 @@ export default {
}, },
methods: { methods: {
onQueriesResponse ({ migrations }, { apps, system }) {
this.migrationsNotDone = migrations.length !== 0
this.apps = apps.length ? apps : null
this.system = system.length ? system : null
},
async performUpgrade ({ type, id = null }) { async performUpgrade ({ type, id = null }) {
const confirmMsg = this.$i18n.t('confirm_update_' + type, id ? { app: id } : {}) const confirmMsg = this.$i18n.t('confirm_update_' + type, id ? { app: id } : {})
const confirmed = await this.$askConfirmation(confirmMsg) const confirmed = await this.$askConfirmation(confirmMsg)
@ -91,20 +103,6 @@ export default {
this.$router.push({ name: 'tool-logs' }) this.$router.push({ name: 'tool-logs' })
}) })
} }
},
created () {
// Since we need to query a `PUT` method, we won't use ViewBase's `queries` prop and
// its automatic loading handling.
Promise.all([
api.get('migrations?pending'),
api.put('update')
]).then(([{ migrations }, { apps, system }]) => {
this.migrationsNotDone = migrations.length !== 0
this.apps = apps.length ? apps : null
this.system = system.length ? system : null
this.loading = false
})
} }
} }
</script> </script>

View file

@ -60,6 +60,7 @@
</template> </template>
<script> <script>
import api from '@/api'
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
import { validationMixin } from 'vuelidate' import { validationMixin } from 'vuelidate'
@ -74,9 +75,9 @@ export default {
data () { data () {
return { return {
queries: [ queries: [
{ uri: 'users' }, ['GET', { uri: 'users' }],
{ uri: 'domains' }, ['GET', { uri: 'domains' }],
{ uri: 'domains/main', storeKey: 'main_domain' } ['GET', { uri: 'domains/main', storeKey: 'main_domain' }]
], ],
form: { form: {
@ -174,12 +175,11 @@ export default {
onSubmit () { onSubmit () {
const data = formatFormData(this.form, { flatten: true }) const data = formatFormData(this.form, { flatten: true })
this.$store.dispatch( api.post({ uri: 'users' }, data).then(() => {
'POST', { uri: 'users', data }
).then(() => {
this.$router.push({ name: 'user-list' }) this.$router.push({ name: 'user-list' })
}).catch(error => { }).catch(err => {
this.serverError = error.message if (err.name !== 'APIBadRequestError') throw err
this.serverError = err.message
}) })
} }
}, },

View file

@ -111,6 +111,7 @@
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
import { validationMixin } from 'vuelidate' import { validationMixin } from 'vuelidate'
import api from '@/api'
import { arrayDiff } from '@/helpers/commons' import { arrayDiff } from '@/helpers/commons'
import { sizeToM, adressToFormValue, formatFormData } from '@/helpers/yunohostArguments' import { sizeToM, adressToFormValue, formatFormData } from '@/helpers/yunohostArguments'
import { import {
@ -130,9 +131,9 @@ export default {
data () { data () {
return { return {
queries: [ queries: [
{ uri: 'users', param: this.name, storeKey: 'users_details' }, ['GET', { uri: 'users', param: this.name, storeKey: 'users_details' }],
{ uri: 'domains/main', storeKey: 'main_domain' }, ['GET', { uri: 'domains/main', storeKey: 'main_domain' }],
{ uri: 'domains' } ['GET', { uri: 'domains' }]
], ],
form: { form: {
@ -293,12 +294,14 @@ export default {
return return
} }
this.$store.dispatch('PUT', api.put(
{ uri: 'users', data, param: this.name, storeKey: 'users_details' } { uri: 'users', param: this.name, storeKey: 'users_details' },
data
).then(() => { ).then(() => {
this.$router.push({ name: 'user-info', param: { name: this.name } }) this.$router.push({ name: 'user-info', param: { name: this.name } })
}).catch(error => { }).catch(err => {
this.serverError = error.message if (err.name !== 'APIBadRequestError') throw err
this.serverError = err.message
}) })
}, },

View file

@ -79,6 +79,7 @@
</template> </template>
<script> <script>
import api from '@/api'
export default { export default {
name: 'UserInfo', name: 'UserInfo',
@ -89,7 +90,9 @@ export default {
data () { data () {
return { return {
queries: [{ uri: 'users', param: this.name, storeKey: 'users_details' }], queries: [
['GET', { uri: 'users', param: this.name, storeKey: 'users_details' }]
],
purge: false purge: false
} }
}, },
@ -103,8 +106,9 @@ export default {
methods: { methods: {
deleteUser () { deleteUser () {
const data = this.purge ? { purge: '' } : {} const data = this.purge ? { purge: '' } : {}
this.$store.dispatch('DELETE', api.delete(
{ uri: 'users', param: this.name, data, storeKey: 'users_details' } { uri: 'users', param: this.name, storeKey: 'users_details' },
data
).then(() => { ).then(() => {
this.$router.push({ name: 'user-list' }) this.$router.push({ name: 'user-list' })
}) })

View file

@ -47,7 +47,9 @@ export default {
data () { data () {
return { return {
queries: [{ uri: 'users' }], queries: [
['GET', { uri: 'users' }]
],
search: '' search: ''
} }
}, },