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

View file

@ -5,14 +5,19 @@
@submit.prevent="onSubmit"
>
<template #disclaimer>
<b-alert variant="warning" show>
<p class="alert alert-warning">
{{ $t('good_practices_about_admin_password') }}
</b-alert>
</p>
<slot name="disclaimer" />
<hr>
</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 -->
<form-field v-bind="fields.password" v-model="form.password" :validation="$v.form.password" />

View file

@ -65,6 +65,7 @@
"backups_no": "No backup",
"begin": "Begin",
"both": "Both",
"cancel": "Cancel",
"catalog": "Catalog",
"check": "Check",
"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.",
"run_first_diagnosis": "Run initial diagnosis",
"disable": "Disable",
"disabled": "Disabled",
"dns": "DNS",
"domain_add": "Add domain",
"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_longdesc": "This is your default 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_longdesc": "View DNS configuration",
"domain_name": "Domain name",
@ -132,6 +135,7 @@
"domains": "Domains",
"download": "Download",
"enable": "Enable",
"enabled": "Enabled",
"error": "Error",
"error_modify_something": "You should modify something",
"error_server_unexpected": "Unexpected server error",
@ -149,6 +153,7 @@
"form_errors": {
"alpha": "Value must be alphabetical 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",
"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)",
@ -370,6 +375,7 @@
"unauthorized": "Unauthorized",
"unignore": "Unignore",
"uninstall": "Uninstall",
"unknown": "Unknown",
"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",
"upnp": "UPnP",

View file

@ -4,10 +4,10 @@
<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')" />
<br>{{ $t('api_error.info') }}
</b-alert>
</div>
<h5 v-t="'error'" />
<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)
}
</script>
<style lang="scss" scoped>
</style>

View file

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

View file

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

View file

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

View file

