Merge branch 'dev' of https://github.com/YunoHost/yunohost-admin into fix-dns-doc-link

This commit is contained in:
MercierCorentin 2021-03-09 14:33:02 +01:00
commit 615fc69a81
67 changed files with 2111 additions and 1040 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -307,5 +307,84 @@
"permission_show_tile_enabled": "Mostrar la «tile» en el portal de l'usuari", "permission_show_tile_enabled": "Mostrar la «tile» en el portal de l'usuari",
"permission_main": "Permís principal", "permission_main": "Permís principal",
"permission_corresponding_url": "URL corresponent", "permission_corresponding_url": "URL corresponent",
"app_manage_label_and_tiles": "Gestionar etiquetes i «tiles»" "app_manage_label_and_tiles": "Gestionar etiquetes i «tiles»",
"items": {
"backups": "cap còpia de seguretat | còpia de seguretat | {c} còpies de seguretat",
"apps": "cap aplicació | app | {c} aplicacions"
},
"history": {
"methods": {
"DELETE": "eliminar",
"PUT": "modificar",
"POST": "crear/executar"
},
"last_action": "Última acció:",
"title": "Historial"
},
"form_errors": {
"required": "Aquest camp és obligatori.",
"passwordMatch": "Les contrasenyes no són iguals.",
"passwordLenght": "La contrasenya ha de tenir 8 caràcters com a mínim.",
"number": "El valor ha de ser un nombre.",
"notInUsers": "L'usuari «{value}» ja existeix.",
"minValue": "El valor ha de ser un nombre superior o igual a {min}.",
"name": "Els noms no poden incloure caràcters especials ha excepció de <code>,.'-</code>",
"githubLink": "L'URL ha de ser un enllaç vàlid a un repositori Github",
"email": "Correu electrònic no vàlid: ha de ser caràcters alfanumèrics i <code>_.</code> exclusivament (per exemple someone@example.com, s0me-1@example.com)",
"dynDomain": "Nom de domini no vàlid: Ha de contenir caràcters alfanumèrics en minúscules i guionets exclusivament",
"domain": "Nom de domini no vàlid: Ha de contenir caràcters alfanumèrics en minúscules, punts i guionets exclusivament",
"between": "El valor ha d'estar entre {min} i {max}.",
"alphalownum_": "Només pot contenir caràcters alfanumèrics en minúscules i la barra baixa.",
"alpha": "Només pot contenir caràcters alfanumèrics."
},
"footer": {
"donate": "Fer una donació",
"help": "Necessiteu ajuda?",
"documentation": "Documentació"
},
"experimental": "Experimental",
"error": "Error",
"enabled": "Activat",
"domain_delete_forbidden_desc": "No podeu eliminar «{domain}» ja que és el domini principal, heu d'escollir un altre domini (o <a href='#/domains/add'>afegir-ne un de nou</a>) i fer-lo el domini principal per a poder eliminar aquest.",
"domain_add_dyndns_forbidden": "Ja us heu subscrit a un domini DynDNS, podeu demanar que eliminin el domini DynDNS actual al fòrum <a href='//forum.yunohost.org/t/nohost-domain-recovery-suppression-de-domaine-en-nohost-me-noho-st-et-ynh-fr/442'>en el fil dedicat</a>.",
"disabled": "Desactivat",
"dead": "Inactiu",
"day_validity": " Expirat | 1 dia | {count} dies",
"confirm_app_install": "Esteu segurs de voler instal·lar aquesta aplicació?",
"common": {
"lastname": "Cognom",
"firstname": "Nom"
},
"code": "Codi",
"cancel": "Cancel·lar",
"app_show_categories": "Mostrar les categories",
"app_config_panel_no_panel": "Aquesta aplicació no té cap configuració diponible",
"app_config_panel_label": "Configurar aquesta aplicació",
"app_config_panel": "Panell de configuració",
"app_choose_category": "Escolliu una categoria",
"app_actions_label": "Executar les accions",
"app_actions": "Accions",
"api_waiting": "Esperant la resposta del servidor…",
"api_errors_titles": {
"APIConnexionError": "YunoHost ha tingut un error de connexió",
"APINotRespondingError": "L'API de YunoHost no respon",
"APIInternalError": "YunoHost ha trobat un error intern",
"APIBadRequestError": "YunoHost ha trobat un error",
"APIError": "YunoHost ha trobat un error inesperat"
},
"api_error": {
"sorry": "Ens sap molt greu això.",
"info": "La informació següent pot ser útil per a la persona que us ajudi:",
"help": "Podeu buscar ajuda <a href=\"https://forum.yunohost.org/\">al fòrum</a> o <a href=\"https://chat.yunohost.org/\">al chat</a> per arreglar la situació, o reportar l'error <a href=\"https://github.com/YunoHost/issues\">al bugtracker</a>."
},
"address": {
"local_part_description": {
"email": "Escolliu una part local per al vostre correu electrònic.",
"domain": "Escolliu un subdomini."
},
"domain_description": {
"email": "Escolliu un domini per al correu electrònic.",
"domain": "Escolliu un domini."
}
}
} }

View file

@ -9,5 +9,7 @@
"administration_password": "Heslo administrátora", "administration_password": "Heslo administrátora",
"add": "Přidat", "add": "Přidat",
"active": "Aktivní", "active": "Aktivní",
"action": "Akce" "action": "Akce",
"cancel": "Storno",
"ok": "OK"
} }

View file

