aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAndrey Borysenko <andrey18106x@gmail.com>2024-11-04 22:19:33 +0200
committerAndrey Borysenko <andrey18106x@gmail.com>2025-01-17 17:57:12 +0200
commitfc483fa548e9c8e31fb6b9d5d0cd62d0db4c7abf (patch)
tree38294d836cbab7a6bb79989f19f95256245baa06
parent326120a7f7713c43084f1eed5f2a1ab1ffd29004 (diff)
downloadnextcloud-server-feat/settings/advanced-deploy-options.tar.gz
nextcloud-server-feat/settings/advanced-deploy-options.zip
feat(app_api): Advanced deploy optionsfeat/settings/advanced-deploy-options
Signed-off-by: Andrey Borysenko <andrey18106x@gmail.com>
-rw-r--r--apps/settings/src/app-types.ts23
-rw-r--r--apps/settings/src/components/AppStoreSidebar/AppDeployOptionsModal.vue322
-rw-r--r--apps/settings/src/components/AppStoreSidebar/AppDetailsTab.vue20
-rw-r--r--apps/settings/src/mixins/AppManagement.js4
-rw-r--r--apps/settings/src/store/app-api-store.ts17
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)
})
},