@ -1,7 +1,10 @@
<template>
<div class="app-actions">
<div v-if="actions">
<b-alert variant="warning" show class="mb-4">
<view-base
:queries="queries" @queries-response="formatAppActions"
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') }}
</b-alert>
@ -13,9 +16,9 @@
@submit.prevent="performAction(action)" :submit-text="$t('perform')"
>
<template #disclaimer>
<b-alert
<div
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" />
</template>
@ -25,13 +28,13 @@
v-bind="field" v-model="action.form[fname]" :validation="$v.actions[i][fname]"
/>
</card-form>
</div>
</template>
<!-- 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') }}
</b-alert>
</div>
</view-base>
</template>
<script>
@ -41,7 +44,6 @@ import { validationMixin } from 'vuelidate'
import { formatI18nField, formatYunoHostArguments, formatFormData } from '@/helpers/yunohostArguments'
import { objectToParams } from '@/helpers/commons'
export default {
name: 'AppActions',
@ -51,6 +53,12 @@ export default {
data () {
return {
queries: [
`apps/${this.id}/actions`,
{ uri: 'domains' },
{ uri: 'domains/main', storeKey: 'main_domain' },
{ uri: 'users' }
],
actions: undefined
}
},
@ -66,18 +74,7 @@ export default {
},
methods: {
fetchData () {
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) {
formatAppActions (data) {
if (!data.actions) {
this.actions = null
return
@ -102,17 +99,13 @@ export default {
const args = objectToParams(action.form ? formatFormData(action.form) : { wut: undefined })
api.put(`apps/${this.id}/actions/${action.id}`, { args }).then(response => {
this.fetchData()
this.$refs.view.fetchQueries()
}).catch(error => {
action.serverError = error.message
})
}
},
created () {
this.fetchData()
},
mixins: [validationMixin]
}
</script>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,109 +1,107 @@
<template>
<div class="diagnosis">
<view-top-bar>
<template #group-right>
<view-base
:loading="loading" ref="view"
:queries="queries" @queries-response="formatData"
>
<template #top-bar-group-right>
<b-button @click="shareLogs" variant="success">
<icon iname="cloud-upload" /> {{ $t('logs_share_with_yunopaste') }}
</b-button>
</template>
</view-top-bar>
<b-alert variant="info" show>
{{ $t(reports ? 'diagnosis_explanation' : 'diagnosis_first_run') }}
<template #top>
<div class="alert alert-info">
{{ $t(reports || loading ? 'diagnosis_explanation' : 'diagnosis_first_run') }}
<b-button
v-if="reports === null" @click="runFullDiagnosis"
class="d-block mt-2" variant="info"
v-if="reports === null" class="d-block mt-2" variant="info"
@click="runDiagnosis"
>
<icon iname="stethoscope" /> {{ $t('run_first_diagnosis') }}
</b-button>
</b-alert>
</div>
<b-alert
class="mb-5" variant="warning" show
v-t="'diagnosis_experimental_disclaimer'"
/>
<div v-t="'diagnosis_experimental_disclaimer'" class="alert alert-warning mb-5" />
</template>
<!-- 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 -->
<b-card-header class="d-flex align-items-md-center flex-column flex-md-row">
<div class="d-flex align-items-center">
<h2>{{ description }}</h2>
<template #header>
<h2>{{ report.description }}</h2>
<b-badge
v-if="noIssues" pill variant="success"
v-t="'everything_good'"
/>
<b-badge
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 class="">
<b-badge v-if="report.noIssues" variant="success" 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 v-if="report.ignoreds" v-t="{ path: 'ignored', args: { count: report.ignoreds } }" />
</div>
</template>
<div class="d-flex ml-md-auto mt-2 mt-md-0">
<b-button size="sm" :variant="items ? 'info' : 'success'" @click="reRunDiagnosis(id)">
<template #header-buttons>
<b-button size="sm" :variant="report.items ? 'info' : 'success'" @click="runDiagnosis(report.id)">
<icon iname="refresh" /> {{ $t('rerun_diagnosis') }}
</b-button>
<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>
</template>
<!-- REPORT BODY -->
<b-collapse :id="'collapse-' + id" :visible="!noIssues">
<p class="last-time-run">
{{ $t('last_ran') }} {{ timestamp | distanceToNow(true, true) }}
{{ $t('last_ran') }} {{ report.timestamp | distanceToNow(true, true) }}
</p>
<b-list-group flush>
<!-- REPORT ITEM -->
<b-list-group-item
v-for="({ status, icon, summary, ignored, issue, details, filterArgs, meta }, i) in items"
:key="i" :variant="status"
v-for="(item, i) in report.items" :key="i"
:variant="item.variant"
>
<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">
<b-button
v-if="ignored" size="sm"
@click="toggleIgnoreIssue(false, filterArgs, r, i)"
v-if="item.ignored" size="sm"
@click="toggleIgnoreIssue(false, report, item)"
>
<icon iname="bell" /> <span v-t="'unignore'" />
<icon iname="bell" /> {{ $t('unignore') }}
</b-button>
<b-button
v-else-if="issue"
variant="warning" size="sm" @click="toggleIgnoreIssue(true, filterArgs, r, i)"
v-else-if="item.issue" variant="warning" size="sm"
@click="toggleIgnoreIssue(true, report, item)"
>
<icon iname="bell-slash" /> <span v-t="'ignore'" />
<icon iname="bell-slash" /> {{ $t('ignore') }}
</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"
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>
</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">
<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>
</b-collapse>
</b-list-group-item>
</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>
</div>
<card-list-skeleton />
</template>
</view-base>
</template>
<script>
@ -115,15 +113,45 @@ export default {
data () {
return {
queries: ['diagnosis/show?full'],
loading: true,
reports: undefined
}
},
methods: {
fetchData () {
api.get('diagnosis/show?full').then((data) => {
formatReportItem (report, item) {
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) {
this.reports = null
this.loading = false
return
}
@ -134,53 +162,36 @@ export default {
report.ignoreds = 0
for (var item of report.items) {
let issue = false
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])
this.formatReportItem(report, item)
}
report.noIssues = report.warnings + report.errors === 0
}
this.reports = reports
})
this.loading = false
},
runFullDiagnosis () {
api.post('diagnosis/run').then(this.fetchData)
runDiagnosis (id = null) {
const param = id !== null ? '?force' : ''
const data = id !== null ? { categories: [id] } : {}
api.post('diagnosis/run' + param, data).then(this.$refs.view.fetchQueries)
},
reRunDiagnosis (id) {
api.post('diagnosis/run?force', { categories: [id] }).then(this.fetchData)
},
toggleIgnoreIssue (ignore, filterArgs, reportIndex, itemIndex) {
toggleIgnoreIssue (ignore, report, item) {
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 () {
@ -191,17 +202,15 @@ export default {
},
created () {
api.post('diagnosis/run?except_if_never_ran_yet').then(this.fetchData)
api.post('diagnosis/run?except_if_never_ran_yet')
},
filters: {
distanceToNow
}
filters: { distanceToNow }
}
</script>
<style lang="scss" scoped>
.badge {
.badge + .badge {
margin-left: .5rem
}

View file

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

View file

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

View file

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

View file

@ -1,9 +1,6 @@
<template>
<div class="domain-info">
<b-card>
<template v-slot:header>
<h2><icon iname="globe" /> {{ name }}</h2>
</template>
<view-base :queries="queries" skeleton="card-list-skeleton">
<card :title="name" icon="globe">
<!-- VISIT -->
<p>{{ $t('domain_visit_url', { url: 'https://' + name }) }}</p>
<b-button variant="success" :href="'https://' + name" target="_blank">
@ -13,16 +10,12 @@
<!-- DEFAULT DOMAIN -->
<p>{{ $t('domain_default_desc') }}</p>
<template v-if="isMainDomain">
<p class="alert alert-info">
<p v-if="isMainDomain" class="alert alert-info">
<icon iname="star" /> {{ $t('domain_default_longdesc') }}
</p>
</template>
<template v-else>
<b-button variant="info" v-b-modal.default-domain-modal>
<b-button v-else variant="info" @click="setAsDefaultDomain">
<icon iname="star" /> {{ $t('set_default') }}
</b-button>
</template>
<hr>
<!-- DNS CONFIG -->
@ -41,53 +34,50 @@
<!-- DELETE -->
<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') }}
</b-button>
</b-card>
<!-- 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>
</card>
</view-base>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
name: 'DomainInfo',
props: {
name: {
type: String,
required: true
}
},
computed: {
mainDomain () {
return this.$store.state.data.main_domain
data () {
return {
queries: [{ uri: 'domains/main', storeKey: 'main_domain' }]
}
},
computed: {
...mapGetters(['mainDomain']),
isMainDomain () {
if (!this.mainDomain) return
return this.name === this.mainDomain
}
},
methods: {
deleteDomain () {
async deleteDomain () {
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_delete', { name: this.name }))
if (!confirmed) return
this.$store.dispatch('DELETE',
{ uri: 'domains', param: this.name }
).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',
{ uri: 'domains/main', data: { new_main_domain: this.name }, storeKey: 'main_domain' }
).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)
})
}
},
created () {
this.$store.dispatch('FETCH',
{ uri: 'domains/main', storeKey: 'main_domain' }
)
}
}
</script>
<style lang="scss" scoped>
</style>

View file

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

View file

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

View file

@ -1,45 +1,34 @@
<template>
<search-view
id="group-list"
<view-search
items-name="groups"
:search.sync="search"
:items="normalGroups"
:filtered-items="filteredGroups"
items-name="groups"
:queries="queries"
@queries-response="formatGroups"
skeleton="card-form-skeleton"
>
<template #top-bar-buttons>
<b-button variant="success" :to="{ name: 'group-create' }">
<icon iname="plus" />
{{ $t('group_new') }}
<icon iname="plus" /> {{ $t('group_new') }}
</b-button>
</template>
<!-- PRIMARY GROUPS CARDS -->
<b-card
v-for="(group, name, index) in filteredGroups" :key="name"
no-body
<card
v-for="(group, name) in filteredGroups" :key="name" collapsable
:title="group.isSpecial ? $t('group_' + name) : `${$t('group')} '${name}'`" icon="group"
>
<b-card-header class="d-flex align-items-center">
<h2>
<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>
<template #header-buttons>
<!-- DELETE GROUP -->
<b-button
v-if="!group.isSpecial" v-b-modal.delete-modal
variant="danger" class="ml-2" size="sm"
@click="groupToDelete = name"
v-if="!group.isSpecial" @click="deleteGroup(name)"
size="sm" variant="danger"
>
<icon :title="$t('delete')" iname="trash-o" /> <span class="sr-only">{{ $t('delete') }}</span>
<icon iname="trash-o" /> {{ $t('delete') }}
</b-button>
</div>
</b-card-header>
</template>
<b-collapse :id="'collapse-' + index" visible>
<b-card-body>
<b-row>
<b-col md="3" lg="2">
<strong>{{ $t('users') }}</strong>
@ -63,6 +52,7 @@
</b-col>
</b-row>
<hr>
<b-row>
<b-col md="3" lg="2">
<strong>{{ $t('permissions') }}</strong>
@ -79,29 +69,15 @@
/>
</b-col>
</b-row>
</b-card-body>
</b-collapse>
</b-card>
</card>
<!-- GROUP SPECIFIC CARD -->
<template #extra>
<b-card no-body v-if="userGroups">
<b-card-header class="d-flex align-items-center">
<h2>
<icon iname="group" /> {{ $t('group_specific_permissions') }}
</h2>
<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>
<card
v-if="userGroups" collapsable
:title="$t('group_specific_permissions')" icon="group"
>
<template v-for="(name, index) in userGroupsNames">
<b-row :key="name">
<b-col md="3" lg="2">
<icon iname="user" /> <strong>{{ name }}</strong>
</b-col>
@ -117,8 +93,8 @@
/>
</b-col>
</b-row>
<hr>
</div>
<hr :key="index">
</template>
<base-selectize
v-if="availableMembers.length"
@ -127,20 +103,8 @@
:selected="userGroupsNames"
@selected="onSpecificUserAdded"
/>
</b-card-body>
</b-collapse>
</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>
</card>
</view-search>
</template>
<script>
@ -148,7 +112,6 @@ import Vue from 'vue'
import api from '@/api'
import { isEmptyValue } from '@/helpers/commons'
import SearchView from '@/components/SearchView'
import ZoneSelectize from '@/components/ZoneSelectize'
import BaseSelectize from '@/components/BaseSelectize'
@ -159,11 +122,15 @@ export default {
data () {
return {
queries: [
{ uri: 'users' },
{ uri: 'users/groups?full&include_primary_groups', storeKey: 'groups' },
{ uri: 'users/permissions?full', storeKey: 'permissions' }
],
search: '',
permissions: undefined,
normalGroups: undefined,
userGroups: undefined,
groupToDelete: undefined
userGroups: undefined
}
},
@ -199,58 +166,7 @@ export default {
},
methods: {
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
},
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]) => {
formatGroups (users, allGroups, permissions) {
// Do not use computed properties to get values from the store here to avoid auto
// updates while modifying values.
const normalGroups = {}
@ -289,12 +205,57 @@ export default {
this.permissions = permissions
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: {
SearchView,
ZoneSelectize,
BaseSelectize
}

View file

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

View file

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

View file

@ -4,13 +4,7 @@
:server-error="serverError"
@submit="onSubmit"
: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>
<script>
@ -20,7 +14,6 @@ import { validationMixin } from 'vuelidate'
import { PasswordForm } from '@/components/reusableForms'
import { required, minLength } from '@/helpers/validators'
export default {
name: 'ToolAdminpw',
@ -67,9 +60,6 @@ export default {
},
mixins: [validationMixin],
components: {
PasswordForm
}
components: { PasswordForm }
}
</script>

View file

@ -1,30 +1,30 @@
<template>
<div class="tool-log">
<view-base
:queries="queries" @queries-response="formatFirewallData"
ref="view" skeleton="card-form-skeleton"
>
<!-- PORTS -->
<b-card>
<template v-slot:header>
<h2><icon iname="shield" /> {{ $t('ports') }}</h2>
</template>
<card :title="$t('ports')" icon="shield">
<div v-for="(items, protocol) in protocols" :key="protocol">
<h5>{{ $t(protocol) }}</h5>
<b-table
:fields="fields" :items="items"
small striped responsive="true"
small striped responsive
>
<!-- PORT CELL -->
<template v-slot:cell(port)="data">
<template #cell(port)="data">
{{ data.value }}
</template>
<!-- CONNECTIONS CELL -->
<template v-slot:cell()="data">
<template #cell()="data">
<b-checkbox
v-if="data.field.key !== 'uPnP'"
class="on-off-switch"
v-model="data.value"
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')">
{{ $t(data.value ? 'close' : 'open') }}
@ -39,108 +39,69 @@
</template>
</b-table>
</div>
</b-card>
</card>
<!-- OPERATIONS -->
<b-card>
<template v-slot:header>
<h2><icon iname="cogs" /> {{ $t('operations') }}</h2>
</template>
<b-form
id="port-form" inline class="d-flex justify-content-between"
@submit.prevent="onFormSubmit"
<card-form
:title="$t('operations')" icon="cogs"
:validation="$v" :server-error="serverError"
@submit.prevent="onFormPortToggling"
inline form-classes="d-flex justify-content-between align-items-start"
>
<b-input-group :prepend="$t('action')">
<b-select
id="input-action"
v-model="form.action" :options="actionChoices"
/>
<b-select v-model="form.action" :options="actionChoices" />
</b-input-group>
<form-field :validation="$v.form.port">
<b-input-group :prepend="$t('port')">
<b-input
id="input-port" placeholder="0"
type="number" min="0" max="65535"
v-model.number="form.port"
<input-item
id="input-port" placeholder="0" type="number"
v-model="form.port"
/>
</b-input-group>
</form-field>
<b-input-group :prepend="$t('connection')">
<b-select
id="input-connection"
v-model="form.connection" :options="connectionChoices"
/>
<b-select v-model="form.connection" :options="connectionChoices" id="input-connection" />
</b-input-group>
<b-input-group :prepend="$t('protocol')">
<b-select
id="input-protocol"
v-model="form.protocol" :options="protocolChoices"
/>
<b-select v-model="form.protocol" :options="protocolChoices" id="input-protocol" />
</b-input-group>
</b-form>
<template v-slot:footer>
<b-button type="submit" form="port-form" variant="success">
{{ $t('save') }}
</b-button>
</template>
</b-card>
</card-form>
<!-- UPnP -->
<b-card :body-text-variant="upnpEnabled ? 'success' : 'danger'">
<template v-slot:header>
<h2><icon iname="exchange" /> {{ $t('upnp') }}</h2>
</template>
<card :title="$t('upnp')" icon="exchange" :body-text-variant="upnpEnabled ? 'success' : 'danger'">
{{ $t(upnpEnabled ? 'upnp_enabled' : 'upnp_disabled' ) }}
<b-form-invalid-feedback :state="upnpError !== '' ? false : null">
{{ upnpError }}
</b-form-invalid-feedback>
<template v-slot:footer>
<b-button
:variant="!upnpEnabled ? 'success' : 'danger'"
v-b-modal.toggle-upnp-modal
>
<template #buttons>
<b-button @click="toggleUpnp" :variant="!upnpEnabled ? 'success' : 'danger'">
{{ $t(!upnpEnabled ? 'enable' : 'disable' ) }}
</b-button>
</template>
</b-card>
<!-- 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>
</card>
</view-base>
</template>
<script>
import { validationMixin } from 'vuelidate'
import api from '@/api'
import { required, integer, between } from '@/helpers/validators'
export default {
name: 'ToolFirewall',
data () {
return {
// Tables data
queries: ['/firewall?raw'],
serverError: '',
// Ports tables data
fields: [
{ key: 'port', label: this.$i18n.t('port') },
{ key: 'ipv4', label: this.$i18n.t('ipv4') },
@ -150,7 +111,7 @@ export default {
protocols: undefined,
portToToggle: undefined,
// Form data
// Ports form data
actionChoices: [
{ value: 'open', text: this.$i18n.t('open') },
{ value: 'close', text: this.$i18n.t('close') }
@ -177,9 +138,14 @@ export default {
}
},
validations: {
form: {
port: { number: required, integer, between: between(0, 65535) }
}
},
methods: {
fetchData () {
api.get('/firewall?raw').then(data => {
formatFirewallData (data) {
const ports = Object.values(data).reduce((ports, protocols) => {
for (const type of ['TCP', 'UDP']) {
for (const port of protocols[type]) {
@ -206,59 +172,59 @@ export default {
this.protocols = tables
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'
api[method](`/firewall/port?${connection}_only`, { port, protocol }).then(() => {
if (index === -1) this.fetchData()
this.portToToggle = undefined
}).catch((err) => {
console.log(err)
resolve(confirmed)
}).catch(error => {
reject(error)
})
} else {
resolve(confirmed)
}
})
})
},
toggleUpnp (value) {
api.get('firewall/upnp?action=' + (value ? 'enable' : 'disable')).then(r => {
async toggleUpnp (value) {
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.
this.fetchData()
this.$refs.view.fetchQueries()
}).catch(err => {
this.upnpError = err.message
})
},
onCancel () {
const { protocol, index, connection, value } = this.portToToggle
if (index > -1) {
onTablePortToggling (port, protocol, connection, index, value) {
this.$set(this.protocols[protocol][index], connection, value)
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.portToToggle = undefined
})
},
onToggle (protocol, connection, port, index, value) {
this.$set(this.protocols[protocol][index], connection, value)
this.portToToggle = {
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()
onFormPortToggling (e) {
this.togglePort(this.form).then(toggled => {
if (toggled) this.$refs.view.fetchQueries()
})
}
},
created () {
this.fetchData()
}
mixins: [validationMixin]
}
</script>
@ -293,16 +259,17 @@ export default {
}
}
form {
::v-deep form {
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>

View file

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

View file

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

View file

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

View file

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

View file

@ -1,31 +1,25 @@
<template>
<div class="tool-power">
<div>
<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>
<icon :iname="action === 'reboot' ? 'refresh' : 'power-off'" /> {{ $t(action === 'reboot' ? 'tools_rebooting' : 'tools_shuttingdown') }}
<b-alert variant="warning">
<icon :iname="action === 'reboot' ? 'refresh' : 'power-off'" />
{{ $t(action === 'reboot' ? 'tools_rebooting' : 'tools_shuttingdown') }}
</b-alert>
<template v-if="canReconnect">
<b-alert variant="success" show v-t="'tools_power_up'" />
<b-alert variant="success" v-t="'tools_power_up'" />
<login-view />
</template>
</div>
<b-card v-else>
<template v-slot:header>
<h2><icon iname="wrench" /> {{ $t('operations') }}</h2>
</template>
<card v-else :title="$t('operations')" icon="wrench">
<!-- REBOOT -->
<b-form-group
label-cols="5" label-cols-md="4" label-cols-lg="3"
:label="$t('tools_reboot')" label-for="reboot"
>
<b-button
variant="danger" id="reboot" v-b-modal.confirm-action
@click="action = 'reboot'"
>
<b-button @click="triggerAction('reboot')" variant="danger" id="reboot">
<icon iname="refresh" /> {{ $t('tools_reboot_btn') }}
</b-button>
</b-form-group>
@ -36,23 +30,11 @@
label-cols="5" label-cols-md="4" label-cols-lg="3"
:label="$t('tools_shutdown')" label-for="shutdown"
>
<b-button
variant="danger" id="shutdown" v-b-modal.confirm-action
@click="action = 'shutdown'"
>
<b-button @click="triggerAction('shutdown')" variant="danger" id="shutdown">
<icon iname="power-off" /> {{ $t('tools_shutdown_btn') }}
</b-button>
</b-form-group>
<!-- 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>
</card>
</div>
</template>
@ -72,7 +54,13 @@ export default {
},
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(() => {
// Use 'RESET_CONNECTED' and not 'DISCONNECT' else user will be redirect to login
this.$store.dispatch('RESET_CONNECTED')
@ -100,8 +88,6 @@ export default {
}
},
components: {
LoginView
}
components: { LoginView }
}
</script>

View file

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

View file

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

View file

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

View file

@ -1,4 +1,5 @@
<template lang="html">
<template>
<view-base :queries="queries" @queries-response="onQueriesResponse" skeleton="card-form-skeleton">
<card-form
:title="$t('user_username_edit', { name })" icon="user"
:validation="$v" :server-error="serverError"
@ -103,6 +104,7 @@
<!-- USER PASSWORD CONFIRMATION -->
<form-field v-bind="fields.confirmation" v-model="form.confirmation" :validation="$v.form.confirmation" />
</card-form>
</view-base>
</template>
<script>
@ -127,7 +129,11 @@ export default {
data () {
return {
ready: false,
queries: [
{ uri: 'users', param: this.name, storeKey: 'users_details' },
{ uri: 'domains/main', storeKey: 'main_domain' },
{ uri: 'domains' }
],
form: {
fullname: { firstname: '', lastname: '' },
@ -236,6 +242,27 @@ export default {
},
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 () {
const formData = formatFormData(this.form, { flatten: true })
const user = this.user(this.name)
@ -280,8 +307,9 @@ export default {
? { localPart: '', separator: '@', domain: this.mainDomain }
: ''
)
// Focus last input after rendering update
this.$nextTick(() => {
const inputs = document.querySelectorAll(`#mail-${type} input`)
const inputs = this.$el.querySelectorAll(`#mail-${type} input`)
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],
components: {
AdressInputSelect
}
components: { AdressInputSelect }
}
</script>

View file

@ -1,15 +1,10 @@
<template>
<div class="user">
<b-card :class="{skeleton: !user}">
<template v-slot:header>
<h2>{{ user ? user.fullname : '' }}</h2>
</template>
<div class="d-flex align-items-center">
<view-base :queries="queries" skeleton="card-info-skeleton">
<card v-if="user" :title="user.fullname" icon="user">
<div class="d-flex align-items-center flex-column flex-md-row">
<icon iname="user" class="fa-fw" />
<div class="w-100">
<template v-if="user">
<b-row>
<b-col><strong>{{ $t('user_username') }}</strong></b-col>
<b-col>{{ user.username }}</b-col>
@ -47,77 +42,64 @@
</template>
</b-col>
</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>
<template v-slot:footer>
<div class="d-flex d-flex justify-content-end">
<b-button :to="user ? {name: 'user-edit', params: {user: user}} : null"
:variant="user ? 'info' : 'dark'"
>
<template #buttons>
<b-button :to="{ name: 'user-edit', params: { user } }" :variant="user ? 'info' : 'dark'">
<icon iname="edit" />
{{ user ? $t('user_username_edit', {name: user.username}) : '' }}
</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') : '' }}
</b-button>
</div>
</template>
</b-card>
</card>
<b-modal
v-if="user" id="delete-modal" centered
header-bg-variant="danger" header-text-variant="light"
:title="$t('confirm_delete', {name: user.username })"
@ok="deleteUser"
v-if="user"
id="delete-modal" :title="$t('confirm_delete', { name: user.username })" @ok="deleteUser"
header-bg-variant="warning" body-class="" body-bg-variant=""
>
<b-form-group>
<template v-slot:description>
<b-alert variant="warning" show>
<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 v-model="purge">
{{ $t('purge_user_data_checkbox', { name: user.username }) }}
</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-modal>
</div>
</view-base>
</template>
<script>
export default {
name: 'UserInfo',
props: {
name: {
type: String,
required: true
}
name: { type: String, required: true }
},
data () {
return {
queries: [{ uri: 'users', param: this.name, storeKey: 'users_details' }],
purge: false
}
},
computed: {
user () {
return this.$store.state.data.users_details[this.name]
return this.$store.getters.user(this.name)
}
},
methods: {
deleteUser () {
const data = this.purge ? { purge: '' } : {}
@ -127,23 +109,11 @@ export default {
this.$router.push({ name: 'user-list' })
})
}
},
created () {
this.$store.dispatch('FETCH',
{ uri: 'users', param: this.name, storeKey: 'users_details' }
)
}
}
</script>
<style lang="scss" scoped>
.card-body > div {
flex-direction: column;
@include media-breakpoint-up(md) {
flex-direction: row;
}
}
.icon.fa-user {
font-size: 10rem;
padding-right: 3rem;
@ -171,30 +141,4 @@ ul {
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>

View file

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