Merge pull request #477 from YunoHost/enh-appv2

appv2: Reflect app manifest v2
This commit is contained in:
Alexandre Aubin 2022-10-18 20:01:30 +02:00 committed by GitHub
commit 8d150c2069
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 71 additions and 261 deletions

View file

@ -35,7 +35,7 @@ export default {
<style lang="scss" scoped>
.description-row {
@include media-breakpoint-up(md) {
margin: .5rem 0;
margin: .25rem 0;
&:hover {
background-color: rgba(0, 0, 0, 0.05);
border-radius: 0.2rem;
@ -54,6 +54,7 @@ export default {
.col {
display: flex;
align-self: start;
}
}
</style>

View file

@ -166,7 +166,7 @@ export function formatYunoHostArgument (arg) {
}
},
{
types: ['select', 'user', 'domain', 'app'],
types: ['select', 'user', 'domain', 'app', 'group'],
name: 'SelectItem',
props: ['id:name', 'choices'],
callback: function () {

View file

@ -54,8 +54,6 @@
"api_not_found": "Seems like the web-admin tried to query something that doesn't exist.",
"api_not_responding": "The YunoHost API is not responding. Maybe 'yunohost-api' is down or got restarted?",
"api_waiting": "Waiting for the server's response...",
"app_actions": "Actions",
"app_actions_label": "Perform actions",
"app_choose_category": "Choose a category",
"app_config_panel": "Config panel",
"app_config_panel_label": "Configure this app",
@ -69,18 +67,14 @@
"app_install_parameters": "Install settings",
"app_manage_label_and_tiles": "Manage label and tiles",
"app_make_default": "Make default",
"app_no_actions": "This application doesn't have any actions",
"app_show_categories": "Show categories",
"app_state_broken": "broken",
"app_state_broken_explanation": "This application is currently broken and not installable according to YunoHost's automatic tests",
"app_state_inprogress": "not yet working",
"app_state_inprogress_explanation": "This maintainer of this app declared that this application is not ready yet for production use. BE CAREFUL!",
"app_state_notworking": "not working",
"app_state_notworking_explanation": "This maintainer of this app declared it as 'not working'. IT WILL BREAK YOUR SYSTEM!",
"app_state_lowquality": "low quality",
"app_state_lowquality_explanation": "This app may be functional, but may still contain issues, or is not fully integrated with YunoHost, or it does not respect the good practices.",
"app_state_highquality": "high quality",
"app_state_highquality_explanation": "This app is well-integrated with YunoHost since at least a year.",
"app_state_working": "working",
"app_state_working_explanation": "The maintainer of this app declared it as 'working'. It means that it should be functional (c.f. application level) but is not necessarily peer-reviewed, it may still contain issues or is not fully integrated with YunoHost.",
"applications": "Applications",
"archive_empty": "Empty archive",
"backup": "Backup",
@ -110,6 +104,7 @@
"confirm_install_custom_app": "WARNING! Installing 3rd party applications may compromise the integrity and security of your system. You should probably NOT install it unless you know what you are doing. Are you willing to take that risk?",
"confirm_install_domain_root": "Are you sure you want to install this application on '/'? You will not be able to install any other app on {domain}",
"confirm_app_install": "Are you sure you want to install this application?",
"confirm_install_app_broken": "WARNING! This application is broken according to YunoHost's automatic tests and it is likely to break your system! You should probably NOT install it unless you know what you are doing. Are you willing to take that risk?",
"confirm_install_app_lowquality": "Warning: this application may work but is not well-integrated in YunoHost. Some features such as single sign-on and backup/restore might not be available.",
"confirm_install_app_inprogress": "WARNING! This application is still experimental (if not explicitly not working) and it is likely to break your system! You should probably NOT install it unless you know what you are doing. Are you willing to take that risk?",
"confirm_migrations_skip": "Skipping migrations is not recommended. Are you sure you want to do that?",
@ -317,7 +312,8 @@
"items_verbose_count": "There are {items}. | There is 1 {items}. | There are {items}.",
"items_verbose_items_left": "There are {items} left. | There is 1 {items} left. | There are {items} left.",
"label": "Label",
"label_for_manifestname": "Label for {name} (name displayed in the user portal)",
"label_for_manifestname": "Label for {name}",
"label_for_manifestname_help": "This is the name displayed in the user portal. This can be changed later.",
"last_ran": "Last time ran:",
"license": "License",
"local_archives": "Local archives",
@ -329,6 +325,7 @@
"manage_apps": "Manage apps",
"manage_domains": "Manage domains",
"manage_users": "Manage users",
"manage_groups": "Manage groups",
"migrations": "Migrations",
"migrations_pending": "Pending migrations",
"migrations_done": "Previous migrations",
@ -414,7 +411,6 @@
"change_url": "Change access URL of '{name}'",
"install": "Install app '{name}'",
"set_default": "Redirect '{domain}' domain root to '{name}'",
"perform_action": "Perform action '{action}' of app '{name}'",
"uninstall": "Uninstall app '{name}'",
"update_config": "Update panel '{id}' of app '{name}' configuration"
},
@ -539,8 +535,6 @@
"unignore": "Unignore",
"uninstall": "Uninstall",
"unknown": "Unknown",
"unmaintained": "Unmaintained",
"unmaintained_details": "This app has not been updated for quite a while and the previous maintainer has gone away or does not have time to maintain this app. Feel free to check the app repository to provide your help",
"upnp": "UPnP",
"upnp_disabled": "UPnP is disabled.",
"upnp_enabled": "UPnP is enabled.",

View file

@ -210,16 +210,6 @@ const routes = [
breadcrumb: ['app-list', 'app-info']
}
},
{
name: 'app-actions',
path: '/apps/:id/actions',
component: () => import(/* webpackChunkName: "views/apps/actions" */ '@/views/app/AppActions'),
props: true,
meta: {
args: { trad: 'app_actions' },
breadcrumb: ['app-list', 'app-info', 'app-actions']
}
},
{
// no need for name here, only children are visited
path: '/apps/:id/config-panel',

View file

@ -1,106 +0,0 @@
<template>
<view-base
:queries="queries" @queries-response="onQueriesResponse"
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>
<!-- ACTIONS FORMS -->
<card-form
v-for="(action, i) in actions" :key="i"
:title="action.name" icon="wrench" title-tag="h4"
:validation="$v.actions[i]" :id="action.id + '-form'" :server-error="action.serverError"
@submit.prevent="performAction(action)" :submit-text="$t('perform')"
>
<form-field
v-for="(field, fname) in action.fields" :key="fname" label-cols="0"
v-bind="field" v-model="action.form[fname]" :validation="$v.actions[i][fname]"
/>
</card-form>
</template>
<!-- In case of a custom url with no manifest found -->
<b-alert v-else-if="actions === null" variant="warning">
<icon iname="exclamation-triangle" /> {{ $t('app_no_actions') }}
</b-alert>
</view-base>
</template>
<script>
import api, { objectToParams } from '@/api'
import { validationMixin } from 'vuelidate'
import { formatI18nField, formatYunoHostArguments, formatFormData } from '@/helpers/yunohostArguments'
export default {
name: 'AppActions',
mixins: [validationMixin],
props: {
id: { type: String, required: true }
},
data () {
return {
queries: [
['GET', `apps/${this.id}/actions`],
['GET', { uri: 'domains' }],
['GET', { uri: 'domains/main', storeKey: 'main_domain' }],
['GET', { uri: 'users' }]
],
actions: undefined
}
},
validations () {
const validations = {}
for (const [i, action] of this.actions.entries()) {
if (action.validations) {
validations[i] = { form: action.validations }
}
}
return { actions: validations }
},
methods: {
onQueriesResponse (data) {
if (!data.actions) {
this.actions = null
return
}
this.actions = data.actions.map(({ name, id, description, arguments: arguments_ }) => {
const action = { name, id, serverError: '' }
if (description) action.description = formatI18nField(description)
if (arguments_ && arguments_.length) {
const { form, fields, validations } = formatYunoHostArguments(arguments_)
action.form = form
action.fields = fields
if (validations) action.validations = validations
}
return action
})
},
performAction (action) {
// FIXME api expects at least one argument ?! (fake one given with { dontmindthis } )
const args = objectToParams(action.form ? formatFormData(action.form) : { dontmindthis: undefined })
api.put(
`apps/${this.id}/actions/${action.id}`,
{ args },
{ key: 'apps.perform_action', action: action.id, name: this.id }
).then(() => {
this.$refs.view.fetchQueries()
}).catch(err => {
if (err.name !== 'APIBadRequestError') throw err
action.serverError = err.message
})
}
}
}
</script>

View file

@ -70,27 +70,28 @@
<b-card-title class="d-flex mb-2">
{{ app.manifest.name }}
<small v-if="app.state !== 'working'" class="d-flex align-items-center ml-2">
<small v-if="app.state !== 'working' || app.high_quality" class="d-flex align-items-center ml-2">
<b-badge
v-if="app.state !== 'highquality'"
:variant="(app.color === 'danger' && app.state === 'lowquality') ? 'warning' : app.color"
v-if="app.state !== 'working'"
:variant="app.color"
v-b-popover.hover.bottom="$t(`app_state_${app.state}_explanation`)"
>
<!-- app.state can be 'lowquality' or 'inprogress' -->
{{ $t('app_state_' + app.state) }}
</b-badge>
<icon
v-else iname="star" class="star"
v-b-popover.hover.bottom="$t(`app_state_${app.state}_explanation`)"
v-if="app.high_quality" iname="star" class="star"
v-b-popover.hover.bottom="$t(`app_state_highquality_explanation`)"
/>
</small>
</b-card-title>
<b-card-text>{{ app.manifest.description }}</b-card-text>
<b-card-text v-if="app.maintained === 'orphaned'" class="align-self-end mt-auto">
<b-card-text v-if="!app.maintained" class="align-self-end mt-auto">
<span class="alert-warning p-1" v-b-popover.hover.top="$t('orphaned_details')">
<icon iname="warning" /> {{ $t(app.maintained) }}
<icon iname="warning" /> {{ $t('orphaned') }}
</span>
</b-card-text>
</b-card-body>
@ -182,9 +183,9 @@ export default {
// 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: 'high_quality', text: this.$i18n.t('only_highquality_apps') },
{ value: 'decent_quality', text: this.$i18n.t('only_decent_quality_apps') },
{ value: 'working', text: this.$i18n.t('only_working_apps') },
{ value: 'all', text: this.$i18n.t('all_apps') }
],
categories: [
@ -197,7 +198,7 @@ export default {
search: '',
category: null,
subtag: 'all',
quality: 'isDecentQuality',
quality: 'decent_quality',
// Custom install form
customInstall: {
@ -264,51 +265,31 @@ export default {
},
methods: {
formatQuality (app) {
const filters = {
isHighQuality: false,
isDecentQuality: false,
isWorking: false,
state: 'inprogress'
}
if (app.state === 'inprogress') return filters
if (app.state === 'working' && app.level > 0) {
filters.state = 'working'
filters.isWorking = true
}
if (app.level <= 4 || app.level === '?') {
filters.state = 'lowquality'
return filters
} else {
filters.isDecentQuality = true
}
if (app.level >= 8) {
filters.state = 'highquality'
filters.isHighQuality = true
}
return filters
},
formatColor (app) {
if (app.isDecentQuality || app.isHighQuality) return 'success'
if (app.isWorking) return 'warning'
return 'danger'
},
onQueriesResponse (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.isInstallable = !app.installed || app.manifest.integration.multi_instance
app.working = app.state === 'working'
app.decent_quality = app.working && app.level > 4
app.high_quality = app.working && app.level >= 8
app.color = 'danger'
if (app.working && app.level <= 0) {
app.state = 'broken'
app.color = 'danger'
} else if (app.working && app.level <= 4) {
app.state = 'lowquality'
app.color = 'warning'
} else if (app.working) {
app.color = 'success'
}
app.color = this.formatColor(app)
app.searchValues = [app.id, app.state, app.manifest.name.toLowerCase(), app.manifest.description.toLowerCase()].join(' ')
app.searchValues = [
app.id,
app.state,
app.manifest.name,
app.manifest.description,
app.potential_alternative_to.join(' ')
].join(' ').toLowerCase()
apps.push(app)
}
this.apps = apps.sort((a, b) => a.id > b.id ? 1 : -1)
@ -328,10 +309,8 @@ export default {
// INSTALL APP
async onInstallClick (app) {
if (!app.isDecentQuality) {
// Ask for confirmation
const state = app.color === 'danger' ? 'inprogress' : app.state
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_install_app_' + state))
if (!app.decent_quality) {
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_install_app_' + app.state))
if (!confirmed) return
}
this.$router.push({ name: 'app-install', params: { id: app.id } })

View file

@ -2,33 +2,22 @@
<view-base :queries="queries" @queries-response="onQueriesResponse" ref="view">
<!-- BASIC INFOS -->
<card v-if="infos" :title="infos.label" icon="cube">
<b-row
v-for="(value, prop) in infos" :key="prop"
no-gutters class="row-line"
<description-row
v-for="(value, key) in infos" :key="key"
:term="$t(key)"
>
<b-col cols="auto" md="3">
<strong>{{ $t(prop) }}</strong>
</b-col>
<b-col>
<a v-if="prop === 'url'" :href="value" target="_blank">{{ value }}</a>
<span v-else>{{ value }}</span>
</b-col>
</b-row>
<b-row no-gutters class="row-line">
<b-col cols="auto" md="3">
<strong>{{ $t('app_info_access_desc') }}</strong>
<span class="sep" />
</b-col>
<b-col>
{{ allowedGroups.length > 0 ? allowedGroups.join(', ') + '.' : $t('nobody') }}
<b-button
size="sm" :to="{ name: 'group-list'}" variant="info"
class="ml-2"
>
<icon iname="key-modern" /> {{ $t('groups_and_permissions_manage') }}
</b-button>
</b-col>
</b-row>
<a v-if="key === 'url'" :href="value" target="_blank">{{ value }}</a>
<template v-else>{{ value }}</template>
</description-row>
<description-row :term="$t('app_info_access_desc')">
{{ allowedGroups.length > 0 ? allowedGroups.join(', ') + '.' : $t('nobody') }}
<b-button
size="sm" :to="{ name: 'group-list'}" variant="info"
class="ml-2"
>
<icon iname="key-modern" /> {{ $t('groups_and_permissions_manage') }}
</b-button>
</description-row>
</card>
<!-- OPERATIONS -->
@ -145,19 +134,6 @@
</b-form-group>
</card>
<!-- EXPERIMENTAL (displayed if experimental feature has been enabled in web-admin options)-->
<card v-if="experimental" :title="$t('experimental')" icon="flask">
<!-- APP ACTIONS -->
<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-form-group>
</card>
<template #skeleton>
<card-info-skeleton :item-count="8" />
<card-form-skeleton />
@ -195,7 +171,7 @@ export default {
},
computed: {
...mapGetters(['domains', 'experimental']),
...mapGetters(['domains']),
allowedGroups () {
if (!this.app) return
@ -243,7 +219,7 @@ export default {
label: mainPermission.label,
description: app.description,
version: app.version,
multi_instance: this.$i18n.t(app.manifest.multi_instance ? 'yes' : 'no'),
multi_instance: this.$i18n.t(app.manifest.integration.multi_instance ? 'yes' : 'no'),
install_time: readableDate(app.settings.install_time, true, true)
}
if (app.settings.domain && app.settings.path) {

View file

@ -3,18 +3,10 @@
<template v-if="infos">
<!-- BASIC INFOS -->
<card :title="name" icon="download">
<b-row
<description-row
v-for="(info, key) in infos" :key="key"
no-gutters class="row-line"
>
<b-col cols="5" md="3" xl="3">
<strong>{{ $t(key) }}</strong>
<span class="sep" />
</b-col>
<b-col>
<span>{{ info }}</span>
</b-col>
</b-row>
:term="$t(key)" :details="info"
/>
</card>
<!-- INSTALL FORM -->
@ -70,7 +62,6 @@ export default {
],
name: undefined,
infos: undefined,
formDisclaimer: null,
form: undefined,
fields: undefined,
validations: null,
@ -87,18 +78,20 @@ export default {
onQueriesResponse (manifest) {
this.name = manifest.name
const infosKeys = ['id', 'description', 'license', 'version', 'multi_instance']
manifest.license = manifest.upstream.license
if (manifest.license === undefined || manifest.license === 'free') {
infosKeys.splice(2, 1)
}
manifest.description = formatI18nField(manifest.description)
manifest.multi_instance = this.$i18n.t(manifest.multi_instance ? 'yes' : 'no')
manifest.multi_instance = this.$i18n.t(manifest.integration.multi_instance ? 'yes' : 'no')
this.infos = Object.fromEntries(infosKeys.map(key => [key, manifest[key]]))
// FIXME yunohost should add the label field by default
manifest.arguments.install.unshift({
manifest.install.unshift({
ask: this.$t('label_for_manifestname', { name: manifest.name }),
default: manifest.name,
name: 'label'
name: 'label',
help: this.$t('label_for_manifestname_help')
})
const {
@ -106,7 +99,7 @@ export default {
fields,
validations,
errors
} = formatYunoHostArguments(manifest.arguments.install)
} = formatYunoHostArguments(manifest.install)
this.fields = fields
this.form = form

View file

@ -68,25 +68,8 @@ export default {
return
}
const multiInstances = {}
this.apps = apps.map(({ id, name, description, permissions, manifest }) => {
// FIXME seems like some apps may no have a label (replace with id)
const label = permissions[id + '.main'].label
// Display the `id` of the instead of its `name` if multiple apps share the same name
if (manifest.multi_instance) {
if (!(name in multiInstances)) {
multiInstances[name] = []
}
const labels = multiInstances[name]
if (labels.includes(label)) {
name = id
}
labels.push(label)
}
if (label === name) {
name = null
}
return { id, name, description, label }
this.apps = apps.map(({ id, name, description, manifest }) => {
return { id, name: manifest.name, label: name, description }
}).sort((prev, app) => {
return prev.label > app.label ? 1 : -1
})