@ -23,7 +23,7 @@
"confirm_change_maindomain": "Möchtest du wirklich die Hauptdomain ändern?", "confirm_change_maindomain": "Möchtest du wirklich die Hauptdomain ändern?",
"confirm_delete": "Möchtest du wirklich {name} löschen?", "confirm_delete": "Möchtest du wirklich {name} löschen?",
"confirm_install_custom_app": "WARNUNG! Die Installation von Drittanbieter Apps könnte die Sicherheit und Integrität deines Systems gefährden. Du solltest sie nicht installieren außer du weißt was du tust. Willst du das Risiko eingehen?", "confirm_install_custom_app": "WARNUNG! Die Installation von Drittanbieter Apps könnte die Sicherheit und Integrität deines Systems gefährden. Du solltest sie nicht installieren außer du weißt was du tust. Willst du das Risiko eingehen?",
"confirm_install_domain_root": "Du wirst keine weiteren Apps auf {domain} installieren können. Dennoch fortfahren?", "confirm_install_domain_root": "Bist du sicher das du die Anwendung '/'? installieren willst? Du kann keine andere App auf der Domäne {domain} installieren",
"confirm_postinstall": "Du bist dabei, den Konfigurationsprozess für die Domain {domain} starten. Dies wird ein paar Minuten dauern, *die Ausführung nicht unterbrechen*.", "confirm_postinstall": "Du bist dabei, den Konfigurationsprozess für die Domain {domain} starten. Dies wird ein paar Minuten dauern, *die Ausführung nicht unterbrechen*.",
"confirm_restore": "Möchtest du wirklich {name} wiederherstellen?", "confirm_restore": "Möchtest du wirklich {name} wiederherstellen?",
"confirm_uninstall": "Möchtest du wirklich {name} deinstallieren?", "confirm_uninstall": "Möchtest du wirklich {name} deinstallieren?",
@ -60,7 +60,7 @@
"hook_data_home": "Benutzerdaten", "hook_data_home": "Benutzerdaten",
"hook_data_home_desc": "Die Daten des Benutzers werden gespeichert unter /home/USER", "hook_data_home_desc": "Die Daten des Benutzers werden gespeichert unter /home/USER",
"hook_data_mail": "E-Mail", "hook_data_mail": "E-Mail",
"hook_data_mail_desc": "Roth-E-Mails auf dem Server gespeichert", "hook_data_mail_desc": "Rohdaten-E-Mails auf dem Server gespeichert",
"id": "ID", "id": "ID",
"infos": "Informationen", "infos": "Informationen",
"install": "Installieren", "install": "Installieren",
@ -231,7 +231,7 @@
"logs_system": "Kernel-Logs und andere Ereignisse auf niederer Ebene", "logs_system": "Kernel-Logs und andere Ereignisse auf niederer Ebene",
"select_none": "Wähle keine", "select_none": "Wähle keine",
"skip": "Überspringe", "skip": "Überspringe",
"logs_share_with_yunopaste": "Teile mit YunoPaste", "logs_share_with_yunopaste": "Logs teilen mit YunoPaste",
"migrations_pending": "Ausstehende Migrationen", "migrations_pending": "Ausstehende Migrationen",
"logs_operation": "Operationen, die auf dem System mit YunoHost durchgeführt wurden", "logs_operation": "Operationen, die auf dem System mit YunoHost durchgeführt wurden",
"logs_history": "Historie der Befehlsausführung auf dem System", "logs_history": "Historie der Befehlsausführung auf dem System",
@ -262,7 +262,8 @@
"unignore": "Unignorieren", "unignore": "Unignorieren",
"warnings": "{count} Warnungen", "warnings": "{count} Warnungen",
"words": { "words": {
"default": "Vorgabe" "default": "Vorgabe",
"collapse": "Zusammenbruch"
}, },
"group": "Gruppe", "group": "Gruppe",
"details": "Details", "details": "Details",
@ -294,7 +295,7 @@
"all": "Alle", "all": "Alle",
"confirm_service_restart": "Bist du sicher, dass du {name} neustarten möchtest?", "confirm_service_restart": "Bist du sicher, dass du {name} neustarten möchtest?",
"run_first_diagnosis": "Initiale Diagnose läuft", "run_first_diagnosis": "Initiale Diagnose läuft",
"diagnosis_first_run": "Die Diagnosefunktion versucht, häufige Probleme in den verschiedenen Bereichen Ihres Servers zu identifizieren, um sicherzustellen, dass alles reibungslos läuft. Bitte geraten Sie nicht in Panik, wenn Sie direkt nach dem Einrichten Ihres Servers einen Haufen Fehler sehen: es soll Ihnen genau dabei helfen, Probleme zu identifizieren und Sie zur Behebung dieser Probleme anleiten. Die Diagnose läuft auch automatisch zweimal täglich, und wenn Probleme gefunden werden, wird eine E-Mail an den Administrator geschickt.", "diagnosis_first_run": "Die Diagnose Funktion wird versuchen, gängige Probleme in verschiedenen Teilen deines Servers zu finden, damit alles reibungslos läuft. Hab keine Angst, wenn du ein paar Fehlermeldungen siehst nachdem du deinen Server aufgesetzt hast: es soll versuchen dir zu helfen, Probleme zu identifizieren und Tipps für Lösungen zu zeigen. Die Diagnose wird auch automatisch zweimal täglich ausgeführt, falls Fehler gefunden werden, bekommt der Administrator ein E-Mail.",
"unmaintained_details": "Diese Anwendung wurde seit einiger Zeit nicht gewartet und der frühere Wart hat die Anwendung aufgegeben oder hat keine Zeit mehr für die Wartung. Du bist herzlich eingeladen dir die Quellen und Unterlagen anzusehen und Hilfe zu leisten", "unmaintained_details": "Diese Anwendung wurde seit einiger Zeit nicht gewartet und der frühere Wart hat die Anwendung aufgegeben oder hat keine Zeit mehr für die Wartung. Du bist herzlich eingeladen dir die Quellen und Unterlagen anzusehen und Hilfe zu leisten",
"group_explain_visitors_needed_for_external_client": "Sei vorsichtig und beachte, dass du manche Anwendungen für externe Besucher*innen freigeben musst, falls du beabsichtigst, diese mit externen Clients aufzurufen. Zum Beispiel trifft das auf Nextcloud zu, wenn du eine Synchronisation auf dem Smartphone oder Desktop PC haben möchtest.", "group_explain_visitors_needed_for_external_client": "Sei vorsichtig und beachte, dass du manche Anwendungen für externe Besucher*innen freigeben musst, falls du beabsichtigst, diese mit externen Clients aufzurufen. Zum Beispiel trifft das auf Nextcloud zu, wenn du eine Synchronisation auf dem Smartphone oder Desktop PC haben möchtest.",
"issues": "{count} Probleme", "issues": "{count} Probleme",
@ -303,5 +304,130 @@
"diagnosis_explanation": "Die Diagnose Funktion wird versuchen, gängige Probleme in verschiedenen Teilen deines Servers zu finden, damit alles reibungslos läuft. Die Diagnose wird auch automatisch zweimal täglich ausgeführt, falls Fehler gefunden werden, bekommt der Administrator ein E-Mail. Beachte, dass einige tests nicht relevant sind, wenn du einzelne Features (zum Beispiel XMPP) nicht benutzt oder du ein komplexes Setup hast. In diesem Fall und wenn du weisst was du tust ist es in Ordnung die dazugehoerigen Warnungen und Hinweise zu ignorieren.", "diagnosis_explanation": "Die Diagnose Funktion wird versuchen, gängige Probleme in verschiedenen Teilen deines Servers zu finden, damit alles reibungslos läuft. Die Diagnose wird auch automatisch zweimal täglich ausgeführt, falls Fehler gefunden werden, bekommt der Administrator ein E-Mail. Beachte, dass einige tests nicht relevant sind, wenn du einzelne Features (zum Beispiel XMPP) nicht benutzt oder du ein komplexes Setup hast. In diesem Fall und wenn du weisst was du tust ist es in Ordnung die dazugehoerigen Warnungen und Hinweise zu ignorieren.",
"pending_migrations": "Es gibt einige ausstehende Migrationen, die darauf warten, ausgeführt zu werden. Bitte gehen Sie auf <a href='#/tools/migrations'>Werkzeuge > Migrationen</a> um diese auszuführen.", "pending_migrations": "Es gibt einige ausstehende Migrationen, die darauf warten, ausgeführt zu werden. Bitte gehen Sie auf <a href='#/tools/migrations'>Werkzeuge > Migrationen</a> um diese auszuführen.",
"tip_about_user_email": "Benutzer*innen werden mit einer verknüpften eMail-Adresse (und XMPP Account) erstellt im Format username@domain.tld. Zusätzliche eMail-Aliasse and eMail-Weiterleitungen können später durch den/die Admin und User*in hinzugefügt werden.", "tip_about_user_email": "Benutzer*innen werden mit einer verknüpften eMail-Adresse (und XMPP Account) erstellt im Format username@domain.tld. Zusätzliche eMail-Aliasse and eMail-Weiterleitungen können später durch den/die Admin und User*in hinzugefügt werden.",
"logs_suboperations": "Unter-Operationen" "logs_suboperations": "Unter-Operationen",
"api_errors_titles": {
"APIBadRequestError": "Yunohost ist ein Fehler widerfahren",
"APIError": "Yunohost ist ein unerwarteter Fehler widerfahren",
"APIConnexionError": "Yunohost hat ein Verbindungsfehler festgestellt",
"APINotRespondingError": "Die Yunohost API antwortet nicht",
"APIInternalError": "Im Yunohost ist ein interner Fehler aufgetreten"
},
"api_error": {
"sorry": "Tut uns wirklich leid.",
"info": "Die folgenden Informationen könnten nützlich sein für die Person, die Ihnen hilft:",
"help": "Sie sollten für Hilfe <a href=\"https://forum.yunohost.org/\">im Forum</a> oder <a href=\"https://chat.yunohost.org/\">im Chat</a> nachschauen, um das Problem zu beheben, oder einen Bug melden im <a href=\"https://github.com/YunoHost/issues\">Bugtracker</a>."
},
"address": {
"local_part_description": {
"email": "Wählen Sie einen lokalen Teil für Ihre E-Mail.",
"domain": "Wählen Sie eine Subdomain."
},
"domain_description": {
"email": "Wählen Sie eine Domäne für Ihre E-Mail.",
"domain": "Wählen Sie eine Domäne."
}
},
"permission_show_tile_enabled": "Sichtbar als Kachel im Benutzerportal",
"permission_main": "Hauptrechte",
"permission_corresponding_url": "Entsprechender URL",
"cancel": "Abbrechen",
"app_show_categories": "Kategorien anzeigen",
"app_manage_label_and_tiles": "Etiketten und Kacheln verwalten",
"app_config_panel_no_panel": "Für diese Anwendung ist keine Konfiguration verfügbar",
"app_config_panel_label": "Konfigurieren dieser App",
"app_config_panel": "Konfigurationsfenster",
"app_choose_category": "Kategorie auswählen",
"app_actions_label": "Aktionen ausführen",
"api_waiting": "Warten auf Server Antwort...",
"user_emailforward_add": "Fügen Sie eine Mail-Weiterleitung hinzu",
"user_emailaliases_add": "Fügen Sie einen Mail-Alias hinzu",
"unknown": "Unbekannt",
"traceback": "Zurück verfolgen",
"tools_webadmin_settings": "Webadministratoreinstellungen",
"tools_webadmin": {
"transitions": "Seitenübergangsanimationen",
"experimental_description": "Ermöglicht den Zugriff auf experimentelle Funktionen. Diese gelten als instabil und können Ihr System beschädigen.<br>Nur aktivieren, wenn Sie wissen, was Sie tun.",
"experimental": "Experimenteller Modus",
"cache_description": "Deaktivieren Sie den Cache, wenn Sie mit der CLI arbeiten möchten, während Sie gleichzeitig in diesem Webadministrator navigieren.",
"cache": "Zwischenspeicher",
"fallback_language_description": "Sprache, die verwendet wird, falls die Übersetzung nicht in der Hauptsprache verfügbar ist.",
"language": "Sprache",
"fallback_language": "Fallback Sprache"
},
"tools_power_up": "Ihr Server scheint zugänglich zu sein. Sie können jetzt versuchen, sich anzumelden.",
"search": {
"not_found": "Es gibt {Elemente}, die Ihren Kriterien entsprechen.",
"for": "Suche nach {items} ..."
},
"readme": "Readme",
"postinstall_set_password": "Legen Sie das Administrationskennwort fest",
"postinstall_set_domain": "Hauptdomäne festlegen",
"placeholder": {
"domain": "my-domain.de",
"groupname": "Mein Gruppenname",
"lastname": "Mustermann",
"firstname": "Max",
"username": "maxmustermann"
},
"perform": "Ausführen",
"migrations_disclaimer_not_checked": "Für diese Migration müssen Sie den Haftungsausschluss bestätigen, bevor Sie sie ausführen.",
"migrations_disclaimer_check_message": "Ich habe diesen Haftungsausschluss gelesen und verstanden",
"mailbox_quota_example": "700M ist eine CD, 4700M ist eine DVD",
"items_verbose_count": "Es gibt {items}.",
"items": {
"users": "keine Benutzer | Benutzer | {c} Benutzer",
"services": "keine Dienste | Dienst | {c} Dienste",
"logs": "keine Protokolle | Protokoll | {c} Protokolle",
"installed_apps": "keine installierten Apps | installierte App | {c} installierte Apps",
"groups": "keine Gruppen | Gruppe | {c} Gruppen",
"domains": "keine Domains | Domain | {c} Domains",
"backups": "keine Backups | Backup | {c} Backups",
"apps": "keine Apps | App | {c} Apps"
},
"history": {
"methods": {
"DELETE": "entfernen",
"PUT": "bearbeiten",
"POST": "erstellen/ausführen"
},
"last_action": "Letzte Aktion:",
"title": "Historie"
},
"form_errors": {
"required": "Feld ist erforderlich.",
"passwordMatch": "Passwörter stimmen nicht überein.",
"passwordLenght": "Das Passwort muss mindestens 8 Zeichen lang sein.",
"number": "Wert muss eine Zahl sein.",
"notInUsers": "Der Benutzer '{value}' existiert bereits.",
"minValue": "Der Wert muss eine Zahl sein, die gleich oder größer als {min} ist.",
"name": "Namen dürfen keine Sonderzeichen außer <code>, .'- </code> enthalten",
"githubLink": "Die URL muss ein gültiger Github-Link zu einem Repository sein",
"emailForward": "Ungültige E-Mail-Weiterleitung: Sie darf nur aus alphanumerischen Zeichen und <code> _.-+</code> bestehen (z. B. jemand+tag@example.com, s0me-1+tag@example.com)",
"email": "Ungültige E-Mail: Sie darf nur aus alphanumerischen Zeichen und <code> _.-</code> bestehen (z. B. jemand@example.com, s0me-1@example.com)",
"dynDomain": "Ungültiger Domainname: Er darf nur aus alphanumerischen Kleinbuchstaben und Bindestrichen bestehen",
"domain": "Ungültiger Domainname: Er darf nur aus alphanumerischen Kleinbuchstaben, Punkt- und Strichzeichen bestehen",
"between": "Der Wert muss zwischen {min} und {max} liegen.",
"alphalownum_": "Der Wert darf nur aus alphanumerischen Kleinbuchstaben und Unterstrichen bestehen.",
"alpha": "Der Wert darf nur aus alphabetischen Zeichen bestehen."
},
"footer": {
"donate": "Spenden",
"help": "Brauchen Sie Hilfe?",
"documentation": "Dokumentation"
},
"experimental": "Experimentell",
"error": "Fehler",
"enabled": "Aktiviert",
"domain_delete_forbidden_desc": "Sie können '{domain}' nicht entfernen, da es sich um die Standarddomäne handelt. Sie müssen eine andere Domäne auswählen (oder <a href='#/domains/add'> eine neue hinzufügen </a>) und diese als Standarddomäne festlegen um die aktuelle entfernen zu können.",
"domain_add_dyndns_forbidden": "Sie haben bereits eine DynDNS-Domain abonniert. Sie können Ihre aktuelle DynDNS-Domain im Forum <a href = '// forum.yunohost.org/t/nohost-domain-recovery-suppression-de-domaine-en- entfernen nohost-me-noho-st-et-ynh-fr / 442 '> im dedizierten Thread </a>.",
"disabled": "Deaktiviert",
"dead": "Inaktiv",
"day_validity": " Abgelaufen seit | einem Tag | {count} Tage",
"confirm_app_install": "Möchtest du diese Anwendung wirklich installieren?",
"common": {
"lastname": "Nachname",
"firstname": "Vorname"
},
"code": "Code",
"app_actions": "Aktionen"
} }

