update views using skeletons and component helpers

This commit is contained in:
Axolotle 2020-12-16 12:16:43 +01:00
parent 0486865f56
commit 6f028961c0
40 changed files with 1843 additions and 2303 deletions

View file

@ -51,11 +51,9 @@ import { mapGetters } from 'vuex'
import { validationMixin } from 'vuelidate' import { validationMixin } from 'vuelidate'
import AdressInputSelect from '@/components/AdressInputSelect' import AdressInputSelect from '@/components/AdressInputSelect'
import { formatFormDataValue } from '@/helpers/yunohostArguments' import { formatFormDataValue } from '@/helpers/yunohostArguments'
import { required, domain, dynDomain } from '@/helpers/validators' import { required, domain, dynDomain } from '@/helpers/validators'
export default { export default {
name: 'DomainForm', name: 'DomainForm',
@ -139,12 +137,9 @@ export default {
}, },
created () { created () {
if (this.noStore) return
this.$store.dispatch('FETCH', { uri: 'domains' }).then(() => {
if (this.dynDnsForbiden) { if (this.dynDnsForbiden) {
this.selected = 'domain' this.selected = 'domain'
} }
})
}, },
mixins: [validationMixin], mixins: [validationMixin],

View file

@ -5,14 +5,19 @@
@submit.prevent="onSubmit" @submit.prevent="onSubmit"
> >
<template #disclaimer> <template #disclaimer>
<b-alert variant="warning" show> <p class="alert alert-warning">
{{ $t('good_practices_about_admin_password') }} {{ $t('good_practices_about_admin_password') }}
</b-alert> </p>
<slot name="disclaimer" /> <slot name="disclaimer" />
<hr> <hr>
</template> </template>
<slot name="extra-fields" v-bind="{ v: $v, fields, form }" /> <slot name="extra" v-bind="{ v: $v, fields, form }">
<form-field
v-for="(value, key) in extra.fields" :key="key"
v-bind="value" v-model="$v.form.$model[key]" :validation="$v.form[key]"
/>
</slot>
<!-- ADMIN PASSWORD --> <!-- ADMIN PASSWORD -->
<form-field v-bind="fields.password" v-model="form.password" :validation="$v.form.password" /> <form-field v-bind="fields.password" v-model="form.password" :validation="$v.form.password" />

View file

@ -65,6 +65,7 @@
"backups_no": "No backup", "backups_no": "No backup",
"begin": "Begin", "begin": "Begin",
"both": "Both", "both": "Both",
"cancel": "Cancel",
"catalog": "Catalog", "catalog": "Catalog",
"check": "Check", "check": "Check",
"close": "Close", "close": "Close",
@ -115,6 +116,7 @@
"diagnosis_explanation": "The diagnosis feature will attempt to identify common issues on the different aspects of your server to make sure everything runs smoothly. The diagnosis runs automatically twice a day and an email is sent to the administrator if issues are found. Note that some tests may not be relevant if you do not want to use some specific features (for example XMPP) or may fail if you have a complex setup. In such cases, and if you know what you are doing, it is alright to ignore the corresponding issues or warnings.", "diagnosis_explanation": "The diagnosis feature will attempt to identify common issues on the different aspects of your server to make sure everything runs smoothly. The diagnosis runs automatically twice a day and an email is sent to the administrator if issues are found. Note that some tests may not be relevant if you do not want to use some specific features (for example XMPP) or may fail if you have a complex setup. In such cases, and if you know what you are doing, it is alright to ignore the corresponding issues or warnings.",
"run_first_diagnosis": "Run initial diagnosis", "run_first_diagnosis": "Run initial diagnosis",
"disable": "Disable", "disable": "Disable",
"disabled": "Disabled",
"dns": "DNS", "dns": "DNS",
"domain_add": "Add domain", "domain_add": "Add domain",
"domain_add_dns_doc": "… and I have <a href='//yunohost.org/dns'>set my DNS correctly</a>.", "domain_add_dns_doc": "… and I have <a href='//yunohost.org/dns'>set my DNS correctly</a>.",
@ -124,6 +126,7 @@
"domain_default_desc": "The default domain is the connection domain where users log in.", "domain_default_desc": "The default domain is the connection domain where users log in.",
"domain_default_longdesc": "This is your default domain.", "domain_default_longdesc": "This is your default domain.",
"domain_delete_longdesc": "Delete this domain", "domain_delete_longdesc": "Delete this domain",
"domain_delete_forbidden_desc": "You cannot remove '{domain}' since it's the default domain, you need to choose another domain (or <a href='#/domains/add'>add a new one</a>) and set it as the default domain to be able to remove this one.",
"domain_dns_config": "DNS configuration", "domain_dns_config": "DNS configuration",
"domain_dns_longdesc": "View DNS configuration", "domain_dns_longdesc": "View DNS configuration",
"domain_name": "Domain name", "domain_name": "Domain name",
@ -132,6 +135,7 @@
"domains": "Domains", "domains": "Domains",
"download": "Download", "download": "Download",
"enable": "Enable", "enable": "Enable",
"enabled": "Enabled",
"error": "Error", "error": "Error",
"error_modify_something": "You should modify something", "error_modify_something": "You should modify something",
"error_server_unexpected": "Unexpected server error", "error_server_unexpected": "Unexpected server error",
@ -149,6 +153,7 @@
"form_errors": { "form_errors": {
"alpha": "Value must be alphabetical characters only.", "alpha": "Value must be alphabetical characters only.",
"alphalownum_": "Value must be lower-case alphanumeric and underscore characters only.", "alphalownum_": "Value must be lower-case alphanumeric and underscore characters only.",
"between": "Value must be between {min} and {max}.",
"domain": "Invalid domain name: Must be lower-case alphanumeric, dot and dash characters only", "domain": "Invalid domain name: Must be lower-case alphanumeric, dot and dash characters only",
"dynDomain": "Invalid domain name: Must be lower-case alphanumeric and dash characters only", "dynDomain": "Invalid domain name: Must be lower-case alphanumeric and dash characters only",
"email": "Invalid email: must be alphanumeric and <code>_.-</code> characters only (e.g. someone@example.com, s0me-1@example.com)", "email": "Invalid email: must be alphanumeric and <code>_.-</code> characters only (e.g. someone@example.com, s0me-1@example.com)",
@ -370,6 +375,7 @@
"unauthorized": "Unauthorized", "unauthorized": "Unauthorized",
"unignore": "Unignore", "unignore": "Unignore",
"uninstall": "Uninstall", "uninstall": "Uninstall",
"unknown": "Unknown",
"unmaintained": "Unmaintained", "unmaintained": "Unmaintained",
"unmaintained_details": "This app has not been update for quite a while and the previous maintainer has gone away or does not have time to maintain this app. Feel free to check the app repository to provide your help", "unmaintained_details": "This app has not been update for quite a while and the previous maintainer has gone away or does not have time to maintain this app. Feel free to check the app repository to provide your help",
"upnp": "UPnP", "upnp": "UPnP",

View file

@ -4,10 +4,10 @@
<em v-t="'api_error.sorry'" /> <em v-t="'api_error.sorry'" />
<b-alert variant="info" class="mt-4" show> <div class="alert alert-info mt-4">
<span v-html="$t('api_error.help')" /> <span v-html="$t('api_error.help')" />
<br>{{ $t('api_error.info') }} <br>{{ $t('api_error.info') }}
</b-alert> </div>
<h5 v-t="'error'" /> <h5 v-t="'error'" />
<pre><code>"{{ error.code }}" {{ error.status }}</code></pre> <pre><code>"{{ error.code }}" {{ error.status }}</code></pre>
@ -36,7 +36,3 @@ export default {
// FIXME add redirect if they're no error (if reload or route entered by hand) // FIXME add redirect if they're no error (if reload or route entered by hand)
} }
</script> </script>
<style lang="scss" scoped>
</style>

View file

@ -3,8 +3,8 @@
<b-list-group class="menu-list"> <b-list-group class="menu-list">
<b-list-group-item <b-list-group-item
v-for="item in menu" v-for="item in menu"
:key="item.id" :key="item.routeName"
:to="{name: item.routeName}" :to="{ name: item.routeName }"
> >
<icon :iname="item.icon" class="lg" /> <icon :iname="item.icon" class="lg" />
<h2>{{ $t(item.translation) }}</h2> <h2>{{ $t(item.translation) }}</h2>
@ -18,17 +18,17 @@
export default { export default {
name: 'Home', name: 'Home',
data: () => { data () {
return { return {
menu: [ menu: [
{ id: 0, routeName: 'user-list', icon: 'users', translation: 'users' }, { routeName: 'user-list', icon: 'users', translation: 'users' },
{ id: 1, routeName: 'domain-list', icon: 'globe', translation: 'domains' }, { routeName: 'domain-list', icon: 'globe', translation: 'domains' },
{ id: 2, routeName: 'app-list', icon: 'cubes', translation: 'applications' }, { routeName: 'app-list', icon: 'cubes', translation: 'applications' },
{ id: 3, routeName: 'update', icon: 'refresh', translation: 'system_update' }, { routeName: 'update', icon: 'refresh', translation: 'system_update' },
{ id: 4, routeName: 'service-list', icon: 'cog', translation: 'services' }, { routeName: 'service-list', icon: 'cog', translation: 'services' },
{ id: 5, routeName: 'tool-list', icon: 'wrench', translation: 'tools' }, { routeName: 'tool-list', icon: 'wrench', translation: 'tools' },
{ id: 6, routeName: 'diagnosis', icon: 'stethoscope', translation: 'diagnosis' }, { routeName: 'diagnosis', icon: 'stethoscope', translation: 'diagnosis' },
{ id: 7, routeName: 'backup', icon: 'archive', translation: 'backup' } { routeName: 'backup', icon: 'archive', translation: 'backup' }
] ]
} }
} }

View file

@ -1,6 +1,6 @@
<template> <template>
<div class="login"> <div class="login">
<b-alert v-if="apiError" variant="danger" show> <b-alert v-if="apiError" variant="danger">
<icon iname="exclamation-triangle" /> {{ $t(apiError) }} <icon iname="exclamation-triangle" /> {{ $t(apiError) }}
</b-alert> </b-alert>
@ -36,7 +36,7 @@
export default { export default {
name: 'Login', name: 'Login',
data: () => { data () {
return { return {
disabled: false, disabled: false,
password: '', password: '',
@ -46,7 +46,7 @@ export default {
}, },
methods: { methods: {
async login () { login () {
this.$store.dispatch('LOGIN', this.password).catch(() => { this.$store.dispatch('LOGIN', this.password).catch(() => {
this.isValid = false this.isValid = false
}) })
@ -66,6 +66,3 @@ export default {
} }
} }
</script> </script>
<style lang="scss" scoped>
</style>

View file

@ -2,14 +2,16 @@
<div class="post-install"> <div class="post-install">
<!-- START STEP --> <!-- START STEP -->
<template v-if="step === 'start'"> <template v-if="step === 'start'">
<b-alert variant="success" show> <p class="alert alert-success">
<icon iname="thumbs-up" /> {{ $t('postinstall_intro_1') }} <icon iname="thumbs-up" /> {{ $t('postinstall_intro_1') }}
</b-alert> </p>
<b-alert variant="info" show>
<p class="alert alert-info">
<span v-t="'postinstall_intro_2'" /> <span v-t="'postinstall_intro_2'" />
<br> <br>
<span v-html="$t('postinstall_intro_3')" /> <span v-html="$t('postinstall_intro_3')" />
</b-alert> </p>
<b-button size="lg" variant="primary" @click="step = 'domain'"> <b-button size="lg" variant="primary" @click="step = 'domain'">
{{ $t('begin') }} {{ $t('begin') }}
</b-button> </b-button>
@ -17,12 +19,9 @@
<!-- DOMAIN SETUP STEP --> <!-- DOMAIN SETUP STEP -->
<template v-else-if="step === 'domain'"> <template v-else-if="step === 'domain'">
<domain-form <domain-form @submit="setDomain" :title="$t('postinstall_set_domain')" :submit-text="$t('next')">
:title="$t('postinstall_set_domain')" :submit-text="$t('next')"
no-store @submit="setDomain"
>
<template #disclaimer> <template #disclaimer>
<b-alert variant="warning" show v-t="'postinstall_domain'" /> <p class="alert alert-warning" v-t="'postinstall_domain'" />
</template> </template>
</domain-form> </domain-form>
@ -35,7 +34,7 @@
<template v-else-if="step === 'password'"> <template v-else-if="step === 'password'">
<password-form :title="$t('postinstall_set_password')" :submit-text="$t('next')" @submit="setPassword"> <password-form :title="$t('postinstall_set_password')" :submit-text="$t('next')" @submit="setPassword">
<template #disclaimer> <template #disclaimer>
<b-alert variant="warning" show v-t="'postinstall_password'" /> <p class="alert alert-warning" v-t="'postinstall_password'" />
</template> </template>
</password-form> </password-form>
@ -46,9 +45,9 @@
<!-- POST-INSTALL SUCCESS STEP --> <!-- POST-INSTALL SUCCESS STEP -->
<template v-else-if="step === 'login'"> <template v-else-if="step === 'login'">
<b-alert variant="success" show> <p class="alert alert-success">
<icon iname="thumbs-up" /> {{ $t('installation_complete') }} <icon iname="thumbs-up" /> {{ $t('installation_complete') }}
</b-alert> </p>
<login-view /> <login-view />
</template> </template>

View file

@ -1,7 +1,10 @@
<template> <template>
<div class="app-actions"> <view-base
<div v-if="actions"> :queries="queries" @queries-response="formatAppActions"
<b-alert variant="warning" show class="mb-4"> ref="view" skeleton="card-form-skeleton"
>
<template v-if="actions" #default>
<b-alert variant="warning" class="mb-4">
<icon iname="exclamation-triangle" /> {{ $t('experimental_warning') }} <icon iname="exclamation-triangle" /> {{ $t('experimental_warning') }}
</b-alert> </b-alert>
@ -13,9 +16,9 @@
@submit.prevent="performAction(action)" :submit-text="$t('perform')" @submit.prevent="performAction(action)" :submit-text="$t('perform')"
> >
<template #disclaimer> <template #disclaimer>
<b-alert <div
v-if="action.formDisclaimer" v-if="action.formDisclaimer"
show variant="info" v-html="action.formDisclaimer" class="alert alert-info" v-html="action.formDisclaimer"
/> />
<b-card-text v-if="action.description" v-html="action.description" /> <b-card-text v-if="action.description" v-html="action.description" />
</template> </template>
@ -25,13 +28,13 @@
v-bind="field" v-model="action.form[fname]" :validation="$v.actions[i][fname]" v-bind="field" v-model="action.form[fname]" :validation="$v.actions[i][fname]"
/> />
</card-form> </card-form>
</div> </template>
<!-- In case of a custom url with no manifest found --> <!-- In case of a custom url with no manifest found -->
<b-alert v-else-if="actions === null" variant="warning" show> <b-alert v-else-if="actions === null" variant="warning">
<icon iname="exclamation-triangle" /> {{ $t('app_no_actions') }} <icon iname="exclamation-triangle" /> {{ $t('app_no_actions') }}
</b-alert> </b-alert>
</div> </view-base>
</template> </template>
<script> <script>
@ -41,7 +44,6 @@ import { validationMixin } from 'vuelidate'
import { formatI18nField, formatYunoHostArguments, formatFormData } from '@/helpers/yunohostArguments' import { formatI18nField, formatYunoHostArguments, formatFormData } from '@/helpers/yunohostArguments'
import { objectToParams } from '@/helpers/commons' import { objectToParams } from '@/helpers/commons'
export default { export default {
name: 'AppActions', name: 'AppActions',
@ -51,6 +53,12 @@ export default {
data () { data () {
return { return {
queries: [
`apps/${this.id}/actions`,
{ uri: 'domains' },
{ uri: 'domains/main', storeKey: 'main_domain' },
{ uri: 'users' }
],
actions: undefined actions: undefined
} }
}, },
@ -66,18 +74,7 @@ export default {
}, },
methods: { methods: {
fetchData () { formatAppActions (data) {
Promise.all([
api.get(`apps/${this.id}/actions`),
this.$store.dispatch('FETCH_ALL', [
{ uri: 'domains' },
{ uri: 'domains/main', storeKey: 'main_domain' },
{ uri: 'users' }
])
]).then((responses) => this.setupForm(responses[0]))
},
setupForm (data) {
if (!data.actions) { if (!data.actions) {
this.actions = null this.actions = null
return return
@ -102,17 +99,13 @@ export default {
const args = objectToParams(action.form ? formatFormData(action.form) : { wut: undefined }) const args = objectToParams(action.form ? formatFormData(action.form) : { wut: undefined })
api.put(`apps/${this.id}/actions/${action.id}`, { args }).then(response => { api.put(`apps/${this.id}/actions/${action.id}`, { args }).then(response => {
this.fetchData() this.$refs.view.fetchQueries()
}).catch(error => { }).catch(error => {
action.serverError = error.message action.serverError = error.message
}) })
} }
}, },
created () {
this.fetchData()
},
mixins: [validationMixin] mixins: [validationMixin]
} }
</script> </script>

View file

@ -1,12 +1,17 @@
<template> <template>
<div class="app-catalog" v-if="apps"> <view-search
:items="apps" :filtered-items="filteredApps" items-name="apps"
:queries="queries" @queries-response="formatAppData"
>
<template #top-bar>
<div id="view-top-bar">
<!-- APP SEARCH --> <!-- APP SEARCH -->
<b-input-group> <b-input-group>
<b-input-group-prepend is-text> <b-input-group-prepend is-text>
<icon iname="search" /> <icon iname="search" />
</b-input-group-prepend> </b-input-group-prepend>
<b-form-input <b-form-input
id="search-input" :placeholder="$t('search_for_apps')" id="search-input" :placeholder="$t('search.for', { items: $tc('items.apps', 2) })"
v-model="search" @input="setCategory" v-model="search" @input="setCategory"
/> />
<b-input-group-append> <b-input-group-append>
@ -39,6 +44,8 @@
/> />
<b-select id="subtags-select" v-model="subtag" :options="subtags" /> <b-select id="subtags-select" v-model="subtag" :options="subtags" />
</b-input-group> </b-input-group>
</div>
</template>
<!-- CATEGORIES CARDS --> <!-- CATEGORIES CARDS -->
<b-card-group v-if="category === null" deck> <b-card-group v-if="category === null" deck>
@ -56,7 +63,7 @@
</b-card-group> </b-card-group>
<!-- APPS CARDS --> <!-- APPS CARDS -->
<b-card-group v-else-if="filteredApps.length > 0" deck> <b-card-group v-else deck>
<b-card no-body v-for="app in filteredApps" :key="app.id"> <b-card no-body v-for="app in filteredApps" :key="app.id">
<b-card-body class="d-flex flex-column"> <b-card-body class="d-flex flex-column">
<b-card-title class="d-flex"> <b-card-title class="d-flex">
@ -90,7 +97,7 @@
<icon iname="book" /> {{ $t('readme') }} <icon iname="book" /> {{ $t('readme') }}
</b-button> </b-button>
<b-button v-if="app.isInstallable" :variant="app.color" @click="onAppInstallClick(app)"> <b-button v-if="app.isInstallable" :variant="app.color" @click="onInstallClick(app)">
<icon iname="plus" /> {{ $t('install') }} <icon v-if="app.color === 'danger'" class="ml-1" iname="warning" /> <icon iname="plus" /> {{ $t('install') }} <icon v-if="app.color === 'danger'" class="ml-1" iname="warning" />
</b-button> </b-button>
<b-button v-else :variant="app.color" disabled> <b-button v-else :variant="app.color" disabled>
@ -100,88 +107,82 @@
</b-card> </b-card>
</b-card-group> </b-card-group>
<!-- NO APPS --> <template #bot>
<b-alert
v-else
variant="warning" show class="mt-4"
>
<icon iname="exclamation-triangle" /> {{ $t('app_not_found') }}
</b-alert>
<!-- INSTALL CUSTOM APP --> <!-- INSTALL CUSTOM APP -->
<card-form <card-form
:title="$t('custom_app_install')" icon="download" :title="$t('custom_app_install')" icon="download"
@submit.prevent="$refs['custom-app-install-modal'].show()" :submit-text="$t('install')" @submit.prevent="onCustomInstallClick" :submit-text="$t('install')"
:validation="$v" class="mt-5" :validation="$v" class="mt-5"
> >
<template #disclaimer> <template #disclaimer>
<b-alert variant="warning" show> <div class="alert alert-warning">
<icon iname="exclamation-triangle" /> {{ $t('confirm_install_custom_app') }} <icon iname="exclamation-triangle" /> {{ $t('confirm_install_custom_app') }}
</b-alert> </div>
</template>
<!-- URL --> <!-- URL -->
<form-field v-bind="customInstall.field" v-model="customInstall.url" :validation="$v.customInstall.url" /> <form-field v-bind="customInstall.field" v-model="customInstall.url" :validation="$v.customInstall.url" />
</template>
</card-form> </card-form>
</template>
<!-- CUSTOM SKELETON -->
<!-- CONFIRM APP INSTALL MODAL --> <template #skeleton>
<b-modal <b-card-group deck>
id="app-install-modal" centered ref="app-install-modal" <b-card
:ok-title="$t('install')" :title="$t('confirm_app_install')" v-for="i in 15" :key="i"
:header-bg-variant="selectedApp.color" no-body style="min-height: 10rem;"
:header-text-variant="selectedApp.color === 'danger' ? 'light' : 'dark'"
@ok="goToAppInstallForm"
> >
{{ $t('confirm_install_app_' + selectedApp.state) }} <div class="d-flex w-100 mt-auto">
</b-modal> <b-skeleton width="30px" height="30px" class="mr-2 ml-auto" />
<b-skeleton :width="randint(30, 70) + '%'" height="30px" class="mr-auto" />
<!-- CONFIRM CUSTOM APP INSTALL MODAL -->
<b-modal
id="custom-app-install-modal" :ref="'custom-app-install-modal'" centered
:ok-title="$t('install')" :title="$t('confirm_app_install')"
header-bg-variant="danger" header-text-variant="light"
@ok="goToCustomAppInstallForm"
>
{{ $t('confirm_install_custom_app') }}
</b-modal>
</div> </div>
<b-skeleton
v-if="randint(0, 1)"
:width="randint(30, 85) + '%'" height="24px" class="mx-auto"
/>
<b-skeleton :width="randint(30, 85) + '%'" height="24px" class="mx-auto mb-auto" />
</b-card>
</b-card-group>
</template>
</view-search>
</template> </template>
<script> <script>
import api from '@/api'
import { validationMixin } from 'vuelidate' import { validationMixin } from 'vuelidate'
import api from '@/api'
import { required, githubLink } from '@/helpers/validators' import { required, githubLink } from '@/helpers/validators'
import { randint } from '@/helpers/commons'
export default { export default {
name: 'AppCatalog', name: 'AppCatalog',
data () { data () {
return { return {
queries: ['appscatalog?full&with_categories'],
// Data
apps: undefined,
// Filtering options
qualityOptions: [ qualityOptions: [
{ value: 'isHighQuality', text: this.$i18n.t('only_highquality_apps') }, { value: 'isHighQuality', text: this.$i18n.t('only_highquality_apps') },
{ value: 'isDecentQuality', text: this.$i18n.t('only_decent_quality_apps') }, { value: 'isDecentQuality', text: this.$i18n.t('only_decent_quality_apps') },
{ value: 'isWorking', text: this.$i18n.t('only_working_apps') }, { value: 'isWorking', text: this.$i18n.t('only_working_apps') },
{ value: 'all', text: this.$i18n.t('all_apps') } { value: 'all', text: this.$i18n.t('all_apps') }
], ],
// Computed/filled from api data
categories: [ categories: [
{ text: this.$i18n.t('app_choose_category'), value: null }, { text: this.$i18n.t('app_choose_category'), value: null },
{ text: this.$i18n.t('all_apps'), value: 'all', icon: 'search' } { text: this.$i18n.t('all_apps'), value: 'all', icon: 'search' }
// The rest is filled from api data
], ],
apps: undefined,
// Set by user inputs // Set by user inputs
search: '',
category: null, category: null,
subtag: 'all', subtag: 'all',
search: '',
quality: 'isDecentQuality', quality: 'isDecentQuality',
selectedApp: {
// Set some basic values to avoid modal errors
state: 'lowquality',
color: 'warning'
},
// Custom install form // Custom install form
customInstall: { customInstall: {
field: { field: {
@ -199,12 +200,13 @@ export default {
computed: { computed: {
filteredApps () { filteredApps () {
if (!this.apps || this.category === null) return
const search = this.search.toLowerCase() const search = this.search.toLowerCase()
if (this.quality === 'all' && this.category === 'all' && search === '') { if (this.quality === 'all' && this.category === 'all' && search === '') {
return this.apps return this.apps
} }
return this.apps.filter(app => { const filtered = this.apps.filter(app => {
// app doesn't match quality filter // app doesn't match quality filter
if (this.quality !== 'all' && !app[this.quality]) return false if (this.quality !== 'all' && !app[this.quality]) return false
// app doesn't match category filter // app doesn't match category filter
@ -220,6 +222,7 @@ export default {
if (app.searchValues.includes(search)) return true if (app.searchValues.includes(search)) return true
return false return false
}) })
return filtered.length ? filtered : null
}, },
subtags () { subtags () {
@ -246,33 +249,7 @@ export default {
}, },
methods: { methods: {
fetchData () { formatQuality (app) {
api.get('appscatalog?full&with_categories').then((data) => {
// APPS
const apps = []
for (const key in data.apps) {
const app = data.apps[key]
if (app.state === 'notworking') continue
Object.assign(app, this.getQuality(app))
app.isInstallable = !app.installed || app.manifest.multi_instance
if (app.maintained !== 'request_adoption') {
app.maintained = app.maintained ? 'maintained' : 'orphaned'
}
app.color = this.getColor(app)
app.searchValues = [app.id, app.state, app.manifest.name.toLowerCase(), app.manifest.description.toLowerCase()].join(' ')
apps.push(app)
}
this.apps = apps.sort((a, b) => a.id > b.id ? 1 : -1)
// CATEGORIES
data.categories.forEach(({ title, id, icon, subtags, description }) => {
this.categories.push({ text: title, value: id, icon, subtags, description })
})
})
},
getQuality (app) {
const filters = { const filters = {
isHighQuality: false, isHighQuality: false,
isDecentQuality: false, isDecentQuality: false,
@ -297,13 +274,37 @@ export default {
return filters return filters
}, },
getColor (app) { formatColor (app) {
if (app.isHighQuality) return 'best' if (app.isHighQuality) return 'best'
if (app.isDecentQuality) return 'success' if (app.isDecentQuality) return 'success'
if (app.isWorking) return 'warning' if (app.isWorking) return 'warning'
return 'danger' return 'danger'
}, },
formatAppData (data) {
// APPS
const apps = []
for (const key in data.apps) {
const app = data.apps[key]
if (app.state === 'notworking') continue
Object.assign(app, this.formatQuality(app))
app.isInstallable = !app.installed || app.manifest.multi_instance
if (app.maintained !== 'request_adoption') {
app.maintained = app.maintained ? 'maintained' : 'orphaned'
}
app.color = this.formatColor(app)
app.searchValues = [app.id, app.state, app.manifest.name.toLowerCase(), app.manifest.description.toLowerCase()].join(' ')
apps.push(app)
}
this.apps = apps.sort((a, b) => a.id > b.id ? 1 : -1)
// CATEGORIES
data.categories.forEach(({ title, id, icon, subtags, description }) => {
this.categories.push({ text: title, value: id, icon, subtags, description })
})
},
setCategory () { setCategory () {
// allow search without selecting a category // allow search without selecting a category
if (this.category === null) { if (this.category === null) {
@ -311,34 +312,30 @@ export default {
} }
}, },
// INSTALL APP METHODS // INSTALL APP
async onInstallClick (app) {
onAppInstallClick (app) {
this.selectedApp = app
if (!app.isDecentQuality) { if (!app.isDecentQuality) {
// Ask for confirmation // Ask for confirmation
this.$refs['app-install-modal'].show() const state = app.color === 'danger' ? 'inprogress' : app.state
} else { const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_install_app_' + state))
this.goToAppInstallForm() if (!confirmed) return
} }
this.$router.push({ name: 'app-install', params: { id: app.id } })
}, },
goToAppInstallForm () { // INSTALL CUSTOM APP
this.$router.push({ name: 'app-install', params: { id: this.selectedApp.id } }) async onCustomInstallClick () {
}, const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_install_custom_app'))
if (!confirmed) return
// INSTALL CUSTOM APP METHODS
goToCustomAppInstallForm () {
const url = this.customInstall.url const url = this.customInstall.url
this.$router.push({ this.$router.push({
name: 'app-install-custom', name: 'app-install-custom',
params: { id: url.endsWith('/') ? url : url + '/' } params: { id: url.endsWith('/') ? url : url + '/' }
}) })
}
}, },
created () { randint
this.fetchData()
}, },
mixins: [validationMixin] mixins: [validationMixin]
@ -346,18 +343,37 @@ export default {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
#search-input { #view-top-bar {
min-width: 8rem; margin-bottom: 2rem;
}
select { #search-input {
min-width: 8rem;
}
select {
border-top-left-radius: 0; border-top-left-radius: 0;
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
}
.subtags {
#subtags-radio {
display: none
}
@include media-breakpoint-up(md) {
#subtags-radio {
display: inline-flex;
}
#subtags-select {
display: none;
}
}
}
} }
.card-deck { .card-deck {
.card { .card {
border-color: $gray-400; border-color: $gray-400;
margin-top: 2rem; margin-bottom: 2rem;
flex-basis: 90%; flex-basis: 90%;
@include media-breakpoint-up(md) { @include media-breakpoint-up(md) {
flex-basis: 50%; flex-basis: 50%;
@ -402,6 +418,7 @@ select {
.btn:last-of-type { .btn:last-of-type {
border-right: 0; border-right: 0;
} }
}
.btn-outline-dark { .btn-outline-dark {
border-color: $gray-400; border-color: $gray-400;
@ -410,21 +427,5 @@ select {
border-color: $dark; border-color: $dark;
} }
} }
}
} }
.subtags {
#subtags-radio {
display: none
}
@include media-breakpoint-up(md) {
#subtags-radio {
display: inline-flex;
}
#subtags-select {
display: none;
}
}
}
</style> </style>

View file

@ -1,10 +1,11 @@
<template> <template>
<div class="app-config-panel"> <view-base :queries="queries" @queries-response="formatAppConfig" skeleton="card-form-skeleton">
<div v-if="panels"> <template v-if="panels" #default>
<b-alert variant="warning" show class="mb-4"> <b-alert variant="warning" class="mb-4">
<icon iname="exclamation-triangle" /> {{ $t('experimental_warning') }} <icon iname="exclamation-triangle" /> {{ $t('experimental_warning') }}
</b-alert> </b-alert>
<!-- FIXME Rework with components -->
<b-form id="config-form" @submit.prevent="applyConfig"> <b-form id="config-form" @submit.prevent="applyConfig">
<b-card no-body v-for="panel in panels" :key="panel.id"> <b-card no-body v-for="panel in panels" :key="panel.id">
<b-card-header class="d-flex align-items-center"> <b-card-header class="d-flex align-items-center">
@ -35,13 +36,13 @@
</b-collapse> </b-collapse>
</b-card> </b-card>
</b-form> </b-form>
</div> </template>
<!-- if no config panel --> <!-- if no config panel -->
<b-alert v-else-if="panels === null" variant="warning" show> <b-alert v-else-if="panels === null" variant="warning">
<icon iname="exclamation-triangle" /> {{ $t('app_config_panel_no_panel') }} <icon iname="exclamation-triangle" /> {{ $t('app_config_panel_no_panel') }}
</b-alert> </b-alert>
</div> </view-base>
</template> </template>
<script> <script>
@ -54,31 +55,23 @@ export default {
name: 'AppConfigPanel', name: 'AppConfigPanel',
props: { props: {
id: { id: { type: String, required: true }
type: String,
required: true
}
}, },
data () { data () {
return { return {
queries: [
`apps/${this.id}/config-panel`,
{ uri: 'domains' },
{ uri: 'domains/main', storeKey: 'main_domain' },
{ uri: 'users' }
],
panels: undefined panels: undefined
} }
}, },
methods: { methods: {
fetchData () { formatAppConfig (data) {
Promise.all([
api.get(`apps/${this.id}/config-panel`),
this.$store.dispatch('FETCH_ALL', [
{ uri: 'domains' },
{ uri: 'domains/main', storeKey: 'main_domain' },
{ uri: 'users' }
])
]).then((responses) => this.setupForm(responses[0]))
},
setupForm (data) {
if (!data.config_panel || data.config_panel.length === 0) { if (!data.config_panel || data.config_panel.length === 0) {
this.panels = null this.panels = null
return return
@ -121,10 +114,6 @@ export default {
console.log('ERROR', err) console.log('ERROR', err)
}) })
} }
},
created () {
this.fetchData()
} }
} }
</script> </script>

View file

@ -1,13 +1,9 @@
<template> <template>
<div class="app-info" v-if="info"> <view-base :queries="queries" @queries-response="formatAppData" ref="view">
<!-- BASIC INFOS --> <!-- BASIC INFOS -->
<b-card> <card v-if="infos" :title="`${$t('infos')} — ${infos.label}`" icon="info-circle">
<template v-slot:header>
<h2><icon iname="info-circle" /> {{ $t('infos') }} {{ info.label }}</h2>
</template>
<b-row <b-row
v-for="(value, prop) in info" :key="prop" v-for="(value, prop) in infos" :key="prop"
no-gutters class="row-line" no-gutters class="row-line"
> >
<b-col cols="auto" md="3"> <b-col cols="auto" md="3">
@ -33,14 +29,10 @@
</b-button> </b-button>
</b-col> </b-col>
</b-row> </b-row>
</b-card> </card>
<!-- OPERATIONS --> <!-- OPERATIONS -->
<b-card> <card v-if="app" :title="$t('operations')" icon="wrench">
<template v-slot:header>
<h2><icon iname="wrench" /> {{ $t('operations') }}</h2>
</template>
<!-- CHANGE PERMISSIONS LABEL --> <!-- CHANGE PERMISSIONS LABEL -->
<b-form-group :label="$t('app_manage_label_and_tiles')" label-class="font-weight-bold"> <b-form-group :label="$t('app_manage_label_and_tiles')" label-class="font-weight-bold">
<form-field <form-field
@ -98,16 +90,13 @@
<b-input id="input-url" v-model="form.url.path" class="flex-grow-3" /> <b-input id="input-url" v-model="form.url.path" class="flex-grow-3" />
<b-input-group-append> <b-input-group-append>
<b-button <b-button @click="changeUrl" variant="info" v-t="'save'" />
variant="info" v-t="'save'"
@click="action = 'changeUrl'" v-b-modal.modal
/>
</b-input-group-append> </b-input-group-append>
</b-input-group> </b-input-group>
<b-alert v-else variant="warning" show> <div v-else class="alert alert-warning">
<icon iname="exclamation" /> {{ $t('app_info_change_url_disabled_tooltip') }} <icon iname="exclamation" /> {{ $t('app_info_change_url_disabled_tooltip') }}
</b-alert> </div>
</b-form-group> </b-form-group>
<hr> <hr>
@ -116,14 +105,9 @@
:label="$t('app_info_default_desc', { domain: app.domain })" label-for="main-domain" :label="$t('app_info_default_desc', { domain: app.domain })" label-for="main-domain"
label-class="font-weight-bold" label-cols-md="4" label-class="font-weight-bold" label-cols-md="4"
> >
<b-input-group> <b-button @click="setAsDefaultDomain" id="main-domain" variant="success">
<b-button
id="main-domain" variant="success" v-b-modal.modal
@click="action = 'setAsDefaultDomain'"
>
<icon iname="star" /> {{ $t('app_make_default') }} <icon iname="star" /> {{ $t('app_make_default') }}
</b-button> </b-button>
</b-input-group>
</b-form-group> </b-form-group>
<hr> <hr>
@ -132,56 +116,45 @@
:label="$t('app_info_uninstall_desc')" label-for="uninstall" :label="$t('app_info_uninstall_desc')" label-for="uninstall"
label-class="font-weight-bold" label-cols-md="4" label-class="font-weight-bold" label-cols-md="4"
> >
<b-input-group> <b-button @click="uninstall" id="uninstall" variant="danger">
<b-button
id="uninstall" variant="danger" v-b-modal.modal
@click="action = 'uninstall'"
>
<icon iname="trash-o" /> {{ $t('uninstall') }} <icon iname="trash-o" /> {{ $t('uninstall') }}
</b-button> </b-button>
</b-input-group>
</b-form-group> </b-form-group>
</b-card> </card>
<!-- EXPERIMENTAL (displayed if experimental feature has been enabled in web-admin options)--> <!-- EXPERIMENTAL (displayed if experimental feature has been enabled in web-admin options)-->
<b-card v-if="this.$store.getters.experimental"> <card v-if="experimental" :title="$t('experimental')" icon="flask">
<template v-slot:header>
<h2><icon iname="flask" /> {{ $t('experimental') }}</h2>
</template>
<!-- APP ACTIONS --> <!-- APP ACTIONS -->
<b-form-group label-cols-md="4" :label="$t('app_actions_label')" label-for="actions"> <b-form-group
<b-input-group> :label="$t('app_actions_label')" label-for="actions"
label-cols-md="4" label-class="font-weight-bold"
>
<b-button id="actions" variant="warning" :to="{ name: 'app-actions', params: { id } }"> <b-button id="actions" variant="warning" :to="{ name: 'app-actions', params: { id } }">
<icon iname="flask" /> {{ $t('app_actions') }} <icon iname="flask" /> {{ $t('app_actions') }}
</b-button> </b-button>
</b-input-group>
</b-form-group> </b-form-group>
<hr> <hr>
<!-- APP CONFIG PANEL --> <!-- APP CONFIG PANEL -->
<b-form-group label-cols-md="4" :label="$t('app_config_panel_label')" label-for="config"> <b-form-group
<b-input-group> :label="$t('app_config_panel_label')" label-for="config"
label-cols-md="4" label-class="font-weight-bold"
>
<b-button id="config" variant="warning" :to="{ name: 'app-config-panel', params: { id } }"> <b-button id="config" variant="warning" :to="{ name: 'app-config-panel', params: { id } }">
<icon iname="flask" /> {{ $t('app_config_panel') }} <icon iname="flask" /> {{ $t('app_config_panel') }}
</b-button> </b-button>
</b-input-group>
</b-form-group> </b-form-group>
</b-card> </card>
<!-- MODAL --> <template #skeleton>
<b-modal <card-info-skeleton :item-count="8" />
v-if="action" <card-form-skeleton />
id="modal" centered </template>
body-bg-variant="danger" body-text-variant="light" </view-base>
@ok="actions[action].method" hide-header
>
{{ $t(actions[action].text, actions[action].name ? { name: actions[action].name } : {}) }}
</b-modal>
</div>
</template> </template>
<script> <script>
import { mapGetters } from 'vuex'
import { validationMixin } from 'vuelidate' import { validationMixin } from 'vuelidate'
import api from '@/api' import api from '@/api'
@ -193,36 +166,27 @@ export default {
name: 'AppInfo', name: 'AppInfo',
props: { props: {
id: { id: { type: String, required: true }
type: String,
required: true
}
}, },
data () { data () {
return { return {
info: undefined, queries: [
`apps/${this.id}?full`,
{ uri: 'users/permissions?full', storeKey: 'permissions' },
{ uri: 'domains' }
],
infos: undefined,
app: undefined, app: undefined,
form: undefined, form: undefined
actions: {
changeUrl: { method: this.changeUrl, text: 'confirm_app_change_url' },
setAsDefaultDomain: { method: this.setAsDefaultDomain, text: 'confirm_app_default' },
uninstall: { method: this.uninstall, text: 'confirm_uninstall', name: this.id }
},
action: undefined
} }
}, },
filters: {
readableDate
},
computed: { computed: {
domains () { ...mapGetters(['domains', 'experimental']),
return this.$store.state.data.domains
},
allowedGroups () { allowedGroups () {
if (!this.app) return
return this.app.permissions[0].allowed return this.app.permissions[0].allowed
} }
}, },
@ -239,14 +203,7 @@ export default {
}, },
methods: { methods: {
fetchData () { formatAppData (app) {
Promise.all([
api.get(`apps/${this.id}?full`),
this.$store.dispatch('FETCH_ALL', [
{ uri: 'users/permissions?full', storeKey: 'permissions' },
{ uri: 'domains' }
])
]).then(([app]) => {
const form = { labels: [] } const form = { labels: [] }
const mainPermission = app.permissions[this.id + '.main'] const mainPermission = app.permissions[this.id + '.main']
@ -269,7 +226,7 @@ export default {
} }
} }
this.info = { this.infos = {
id: this.id, id: this.id,
label: mainPermission.label, label: mainPermission.label,
description: app.description, description: app.description,
@ -278,7 +235,7 @@ export default {
install_time: readableDate(app.settings.install_time, true, true) install_time: readableDate(app.settings.install_time, true, true)
} }
if (app.settings.domain) { if (app.settings.domain) {
this.info.url = 'https://' + app.settings.domain + app.settings.path this.infos.url = 'https://' + app.settings.domain + app.settings.path
form.url = { form.url = {
domain: app.settings.domain, domain: app.settings.domain,
path: app.settings.path.slice(1) path: app.settings.path.slice(1)
@ -291,15 +248,17 @@ export default {
supports_change_url: app.supports_change_url, supports_change_url: app.supports_change_url,
permissions permissions
} }
})
}, },
changeLabel (permName, data) { changeLabel (permName, data) {
data.show_tile = data.show_tile ? 'True' : 'False' data.show_tile = data.show_tile ? 'True' : 'False'
api.put('users/permissions/' + permName, data).then(this.fetchData) api.put('users/permissions/' + permName, data).then(this.$refs.view.fetchQueries)
}, },
changeUrl () { async changeUrl () {
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_app_change_url'))
if (!confirmed) return
const { domain, path } = this.form.url const { domain, path } = this.form.url
api.put( api.put(
`apps/${this.id}/changeurl`, `apps/${this.id}/changeurl`,
@ -307,21 +266,26 @@ export default {
).then(this.fetchData) ).then(this.fetchData)
}, },
setAsDefaultDomain () { async setAsDefaultDomain () {
api.put(`apps/${this.id}/default`).then(this.fetchData) const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_app_default'))
if (!confirmed) return
api.put(`apps/${this.id}/default`).then(this.$refs.view.fetchQueries)
}, },
uninstall () { async uninstall () {
const confirmed = await this.$askConfirmation(
this.$i18n.t('confirm_uninstall', { name: this.id })
)
if (!confirmed) return
api.delete('apps/' + this.id).then(() => { api.delete('apps/' + this.id).then(() => {
this.$router.push({ name: 'app-list' }) this.$router.push({ name: 'app-list' })
}) })
} }
}, },
created () { filters: { readableDate },
this.fetchData()
},
mixins: [validationMixin] mixins: [validationMixin]
} }
</script> </script>

View file

@ -1,12 +1,8 @@
<template> <template>
<div class="app-install"> <view-base :loading="loading">
<div v-if="infos"> <template v-if="infos">
<!-- BASIC INFOS --> <!-- BASIC INFOS -->
<b-card> <card :title="`${$t('infos')} — ${name}`" icon="info-circle">
<template v-slot:header>
<h2><icon iname="info-circle" /> {{ $t('infos') }} {{ name }}</h2>
</template>
<b-row <b-row
v-for="(info, key) in infos" :key="key" v-for="(info, key) in infos" :key="key"
no-gutters class="row-line" no-gutters class="row-line"
@ -19,16 +15,16 @@
<span>{{ info }}</span> <span>{{ info }}</span>
</b-col> </b-col>
</b-row> </b-row>
</b-card> </card>
<!-- INSTALL FORM --> <!-- INSTALL FORM -->
<card-form <card-form
:title="$t('operations')" icon="wrench" :submit-text="$t('install')" :title="$t('operations')" icon="wrench" :submit-text="$t('install')"
:validation="$v" :server-error="serverError" :validation="$v" :server-error="serverError"
@submit.prevent="beforeInstall" @submit.prevent="performInstall"
> >
<template v-if="formDisclaimer" #disclaimer> <template v-if="formDisclaimer" #disclaimer>
<b-alert show variant="info" v-html="formDisclaimer" /> <div class="alert alert-info" v-html="formDisclaimer" />
</template> </template>
<form-field <form-field
@ -36,30 +32,26 @@
v-bind="field" v-model="form[fname]" :validation="$v.form[fname]" v-bind="field" v-model="form[fname]" :validation="$v.form[fname]"
/> />
</card-form> </card-form>
</template>
<!-- CONFIRM INSTALL DOMAIN ROOT MODAL -->
<b-modal
id="confirm-domain-root-modal" ref="confirm-domain-root-modal" centered
body-bg-variant="danger" body-text-variant="light"
@ok="performInstall" hide-header
:ok-title="$t('install')"
>
{{ $t('confirm_install_domain_root', { domain: this.form.domain }) }}
</b-modal>
</div>
<!-- In case of a custom url with no manifest found --> <!-- In case of a custom url with no manifest found -->
<b-alert v-else-if="infos === null" variant="warning" show> <b-alert v-else-if="infos === null" variant="warning">
<icon iname="exclamation-triangle" /> {{ $t('app_install_custom_no_manifest') }} <icon iname="exclamation-triangle" /> {{ $t('app_install_custom_no_manifest') }}
</b-alert> </b-alert>
</div>
<template #skeleton>
<card-info-skeleton />
<card-form-skeleton :cols="null" />
</template>
</view-base>
</template> </template>
<script> <script>
import api from '@/api'
import { validationMixin } from 'vuelidate' import { validationMixin } from 'vuelidate'
import { formatYunoHostArguments, formatI18nField, formatFormData } from '@/helpers/yunohostArguments'
import api from '@/api'
import { objectToParams } from '@/helpers/commons' import { objectToParams } from '@/helpers/commons'
import { formatYunoHostArguments, formatI18nField, formatFormData } from '@/helpers/yunohostArguments'
export default { export default {
name: 'AppInstall', name: 'AppInstall',
@ -67,14 +59,12 @@ export default {
mixins: [validationMixin], mixins: [validationMixin],
props: { props: {
id: { id: { type: String, required: true }
type: String,
required: true
}
}, },
data () { data () {
return { return {
loading: true,
name: undefined, name: undefined,
infos: undefined, infos: undefined,
formDisclaimer: null, formDisclaimer: null,
@ -106,19 +96,7 @@ export default {
return api.get('appscatalog?full').then(response => response.apps[this.id].manifest) return api.get('appscatalog?full').then(response => response.apps[this.id].manifest)
}, },
fetchData () { formatManifestData (manifest) {
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' }
])
]).then((responses) => this.setupForm(responses[0]))
},
setupForm (manifest) {
this.name = manifest.name this.name = manifest.name
const infosKeys = ['id', 'description', 'license', 'version', 'multi_instance'] const infosKeys = ['id', 'description', 'license', 'version', 'multi_instance']
if (manifest.license === undefined || manifest.license === 'free') { if (manifest.license === undefined || manifest.license === 'free') {
@ -137,17 +115,17 @@ export default {
this.fields = fields this.fields = fields
this.form = form this.form = form
this.validations = { form: validations } this.validations = { form: validations }
this.loading = false
}, },
beforeInstall () { async performInstall () {
if ('path' in this.form && this.form.path === '/') { if ('path' in this.form && this.form.path === '/') {
this.$refs['confirm-domain-root-modal'].show() const confirmed = await this.$askConfirmation(
} else { this.$i18n.t('confirm_install_domain_root', { domain: this.form.domain })
this.performInstall() )
if (!confirmed) return
} }
},
performInstall () {
const { data: args, label } = formatFormData(this.form, { extract: ['label'] }) const { data: args, label } = formatFormData(this.form, { extract: ['label'] })
const data = { app: this.id, label, args: objectToParams(args) } const data = { app: this.id, label, args: objectToParams(args) }
@ -160,7 +138,15 @@ export default {
}, },
created () { created () {
this.fetchData() 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' }
])
]).then((responses) => this.formatManifestData(responses[0]))
} }
} }
</script> </script>

View file

@ -1,10 +1,11 @@
<template> <template>
<search-view <view-search
id="app-list"
:search.sync="search" :search.sync="search"
items-name="installed_apps"
:items="apps" :items="apps"
:filtered-items="filteredApps" :filtered-items="filteredApps"
items-name="installed_apps" :queries="queries"
@queries-response="formatAppData"
> >
<template #top-bar-buttons> <template #top-bar-buttons>
<b-button variant="success" :to="{ name: 'app-catalog' }"> <b-button variant="success" :to="{ name: 'app-catalog' }">
@ -32,18 +33,16 @@
<icon iname="chevron-right" class="lg fs-sm ml-auto" /> <icon iname="chevron-right" class="lg fs-sm ml-auto" />
</b-list-group-item> </b-list-group-item>
</b-list-group> </b-list-group>
</search-view> </view-search>
</template> </template>
<script> <script>
import api from '@/api'
import SearchView from '@/components/SearchView'
export default { export default {
name: 'AppList', name: 'AppList',
data () { data () {
return { return {
queries: ['apps?full'],
search: '', search: '',
apps: undefined apps: undefined
} }
@ -56,13 +55,12 @@ export default {
const match = (item) => item && item.toLowerCase().includes(search) const match = (item) => item && item.toLowerCase().includes(search)
// Check if any value in apps (label, id, name, description) match the search query. // Check if any value in apps (label, id, name, description) match the search query.
const filtered = this.apps.filter(app => Object.values(app).some(match)) const filtered = this.apps.filter(app => Object.values(app).some(match))
return filtered.length > 0 ? filtered : null return filtered.length ? filtered : null
} }
}, },
methods: { methods: {
fetchData () { formatAppData ({ apps }) {
api.get('apps?full').then(({ apps }) => {
if (apps.length === 0) { if (apps.length === 0) {
this.apps = null this.apps = null
return return
@ -90,14 +88,7 @@ export default {
}).sort((prev, app) => { }).sort((prev, app) => {
return prev.label > app.label ? 1 : -1 return prev.label > app.label ? 1 : -1
}) })
})
} }
}, }
created () {
this.fetchData()
},
components: { SearchView }
} }
</script> </script>

View file

@ -1,5 +1,5 @@
<template> <template>
<div class="backup"> <div>
<b-list-group> <b-list-group>
<b-list-group-item <b-list-group-item
v-for="{ id, name, uri } in storages" :key="id" v-for="{ id, name, uri } in storages" :key="id"
@ -7,11 +7,13 @@
class="d-flex justify-content-between align-items-center pr-0" class="d-flex justify-content-between align-items-center pr-0"
> >
<div> <div>
<h5> <h5 class="font-weight-bold">
{{ name }} {{ name }}
<small>{{ id }}</small> <small class="text-secondary">{{ id }}</small>
</h5> </h5>
<p class="mb-0">{{ uri }}</p> <p class="m-0">
{{ uri }}
</p>
</div> </div>
<icon iname="chevron-right" class="lg fs-sm ml-auto" /> <icon iname="chevron-right" class="lg fs-sm ml-auto" />
</b-list-group-item> </b-list-group-item>

View file

@ -1,162 +1,118 @@
<template> <template>
<div class="backup-create" v-if="isReady"> <view-base :queries="queries" @queries-response="formatData" skeleton="card-list-skeleton">
<b-card no-body> <!-- FIXME switch to <card-form> ? -->
<template v-slot:header> <card :title="$t('backup_create')" icon="archive" no-body>
<h2><icon iname="archive" /> {{ $t('backup_create') }}</h2>
</template>
<b-form-checkbox-group <b-form-checkbox-group
v-model="selected" v-model="selected"
id="backup-select" name="backup-select" size="lg" id="backup-select" name="backup-select" size="lg"
aria-describedby="backup-restore-feedback"
> >
<b-list-group flush> <b-list-group flush>
<!-- SYSTEM TITLE --> <!-- SYSTEM HEADER -->
<b-list-group-item class="d-flex align-items-md-center flex-column flex-md-row" variant="dark"> <b-list-group-item class="d-flex align-items-sm-center flex-column flex-sm-row" variant="light">
<div> <h4 class="m-0">
<h4 class="mb-0"><icon iname="cube" /> {{ $t('system') }}</h4> <icon iname="cube" /> {{ $t('system') }}
</div> </h4>
<div class="ml-md-auto mt-2 mt-md-0"> <div class="ml-sm-auto mt-2 mt-sm-0">
<b-button <b-button
size="sm" variant="light" @click="toggleSelected(true, 'system')" v-t="'select_all'"
v-t="'select_all'" @click="toggleSelected(true, 'hooks')" size="sm" variant="outline-dark"
/> />
<b-button <b-button
size="sm" variant="light" class="ml-2" @click="toggleSelected(false, 'system')" v-t="'select_none'"
v-t="'select_none'" @click="toggleSelected(false, 'hooks')" size="sm" variant="outline-dark" class="ml-2"
/> />
</div> </div>
</b-list-group-item> </b-list-group-item>
<!-- SYSTEM ITEMS --> <!-- SYSTEM ITEMS -->
<b-list-group-item <b-list-group-item
v-for="(item, partName) in hooks" :key="partName" v-for="(item, partName) in system" :key="partName"
class="d-flex justify-content-between align-items-center pr-0" class="d-flex justify-content-between align-items-center pr-0"
> >
<div class="mr-2"> <div class="mr-2">
<h5>{{ item.name }} </h5> <h5 class="font-weight-bold">
<p class="mb-0">{{ item.description }}</p> {{ item.name }}
</h5>
<p class="m-0">
{{ item.description }}
</p>
</div> </div>
<b-form-checkbox :value="partName" :aria-label="$t('check')" class="d-inline" /> <b-form-checkbox :value="partName" :aria-label="$t('check')" class="d-inline" />
</b-list-group-item> </b-list-group-item>
<!-- APPS TITLE --> <!-- APPS HEADER -->
<b-list-group-item class="d-flex align-items-md-center flex-column flex-md-row" variant="dark"> <b-list-group-item class="d-flex align-items-sm-center flex-column flex-sm-row" variant="light">
<div> <h4 class="m-0">
<h4 class="mb-0"><icon iname="cubes" /> {{ $t('applications') }}</h4> <icon iname="cubes" /> {{ $t('applications') }}
</div> </h4>
<div class="ml-md-auto mt-2 mt-md-0"> <div class="ml-sm-auto mt-2 mt-sm-0">
<b-button <b-button
size="sm" variant="light" @click="toggleSelected(true, 'apps')" v-t="'select_all'"
v-t="'select_all'" @click="toggleSelected(true, 'apps')" size="sm" variant="outline-dark"
/> />
<b-button <b-button
size="sm" variant="light" class="ml-2" @click="toggleSelected(false, 'apps')" v-t="'select_none'"
v-t="'select_none'" @click="toggleSelected(false, 'apps')" size="sm" variant="outline-dark" class="ml-2"
/> />
</div> </div>
</b-list-group-item> </b-list-group-item>
<!-- APPS ITEMS --> <!-- APPS ITEMS -->
<b-list-group-item <b-list-group-item
v-for="(item, appName) in apps" :key="appName" v-for="(item, appName) in apps" :key="appName"
class="d-flex justify-content-between align-items-center pr-0" class="d-flex justify-content-between align-items-center pr-0"
> >
<div class="mr-2"> <div class="mr-2">
<h5>{{ item.name }} <small>{{ item.id }}</small></h5> <h5 class="font-weight-bold">
<p class="mb-0">{{ item.description }}</p> {{ item.name }} <small class="text-secondary">{{ item.id }}</small>
</h5>
<p class="m-0">
{{ item.description }}
</p>
</div> </div>
<b-form-checkbox :value="appName" :aria-label="$t('check')" class="d-inline"/> <b-form-checkbox :value="appName" :aria-label="$t('check')" class="d-inline" />
</b-list-group-item> </b-list-group-item>
</b-list-group> </b-list-group>
</b-form-checkbox-group> </b-form-checkbox-group>
<!-- SUBMIT --> <!-- SUBMIT -->
<template v-slot:footer> <template #buttons>
<div class="d-flex justify-content-end">
<b-button <b-button
@click="createBackup" variant="success" @click="createBackup" v-t="'backup_action'"
v-t="'backup_action'" :disabled="selected.length === 0" variant="success" :disabled="selected.length === 0"
/> />
</div>
</template> </template>
</b-card> </card>
</div> </view-base>
</template> </template>
<script> <script>
import api from '@/api' import api from '@/api'
export default { export default {
name: 'BackupCreate', name: 'BackupCreate',
props: { props: {
id: { id: { type: String, required: true }
type: String,
required: true
}
}, },
data () { data () {
return { return {
isReady: false, queries: ['hooks/backup', 'apps?with_backup'],
selected: [], selected: [],
// api data // api data
hooks: undefined, system: undefined,
apps: undefined apps: undefined
} }
}, },
methods: { methods: {
fetchData () {
api.getAll(['hooks/backup', 'apps?with_backup']).then(([{ hooks }, { apps }]) => {
this.hooks = this.formatHooks(hooks)
// transform app array into literal object to match hooks data structure
this.apps = apps.reduce((obj, app) => {
obj[app.id] = app
return obj
}, {})
this.selected = [...Object.keys(this.hooks), ...Object.keys(this.apps)]
this.isReady = true
})
},
toggleSelected (select, type) {
if (select) {
const toSelect = Object.keys(this[type]).filter(item => !this.selected.includes(item))
this.selected = [...this.selected, ...toSelect]
} else {
const toUnselect = Object.keys(this[type])
this.selected = this.selected.filter(selected => !toUnselect.includes(selected))
}
},
createBackup () {
const data = {
apps: [],
system: []
}
for (const item of this.selected) {
if (item in this.hooks) {
data.system = [...data.system, ...this.hooks[item].value]
} else {
data.apps.push(item)
}
}
api.post('backup', data).then(response => {
// FIXME display ws messages
this.$router.push({ name: 'backup-list', params: { id: this.id } })
})
},
formatHooks (hooks) { formatHooks (hooks) {
const data = {} const data = {}
hooks.forEach(hook => { hooks.forEach(hook => {
@ -173,11 +129,42 @@ export default {
} }
}) })
return data return data
},
formatData ({ hooks }, { apps }) {
this.system = this.formatHooks(hooks)
// transform app array into literal object to match hooks data structure
this.apps = apps.reduce((obj, app) => {
obj[app.id] = app
return obj
}, {})
this.selected = [...Object.keys(this.system), ...Object.keys(this.apps)]
},
toggleSelected (select, type) {
if (select) {
const toSelect = Object.keys(this[type]).filter(item => !this.selected.includes(item))
this.selected = [...this.selected, ...toSelect]
} else {
const toUnselect = Object.keys(this[type])
this.selected = this.selected.filter(selected => !toUnselect.includes(selected))
} }
}, },
created () { createBackup () {
this.fetchData() const data = { apps: [], system: [] }
for (const item of this.selected) {
if (item in this.system) {
data.system = [...data.system, ...this.system[item].value]
} else {
data.apps.push(item)
}
}
api.post('backup', data).then(response => {
this.$router.push({ name: 'backup-list', params: { id: this.id } })
})
}
} }
} }
</script> </script>

View file

@ -1,36 +1,25 @@
<template> <template>
<div class="backup-info" v-if="isReady"> <view-base :queries="queries" @queries-response="formatBackupData">
<!-- BACKUP INFO --> <!-- BACKUP INFO -->
<b-card no-body> <card :title="$t('infos')" icon="info-circle" button-unbreak="sm">
<b-card-header class="d-flex align-items-md-center flex-column flex-md-row"> <template #header-buttons>
<div>
<h2><icon iname="info-circle" /> {{ $t('infos') }}</h2>
</div>
<div class="ml-md-auto mt-2 mt-md-0">
<!-- DOWNLOAD ARCHIVE --> <!-- DOWNLOAD ARCHIVE -->
<b-button size="sm" variant="success" @click="downloadBackup"> <b-button @click="downloadBackup" size="sm" variant="success">
<icon iname="download" /> {{ $t('download') }} <icon iname="download" /> {{ $t('download') }}
</b-button> </b-button>
<!-- DELETE ARCHIVE --> <!-- DELETE ARCHIVE -->
<b-button <b-button @click="deleteBackup" size="sm" variant="danger">
size="sm" variant="danger" id="delete-backup"
class="ml-2" v-b-modal.confirm-delete-backup
>
<icon iname="trash-o" /> {{ $t('delete') }} <icon iname="trash-o" /> {{ $t('delete') }}
</b-button> </b-button>
</div> </template>
</b-card-header>
<b-card-body>
<b-row <b-row
v-for="(value, prop) in info" :key="prop" v-for="(value, prop) in infos" :key="prop"
no-gutters class="row-line" no-gutters class="row-line"
> >
<b-col cols="5" md="3" xl="3"> <b-col md="3" xl="2">
<strong>{{ $t(prop === 'name' ? 'id' : prop) }}</strong> <strong>{{ $t(prop === 'name' ? 'id' : prop) }}</strong>
<span class="sep" />
</b-col> </b-col>
<b-col> <b-col>
<span v-if="prop === 'created_at'">{{ value | readableDate }}</span> <span v-if="prop === 'created_at'">{{ value | readableDate }}</span>
@ -38,45 +27,42 @@
<span v-else>{{ value }}</span> <span v-else>{{ value }}</span>
</b-col> </b-col>
</b-row> </b-row>
</b-card-body> </card>
</b-card>
<!-- BACKUP CONTENT --> <!-- BACKUP CONTENT -->
<b-card no-body> <!-- FIXME switch to <card-form> ? -->
<b-card-header class="d-flex align-items-md-center flex-column flex-md-row"> <card
<div> :title="$t('backup_content')" icon="archive"
<h2><icon iname="archive" /> {{ $t('backup_content') }}</h2> no-body button-unbreak="sm"
</div> >
<template #header-buttons>
<div class="ml-md-auto mt-2 mt-md-0">
<b-button <b-button
size="sm" variant="outline-secondary" size="sm" variant="outline-secondary"
v-t="'select_all'" @click="toggleSelected()" v-t="'select_all'"
@click="toggleSelected()"
/> />
<b-button <b-button
size="sm" variant="outline-secondary" class="ml-2" size="sm" variant="outline-secondary"
v-t="'select_none'" @click="toggleSelected(false)" v-t="'select_none'"
@click="toggleSelected(false)"
/> />
</div> </template>
</b-card-header>
<b-form-checkbox-group <b-form-checkbox-group
v-if="hasItems" v-model="selected" v-if="hasBackupData" v-model="selected"
id="backup-select" name="backup-select" size="lg" id="backup-select" name="backup-select" size="lg"
aria-describedby="backup-restore-feedback" aria-describedby="backup-restore-feedback"
> >
<b-list-group flush> <b-list-group flush>
<!-- SYSTEM PARTS --> <!-- SYSTEM PARTS -->
<b-list-group-item <b-list-group-item
v-for="(item, partName) in systemParts" :key="partName" v-for="(item, partName) in system" :key="partName"
class="d-flex justify-content-between align-items-center pr-0" class="d-flex justify-content-between align-items-center pr-0"
> >
<div class="mr-2"> <div class="mr-2">
<h5>{{ item.name }} <small v-if="item.size">({{ item.size | humanSize }})</small></h5> <h5 class="font-weight-bold">
<p class="mb-0"> {{ item.name }} <small class="text-secondary" v-if="item.size">({{ item.size | humanSize }})</small>
</h5>
<p class="m-0">
{{ item.description }} {{ item.description }}
</p> </p>
</div> </div>
@ -90,8 +76,10 @@
class="d-flex justify-content-between align-items-center pr-0" class="d-flex justify-content-between align-items-center pr-0"
> >
<div class="mr-2"> <div class="mr-2">
<h5>{{ item.name }} <small>{{ appName }} ({{ item.size | humanSize }})</small></h5> <h5 class="font-weight-bold">
<p class="mb-0"> {{ item.name }} <small class="text-secondary">{{ appName }} ({{ item.size | humanSize }})</small>
</h5>
<p class="m-0">
{{ $t('version') }} {{ item.version }} {{ $t('version') }} {{ item.version }}
</p> </p>
</div> </div>
@ -101,155 +89,66 @@
</b-list-group> </b-list-group>
<b-form-invalid-feedback id="backup-restore-feedback" :state="isValid"> <b-form-invalid-feedback id="backup-restore-feedback" :state="isValid">
<b-alert variant="danger" show class="mb-0"> <b-alert variant="danger" class="mb-0">
{{ error }} {{ error }}
</b-alert> </b-alert>
</b-form-invalid-feedback> </b-form-invalid-feedback>
</b-form-checkbox-group> </b-form-checkbox-group>
<b-alert <div v-else class="alert alert-warning mb-0">
v-else
variant="warning" class="mb-0" show
>
<icon iname="exclamation-triangle" /> {{ $t('archive_empty') }} <icon iname="exclamation-triangle" /> {{ $t('archive_empty') }}
</b-alert> </div>
<!-- SUBMIT --> <!-- SUBMIT -->
<template v-if="hasItems" v-slot:footer> <template v-if="hasBackupData" #buttons>
<div class="d-flex justify-content-end">
<b-button <b-button
v-b-modal.confirm-restore-backup form="backup-restore" variant="success" @click="restoreBackup" form="backup-restore" variant="success"
v-t="'restore'" :disabled="selected.length === 0" v-t="'restore'" :disabled="selected.length === 0"
/> />
</div>
</template> </template>
</b-card> </card>
<!-- RESTORE BACKUP MODAL --> <template #skeleton>
<b-modal <card-info-skeleton :item-count="4" />
id="confirm-restore-backup" centered <card-list-skeleton />
body-bg-variant="danger" body-text-variant="light" </template>
@ok="restoreBackup" hide-header </view-base>
>
{{ $t('confirm_restore', { name }) }}
</b-modal>
<!-- DELETE BACKUP MODAL -->
<b-modal
id="confirm-delete-backup" centered
body-bg-variant="danger" body-text-variant="light"
@ok="deleteBackup" hide-header
>
{{ $t('confirm_delete', { name }) }}
</b-modal>
</div>
</template> </template>
<script> <script>
import api from '@/api' import api from '@/api'
import { readableDate } from '@/helpers/filters/date' import { readableDate } from '@/helpers/filters/date'
import { humanSize } from '@/helpers/filters/human' import { humanSize } from '@/helpers/filters/human'
import { isEmptyValue } from '@/helpers/commons'
export default { export default {
name: 'BackupInfo', name: 'BackupInfo',
props: { props: {
id: { id: { type: String, required: true },
type: String, name: { type: String, required: true }
required: true
},
name: {
type: String,
required: true
}
}, },
data () { data () {
return { return {
isReady: false, queries: [`backup/archives/${this.name}?with_details`],
restore: false,
selected: [], selected: [],
error: '', error: '',
isValid: null, isValid: null,
// api data // api data
info: { infos: undefined,
name: this.name,
created_at: undefined,
size: undefined,
path: undefined
},
apps: undefined, apps: undefined,
systemParts: undefined system: undefined
} }
}, },
filters: { computed: {
readableDate, hasBackupData () {
humanSize return !isEmptyValue(this.system) || !isEmptyValue(this.apps)
}
}, },
methods: { methods: {
fetchData () {
api.get(`backup/archives/${this.name}?with_details`).then((data) => {
this.info.created_at = data.created_at
this.info.size = data.size
this.info.path = data.path
this.hasItems = Object.keys(data.system).length !== 0 || Object.keys(data.apps).length !== 0
this.systemParts = this.formatHooks(data.system)
this.apps = data.apps
this.toggleSelected()
this.isReady = true
})
},
toggleSelected (select = true) {
if (select) {
this.selected = [
...Object.keys(this.apps),
...Object.keys(this.systemParts)
]
} else {
this.selected = []
}
},
restoreBackup () {
const data = {
apps: [],
system: [],
force: ''
}
for (const item of this.selected) {
if (item in this.systemParts) {
data.system = [...data.system, ...this.systemParts[item].value]
} else {
data.apps.push(item)
}
}
api.post('backup/restore/' + this.name, data).then(response => {
// FIXME display ws messages
this.isValid = null
}).catch(err => {
// FIXME some errors may be sent by the websocket (yunohost api error for exemple)
this.error = err.message
this.isValid = false
})
},
deleteBackup () {
api.delete('backup/archives/' + this.name).then(() => {
this.$router.push({ name: 'backup-list', params: { id: this.id } })
})
},
downloadBackup () {
const host = this.$store.getters.host
window.open(`https://${host}/yunohost/api/backup/download/${this.name}`, '_blank')
},
formatHooks (hooks) { formatHooks (hooks) {
const data = {} const data = {}
Object.entries(hooks).forEach(([hook, { size }]) => { Object.entries(hooks).forEach(([hook, { size }]) => {
@ -268,11 +167,73 @@ export default {
} }
}) })
return data return data
},
formatBackupData (data) {
this.infos = {
name: this.name,
created_at: data.created_at,
size: data.size,
path: data.path
}
this.system = this.formatHooks(data.system)
this.apps = data.apps
this.toggleSelected()
},
toggleSelected (select = true) {
if (select) {
this.selected = [
...Object.keys(this.apps),
...Object.keys(this.system)
]
} else {
this.selected = []
} }
}, },
created () { async restoreBackup () {
this.fetchData() const confirmed = await this.$askConfirmation(
this.$i18n.t('confirm_restore', { name: this.name })
)
if (!confirmed) return
const data = { apps: [], system: [], force: '' }
for (const item of this.selected) {
if (item in this.system) {
data.system = [...data.system, ...this.system[item].value]
} else {
data.apps.push(item)
}
}
api.post('backup/restore/' + this.name, data).then(response => {
this.isValid = null
}).catch(err => {
this.error = err.message
this.isValid = false
})
},
async deleteBackup () {
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_delete', { name: this.name }))
if (!confirmed) return
api.delete('backup/archives/' + this.name).then(() => {
this.$router.push({ name: 'backup-list', params: { id: this.id } })
})
},
downloadBackup () {
const host = this.$store.getters.host
window.open(`https://${host}/yunohost/api/backup/download/${this.name}`, '_blank')
}
},
filters: {
readableDate,
humanSize
} }
} }
</script> </script>

View file

@ -1,8 +1,10 @@
<template> <template>
<div class="backup-list"> <view-base :queries="queries" @queries-response="formatBackupList" skeleton="list-group-skeleton">
<view-top-bar :button="{ text: $t('backup_new'), icon: 'plus', to: { name: 'backup-create' } }" /> <template #top>
<top-bar :button="{ text: $t('backup_new'), icon: 'plus', to: { name: 'backup-create' } }" />
</template>
<b-alert v-if="!archives" variant="warning" show> <b-alert v-if="!archives" variant="warning">
<icon iname="exclamation-triangle" /> <icon iname="exclamation-triangle" />
{{ $t('items_verbose_count', { items: $tc('items.backups', 0) }) }} {{ $t('items_verbose_count', { items: $tc('items.backups', 0) }) }}
</b-alert> </b-alert>
@ -15,9 +17,9 @@
class="d-flex justify-content-between align-items-center pr-0" class="d-flex justify-content-between align-items-center pr-0"
> >
<div> <div>
<h5> <h5 class="font-weight-bold">
{{ created_at | distanceToNow }} {{ created_at | distanceToNow }}
<small>{{ name }} ({{ size | humanSize }})</small> <small class="text-secondary">{{ name }} ({{ size | humanSize }})</small>
</h5> </h5>
<p class="mb-0"> <p class="mb-0">
{{ path }} {{ path }}
@ -26,11 +28,10 @@
<icon iname="chevron-right" class="lg fs-sm ml-auto" /> <icon iname="chevron-right" class="lg fs-sm ml-auto" />
</b-list-group-item> </b-list-group-item>
</b-list-group> </b-list-group>
</div> </view-base>
</template> </template>
<script> <script>
import api from '@/api'
import { distanceToNow, readableDate } from '@/helpers/filters/date' import { distanceToNow, readableDate } from '@/helpers/filters/date'
import { humanSize } from '@/helpers/filters/human' import { humanSize } from '@/helpers/filters/human'
@ -38,35 +39,26 @@ export default {
name: 'BackupList', name: 'BackupList',
props: { props: {
id: { id: { type: String, required: true }
type: String,
required: true
}
}, },
data () { data () {
return { return {
queries: ['backup/archives?with_info'],
archives: undefined archives: undefined
} }
}, },
methods: { methods: {
fetchData () { formatBackupList (data) {
api.get('backup/archives?with_info').then(data => {
// FIXME use archives = null if no archives
const archives = Object.entries(data.archives) const archives = Object.entries(data.archives)
this.archives = archives.length === 0 ? null : archives.map(([name, infos]) => { this.archives = archives.length === 0 ? null : archives.map(([name, infos]) => {
infos.name = name infos.name = name
return infos return infos
}).reverse() }).reverse()
})
} }
}, },
created () {
this.fetchData()
},
filters: { filters: {
distanceToNow, distanceToNow,
readableDate, readableDate,

View file

@ -1,109 +1,107 @@
<template> <template>
<div class="diagnosis"> <view-base
<view-top-bar> :loading="loading" ref="view"
<template #group-right> :queries="queries" @queries-response="formatData"
>
<template #top-bar-group-right>
<b-button @click="shareLogs" variant="success"> <b-button @click="shareLogs" variant="success">
<icon iname="cloud-upload" /> {{ $t('logs_share_with_yunopaste') }} <icon iname="cloud-upload" /> {{ $t('logs_share_with_yunopaste') }}
</b-button> </b-button>
</template> </template>
</view-top-bar>
<b-alert variant="info" show> <template #top>
{{ $t(reports ? 'diagnosis_explanation' : 'diagnosis_first_run') }} <div class="alert alert-info">
{{ $t(reports || loading ? 'diagnosis_explanation' : 'diagnosis_first_run') }}
<b-button <b-button
v-if="reports === null" @click="runFullDiagnosis" v-if="reports === null" class="d-block mt-2" variant="info"
class="d-block mt-2" variant="info" @click="runDiagnosis"
> >
<icon iname="stethoscope" /> {{ $t('run_first_diagnosis') }} <icon iname="stethoscope" /> {{ $t('run_first_diagnosis') }}
</b-button> </b-button>
</b-alert> </div>
<b-alert <div v-t="'diagnosis_experimental_disclaimer'" class="alert alert-warning mb-5" />
class="mb-5" variant="warning" show </template>
v-t="'diagnosis_experimental_disclaimer'"
/>
<!-- REPORT CARD --> <!-- REPORT CARD -->
<b-card no-body v-for="({ id, description, noIssues, errors, warnings, ignoreds, timestamp, items }, r) in reports" :key="id"> <card
v-for="report in reports" :key="report.id"
collapsable :collapsed="report.noIssues" button-unbreak="lg"
>
<!-- REPORT HEADER --> <!-- REPORT HEADER -->
<b-card-header class="d-flex align-items-md-center flex-column flex-md-row"> <template #header>
<div class="d-flex align-items-center"> <h2>{{ report.description }}</h2>
<h2>{{ description }}</h2>
<b-badge <div class="">
v-if="noIssues" pill variant="success" <b-badge v-if="report.noIssues" variant="success" v-t="'everything_good'" />
v-t="'everything_good'" <b-badge v-if="report.errors" variant="danger" v-t="{ path: 'issues', args: { count: report.errors } }" />
/> <b-badge v-if="report.warnings" variant="warning" v-t="{ path: 'warnings', args: { count: report.warnings } }" />
<b-badge <b-badge v-if="report.ignoreds" v-t="{ path: 'ignored', args: { count: report.ignoreds } }" />
v-if="errors" variant="danger" pill
v-t="{ path: 'issues', args: { count: errors } }"
/>
<b-badge v-if="warnings" variant="warning" v-t="{ path: 'warnings', args: { count: warnings } }" />
<b-badge v-if="ignoreds" v-t="{ path: 'ignored', args: { count: ignoreds } }" />
</div> </div>
</template>
<div class="d-flex ml-md-auto mt-2 mt-md-0"> <template #header-buttons>
<b-button size="sm" :variant="items ? 'info' : 'success'" @click="reRunDiagnosis(id)"> <b-button size="sm" :variant="report.items ? 'info' : 'success'" @click="runDiagnosis(report.id)">
<icon iname="refresh" /> {{ $t('rerun_diagnosis') }} <icon iname="refresh" /> {{ $t('rerun_diagnosis') }}
</b-button> </b-button>
</template>
<b-button
size="sm" variant="outline-secondary" class="ml-auto ml-md-2"
v-b-toggle="'collapse-' + id"
>
<icon iname="chevron-right" /><span class="sr-only">{{ $t('words.collapse') }}</span>
</b-button>
</div>
</b-card-header>
<!-- REPORT BODY --> <!-- REPORT BODY -->
<b-collapse :id="'collapse-' + id" :visible="!noIssues">
<p class="last-time-run"> <p class="last-time-run">
{{ $t('last_ran') }} {{ timestamp | distanceToNow(true, true) }} {{ $t('last_ran') }} {{ report.timestamp | distanceToNow(true, true) }}
</p> </p>
<b-list-group flush> <b-list-group flush>
<!-- REPORT ITEM --> <!-- REPORT ITEM -->
<b-list-group-item <b-list-group-item
v-for="({ status, icon, summary, ignored, issue, details, filterArgs, meta }, i) in items" v-for="(item, i) in report.items" :key="i"
:key="i" :variant="status" :variant="item.variant"
> >
<div class="item-button d-flex align-items-center"> <div class="item-button d-flex align-items-center">
<icon :iname="icon" class="mr-1" /> <p class="mb-0 mr-2" v-html="summary" /> <icon :iname="item.icon" class="mr-1" /> <p class="mb-0 mr-2" v-html="item.summary" />
<div class="d-flex flex-column flex-lg-row ml-auto"> <div class="d-flex flex-column flex-lg-row ml-auto">
<b-button <b-button
v-if="ignored" size="sm" v-if="item.ignored" size="sm"
@click="toggleIgnoreIssue(false, filterArgs, r, i)" @click="toggleIgnoreIssue(false, report, item)"
> >
<icon iname="bell" /> <span v-t="'unignore'" /> <icon iname="bell" /> {{ $t('unignore') }}
</b-button> </b-button>
<b-button <b-button
v-else-if="issue" v-else-if="item.issue" variant="warning" size="sm"
variant="warning" size="sm" @click="toggleIgnoreIssue(true, filterArgs, r, i)" @click="toggleIgnoreIssue(true, report, item)"
> >
<icon iname="bell-slash" /> <span v-t="'ignore'" /> <icon iname="bell-slash" /> {{ $t('ignore') }}
</b-button> </b-button>
<b-button <b-button
v-if="details" v-if="item.details"
size="sm" variant="outline-dark" class="ml-lg-2 mt-2 mt-lg-0" size="sm" variant="outline-dark" class="ml-lg-2 mt-2 mt-lg-0"
v-b-toggle="'collapse-' + id + '-item-' + i" v-b-toggle="`collapse-${report.id}-item-${i}`"
> >
<icon iname="level-down" /> <span v-t="'details'" /> <icon iname="level-down" /> {{ $t('details') }}
</b-button> </b-button>
</div> </div>
</div> </div>
<b-collapse v-if="details" :id="'collapse-' + id + '-item-' + i"> <b-collapse v-if="item.details" :id="`collapse-${report.id}-item-${i}`">
<ul class="mt-2 pl-4"> <ul class="mt-2 pl-4">
<li v-for="(detail, index) in details" :key="index" v-html="detail" /> <li v-for="(detail, index) in item.details" :key="index" v-html="detail" />
</ul> </ul>
</b-collapse> </b-collapse>
</b-list-group-item> </b-list-group-item>
</b-list-group> </b-list-group>
</b-collapse> </card>
<template #skeleton>
<card-list-skeleton />
<b-card no-body>
<template #header>
<b-skeleton width="30%" height="36px" class="m-0" />
</template>
</b-card> </b-card>
</div> <card-list-skeleton />
</template>
</view-base>
</template> </template>
<script> <script>
@ -115,15 +113,45 @@ export default {
data () { data () {
return { return {
queries: ['diagnosis/show?full'],
loading: true,
reports: undefined reports: undefined
} }
}, },
methods: { methods: {
fetchData () { formatReportItem (report, item) {
api.get('diagnosis/show?full').then((data) => { let issue = false
let icon = ''
const status = item.variant = item.status.toLowerCase()
if (status === 'success') {
icon = 'check-circle'
} else if (status === 'info') {
icon = 'info-circle'
} else if (item.ignored) {
icon = status !== 'error' ? status : 'times'
item.variant = 'light'
report.ignoreds++
} else if (status === 'warning') {
icon = status
issue = true
report.warnings++
} else if (status === 'error') {
item.variant = 'danger'
icon = 'times'
issue = true
report.errors++
}
item.issue = issue
item.icon = icon
},
formatData (data) {
if (data === null) { if (data === null) {
this.reports = null this.reports = null
this.loading = false
return return
} }
@ -134,53 +162,36 @@ export default {
report.ignoreds = 0 report.ignoreds = 0
for (var item of report.items) { for (var item of report.items) {
let issue = false this.formatReportItem(report, item)
let icon = ''
const status = item.status = item.status.toLowerCase()
if (status === 'success') {
icon = 'check-circle'
} else if (status === 'info') {
icon = 'info-circle'
} else if (item.ignored) {
icon = status !== 'error' ? status : 'times'
item.status = 'ignored'
report.ignoreds++
} else if (status === 'warning') {
icon = status
issue = true
report.warnings++
} else if (status === 'error') {
item.status = 'danger'
icon = 'times'
issue = true
report.errors++
}
item.issue = issue
item.icon = icon
item.filterArgs = Object.entries(item.meta).reduce((filterArgs, entries) => {
filterArgs.push(entries.join('='))
return filterArgs
}, [report.id])
} }
report.noIssues = report.warnings + report.errors === 0 report.noIssues = report.warnings + report.errors === 0
} }
this.reports = reports this.reports = reports
}) this.loading = false
}, },
runFullDiagnosis () { runDiagnosis (id = null) {
api.post('diagnosis/run').then(this.fetchData) const param = id !== null ? '?force' : ''
const data = id !== null ? { categories: [id] } : {}
api.post('diagnosis/run' + param, data).then(this.$refs.view.fetchQueries)
}, },
reRunDiagnosis (id) { toggleIgnoreIssue (ignore, report, item) {
api.post('diagnosis/run?force', { categories: [id] }).then(this.fetchData)
},
toggleIgnoreIssue (ignore, filterArgs, reportIndex, itemIndex) {
const key = (ignore ? 'add' : 'remove') + '_filter' const key = (ignore ? 'add' : 'remove') + '_filter'
api.post('diagnosis/ignore', { [key]: filterArgs }).then(this.fetchData) const filterArgs = Object.entries(item.meta).reduce((filterArgs, entries) => {
filterArgs.push(entries.join('='))
return filterArgs
}, [report.id])
api.post('diagnosis/ignore', { [key]: filterArgs }).then(() => {
item.ignored = ignore
if (ignore) {
report[item.status.toLowerCase() + 's']--
} else {
report.ignoreds--
}
this.formatReportItem(report, item)
})
}, },
shareLogs () { shareLogs () {
@ -191,17 +202,15 @@ export default {
}, },
created () { created () {
api.post('diagnosis/run?except_if_never_ran_yet').then(this.fetchData) api.post('diagnosis/run?except_if_never_ran_yet')
}, },
filters: { filters: { distanceToNow }
distanceToNow
}
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.badge { .badge + .badge {
margin-left: .5rem margin-left: .5rem
} }

View file

@ -1,8 +1,10 @@
<template lang="html"> <template>
<view-base :queries="queries" skeleton="card-form-skeleton">
<domain-form <domain-form
:title="$t('domain_add')" :server-error="serverError" :title="$t('domain_add')" :server-error="serverError"
@submit="onSubmit" :submit-text="$t('add')" @submit="onSubmit" :submit-text="$t('add')"
/> />
</view-base>
</template> </template>
<script> <script>
@ -13,6 +15,7 @@ export default {
data () { data () {
return { return {
queries: [{ uri: 'domains' }],
serverError: '' serverError: ''
} }
}, },
@ -33,8 +36,6 @@ export default {
} }
}, },
components: { components: { DomainForm }
DomainForm
}
} }
</script> </script>

View file

@ -1,37 +1,33 @@
<template> <template>
<div class="domain-cert" v-if="cert"> <view-base :queries="queries" @queries-response="formatCertData" ref="view">
<b-card> <card v-if="cert" :title="$t('certificate_status')" icon="lock">
<template v-slot:header>
<h2><icon iname="lock" /> {{ $t('certificate_status') }}</h2>
</template>
<p :class="'alert alert-' + cert.alert.type"> <p :class="'alert alert-' + cert.alert.type">
<icon :iname="cert.alert.icon" /> {{ $t('certificate_alert_' + cert.alert.trad) }} <icon :iname="cert.alert.icon" /> {{ $t('certificate_alert_' + cert.alert.trad) }}
</p> </p>
<dl> <b-row no-gutters class="row-line">
<dt v-t="'certificate_authority'" /> <b-col md="4" xl="2">
<dd>{{ cert.type }} ({{ name }})</dd> <strong v-t="'certificate_authority'" />
<hr> </b-col>
<dt v-t="'validity'" /> <b-col>{{ cert.type }} ({{ name }})</b-col>
<dd>{{ $tc('day_validity', cert.validity) }}</dd> </b-row>
</dl>
</b-card>
<b-card> <b-row no-gutters class="row-line">
<template v-slot:header> <b-col md="4" xl="2">
<h2><icon iname="wrench" /> {{ $t('operations') }}</h2> <strong v-t="'validity'" />
</template> </b-col>
<b-col>{{ $tc('day_validity', cert.validity) }}</b-col>
</b-row>
</card>
<card v-if="cert" :title="$t('operations')" icon="wrench">
<!-- CERT INSTALL LETSENCRYPT --> <!-- CERT INSTALL LETSENCRYPT -->
<template v-if="actionsEnabled.installLetsencrypt"> <template v-if="actionsEnabled.installLetsencrypt">
<p> <p>
<icon :iname="cert.acmeEligible ? 'check' : 'meh-o'" /> <span v-html="$t(`domain_${cert.acmeEligible ? 'is' : 'not'}_eligible_for_ACME`)" /> <icon :iname="cert.acmeEligible ? 'check' : 'meh-o'" /> <span v-html="$t(`domain_${cert.acmeEligible ? 'is' : 'not'}_eligible_for_ACME`)" />
</p> </p>
<b-button
variant="success" :disabled="!cert.acmeEligible" <b-button @click="callAction('install_LE')" variant="success" :disabled="!cert.acmeEligible">
@click="action = 'install_LE'" v-b-modal.action-confirm-modal
>
<icon iname="star" /> {{ $t('install_letsencrypt_cert') }} <icon iname="star" /> {{ $t('install_letsencrypt_cert') }}
</b-button> </b-button>
<hr> <hr>
@ -40,7 +36,8 @@
<!-- CERT RENEW LETS-ENCRYPT --> <!-- CERT RENEW LETS-ENCRYPT -->
<template v-if="actionsEnabled.manualRenewLetsencrypt"> <template v-if="actionsEnabled.manualRenewLetsencrypt">
<p v-t="'manually_renew_letsencrypt_message'" /> <p v-t="'manually_renew_letsencrypt_message'" />
<b-button variant="warning" @click="action = 'manual_renew_LE'" v-b-modal.action-confirm-modal>
<b-button @click="callAction('manual_renew_LE')" variant="warning">
<icon iname="refresh" /> {{ $t('manually_renew_letsencrypt') }} <icon iname="refresh" /> {{ $t('manually_renew_letsencrypt') }}
</b-button> </b-button>
<hr> <hr>
@ -49,7 +46,8 @@
<!-- CERT REGEN SELF-SIGNED --> <!-- CERT REGEN SELF-SIGNED -->
<template v-if="actionsEnabled.regenSelfsigned"> <template v-if="actionsEnabled.regenSelfsigned">
<p v-t="'regenerate_selfsigned_cert_message'" /> <p v-t="'regenerate_selfsigned_cert_message'" />
<b-button variant="warning" @click="action = 'regen_selfsigned'" v-b-modal.action-confirm-modal>
<b-button @click="callAction('regen_selfsigned')" variant="warning">
<icon iname="refresh" /> {{ $t('regenerate_selfsigned_cert') }} <icon iname="refresh" /> {{ $t('regenerate_selfsigned_cert') }}
</b-button> </b-button>
<hr> <hr>
@ -58,23 +56,19 @@
<!-- CERT REPLACE WITH SELF-SIGNED --> <!-- CERT REPLACE WITH SELF-SIGNED -->
<template v-if="actionsEnabled.replaceWithSelfsigned"> <template v-if="actionsEnabled.replaceWithSelfsigned">
<p v-t="'revert_to_selfsigned_cert_message'" /> <p v-t="'revert_to_selfsigned_cert_message'" />
<b-button variant="danger" @click="action = 'revert_to_selfsigned'" v-b-modal.action-confirm-modal>
<b-button @click="callAction('revert_to_selfsigned')" variant="danger">
<icon iname="exclamation-triangle" /> {{ $t('revert_to_selfsigned_cert') }} <icon iname="exclamation-triangle" /> {{ $t('revert_to_selfsigned_cert') }}
</b-button> </b-button>
<hr> <hr>
</template> </template>
</b-card> </card>
<!-- ACTIONS CONFIRMATION MODAL --> <template #skeleton>
<b-modal <card-info-skeleton :item-count="2" />
v-if="action" <card-buttons-skeleton :item-count="2" />
id="action-confirm-modal" centered </template>
body-bg-variant="danger" body-text-variant="light" </view-base>
@ok="callAction" hide-header
>
{{ $t(`confirm_cert_${action}`) }}
</b-modal>
</div>
</template> </template>
<script> <script>
@ -84,55 +78,43 @@ export default {
name: 'DomainCert', name: 'DomainCert',
props: { props: {
name: { name: { type: String, required: true }
type: String,
required: true
}
}, },
data () { data () {
return { return {
queries: [`domains/cert-status/${this.name}?full`],
cert: undefined, cert: undefined,
actionsEnabled: undefined, actionsEnabled: undefined
action: undefined
} }
}, },
methods: { methods: {
fetchData () { formatCertAlert (code, type) {
// simply use the api helper since we will not store the request's result. switch (code) {
api.get(`domains/cert-status/${this.name}?full`).then((data) => { case 'critical': return { type: 'danger', trad: 'not_valid', icon: 'exclamation-circle' }
case 'warning': return { type: 'warning', trad: 'selfsigned', icon: 'exclamation-triangle' }
case 'attention':
if (type === 'lets-encrypt') {
return { type: 'warning', trad: 'letsencrypt_about_to_expire', icon: 'clock-o' }
} else {
return { type: 'danger', trad: 'about_to_expire', icon: 'clock-o' }
}
case 'good': return { type: 'success', trad: 'good', icon: 'check-circle' }
case 'great': return { type: 'success', trad: 'great', icon: 'thumbs-up' }
default: return { type: 'warning', trad: 'unknown', icon: 'question' }
}
},
formatCertData (data) {
const certData = data.certificates[this.name] const certData = data.certificates[this.name]
const cert = { const cert = {
type: certData.CA_type.verbose, type: certData.CA_type.verbose,
name: certData.CA_name, name: certData.CA_name,
validity: certData.validity, validity: certData.validity,
acmeEligible: certData.ACME_eligible acmeEligible: certData.ACME_eligible,
} alert: this.formatCertAlert(certData.summary.code, certData.CA_type.verbose)
switch (certData.summary.code) {
case 'critical':
cert.alert = { type: 'danger', trad: 'not_valid', icon: 'exclamation-circle' }
break
case 'warning':
cert.alert = { type: 'warning', trad: 'selfsigned', icon: 'exclamation-triangle' }
break
case 'attention':
if (cert.type === 'lets-encrypt') {
cert.alert = { type: 'warning', trad: 'letsencrypt_about_to_expire', icon: 'clock-o' }
} else {
cert.alert = { type: 'danger', trad: 'about_to_expire', icon: 'clock-o' }
}
break
case 'good':
cert.alert = { type: 'success', trad: 'good', icon: 'check-circle' }
break
case 'great':
cert.alert = { type: 'success', trad: 'great', icon: 'thumbs-up' }
break
default:
cert.alert = { type: 'warning', trad: 'unknown', icon: 'question' }
} }
const actionsEnabled = { const actionsEnabled = {
@ -155,25 +137,22 @@ export default {
actionsEnabled.replaceWithSelfsigned = true actionsEnabled.replaceWithSelfsigned = true
} }
this.action = undefined
this.cert = cert this.cert = cert
this.actionsEnabled = actionsEnabled this.actionsEnabled = actionsEnabled
})
}, },
callAction () { async callAction (action) {
const action = this.action const confirmed = await this.$askConfirmation(this.$i18n.t(`confirm_cert_${action}`))
if (!confirmed) return
let uri = 'domains/cert-install/' + this.name let uri = 'domains/cert-install/' + this.name
if (action === 'regen_selfsigned') uri += '?self_signed' if (action === 'regen_selfsigned') uri += '?self_signed'
else if (action === 'manual_renew_LE') uri += '?force' else if (action === 'manual_renew_LE') uri += '?force'
else if (action === 'revert_to_selfsigned') uri += '?self_signed&force' else if (action === 'revert_to_selfsigned') uri += '?self_signed&force'
// FIXME trigger loading ? while posting ? while getting ?
api.post(uri, {}).then(() => this.fetchData()) // this.$refs.view.fallback_loading = true
api.post(uri).then(this.$refs.view.fetchQueries)
} }
},
created () {
this.fetchData()
} }
} }
</script> </script>

View file

@ -1,41 +1,30 @@
<template> <template>
<div class="domain-dns"> <view-base :queries="queries" @queries-response="dnsConfig = $event" skeleton="card-info-skeleton">
<template #top>
<p class="alert alert-warning"> <p class="alert alert-warning">
<icon iname="warning" /> {{ $t('domain_dns_conf_is_just_a_recommendation') }} <icon iname="warning" /> {{ $t('domain_dns_conf_is_just_a_recommendation') }}
</p> </p>
<b-card>
<template v-slot:header>
<h2><icon iname="globe" /> {{ $t('domain_dns_config') }}</h2>
</template> </template>
<pre><code>{{ dnsConfig }}</code></pre>
</b-card> <card :title="$t('domain_dns_config')" icon="globe" no-body>
</div> <pre class="log"><code>{{ dnsConfig }}</code></pre>
</card>
</view-base>
</template> </template>
<script> <script>
import api from '@/api'
export default { export default {
name: 'DomainDns', name: 'DomainDns',
props: { props: {
name: { name: { type: String, required: true }
type: String,
required: true
}
}, },
data () { data () {
return { return {
queries: [`domains/${this.name}/dns`],
dnsConfig: '' dnsConfig: ''
} }
},
created () {
// simply use the api helper since we will not store the request's result.
api.get(`domains/${this.name}/dns`).then(dnsConfig => {
this.dnsConfig = dnsConfig
})
} }
} }
</script> </script>
<style lang="scss" scoped>
</style>

View file

@ -1,9 +1,6 @@
<template> <template>
<div class="domain-info"> <view-base :queries="queries" skeleton="card-list-skeleton">
<b-card> <card :title="name" icon="globe">
<template v-slot:header>
<h2><icon iname="globe" /> {{ name }}</h2>
</template>
<!-- VISIT --> <!-- VISIT -->
<p>{{ $t('domain_visit_url', { url: 'https://' + name }) }}</p> <p>{{ $t('domain_visit_url', { url: 'https://' + name }) }}</p>
<b-button variant="success" :href="'https://' + name" target="_blank"> <b-button variant="success" :href="'https://' + name" target="_blank">
@ -13,16 +10,12 @@
<!-- DEFAULT DOMAIN --> <!-- DEFAULT DOMAIN -->
<p>{{ $t('domain_default_desc') }}</p> <p>{{ $t('domain_default_desc') }}</p>
<template v-if="isMainDomain"> <p v-if="isMainDomain" class="alert alert-info">
<p class="alert alert-info">
<icon iname="star" /> {{ $t('domain_default_longdesc') }} <icon iname="star" /> {{ $t('domain_default_longdesc') }}
</p> </p>
</template> <b-button v-else variant="info" @click="setAsDefaultDomain">
<template v-else>
<b-button variant="info" v-b-modal.default-domain-modal>
<icon iname="star" /> {{ $t('set_default') }} <icon iname="star" /> {{ $t('set_default') }}
</b-button> </b-button>
</template>
<hr> <hr>
<!-- DNS CONFIG --> <!-- DNS CONFIG -->
@ -41,53 +34,50 @@
<!-- DELETE --> <!-- DELETE -->
<p>{{ $t('domain_delete_longdesc') }}</p> <p>{{ $t('domain_delete_longdesc') }}</p>
<b-button variant="danger" v-b-modal.delete-modal> <p
v-if="isMainDomain" class="alert alert-danger"
v-html="$t('domain_delete_forbidden_desc', { domain: name })"
/>
<b-button v-else variant="danger" @click="deleteDomain">
<icon iname="trash-o" /> {{ $t('delete') }} <icon iname="trash-o" /> {{ $t('delete') }}
</b-button> </b-button>
</b-card> </card>
</view-base>
<!-- DEFAULT DOMAIN MODAL -->
<b-modal
id="default-domain-modal" centered
body-bg-variant="danger" body-text-variant="light"
@ok="setAsDefaultDomain" hide-header
>
{{ $t('confirm_change_maindomain') }}
</b-modal>
<!-- DELETE MODAL -->
<b-modal
id="delete-modal" centered
body-bg-variant="danger" body-text-variant="light"
@ok="deleteDomain" hide-header
>
{{ $t('confirm_delete', { name }) }}
</b-modal>
</div>
</template> </template>
<script> <script>
import { mapGetters } from 'vuex'
export default { export default {
name: 'DomainInfo', name: 'DomainInfo',
props: { props: {
name: { name: {
type: String, type: String,
required: true required: true
} }
}, },
computed: {
mainDomain () { data () {
return this.$store.state.data.main_domain return {
queries: [{ uri: 'domains/main', storeKey: 'main_domain' }]
}
}, },
computed: {
...mapGetters(['mainDomain']),
isMainDomain () { isMainDomain () {
if (!this.mainDomain) return if (!this.mainDomain) return
return this.name === this.mainDomain return this.name === this.mainDomain
} }
}, },
methods: { methods: {
deleteDomain () { async deleteDomain () {
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_delete', { name: this.name }))
if (!confirmed) return
this.$store.dispatch('DELETE', this.$store.dispatch('DELETE',
{ uri: 'domains', param: this.name } { uri: 'domains', param: this.name }
).then(() => { ).then(() => {
@ -95,22 +85,17 @@ export default {
}) })
}, },
setAsDefaultDomain () { async setAsDefaultDomain () {
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_change_maindomain'))
if (!confirmed) return
this.$store.dispatch('PUT', this.$store.dispatch('PUT',
{ uri: 'domains/main', data: { new_main_domain: this.name }, storeKey: 'main_domain' } { uri: 'domains/main', data: { new_main_domain: this.name }, storeKey: 'main_domain' }
).then(() => { ).then(() => {
// FIXME have to commit here since the response's is empty // Have to commit by hand here since the response is empty
this.$store.commit('UPDATE_MAIN_DOMAIN', this.name) this.$store.commit('UPDATE_MAIN_DOMAIN', this.name)
}) })
} }
},
created () {
this.$store.dispatch('FETCH',
{ uri: 'domains/main', storeKey: 'main_domain' }
)
} }
} }
</script> </script>
<style lang="scss" scoped>
</style>

View file

@ -1,10 +1,11 @@
<template> <template>
<search-view <view-search
id="domain-list" id="domain-list"
:search.sync="search" :search.sync="search"
:items="domains" :items="domains"
:filtered-items="filteredDomains" :filtered-items="filteredDomains"
items-name="domains" items-name="domains"
:queries="queries"
> >
<template #top-bar-buttons> <template #top-bar-buttons>
<b-button variant="success" :to="{ name: 'domain-add' }"> <b-button variant="success" :to="{ name: 'domain-add' }">
@ -34,19 +35,21 @@
<icon iname="chevron-right" class="lg fs-sm ml-auto" /> <icon iname="chevron-right" class="lg fs-sm ml-auto" />
</b-list-group-item> </b-list-group-item>
</b-list-group> </b-list-group>
</search-view> </view-search>
</template> </template>
<script> <script>
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
import SearchView from '@/components/SearchView'
export default { export default {
name: 'DomainList', name: 'DomainList',
data () { data () {
return { return {
queries: [
{ uri: 'domains/main', storeKey: 'main_domain' },
{ uri: 'domains' }
],
search: '' search: ''
} }
}, },
@ -61,17 +64,8 @@ export default {
const domains = this.domains const domains = this.domains
.filter(name => name.toLowerCase().includes(search)) .filter(name => name.toLowerCase().includes(search))
.sort(prevDomain => prevDomain === mainDomain ? -1 : 1) .sort(prevDomain => prevDomain === mainDomain ? -1 : 1)
return domains.length > 0 ? domains : null return domains.length ? domains : null
}
} }
},
created () {
this.$store.dispatch('FETCH_ALL', [
{ uri: 'domains/main', storeKey: 'main_domain' },
{ uri: 'domains' }
])
},
components: { SearchView }
} }
</script> </script>

View file

@ -1,4 +1,4 @@
<template lang="html"> <template>
<card-form <card-form
:title="$t('group_new')" icon="users" :title="$t('group_new')" icon="users"
:validation="$v" :server-error="serverError" :validation="$v" :server-error="serverError"
@ -11,22 +11,18 @@
<script> <script>
import { validationMixin } from 'vuelidate' import { validationMixin } from 'vuelidate'
import { required, alphalownum_ } from '@/helpers/validators'
import { required, alphalownum_ } from '@/helpers/validators'
export default { export default {
name: 'GroupCreate', name: 'GroupCreate',
mixins: [validationMixin],
data () { data () {
return { return {
form: { form: {
groupname: '' groupname: ''
}, },
serverError: '', serverError: '',
groupname: { groupname: {
label: this.$i18n.t('group_name'), label: this.$i18n.t('group_name'),
description: this.$i18n.t('group_format_name_help'), description: this.$i18n.t('group_format_name_help'),
@ -55,6 +51,8 @@ export default {
this.isValid.groupname = false this.isValid.groupname = false
}) })
} }
} },
mixins: [validationMixin]
} }
</script> </script>

View file

@ -1,45 +1,34 @@
<template> <template>
<search-view <view-search
id="group-list" items-name="groups"
:search.sync="search" :search.sync="search"
:items="normalGroups" :items="normalGroups"
:filtered-items="filteredGroups" :filtered-items="filteredGroups"
items-name="groups" :queries="queries"
@queries-response="formatGroups"
skeleton="card-form-skeleton"
> >
<template #top-bar-buttons> <template #top-bar-buttons>
<b-button variant="success" :to="{ name: 'group-create' }"> <b-button variant="success" :to="{ name: 'group-create' }">
<icon iname="plus" /> <icon iname="plus" /> {{ $t('group_new') }}
{{ $t('group_new') }}
</b-button> </b-button>
</template> </template>
<!-- PRIMARY GROUPS CARDS --> <!-- PRIMARY GROUPS CARDS -->
<b-card <card
v-for="(group, name, index) in filteredGroups" :key="name" v-for="(group, name) in filteredGroups" :key="name" collapsable
no-body :title="group.isSpecial ? $t('group_' + name) : `${$t('group')} '${name}'`" icon="group"
> >
<b-card-header class="d-flex align-items-center"> <template #header-buttons>
<h2> <!-- DELETE GROUP -->
<icon iname="group" /> {{ group.isSpecial ? $t('group_' + name) : `${$t('group')} "${name}"` }}
</h2>
<div class="ml-auto">
<b-button v-b-toggle="'collapse-' + index" size="sm" variant="outline-secondary">
<icon iname="chevron-right" class="rotate" /><span class="sr-only">{{ $t('words.collapse') }}</span>
</b-button>
<b-button <b-button
v-if="!group.isSpecial" v-b-modal.delete-modal v-if="!group.isSpecial" @click="deleteGroup(name)"
variant="danger" class="ml-2" size="sm" size="sm" variant="danger"
@click="groupToDelete = name"
> >
<icon :title="$t('delete')" iname="trash-o" /> <span class="sr-only">{{ $t('delete') }}</span> <icon iname="trash-o" /> {{ $t('delete') }}
</b-button> </b-button>
</div> </template>
</b-card-header>
<b-collapse :id="'collapse-' + index" visible>
<b-card-body>
<b-row> <b-row>
<b-col md="3" lg="2"> <b-col md="3" lg="2">
<strong>{{ $t('users') }}</strong> <strong>{{ $t('users') }}</strong>
@ -63,6 +52,7 @@
</b-col> </b-col>
</b-row> </b-row>
<hr> <hr>
<b-row> <b-row>
<b-col md="3" lg="2"> <b-col md="3" lg="2">
<strong>{{ $t('permissions') }}</strong> <strong>{{ $t('permissions') }}</strong>
@ -79,29 +69,15 @@
/> />
</b-col> </b-col>
</b-row> </b-row>
</b-card-body> </card>
</b-collapse>
</b-card>
<!-- GROUP SPECIFIC CARD --> <!-- GROUP SPECIFIC CARD -->
<template #extra> <card
<b-card no-body v-if="userGroups"> v-if="userGroups" collapsable
<b-card-header class="d-flex align-items-center"> :title="$t('group_specific_permissions')" icon="group"
<h2> >
<icon iname="group" /> {{ $t('group_specific_permissions') }} <template v-for="(name, index) in userGroupsNames">
</h2> <b-row :key="name">
<div class="ml-auto">
<b-button v-b-toggle.collapse-specific size="sm" variant="outline-secondary">
<icon iname="chevron-right" class="rotate" /><span class="sr-only">{{ $t('words.collapse') }}</span>
</b-button>
</div>
</b-card-header>
<b-collapse id="collapse-specific" visible>
<b-card-body>
<div v-for="name in userGroupsNames" :key="name">
<b-row>
<b-col md="3" lg="2"> <b-col md="3" lg="2">
<icon iname="user" /> <strong>{{ name }}</strong> <icon iname="user" /> <strong>{{ name }}</strong>
</b-col> </b-col>
@ -117,8 +93,8 @@
/> />
</b-col> </b-col>
</b-row> </b-row>
<hr> <hr :key="index">
</div> </template>
<base-selectize <base-selectize
v-if="availableMembers.length" v-if="availableMembers.length"
@ -127,20 +103,8 @@
:selected="userGroupsNames" :selected="userGroupsNames"
@selected="onSpecificUserAdded" @selected="onSpecificUserAdded"
/> />
</b-card-body> </card>
</b-collapse> </view-search>
</b-card>
<!-- DELETE GROUP MODAL -->
<b-modal
v-if="groupToDelete" id="delete-modal" centered
body-bg-variant="danger" body-text-variant="light"
@ok="deleteGroup" hide-header
>
{{ $t('confirm_delete', {name: groupToDelete }) }}
</b-modal>
</template>
</search-view>
</template> </template>
<script> <script>
@ -148,7 +112,6 @@ import Vue from 'vue'
import api from '@/api' import api from '@/api'
import { isEmptyValue } from '@/helpers/commons' import { isEmptyValue } from '@/helpers/commons'
import SearchView from '@/components/SearchView'
import ZoneSelectize from '@/components/ZoneSelectize' import ZoneSelectize from '@/components/ZoneSelectize'
import BaseSelectize from '@/components/BaseSelectize' import BaseSelectize from '@/components/BaseSelectize'
@ -159,11 +122,15 @@ export default {
data () { data () {
return { return {
queries: [
{ uri: 'users' },
{ uri: 'users/groups?full&include_primary_groups', storeKey: 'groups' },
{ uri: 'users/permissions?full', storeKey: 'permissions' }
],
search: '', search: '',
permissions: undefined, permissions: undefined,
normalGroups: undefined, normalGroups: undefined,
userGroups: undefined, userGroups: undefined
groupToDelete: undefined
} }
}, },
@ -199,58 +166,7 @@ export default {
}, },
methods: { methods: {
onPermissionChanged ({ item, index, name, groupType, action }) { formatGroups (users, allGroups, permissions) {
const uri = 'users/permissions/' + item
const data = { [action]: name }
const from = action === 'add' ? 'availablePermissions' : 'permissions'
const to = action === 'add' ? 'permissions' : 'availablePermissions'
api.put(uri, data).then(() => {
this[groupType + 'Groups'][name][from].splice(index, 1)
this[groupType + 'Groups'][name][to].push(item)
})
},
onUserChanged ({ item, index, name, action }) {
const uri = 'users/groups/' + name
const data = { [action]: item }
const from = action === 'add' ? 'availableMembers' : 'members'
const to = action === 'add' ? 'members' : 'availableMembers'
api.put(uri, data).then(() => {
this.normalGroups[name][from].splice(index, 1)
this.normalGroups[name][to].push(item)
})
},
onSpecificUserAdded ({ item }) {
this.userGroups[item].permissions = []
},
// FIXME Find a way to pass a filter to a component
formatPermission (name) {
return this.permissions[name].label
},
removable (name) {
return this.permissions[name].protected === false
},
deleteGroup () {
const groupname = this.groupToDelete
this.$store.dispatch('DELETE',
{ uri: 'users/groups', param: groupname, storeKey: 'groups' }
).then(() => {
Vue.delete(this.groups, groupname)
})
this.groupToDelete = undefined
}
},
created () {
this.$store.dispatch('FETCH_ALL', [
{ uri: 'users' },
{ uri: 'users/groups?full&include_primary_groups', storeKey: 'groups' },
{ uri: 'users/permissions?full', storeKey: 'permissions' }
]).then(([users, allGroups, permissions]) => {
// Do not use computed properties to get values from the store here to avoid auto // Do not use computed properties to get values from the store here to avoid auto
// updates while modifying values. // updates while modifying values.
const normalGroups = {} const normalGroups = {}
@ -289,12 +205,57 @@ export default {
this.permissions = permissions this.permissions = permissions
this.normalGroups = normalGroups this.normalGroups = normalGroups
this.userGroups = userGroups this.userGroups = isEmptyValue(userGroups) ? null : userGroups
},
onPermissionChanged ({ item, index, name, groupType, action }) {
const uri = 'users/permissions/' + item
const data = { [action]: name }
const from = action === 'add' ? 'availablePermissions' : 'permissions'
const to = action === 'add' ? 'permissions' : 'availablePermissions'
api.put(uri, data).then(() => {
this[groupType + 'Groups'][name][from].splice(index, 1)
this[groupType + 'Groups'][name][to].push(item)
}) })
}, },
onUserChanged ({ item, index, name, action }) {
const uri = 'users/groups/' + name
const data = { [action]: item }
const from = action === 'add' ? 'availableMembers' : 'members'
const to = action === 'add' ? 'members' : 'availableMembers'
api.put(uri, data).then(() => {
this.normalGroups[name][from].splice(index, 1)
this.normalGroups[name][to].push(item)
})
},
onSpecificUserAdded ({ item }) {
this.userGroups[item].permissions = []
},
// FIXME Find a way to pass a filter to a component
formatPermission (name) {
return this.permissions[name].label
},
removable (name) {
return this.permissions[name].protected === false
},
async deleteGroup (name) {
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_delete', { name }))
if (!confirmed) return
this.$store.dispatch('DELETE',
{ uri: 'users/groups', param: name, storeKey: 'groups' }
).then(() => {
Vue.delete(this.normalGroups, name)
})
}
},
components: { components: {
SearchView,
ZoneSelectize, ZoneSelectize,
BaseSelectize BaseSelectize
} }

View file

@ -1,91 +1,71 @@
<template> <template>
<div class="service-info"> <view-base
:queries="queries" @queries-response="formatServiceData"
ref="view" skeleton="card-info-skeleton"
>
<!-- INFO CARD --> <!-- INFO CARD -->
<b-card> <card :title="name" icon="info-circle" button-unbreak="sm">
<template v-slot:header> <template #header-buttons>
<div class="d-sm-flex"> <template v-if="infos.status === 'running'">
<h2><icon iname="info-circle" /> {{ name }}</h2> <!-- RESTART SERVICE -->
<div class="ml-auto mt-2 mt-sm-0"> <b-button @click="updateService('restart')" variant="warning">
<template v-if="status === 'running'">
<b-button variant="warning" @click="action = 'restart'" v-b-modal.action-confirm-modal>
<icon iname="refresh" /> {{ $t('restart') }} <icon iname="refresh" /> {{ $t('restart') }}
</b-button> </b-button>
<b-button
v-if="!critical" variant="danger" class="ml-2" <!-- STOP SERVICE -->
@click="action = 'stop'" v-b-modal.action-confirm-modal <b-button v-if="!isCritical" @click="updateService('stop')" variant="danger">
>
<icon iname="warning" /> {{ $t('stop') }} <icon iname="warning" /> {{ $t('stop') }}
</b-button> </b-button>
</template> </template>
<b-button
v-else <!-- START SERVICE -->
variant="success" @click="action = 'start'" v-b-modal.action-confirm-modal <b-button v-else @click="updateService('start')" variant="success">
>
<icon iname="play" /> {{ $t('start') }} <icon iname="play" /> {{ $t('start') }}
</b-button> </b-button>
</div>
</div>
</template> </template>
<b-row no-gutters class="row-line"> <b-row
<b-col cols="auto" md="3"><strong v-t="'description'" /></b-col> v-for="(value, key) in infos" :key="key"
<b-col>{{ description }}</b-col> no-gutters class="row-line"
</b-row> >
<b-row no-gutters class="row-line"> <b-col md="3" xl="2">
<b-col cols="auto" md="3"><strong v-t="'status'" /></b-col> <strong>{{ $t(key === 'start_on_boot' ? 'service_' + key : key) }}</strong>
</b-col>
<b-col> <b-col>
<span :class="status === 'running' ? 'text-success' : 'text-danger'"> <template v-if="key === 'status'">
<icon :iname="status === 'running' ? 'check-circle' : 'times'" /> <span :class="value === 'running' ? 'text-success' : 'text-danger'">
{{ $t(status) }} <icon :iname="value === 'running' ? 'check-circle' : 'times'" />
{{ $t(value) }}
</span> </span>
{{ $t('since') }} {{ last_state_change | distanceToNow }} {{ $t('since') }} {{ uptime | distanceToNow }}
</template>
<span v-else-if="key === 'start_on_boot'" :class="value === 'enabled' ? 'text-success' : 'text-danger'">
{{ $t(value) }}
</span>
<span v-else v-t="value" />
</b-col> </b-col>
</b-row> </b-row>
<b-row no-gutters class="row-line"> </card>
<b-col cols="auto" md="3"><strong v-t="'service_start_on_boot'" /></b-col>
<b-col>
<span :class="start_on_boot === 'enabled' ? 'text-success' : 'text-danger'">
{{ $t(start_on_boot) }}
</span>
</b-col>
</b-row>
<b-row no-gutters class="row-line">
<b-col cols="auto" md="3"><strong v-t="'configuration'" /></b-col>
<b-col>
<span :class="{ 'text-success': configuration === 'valid', 'text-danger': configuration === 'broken' }">
{{ $t(configuration) }}
</span>
</b-col>
</b-row>
</b-card>
<!-- LOGS CARD --> <!-- LOGS CARD -->
<b-card> <card :title="$t('logs')" icon="book" button-unbreak="sm">
<template v-slot:header> <template #header-buttons>
<div class="d-sm-flex justify-content-sm-between"> <b-button variant="success" @click="shareLogs">
<h2><icon iname="book" /> {{ $t('logs') }}</h2>
<b-button variant="success" @click="shareLogs" class="mt-2 mt-sm-0">
<icon iname="cloud-upload" /> {{ $t('logs_share_with_yunopaste') }} <icon iname="cloud-upload" /> {{ $t('logs_share_with_yunopaste') }}
</b-button> </b-button>
</div>
</template> </template>
<div class="w-100" v-for="{ filename, content} in logs" :key="filename"> <template v-for="({ filename, content }, i) in logs">
<h3>{{ filename }}</h3> <h3 :key="i + '-filename'">
<pre class="bg-light p-3"><code>{{ content }}</code></pre> {{ filename }}
</div> </h3>
</b-card>
<!-- ACTIONS CONFIRMATION MODAL --> <pre :key="i + '-content'" class="log"><code>{{ content }}</code></pre>
<b-modal </template>
v-if="action" </card>
id="action-confirm-modal" centered </view-base>
body-bg-variant="danger" body-text-variant="light"
@ok="updateService" hide-header
>
{{ $t(`confirm_service_${action}`, { name }) }}
</b-modal>
</div>
</template> </template>
<script> <script>
@ -96,68 +76,58 @@ export default {
name: 'ServiceInfo', name: 'ServiceInfo',
props: { props: {
name: { name: { type: String, required: true }
type: String,
required: true
}
}, },
data () { data () {
return { return {
queries: [
'services/' + this.name,
`services/${this.name}/log?number=50`
],
// Service data // Service data
status: undefined, infos: undefined,
description: '', uptime: undefined,
configuration: '', isCritical: undefined,
last_state_change: 0,
start_on_boot: undefined,
logs: undefined, logs: undefined,
// Modal action // Modal action
action: undefined, action: undefined
critical: undefined
} }
}, },
filters: {
distanceToNow
},
computed: {
},
methods: { methods: {
fetchData () { formatServiceData (
// simply use the api helper since we will not store the request's result. // eslint-disable-next-line
api.getAll([ { status, description, start_on_boot, last_state_change, configuration },
'services/' + this.name, logs
`services/${this.name}/log?number=50` ) {
]).then(([service, logs]) => { this.isCritical = ['nginx', 'ssh', 'slapd', 'yunohost-api'].includes(this.name)
this.critical = ['nginx', 'ssh', 'slapd', 'yunohost-api'].includes(this.name) // eslint-disable-next-line
if (service.last_state_change === 'unknown') { this.uptime = last_state_change === 'unknown' ? 0 : last_state_change
service.last_state_change = 0 this.infos = { description, status, start_on_boot, configuration }
}
for (const key in service) {
this[key] = service[key]
}
this.logs = Object.keys(logs).sort((prev, curr) => { this.logs = Object.keys(logs).sort((prev, curr) => {
if (prev === 'journalctl') return -1 if (prev === 'journalctl') return -1
else if (curr === 'journalctl') return 1 else if (curr === 'journalctl') return 1
else if (prev < curr) return -1 else if (prev < curr) return -1
else return 1 else return 1
}).map(filename => ({ content: logs[filename].join('\n'), filename })) }).map(filename => ({ content: logs[filename].join('\n'), filename }))
})
}, },
updateService () { async updateService (action) {
if (!['start', 'restart', 'stop'].includes(this.action)) return const confirmed = await this.$askConfirmation(
const method = this.action === 'stop' ? 'delete' : 'put' this.$i18n.t('confirm_service_' + action, { name: this.name })
const uri = this.action === 'restart' )
if (!confirmed) return
if (!['start', 'restart', 'stop'].includes(action)) return
const method = action === 'stop' ? 'delete' : 'put'
const uri = action === 'restart'
? `services/${this.name}/restart` ? `services/${this.name}/restart`
: 'services/' + this.name : 'services/' + this.name
// FIXME API doesn't return anything to the PUT so => json err // FIXME API doesn't return anything to the PUT so => json err
api[method](uri).then(() => { api[method](uri).then(this.$refs.view.fetchQueries)
this.fetchData()
})
}, },
shareLogs () { shareLogs () {
@ -178,11 +148,16 @@ export default {
} }
}, },
created () { filters: { distanceToNow }
this.fetchData()
}
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
h3 {
margin-bottom: 1rem;
&:not(:first-of-type) {
margin-top: 2rem;
}
}
</style> </style>

View file

@ -1,24 +1,25 @@
<template> <template>
<search-view <view-search
id="service-list" id="service-list"
:search.sync="search" :search.sync="search"
:items="services" :items="services"
:filtered-items="filteredServices" :filtered-items="filteredServices"
items-name="services" items-name="services"
:queries="queries"
@queries-response="formatServices"
> >
<b-list-group v-if="filteredServices"> <b-list-group>
<b-list-group-item <b-list-group-item
v-for="{ name, description, status, last_state_change } in filteredServices" v-for="{ name, description, status, last_state_change } in filteredServices" :key="name"
:key="name || service"
:to="{ name: 'service-info', params: { name }}" :to="{ name: 'service-info', params: { name }}"
class="d-flex justify-content-between align-items-center pr-0" class="d-flex justify-content-between align-items-center pr-0"
> >
<div class="w-100"> <div>
<h5 class="font-weight-bold"> <h5 class="font-weight-bold">
{{ name }} {{ name }}
<small class="text-secondary">{{ description }}</small> <small class="text-secondary">{{ description }}</small>
</h5> </h5>
<p class="mb-0"> <p class="m-0">
<span :class="status === 'running' ? 'text-success' : 'text-danger'"> <span :class="status === 'running' ? 'text-success' : 'text-danger'">
<icon :iname="status === 'running' ? 'check-circle' : 'times'" /> <icon :iname="status === 'running' ? 'check-circle' : 'times'" />
{{ $t(status) }} {{ $t(status) }}
@ -26,22 +27,22 @@
{{ $t('since') }} {{ last_state_change | distanceToNow }} {{ $t('since') }} {{ last_state_change | distanceToNow }}
</p> </p>
</div> </div>
<icon iname="chevron-right" class="lg fs-sm ml-auto" /> <icon iname="chevron-right" class="lg fs-sm ml-auto" />
</b-list-group-item> </b-list-group-item>
</b-list-group> </b-list-group>
</search-view> </view-search>
</template> </template>
<script> <script>
import api from '@/api'
import { distanceToNow } from '@/helpers/filters/date' import { distanceToNow } from '@/helpers/filters/date'
import SearchView from '@/components/SearchView'
export default { export default {
name: 'ServiceList', name: 'ServiceList',
data () { data () {
return { return {
queries: ['services'],
search: '', search: '',
services: undefined services: undefined
} }
@ -54,41 +55,31 @@ export default {
const services = this.services.filter(({ name }) => { const services = this.services.filter(({ name }) => {
return name.toLowerCase().includes(search) return name.toLowerCase().includes(search)
}) })
return services.length > 0 ? services : null return services.length ? services : null
} }
}, },
methods: { methods: {
fetchData () { formatServices (services) {
// simply use the api helper since we will not store the request's result. this.services = Object.keys(services).sort().map(name => {
api.get('services').then(servicesData => { const service = services[name]
this.services = Object.keys(servicesData).sort().map(name => {
const service = servicesData[name]
if (service.last_state_change === 'unknown') { if (service.last_state_change === 'unknown') {
service.last_state_change = 0 service.last_state_change = 0
} }
return { ...service, name } return { ...service, name }
}) })
})
} }
}, },
created () { filters: { distanceToNow }
this.fetchData()
},
components: { SearchView },
filters: {
distanceToNow
}
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@include media-breakpoint-down(sm) { @include media-breakpoint-down(md) {
h5 small { h5 small {
display: block; display: block;
margin-top: .25rem;
} }
} }
</style> </style>

View file

@ -4,13 +4,7 @@
:server-error="serverError" :server-error="serverError"
@submit="onSubmit" @submit="onSubmit"
:extra="extra" :extra="extra"
> />
<template #extra-fields="{ v, fields, form }">
<!-- CURRENT ADMIN PASSWORD -->
<form-field v-bind="fields.currentPassword" v-model="form.currentPassword" :validation="v.form.currentPassword" />
<hr>
</template>
</password-form>
</template> </template>
<script> <script>
@ -20,7 +14,6 @@ import { validationMixin } from 'vuelidate'
import { PasswordForm } from '@/components/reusableForms' import { PasswordForm } from '@/components/reusableForms'
import { required, minLength } from '@/helpers/validators' import { required, minLength } from '@/helpers/validators'
export default { export default {
name: 'ToolAdminpw', name: 'ToolAdminpw',
@ -67,9 +60,6 @@ export default {
}, },
mixins: [validationMixin], mixins: [validationMixin],
components: { PasswordForm }
components: {
PasswordForm
}
} }
</script> </script>

View file

@ -1,30 +1,30 @@
<template> <template>
<div class="tool-log"> <view-base
:queries="queries" @queries-response="formatFirewallData"
ref="view" skeleton="card-form-skeleton"
>
<!-- PORTS --> <!-- PORTS -->
<b-card> <card :title="$t('ports')" icon="shield">
<template v-slot:header>
<h2><icon iname="shield" /> {{ $t('ports') }}</h2>
</template>
<div v-for="(items, protocol) in protocols" :key="protocol"> <div v-for="(items, protocol) in protocols" :key="protocol">
<h5>{{ $t(protocol) }}</h5> <h5>{{ $t(protocol) }}</h5>
<b-table <b-table
:fields="fields" :items="items" :fields="fields" :items="items"
small striped responsive="true" small striped responsive
> >
<!-- PORT CELL --> <!-- PORT CELL -->
<template v-slot:cell(port)="data"> <template #cell(port)="data">
{{ data.value }} {{ data.value }}
</template> </template>
<!-- CONNECTIONS CELL --> <!-- CONNECTIONS CELL -->
<template v-slot:cell()="data"> <template #cell()="data">
<b-checkbox <b-checkbox
v-if="data.field.key !== 'uPnP'" v-if="data.field.key !== 'uPnP'"
class="on-off-switch" class="on-off-switch"
v-model="data.value" v-model="data.value"
switch switch
@change="onToggle(protocol, data.field.key, data.item.port, data.index, $event)" @change="onTablePortToggling(data.item.port, protocol, data.field.key, data.index, $event)"
> >
<span :class="'btn btn-sm py-0 btn-' + (data.value ? 'danger' : 'success')"> <span :class="'btn btn-sm py-0 btn-' + (data.value ? 'danger' : 'success')">
{{ $t(data.value ? 'close' : 'open') }} {{ $t(data.value ? 'close' : 'open') }}
@ -39,108 +39,69 @@
</template> </template>
</b-table> </b-table>
</div> </div>
</b-card> </card>
<!-- OPERATIONS --> <!-- OPERATIONS -->
<b-card> <card-form
<template v-slot:header> :title="$t('operations')" icon="cogs"
<h2><icon iname="cogs" /> {{ $t('operations') }}</h2> :validation="$v" :server-error="serverError"
</template> @submit.prevent="onFormPortToggling"
inline form-classes="d-flex justify-content-between align-items-start"
<b-form
id="port-form" inline class="d-flex justify-content-between"
@submit.prevent="onFormSubmit"
> >
<b-input-group :prepend="$t('action')"> <b-input-group :prepend="$t('action')">
<b-select <b-select v-model="form.action" :options="actionChoices" />
id="input-action"
v-model="form.action" :options="actionChoices"
/>
</b-input-group> </b-input-group>
<form-field :validation="$v.form.port">
<b-input-group :prepend="$t('port')"> <b-input-group :prepend="$t('port')">
<b-input <input-item
id="input-port" placeholder="0" id="input-port" placeholder="0" type="number"
type="number" min="0" max="65535" v-model="form.port"
v-model.number="form.port"
/> />
</b-input-group> </b-input-group>
</form-field>
<b-input-group :prepend="$t('connection')"> <b-input-group :prepend="$t('connection')">
<b-select <b-select v-model="form.connection" :options="connectionChoices" id="input-connection" />
id="input-connection"
v-model="form.connection" :options="connectionChoices"
/>
</b-input-group> </b-input-group>
<b-input-group :prepend="$t('protocol')"> <b-input-group :prepend="$t('protocol')">
<b-select <b-select v-model="form.protocol" :options="protocolChoices" id="input-protocol" />
id="input-protocol"
v-model="form.protocol" :options="protocolChoices"
/>
</b-input-group> </b-input-group>
</b-form> </card-form>
<template v-slot:footer>
<b-button type="submit" form="port-form" variant="success">
{{ $t('save') }}
</b-button>
</template>
</b-card>
<!-- UPnP --> <!-- UPnP -->
<b-card :body-text-variant="upnpEnabled ? 'success' : 'danger'"> <card :title="$t('upnp')" icon="exchange" :body-text-variant="upnpEnabled ? 'success' : 'danger'">
<template v-slot:header>
<h2><icon iname="exchange" /> {{ $t('upnp') }}</h2>
</template>
{{ $t(upnpEnabled ? 'upnp_enabled' : 'upnp_disabled' ) }} {{ $t(upnpEnabled ? 'upnp_enabled' : 'upnp_disabled' ) }}
<b-form-invalid-feedback :state="upnpError !== '' ? false : null"> <b-form-invalid-feedback :state="upnpError !== '' ? false : null">
{{ upnpError }} {{ upnpError }}
</b-form-invalid-feedback> </b-form-invalid-feedback>
<template v-slot:footer> <template #buttons>
<b-button <b-button @click="toggleUpnp" :variant="!upnpEnabled ? 'success' : 'danger'">
:variant="!upnpEnabled ? 'success' : 'danger'"
v-b-modal.toggle-upnp-modal
>
{{ $t(!upnpEnabled ? 'enable' : 'disable' ) }} {{ $t(!upnpEnabled ? 'enable' : 'disable' ) }}
</b-button> </b-button>
</template> </template>
</b-card> </card>
</view-base>
<!-- TOGGLE PORT CONFIRM MODAL -->
<b-modal
no-close-on-backdrop centered hide-header
body-bg-variant="danger" body-text-variant="light"
@ok="togglePort(portToToggle)" ref="modal"
@cancel="onCancel"
>
{{ portToToggle ? $t('confirm_firewall_' + portToToggle.action, portToToggle) : '' }}
</b-modal>
<!-- TOGGLE UPNP CONFIRM MODAL -->
<b-modal
id="toggle-upnp-modal"
no-close-on-backdrop centered hide-header
body-bg-variant="danger" body-text-variant="light"
@ok="toggleUpnp(!upnpEnabled)"
>
{{ $t('confirm_upnp_' + (upnpEnabled ? 'disable' : 'enable')) }}
</b-modal>
</div>
</template> </template>
<script> <script>
import { validationMixin } from 'vuelidate'
import api from '@/api' import api from '@/api'
import { required, integer, between } from '@/helpers/validators'
export default { export default {
name: 'ToolFirewall', name: 'ToolFirewall',
data () { data () {
return { return {
// Tables data queries: ['/firewall?raw'],
serverError: '',
// Ports tables data
fields: [ fields: [
{ key: 'port', label: this.$i18n.t('port') }, { key: 'port', label: this.$i18n.t('port') },
{ key: 'ipv4', label: this.$i18n.t('ipv4') }, { key: 'ipv4', label: this.$i18n.t('ipv4') },
@ -150,7 +111,7 @@ export default {
protocols: undefined, protocols: undefined,
portToToggle: undefined, portToToggle: undefined,
// Form data // Ports form data
actionChoices: [ actionChoices: [
{ value: 'open', text: this.$i18n.t('open') }, { value: 'open', text: this.$i18n.t('open') },
{ value: 'close', text: this.$i18n.t('close') } { value: 'close', text: this.$i18n.t('close') }
@ -177,9 +138,14 @@ export default {
} }
}, },
validations: {
form: {
port: { number: required, integer, between: between(0, 65535) }
}
},
methods: { methods: {
fetchData () { formatFirewallData (data) {
api.get('/firewall?raw').then(data => {
const ports = Object.values(data).reduce((ports, protocols) => { const ports = Object.values(data).reduce((ports, protocols) => {
for (const type of ['TCP', 'UDP']) { for (const type of ['TCP', 'UDP']) {
for (const port of protocols[type]) { for (const port of protocols[type]) {
@ -206,59 +172,59 @@ export default {
this.protocols = tables this.protocols = tables
this.upnpEnabled = data.uPnP.enabled this.upnpEnabled = data.uPnP.enabled
})
}, },
togglePort ({ port, protocol, connection, action, index }) { togglePort ({ action, port, protocol, connection }) {
return new Promise((resolve, reject) => {
this.$askConfirmation(
this.$i18n.t('confirm_firewall_' + action, { port, protocol, connection })
).then(confirmed => {
if (confirmed) {
const method = action === 'open' ? 'post' : 'delete' const method = action === 'open' ? 'post' : 'delete'
api[method](`/firewall/port?${connection}_only`, { port, protocol }).then(() => { api[method](`/firewall/port?${connection}_only`, { port, protocol }).then(() => {
if (index === -1) this.fetchData() resolve(confirmed)
this.portToToggle = undefined }).catch(error => {
}).catch((err) => { reject(error)
console.log(err) })
} else {
resolve(confirmed)
}
})
}) })
}, },
toggleUpnp (value) { async toggleUpnp (value) {
api.get('firewall/upnp?action=' + (value ? 'enable' : 'disable')).then(r => { const action = this.upnpEnabled ? 'disable' : 'enable'
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_upnp_' + action))
if (!confirmed) return
api.get('firewall/upnp?action=' + action).then(() => {
// FIXME Couldn't test when it works. // FIXME Couldn't test when it works.
this.fetchData() this.$refs.view.fetchQueries()
}).catch(err => { }).catch(err => {
this.upnpError = err.message this.upnpError = err.message
}) })
}, },
onCancel () { onTablePortToggling (port, protocol, connection, index, value) {
const { protocol, index, connection, value } = this.portToToggle this.$set(this.protocols[protocol][index], connection, value)
if (index > -1) { const action = value ? 'open' : 'close'
this.togglePort({ action, port, protocol, connection }).then(toggled => {
// Revert change on cancel
if (!toggled) {
this.$set(this.protocols[protocol][index], connection, !value) this.$set(this.protocols[protocol][index], connection, !value)
} }
this.portToToggle = undefined })
}, },
onToggle (protocol, connection, port, index, value) { onFormPortToggling (e) {
this.$set(this.protocols[protocol][index], connection, value) this.togglePort(this.form).then(toggled => {
this.portToToggle = { if (toggled) this.$refs.view.fetchQueries()
protocol, connection, port, action: value ? 'open' : 'close', index, value })
}
this.$refs.modal.show()
},
onFormSubmit (e) {
// IMPROVEMENT: could check if ports are already opened for known ports (tricky with protocol='Both')
this.portToToggle = {
...this.form,
value: this.form.action === 'open',
// set index to -1 to trigger `this.fetchData` at modal `@ok`
index: -1
}
this.$refs.modal.show()
} }
}, },
created () { mixins: [validationMixin]
this.fetchData()
}
} }
</script> </script>
@ -293,16 +259,17 @@ export default {
} }
} }
form { ::v-deep form {
margin-bottom: -1rem; margin-bottom: -1rem;
.input-group { & > * {
margin-bottom: 1rem margin-bottom: 1rem;
}
@include media-breakpoint-down(xs) {
fieldset {
width: 100%;
}
} }
} }
.card-footer {
display: flex;
justify-content: flex-end;
}
</style> </style>

View file

@ -1,10 +1,9 @@
<!-- FIXME make a component shared with Home.vue ? --> <!-- FIXME make a component shared with Home.vue ? -->
<template> <template>
<div class="tools-menu">
<b-list-group class="menu-list"> <b-list-group class="menu-list">
<b-list-group-item <b-list-group-item
v-for="item in menu" v-for="item in menu"
:key="item.id" :key="item.routeName"
:to="{name: item.routeName}" :to="{name: item.routeName}"
> >
<icon :iname="item.icon" class="lg" /> <icon :iname="item.icon" class="lg" />
@ -12,27 +11,23 @@
<icon iname="chevron-right" class="lg fs-sm ml-auto" /> <icon iname="chevron-right" class="lg fs-sm ml-auto" />
</b-list-group-item> </b-list-group-item>
</b-list-group> </b-list-group>
</div>
</template> </template>
<script> <script>
export default { export default {
name: 'ToolList', name: 'ToolList',
data: () => { data () {
return { return {
menu: [ menu: [
{ id: 0, routeName: 'tool-logs', icon: 'book', translation: 'logs' }, { routeName: 'tool-logs', icon: 'book', translation: 'logs' },
{ id: 1, routeName: 'tool-migrations', icon: 'share', translation: 'migrations' }, { routeName: 'tool-migrations', icon: 'share', translation: 'migrations' },
{ id: 2, routeName: 'tool-firewall', icon: 'shield', translation: 'firewall' }, { routeName: 'tool-firewall', icon: 'shield', translation: 'firewall' },
{ id: 3, routeName: 'tool-adminpw', icon: 'key-modern', translation: 'tools_adminpw' }, { routeName: 'tool-adminpw', icon: 'key-modern', translation: 'tools_adminpw' },
{ id: 4, routeName: 'tool-webadmin', icon: 'cog', translation: 'tools_webadmin_settings' }, { routeName: 'tool-webadmin', icon: 'cog', translation: 'tools_webadmin_settings' },
{ id: 5, routeName: 'tool-power', icon: 'power-off', translation: 'tools_shutdown_reboot' } { routeName: 'tool-power', icon: 'power-off', translation: 'tools_shutdown_reboot' }
] ]
} }
} }
} }
</script> </script>
<style lang="scss" scoped>
</style>

View file

@ -1,20 +1,21 @@
<template> <template>
<div class="tool-log"> <view-base
:queries="queries" @queries-response="formatLogData"
ref="view" skeleton="card-info-skeleton"
>
<!-- INFO CARD --> <!-- INFO CARD -->
<b-card> <card :title="description" icon="info-circle">
<template v-slot:header>
<h2><icon iname="info-circle" /> {{ description }}</h2>
</template>
<b-row <b-row
v-for="(value, prop) in info" :key="prop" v-for="(value, prop) in info" :key="prop"
no-gutters class="row-line" no-gutters class="row-line"
> >
<b-col cols="auto" md="3"> <b-col md="3" xl="2">
<strong>{{ $t('logs_' + prop) }}</strong> <strong>{{ $t('logs_' + prop) }}</strong>
</b-col> </b-col>
<b-col> <b-col>
<span v-if="prop.endsWith('_at')">{{ value | readableDate }}</span> <span v-if="prop.endsWith('_at')">{{ value | readableDate }}</span>
<div v-else-if="prop === 'suboperations'"> <div v-else-if="prop === 'suboperations'">
<div v-for="operation in value" :key="operation.name"> <div v-for="operation in value" :key="operation.name">
<icon v-if="!operation.success" iname="times" class="text-danger" /> <icon v-if="!operation.success" iname="times" class="text-danger" />
@ -23,44 +24,39 @@
</b-link> </b-link>
</div> </div>
</div> </div>
<span v-else>{{ value }}</span> <span v-else>{{ value }}</span>
</b-col> </b-col>
</b-row> </b-row>
</b-card> </card>
<b-alert <div v-if="info.error" class="alert alert-danger my-5">
v-if="info.error" variant="danger" show <icon iname="exclamation-circle" /> {{ $t('operation_failed_explanation') }}
class="my-5" </div>
>
<icon iname="exclamation-circle" /> <span v-html="$t('operation_failed_explanation')" />
</b-alert>
<!-- LOGS CARD --> <!-- LOGS CARD -->
<b-card class="log"> <card :title="$t('logs')" icon="file-text" no-body>
<template v-slot:header> <template #header-buttons>
<div class="d-sm-flex justify-content-sm-between">
<h2><icon iname="file-text" /> {{ $t('logs') }}</h2>
<b-button @click="shareLogs" variant="success"> <b-button @click="shareLogs" variant="success">
<icon iname="cloud-upload" /> {{ $t('logs_share_with_yunopaste') }} <icon iname="cloud-upload" /> {{ $t('logs_share_with_yunopaste') }}
</b-button> </b-button>
</div>
</template> </template>
<b-button <b-button
v-if="moreLogsAvailable" v-if="moreLogsAvailable"
variant="white" class="w-100 rounded-0" variant="white" class="w-100 rounded-0"
@click="fetchData" @click="$ref.view.fetchQueries()"
> >
<icon iname="plus" /> {{ $t('logs_more') }} <icon iname="plus" /> {{ $t('logs_more') }}
</b-button> </b-button>
<pre><code v-html="logs" /></pre> <pre class="log"><code v-html="logs" /></pre>
<b-button @click="shareLogs" variant="success" class="w-100 rounded-0"> <b-button @click="shareLogs" variant="success" class="w-100 rounded-0">
<icon iname="cloud-upload" /> {{ $t('logs_share_with_yunopaste') }} <icon iname="cloud-upload" /> {{ $t('logs_share_with_yunopaste') }}
</b-button> </b-button>
</b-card> </card>
</div> </view-base>
</template> </template>
<script> <script>
@ -72,38 +68,35 @@ export default {
name: 'ToolLog', name: 'ToolLog',
props: { props: {
name: { name: { type: String, required: true }
type: String,
required: true
}
}, },
data () { data () {
return { return {
// Log data // Log data
description: '', description: undefined,
info: {}, info: {},
logs: '', logs: undefined,
// Logs line display // Logs line display
numberOfLines: 25, numberOfLines: 25,
moreLogsAvailable: false moreLogsAvailable: false
} }
}, },
filters: { computed: {
readableDate queries () {
},
methods: {
fetchData () {
const queryString = objectToParams({ const queryString = objectToParams({
path: this.name, path: this.name,
filter_irrelevant: '', filter_irrelevant: '',
with_suboperations: '', with_suboperations: '',
number: this.numberOfLines number: this.numberOfLines
}) })
return ['logs/display?' + queryString]
}
},
api.get('logs/display?' + queryString).then(log => { methods: {
formatLogData (log) {
if (log.logs.length === this.numberOfLines) { if (log.logs.length === this.numberOfLines) {
this.moreLogsAvailable = true this.moreLogsAvailable = true
this.numberOfLines *= 10 this.numberOfLines *= 10
@ -123,13 +116,12 @@ export default {
} }
return line return line
}).join('\n') }).join('\n')
// eslint-disable-next-line
const { started_at, ended_at, error, success, suboperations } = log.metadata const { started_at, ended_at, error, success, suboperations } = log.metadata
const info = { path: log.log_path, started_at, ended_at } const info = { path: log.log_path, started_at, ended_at }
if (!success) info.error = error if (!success) info.error = error
if (suboperations) info.suboperations = suboperations if (suboperations && suboperations.length) info.suboperations = suboperations
this.info = info this.info = info
})
}, },
shareLogs () { shareLogs () {
@ -139,8 +131,6 @@ export default {
} }
}, },
created () { filters: { readableDate }
this.fetchData()
}
} }
</script> </script>

View file

@ -1,15 +1,14 @@
<template> <template>
<search-view <view-search
id="tool-logs"
:search.sync="search" :search.sync="search"
:items="operations" :items="operations"
:filtered-items="filteredOperations" :filtered-items="filteredOperations"
items-name="logs" items-name="logs"
:queries="queries"
@queries-response="formatLogsData"
skeleton="card-list-skeleton"
> >
<b-card no-body> <card :title="$t('logs_operation')" icon="wrench" no-body>
<template v-slot:header>
<h2><icon iname="wrench" /> {{ $t('logs_operation') }}</h2>
</template>
<b-list-group flush> <b-list-group flush>
<b-list-group-item <b-list-group-item
v-for="log in filteredOperations" :key="log.name" v-for="log in filteredOperations" :key="log.name"
@ -21,20 +20,19 @@
{{ log.description }} {{ log.description }}
</b-list-group-item> </b-list-group-item>
</b-list-group> </b-list-group>
</b-card> </card>
</search-view> </view-search>
</template> </template>
<script> <script>
import api from '@/api'
import { distanceToNow, readableDate } from '@/helpers/filters/date' import { distanceToNow, readableDate } from '@/helpers/filters/date'
import SearchView from '@/components/SearchView'
export default { export default {
name: 'ServiceList', name: 'ToolLogs',
data () { data () {
return { return {
queries: [`logs?limit=${25}&with_details`],
search: '', search: '',
operations: undefined operations: undefined
} }
@ -47,18 +45,12 @@ export default {
const operations = this.operations.filter(({ description }) => { const operations = this.operations.filter(({ description }) => {
return description.toLowerCase().includes(search) return description.toLowerCase().includes(search)
}) })
return operations.length > 0 ? operations : null return operations.length ? operations : null
} }
}, },
filters: {
distanceToNow,
readableDate
},
methods: { methods: {
fetchData () { formatLogsData ({ operation }) {
api.get(`logs?limit=${25}&with_details`).then(({ operation }) => {
operation.forEach((log, index) => { operation.forEach((log, index) => {
if (log.success === '?') { if (log.success === '?') {
operation[index].icon = 'question' operation[index].icon = 'question'
@ -72,14 +64,12 @@ export default {
} }
}) })
this.operations = operation this.operations = operation
})
} }
}, },
created () { filters: {
this.fetchData() distanceToNow,
}, readableDate
}
components: { SearchView }
} }
</script> </script>

View file

@ -1,37 +1,28 @@
<template> <template>
<div class="tool-log"> <view-base :queries="queries" @queries-response="formatMigrationsData" ref="view">
<!-- PENDING MIGRATIONS --> <!-- PENDING MIGRATIONS -->
<b-card no-body> <card :title="$t('migrations_pending')" icon="cogs" no-body>
<b-card-header class="d-flex align-items-center"> <template #header-buttons v-if="pending">
<h2>
<icon iname="cogs" /> {{ $t('migrations_pending') }}
</h2>
<div class="ml-auto" v-if="pending && pending.length">
<b-button size="sm" variant="success" @click="runMigrations"> <b-button size="sm" variant="success" @click="runMigrations">
<icon iname="play" /> {{ $t('run') }} <icon iname="play" /> {{ $t('run') }}
</b-button> </b-button>
</div> </template>
</b-card-header>
<b-card-body v-if="pending && !pending.length"> <b-card-body v-if="pending === null">
<span class="text-success"> <span class="text-success">
<icon iname="check-circle" /> {{ $t('migrations_no_pending') }} <icon iname="check-circle" /> {{ $t('migrations_no_pending') }}
</span> </span>
</b-card-body> </b-card-body>
<b-list-group flush v-else-if="pending"> <b-list-group v-else-if="pending" flush>
<b-list-group-item <b-list-group-item
v-for="{ number, description, id, disclaimer } in pending" :key="number" v-for="{ number, description, id, disclaimer } in pending" :key="number"
> >
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
{{ number }}. {{ description }} {{ number }}. {{ description }}
<div class="ml-auto" v-if="pending && pending.length"> <div class="ml-auto">
<b-button <b-button @click="skipMigration(id)" size="sm" variant="warning">
@click="skipId = id" v-b-modal.skip-modal
size="sm" variant="warning"
>
<icon iname="close" /> {{ $t('skip') }} <icon iname="close" /> {{ $t('skip') }}
</b-button> </b-button>
</div> </div>
@ -58,75 +49,59 @@
</template> </template>
</b-list-group-item> </b-list-group-item>
</b-list-group> </b-list-group>
</b-card> </card>
<!-- DONE MIGRATIONS --> <!-- DONE MIGRATIONS -->
<b-card no-body> <card
<b-card-header class="d-flex align-items-center"> :title="$t('migrations_done')" icon="cogs"
<h2><icon iname="cogs" /> {{ $t('migrations_done') }}</h2> collapsable collapsed no-body
>
<div class="ml-auto"> <b-card-body v-if="done === null">
<b-button v-b-toggle.collapse-done size="sm" variant="outline-secondary">
<icon iname="chevron-right" /><span class="sr-only">{{ $t('words.collapse') }}</span>
</b-button>
</div>
</b-card-header>
<b-collapse id="collapse-done">
<b-card-body v-if="done && !done.length">
<span class="text-success"> <span class="text-success">
<icon iname="check-circle" /> {{ $t('migrations_no_done') }} <icon iname="check-circle" /> {{ $t('migrations_no_done') }}
</span> </span>
</b-card-body> </b-card-body>
<b-list-group flush v-else-if="done"> <b-list-group flush v-else-if="done">
<b-list-group-item <b-list-group-item v-for="{ number, description } in done" :key="number">
v-for="{ number, description } in done" :key="number"
>
{{ number }}. {{ description }} {{ number }}. {{ description }}
</b-list-group-item> </b-list-group-item>
</b-list-group> </b-list-group>
</b-collapse> </card>
</b-card>
<!-- SKIP MIGRATION CONFIRMATION MODAL --> <template #skeleton>
<b-modal <card-list-skeleton :item-count="3" />
id="skip-modal" centered <b-card no-body>
body-bg-variant="warning" <template #header>
@ok="skipMigration" hide-header <b-skeleton width="30%" height="36px" class="m-0" />
> </template>
{{ $t('confirm_migrations_skip') }} </b-card>
</b-modal> </template>
</div> </view-base>
</template> </template>
<script> <script>
import api from '@/api' import api from '@/api'
// FIXME not tested with pending migrations (disclaimer and stuff) // FIXME not tested with pending migrations (disclaimer and stuff)
export default { export default {
name: 'ToolMigrations', name: 'ToolMigrations',
props: {
},
data () { data () {
return { return {
queries: [
'migrations?pending',
'migrations?done'
],
pending: undefined, pending: undefined,
done: undefined, done: undefined,
skipId: undefined,
checked: {} checked: {}
} }
}, },
methods: { methods: {
fetchData () { formatMigrationsData ({ migrations: pending }, { migrations: done }) {
api.getAll([ this.done = done.length ? done.reverse() : null
'migrations?pending',
'migrations?done'
]).then(([{ migrations: pending }, { migrations: done }]) => {
this.done = done.reverse()
pending.forEach(migration => { pending.forEach(migration => {
if (migration.disclaimer) { if (migration.disclaimer) {
migration.disclaimer = migration.disclaimer.replace('\n', '<br>') migration.disclaimer = migration.disclaimer.replace('\n', '<br>')
@ -134,8 +109,7 @@ export default {
} }
}) })
// FIXME change to pending // FIXME change to pending
this.pending = pending.reverse() this.pending = pending.length ? pending.reverse() : null
})
}, },
runMigrations () { runMigrations () {
@ -147,17 +121,20 @@ export default {
} }
// Check that every migration's disclaimer has been checked. // Check that every migration's disclaimer has been checked.
if (Object.values(this.checked).every(value => value === true)) { if (Object.values(this.checked).every(value => value === true)) {
api.post('migrations/migrate', { accept_disclaimer: true }).then(this.fetchData) api.post('migrations/migrate', { accept_disclaimer: true }).then(() => {
this.$refs.view.fetchQueries()
})
} }
}, },
skipMigration () { async skipMigration (id) {
api.post('/migrations/migrate', { skip: true, targets: this.skipId }).then(this.fetchData) const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_migrations_skip'))
} if (!confirmed) return
},
created () { api.post('/migrations/migrate', { skip: true, targets: id }).then(() => {
this.fetchData() this.$refs.view.fetchQueries()
})
}
} }
} }
</script> </script>

View file

@ -1,31 +1,25 @@
<template> <template>
<div class="tool-power"> <div>
<div v-if="inProcess"> <div v-if="inProcess">
<b-alert variant="info" show v-t="'tools_' + action + '_done'" /> <b-alert variant="info" v-t="'tools_' + action + '_done'" />
<b-alert variant="warning" show> <b-alert variant="warning">
<icon :iname="action === 'reboot' ? 'refresh' : 'power-off'" /> {{ $t(action === 'reboot' ? 'tools_rebooting' : 'tools_shuttingdown') }} <icon :iname="action === 'reboot' ? 'refresh' : 'power-off'" />
{{ $t(action === 'reboot' ? 'tools_rebooting' : 'tools_shuttingdown') }}
</b-alert> </b-alert>
<template v-if="canReconnect"> <template v-if="canReconnect">
<b-alert variant="success" show v-t="'tools_power_up'" /> <b-alert variant="success" v-t="'tools_power_up'" />
<login-view /> <login-view />
</template> </template>
</div> </div>
<b-card v-else> <card v-else :title="$t('operations')" icon="wrench">
<template v-slot:header>
<h2><icon iname="wrench" /> {{ $t('operations') }}</h2>
</template>
<!-- REBOOT --> <!-- REBOOT -->
<b-form-group <b-form-group
label-cols="5" label-cols-md="4" label-cols-lg="3" label-cols="5" label-cols-md="4" label-cols-lg="3"
:label="$t('tools_reboot')" label-for="reboot" :label="$t('tools_reboot')" label-for="reboot"
> >
<b-button <b-button @click="triggerAction('reboot')" variant="danger" id="reboot">
variant="danger" id="reboot" v-b-modal.confirm-action
@click="action = 'reboot'"
>
<icon iname="refresh" /> {{ $t('tools_reboot_btn') }} <icon iname="refresh" /> {{ $t('tools_reboot_btn') }}
</b-button> </b-button>
</b-form-group> </b-form-group>
@ -36,23 +30,11 @@
label-cols="5" label-cols-md="4" label-cols-lg="3" label-cols="5" label-cols-md="4" label-cols-lg="3"
:label="$t('tools_shutdown')" label-for="shutdown" :label="$t('tools_shutdown')" label-for="shutdown"
> >
<b-button <b-button @click="triggerAction('shutdown')" variant="danger" id="shutdown">
variant="danger" id="shutdown" v-b-modal.confirm-action
@click="action = 'shutdown'"
>
<icon iname="power-off" /> {{ $t('tools_shutdown_btn') }} <icon iname="power-off" /> {{ $t('tools_shutdown_btn') }}
</b-button> </b-button>
</b-form-group> </b-form-group>
</card>
<!-- REBOOT/SHUTDOWN CONFIRM MODAL -->
<b-modal
centered hide-header
id="confirm-action" body-bg-variant="danger" body-text-variant="light"
@ok="triggerAction(action)"
>
{{ $t('confirm_reboot_action_' + action) }}
</b-modal>
</b-card>
</div> </div>
</template> </template>
@ -72,7 +54,13 @@ export default {
}, },
methods: { methods: {
triggerAction (action) { async triggerAction (action) {
const confirmed = await this.$askConfirmation(
this.$i18n.t('confirm_reboot_action_' + action)
)
if (!confirmed) return
this.action = action
api.put(action + '?force').then(() => { api.put(action + '?force').then(() => {
// Use 'RESET_CONNECTED' and not 'DISCONNECT' else user will be redirect to login // Use 'RESET_CONNECTED' and not 'DISCONNECT' else user will be redirect to login
this.$store.dispatch('RESET_CONNECTED') this.$store.dispatch('RESET_CONNECTED')
@ -100,8 +88,6 @@ export default {
} }
}, },
components: { components: { LoginView }
LoginView
}
} }
</script> </script>

View file

@ -1,12 +1,7 @@
<template> <template>
<card-form <card-form :title="$t('tools_webadmin_settings')" icon="cog" no-footer>
:title="$t('tools_webadmin_settings')" icon="cog"
no-footer
>
<template v-for="(field, fname) in fields"> <template v-for="(field, fname) in fields">
<form-field <form-field v-bind="field" v-model="self[fname]" :key="fname" />
v-bind="field" v-model="self[fname]" :key="fname"
/>
<hr :key="fname + 'hr'"> <hr :key="fname + 'hr'">
</template> </template>
</card-form> </card-form>

View file

@ -1,30 +1,18 @@
<template> <template>
<div class="system-update"> <view-base :loading="loading" skeleton="card-list-skeleton">
<!-- FIXME add perform update button ? -->
<!-- <div class="actions">
<div class="buttons ml-auto">
<b-button variant="success" @click="performUpdate">
<icon iname="refresh" /> {{ $t('system_update') }}
</b-button>
</div>
</div> -->
<!-- MIGRATIONS WARN --> <!-- MIGRATIONS WARN -->
<b-alert variant="warning" :show="migrationsNotDone"> <b-alert variant="warning" :show="migrationsNotDone">
<icon iname="exclamation-triangle" /> <span v-html="$t('pending_migrations')" /> <icon iname="exclamation-triangle" /> <span v-html="$t('pending_migrations')" />
</b-alert> </b-alert>
<!-- SYSTEM UPGRADE --> <!-- SYSTEM UPGRADE -->
<b-card no-body> <card :title="$t('system')" icon="server" no-body>
<template v-slot:header>
<h2><icon iname="server" /> {{ $t('system') }}</h2>
</template>
<b-list-group v-if="system" flush> <b-list-group v-if="system" flush>
<b-list-group-item <b-list-group-item v-for="{ name, current_version, new_version } in system" :key="name">
v-for="{ name, current_version, new_version } in system" :key="name" <h5 class="m-0">
> {{ name }}
<h5 class="m-0">{{ name }} <small>({{ $t('from_to', [current_version, new_version]) }})</small></h5> <small>({{ $t('from_to', [current_version, new_version]) }})</small>
</h5>
</b-list-group-item> </b-list-group-item>
</b-list-group> </b-list-group>
@ -32,34 +20,29 @@
<span class="text-success"><icon iname="check-circle" /> {{ $t('system_packages_nothing') }}</span> <span class="text-success"><icon iname="check-circle" /> {{ $t('system_packages_nothing') }}</span>
</b-card-body> </b-card-body>
<template v-if="system" v-slot:footer> <template #buttons v-if="system">
<div class="d-flex justify-content-end">
<b-button <b-button
v-b-modal.confirm-upgrade variant="success" variant="success" v-t="'system_upgrade_all_packages_btn'"
v-t="'system_upgrade_all_packages_btn'" @click="performUpgrade({ type: 'system' })"
@click="action = ['system']"
/> />
</div>
</template> </template>
</b-card> </card>
<!-- APPS UPGRADE --> <!-- APPS UPGRADE -->
<b-card no-body> <card :title="$t('applications')" icon="cubes" no-body>
<template v-slot:header>
<h2><icon iname="cubes" /> {{ $t('applications') }}</h2>
</template>
<b-list-group v-if="apps" flush> <b-list-group v-if="apps" flush>
<b-list-group-item <b-list-group-item
v-for="{ label, id, current_version, new_version } in apps" :key="id" v-for="{ label, id, current_version, new_version } in apps" :key="id"
class="d-flex justify-content-between align-items-center" class="d-flex justify-content-between align-items-center"
> >
<h5 class="m-0">{{ label }} <small>({{ id }}) {{ $t('from_to', [current_version, new_version]) }}</small></h5> <h5 class="m-0">
{{ label }}
<small>({{ id }}) {{ $t('from_to', [current_version, new_version]) }}</small>
</h5>
<b-button <b-button
v-b-modal.confirm-upgrade variant="success" size="sm" variant="success" size="sm" v-t="'system_upgrade_btn'"
v-t="'system_upgrade_btn'" @click="performUpgrade({ type: 'specific_app', id })"
@click="action = ['specific_app', id]"
/> />
</b-list-group-item> </b-list-group-item>
</b-list-group> </b-list-group>
@ -68,27 +51,14 @@
<span class="text-success"><icon iname="check-circle" /> {{ $t('system_apps_nothing') }}</span> <span class="text-success"><icon iname="check-circle" /> {{ $t('system_apps_nothing') }}</span>
</b-card-body> </b-card-body>
<template v-if="apps" v-slot:footer> <template #buttons v-if="apps">
<div class="d-flex justify-content-end">
<b-button <b-button
v-b-modal.confirm-upgrade variant="success" variant="success" v-t="'system_upgrade_all_applications_btn'"
v-t="'system_upgrade_all_applications_btn'" @click="performUpgrade({ type: 'apps' })"
@click="action = ['apps']"
/> />
</div>
</template> </template>
</b-card> </card>
</view-base>
<!-- UPGRADE CONFIRM MODAL -->
<b-modal
v-if="action"
id="confirm-upgrade" centered
body-bg-variant="danger" body-text-variant="light"
@ok="performUpgrade" hide-header
>
{{ $t('confirm_update_' + action[0], action[1] ? { app: action[1] } : {}) }}
</b-modal>
</div>
</template> </template>
<script> <script>
@ -99,9 +69,8 @@ export default {
data () { data () {
return { return {
action: undefined, loading: true,
app: undefined, // API data
// api data
migrationsNotDone: undefined, migrationsNotDone: undefined,
system: undefined, system: undefined,
apps: undefined apps: undefined
@ -109,21 +78,11 @@ export default {
}, },
methods: { methods: {
async fetchData () { async performUpgrade ({ type, id = null }) {
api.get('migrations?pending').then(({ migrations }) => { const confirmMsg = this.$i18n.t('confirm_update_' + type, id ? { app: id } : {})
this.migrationsNotDone = migrations.length !== 0 const confirmed = await this.$askConfirmation(confirmMsg)
}) if (!confirmed) return
},
performUpdate () {
api.put('update').then(({ apps, system }) => {
this.apps = apps.length ? apps : null
this.system = system.length ? system : null
})
},
performUpgrade () {
const [type, id] = this.action
const uri = type === 'specific_app' const uri = type === 'specific_app'
? 'upgrade/apps?app=' + id ? 'upgrade/apps?app=' + id
: 'upgrade?' + type : 'upgrade?' + type
@ -135,9 +94,17 @@ export default {
}, },
created () { created () {
// FIXME Do not perform directly the update ? // Since we need to query a `PUT` method, we won't use ViewBase's `queries` prop and
this.performUpdate() // its automatic loading handling.
this.fetchData() Promise.all([
api.get('migrations?pending'),
api.put('update')
]).then(([{ migrations }, { apps, system }]) => {
this.migrationsNotDone = migrations.length !== 0
this.apps = apps.length ? apps : null
this.system = system.length ? system : null
this.loading = false
})
} }
} }
</script> </script>

View file

@ -1,4 +1,5 @@
<template> <template>
<view-base :queries="queries" @queries-response="onQueriesResponse" skeleton="card-form-skeleton">
<card-form <card-form
:title="$t('users_new')" icon="user-plus" :title="$t('users_new')" icon="user-plus"
:validation="$v" :server-error="serverError" :validation="$v" :server-error="serverError"
@ -55,6 +56,7 @@
<!-- USER PASSWORD CONFIRMATION --> <!-- USER PASSWORD CONFIRMATION -->
<form-field v-bind="fields.confirmation" v-model="form.confirmation" :validation="$v.form.confirmation" /> <form-field v-bind="fields.confirmation" v-model="form.confirmation" :validation="$v.form.confirmation" />
</card-form> </card-form>
</view-base>
</template> </template>
<script> <script>
@ -66,14 +68,17 @@ import {
alphalownum_, unique, required, minLength, name, sameAs alphalownum_, unique, required, minLength, name, sameAs
} from '@/helpers/validators' } from '@/helpers/validators'
export default { export default {
name: 'UserCreate', name: 'UserCreate',
mixins: [validationMixin],
data () { data () {
return { return {
queries: [
{ uri: 'users' },
{ uri: 'domains' },
{ uri: 'domains/main', storeKey: 'main_domain' }
],
form: { form: {
username: '', username: '',
fullname: { fullname: {
@ -162,6 +167,11 @@ export default {
}, },
methods: { methods: {
onQueriesResponse () {
this.fields.domain.props.choices = this.domainsAsChoices
this.form.domain = this.mainDomain
},
onSubmit () { onSubmit () {
const data = formatFormData(this.form, { flatten: true }) const data = formatFormData(this.form, { flatten: true })
this.$store.dispatch( this.$store.dispatch(
@ -174,14 +184,7 @@ export default {
} }
}, },
created () { mixins: [validationMixin]
this.$store.dispatch('FETCH_ALL',
[{ uri: 'domains' }, { uri: 'users' }, { uri: 'domains/main', storeKey: 'main_domain' }]
).then(([domains]) => {
this.fields.domain.props.choices = this.domainsAsChoices
this.form.domain = this.mainDomain
})
}
} }
</script> </script>

View file

@ -1,4 +1,5 @@
<template lang="html"> <template>
<view-base :queries="queries" @queries-response="onQueriesResponse" skeleton="card-form-skeleton">
<card-form <card-form
:title="$t('user_username_edit', { name })" icon="user" :title="$t('user_username_edit', { name })" icon="user"
:validation="$v" :server-error="serverError" :validation="$v" :server-error="serverError"
@ -103,6 +104,7 @@
<!-- USER PASSWORD CONFIRMATION --> <!-- USER PASSWORD CONFIRMATION -->
<form-field v-bind="fields.confirmation" v-model="form.confirmation" :validation="$v.form.confirmation" /> <form-field v-bind="fields.confirmation" v-model="form.confirmation" :validation="$v.form.confirmation" />
</card-form> </card-form>
</view-base>
</template> </template>
<script> <script>
@ -127,7 +129,11 @@ export default {
data () { data () {
return { return {
ready: false, queries: [
{ uri: 'users', param: this.name, storeKey: 'users_details' },
{ uri: 'domains/main', storeKey: 'main_domain' },
{ uri: 'domains' }
],
form: { form: {
fullname: { firstname: '', lastname: '' }, fullname: { firstname: '', lastname: '' },
@ -236,6 +242,27 @@ export default {
}, },
methods: { methods: {
onQueriesResponse (user) {
this.fields.mail.props.choices = this.domainsAsChoices
this.fields.mail_aliases.props.choices = this.domainsAsChoices
this.form.fullname = {
// Copy value to avoid refering to the stored user data
firstname: user.firstname.valueOf(),
lastname: user.lastname.valueOf()
}
this.form.mail = adressToFormValue(user.mail)
if (user['mail-aliases']) {
this.form.mail_aliases = user['mail-aliases'].map(mail => adressToFormValue(mail))
}
if (user['mail-forward']) {
this.form.mail_forward = user['mail-forward'].slice() // Copy value
}
if (user['mailbox-quota'].limit !== 'No quota') {
this.form.mailbox_quota = sizeToM(user['mailbox-quota'].limit)
}
},
onSubmit () { onSubmit () {
const formData = formatFormData(this.form, { flatten: true }) const formData = formatFormData(this.form, { flatten: true })
const user = this.user(this.name) const user = this.user(this.name)
@ -280,8 +307,9 @@ export default {
? { localPart: '', separator: '@', domain: this.mainDomain } ? { localPart: '', separator: '@', domain: this.mainDomain }
: '' : ''
) )
// Focus last input after rendering update
this.$nextTick(() => { this.$nextTick(() => {
const inputs = document.querySelectorAll(`#mail-${type} input`) const inputs = this.$el.querySelectorAll(`#mail-${type} input`)
inputs[inputs.length - 1].focus() inputs[inputs.length - 1].focus()
}) })
}, },
@ -291,39 +319,8 @@ export default {
} }
}, },
created () {
this.$store.dispatch('FETCH_ALL', [
{ uri: 'users', param: this.name, storeKey: 'users_details' },
{ uri: 'domains/main', storeKey: 'main_domain' },
{ uri: 'domains' }
]).then(([user, mainDomain, domains]) => {
this.fields.mail.props.choices = this.domainsAsChoices
this.fields.mail_aliases.props.choices = this.domainsAsChoices
this.form.fullname = {
// Copy value to avoid refering to the stored user data
firstname: user.firstname.valueOf(),
lastname: user.lastname.valueOf()
}
this.form.mail = adressToFormValue(user.mail)
if (user['mail-aliases']) {
this.form.mail_aliases = user['mail-aliases'].map(mail => adressToFormValue(mail))
}
if (user['mail-forward']) {
this.form.mail_forward = user['mail-forward'].slice() // Copy value
}
if (user['mailbox-quota'].limit !== 'No quota') {
this.form.mailbox_quota = sizeToM(user['mailbox-quota'].limit)
}
this.ready = true
})
},
mixins: [validationMixin], mixins: [validationMixin],
components: { AdressInputSelect }
components: {
AdressInputSelect
}
} }
</script> </script>

View file

@ -1,15 +1,10 @@
<template> <template>
<div class="user"> <view-base :queries="queries" skeleton="card-info-skeleton">
<b-card :class="{skeleton: !user}"> <card v-if="user" :title="user.fullname" icon="user">
<template v-slot:header> <div class="d-flex align-items-center flex-column flex-md-row">
<h2>{{ user ? user.fullname : '' }}</h2>
</template>
<div class="d-flex align-items-center">
<icon iname="user" class="fa-fw" /> <icon iname="user" class="fa-fw" />
<div class="w-100"> <div class="w-100">
<template v-if="user">
<b-row> <b-row>
<b-col><strong>{{ $t('user_username') }}</strong></b-col> <b-col><strong>{{ $t('user_username') }}</strong></b-col>
<b-col>{{ user.username }}</b-col> <b-col>{{ user.username }}</b-col>
@ -47,77 +42,64 @@
</template> </template>
</b-col> </b-col>
</b-row> </b-row>
</template>
<!-- skeleton -->
<template v-else>
<b-row v-for="(n, index) in 6" :key="index">
<b-col>
<strong class="rounded" />
</b-col>
<b-col>
<span v-if="n <= 4" class="rounded" />
</b-col>
</b-row>
</template>
</div> </div>
</div> </div>
<template v-slot:footer>
<div class="d-flex d-flex justify-content-end"> <template #buttons>
<b-button :to="user ? {name: 'user-edit', params: {user: user}} : null" <b-button :to="{ name: 'user-edit', params: { user } }" :variant="user ? 'info' : 'dark'">
:variant="user ? 'info' : 'dark'" <icon iname="edit" />
>
{{ user ? $t('user_username_edit', {name: user.username}) : '' }} {{ user ? $t('user_username_edit', {name: user.username}) : '' }}
</b-button> </b-button>
<b-button :variant="user ? 'danger' : 'dark'" class="ml-2" v-b-modal.delete-modal> <b-button v-b-modal.delete-modal :variant="user ? 'danger' : 'dark'">
<icon iname="trash-o" />
{{ user ? $t('delete') : '' }} {{ user ? $t('delete') : '' }}
</b-button> </b-button>
</div>
</template> </template>
</b-card> </card>
<b-modal <b-modal
v-if="user" id="delete-modal" centered v-if="user"
header-bg-variant="danger" header-text-variant="light" id="delete-modal" :title="$t('confirm_delete', { name: user.username })" @ok="deleteUser"
:title="$t('confirm_delete', {name: user.username })" header-bg-variant="warning" body-class="" body-bg-variant=""
@ok="deleteUser"
> >
<b-form-group> <b-form-group>
<template v-slot:description> <b-form-checkbox v-model="purge">
<b-alert variant="warning" show> {{ $t('purge_user_data_checkbox', { name: user.username }) }}
<icon iname="exclamation-triangle" /> {{ $t('purge_user_data_warning') }}
</b-alert>
</template>
<b-form-checkbox v-model="purge" class="mb-3">
{{ $t('purge_user_data_checkbox', {name: user.username}) }}
</b-form-checkbox> </b-form-checkbox>
<template #description>
<div class="alert alert-warning">
<icon iname="exclamation-triangle" /> {{ $t('purge_user_data_warning') }}
</div>
</template>
</b-form-group> </b-form-group>
</b-modal> </b-modal>
</div> </view-base>
</template> </template>
<script> <script>
export default { export default {
name: 'UserInfo', name: 'UserInfo',
props: { props: {
name: { name: { type: String, required: true }
type: String,
required: true
}
}, },
data () { data () {
return { return {
queries: [{ uri: 'users', param: this.name, storeKey: 'users_details' }],
purge: false purge: false
} }
}, },
computed: { computed: {
user () { user () {
return this.$store.state.data.users_details[this.name] return this.$store.getters.user(this.name)
} }
}, },
methods: { methods: {
deleteUser () { deleteUser () {
const data = this.purge ? { purge: '' } : {} const data = this.purge ? { purge: '' } : {}
@ -127,23 +109,11 @@ export default {
this.$router.push({ name: 'user-list' }) this.$router.push({ name: 'user-list' })
}) })
} }
},
created () {
this.$store.dispatch('FETCH',
{ uri: 'users', param: this.name, storeKey: 'users_details' }
)
} }
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.card-body > div {
flex-direction: column;
@include media-breakpoint-up(md) {
flex-direction: row;
}
}
.icon.fa-user { .icon.fa-user {
font-size: 10rem; font-size: 10rem;
padding-right: 3rem; padding-right: 3rem;
@ -171,30 +141,4 @@ ul {
list-style: none; list-style: none;
} }
} }
.skeleton {
opacity: 0.5;
h2 {
height: #{2 * 1.2}rem;
}
.col {
& > * {
display: block;
background-color: $skeleton-color;
height: 1.5rem;
max-width: 8rem;
}
strong {
max-width: 12rem;
}
}
button {
height: calc(2.25rem + 2px);
width: 7rem;
}
}
</style> </style>

View file

@ -1,10 +1,10 @@
<template> <template>
<search-view <view-search
id="user-list"
:search.sync="search" :search.sync="search"
:items="users" :items="users"
:filtered-items="filteredUsers" :filtered-items="filteredUsers"
items-name="users" items-name="users"
:queries="queries"
> >
<template #top-bar-buttons> <template #top-bar-buttons>
<b-button variant="info" :to="{ name: 'group-list' }"> <b-button variant="info" :to="{ name: 'group-list' }">
@ -36,19 +36,18 @@
<icon iname="chevron-right" class="lg fs-sm ml-auto" /> <icon iname="chevron-right" class="lg fs-sm ml-auto" />
</b-list-group-item> </b-list-group-item>
</b-list-group> </b-list-group>
</search-view> </view-search>
</template> </template>
<script> <script>
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
import SearchView from '@/components/SearchView'
export default { export default {
name: 'UserList', name: 'UserList',
data () { data () {
return { return {
queries: [{ uri: 'users' }],
search: '' search: ''
} }
}, },
@ -64,12 +63,6 @@ export default {
}) })
return filtered.length === 0 ? null : filtered return filtered.length === 0 ? null : filtered
} }
}, }
created () {
this.$store.dispatch('FETCH', { uri: 'users' })
},
components: { SearchView }
} }
</script> </script>