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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -307,5 +307,84 @@
"permission_show_tile_enabled": "Mostrar la «tile» en el portal de l'usuari",
"permission_main": "Permís principal",
"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",
"add": "Přidat",
"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_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_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_restore": "Möchtest du wirklich {name} wiederherstellen?",
"confirm_uninstall": "Möchtest du wirklich {name} deinstallieren?",
@ -60,7 +60,7 @@
"hook_data_home": "Benutzerdaten",
"hook_data_home_desc": "Die Daten des Benutzers werden gespeichert unter /home/USER",
"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",
"infos": "Informationen",
"install": "Installieren",
@ -231,7 +231,7 @@
"logs_system": "Kernel-Logs und andere Ereignisse auf niederer Ebene",
"select_none": "Wähle keine",
"skip": "Überspringe",
"logs_share_with_yunopaste": "Teile mit YunoPaste",
"logs_share_with_yunopaste": "Logs teilen mit YunoPaste",
"migrations_pending": "Ausstehende Migrationen",
"logs_operation": "Operationen, die auf dem System mit YunoHost durchgeführt wurden",
"logs_history": "Historie der Befehlsausführung auf dem System",
@ -262,7 +262,8 @@
"unignore": "Unignorieren",
"warnings": "{count} Warnungen",
"words": {
"default": "Vorgabe"
"default": "Vorgabe",
"collapse": "Zusammenbruch"
},
"group": "Gruppe",
"details": "Details",
@ -294,7 +295,7 @@
"all": "Alle",
"confirm_service_restart": "Bist du sicher, dass du {name} neustarten möchtest?",
"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",
"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",
@ -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.",
"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.",
"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",
"all": "All",
"api": {
"processing": "The server is processing the action...",
"query_status": {
"error": "Unsuccessful",
"pending": "In progress",
"success": "Successfully completed",
"warning": "Successfully completed with errors or alerts"
}
},
"api_error": {
"error_message": "Error message:",
"help": "You should look for help on <a href=\"https://forum.yunohost.org/\">the forum</a> or <a href=\"https://chat.yunohost.org/\">the chat</a> to fix the situation, or report the bug on <a href=\"https://github.com/YunoHost/issues\">the bugtracker</a>.",
"info": "The following information might be useful for the person helping you:",
"sorry": "Really sorry about that."
"server_said": "While processing the action the server said:",
"sorry": "Really sorry about that.",
"view_error": "View error"
},
"api_errors_titles": {
"APIError": "Yunohost encountered an unexpected error",
"APIBadRequestError": "Yunohost encountered an error",
"APIInternalError": "Yunohost encountered an internal error",
"APINotFoundError": "Yunohost API could not find a route",
"APINotRespondingError": "Yunohost API is not responding",
"APIConnexionError": "Yunohost encountered an connexion error"
"APIConnexionError": "Yunohost encountered a connexion error"
},
"all_apps": "All apps",
"api_not_found": "Seems like the web-admin tryed to query something that doesn't exist.",
"api_not_responding": "The YunoHost API is not responding. Maybe 'yunohost-api' is down or got restarted?",
"api_waiting": "Waiting for the server's response...",
"app_actions": "Actions",
@ -168,6 +182,7 @@
},
"form_input_example": "Example: {example}",
"from_to": "from {0} to {1}",
"go_back": "Go back",
"good_practices_about_admin_password": "You are now about to define a new admin password. The password should be at least 8 characters - though it is good practice to use longer password (i.e. a passphrase) and/or to use various kind of characters (uppercase, lowercase, digits and special characters).",
"good_practices_about_user_password": "You are now about to define a new user password. The password should be at least 8 characters - though it is good practice to use longer password (i.e. a passphrase) and/or to use various kind of characters (uppercase, lowercase, digits and special characters).",
"group": "Group",
@ -189,9 +204,10 @@
"title": "History",
"last_action": "Last action:",
"methods": {
"DELETE": "delete",
"GET": "read",
"POST": "create/execute",
"PUT": "modify",
"DELETE": "delete"
"PUT": "modify"
}
},
"home": "Home",
@ -398,7 +414,8 @@
"warnings": "{count} warnings",
"words": {
"collapse": "Collapse",
"default": "Default"
"default": "Default",
"dismiss": "Dismiss"
},
"wrong_password": "Wrong password",
"yes": "Yes",

View file

