Merge pull request #436 from YunoHost/enh-domains

Rework domain list and domain info view (using tree-structure and new config panel for domain)
This commit is contained in:
Alexandre Aubin 2022-10-10 17:00:27 +02:00 committed by GitHub
commit c1395df89d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 871 additions and 216 deletions

View file

@ -2,6 +2,7 @@
<abstract-form
v-bind="{ id: panel.id + '-form', validation, serverError: panel.serverError }"
@submit.prevent.stop="onApply"
:no-footer="!panel.hasApplyButton"
>
<slot name="tab-top" />
@ -13,8 +14,10 @@
<template v-for="section in panel.sections">
<component
v-if="section.visible" :is="section.name ? 'section' : 'div'"
:key="section.id" class="mb-5"
v-if="section.visible"
:is="section.name ? 'section' : 'div'"
:key="section.id"
class="panel-section"
>
<b-card-title v-if="section.name" title-tag="h3">
{{ section.name }} <small v-if="section.help">{{ section.help }}</small>
@ -86,6 +89,9 @@ export default {
<style lang="scss" scoped>
.card-title {
margin-bottom: 1em;
border-bottom: solid 1px #aaa;
border-bottom: solid $border-width $gray-500;
}
::v-deep .panel-section:not(:last-child) {
margin-bottom: 3rem;
}
</style>

View file

@ -3,7 +3,11 @@
:routes="routes_"
v-bind="{ panels, forms, v: $v, ...$attrs }"
v-on="$listeners"
/>
>
<slot name="tab-top" slot="tab-top"></slot>
<slot name="tab-before" slot="tab-before"></slot>
<slot name="tab-after" slot="tab-after"></slot>
</routable-tabs>
</template>
<script>

View file

@ -0,0 +1,112 @@
<template>
<b-list-group :flush="flush" :style="{ '--depth': tree.depth }">
<template v-for="(node, i) in tree.children">
<b-list-group-item
:key="node.id"
class="list-group-item-action" :class="getClasses(node, i)"
@click="$router.push(node.data.to)"
>
<slot name="default" v-bind="node" />
<b-button
v-if="node.children"
size="xs" variant="outline-secondary"
:aria-expanded="node.data.opened ? 'true' : 'false'" :aria-controls="'collapse-' + node.id"
:class="node.data.opened ? 'not-collapsed' : 'collapsed'" class="ml-2"
@click.stop="node.data.opened = !node.data.opened"
>
<span class="sr-only">{{ toggleText }}</span>
<icon iname="chevron-right" />
</b-button>
</b-list-group-item>
<b-collapse
v-if="node.children" :key="'collapse-' + node.id"
v-model="node.data.opened" :id="'collapse-' + node.id"
>
<recursive-list-group
:tree="node"
:last="last !== undefined ? last : i === tree.children.length - 1" flush
>
<!-- PASS THE DEFAULT SLOT WITH SCOPE TO NEXT NESTED COMPONENT -->
<template slot="default" slot-scope="scope">
<slot name="default" v-bind="scope" />
</template>
</recursive-list-group>
</b-collapse>
</template>
</b-list-group>
</template>
<script>
export default {
name: 'RecursiveListGroup',
props: {
tree: { type: Object, required: true },
flush: { type: Boolean, default: false },
last: { type: Boolean, default: undefined },
toggleText: { type: String, default: null }
},
methods: {
getClasses (node, i) {
const children = node.height > 0
const opened = children && node.data.opened
const last = this.last !== false && (!children || !opened) && i === this.tree.children.length - 1
return { collapsible: children, uncollapsible: !children, opened, last }
}
}
}
</script>
<style lang="scss" scoped>
.list-group {
.collapse {
&:not(.show) + .list-group-item {
border-end-start-radius: $border-radius;
}
&.show + .list-group-item {
border-start-start-radius: $border-radius;
}
+ .list-group-item {
border-block-start-width: 1px !important;
}
}
&-item {
&-action {
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
width: unset;
}
&.collapsible.opened {
border-end-start-radius: $border-radius;
}
&.collapsible:not(.opened, .last) {
border-block-end-width: 0;
}
&.last {
border-block-end-width: $list-group-border-width;
border-end-start-radius: $border-radius;
}
}
&-flush .list-group-item {
margin-inline-start: calc(1rem * var(--depth));
border-inline-end: $list-group-border-width solid $list-group-border-color;
border-inline-start: $list-group-border-width solid $list-group-border-color;
text-decoration: none;
background-color: $list-group-hover-bg;
@include hover-focus() {
background-color: darken($list-group-hover-bg, 3%);
}
}
}
</style>

View file

@ -13,7 +13,11 @@
</b-card-header>
<!-- Bind extra props to the child view and forward child events to parent -->
<router-view v-bind="$attrs" v-on="$listeners" />
<router-view v-bind="$attrs" v-on="$listeners">
<slot name="tab-top" slot="tab-top"></slot>
<slot name="tab-before" slot="tab-before"></slot>
<slot name="tab-after" slot="tab-after"></slot>
</router-view>
</b-card>
</template>

View file

@ -6,6 +6,7 @@
<component :is="titleTag" class="custom-header-title">
<icon v-if="icon" :iname="icon" class="mr-2" />{{ title }}
</component>
<slot name="header-next" />
</slot>
<div v-if="hasButtons" class="mt-2 w-100 custom-header-buttons" :class="{ [`ml-${buttonUnbreak}-auto mt-${buttonUnbreak}-0 w-${buttonUnbreak}-auto`]: buttonUnbreak }">

View file

@ -0,0 +1,59 @@
<template>
<b-row no-gutters class="description-row">
<b-col v-bind="cols_">
<slot name="term">
<strong>{{ term }}</strong>
</slot>
</b-col>
<b-col>
<slot name="default">
{{ details }}
</slot>
</b-col>
</b-row>
</template>
<script>
export default {
name: 'DescriptionRow',
props: {
term: { type: String, default: null },
details: { type: String, default: null },
cols: { type: Object, default: () => ({ md: 4, xl: 3 }) }
},
computed: {
cols_ () {
return Object.assign({ md: 4, xl: 3 }, this.cols)
}
}
}
</script>
<style lang="scss" scoped>
.description-row {
@include media-breakpoint-up(md) {
margin: .5rem 0;
&:hover {
background-color: rgba(0, 0, 0, 0.05);
border-radius: 0.2rem;
}
}
@include media-breakpoint-down(sm) {
flex-direction: column;
&:not(:last-of-type) {
margin-bottom: 0.5rem;
padding-bottom: 0.5rem;
border-bottom: $border-width solid $card-border-color;
}
}
.col {
display: flex;
}
}
</style>

View file

@ -0,0 +1,63 @@
<template>
<span class="explain-what">
<slot name="default" />
<span class="explain-what-popover-container">
<b-button
:id="id" href="#"
variant="light"
>
<icon iname="question" />
<span class="sr-only">{{ $t('details_about', { subject: title }) }}</span>
</b-button>
<b-popover
placement="auto"
:target="id" triggers="click" custom-class="explain-what-popover"
:variant="variant" :title="title"
>
<span v-html="content" />
</b-popover>
</span>
</span>
</template>
<script>
export default {
name: 'ExplainWhat',
props: {
id: { type: String, required: true },
title: { type: String, required: true },
content: { type: String, required: true },
variant: { type: String, default: 'info' }
},
computed: {
cols_ () {
return Object.assign({ md: 4, xl: 3 }, this.cols)
}
}
}
</script>
<style lang="scss" scoped>
.explain-what {
line-height: 1.2;
.btn {
padding: 0;
margin-left: .1rem;
border-radius: 50rem;
line-height: inherit;
font-size: inherit;
}
&-popover {
background-color: $white;
border-width: 2px;
::v-deep .popover-body {
color: $dark;
}
}
}
</style>

View file

@ -1,12 +1,13 @@
<template>
<span :class="'icon fa fa-' + iname" aria-hidden="true" />
<span :class="['icon fa fa-' + iname, variant ? 'variant ' + variant : '']" aria-hidden="true" />
</template>
<script>
export default {
name: 'Icon',
props: {
iname: { type: String, required: true }
iname: { type: String, required: true },
variant: { type: String, default: null }
}
}
</script>
@ -26,5 +27,25 @@ export default {
&.fs-sm {
font-size: 1rem;
}
&.variant {
font-size: .8rem;
width: 1.35rem;
min-width: 1.35rem;
height: 1.35rem;
line-height: 165%;
border-radius: 50rem;
@each $color, $value in $theme-colors {
&.#{$color} {
background-color: $value;
color: $white;
}
}
&.warning {
color: $black;
}
}
}
</style>

