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

View file

@ -4,14 +4,33 @@
*/
import store from '@/store'
import { handleResponse } from './handlers'
import { openWebSocket, getResponseData, handleError } from './handlers'
import { objectToParams } from '@/helpers/commons'
/**
* A digested fetch response as an object, a string or an error.
* @typedef {(Object|string|Error)} DigestedResponse
* Options available for an API call.
*
* @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 {
options: {
credentials: 'include',
@ -22,106 +41,124 @@ export default {
// Auto header is :
// "Accept": "*/*",
// Also is this still important ? (needed by back-end)
'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.
*
* @param {string} method - a method between 'GET', 'POST', 'PUT' and 'DELETE'.
* @param {string} uri
* @param {string} [data={}] - data to send as body for 'POST', 'PUT' and 'DELETE' methods.
* @return {Promise<Response>} Promise that resolve a fetch `Response`.
* @param {String} method - a method between 'GET', 'POST', 'PUT' and 'DELETE'.
* @param {String} uri
* @param {Object} [data={}] - data to send as body.
* @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 = {}) {
// Open a websocket connection that will dispatch messages received.
// FIXME add ability to do not open it
await this.openWebSocket()
async fetch (method, uri, data = {}, { wait = true, websocket = true, initial = false, asFormData = false } = {}) {
// `await` because Vuex actions returns promises by default.
const request = await store.dispatch('INIT_REQUEST', { method, uri, initial, wait, websocket })
if (method === 'GET') {
const localeQs = `${uri.includes('?') ? '&' : '?'}locale=${store.getters.locale}`
return fetch('/yunohost/api/' + uri + localeQs, this.options)
if (websocket) {
await openWebSocket(request)
}
store.dispatch('WAITING_FOR_RESPONSE', [uri, method])
return fetch('/yunohost/api/' + uri, {
...this.options,
method,
body: objectToParams(data, { addLocale: true })
})
let options = this.options
if (method === 'GET') {
uri += `${uri.includes('?') ? '&' : '?'}locale=${store.getters.locale}`
} else {
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.
*
* @param {string} uri - the uri to call.
* @return {Promise<module:api~DigestedResponse>} Promise that resolve the api response as an object, a string or as an error.
* @param {String|Object} uri
* @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) {
return this.fetch('GET', uri).then(response => handleResponse(response, 'GET'))
get (uri, data = null, options = {}) {
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.
*
* @param {string} uri - the uri to call.
* @param {string} [data={}] - data to send as body.
* @return {Promise<module:api~DigestedResponse>} Promise that resolve the api responses as an array.
* @param {String|Object} uri
* @param {String} [data={}] - data to send as body.
* @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 = {}) {
return this.fetch('POST', uri, data).then(response => handleResponse(response, 'POST'))
post (uri, data = {}, options = {}) {
if (typeof uri === 'string') return this.fetch('POST', uri, data, options)
return store.dispatch('POST', { ...uri, data, options })
},
/**
* Api put helper function.
*
* @param {string} uri - the uri to call.
* @param {string} [data={}] - data to send as body.
* @return {Promise<module:api~DigestedResponse>} Promise that resolve the api responses as an array.
* @param {String|Object} uri
* @param {String} [data={}] - data to send as body.
* @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 = {}) {
return this.fetch('PUT', uri, data).then(response => handleResponse(response, 'PUT'))
put (uri, data = {}, options = {}) {
if (typeof uri === 'string') return this.fetch('PUT', uri, data, options)
return store.dispatch('PUT', { ...uri, data, options })
},
/**
* Api delete helper function.
*
* @param {string} uri - the uri to call.
* @param {string} [data={}] - data to send as body.
* @return {Promise<('ok'|Error)>} Promise that resolve the api responses as an array.
* @param {String|Object} uri
* @param {String} [data={}] - data to send as body.
* @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 = {}) {
return this.fetch('DELETE', uri, data).then(response => handleResponse(response, 'DELETE'))
delete (uri, data = {}, options = {}) {
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'
class APIError extends Error {
constructor (method, { url, status, statusText }, message) {
super(message || i18n.t('error_server_unexpected'))
this.uri = new URL(url).pathname.replace('/yunohost', '')
this.method = method
constructor (request, { url, status, statusText }, errorData) {
super(errorData.error || i18n.t('error_server_unexpected'))
const urlObj = new URL(url)
this.name = 'APIError'
this.code = status
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}`)
}
}
// 401 — Unauthorized
class APIUnauthorizedError extends APIError {
constructor (method, response, message) {
super(method, response, i18n.t('unauthorized'))
this.name = 'APIUnauthorizedError'
// Log (Special error to trigger a redirect to a log page)
class APIErrorLog extends APIError {
constructor (method, response, errorData) {
super(method, response, errorData)
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)
class APIConnexionError extends APIError {
constructor (method, response) {
super(method, response, i18n.t('error_connection_interrupted'))
super(method, response, { error: i18n.t('error_connection_interrupted') })
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
const errors = {
[undefined]: APIError,
log: APIErrorLog,
0: APIConnexionError,
400: APIBadRequestError,
401: APIUnauthorizedError,
404: APINotFoundError,
500: APIInternalError,
502: APINotRespondingError
}
export {
errors as default,
APIError,
APIUnauthorizedError,
APIErrorLog,
APIBadRequestError,
APIConnexionError,
APIInternalError,
APINotFoundError,
APINotRespondingError,
APIConnexionError
APIUnauthorizedError
}

View file

@ -4,8 +4,8 @@
*/
import store from '@/store'
import errors from './errors'
import router from '@/router'
import errors, { APIError } from './errors'
/**
* 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.
* @return {(Object|String)} Parsed response's json or response's text.
*/
async function _getResponseContent (response) {
export async function getResponseData (response) {
// FIXME the api should always return json as response
const responseText = await response.text()
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.
* @return {(Object|String)} Parsed response's json, response's text or an error.
* @param {Object} request - Request info data.
* @return {Promise<Event>} Promise that resolve on websocket 'open' or 'error' event.
*/
export function handleResponse (response, method) {
if (method !== 'GET') {
store.dispatch('SERVER_RESPONDED', response.ok)
}
if (!response.ok) return handleError(response, method)
// FIXME the api should always return json objects
return _getResponseContent(response)
export function openWebSocket (request) {
return new Promise(resolve => {
const ws = new WebSocket(`wss://${store.getters.host}/yunohost/api/messages`)
ws.onmessage = ({ data }) => {
store.dispatch('DISPATCH_MESSAGE', { request, messages: JSON.parse(data) })
}
// ws.onclose = (e) => {}
ws.onopen = resolve
// Resolve also on error so the actual fetch may be called.
ws.onerror = resolve
})
}
/**
* Handler for API errors.
*
* @param {Response} response - A fetch `Response` object.
* @throws Will throw a custom error with response data.
* @param {Object} request - Request info 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) {
const message = await _getResponseContent(response)
const errorCode = response.status in errors ? response.status : undefined
const error = new errors[errorCode](method, response, message.error || message)
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
store.dispatch('SERVER_RESPONDED', true)
} else {
store.dispatch('DISPATCH_ERROR', error)
export async function handleError (request, response, errorData) {
let errorCode = response.status in errors ? response.status : undefined
if (typeof errorData === 'string') {
// FIXME API: Patching errors that are plain text or html.
errorData = { error: errorData }
}
if ('log_ref' in errorData) {
// Define a special error so it won't get caught as a `APIBadRequestError`.
errorCode = 'log'
}
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 { 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;
}
}
.collapse:not(.show) + .card-footer {
display: none;
}
</style>

View file

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

View file

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

View file

@ -13,19 +13,33 @@
},
"administration_password": "Administration password",
"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": {
"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>.",
"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": {
"APIError": "Yunohost encountered an unexpected error",
"APIBadRequestError": "Yunohost encountered an error",
"APIInternalError": "Yunohost encountered an internal error",
"APINotFoundError": "Yunohost API could not find a route",
"APINotRespondingError": "Yunohost API is not responding",
"APIConnexionError": "Yunohost encountered an connexion error"
"APIConnexionError": "Yunohost encountered a connexion error"
},
"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_waiting": "Waiting for the server's response...",
"app_actions": "Actions",
@ -168,6 +182,7 @@
},
"form_input_example": "Example: {example}",
"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_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",
@ -189,9 +204,10 @@
"title": "History",
"last_action": "Last action:",
"methods": {
"DELETE": "delete",
"GET": "read",
"POST": "create/execute",
"PUT": "modify",
"DELETE": "delete"
"PUT": "modify"
}
},
"home": "Home",
@ -398,7 +414,8 @@
"warnings": "{count} warnings",
"words": {
"collapse": "Collapse",
"default": "Default"
"default": "Default",
"dismiss": "Dismiss"
},
"wrong_password": "Wrong password",
"yes": "Yes",

View file

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

View file

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

View file

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

View file

@ -90,40 +90,21 @@ export default {
},
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]
// 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
commit('SET_' + storeKey.toUpperCase(), param ? [param, data] : data)
return param ? state[storeKey][param] : state[storeKey]
})
},
'FETCH_ALL' ({ state, commit, rootState }, queries) {
return Promise.all(queries.map(({ uri, param, storeKey = uri, cache = rootState.cache }) => {
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 => {
'POST' ({ state, commit }, { uri, storeKey = uri, data, options }) {
return api.fetch('POST', uri, data, options).then(responseData => {
// FIXME api/domains returns null
if (responseData === null) responseData = data
responseData = responseData[storeKey] ? responseData[storeKey] : responseData
@ -132,16 +113,16 @@ export default {
})
},
'PUT' ({ state, commit }, { uri, param, data, storeKey = uri }) {
return api.put(param ? `${uri}/${param}` : uri, data).then(responseData => {
'PUT' ({ state, commit }, { uri, param, storeKey = uri, data, options }) {
return api.fetch('PUT', param ? `${uri}/${param}` : uri, data, options).then(responseData => {
const data = responseData[storeKey] ? responseData[storeKey] : responseData
commit('UPDATE_' + storeKey.toUpperCase(), param ? [param, data] : data)
return param ? state[storeKey][param] : state[storeKey]
})
},
'DELETE' ({ commit }, { uri, param, data = {}, storeKey = uri }) {
return api.delete(param ? `${uri}/${param}` : uri, data).then(() => {
'DELETE' ({ commit }, { uri, param, storeKey = uri, data, options }) {
return api.fetch('DELETE', param ? `${uri}/${param}` : uri, data, options).then(() => {
commit('DEL_' + storeKey.toUpperCase(), param)
})
}
@ -164,8 +145,7 @@ export default {
})
},
// not cached
user: state => name => state.users_details[name],
user: state => name => state.users_details[name], // not cached
domains: state => state.domains,

View file

@ -5,91 +5,70 @@ import { timeout } from '@/helpers/commons'
export default {
state: {
host: window.location.host,
connected: localStorage.getItem('connected') === 'true',
yunohost: null, // yunohost app infos: Object {version, repo}
error: null,
waiting: false,
history: []
host: window.location.host, // String
connected: localStorage.getItem('connected') === 'true', // Boolean
yunohost: null, // Object { version, repo }
waiting: false, // Boolean
history: [], // Array of `request`
requests: [], // Array of `request`
error: null // null || request
},
mutations: {
'SET_CONNECTED' (state, connected) {
localStorage.setItem('connected', connected)
state.connected = connected
'SET_CONNECTED' (state, boolean) {
localStorage.setItem('connected', boolean)
state.connected = boolean
},
'SET_YUNOHOST_INFOS' (state, yunohost) {
state.yunohost = yunohost
},
'UPDATE_WAITING' (state, boolean) {
'SET_WAITING' (state, boolean) {
state.waiting = boolean
},
'ADD_HISTORY_ENTRY' (state, [uri, method, date]) {
state.history.push({ uri, method, date, messages: [] })
'ADD_REQUEST' (state, request) {
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) {
state.history[state.history.length - 1].messages.push(message)
'UPDATE_REQUEST' (state, { request, key, value }) {
// This rely on data persistance and reactivity.
Vue.set(request, key, value)
},
'UPDATE_PROGRESS' (state, progress) {
Vue.set(state.history[state.history.length - 1], 'progress', progress)
'REMOVE_REQUEST' (state, request) {
const index = state.requests.lastIndexOf(request)
state.requests.splice(index, 1)
},
'SET_ERROR' (state, error) {
state.error = error
'ADD_HISTORY_ACTION' (state, request) {
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: {
'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) {
// this action will try to query the `/installed` route 3 times every 5 s with
// a timeout of the same delay.
@ -104,24 +83,85 @@ export default {
})
},
'WAITING_FOR_RESPONSE' ({ commit }, [uri, method]) {
commit('UPDATE_WAITING', true)
commit('ADD_HISTORY_ENTRY', [uri, method, Date.now()])
'CONNECT' ({ commit, dispatch }) {
commit('SET_CONNECTED', true)
dispatch('GET_YUNOHOST_INFOS')
router.push(router.currentRoute.query.redirect || { name: 'home' })
},
'SERVER_RESPONDED' ({ state, dispatch, commit }, responseIsOk) {
if (responseIsOk) {
commit('UPDATE_WAITING', false)
commit('SET_ERROR', '')
'RESET_CONNECTED' ({ commit }) {
commit('SET_CONNECTED', false)
commit('SET_YUNOHOST_INFOS', null)
},
'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) {
const typeToColor = { error: 'danger' }
'DISPATCH_MESSAGE' ({ commit }, { request, messages }) {
for (const type in messages) {
const message = {
text: messages[type],
type: type in typeToColor ? typeToColor[type] : type
color: type === 'error' ? 'danger' : type
}
let progressBar = message.text.match(/^\[#*\+*\.*\] > /)
if (progressBar) {
@ -131,30 +171,63 @@ export default {
for (const char of progressBar) {
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) {
commit('ADD_MESSAGE', message)
commit('ADD_MESSAGE', { request, message, type })
}
}
},
'DISPATCH_ERROR' ({ state, commit }, error) {
commit('SET_ERROR', error)
if (error.method === 'GET') {
router.push({ name: 'error', params: { type: error.code } })
'HANDLE_ERROR' ({ commit, dispatch }, error) {
if (error.code === 401) {
// Unauthorized
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: {
host: state => state.host,
connected: state => (state.connected),
yunohost: state => (state.yunohost),
connected: state => state.connected,
yunohost: state => state.yunohost,
error: state => state.error,
waiting: state => state.waiting,
history: state => state.history,
lastAction: state => state.history[state.history.length - 1]
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,35 +1,31 @@
<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-input-group>
<template v-slot:prepend>
<b-input-group-text>
<label class="sr-only" for="input-password">{{ $t('password') }}</label>
<icon iname="lock" class="sm" />
</b-input-group-text>
</template>
<b-form @submit.prevent="login">
<!-- FIXME add hidden domain input ? -->
<b-input-group>
<template v-slot:prepend>
<b-input-group-text>
<label class="sr-only" for="input-password">{{ $t('password') }}</label>
<icon iname="lock" class="sm" />
</b-input-group-text>
</template>
<b-form-input
id="input-password"
required type="password"
v-model="password" :disabled="disabled"
:placeholder="$t('administration_password')" :state="isValid"
/>
<template v-slot:append>
<b-button type="submit" variant="success" :disabled="disabled">
{{ $t('login') }}
</b-button>
</template>
</b-input-group>
<b-form-invalid-feedback :state="isValid">
{{ $t('wrong_password') }}
</b-form-invalid-feedback>
</b-form>
</div>
<b-form-input
id="input-password"
required type="password"
v-model="password"
:placeholder="$t('administration_password')" :state="isValid"
/>
<template v-slot:append>
<b-button type="submit" variant="success" :disabled="disabled">
{{ $t('login') }}
</b-button>
</template>
</b-input-group>
<b-form-invalid-feedback :state="isValid">
{{ $t('wrong_password') }}
</b-form-invalid-feedback>
</b-form>
</template>
<script>
@ -38,7 +34,7 @@ export default {
data () {
return {
disabled: false,
disabled: true,
password: '',
isValid: null,
apiError: undefined
@ -47,7 +43,8 @@ export default {
methods: {
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
})
}
@ -60,8 +57,6 @@ export default {
} else {
this.$router.push({ name: 'post-install' })
}
}).catch(err => {
this.apiError = err.message
})
}
}

View file

@ -48,28 +48,25 @@
<p class="alert alert-success">
<icon iname="thumbs-up" /> {{ $t('installation_complete') }}
</p>
<login-view />
<login />
</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>
</template>
<script>
import api from '@/api'
import { DomainForm, PasswordForm } from '@/components/reusableForms'
import LoginView from '@/views/Login'
import { DomainForm, PasswordForm } from '@/views/_partials'
import Login from '@/views/Login'
export default {
name: 'PostInstall',
components: {
DomainForm,
PasswordForm,
Login
},
data () {
return {
step: 'start',
@ -84,9 +81,13 @@ export default {
this.step = 'password'
},
setPassword ({ password }) {
async setPassword ({ 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 () {
@ -104,12 +105,6 @@ export default {
this.$router.push({ name: 'home' })
}
})
},
components: {
DomainForm,
PasswordForm,
LoginView
}
}
</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>
<view-base
:queries="queries" @queries-response="formatAppActions"
:queries="queries" @queries-response="onQueriesResponse"
ref="view" skeleton="card-form-skeleton"
>
<template v-if="actions" #default>
@ -47,6 +47,8 @@ import { objectToParams } from '@/helpers/commons'
export default {
name: 'AppActions',
mixins: [validationMixin],
props: {
id: { type: String, required: true }
},
@ -54,10 +56,10 @@ export default {
data () {
return {
queries: [
`apps/${this.id}/actions`,
{ uri: 'domains' },
{ uri: 'domains/main', storeKey: 'main_domain' },
{ uri: 'users' }
['GET', `apps/${this.id}/actions`],
['GET', { uri: 'domains' }],
['GET', { uri: 'domains/main', storeKey: 'main_domain' }],
['GET', { uri: 'users' }]
],
actions: undefined
}
@ -74,7 +76,7 @@ export default {
},
methods: {
formatAppActions (data) {
onQueriesResponse (data) {
if (!data.actions) {
this.actions = null
return
@ -95,17 +97,16 @@ export default {
},
performAction (action) {
// FIXME api expects at least one argument ?! (fake one given with { wut } )
const args = objectToParams(action.form ? formatFormData(action.form) : { wut: undefined })
// FIXME api expects at least one argument ?! (fake one given with { dontmindthis } )
const args = objectToParams(action.form ? formatFormData(action.form) : { dontmindthis: undefined })
api.put(`apps/${this.id}/actions/${action.id}`, { args }).then(response => {
this.$refs.view.fetchQueries()
}).catch(error => {
action.serverError = error.message
}).catch(err => {
if (err.name !== 'APIBadRequestError') throw err
action.serverError = err.message
})
}
},
mixins: [validationMixin]
}
}
</script>

View file

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

View file

@ -1,41 +1,30 @@
<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>
<b-alert variant="warning" class="mb-4">
<icon iname="exclamation-triangle" /> {{ $t('experimental_warning') }}
</b-alert>
<!-- FIXME Rework with components -->
<b-form id="config-form" @submit.prevent="applyConfig">
<b-card no-body v-for="panel in panels" :key="panel.id">
<b-card-header class="d-flex align-items-center">
<h2>{{ panel.name }} <small v-if="panel.help">{{ panel.help }}</small></h2>
<card-form
v-for="{ name, id: id_, sections, help, serverError } in panels" :key="id_"
:title="name" icon="wrench" title-tag="h4"
:validation="$v.forms[id_]" :id="id_ + '-form'" :server-error="serverError"
collapsable
@submit.prevent="applyConfig(id_)"
>
<template v-if="help" #disclaimer>
<div class="alert alert-info" v-html="help" />
</template>
<div class="ml-auto">
<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>
<div v-for="section in sections" :key="section.id" class="mb-5">
<b-card-title>{{ section.name }} <small v-if="section.help">{{ section.help }}</small></b-card-title>
<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>
<form-item-helper v-for="arg in section.args" :key="arg.name" v-bind="arg" />
</b-card-body>
</b-collapse>
<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>
</b-collapse>
</b-card>
</b-form>
<form-field
v-for="(field, fname) in section.fields" :key="fname" label-cols="0"
v-bind="field" v-model="forms[id_][fname]" :validation="$v.forms[id_][fname]"
/>
</div>
</card-form>
</template>
<!-- if no config panel -->
@ -46,14 +35,18 @@
</template>
<script>
import { validationMixin } from 'vuelidate'
// FIXME needs test and rework
import api from '@/api'
import { formatI18nField, formatYunoHostArgument } from '@/helpers/yunohostArguments'
import { formatI18nField, formatYunoHostArguments, formatFormData } from '@/helpers/yunohostArguments'
import { objectToParams } from '@/helpers/commons'
export default {
name: 'AppConfigPanel',
mixins: [validationMixin],
props: {
id: { type: String, required: true }
},
@ -61,57 +54,61 @@ export default {
data () {
return {
queries: [
`apps/${this.id}/config-panel`,
{ uri: 'domains' },
{ uri: 'domains/main', storeKey: 'main_domain' },
{ uri: 'users' }
['GET', `apps/${this.id}/config-panel`],
['GET', { uri: 'domains' }],
['GET', { uri: 'domains/main', storeKey: 'main_domain' }],
['GET', { uri: 'users' }]
],
panels: undefined
panels: undefined,
forms: undefined,
validations: null
}
},
validations () {
return this.validations
},
methods: {
formatAppConfig (data) {
onQueriesResponse (data) {
if (!data.config_panel || data.config_panel.length === 0) {
this.panels = null
return
}
const forms = {}
const validations_ = {}
const panels_ = []
for (const { id, name, help, sections } of data.config_panel.panel) {
const panel_ = { id, name, sections: [] }
if (help) panel_.help = formatI18nField(help)
forms[id] = {}
validations_[id] = {}
for (const { name, help, options } of sections) {
const section_ = { name }
if (help) section_.help = formatI18nField(help)
section_.args = options.map(option => formatYunoHostArgument(option))
panel_.sections.push(section_)
const { form, fields, validations } = formatYunoHostArguments(options)
Object.assign(forms[id], form)
Object.assign(validations_[id], validations)
panel_.sections.push({ name, fields })
}
panels_.push(panel_)
}
this.forms = forms
this.validations = { forms: validations_ }
this.panels = panels_
},
applyConfig () {
// FIXME not tested
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
}
}
}
}
applyConfig (id_) {
const args = objectToParams(formatFormData(this.forms[id_]))
// FIXME not tested at all, route is currently broken
api.post(`apps/${this.id}/config`, { args: objectToParams(args) }).then(response => {
api.post(`apps/${this.id}/config`, { args }).then(response => {
console.log('SUCCESS', response)
}).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>
<view-base :queries="queries" @queries-response="formatAppData" ref="view">
<view-base :queries="queries" @queries-response="onQueriesResponse" ref="view">
<!-- BASIC INFOS -->
<card v-if="infos" :title="`${$t('infos')} — ${infos.label}`" icon="info-circle">
<b-row
@ -172,9 +172,9 @@ export default {
data () {
return {
queries: [
`apps/${this.id}?full`,
{ uri: 'users/permissions?full', storeKey: 'permissions' },
{ uri: 'domains' }
['GET', `apps/${this.id}?full`],
['GET', { uri: 'users/permissions?full', storeKey: 'permissions' }],
['GET', { uri: 'domains' }]
],
infos: undefined,
app: undefined,
@ -203,7 +203,7 @@ export default {
},
methods: {
formatAppData (app) {
onQueriesResponse (app) {
const form = { labels: [] }
const mainPermission = app.permissions[this.id + '.main']
@ -263,7 +263,7 @@ export default {
api.put(
`apps/${this.id}/changeurl`,
{ domain, path: '/' + path }
).then(this.fetchData)
).then(this.$refs.view.fetchQueries)
},
async setAsDefaultDomain () {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
<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">
<p :class="'alert alert-' + cert.alert.type">
<icon :iname="cert.alert.icon" /> {{ $t('certificate_alert_' + cert.alert.trad) }}
@ -83,7 +83,9 @@ export default {
data () {
return {
queries: [`domains/cert-status/${this.name}?full`],
queries: [
['GET', `domains/cert-status/${this.name}?full`]
],
cert: undefined,
actionsEnabled: undefined
}
@ -106,7 +108,7 @@ export default {
}
},
formatCertData (data) {
onQueriesResponse (data) {
const certData = data.certificates[this.name]
const cert = {

View file

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

View file

@ -48,6 +48,8 @@
<script>
import { mapGetters } from 'vuex'
import api from '@/api'
export default {
name: 'DomainInfo',
@ -58,9 +60,11 @@ export default {
}
},
data () {
data: () => {
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 }))
if (!confirmed) return
this.$store.dispatch('DELETE',
api.delete(
{ uri: 'domains', param: this.name }
).then(() => {
this.$router.push({ name: 'domain-list' })
@ -89,10 +93,11 @@ export default {
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_change_maindomain'))
if (!confirmed) return
this.$store.dispatch('PUT',
{ uri: 'domains/main', data: { new_main_domain: this.name }, storeKey: 'main_domain' }
api.put(
{ uri: 'domains/main', storeKey: 'main_domain' },
{ new_main_domain: this.name }
).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)
})
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
<template>
<view-base
:queries="queries" @queries-response="formatFirewallData"
:queries="queries" @queries-response="onQueriesResponse"
ref="view" skeleton="card-form-skeleton"
>
<!-- PORTS -->
@ -98,7 +98,9 @@ export default {
data () {
return {
queries: ['/firewall?raw'],
queries: [
['GET', '/firewall?raw']
],
serverError: '',
// Ports tables data
@ -145,7 +147,7 @@ export default {
},
methods: {
formatFirewallData (data) {
onQueriesResponse (data) {
const ports = Object.values(data).reduce((ports, protocols) => {
for (const type of ['TCP', 'UDP']) {
for (const port of protocols[type]) {
@ -181,7 +183,11 @@ export default {
).then(confirmed => {
if (confirmed) {
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)
}).catch(error => {
reject(error)
@ -198,10 +204,11 @@ export default {
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_upnp_' + action))
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.
this.$refs.view.fetchQueries()
}).catch(err => {
if (err.name !== 'APIBadRequestError') throw err
this.upnpError = err.message
})
},

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,8 @@
<template>
<view-base :loading="loading" skeleton="card-list-skeleton">
<view-base
:queries="queries" queries-wait @queries-response="onQueriesResponse"
skeleton="card-list-skeleton"
>
<!-- MIGRATIONS WARN -->
<b-alert variant="warning" :show="migrationsNotDone">
<icon iname="exclamation-triangle" /> <span v-html="$t('pending_migrations')" />
@ -69,7 +72,10 @@ export default {
data () {
return {
loading: true,
queries: [
['GET', 'migrations?pending'],
['PUT', 'update']
],
// API data
migrationsNotDone: undefined,
system: undefined,
@ -78,6 +84,12 @@ export default {
},
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 }) {
const confirmMsg = this.$i18n.t('confirm_update_' + type, id ? { app: id } : {})
const confirmed = await this.$askConfirmation(confirmMsg)
@ -91,20 +103,6 @@ export default {
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>

View file

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

View file

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

View file

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

View file

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