@ -26,7 +26,7 @@
"confirm_change_maindomain": "Voulez-vous vraiment changer le domaine principal ?",
"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_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_restore": "Voulez-vous vraiment restaurer {name} ?",
"confirm_uninstall": "Voulez-vous vraiment désinstaller {name} ?",
@ -87,7 +87,7 @@
"local_archives": "Archives locales",
"login": "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.",
"manage_apps": "Gérer les applications",
"manage_domains": "Gérer les domaines",
@ -155,8 +155,8 @@
"yes": "Oui",
"form_input_example": "Exemple : {example}",
"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_selfsigned": "AVERTISSEMENT : le certificat actuel est auto-signé. Les navigateurs afficheront un avertissement effrayant pour les nouveaux visiteurs !",
"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_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_good": "Bien, le certificat actuel a lair correct !",
@ -199,7 +199,7 @@
"tools_reboot": "Redémarrer votre serveur",
"tools_reboot_btn": "Redémarrer",
"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_btn": "Éteindre",
"tools_shutdown_done": "Extinction…",
@ -220,7 +220,7 @@
"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_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...",
"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.",
@ -239,10 +239,10 @@
"logs_started_at": "Début",
"logs_path": "Chemin",
"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",
"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 !",
"version": "Version",
"confirm_update_system": "Voulez-vous vraiment mettre à jour tous les paquets système ?",
@ -263,7 +263,7 @@
"group": "Groupe",
"group_all_users": "Tous les utilisateurs",
"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_permission": "Ajouter une permission",
"group_new": "Nouveau groupe",
@ -283,7 +283,8 @@
"unignore": "Cesser d'ignorer",
"warnings": "{count} avertissements",
"words": {
"default": "Défaut"
"default": "Défaut",
"collapse": "Replier"
},
"configuration": "Configuration",
"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.",
"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",
"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_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",
"permission_corresponding_url": "URL correspondenta",
"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": "Действие",
"add": "Добавить",
"administration_password": "Пароль администратора",
"api_not_responding": "API не отвечает",
"app_info_access_desc": "Управление доступом пользователей. Разрешенные пользователи: %s",
"app_info_default_desc": "Перенаправить домен root в это приложение ({domain}).",
"app_info_uninstall_desc": "Удалите это приложение.",
"api_not_responding": "YunoHost API не отвечает. Может быть, 'yunohost-api' не работает или только что перезапускался?",
"app_info_access_desc": "Группы / пользователи, у которых есть доступ к этому приложению:",
"app_info_default_desc": "Перенаправить корень сайта в это приложение ({domain}).",
"app_info_uninstall_desc": "Удалить приложение.",
"app_install_custom_no_manifest": "Нет файла manifest.json",
"app_make_default": "Использовать по умолчанию",
"app_state_inprogress": "Выполняется",
@ -14,7 +14,7 @@
"login": "Логин",
"logout": "Выйти",
"ok": "ОК",
"app_info_changeurl_desc": "Изменить url доступа к этому приложению (домен и/ли путь).",
"app_info_changeurl_desc": "Изменить ссылку доступа к этому приложению (домен и/ли путь).",
"app_info_change_url_disabled_tooltip": "Эта опция ещё не реализована в этом приложении",
"app_no_actions": "Это приложение не осуществляет никаких действий",
"app_state_notworking": "Не работает",
@ -219,5 +219,83 @@
"system_upgrade_all_applications_btn": "Обновление всех приложений",
"system_upgrade_all_packages_btn": "Обновление всех пакетов",
"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_default_desc": "重定向域根到这个应用({domain})。",
"unignore": "取消忽略",
"last_ran": "最近一次运行:",
"last_ran": "次运行:",
"app_info_change_url_disabled_tooltip": "此应用尚未实现该功能",
"archive_empty": "空存档",
"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 store from './store'
import { registerGlobalErrorHandlers } from './api'
Vue.config.productionTip = false
// Styles are imported in `src/App.vue` <style>
Vue.use(BootstrapVue, {
BSkeleton: { animation: 'none' },
@ -20,6 +24,7 @@ Vue.use(BootstrapVue, {
}
})
// Ugly wrapper for `$bvModal.msgBoxConfirm` to set default i18n button titles
// FIXME find or wait for a better way
Vue.prototype.$askConfirmation = function (message, props) {
@ -30,6 +35,7 @@ Vue.prototype.$askConfirmation = function (message, props) {
})
}
// Register global components
const requireComponent = require.context('@/components/globals', true, /\.(js|vue)$/i)
// For each matching file name...
@ -40,6 +46,10 @@ requireComponent.keys().forEach((fileName) => {
Vue.component(component.name, component)
})
registerGlobalErrorHandlers()
new Vue({
i18n,
router,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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