View file

@ -0,0 +1,209 @@
/**
* A Node that can have a parent and children.
*/
export class Node {
constructor (data) {
this.data = data
this.depth = 0
this.height = 0
this.parent = null
// this.id = null
// this.children = null
}
/**
* Invokes the specified `callback` for this node and each descendant in pre-order
* traversal, such that a given node is only visited after all of its ancestors
* have already been visited.
* The specified function is passed the current descendant, the zero-based traversal
* index, and this node.
* Code taken and adapted from d3.js https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/eachBefore.js.
*
* @param {function} callback
* @return {Object}
*/
eachBefore (callback) {
const nodes = []
let index = -1
let node = this
while (node) {
callback(node, ++index, this)
if (node.children) {
nodes.push(...node.children)
}
node = nodes.pop()
}
return this
}
/**
* Invokes the specified `callback` for this node and each descendant in post-order
* traversal, such that a given node s only visited after all of its descendants
* have already been visited
* The specified function is passed the current descendant, the zero-based traversal
* index, and this node.
* Code taken and adapted from d3.js https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/eachAfter.js.
*
* @param {function} callback
* @return {Object}
*/
eachAfter (callback) {
const nodes = []
const next = []
let node = this
while (node) {
next.push(node)
if (node.children) {
nodes.push(...node.children)
}
node = nodes.pop()
}
let index = 0
for (let i = next.length - 1; i >= 0; i--) {
callback(next[i], index++, this)
}
return this
}
/**
* Returns a deep copied and filtered tree of itself.
* Specified filter function is passed each nodes in post-order traversal and must
* return `true` or `false` like a regular filter function.
*
* @param {Function} callback - filter callback function to invoke on each nodes
* @param {Object} args
* @param {String} [args.idKey='name'] - the key name where we can find the node identity.
* @param {String} [args.parentIdKey='name'] - the key name where we can find the parent identity.
* @return {Node}
*/
filter (callback) {
// Duplicates this tree and iter on nodes from leaves to root (post-order traversal)
return hierarchy(this).eachAfter((node, i) => {
// Since we create a new hierarchy from another, nodes's `data` contains the
// whole dupplicated node. Overwrite node's `data` by node's original `data`.
node.data = node.data.data
if (node.children) {
// Removed flagged children
node.children = node.children.filter(child => !child.remove)
if (!node.children.length) delete node.children
}
// Perform filter callback on non-root nodes
const match = node.data ? callback(node, i, this) : true
// Flag node if there's no match in node nor in its children
if (!match && !node.children) {
node.remove = true
}
})
}
}
/**
* Generates a new hierarchy from the specified tabular `dataset`.
* The specified `dataset` must be an array of objects that contains at least a
* `name` property and an optional `parent` property referencing its parent `name`.
* Code taken and adapted from d3.js https://github.com/d3/d3-hierarchy/blob/main/src/stratify.js#L16.
*
* @param {Array} dataset
* @param {Object} args
* @param {String} [args.idKey='name'] - the key name where we can find the node identity.
* @param {String} [args.parentIdKey='name'] - the key name where we can find the parent identity.
* @return {Node}
*/
export function stratify (dataset, { idKey = 'name', parentIdKey = 'parent' } = {}) {
const root = new Node(null, true)
root.children = []
const nodesMap = new Map()
// Creates all nodes that will be arranged in a hierarchy
const nodes = dataset.map(d => {
const node = new Node(d)
node.id = d[idKey]
nodesMap.set(node.id, node)
if (d[parentIdKey]) {
node.parent = d[parentIdKey]
}
return node
})
// Build a hierarchy from nodes
nodes.forEach((node, i) => {
const parentId = node.parent
if (parentId) {
const parent = nodesMap.get(parentId)
if (!parent) throw new Error('Missing parent node: ' + parentId)
if (parent.children) parent.children.push(node)
else parent.children = [node]
node.parent = parent
} else {
node.parent = root
root.children.push(node)
}
})
root.eachBefore(node => {
// Compute node depth
if (node.parent) {
node.depth = node.parent.depth + 1
// Remove parent key if parent is root (node with no data)
if (!node.parent.data) delete node.parent
}
computeNodeHeight(node)
})
return root
}
/**
* Constructs a root node from the specified hierarchical `data`.
* The specified `data` must be an object representing the root node and its children.
* If given a `Node` object this will return a deep copy of it.
* Code taken and adapted from d3.js https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/index.js#L15.
*
* @param {Node|Object} data - object representing a root node (a simple { id, children } object or a `Node`)
* @return {Node}
*/
export function hierarchy (data) {
const root = new Node(data)
const nodes = []
let node = root
while (node) {
if (node.data.children) {
node.children = node.data.children.map(child_ => {
const child = new Node(child_)
child.id = child_.id
child.parent = node === root ? null : node
child.depth = node.depth + 1
nodes.push(child)
return child
})
}
node = nodes.pop()
}
root.eachBefore(computeNodeHeight)
return root
}
/**
* Compute the node height by iterating on parents
* Code taken from d3.js https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/index.js#L62.
*
* @param {Node} node
*/
function computeNodeHeight (node) {
let height = 0
do {
node.height = height
node = node.parent
} while (node && node.height < ++height)
}

