mirror of
https://github.com/YunoHost/yunohost-admin.git
synced 2024-09-03 20:06:15 +02:00
Merge pull request #330 from YunoHost/enh-error-handling
Enh error handling
This commit is contained in:
commit
91ed70ef8d
60 changed files with 1554 additions and 1011 deletions
|
@ -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.
|
||||
|
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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) => {}
|
||||
}
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
export { default } from './api'
|
||||
export { handleResponse, handleError } from './handlers'
|
||||
export { handleError, registerGlobalErrorHandlers } from './handlers'
|
||||
|
|
|
@ -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>
|
65
app/src/components/MessageListGroup.vue
Normal file
65
app/src/components/MessageListGroup.vue
Normal 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>
|
136
app/src/components/QueryHeader.vue
Normal file
136
app/src/components/QueryHeader.vue
Normal 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>
|
|
@ -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>
|
|
@ -89,4 +89,7 @@ export default {
|
|||
margin-left: .5rem;
|
||||
}
|
||||
}
|
||||
.collapse:not(.show) + .card-footer {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1,2 +0,0 @@
|
|||
export { default as PasswordForm } from './PasswordForm'
|
||||
export { default as DomainForm } from './DomainForm'
|
|
@ -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 },
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 │
|
||||
╰─────────────── */
|
||||
|
|
|
@ -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,
|
||||
|
||||
|
|
|
@ -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]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
89
app/src/views/_partials/ErrorDisplay.vue
Normal file
89
app/src/views/_partials/ErrorDisplay.vue
Normal 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>
|
256
app/src/views/_partials/HistoryConsole.vue
Normal file
256
app/src/views/_partials/HistoryConsole.vue
Normal 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>
|
77
app/src/views/_partials/ViewLockOverlay.vue
Normal file
77
app/src/views/_partials/ViewLockOverlay.vue
Normal 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>
|
107
app/src/views/_partials/WaitingDisplay.vue
Normal file
107
app/src/views/_partials/WaitingDisplay.vue
Normal 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>
|
8
app/src/views/_partials/index.js
Normal file
8
app/src/views/_partials/index.js
Normal 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'
|
|
@ -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>
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -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]))
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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
|
||||
})
|
||||
|
|
|
@ -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]) => {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
})
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -22,7 +22,9 @@ export default {
|
|||
|
||||
data () {
|
||||
return {
|
||||
queries: [`domains/${this.name}/dns`],
|
||||
queries: [
|
||||
['GET', `domains/${this.name}/dns`]
|
||||
],
|
||||
dnsConfig: ''
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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: ''
|
||||
}
|
||||
|
|
|
@ -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
|
||||
})
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
},
|
||||
|
||||
|
|
|
@ -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') {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
|
||||
})
|
||||
},
|
||||
|
|
|
@ -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')
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
})
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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
|
||||
})
|
||||
},
|
||||
|
||||
|
|
|
@ -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' })
|
||||
})
|
||||
|
|
|
@ -47,7 +47,9 @@ export default {
|
|||
|
||||
data () {
|
||||
return {
|
||||
queries: [{ uri: 'users' }],
|
||||
queries: [
|
||||
['GET', { uri: 'users' }]
|
||||
],
|
||||
search: ''
|
||||
}
|
||||
},
|
||||
|
|
Loading…
Reference in a new issue