diff options
-rw-r--r-- | apps/settings/src/app-types.ts | 23 | ||||
-rw-r--r-- | apps/settings/src/components/AppStoreSidebar/AppDeployOptionsModal.vue | 322 | ||||
-rw-r--r-- | apps/settings/src/components/AppStoreSidebar/AppDetailsTab.vue | 20 | ||||
-rw-r--r-- | apps/settings/src/mixins/AppManagement.js | 4 | ||||
-rw-r--r-- | apps/settings/src/store/app-api-store.ts | 17 |
5 files changed, 380 insertions, 6 deletions
diff --git a/apps/settings/src/app-types.ts b/apps/settings/src/app-types.ts index 9bba3ee6d50..49f0d5a1709 100644 --- a/apps/settings/src/app-types.ts +++ b/apps/settings/src/app-types.ts @@ -87,8 +87,31 @@ export interface IExAppStatus { type: string } +export interface IDeployEnv { + envName: string + displayName: string + description: string + default?: string +} + +export interface IDeployMount { + hostPath: string + containerPath: string + readOnly: boolean +} + +export interface IDeployOptions { + environment_variables: IDeployEnv[] + mounts: IDeployMount[] +} + +export interface IAppstoreExAppRelease extends IAppstoreAppRelease { + environmentVariables?: IDeployEnv[] +} + export interface IAppstoreExApp extends IAppstoreApp { daemon: IDeployDaemon | null | undefined status: IExAppStatus | Record<string, never> error: string + releases: IAppstoreExAppRelease[] } diff --git a/apps/settings/src/components/AppStoreSidebar/AppDeployOptionsModal.vue b/apps/settings/src/components/AppStoreSidebar/AppDeployOptionsModal.vue new file mode 100644 index 00000000000..d1b57a757d8 --- /dev/null +++ b/apps/settings/src/components/AppStoreSidebar/AppDeployOptionsModal.vue @@ -0,0 +1,322 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <NcModal v-if="show" + label-id="form-name" + @close="() => $emit('update:show', false)"> + <div class="modal__content"> + <h2 id="form-name"> + {{ t('settings', 'Advanced deploy options') }} + </h2> + <p class="description" style="text-align: center;"> + {{ configuredDeployOptions === null ? t('settings', 'Edit ExApp deploy options before installation') : t('settings', 'Configured ExApp deploy options. Can be set only during installation') }}. + <a href="https://docs.nextcloud.com/server/latest/admin_manual/exapps_management/AdvancedDeployOptions.html">Learn more</a> + </p> + + <h3 v-if="environmentVariables.length > 0 || (configuredDeployOptions !== null && configuredDeployOptions.environment_variables.length > 0)"> + {{ t('settings', 'Environment variables') }} + </h3> + <template v-if="configuredDeployOptions === null"> + <div v-for="envVar in environmentVariables" + :key="envVar.envName" + class="deploy-option"> + <NcTextField :label="envVar.displayName" :value.sync="deployOptions.environment_variables[envVar.envName]" /> + <p class="description"> + {{ envVar.description }} + </p> + </div> + </template> + <template v-else-if="Object.keys(configuredDeployOptions).length > 0"> + <p class="description"> + {{ t('settings', 'ExApp container environment variables') }} + </p> + <ul class="envs"> + <li v-for="envVar in Object.keys(configuredDeployOptions.environment_variables)" :key="envVar"> + <NcTextField :label="configuredDeployOptions.environment_variables[envVar].displayName ?? envVar" + :value="configuredDeployOptions.environment_variables[envVar].value" + readonly /> + <p class="description"> + {{ configuredDeployOptions.environment_variables[envVar].description }} + </p> + </li> + </ul> + </template> + <template v-else> + <p class="description"> + {{ t('settings', 'No environment variables defined') }} + </p> + </template> + + <h3>{{ t('settings', 'Mounts') }}</h3> + <template v-if="configuredDeployOptions === null"> + <p class="description"> + {{ t('settings', 'Define host folder mounts to bind to the ExApp container') }} + </p> + <p class="warning"> + {{ t('settings', 'Must exist on the Deploy daemon host prior to installing the ExApp') }} + </p> + <div v-for="mount in deployOptions.mounts" + :key="mount.hostPath" + class="deploy-option" + style="display: flex; align-items: center; justify-content: space-between; flex-direction: row;"> + <NcTextField :label="t('settings', 'Host path')" :value.sync="mount.hostPath" /> + <NcTextField :label="t('settings', 'Container path')" :value.sync="mount.containerPath" /> + <NcCheckboxRadioSwitch :checked.sync="mount.readonly"> + {{ t('settings', 'Read-only') }} + </NcCheckboxRadioSwitch> + <NcButton :aria-label="t('settings', 'Remove mount')" + style="margin-top: 6px;" + @click="removeMount(mount)"> + <template #icon> + <NcIconSvgWrapper :path="mdiDelete" /> + </template> + </NcButton> + </div> + <div v-if="addingMount" class="deploy-option"> + <h4> + {{ t('settings', 'New mount') }} + </h4> + <div style="display: flex; align-items: center; justify-content: space-between; flex-direction: row;"> + <NcTextField ref="newMountHostPath" + :label="t('settings', 'Host path')" + :aria-label="t('settings', 'Enter path to host folder')" + :value.sync="newMountPoint.hostPath" /> + <NcTextField :label="t('settings', 'Container path')" + :aria-label="t('settings', 'Enter path to container folder')" + :value.sync="newMountPoint.containerPath" /> + <NcCheckboxRadioSwitch :checked.sync="newMountPoint.readonly" + :aria-label="t('settings', 'Toggle read-only mode')"> + {{ t('settings', 'Read-only') }} + </NcCheckboxRadioSwitch> + </div> + <div style="display: flex; align-items: center; margin-top: 4px;"> + <NcButton :aria-label="t('settings', 'Confirm adding new mount')" + @click="addMountPoint"> + <template #icon> + <NcIconSvgWrapper :path="mdiCheck" /> + </template> + {{ t('settings', 'Confirm') }} + </NcButton> + <NcButton :aria-label="t('settings', 'Cancel adding mount')" + style="margin-left: 4px;" + @click="cancelAddMountPoint"> + <template #icon> + <NcIconSvgWrapper :path="mdiClose" /> + </template> + {{ t('settings', 'Cancel') }} + </NcButton> + </div> + </div> + <NcButton v-if="!addingMount" + :aria-label="t('settings', 'Add mount')" + style="margin-top: 5px;" + @click="() => { + addingMount = true + $nextTick(() => { + this.$refs.newMountHostPath.focus() + }) + }"> + <template #icon> + <NcIconSvgWrapper :path="mdiPlus" /> + </template> + {{ t('settings', 'Add mount') }} + </NcButton> + </template> + <template v-else-if="configuredDeployOptions.mounts.length > 0"> + <p class="description"> + {{ t('settings', 'ExApp container mounts') }} + </p> + <div v-for="mount in configuredDeployOptions.mounts" + :key="mount.hostPath" + class="deploy-option" + style="display: flex; align-items: center; justify-content: space-between; flex-direction: row;"> + <NcTextField :label="t('settings', 'Host path')" :value.sync="mount.hostPath" readonly /> + <NcTextField :label="t('settings', 'Container path')" :value.sync="mount.containerPath" readonly /> + <NcCheckboxRadioSwitch :checked.sync="mount.readonly" disabled> + {{ t('settings', 'Read-only') }} + </NcCheckboxRadioSwitch> + </div> + </template> + <template v-else> + <p class="description"> + {{ t('settings', 'No mounts defined') }} + </p> + </template> + + <NcButton v-if="!app.active && (app.canInstall || app.isCompatible) && configuredDeployOptions === null" + :title="enableButtonTooltip" + :aria-label="enableButtonTooltip" + type="primary" + :disabled="!app.canInstall || installing || isLoading || !defaultDeployDaemonAccessible || isInitializing || isDeploying" + style="margin-top: 10px;" + @click.stop="() => { + enable(app.id, deployOptions) + $emit('update:show', false) + }"> + {{ enableButtonText }} + </NcButton> + </div> + </NcModal> +</template> + +<script> +import { computed, ref } from 'vue' + +import axios from '@nextcloud/axios' +import { generateUrl } from '@nextcloud/router' + +import NcModal from '@nextcloud/vue/dist/Components/NcModal.js' +import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' +import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' +import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' +import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' + +import { mdiPlus, mdiCheck, mdiClose, mdiDelete } from '@mdi/js' + +import { useAppApiStore } from '../../store/app-api-store.ts' +import { useAppsStore } from '../../store/apps-store.ts' + +import AppManagement from '../../mixins/AppManagement.js' + +export default { + name: 'AppDeployOptionsModal', + components: { + NcModal, + NcTextField, + NcButton, + NcCheckboxRadioSwitch, + NcIconSvgWrapper, + }, + mixins: [AppManagement], + props: { + app: { + type: Object, + required: true, + }, + show: { + type: Boolean, + required: true, + }, + }, + setup(props) { + // for AppManagement mixin + const store = useAppsStore() + const appApiStore = useAppApiStore() + + const environmentVariables = computed(() => { + if (props.app?.releases?.length === 1) { + return props.app?.releases[0]?.environmentVariables || [] + } + return [] + }) + + const deployOptions = ref({ + environment_variables: environmentVariables.value.reduce((acc, envVar) => { + acc[envVar.envName] = envVar.default || '' + return acc + }, {}), + mounts: [], + }) + + return { + environmentVariables, + deployOptions, + store, + appApiStore, + mdiPlus, + mdiCheck, + mdiClose, + mdiDelete, + } + }, + data() { + return { + addingMount: false, + newMountPoint: { + hostPath: '', + containerPath: '', + readonly: false, + }, + addingPortBinding: false, + configuredDeployOptions: null, + } + }, + watch: { + show(newShow) { + if (newShow) { + this.fetchExAppDeployOptions() + } else { + this.configuredDeployOptions = null + } + }, + }, + methods: { + addMountPoint() { + this.deployOptions.mounts.push(this.newMountPoint) + this.newMountPoint = { + hostPath: '', + containerPath: '', + readonly: false, + } + this.addingMount = false + }, + cancelAddMountPoint() { + this.newMountPoint = { + hostPath: '', + containerPath: '', + readonly: false, + } + this.addingMount = false + }, + removeMount(mountToRemove) { + this.deployOptions.mounts = this.deployOptions.mounts.filter(mount => mount !== mountToRemove) + }, + async fetchExAppDeployOptions() { + return axios.get(generateUrl(`/apps/app_api/apps/deploy-options/${this.app.id}`)) + .then(response => { + this.configuredDeployOptions = response.data + }) + .catch(() => { + this.configuredDeployOptions = null + }) + }, + }, +} +</script> + +<style scoped> +.modal__content { + margin: 40px; +} + +.modal__content h2 { + text-align: center; +} + +.deploy-option { + margin: calc(var(--default-grid-baseline) * 4) 0; + display: flex; + flex-direction: column; + align-items: flex-start; +} + +.envs { + width: 100%; + overflow: auto; + height: 100%; + max-height: 300px; + + li { + margin: 10px 0; + } +} + +.description { + margin-top: 4px; + font-size: 0.8em; + color: var(--color-text-lighter); +} +</style> diff --git a/apps/settings/src/components/AppStoreSidebar/AppDetailsTab.vue b/apps/settings/src/components/AppStoreSidebar/AppDetailsTab.vue index f3adbfd2a1c..3a21cad3851 100644 --- a/apps/settings/src/components/AppStoreSidebar/AppDetailsTab.vue +++ b/apps/settings/src/components/AppStoreSidebar/AppDetailsTab.vue @@ -77,6 +77,15 @@ :value="forceEnableButtonText" :disabled="installing || isLoading" @click="forceEnable(app.id)"> + <NcButton v-if="app?.app_api && (app.canInstall || app.isCompatible)" + :aria-label="t('settings', 'Advanced deploy options')" + type="secondary" + @click="() => showDeployOptionsModal = true"> + <template #icon> + <NcIconSvgWrapper :path="mdiToyBrickPlus" /> + </template> + {{ t('settings', 'Deploy options') }} + </NcButton> </div> <p v-if="!defaultDeployDaemonAccessible" class="warning"> {{ t('settings', 'Default Deploy daemon is not accessible') }} @@ -182,6 +191,10 @@ </NcButton> </div> </div> + + <AppDeployOptionsModal v-if="app?.app_api" + :show.sync="showDeployOptionsModal" + :app="app" /> </div> </NcAppSidebarTab> </template> @@ -193,9 +206,10 @@ import NcDateTime from '@nextcloud/vue/dist/Components/NcDateTime.js' import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js' import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' +import AppDeployOptionsModal from './AppDeployOptionsModal.vue' import AppManagement from '../../mixins/AppManagement.js' -import { mdiBug, mdiFeatureSearch, mdiStar, mdiTextBox, mdiTooltipQuestion } from '@mdi/js' +import { mdiBug, mdiFeatureSearch, mdiStar, mdiTextBox, mdiTooltipQuestion, mdiToyBrickPlus } from '@mdi/js' import { useAppsStore } from '../../store/apps-store' import { useAppApiStore } from '../../store/app-api-store' @@ -209,6 +223,7 @@ export default { NcIconSvgWrapper, NcSelect, NcCheckboxRadioSwitch, + AppDeployOptionsModal, }, mixins: [AppManagement], @@ -232,6 +247,7 @@ export default { mdiStar, mdiTextBox, mdiTooltipQuestion, + mdiToyBrickPlus, } }, @@ -239,6 +255,7 @@ export default { return { groupCheckedAppsData: false, removeData: false, + showDeployOptionsModal: false, } }, @@ -370,6 +387,7 @@ export default { &-manage { // if too many, shrink them and ellipsis display: flex; + align-items: center; input { flex: 0 1 auto; min-width: 0; diff --git a/apps/settings/src/mixins/AppManagement.js b/apps/settings/src/mixins/AppManagement.js index 893939bc264..55a702e4144 100644 --- a/apps/settings/src/mixins/AppManagement.js +++ b/apps/settings/src/mixins/AppManagement.js @@ -188,9 +188,9 @@ export default { .catch((error) => { showError(error) }) } }, - enable(appId) { + enable(appId, deployOptions = []) { if (this.app?.app_api) { - this.appApiStore.enableApp(appId) + this.appApiStore.enableApp(appId, deployOptions) .then(() => { rebuildNavigation() }) .catch((error) => { showError(error) }) } else { diff --git a/apps/settings/src/store/app-api-store.ts b/apps/settings/src/store/app-api-store.ts index ff7a56c6d03..f2f950d6948 100644 --- a/apps/settings/src/store/app-api-store.ts +++ b/apps/settings/src/store/app-api-store.ts @@ -14,7 +14,7 @@ import { defineStore } from 'pinia' import api from './api' import logger from '../logger' -import type { IAppstoreExApp, IDeployDaemon, IExAppStatus } from '../app-types' +import type { IAppstoreExApp, IDeployDaemon, IDeployOptions, IExAppStatus } from '../app-types.ts' import Vue from 'vue' interface AppApiState { @@ -76,12 +76,12 @@ export const useAppApiStore = defineStore('app-api-apps', { }) }, - enableApp(appId: string) { + enableApp(appId: string, deployOptions: IDeployOptions[] = []) { this.setLoading(appId, true) this.setLoading('install', true) return confirmPassword().then(() => { - return axios.post(generateUrl(`/apps/app_api/apps/enable/${appId}`)) + return axios.post(generateUrl(`/apps/app_api/apps/enable/${appId}`), { deployOptions }) .then((response) => { this.setLoading(appId, false) this.setLoading('install', false) @@ -132,6 +132,9 @@ export const useAppApiStore = defineStore('app-api-apps', { this.setError(appId, error.response.data.data.message) this.appsApiFailure({ appId, error }) }) + }).catch(() => { + this.setLoading(appId, false) + this.setLoading('install', false) }) }, @@ -150,6 +153,9 @@ export const useAppApiStore = defineStore('app-api-apps', { this.setError(appId, error.response.data.data.message) this.appsApiFailure({ appId, error }) }) + }).catch(() => { + this.setLoading(appId, false) + this.setLoading('install', false) }) }, @@ -173,6 +179,8 @@ export const useAppApiStore = defineStore('app-api-apps', { this.setLoading(appId, false) this.appsApiFailure({ appId, error }) }) + }).catch(() => { + this.setLoading(appId, false) }) }, @@ -237,6 +245,9 @@ export const useAppApiStore = defineStore('app-api-apps', { this.setLoading('install', false) this.appsApiFailure({ appId, error }) }) + }).catch(() => { + this.setLoading(appId, false) + this.setLoading('install', false) }) }, |