feat(app_api): Advanced deploy options

Signed-off-by: Andrey Borysenko <andrey18106x@gmail.com>
pull/50230/head
Andrey Borysenko 2024-11-04 22:19:33 +07:00
parent 5ba9ece039
commit 73c138b0f3
No known key found for this signature in database
GPG Key ID: 934CB29F9F59B0D1
5 changed files with 370 additions and 6 deletions

@ -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[]
}

@ -0,0 +1,312 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcDialog :open="show"
size="normal"
:name="t('settings', 'Advanced deploy options')"
@update:open="$emit('update:show', $event)">
<div class="modal__content">
<p class="deploy-option__hint">
{{ configuredDeployOptions === null ? t('settings', 'Edit ExApp deploy options before installation') : t('settings', 'Configured ExApp deploy options. Can be set only during installation') }}.
<a v-if="deployOptionsDocsUrl" :href="deployOptionsDocsUrl">
{{ t('settings', '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="deploy-option__hint">
{{ envVar.description }}
</p>
</div>
</template>
<fieldset v-else-if="Object.keys(configuredDeployOptions).length > 0"
class="envs">
<legend class="deploy-option__hint">
{{ t('settings', 'ExApp container environment variables') }}
</legend>
<NcTextField v-for="(value, key) in configuredDeployOptions.environment_variables"
:key="key"
:label="value.displayName ?? key"
:helper-text="value.description"
:value="value.value"
readonly />
</fieldset>
<template v-else>
<p class="deploy-option__hint">
{{ t('settings', 'No environment variables defined') }}
</p>
</template>
<h3>{{ t('settings', 'Mounts') }}</h3>
<template v-if="configuredDeployOptions === null">
<p class="deploy-option__hint">
{{ t('settings', 'Define host folder mounts to bind to the ExApp container') }}
</p>
<NcNoteCard type="info" :text="t('settings', 'Must exist on the Deploy daemon host prior to installing the ExApp')" />
<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="startAddingMount">
<template #icon>
<NcIconSvgWrapper :path="mdiPlus" />
</template>
{{ t('settings', 'Add mount') }}
</NcButton>
</template>
<template v-else-if="configuredDeployOptions.mounts.length > 0">
<p class="deploy-option__hint">
{{ 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>
<p v-else class="deploy-option__hint">
{{ t('settings', 'No mounts defined') }}
</p>
</div>
<template v-if="!app.active && (app.canInstall || app.isCompatible) && configuredDeployOptions === null" #actions>
<NcButton :title="enableButtonTooltip"
:aria-label="enableButtonTooltip"
type="primary"
:disabled="!app.canInstall || installing || isLoading || !defaultDeployDaemonAccessible || isInitializing || isDeploying"
@click.stop="submitDeployOptions">
{{ enableButtonText }}
</NcButton>
</template>
</NcDialog>
</template>
<script>
import { computed, ref } from 'vue'
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
import { loadState } from '@nextcloud/initial-state'
import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js'
import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.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: {
NcDialog,
NcTextField,
NcButton,
NcNoteCard,
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,
deployOptionsDocsUrl: loadState('settings', 'deployOptionsDocsUrl', null),
}
},
watch: {
show(newShow) {
if (newShow) {
this.fetchExAppDeployOptions()
} else {
this.configuredDeployOptions = null
}
},
},
methods: {
startAddingMount() {
this.addingMount = true
this.$nextTick(() => {
this.$refs.newMountHostPath.focus()
})
},
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
})
},
submitDeployOptions() {
this.enable(this.app.id, this.deployOptions)
this.$emit('update:show', false)
},
},
}
</script>
<style scoped>
.deploy-option {
margin: calc(var(--default-grid-baseline) * 4) 0;
display: flex;
flex-direction: column;
align-items: flex-start;
&__hint {
margin-top: 4px;
font-size: 0.8em;
color: var(--color-text-maxcontrast);
}
}
.envs {
width: 100%;
overflow: auto;
height: 100%;
max-height: 300px;
li {
margin: 10px 0;
}
}
</style>

@ -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;

@ -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 {

@ -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)
})
},