View file

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

View file

@ -26,7 +26,7 @@
"confirm_change_maindomain": "Voulez-vous vraiment changer le domaine principal ?", "confirm_change_maindomain": "Voulez-vous vraiment changer le domaine principal ?",
"confirm_delete": "Voulez-vous vraiment supprimer {name} ?", "confirm_delete": "Voulez-vous vraiment supprimer {name} ?",
"confirm_install_custom_app": "ATTENTION ! Linstallation dapplications tierces peut compromettre lintégrité et la sécurité de votre système. Vous ne devriez probablement PAS linstaller si vous ne savez pas ce que vous faites. Prenez-vous ce risque ?", "confirm_install_custom_app": "ATTENTION ! Linstallation dapplications tierces peut compromettre lintégrité et la sécurité de votre système. Vous ne devriez probablement PAS linstaller si vous ne savez pas ce que vous faites. Prenez-vous ce risque ?",
"confirm_install_domain_root": "Vous ne pourrez pas installer d'autres applications sur {domain}. Continuer ?", "confirm_install_domain_root": "Êtes-vous sûr de vouloir installer cette application sur '/'? Vous ne pourrez installer aucune autre application sur {domain}",
"confirm_postinstall": "Vous êtes sur le point de lancer le processus de post-installation sur le domaine {domain}. Cela peut prendre du temps, *n'interrompez pas l'opération avant la fin*.", "confirm_postinstall": "Vous êtes sur le point de lancer le processus de post-installation sur le domaine {domain}. Cela peut prendre du temps, *n'interrompez pas l'opération avant la fin*.",
"confirm_restore": "Voulez-vous vraiment restaurer {name} ?", "confirm_restore": "Voulez-vous vraiment restaurer {name} ?",
"confirm_uninstall": "Voulez-vous vraiment désinstaller {name} ?", "confirm_uninstall": "Voulez-vous vraiment désinstaller {name} ?",
@ -87,7 +87,7 @@
"local_archives": "Archives locales", "local_archives": "Archives locales",
"login": "Connexion", "login": "Connexion",
"logout": "Déconnexion", "logout": "Déconnexion",
"mailbox_quota_description": "Par exemple, 700M est un CD, 4700M est un DVD.", "mailbox_quota_description": "Définissez une taille limite de stockage pour vos courriels.<br>Mettre 0 pour la désactiver.",
"mailbox_quota_placeholder": "Laissez vide ou à zéro pour désactiver.", "mailbox_quota_placeholder": "Laissez vide ou à zéro pour désactiver.",
"manage_apps": "Gérer les applications", "manage_apps": "Gérer les applications",
"manage_domains": "Gérer les domaines", "manage_domains": "Gérer les domaines",
@ -155,8 +155,8 @@
"yes": "Oui", "yes": "Oui",
"form_input_example": "Exemple : {example}", "form_input_example": "Exemple : {example}",
"footer_version": "Propulsé par <a href='https://yunohost.org'>YunoHost</a> {version} ({repo}).", "footer_version": "Propulsé par <a href='https://yunohost.org'>YunoHost</a> {version} ({repo}).",
"certificate_alert_not_valid": "CRITIQUE : le certificat actuel nest pas valide ! Le HTTPS ne fonctionnera pas du tout !", "certificate_alert_not_valid": "CRITIQUE : Le certificat actuel est invalide ! HTTPS ne fonctionnera pas du tout !",
"certificate_alert_selfsigned": "AVERTISSEMENT : le certificat actuel est auto-signé. Les navigateurs afficheront un avertissement effrayant pour les nouveaux visiteurs !", "certificate_alert_selfsigned": "AVERTISSEMENT : Le certificat actuel est auto-signé. Les navigateurs afficheront un avertissement effrayant pour les nouveaux visiteurs !",
"certificate_alert_letsencrypt_about_to_expire": "Le certificat actuel est sur le point dexpirer. Il devrait bientôt être renouvelé automatiquement.", "certificate_alert_letsencrypt_about_to_expire": "Le certificat actuel est sur le point dexpirer. Il devrait bientôt être renouvelé automatiquement.",
"certificate_alert_about_to_expire": "AVERTISSEMENT : le certificat actuel est sur le point dexpirer ! Il ne sera PAS renouvelé automatiquement !", "certificate_alert_about_to_expire": "AVERTISSEMENT : le certificat actuel est sur le point dexpirer ! Il ne sera PAS renouvelé automatiquement !",
"certificate_alert_good": "Bien, le certificat actuel a lair correct !", "certificate_alert_good": "Bien, le certificat actuel a lair correct !",
@ -199,7 +199,7 @@
"tools_reboot": "Redémarrer votre serveur", "tools_reboot": "Redémarrer votre serveur",
"tools_reboot_btn": "Redémarrer", "tools_reboot_btn": "Redémarrer",
"tools_reboot_done": "Redémarrage…", "tools_reboot_done": "Redémarrage…",
"tools_rebooting": "Votre serveur redémarre. Pour retourner sur linterface d'administration vous devez attendre que votre serveur soit démarré. Vous pouvez le vérifier en actualisant cette page (F5).", "tools_rebooting": "Votre serveur redémarre. Pour retourner sur linterface d'administration vous devez attendre que votre serveur soit démarré. Vous pouvez attendre que le formulaire de connexion apparaisse ou vous pouvez actualiser cette page (F5).",
"tools_shutdown": "Éteindre votre serveur", "tools_shutdown": "Éteindre votre serveur",
"tools_shutdown_btn": "Éteindre", "tools_shutdown_btn": "Éteindre",
"tools_shutdown_done": "Extinction…", "tools_shutdown_done": "Extinction…",
@ -220,7 +220,7 @@
"app_no_actions": "Cette application ne possède aucune action", "app_no_actions": "Cette application ne possède aucune action",
"confirm_install_app_lowquality": "Avertissement : cette application peut fonctionner mais nest pas bien intégrée dans YunoHost. Certaines fonctionnalités telles que lauthentification unique et la sauvegarde/restauration pourraient ne pas être disponibles.", "confirm_install_app_lowquality": "Avertissement : cette application peut fonctionner mais nest pas bien intégrée dans YunoHost. Certaines fonctionnalités telles que lauthentification unique et la sauvegarde/restauration pourraient ne pas être disponibles.",
"confirm_install_app_inprogress": "AVERTISSEMENT ! Cette application est encore expérimentale et risque de casser votre système ! Vous ne devriez probablement PAS linstaller si vous ne savez pas ce que vous faites. Voulez-vous vraiment prendre ce risque ?", "confirm_install_app_inprogress": "AVERTISSEMENT ! Cette application est encore expérimentale et risque de casser votre système ! Vous ne devriez probablement PAS linstaller si vous ne savez pas ce que vous faites. Voulez-vous vraiment prendre ce risque ?",
"error_connection_interrupted": "Le serveur a fermé la connexion au lieu dy répondre. Est-ce que NGINX ou l'API YunoHost aurait été redémarré ou arrêté ?", "error_connection_interrupted": "Le serveur a fermé la connexion au lieu dy répondre. Est-ce que nginx ou yunohost-api ont été redémarrés ou arrêtés pour une raison quelconque?",
"experimental_warning": "Attention : cette fonctionnalité est expérimentale et ne doit pas être considérée comme stable, vous ne devriez pas lutiliser à moins que vous ne sachiez ce que vous faites...", "experimental_warning": "Attention : cette fonctionnalité est expérimentale et ne doit pas être considérée comme stable, vous ne devriez pas lutiliser à moins que vous ne sachiez ce que vous faites...",
"good_practices_about_admin_password": "Vous êtes maintenant sur le point de définir un nouveau mot de passe administrateur. Le mot de passe doit comporter au moins 8 caractères — bien quil soit recommandé dutiliser un mot de passe plus long (cest-à-dire une phrase secrète) et/ou dutiliser différents types de caractères (majuscules, minuscules, chiffres et caractères spéciaux).", "good_practices_about_admin_password": "Vous êtes maintenant sur le point de définir un nouveau mot de passe administrateur. Le mot de passe doit comporter au moins 8 caractères — bien quil soit recommandé dutiliser un mot de passe plus long (cest-à-dire une phrase secrète) et/ou dutiliser différents types de caractères (majuscules, minuscules, chiffres et caractères spéciaux).",
"good_practices_about_user_password": "Vous êtes maintenant sur le point de définir un nouveau mot de passe pour l'utilisateur. Le mot de passe doit comporter au moins 8 caractères - bien quil soit recommandé dutiliser un mot de passe plus long (cest-à-dire une phrase secrète) et/ou dutiliser différents types de caractères tels que : majuscules, minuscules, chiffres et caractères spéciaux.", "good_practices_about_user_password": "Vous êtes maintenant sur le point de définir un nouveau mot de passe pour l'utilisateur. Le mot de passe doit comporter au moins 8 caractères - bien quil soit recommandé dutiliser un mot de passe plus long (cest-à-dire une phrase secrète) et/ou dutiliser différents types de caractères tels que : majuscules, minuscules, chiffres et caractères spéciaux.",
@ -239,10 +239,10 @@
"logs_started_at": "Début", "logs_started_at": "Début",
"logs_path": "Chemin", "logs_path": "Chemin",
"logs_context": "Contexte", "logs_context": "Contexte",
"logs_share_with_yunopaste": "Partager avec YunoPaste", "logs_share_with_yunopaste": "Partager les logs avec YunoPaste",
"logs_more": "Afficher plus de lignes", "logs_more": "Afficher plus de lignes",
"unmaintained": "Non maintenue", "unmaintained": "Non maintenue",
"purge_user_data_checkbox": "Purger les données de l'utilisateur %s ? (Cela supprimera le contenu du dossier personnel de %s ainsi que tous les courriers électroniques de %s .)", "purge_user_data_checkbox": "Purger les données de {name}? (Cela supprimera toutes les données de son répertoire ainsi que ses courriels)",
"purge_user_data_warning": "La purge des données de lutilisateur nest pas réversible. Assurez-vous de savoir ce que vous faites !", "purge_user_data_warning": "La purge des données de lutilisateur nest pas réversible. Assurez-vous de savoir ce que vous faites !",
"version": "Version", "version": "Version",
"confirm_update_system": "Voulez-vous vraiment mettre à jour tous les paquets système ?", "confirm_update_system": "Voulez-vous vraiment mettre à jour tous les paquets système ?",
@ -263,7 +263,7 @@
"group": "Groupe", "group": "Groupe",
"group_all_users": "Tous les utilisateurs", "group_all_users": "Tous les utilisateurs",
"group_visitors": "Visiteurs", "group_visitors": "Visiteurs",
"group_format_name_help": "Vous pouvez utiliser des caractères alphanumériques et des espaces", "group_format_name_help": "Vous pouvez utiliser des caractères alphanumériques et des tirets bas",
"group_add_member": "Ajouter un utilisateur", "group_add_member": "Ajouter un utilisateur",
"group_add_permission": "Ajouter une permission", "group_add_permission": "Ajouter une permission",
"group_new": "Nouveau groupe", "group_new": "Nouveau groupe",
@ -283,7 +283,8 @@
"unignore": "Cesser d'ignorer", "unignore": "Cesser d'ignorer",
"warnings": "{count} avertissements", "warnings": "{count} avertissements",
"words": { "words": {
"default": "Défaut" "default": "Défaut",
"collapse": "Replier"
}, },
"configuration": "Configuration", "configuration": "Configuration",
"since": "depuis", "since": "depuis",
@ -304,8 +305,129 @@
"pending_migrations": "Il y a des migrations en suspens qui attentent d'être exécutées. Veuillez aller dans <a href='#/tools/migrations'>Outils > Migrations</a> pour les exécuter.", "pending_migrations": "Il y a des migrations en suspens qui attentent d'être exécutées. Veuillez aller dans <a href='#/tools/migrations'>Outils > Migrations</a> pour les exécuter.",
"tip_about_user_email": "Les utilisateurs sont créés avec une adresse e-mail associée (et un compte XMPP) au format username@domain.tld. Des alias d'email et des transferts d'emails supplémentaires peuvent être ajoutés ultérieurement par l'administrateur et l'utilisateur.", "tip_about_user_email": "Les utilisateurs sont créés avec une adresse e-mail associée (et un compte XMPP) au format username@domain.tld. Des alias d'email et des transferts d'emails supplémentaires peuvent être ajoutés ultérieurement par l'administrateur et l'utilisateur.",
"logs_suboperations": "Sous-opérations", "logs_suboperations": "Sous-opérations",
"permission_show_tile_enabled": "Montrer la tuile dans le portail utilisateur", "permission_show_tile_enabled": "Visible en tuile dans le portail utilisateur",
"permission_main": "Permission principale", "permission_main": "Permission principale",
"permission_corresponding_url": "URL correspondante", "permission_corresponding_url": "URL correspondante",
"app_manage_label_and_tiles": "Gérer les étiquettes et les tuiles" "app_manage_label_and_tiles": "Gérer les étiquettes et les tuiles",
"user_emailforward_add": "Ajouter une adresse mail de redirection",
"user_emailaliases_add": "Ajouter un alias de courriel",
"unknown": "Inconnu",
"traceback": "Trace",
"tools_webadmin_settings": "Paramètres de l'administration web",
"tools_webadmin": {
"transitions": "Animations de transition entre les pages",
"experimental_description": "Cela vous donne accès à des fonctionnalités expérimentales. Celles-ci sont considérées comme instables et peuvent casser votre système.<br> Ne les activez uniquement si vous savez ce que vous faites.",
"experimental": "Mode expérimental",
"cache_description": "Pensez à désactiver le cache si vous prévoyez de travailler avec l'interface en ligne de commande (CLI) tout en naviguant dans l'administration web (web-admin/panel web).",
"cache": "Cache",
"fallback_language_description": "Langue qui sera utilisée au cas où la traduction ne serait pas disponible dans la langue principale.",
"fallback_language": "Langue de secours",
"language": "Langue"
},
"tools_power_up": "Votre serveur semble être accessible, vous pouvez maintenant essayer de vous connecter.",
"search": {
"not_found": "Il y a des {items} qui correspondent à vos critères.",
"for": "Rechercher {items} ..."
},
"readme": "Lisez-moi",
"postinstall_set_password": "Définir le mot de passe d'administration",
"postinstall_set_domain": "Définir le domaine principal",
"placeholder": {
"domain": "mon-domaine.fr",
"groupname": "Le nom de mon groupe",
"lastname": "Dupont",
"firstname": "Jean",
"username": "jeandupont"
},
"perform": "Exécuter",
"migrations_disclaimer_not_checked": "Cette migration nécessite que vous preniez connaissance de sa décharge de responsabilité avant de l'exécuter.",
"migrations_disclaimer_check_message": "J'ai lu et compris cette décharge de responsabilité",
"mailbox_quota_example": "700 M correspond à un CD, 4 700 M correspond à un DVD",
"items_verbose_count": "Il y a {items}.",
"items": {
"users": "aucun utilisateur | utilisateur | {c} utilisateurs",
"services": "aucun service | service | {c} services",
"logs": "aucun historique/log | log | {c} logs",
"installed_apps": "aucune application installée | application installée | {c} applications installées",
"groups": "aucun groupe | groupe | {c} groupes",
"domains": "aucun domaine | domaine | {c} domaines",
"backups": "aucune sauvegarde | sauvegarde | {c} sauvegardes",
"apps": "aucune application | app | {c} apps"
},
"history": {
"methods": {
"DELETE": "effacer",
"PUT": "modifier",
"POST": "créer/exécuter"
},
"last_action": "Dernière action :",
"title": "Historique"
},
"form_errors": {
"required": "Ce champ est obligatoire.",
"passwordMatch": "Les mots de passe ne correspondent pas.",
"passwordLenght": "Le mot de passe doit comporter au moins 8 caractères.",
"number": "La valeur doit être un nombre.",
"notInUsers": "L'utilisateur '{value}' existe déjà.",
"minValue": "La valeur doit être un nombre égal ou supérieur à {min}.",
"name": "Les noms ne peuvent pas comporter de caractères spéciaux, sauf <code> ,.'-</code>",
"githubLink": "L'URL doit être un lien Github valide vers un dépôt",
"emailForward": "Adresse de transfert de courrier électronique invalide : elle doit être composée de caractères alphanumérique et de <code>_.-+</code> seulement (par exemple, someone+tag@example.com, s0me-1+tag@example.com)",
"email": "Adresse de courriel invalide : elle doit être composée de caractères alphanumérique et des caractères <code>_.-</code> seulement (par exemple someone@example.com, s0me-1@example.com)",
"dynDomain": "Nom de domaine invalide : Il doit être composé de minuscules alphanumériques et de tirets uniquement",
"domain": "Nom de domaine invalide : Il doit être composé de minuscules alphanumériques, de points et de tirets uniquement",
"between": "La valeur doit être comprise entre {min} et {max}.",
"alpha": "La chaîne de caractères ne doit contenir que des lettres.",
"alphalownum_": "La chaîne de caractères doit être composé uniquement de caractères alphanumériques minuscules et de tirets bas (aussi appelé tiret du 8 ou underscore)."
},
"footer": {
"donate": "Faire un don",
"help": "Besoin d'aide ?",
"documentation": "Documentation"
},
"experimental": "Expérimental",
"error": "Erreur",
"enabled": "Activé",
"domain_delete_forbidden_desc": "Vous ne pouvez pas supprimer '{domain}' car c'est le domaine par défaut, vous devez choisir un autre domaine (ou <a href='#/domains/add'> ajoutez en un nouveau</a>) et le définir comme le domaine par défaut pour pouvoir supprimer celui-ci.",
"domain_add_dyndns_forbidden": "Vous avez déjà souscrit à un domaine DynDNS, vous pouvez demander la suppression de votre domaine DynDNS actuel sur le forum <a href='//forum.yunohost.org/t/nohost-domain-recovery-suppression-de-domaine-en-nohost-me-noho-st-et-ynh-fr/442'> dans le fil de discussion dédié</a>.",
"disabled": "Désactivé",
"dead": "Inactif",
"day_validity": " Expiré | 1 jour | {count} jours",
"confirm_app_install": "Êtes-vous sûr de vouloir installer cette application ?",
"common": {
"lastname": "Nom de famille",
"firstname": "Prénom"
},
"code": "Code",
"cancel": "Annuler",
"app_show_categories": "Afficher les catégories",
"app_config_panel_no_panel": "Cette application n'a aucune configuration disponible",
"app_config_panel_label": "Configurez cette application",
"app_config_panel": "Panneau de configuration",
"app_choose_category": "Choisissez une catégorie",
"app_actions_label": "Exécuter les actions",
"app_actions": "Actions",
"api_waiting": "Attente de la réponse du serveur ...",
"api_errors_titles": {
"APIConnexionError": "Yunohost a rencontré une erreur de connexion",
"APINotRespondingError": "L'API Yunohost ne répond pas",
"APIInternalError": "Yunohost a rencontré une erreur interne",
"APIBadRequestError": "Yunohost a rencontré une erreur",
"APIError": "Yunohost a rencontré une erreur inattendue"
},
"api_error": {
"sorry": "Vraiment désolé de cela.",
"info": "Les informations suivantes peuvent être utiles à la personne qui vous aide :",
"help": "Vous devez chercher de l'aide sur <a href=\"https://forum.yunohost.org/\"> le forum</a> ou <a href=\"https://chat.yunohost.org/\">le chat</a> pour corriger la situation, ou signaler le bug sur <a href=\"https://github.com/YunoHost/issues\"> le bugtracker</a>."
},
"address": {
"local_part_description": {
"email": "Choisissez une section locale pour votre courriel.",
"domain": "Choisissez un sous-domaine."
},
"domain_description": {
"email": "Choisissez un domaine pour votre courrier électronique.",
"domain": "Choisissez un domaine."
}
}
} }