View file

@ -10,6 +10,14 @@ import {
} from '@/helpers/commons'
const NO_VALUE_FIELDS = [
'ReadOnlyField',
'ReadOnlyAlertItem',
'MarkdownItem',
'DisplayTextItem',
'ButtonItem'
]
/**
* Tries to find a translation corresponding to the user's locale/fallback locale in a
* Yunohost argument or simply return the string if it's not an object literal.
@ -359,7 +367,7 @@ export function formatYunoHostConfigPanels (data) {
}
for (const { id: panelId, name, help, sections } of data.panels) {
const panel = { id: panelId, sections: [], serverError: '' }
const panel = { id: panelId, sections: [], serverError: '', hasApplyButton: false }
result.forms[panelId] = {}
result.validations[panelId] = {}
result.errors[panelId] = {}
@ -391,6 +399,10 @@ export function formatYunoHostConfigPanels (data) {
Object.assign(result.errors[panelId], errors)
section.fields = fields
panel.sections.push(section)
if (!section.isActionSection && Object.values(fields).some((field) => !NO_VALUE_FIELDS.includes(field.is))) {
panel.hasApplyButton = true
}
}
result.panels.push(panel)

View file

@ -134,6 +134,7 @@
"delete": "Delete",
"description": "Description",
"details": "Details",
"details_about": "Show more details about {subject}",
"domain_dns_conf_is_just_a_recommendation": "This page shows you the *recommended* configuration. It does *not* configure the DNS for you. It is your responsibility to configure your DNS zone in your DNS registrar according to this recommendation.",
"diagnosis": "Diagnosis",
"diagnosis_experimental_disclaimer": "Be aware that the diagnosis feature is still experimental and being polished, and it may not be fully reliable.",
@ -144,6 +145,14 @@
"disabled": "Disabled",
"dns": "DNS",
"domain": {
"cert": {
"types": {
"selfsigned": "Self-signed",
"letsencrypt": "Let's Encrypt",
"other": "Other/Unknown"
},
"valid_for": "valid for {days}"
},
"config": {
"edit": "Edit domain configuration",
"title": "Domain configuration"
@ -153,6 +162,13 @@
"auto_config_ignored": "ignored, won't be changed by YunoHost unless you check the overwrite option",
"auto_config_ok": "Automatic configuration seems to be OK!",
"auto_config_zone": "Current DNS zone",
"methods": {
"auto": "Automatic",
"handled_in_parent": "Handled in parent domain",
"manual": "Manual",
"none": "None",
"semi_auto": "Semi-automatic"
},
"edit": "Edit DNS configuration",
"info": "The automatic DNS records configuration is an experimental feature. <br>Consider saving your current DNS zone from your DNS registrar's interface before pushing records from here.",
"manual_config": "Suggested DNS records for manual configuration",
@ -160,7 +176,20 @@
"push_force": "Overwrite existing records",
"push_force_confirm": "Are you sure you want to force push all suggested dns records? Be aware that it may overwrite manually or important default records set by you or your registrar.",
"push_force_warning": "It looks like some DNS records that YunoHost would have set are already in the registrar configuration. You can use the overwrite option if you know what you are doing."
}
},
"explain": {
"main_domain": "The main domain is the domain from which users can connect to the portal (via \"{domain}/yunohost/sso\").<br>Therefore, it is not possible to delete it.<br>If you want to delete \"{domain}\", you will first have to choose or add another domain and set it as the main domain."
},
"info": {
"apps_on_domain": "Apps installed on domain",
"certificate_authority": "SSL Certification authority",
"registrar": "Registrar"
},
"see_parent_domain": "See parent domain",
"types": {
"main_domain": "Main domain"
},
"toggle_subdomains": "Toggle subdomains"
},
"domain_add": "Add domain",
"domain_add_dns_doc": "… and I have <a href='//yunohost.org/dns_config' target='_blank'>set my DNS correctly</a>.",
@ -178,8 +207,6 @@
"domain_dns_push_managed_in_parent_domain": "The automatic DNS records feature is managed in the parent domain <a href='#/domains/{parent_domain}/dns'>{parent_domain}</a>.",
"domain_dns_push_not_applicable": "The automatic DNS records feature is not applicable to domain {domain},<br> You should manually configure your DNS records following the <a href='https://yunohost.org/dns'>documentation</a> and the suggested configuration below.",
"domain_name": "Domain name",
"domain_visit": "Visit",
"domain_visit_url": "Visit {url}",
"domains": "Domains",
"download": "Download",
"enable": "Enable",
@ -550,8 +577,10 @@
"browse": "Browse",
"collapse": "Collapse",
"default": "Default",
"link": "Link",
"none": "None",
"separator": ", "
"separator": ", ",
"valid": "Valid"
},
"wrong_password_or_username": "Wrong password or username",
"yes": "Yes",

View file

@ -141,44 +141,23 @@ const routes = [
}
},
{
name: 'domain-info',
path: '/domains/:name',
component: () => import(/* webpackChunkName: "views/domain/info" */ '@/views/domain/DomainInfo'),
props: true,
meta: {
args: { param: 'name' },
breadcrumb: ['domain-list', 'domain-info']
}
},
{
// no need for name here, only children are visited
path: '/domains/:name/config',
component: () => import(/* webpackChunkName: "views/domain/config" */ '@/views/domain/DomainConfig'),
props: true,
children: [
{
name: 'domain-config',
name: 'domain-info',
path: ':tabId?',
component: () => import(/* webpackChunkName: "components/configPanel" */ '@/components/ConfigPanel'),
props: true,
meta: {
routerParams: ['name'], // Override router key params to avoid view recreation at tab change.
args: { trad: 'config' },
breadcrumb: ['domain-list', 'domain-info', 'domain-config']
args: { param: 'name' },
breadcrumb: ['domain-list', 'domain-info']
}
}
]
},
{
name: 'domain-dns',
path: '/domains/:name/dns',
component: () => import(/* webpackChunkName: "views/domain/dns" */ '@/views/domain/DomainDns'),
props: true,
meta: {
args: { trad: 'dns' },
breadcrumb: ['domain-list', 'domain-info', 'domain-dns']
}
},
/*
APPS

View file

@ -97,3 +97,7 @@ $fa-font-size-base: $font-size-base;
//
$thin-border: $hr-border-width solid $hr-border-color;
$btn-padding-y-xs: .25rem;
$btn-padding-x-xs: .35rem;
$btn-line-height-xs: 1.5;

View file

@ -58,12 +58,20 @@ body {
}
}
// Add xs sized btn
.btn-xs {
@include button-size($btn-padding-y-xs, $btn-padding-x-xs, $btn-font-size-sm, $btn-line-height-xs, $btn-border-radius-sm);
}
// Allow state of input group to be displayed under the group
.input-group .is-invalid ~ .invalid-feedback {
display: block;
}
.tooltip { top: 0; }
// Descriptive list (<b-row /> elems with <b-col> inside)
// FIXME REMOVE when every infos switch to `DescriptionRow`
.row-line {
@include media-breakpoint-up(md) {
&:hover {

View file

@ -2,12 +2,27 @@ import Vue from 'vue'
import api from '@/api'
import { isEmptyValue } from '@/helpers/commons'
import { stratify } from '@/helpers/data/tree'
export function getParentDomain (domain, domains, highest = false) {
const method = highest ? 'lastIndexOf' : 'indexOf'
let i = domain[method]('.')
while (i !== -1) {
const dn = domain.slice(i + 1)
if (domains.includes(dn)) return dn
i = domain[method]('.', i + (highest ? -1 : 1))
}
return null
}
export default {
state: () => ({
domains: undefined, // Array
main_domain: undefined,
domains: undefined, // Array
domains_details: {},
users: undefined, // basic user data: Object {username: {data}}
users_details: {}, // precise user data: Object {username: {data}}
groups: undefined,
@ -19,6 +34,22 @@ export default {
state.domains = domains
},
'SET_DOMAINS_DETAILS' (state, [name, details]) {
Vue.set(state.domains_details, name, details)
},
'UPDATE_DOMAINS_DETAILS' (state, payload) {
// FIXME use a common function to execute the same code ?
this.commit('SET_DOMAINS_DETAILS', payload)
},
'DEL_DOMAINS_DETAILS' (state, [name]) {
Vue.delete(state.domains_details, name)
if (state.domains) {
Vue.delete(state.domains, name)
}
},
'ADD_DOMAINS' (state, [{ domain }]) {
state.domains.push(domain)
},
@ -174,6 +205,44 @@ export default {
domains: state => state.domains,
orderedDomains: state => {
if (!state.domains) return
const splittedDomains = Object.fromEntries(state.domains.map(domain => {
// Keep the main part of the domain and the extension together
// eg: this.is.an.example.com -> ['example.com', 'an', 'is', 'this']
domain = domain.split('.')
domain.push(domain.pop() + domain.pop())
return [domain, domain.reverse()]
}))
return state.domains.sort((a, b) => splittedDomains[a] > splittedDomains[b])
},
domainsTree: (state, getters) => {
// This getter will not return any reactive data, make sure to assign its output
// to a component's `data`.
// FIXME manage to store the result in the store to allow reactive data (trigger an
// action when state.domain change)
const domains = getters.orderedDomains
if (!domains) return
const dataset = domains.map(name => ({
// data to build a hierarchy
name,
parent: getParentDomain(name, domains),
// utility data that will be used by `RecursiveListGroup` component
to: { name: 'domain-info', params: { name } },
opened: true
}))
return stratify(dataset)
},
domain: state => name => state.domains_details[name],
highestDomainParentName: (state, getters) => name => {
return getParentDomain(name, getters.orderedDomains, true)
},
mainDomain: state => state.main_domain,
domainsAsChoices: state => {

View file

@ -1,73 +0,0 @@
<template>
<view-base
:queries="queries" @queries-response="onQueriesResponse"
ref="view" skeleton="card-form-skeleton"
>
<config-panels
v-if="config.panels" v-bind="config"
@submit="onConfigSubmit"
/>
</view-base>
</template>
<script>
import api, { objectToParams } from '@/api'
import {
formatFormData,
formatYunoHostConfigPanels
} from '@/helpers/yunohostArguments'
import ConfigPanels from '@/components/ConfigPanels'
export default {
name: 'DomainConfig',
components: {
ConfigPanels
},
props: {
name: { type: String, required: true }
},
data () {
return {
queries: [
['GET', `domains/${this.name}/config?full`]
],
config: {}
}
},
methods: {
onQueriesResponse (config) {
this.config = formatYunoHostConfigPanels(config)
},
async onConfigSubmit ({ id, form, action, name }) {
const args = await formatFormData(form, { removeEmpty: false, removeNull: true })
const call = action
? api.put(
`domain/${this.name}/actions/${action}`,
{ args: objectToParams(args) },
{ key: 'domains.' + name, name: this.name }
)
: api.put(
`domains/${this.name}/config/${id}`,
{ args: objectToParams(args) },
{ key: 'domains.update_config', id, name: this.name }
)
call.then(() => {
this.$refs.view.fetchQueries({ triggerLoading: true })
}).catch(err => {
if (err.name !== 'APIBadRequestError') throw err
const panel = this.config.panels.find(panel => panel.id === id)
if (err.data.name) {
this.config.errors[id][err.data.name].message = err.message
} else this.$set(panel, 'serverError', err.message)
})
}
}
}
</script>

View file

@ -3,10 +3,16 @@
:queries="queries" @queries-response="onQueriesResponse" :loading="loading"
skeleton="card-info-skeleton"
>
<card v-if="showAutoConfigCard" :title="$t('domain.dns.auto_config')" icon="wrench">
<b-alert variant="warning">
<icon iname="flask" /> <icon iname="warning" /> <span v-html="$t('domain.dns.info')" />
</b-alert>
<section v-if="showAutoConfigCard" class="panel-section">
<b-card-title title-tag="h3">
{{ $t('domain.dns.auto_config') }}
</b-card-title>
<read-only-alert-item
:label="$t('domain.dns.info')"
type="warning"
icon="flask"
/>
<!-- AUTO CONFIG CHANGES -->
<template v-if="dnsChanges">
@ -32,27 +38,32 @@
</template>
<!-- CONFIG OK ALERT -->
<b-alert v-else-if="dnsChanges === null" variant="success" class="m-0">
<icon iname="thumbs-up" /> {{ $t('domain.dns.auto_config_ok') }}
</b-alert>
<read-only-alert-item
v-else-if="dnsChanges === null"
:label="$t('domain.dns.auto_config_ok')"
type="success"
icon="thumbs-up"
/>
<!-- CONFIG ERROR ALERT -->
<template v-if="dnsErrors && dnsErrors.length">
<b-alert
<read-only-alert-item
v-for="({ variant, icon, message }, i) in dnsErrors" :key="i"
:variant="variant" :class="dnsErrors.length === 1 ? 'm-0' : ''"
>
<icon :iname="icon" /> <span v-html="message" />
</b-alert>
:label="message"
:type="variant"
:icon="icon"
/>
</template>
<!-- CONFIG OVERWRITE DISCLAIMER -->
<b-alert v-if="force !== null" variant="warning">
<icon iname="warning" /> <span v-html="$t('domain.dns.push_force_warning')" />
</b-alert>
<read-only-alert-item
v-if="force !== null"
:label="$t('domain.dns.push_force_warning')"
type="warning"
/>
<!-- CONFIG PUSH SUBMIT -->
<template v-if="dnsChanges" #buttons>
<template v-if="dnsChanges">
<b-form-checkbox v-if="force !== null" v-model="force">
{{ $t('domain.dns.push_force') }}
</b-form-checkbox>
@ -61,13 +72,14 @@
{{ $t('domain.dns.push') }}
</b-button>
</template>
</card>
</section>
<!-- CURRENT DNS ZONE -->
<card
v-if="showAutoConfigCard && dnsZone && dnsZone.length"
:title="$t('domain.dns.auto_config_zone')" icon="globe" no-body
>
<section v-if="showAutoConfigCard && dnsZone && dnsZone.length" class="panel-section">
<b-card-title title-tag="h3">
{{ $t('domain.dns.auto_config_zone') }}
</b-card-title>
<div class="log">
<div v-for="({ name: record, spaces, content, type }, i) in dnsZone" :key="'zone-' + i" class="records">
{{ record }}
@ -75,19 +87,21 @@
<span>{{ content }}</span>
</div>
</div>
</card>
</section>
<!-- MANUAL CONFIG CARD -->
<card
v-if="showManualConfigCard"
:title="$t('domain.dns.manual_config')" icon="globe" no-body
>
<b-alert variant="warning" class="m-0">
<icon iname="warning" /> {{ $t('domain_dns_conf_is_just_a_recommendation') }}
</b-alert>
<section v-if="showManualConfigCard" class="panel-section">
<b-card-title title-tag="h3">
{{ $t('domain.dns.manual_config') }}
</b-card-title>
<read-only-alert-item
:label="$t('domain_dns_conf_is_just_a_recommendation')"
type="warning"
/>
<pre class="log">{{ dnsConfig }}</pre>
</card>
</section>
</view-base>
</template>

View file

@ -1,76 +1,160 @@
<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">
<icon iname="external-link" /> {{ $t('domain_visit') }}
</b-button>
<hr>
<view-base
:queries="queries" @queries-response="onQueriesResponse"
ref="view" skeleton="card-list-skeleton"
>
<!-- INFO CARD -->
<card v-if="domain" :title="name" icon="globe">
<template v-if="isMainDomain" #header-next>
<b-badge variant="info" class="main-domain-badge">
<explain-what
id="explain-main-domain"
:title="$t('domain.types.main_domain')"
:content="$t('domain.explain.main_domain', { domain: name })"
>
<icon iname="star" /> {{ $t('domain.types.main_domain') }}
</explain-what>
</b-badge>
</template>
<!-- DEFAULT DOMAIN -->
<p>{{ $t('domain_default_desc') }}</p>
<p v-if="isMainDomain" class="alert alert-info">
<icon iname="star" /> {{ $t('domain_default_longdesc') }}
</p>
<b-button v-else variant="info" @click="setAsDefaultDomain">
<icon iname="star" /> {{ $t('set_default') }}
</b-button>
<hr>
<template #header-buttons>
<!-- DEFAULT DOMAIN -->
<b-button v-if="!isMainDomain" @click="setAsDefaultDomain" variant="info">
<icon iname="star" /> {{ $t('set_default') }}
</b-button>
<!-- DOMAIN CONFIG -->
<p>{{ $t('domain.config.edit') }}</p>
<b-button variant="warning" :to="{ name: 'domain-config', param: { name } }">
<icon iname="cog" /> {{ $t('domain.config.title') }}
</b-button>
<hr>
<!-- DELETE DOMAIN -->
<b-button @click="deleteDomain" :disabled="isMainDomain" variant="danger">
<icon iname="trash-o" /> {{ $t('delete') }}
</b-button>
</template>
<!-- DNS CONFIG -->
<p>{{ $t('domain.dns.edit') }}</p>
<b-button variant="warning" :to="{ name: 'domain-dns', param: { name } }">
<icon iname="globe" /> {{ $t('domain_dns_config') }}
</b-button>
<hr>
<!-- DOMAIN LINK -->
<description-row :term="$t('words.link')">
<b-link :href="'https://' + name" target="_blank">
https://{{ name }}
</b-link>
</description-row>
<!-- DELETE -->
<p>{{ $t('domain_delete_longdesc') }}</p>
<p
v-if="isMainDomain" class="alert alert-info"
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>
<!-- DOMAIN CERT AUTHORITY -->
<description-row :term="$t('domain.info.certificate_authority')">
<icon :iname="cert.icon" :variant="cert.variant" class="mr-1" />
{{ $t('domain.cert.types.' + cert.authority) }}
<span class="text-secondary px-2">({{ $t('domain.cert.valid_for', { days: $tc('day_validity', cert.validity) }) }})</span>
</description-row>
<!-- DOMAIN REGISTRAR -->
<description-row v-if="domain.registrar" :term="$t('domain.info.registrar')">
<template v-if="domain.registrar === 'parent_domain'">
{{ $t('domain.see_parent_domain') }}&nbsp;<b-link :href="`#/domains/${domain.topest_parent}/dns`">
{{ domain.topest_parent }}
</b-link>
</template>
<template v-else>
{{ domain.registrar }}
</template>
</description-row>
<!-- DOMAIN APPS -->
<description-row :term="$t('domain.info.apps_on_domain')">
<b-button-group
v-for="app in domain.apps" :key="app.id"
size="sm" class="mr-2"
>
<b-button class="py-0 font-weight-bold" variant="outline-dark" :to="{ name: 'app-info', params: { id: app.id }}">
{{ app.name }}
</b-button>
<b-button
variant="outline-dark" class="py-0 px-1"
:href="'https://' + name + app.path" target="_blank"
>
<span class="sr-only">{{ $t('app.visit_app') }}</span>
<icon iname="external-link" />
</b-button>
</b-button-group>
{{ domain.apps.length ? '' : $t('words.none') }}
</description-row>
</card>
<config-panels v-if="config.panels" v-bind="config" @submit="onConfigSubmit">
<template v-if="currentTab === 'dns'" #tab-after>
<domain-dns :name="name" />
</template>
</config-panels>
</view-base>
</template>
<script>
import { mapGetters } from 'vuex'
import api from '@/api'
import api, { objectToParams } from '@/api'
import {
formatFormData,
formatYunoHostConfigPanels
} from '@/helpers/yunohostArguments'
import ConfigPanels from '@/components/ConfigPanels'
import DomainDns from './DomainDns.vue'
export default {
name: 'DomainInfo',
props: {
name: {
type: String,
required: true
}
components: {
ConfigPanels,
DomainDns
},
data: () => {
props: {
name: { type: String, required: true }
},
data () {
return {
queries: [
['GET', { uri: 'domains/main', storeKey: 'main_domain' }]
]
['GET', { uri: 'domains', storeKey: 'domains' }],
['GET', { uri: 'domains/main', storeKey: 'main_domain' }],
['GET', { uri: 'domains', storeKey: 'domains_details', param: this.name }],
['GET', `domains/${this.name}/config?full`]
],
config: {}
}
},
computed: {
...mapGetters(['mainDomain']),
currentTab () {
return this.$route.params.tabId
},
domain () {
return this.$store.getters.domain(this.name)
},
parentName () {
return this.$store.getters.highestDomainParentName(this.name)
},
cert () {
const { CA_type: authority, validity } = this.domain.certificate
const baseInfos = { authority, validity }
if (validity <= 0) {
return { icon: 'times', variant: 'danger', ...baseInfos }
} else if (authority === 'other') {
return validity < 15
? { icon: 'exclamation', variant: 'danger', ...baseInfos }
: { icon: 'check', variant: 'success', ...baseInfos }
} else if (authority === 'letsencrypt') {
return { icon: 'thumbs-up', variant: 'success', ...baseInfos }
}
return { icon: 'exclamation', variant: 'warning', ...baseInfos }
},
dns () {
return this.domain.dns
},
isMainDomain () {
if (!this.mainDomain) return
return this.name === this.mainDomain
@ -78,6 +162,30 @@ export default {
},
methods: {
onQueriesResponse (domains, mainDomain, domain, config) {
this.config = formatYunoHostConfigPanels(config)
},
async onConfigSubmit ({ id, form, action, name }) {
const args = await formatFormData(form, { removeEmpty: false, removeNull: true })
api.put(
action
? `domain/${this.name}/actions/${action}`
: `domains/${this.name}/config/${id}`,
{ args: objectToParams(args) },
{ key: `domains.${action ? 'action' : 'update'}_config`, id, name: this.name }
).then(() => {
this.$refs.view.fetchQueries({ triggerLoading: true })
}).catch(err => {
if (err.name !== 'APIBadRequestError') throw err
const panel = this.config.panels.find(panel => panel.id === id)
if (err.data.name) {
this.config.errors[id][err.data.name].message = err.message
} else this.$set(panel, 'serverError', err.message)
})
},
async deleteDomain () {
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_delete', { name: this.name }))
if (!confirmed) return
@ -105,3 +213,10 @@ export default {
}
}
</script>
<style lang="scss" scoped>
.main-domain-badge {
font-size: .75rem;
padding-right: .2em;
}
</style>

View file

@ -3,9 +3,10 @@
id="domain-list"
:search.sync="search"
:items="domains"
:filtered-items="filteredDomains"
items-name="domains"
:queries="queries"
:filtered-items="hasFilteredItems"
@queries-response="onQueriesResponse"
>
<template #top-bar-buttons>
<b-button variant="success" :to="{ name: 'domain-add' }">
@ -14,57 +15,75 @@
</b-button>
</template>
<b-list-group>
<b-list-group-item
v-for="domain in filteredDomains" :key="domain"
:to="{ name: 'domain-info', params: { name: domain }}"
class="d-flex justify-content-between align-items-center pr-0"
>
<div>
<h5 class="font-weight-bold">
{{ domain }}
<small v-if="domain === mainDomain">
<span class="sr-only">{{ $t('words.default') }}</span>
<icon iname="star" :title="$t('words.default')" />
<recursive-list-group :tree="tree" :toggle-text="$t('domain.toggle_subdomains')" class="mb-5">
<template #default="{ data, parent }">
<div class="w-100 d-flex justify-content-between align-items-center">
<h5 class="mr-3">
<b-link :to="data.to" class="text-body text-decoration-none">
<span class="font-weight-bold">{{ data.name.replace(parent ? parent.data.name : null, '') }}</span>
<span v-if="parent" class="text-secondary">{{ parent.data.name }}</span>
</b-link>
<small
v-if="data.name === mainDomain"
:title="$t('domain.types.main_domain')" class="ml-1"
v-b-tooltip.hover
>
<icon iname="star" />
</small>
</h5>
<p class="font-italic m-0">
https://{{ domain }}
</p>
</div>
<icon iname="chevron-right" class="lg fs-sm ml-auto" />
</b-list-group-item>
</b-list-group>
</template>
</recursive-list-group>
</view-search>
</template>
<script>
import { mapGetters } from 'vuex'
import RecursiveListGroup from '@/components/RecursiveListGroup'
export default {
name: 'DomainList',
components: {
RecursiveListGroup
},
data () {
return {
queries: [
['GET', { uri: 'domains/main', storeKey: 'main_domain' }],
['GET', { uri: 'domains' }]
['GET', { uri: 'domains', storeKey: 'domains' }]
],
search: ''
search: '',
domainsTree: undefined
}
},
computed: {
...mapGetters(['domains', 'mainDomain']),
filteredDomains () {
if (!this.domains || !this.mainDomain) return
const search = this.search.toLowerCase()
const mainDomain = this.mainDomain
const domains = this.domains
.filter(name => name.toLowerCase().includes(search))
.sort(prevDomain => prevDomain === mainDomain ? -1 : 1)
return domains.length ? domains : null
tree () {
if (!this.domainsTree) return
if (this.search) {
const search = this.search.toLowerCase()
return this.domainsTree.filter(node => node.data.name.includes(search))
}
return this.domainsTree
},
hasFilteredItems () {
if (!this.tree) return
return this.tree.children || null
}
},
methods: {
onQueriesResponse () {
// Add the tree to `data` to make it reactive
this.domainsTree = this.$store.getters.domainsTree
}
}
}