View file

@ -307,5 +307,13 @@
"app_manage_label_and_tiles": "Gestion de las apelacions e títols", "app_manage_label_and_tiles": "Gestion de las apelacions e títols",
"permission_corresponding_url": "URL correspondenta", "permission_corresponding_url": "URL correspondenta",
"permission_main": "Permission principala", "permission_main": "Permission principala",
"permission_show_tile_enabled": "Afichar lo teule al portal utilizaire" "permission_show_tile_enabled": "Afichar lo teule al portal utilizaire",
"address": {
"local_part_description": {
"domain": "Causissètz un jos-domeni."
},
"domain_description": {
"domain": "Causissètz un domeni."
}
}
} }

View file

@ -2,10 +2,10 @@
"action": "Действие", "action": "Действие",
"add": "Добавить", "add": "Добавить",
"administration_password": "Пароль администратора", "administration_password": "Пароль администратора",
"api_not_responding": "API не отвечает", "api_not_responding": "YunoHost API не отвечает. Может быть, 'yunohost-api' не работает или только что перезапускался?",
"app_info_access_desc": "Управление доступом пользователей. Разрешенные пользователи: %s", "app_info_access_desc": "Группы / пользователи, у которых есть доступ к этому приложению:",
"app_info_default_desc": "Перенаправить домен root в это приложение ({domain}).", "app_info_default_desc": "Перенаправить корень сайта в это приложение ({domain}).",
"app_info_uninstall_desc": "Удалите это приложение.", "app_info_uninstall_desc": "Удалить приложение.",
"app_install_custom_no_manifest": "Нет файла manifest.json", "app_install_custom_no_manifest": "Нет файла manifest.json",
"app_make_default": "Использовать по умолчанию", "app_make_default": "Использовать по умолчанию",
"app_state_inprogress": "Выполняется", "app_state_inprogress": "Выполняется",
@ -14,7 +14,7 @@
"login": "Логин", "login": "Логин",
"logout": "Выйти", "logout": "Выйти",
"ok": "ОК", "ok": "ОК",
"app_info_changeurl_desc": "Изменить url доступа к этому приложению (домен и/ли путь).", "app_info_changeurl_desc": "Изменить ссылку доступа к этому приложению (домен и/ли путь).",
"app_info_change_url_disabled_tooltip": "Эта опция ещё не реализована в этом приложении", "app_info_change_url_disabled_tooltip": "Эта опция ещё не реализована в этом приложении",
"app_no_actions": "Это приложение не осуществляет никаких действий", "app_no_actions": "Это приложение не осуществляет никаких действий",
"app_state_notworking": "Не работает", "app_state_notworking": "Не работает",
@ -219,5 +219,83 @@
"system_upgrade_all_applications_btn": "Обновление всех приложений", "system_upgrade_all_applications_btn": "Обновление всех приложений",
"system_upgrade_all_packages_btn": "Обновление всех пакетов", "system_upgrade_all_packages_btn": "Обновление всех пакетов",
"tcp": "TCP", "tcp": "TCP",
"certificate_authority": "Центр сертификации" "certificate_authority": "Центр сертификации",
"form_errors": {
"between": "Значение должно быть между {min} и {max}.",
"alphalownum_": "В значении могут быть только буквы в нижнем регистре, цифры и символ подчёркивания.",
"alpha": "Значением могут быть только буквы."
},
"footer": {
"donate": "Пожертвования",
"help": "Нужна помощь?",
"documentation": "Документация"
},
"experimental": "Экспериментальное",
"everything_good": "Всё хорошо!",
"error_connection_interrupted": "Сервер закрыл соединение вместо ответа. Перезагружался ли Nginx или YunoHost API, или они не работают по какой либо причине?",
"error": "Ошибка",
"enabled": "Включено",
"domain_delete_forbidden_desc": "Вы не можете удалить домен '{domain}', так как он является доменом по умолчанию. Вы должны сначала выбрать другой домен по умолчанию (либо <a href='#/domains/add'>создать новый</a>), после чего можно будет его удалить.",
"domain_add_dyndns_forbidden": "Вы уже подписаны на домен DynDNS, вы можете попросить удалить ваш домен DynDNS на форуме <a href='//forum.yunohost.org/t/nohost-domain-recovery-suppression-de-domaine-en-nohost-me-noho-st-et-ynh-fr/442'>в этой выделенной ветке</a>.",
"disabled": "Отключено",
"run_first_diagnosis": "Провести начальную диагностику",
"diagnosis_first_run": "Функция диагностики попробует найти типичные проблемы разных аспектов вашего сервера, для того, чтобы убедиться, что сервер работает стабильно. Пожалуйста, не паникуйте, если Вы видите кучу ошибок сразу после поднятия сервера: данная функция какраз разработана для того, чтобы найти ошибки и помочь вам их починить. Диагностика будет запускаться дважды в день, после чего будет направляться по электронной почте отчёт администратору в случае обнаружения проблем.",
"diagnosis_experimental_disclaimer": "Будьте внимательны, ведь функция диагностики всё еще экспериментальная и на стадии разработки, а так же не на 100% надёжная.",
"details": "Подробности",
"dead": "Не активный",
"day_validity": " Истекшее | 1 день | {count} дня(-ей)",
"confirm_update_system": "Вы точно хотите обновить все системные пакеты?",
"confirm_service_restart": "Вы точно хотите перезапустить {name}?",
"confirm_migrations_skip": "Не рекомендуется пропуск миграций. Вы точно в этом уверены?",
"confirm_install_app_inprogress": "ВНИМАНИЕ! Это всё еще экспериментальное приложение (или вообще специально не работает) и с большей долей вероятности сломает Вашу систему. Настоятельно рекомендуется НЕ устанавливать, если вы не уверены в том, что делаете. Вы хотите принять этот риск?",
"confirm_install_app_lowquality": "Внимание: это приложения может работать, но оно плохо интегрировано с YunoHost. Некоторые функции типа SSO или бэкапов могут быть не доступны.",
"confirm_app_install": "Вы уверены, что хотите установить это приложение?",
"configuration": "Настройки",
"common": {
"lastname": "Фамилия",
"firstname": "Имя"
},
"code": "Код",
"catalog": "Каталог",
"cancel": "Отмена",
"app_state_working_explanation": "Создатель приложения пометил его как \"рабочее\". Это означает, что оно должно быть рабочим (на уровне приложения), но не проверено сообществом. Оно может содержать ошибки или не полностью интегрировано с YunoHost.",
"app_state_highquality_explanation": "Это приложение хорошо интегрировано с YunoHost. Оно было проверено командой YunoHost. От приложения ожидается, что оно безопасно для использования, а так же будет осуществляться его долговременная поддержка.",
"app_state_highquality": "высокое качество",
"app_state_lowquality_explanation": "Это приложение может быть рабочим, но все еще может содержать ошибки или не полностью интегрировано с YunoHost, или не следует хорошим практикам.",
"app_state_lowquality": "низкое качество",
"app_state_notworking_explanation": "Создатель приложения пометил его как не рабочее. ЭТО ПРИЛОЖЕНИЕ СЛОМАЕТ ВАШУ СИСТЕМУ!",
"app_state_inprogress_explanation": "Создатель приложения пометил это приложение, как не готовое к ежедневному пользованию. БУДЬТЕ ОСТОРОЖНЫ!",
"app_show_categories": "Показать категории",
"app_manage_label_and_tiles": "Управление меткой и заголовками",
"app_config_panel_no_panel": "У этого приложения нет настроек",
"app_config_panel_label": "Настроить это приложение",
"app_config_panel": "Панель настроек",
"app_choose_category": "Выберите категорию",
"app_actions_label": "Предпринимать действия",
"app_actions": "Действия",
"api_waiting": "Ожидание ответа сервера...",
"all_apps": "Все приложения",
"api_errors_titles": {
"APIConnexionError": "Ошибка внутри пакета connexion",
"APINotRespondingError": "Апи Yunohost не отвечает",
"APIInternalError": "Внутренняя ошибка на сервере Yunohost",
"APIBadRequestError": "Ошибка на сервере Yunohost",
"APIError": "Неожиданная ошибка на сервере Yunohost"
},
"api_error": {
"sorry": "Нам очень жаль.",
"info": "Следующая информация может быть полезня для помогающего человека:",
"help": "Вы можете обратиться за помощью на <a href=\"https://forum.yunohost.org/\">форум</a> или в <a href=\"https://chat.yunohost.org/\">чат</a>, или сообщить о неполадке в <a href=\"https://github.com/YunoHost/issues\">багтрекер</a>."
},
"all": "Все",
"address": {
"local_part_description": {
"email": "Выберите локальную часть вашей почты.",
"domain": "Выберите поддомен."
},
"domain_description": {
"email": "Выберите домен для вашей почты.",
"domain": "Выберите домен."
}
}
} }

View file

@ -26,9 +26,122 @@
"app_info_uninstall_desc": "删除此应用程序。", "app_info_uninstall_desc": "删除此应用程序。",
"app_info_default_desc": "重定向域根到这个应用({domain})。", "app_info_default_desc": "重定向域根到这个应用({domain})。",
"unignore": "取消忽略", "unignore": "取消忽略",
"last_ran": "最近一次运行:", "last_ran": "次运行:",
"app_info_change_url_disabled_tooltip": "此应用尚未实现该功能", "app_info_change_url_disabled_tooltip": "此应用尚未实现该功能",
"archive_empty": "空存档", "archive_empty": "空存档",
"app_state_lowquality": "低质量", "app_state_lowquality": "低质量",
"app_state_inprogress": "暂不工作" "app_state_inprogress": "暂不工作",
"ports": "端口",
"port": "端口",
"logs_more": "显示更多行",
"logs_path": "路径",
"logs_started_at": "开始",
"logs_ended_at": "结束",
"logs_error": "错误",
"logs_app": "应用日志",
"logs_service": "服务日志",
"logs": "日志",
"path": "路径",
"migrations_done": "上次迁移",
"migrations_pending": "待定迁移",
"migrations": "迁移",
"manage_users": "管理用户",
"manage_domains": "管理域",
"manage_apps": "管理应用",
"logout": "登出",
"login": "登录",
"local_archives": "本地档案",
"license": "许可",
"label": "标签",
"ipv6": "IPv6",
"ipv4": "IPv4",
"installed": "已安装",
"installation_complete": "安装完成",
"install_time": "安装时间",
"install_name": "安装{id}",
"install": "安装",
"infos": "信息",
"ignore": "忽略",
"id": "ID",
"hook_data_mail": "邮件",
"hook_data_home": "用户密码",
"hook_conf_ynh_mysql": "MySQL密码",
"hook_conf_ynh_firewall": "防火墙",
"hook_conf_nginx": "Nginx",
"hook_conf_ssh": "SSH",
"hook_conf_ssowat": "SSOwat",
"hook_conf_xmpp": "XMPP",
"hook_conf_ynh_certs": "SSL证书",
"hook_conf_ldap": "LDAP数据库",
"hook_conf_ynh_currenthost": "当前主域",
"hook_conf_cron": "自动化任务",
"hook_adminjs_group_configuration": "系统配置",
"home": "家",
"history": {
"methods": {
"DELETE": "删除",
"PUT": "修改",
"POST": "创建/执行"
},
"title": "历史"
},
"permissions": "权限",
"group_new": "新群组",
"group_add_member": "添加一个用户",
"group_visitors": "访客",
"group_all_users": "所有用户",
"form_errors": {
"passwordLenght": "密码至少需要是8个字符长。"
},
"footer": {
"donate": "赞助",
"help": "需要帮助?",
"documentation": "文档"
},
"firewall": "防火墙",
"experimental": "实验性",
"error_server_unexpected": "未知服务器错误",
"error": "错误",
"enabled": "已启用",
"enable": "启用",
"download": "下载",
"domains": "域",
"domain_visit_url": "访问 {url}",
"domain_visit": "访问",
"domain_name": "域名",
"domain_dns_longdesc": "显示DNS配置",
"domain_dns_config": "DNS配置",
"domain_delete_longdesc": "删除这个域",
"domain_add_panel_without_domain": "我没有一个域名……",
"domain_default_longdesc": "这是你的默认域。",
"domain_add": "添加域",
"dns": "DNS",
"disabled": "已禁用",
"disable": "禁用",
"run_first_diagnosis": "运行初始诊断",
"diagnosis": "诊断",
"details": "详情",
"description": "介绍",
"delete": "删除",
"dead": "不活跃",
"custom_app_install": "安装自定义应用",
"connection": "连接",
"confirm_app_change_url": "你确定你希望修改这个应用的访问URL",
"common": {
"lastname": "姓",
"firstname": "名"
},
"api_waiting": "正在等待服务器响应……",
"app_actions_label": "执行动作",
"app_actions": "动作",
"address": {
"domain_description": {
"email": "为你的邮箱选择一个网域。",
"domain": "选择一个网域。"
},
"local_part_description": {
"domain": "选择一个子网域。"
}
},
"cancel": "取消"
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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