From bd9a7d53fce623fa1054451d739e2fa2d05d0cc3 Mon Sep 17 00:00:00 2001 From: Andrey Borysenko Date: Wed, 9 Oct 2024 21:02:29 +0300 Subject: [PATCH 01/14] feat(app_api): add initial state data for AppAPI UI part Signed-off-by: Andrey Borysenko # Conflicts: # apps/settings/lib/Controller/AppSettingsController.php --- .../lib/Controller/AppSettingsController.php | 54 ++++++++++++------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/apps/settings/lib/Controller/AppSettingsController.php b/apps/settings/lib/Controller/AppSettingsController.php index 60c796adfb4f4..29b5d2e11b018 100644 --- a/apps/settings/lib/Controller/AppSettingsController.php +++ b/apps/settings/lib/Controller/AppSettingsController.php @@ -43,6 +43,7 @@ use OCP\IRequest; use OCP\IURLGenerator; use OCP\L10N\IFactory; +use OCP\Server; use OCP\Util; use Psr\Log\LoggerInterface; @@ -89,6 +90,8 @@ public function viewApps(): TemplateResponse { $this->initialState->provideInitialState('appstoreDeveloperDocs', $this->urlGenerator->linkToDocs('developer-manual')); $this->initialState->provideInitialState('appstoreUpdateCount', count($this->getAppsWithUpdates())); + $this->provideAppApiState(); + $policy = new ContentSecurityPolicy(); $policy->addAllowedImageDomain('https://usercontent.apps.nextcloud.com'); @@ -101,6 +104,37 @@ public function viewApps(): TemplateResponse { return $templateResponse; } + private function provideAppApiState(): void { + $appApiEnabled = $this->appManager->isInstalled('app_api'); + $this->initialState->provideInitialState('appApiEnabled', $appApiEnabled); + $daemonConfigAccessible = false; + $defaultDaemonConfig = null; + + if ($appApiEnabled) { + $exAppFetcher = Server::get(\OCA\AppAPI\Fetcher\ExAppFetcher::class); + $this->initialState->provideInitialState('appstoreExAppUpdateCount', count($exAppFetcher->getExAppsWithUpdates())); + + $defaultDaemonConfigName = $this->config->getAppValue('app_api', 'default_daemon_config'); + if ($defaultDaemonConfigName !== '') { + $daemonConfigService = Server::get(\OCA\AppAPI\Service\DaemonConfigService::class); + $daemonConfig = $daemonConfigService->getDaemonConfigByName($defaultDaemonConfigName); + if ($daemonConfig !== null) { + $defaultDaemonConfig = $daemonConfig->jsonSerialize(); + unset($defaultDaemonConfig['deploy_config']['haproxy_password']); + $dockerActions = Server::get(\OCA\AppAPI\DeployActions\DockerActions::class); + $dockerActions->initGuzzleClient($daemonConfig); + $daemonConfigAccessible = $dockerActions->ping($dockerActions->buildDockerUrl($daemonConfig)); + if (!$daemonConfigAccessible) { + $this->logger->warning(sprintf('Deploy daemon "%s" is not accessible by Nextcloud. Please verify its configuration', $daemonConfig->getName())); + } + } + } + } + + $this->initialState->provideInitialState('defaultDaemonConfigAccessible', $daemonConfigAccessible); + $this->initialState->provideInitialState('defaultDaemonConfig', $defaultDaemonConfig); + } + /** * Get all active entries for the app discover section */ @@ -230,25 +264,11 @@ private function getAllCategories() { ], $categories); } - /** - * Convert URL to proxied URL so CSP is no problem - */ - private function createProxyPreviewUrl(string $url): string { - if ($url === '') { - return ''; - } - return 'https://usercontent.apps.nextcloud.com/' . base64_encode($url); - } - private function fetchApps() { $appClass = new \OC_App(); $apps = $appClass->listAllApps(); foreach ($apps as $app) { $app['installed'] = true; - // locally installed apps have a flatted screenshot property - if (isset($app['screenshot'][0])) { - $app['screenshot'] = $this->createProxyPreviewUrl($app['screenshot'][0]); - } $this->allApps[$app['id']] = $app; } @@ -307,7 +327,7 @@ public function listApps(): JSONResponse { $apps = array_map(function (array $appData) use ($dependencyAnalyzer, $ignoreMaxApps) { if (isset($appData['appstoreData'])) { $appstoreData = $appData['appstoreData']; - $appData['screenshot'] = $this->createProxyPreviewUrl($appstoreData['screenshots'][0]['url'] ?? ''); + $appData['screenshot'] = isset($appstoreData['screenshots'][0]['url']) ? 'https://usercontent.apps.nextcloud.com/' . base64_encode($appstoreData['screenshots'][0]['url']) : ''; $appData['category'] = $appstoreData['categories']; $appData['releases'] = $appstoreData['releases']; } @@ -321,10 +341,6 @@ public function listApps(): JSONResponse { $groups = []; if (is_string($appData['groups'])) { $groups = json_decode($appData['groups']); - // ensure 'groups' is an array - if (!is_array($groups)) { - $groups = [$groups]; - } } $appData['groups'] = $groups; $appData['canUnInstall'] = !$appData['active'] && $appData['removable']; From 8b8636abcee513e180d1ebb2a32eabfcb445df8d Mon Sep 17 00:00:00 2001 From: Andrey Borysenko Date: Wed, 9 Oct 2024 21:04:52 +0300 Subject: [PATCH 02/14] feat(settings): migrate AppAPI ExApps management (1) Signed-off-by: Andrey Borysenko --- apps/settings/src/app-types.ts | 38 ++ apps/settings/src/components/AppList.vue | 17 +- .../src/components/AppList/AppItem.vue | 25 +- .../AppStoreSidebar/AppDeployDaemonTab.vue | 47 +++ .../AppStoreSidebar/AppDetailsTab.vue | 29 +- apps/settings/src/mixins/AppManagement.js | 138 +++++- apps/settings/src/store/app_api_apps.js | 399 ++++++++++++++++++ apps/settings/src/store/apps.js | 13 + apps/settings/src/store/index.js | 2 + apps/settings/src/views/AppStore.vue | 14 +- apps/settings/src/views/AppStoreSidebar.vue | 15 +- 11 files changed, 704 insertions(+), 33 deletions(-) create mode 100644 apps/settings/src/components/AppStoreSidebar/AppDeployDaemonTab.vue create mode 100644 apps/settings/src/store/app_api_apps.js diff --git a/apps/settings/src/app-types.ts b/apps/settings/src/app-types.ts index 604e250df3d92..77a1b6797f3cd 100644 --- a/apps/settings/src/app-types.ts +++ b/apps/settings/src/app-types.ts @@ -52,3 +52,41 @@ export interface IAppstoreApp { appstoreData: Record releases?: IAppstoreAppRelease[] } + +export interface IAppstoreExApp extends IAppstoreApp { + daemon: IDeployDaemon, + status: IExAppStatus, + error: string, + app_api: boolean, +} + +export interface IComputeDevice { + id: string, + label: string, +} + +export interface IDeployConfig { + computeDevice: IComputeDevice, + net: string, + nextcloud_url: string, +} + +export interface IDeployDaemon { + accepts_deploy_id: string, + deploy_config: IDeployConfig, + display_name: string, + host: string, + id: number, + name: string, + protocol: string, +} + +export interface IExAppStatus { + action: string, + deploy: number, + deploy_start_time: number, + error: string, + init: number, + init_start_time: number, + type: string, +} diff --git a/apps/settings/src/components/AppList.vue b/apps/settings/src/components/AppList.vue index 2372b461f5785..9f53cba25e314 100644 --- a/apps/settings/src/components/AppList.vue +++ b/apps/settings/src/components/AppList.vue @@ -168,7 +168,10 @@ export default { return this.apps.filter(app => app.update).length }, loading() { - return this.$store.getters.loading('list') + if (!this.$store.getters['app_api_apps/isAppApiEnabled']) { + return this.$store.getters.loading('list') + } + return this.$store.getters.loading('list') || this.$store.getters['app_api_apps/loading']('list') }, hasPendingUpdate() { return this.apps.filter(app => app.update).length > 0 @@ -177,7 +180,9 @@ export default { return this.hasPendingUpdate && this.useListView }, apps() { - const apps = this.$store.getters.getAllApps + // Exclude ExApps from the list if AppAPI is disabled + const exApps = this.$store.getters.isAppApiEnabled ? this.$store.getters['app_api_apps/getAllApps'] : [] + const apps = [...this.$store.getters.getAllApps, ...exApps] .filter(app => app.name.toLowerCase().search(this.search.toLowerCase()) !== -1) .sort(function(a, b) { const sortStringA = '' + (a.active ? 0 : 1) + (a.update ? 0 : 1) + a.name @@ -304,7 +309,13 @@ export default { const limit = pLimit(1) this.apps .filter(app => app.update) - .map(app => limit(() => this.$store.dispatch('updateApp', { appId: app.id })), + .map(app => limit(() => { + let type = 'updateApp' + if (app?.app_api) { + type = 'app_api_apps/updateApp' + } + this.$store.dispatch(type, { appId: app.id }) + }), ) }, }, diff --git a/apps/settings/src/components/AppList/AppItem.vue b/apps/settings/src/components/AppList/AppItem.vue index 08faa06f1cd76..7d89f50b8976a 100644 --- a/apps/settings/src/components/AppList/AppItem.vue +++ b/apps/settings/src/components/AppList/AppItem.vue @@ -14,9 +14,13 @@ -
+
+ - @@ -71,10 +75,10 @@
{{ app.error }}
-
+
{{ t('settings', 'Update to {update}', {update:app.update}) }} @@ -88,13 +92,13 @@ - {{ t('settings','Disable') }} + {{ disableButtonText }} {{ enableButtonText }} @@ -102,7 +106,7 @@ :title="forceEnableButtonTooltip" :aria-label="forceEnableButtonTooltip" type="secondary" - :disabled="installing || isLoading" + :disabled="installing || isLoading || !defaultDeployDaemonAccessible" @click.stop="forceEnable(app.id)"> {{ forceEnableButtonText }} @@ -116,6 +120,8 @@ import AppLevelBadge from './AppLevelBadge.vue' import AppManagement from '../../mixins/AppManagement.js' import SvgFilterMixin from '../SvgFilterMixin.vue' import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' +import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' +import { mdiCogOutline } from '@mdi/js' export default { name: 'AppItem', @@ -123,6 +129,8 @@ export default { AppLevelBadge, AppScore, NcButton, + NcIconSvgWrapper, + mdiCogOutline, }, mixins: [AppManagement, SvgFilterMixin], props: { @@ -188,6 +196,9 @@ export default { }, methods: { + mdiCogOutline() { + return mdiCogOutline + }, prefix(prefix, content) { return prefix + '_' + content }, diff --git a/apps/settings/src/components/AppStoreSidebar/AppDeployDaemonTab.vue b/apps/settings/src/components/AppStoreSidebar/AppDeployDaemonTab.vue new file mode 100644 index 0000000000000..6f223f821d3a0 --- /dev/null +++ b/apps/settings/src/components/AppStoreSidebar/AppDeployDaemonTab.vue @@ -0,0 +1,47 @@ + + + + + + + + diff --git a/apps/settings/src/components/AppStoreSidebar/AppDetailsTab.vue b/apps/settings/src/components/AppStoreSidebar/AppDetailsTab.vue index 653a1ee5a2db0..a3723dc3a7948 100644 --- a/apps/settings/src/components/AppStoreSidebar/AppDetailsTab.vue +++ b/apps/settings/src/components/AppStoreSidebar/AppDetailsTab.vue @@ -47,19 +47,19 @@ class="update primary" type="button" :value="t('settings', 'Update to {version}', { version: app.update })" - :disabled="installing || isLoading" + :disabled="installing || isLoading || isManualInstall" @click="update(app.id)"> + @click="remove(app.id, removeData)">
+ + {{ t('settings', 'Delete data on remove') }} +
    @@ -183,6 +189,7 @@ import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' 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 AppManagement from '../../mixins/AppManagement.js' import { mdiBug, mdiFeatureSearch, mdiStar, mdiTextBox, mdiTooltipQuestion } from '@mdi/js' @@ -197,6 +204,7 @@ export default { NcDateTime, NcIconSvgWrapper, NcSelect, + NcCheckboxRadioSwitch, }, mixins: [AppManagement], @@ -224,6 +232,7 @@ export default { data() { return { groupCheckedAppsData: false, + removeData: false, } }, @@ -333,6 +342,16 @@ export default { this.groupCheckedAppsData = true } }, + watch: { + 'app.id'() { + this.removeData = false + }, + }, + methods: { + toggleRemoveData() { + this.removeData = !this.removeData + }, + }, } diff --git a/apps/settings/src/mixins/AppManagement.js b/apps/settings/src/mixins/AppManagement.js index c63041f45c963..d3ebc98db4ae5 100644 --- a/apps/settings/src/mixins/AppManagement.js +++ b/apps/settings/src/mixins/AppManagement.js @@ -12,16 +12,76 @@ export default { return this.app.groups.map(group => { return { id: group, name: group } }) }, installing() { + if (this.app?.app_api) { + return this.app && this.$store.getters['app_api_apps/loading']('install') + } return this.$store.getters.loading('install') }, isLoading() { + if (this.app?.app_api) { + return this.app && this.$store.getters['app_api_apps/loading'](this.app.id) + } return this.app && this.$store.getters.loading(this.app.id) }, + isInitializing() { + if (this.app?.app_api) { + return this.app && Object.hasOwn(this.app?.status, 'action') && (this.app.status.action === 'init' || this.app.status.action === 'healthcheck') + } + return false + }, + isDeploying() { + if (this.app?.app_api) { + return this.app && Object.hasOwn(this.app?.status, 'action') && this.app.status.action === 'deploy' + } + return false + }, + isManualInstall() { + if (this.app?.app_api) { + return this.app?.daemon?.accepts_deploy_id === 'manual-install' + } + return false + }, + updateButtonText() { + if (this.app?.daemon?.accepts_deploy_id === 'manual-install') { + return t('app_api', 'manual-install apps cannot be updated') + } + return '' + }, enableButtonText() { - if (this.app.needsDownload) { - return t('settings', 'Download and enable') + if (this.app?.app_api) { + if (this.app && Object.hasOwn(this.app?.status, 'action') && this.app.status.action === 'deploy') { + return t('app_api', '{progress}% Deploying', { progress: this.app.status?.deploy }) + } + if (this.app && Object.hasOwn(this.app?.status, 'action') && this.app.status.action === 'init') { + return t('app_api', '{progress}% Initializing', { progress: this.app.status?.init }) + } + if (this.app && Object.hasOwn(this.app?.status, 'action') && this.app.status.action === 'healthcheck') { + return t('app_api', 'Healthchecking') + } + if (this.app.needsDownload) { + return t('app_api', 'Deploy and Enable') + } + return t('app_api', 'Enable') + } else { + if (this.app.needsDownload) { + return t('settings', 'Download and enable') + } + return t('settings', 'Enable') + } + }, + disableButtonText() { + if (this.app?.app_api) { + if (this.app && Object.hasOwn(this.app?.status, 'action') && this.app.status.action === 'deploy') { + return t('app_api', '{progress}% Deploying', { progress: this.app.status?.deploy }) + } + if (this.app && Object.hasOwn(this.app?.status, 'action') && this.app.status.action === 'init') { + return t('app_api', '{progress}% Initializing', { progress: this.app.status?.init }) + } + if (this.app && Object.hasOwn(this.app?.status, 'action') && this.app.status.action === 'healthcheck') { + return t('app_api', 'Healthchecking') + } } - return t('settings', 'Enable') + return t('app_api', 'Disable') }, forceEnableButtonText() { if (this.app.needsDownload) { @@ -30,7 +90,7 @@ export default { return t('settings', 'Allow untested app') }, enableButtonTooltip() { - if (this.app.needsDownload) { + if (!this.app?.app_api && this.app.needsDownload) { return t('settings', 'The app will be downloaded from the App Store') } return null @@ -42,6 +102,18 @@ export default { } return base }, + defaultDeployDaemonAccessible() { + if (this.app?.app_api) { + if (this.app?.daemon && this.app?.daemon?.accepts_deploy_id === 'manual-install') { + return true + } + if (this.app?.daemon?.accepts_deploy_id === 'docker-install') { + return this.$store.getters['app_api_apps/getDaemonAccessible'] === true + } + return this.$store.getters['app_api_apps/getDaemonAccessible'] + } + return true + }, }, data() { @@ -61,12 +133,15 @@ export default { return this.$store.dispatch('getGroups', { search: query, limit: 5, offset: 0 }) }, isLimitedToGroups(app) { - if (this.app.groups.length || this.groupCheckedAppsData) { - return true + if (this.app?.app_api) { + return false } - return false + return this.app.groups.length || this.groupCheckedAppsData; }, setGroupLimit() { + if (this.app?.app_api) { + return // not supported for app_api apps + } if (!this.groupCheckedAppsData) { this.$store.dispatch('enableApp', { appId: this.app.id, groups: [] }) } @@ -76,17 +151,24 @@ export default { || app.types.includes('prelogin') || app.types.includes('authentication') || app.types.includes('logging') - || app.types.includes('prevent_group_restriction')) { + || app.types.includes('prevent_group_restriction') + || app?.app_api) { return false } return true }, addGroupLimitation(groupArray) { + if (this.app?.app_api) { + return // not supported for app_api apps + } const group = groupArray.pop() const groups = this.app.groups.concat([]).concat([group.id]) this.$store.dispatch('enableApp', { appId: this.app.id, groups }) }, removeGroupLimitation(group) { + if (this.app?.app_api) { + return // not supported for app_api apps + } const currentGroups = this.app.groups.concat([]) const index = currentGroups.indexOf(group.id) if (index > -1) { @@ -95,32 +177,58 @@ export default { this.$store.dispatch('enableApp', { appId: this.app.id, groups: currentGroups }) }, forceEnable(appId) { - this.$store.dispatch('forceEnableApp', { appId, groups: [] }) + let type = 'forceEnableApp' + if (this.app?.app_api) { + type = 'app_api_apps/forceEnableApp' + } + this.$store.dispatch(type, { appId, groups: [] }) .then((response) => { rebuildNavigation() }) .catch((error) => { showError(error) }) }, enable(appId) { - this.$store.dispatch('enableApp', { appId, groups: [] }) + let type = 'enableApp' + if (this.app?.app_api) { + type = 'app_api_apps/enableApp' + } + this.$store.dispatch(type, { appId, groups: [] }) .then((response) => { rebuildNavigation() }) .catch((error) => { showError(error) }) }, disable(appId) { - this.$store.dispatch('disableApp', { appId }) + let type = 'disableApp' + if (this.app?.app_api) { + type = 'app_api_apps/disableApp' + } + this.$store.dispatch(type, { appId }) .then((response) => { rebuildNavigation() }) .catch((error) => { showError(error) }) }, - remove(appId) { - this.$store.dispatch('uninstallApp', { appId }) + remove(appId, removeData = false) { + let type = 'uninstallApp' + let payload = { appId } + if (this.app?.app_api) { + type = 'app_api_apps/uninstallApp' + payload = { appId, removeData } + } + this.$store.dispatch(type, payload) .then((response) => { rebuildNavigation() }) .catch((error) => { showError(error) }) }, install(appId) { - this.$store.dispatch('enableApp', { appId }) + let type = 'enableApp' + if (this.app?.app_api) { + type = 'app_api_apps/enableApp' + } + this.$store.dispatch(type, { appId }) .then((response) => { rebuildNavigation() }) .catch((error) => { showError(error) }) }, update(appId) { - this.$store.dispatch('updateApp', { appId }) + let type = 'updateApp' + if (this.app?.app_api) { + type = 'app_api_apps/updateApp' + } + this.$store.dispatch(type, { appId }) .then((response) => { rebuildNavigation() }) .catch((error) => { showError(error) }) }, diff --git a/apps/settings/src/store/app_api_apps.js b/apps/settings/src/store/app_api_apps.js new file mode 100644 index 0000000000000..8d246a15e813b --- /dev/null +++ b/apps/settings/src/store/app_api_apps.js @@ -0,0 +1,399 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import api from './api.js' +import Vue from 'vue' +import { generateUrl } from '@nextcloud/router' +import { showError, showInfo } from '@nextcloud/dialogs' +import { loadState } from '@nextcloud/initial-state' + +const state = { + apps: [], + categories: [], + updateCount: loadState('settings', 'appstoreExAppUpdateCount', 0), + loading: {}, + loadingList: false, + statusUpdater: null, + gettingCategoriesPromise: null, + daemonAccessible: loadState('settings', 'defaultDaemonConfigAccessible', false), + defaultDaemon: loadState('settings', 'defaultDaemonConfig', null), +} + +const mutations = { + + APPS_API_FAILURE(state, error) { + showError(t('app_api', 'An error occurred during the request. Unable to proceed.') + '
    ' + error.error.response.data.data.message, { isHTML: true }) + console.error(state, error) + }, + + initCategories(state, { categories, updateCount }) { + state.categories = categories + state.updateCount = updateCount + }, + + updateCategories(state, categoriesPromise) { + state.gettingCategoriesPromise = categoriesPromise + }, + + setUpdateCount(state, updateCount) { + state.updateCount = updateCount + }, + + addCategory(state, category) { + state.categories.push(category) + }, + + appendCategories(state, categoriesArray) { + // convert obj to array + state.categories = categoriesArray + }, + + setAllApps(state, apps) { + state.apps = apps + }, + + setError(state, { appId, error }) { + if (!Array.isArray(appId)) { + appId = [appId] + } + appId.forEach((_id) => { + const app = state.apps.find(app => app.id === _id) + app.error = error + }) + }, + + enableApp(state, { appId }) { + const app = state.apps.find(app => app.id === appId) + if (!app.installed) { + app.installed = true + app.needsDownload = false + app.daemon = state.defaultDaemon + app.status = { + type: 'install', + action: 'deploy', + init: 0, + deploy: 0, + } + } + app.active = true + app.canUnInstall = false + app.removable = true + app.error = null + }, + + disableApp(state, appId) { + const app = state.apps.find(app => app.id === appId) + app.active = false + if (app.removable) { + app.canUnInstall = true + } + }, + + uninstallApp(state, appId) { + state.apps.find(app => app.id === appId).active = false + state.apps.find(app => app.id === appId).needsDownload = true + state.apps.find(app => app.id === appId).installed = false + state.apps.find(app => app.id === appId).canUnInstall = false + state.apps.find(app => app.id === appId).canInstall = true + state.apps.find(app => app.id === appId).daemon = null + state.apps.find(app => app.id === appId).status = {} + if (state.apps.find(app => app.id === appId).update !== null) { + state.updateCount-- + } + state.apps.find(app => app.id === appId).update = null + }, + + updateApp(state, { appId }) { + const app = state.apps.find(app => app.id === appId) + const version = app.update + app.update = null + app.version = version + app.status = { + type: 'update', + action: 'deploy', + init: 0, + deploy: 0, + } + app.error = null + state.updateCount-- + }, + + startLoading(state, id) { + Vue.set(state.loading, id, true) // eslint-disable-line + }, + + stopLoading(state, id) { + Vue.set(state.loading, id, false) // eslint-disable-line + }, + + setDaemonAccessible(state, value) { + state.daemonAccessible = value + }, + + setDefaultDaemon(state, value) { + Vue.set(state, 'defaultDaemon', value) // eslint-disable-line + }, + + setAppStatus(state, { appId, status }) { + const app = state.apps.find(app => app.id === appId) + if (status.type === 'install' && status.deploy === 100 && status.action === '') { + console.debug('catching intermediate state deploying -> initializing') + // catching moment when app is deployed but initialization status not started yet + status.action = 'init' + app.canUnInstall = true + } + if (status.error !== '') { + app.error = status.error + app.canUnInstall = true + } + if (status.deploy === 100 && status.init === 100) { + app.active = true + app.canUnInstall = false + app.removable = true + } + app.status = status + }, + + setIntervalUpdater(state, updater) { + state.statusUpdater = updater + }, +} + +const getters = { + loading(state) { + return function(id) { + return state.loading[id] + } + }, + getCategories(state) { + return state.categories + }, + getAllApps(state) { + return state.apps + }, + getUpdateCount(state) { + return state.updateCount + }, + getCategoryById: (state) => (selectedCategoryId) => { + return state.categories.find((category) => category.id === selectedCategoryId) + }, + getDaemonAccessible(state) { + return state.daemonAccessible + }, + getAppStatus(state) { + return function(appId) { + return state.apps.find(app => app.id === appId).status + } + }, + getStatusUpdater(state) { + return state.statusUpdater + }, + getInitializingOrDeployingApps(state) { + return state.apps.filter(app => Object.hasOwn(app.status, 'action') + && (app.status.action === 'deploy' || app.status.action === 'init' || app.status.action === 'healthcheck') + && app.status.type !== '') + }, +} + +const actions = { + + enableApp(context, { appId }) { + return api.requireAdmin().then((response) => { + context.commit('startLoading', appId) + context.commit('startLoading', 'install') + return api.post(generateUrl(`/apps/app_api/apps/enable/${appId}`)) + .then((response) => { + context.commit('stopLoading', appId) + context.commit('stopLoading', 'install') + + context.commit('enableApp', { appId }) + + context.dispatch('updateAppsStatus') + + // check for server health + return axios.get(generateUrl('apps/files')) + .then(() => { + if (response.data.update_required) { + showInfo( + t( + 'app_api', + 'The app has been enabled but needs to be updated.', + ), + { + onClick: () => window.location.reload(), + close: false, + }, + ) + setTimeout(function() { + location.reload() + }, 5000) + } + }) + .catch(() => { + context.commit('setError', { + appId: [appId], + error: t('app_api', 'Error: This app cannot be enabled because it makes the server unstable'), + }) + }) + }) + .catch((error) => { + context.commit('stopLoading', appId) + context.commit('stopLoading', 'install') + context.commit('setError', { + appId: [appId], + error: error.response.data.data.message, + }) + context.commit('APPS_API_FAILURE', { appId, error }) + }) + }).catch((error) => context.commit('API_FAILURE', { appId, error })) + }, + + forceEnableApp(context, { appId }) { + return api.requireAdmin().then(() => { + context.commit('startLoading', appId) + context.commit('startLoading', 'install') + return api.post(generateUrl('/apps/app_api/apps/force'), { appId }) + .then((response) => { + location.reload() + }) + .catch((error) => { + context.commit('stopLoading', appId) + context.commit('stopLoading', 'install') + context.commit('setError', { + appId: [appId], + error: error.response.data.data.message, + }) + context.commit('APPS_API_FAILURE', { appId, error }) + }) + }).catch((error) => context.commit('API_FAILURE', { appId, error })) + }, + + disableApp(context, { appId }) { + return api.requireAdmin().then((response) => { + context.commit('startLoading', appId) + return api.get(generateUrl(`apps/app_api/apps/disable/${appId}`)) + .then((response) => { + context.commit('stopLoading', appId) + context.commit('disableApp', appId) + return true + }) + .catch((error) => { + context.commit('disableApp', appId) + context.commit('stopLoading', appId) + context.commit('APPS_API_FAILURE', { appId, error }) + }) + }).catch((error) => context.commit('API_FAILURE', { appId, error })) + }, + + uninstallApp(context, { appId, removeData }) { + return api.requireAdmin().then((response) => { + context.commit('startLoading', appId) + return api.get(generateUrl(`/apps/app_api/apps/uninstall/${appId}?removeData=${removeData}`)) + .then((response) => { + context.commit('stopLoading', appId) + context.commit('uninstallApp', appId) + return true + }) + .catch((error) => { + context.commit('stopLoading', appId) + context.commit('APPS_API_FAILURE', { appId, error }) + }) + }).catch((error) => context.commit('API_FAILURE', { appId, error })) + }, + + updateApp(context, { appId }) { + return api.requireAdmin().then((response) => { + context.commit('startLoading', appId) + context.commit('startLoading', 'install') + return api.get(generateUrl(`/apps/app_api/apps/update/${appId}`)) + .then((response) => { + context.commit('stopLoading', 'install') + context.commit('stopLoading', appId) + context.commit('updateApp', { appId }) + context.dispatch('updateAppsStatus') + return true + }) + .catch((error) => { + context.commit('stopLoading', appId) + context.commit('stopLoading', 'install') + context.commit('APPS_API_FAILURE', { appId, error }) + }) + }).catch((error) => context.commit('API_FAILURE', { appId, error })) + }, + + getAllApps(context) { + context.commit('startLoading', 'list') + return api.get(generateUrl('/apps/app_api/apps/list')) + .then((response) => { + context.commit('setAllApps', response.data.apps) + context.commit('stopLoading', 'list') + return true + }) + .catch((error) => context.commit('API_FAILURE', error)) + }, + + async getCategories(context, { shouldRefetchCategories = false } = {}) { + if (shouldRefetchCategories || !context.state.gettingCategoriesPromise) { + context.commit('startLoading', 'categories') + try { + const categoriesPromise = api.get(generateUrl('/apps/app_api/apps/categories')) + context.commit('updateCategories', categoriesPromise) + const categoriesPromiseResponse = await categoriesPromise + if (categoriesPromiseResponse.data.length > 0) { + context.commit('appendCategories', categoriesPromiseResponse.data) + context.commit('stopLoading', 'categories') + return true + } + context.commit('stopLoading', 'categories') + return false + } catch (error) { + context.commit('API_FAILURE', error) + } + } + return context.state.gettingCategoriesPromise + }, + + getAppStatus(context, { appId }) { + return api.get(generateUrl(`/apps/app_api/apps/status/${appId}`)) + .then((response) => { + context.commit('setAppStatus', { appId, status: response.data }) + const initializingOrDeployingApps = context.getters.getInitializingOrDeployingApps + console.debug('initializingOrDeployingApps after setAppStatus', initializingOrDeployingApps) + if (initializingOrDeployingApps.length === 0) { + console.debug('clearing interval') + clearInterval(context.getters.getStatusUpdater) + context.commit('setIntervalUpdater', null) + } + if (Object.hasOwn(response.data, 'error') + && response.data.error !== '' + && initializingOrDeployingApps.length === 1) { + clearInterval(context.getters.getStatusUpdater) + context.commit('setIntervalUpdater', null) + } + }) + .catch((error) => { + context.commit('API_FAILURE', error) + context.commit('unregisterApp', { appId }) + context.dispatch('updateAppsStatus') + }) + }, + + updateAppsStatus(context) { + clearInterval(context.getters.getStatusUpdater) // clear previous interval if exists + context.commit('setIntervalUpdater', setInterval(() => { + const initializingOrDeployingApps = context.getters.getInitializingOrDeployingApps + console.debug('initializingOrDeployingApps', initializingOrDeployingApps) + Array.from(initializingOrDeployingApps).forEach(app => { + context.dispatch('getAppStatus', { appId: app.id }) + }) + }, 2000)) + }, + +} + +export default { + namespaced: true, // we will use AppAPI store module explicitly, since methods names are the same, we need to scope it + state, mutations, getters, actions +} diff --git a/apps/settings/src/store/apps.js b/apps/settings/src/store/apps.js index ed5a724537111..5de9628f947fb 100644 --- a/apps/settings/src/store/apps.js +++ b/apps/settings/src/store/apps.js @@ -16,6 +16,7 @@ const state = { updateCount: loadState('settings', 'appstoreUpdateCount', 0), loading: {}, gettingCategoriesPromise: null, + appApiEnabled: loadState('settings', 'appApiEnabled'), } const mutations = { @@ -70,6 +71,9 @@ const mutations = { const app = state.apps.find(app => app.id === appId) app.active = true app.groups = groups + if (app.id === 'app_api') { + state.appApiEnabled = true + } }, setInstallState(state, { appId, canInstall }) { @@ -86,6 +90,9 @@ const mutations = { if (app.removable) { app.canUnInstall = true } + if (app.id === 'app_api') { + state.appApiEnabled = false + } }, uninstallApp(state, appId) { @@ -95,6 +102,9 @@ const mutations = { state.apps.find(app => app.id === appId).installed = false state.apps.find(app => app.id === appId).canUnInstall = false state.apps.find(app => app.id === appId).canInstall = true + if (app.id === 'app_api') { + state.appApiEnabled = false + } }, updateApp(state, appId) { @@ -135,6 +145,9 @@ const mutations = { } const getters = { + isAppApiEnabled(state) { + return state.appApiEnabled + }, loading(state) { return function(id) { return state.loading[id] diff --git a/apps/settings/src/store/index.js b/apps/settings/src/store/index.js index 910185edb5107..2cf8767b9046d 100644 --- a/apps/settings/src/store/index.js +++ b/apps/settings/src/store/index.js @@ -7,6 +7,7 @@ import Vue from 'vue' import Vuex, { Store } from 'vuex' import users from './users.js' import apps from './apps.js' +import app_api_apps from './app_api_apps.js' import settings from './users-settings.js' import oc from './oc.js' import { showError } from '@nextcloud/dialogs' @@ -35,6 +36,7 @@ export const useStore = () => { modules: { users, apps, + app_api_apps, settings, oc, }, diff --git a/apps/settings/src/views/AppStore.vue b/apps/settings/src/views/AppStore.vue index 614cb9d2837ab..5be2b118b9f55 100644 --- a/apps/settings/src/views/AppStore.vue +++ b/apps/settings/src/views/AppStore.vue @@ -23,7 +23,7 @@ diff --git a/apps/settings/src/views/AppStoreSidebar.vue b/apps/settings/src/views/AppStoreSidebar.vue index 5811b26b56e4e..676458d74591c 100644 --- a/apps/settings/src/views/AppStoreSidebar.vue +++ b/apps/settings/src/views/AppStoreSidebar.vue @@ -34,6 +34,7 @@ + @@ -49,15 +50,27 @@ import AppScore from '../components/AppList/AppScore.vue' import AppDescriptionTab from '../components/AppStoreSidebar/AppDescriptionTab.vue' import AppDetailsTab from '../components/AppStoreSidebar/AppDetailsTab.vue' import AppReleasesTab from '../components/AppStoreSidebar/AppReleasesTab.vue' +import AppDeployDaemonTab from '../components/AppStoreSidebar/AppDeployDaemonTab.vue' import AppLevelBadge from '../components/AppList/AppLevelBadge.vue' import { useAppIcon } from '../composables/useAppIcon.ts' +import { useStore } from '../store' const route = useRoute() const router = useRouter() const store = useAppsStore() +const legacyStore = useStore() const appId = computed(() => route.params.id ?? '') -const app = computed(() => store.getAppById(appId.value)!) +const app = computed(() => { + if (legacyStore.getters.isAppApiEnabled) { + const exApp = legacyStore.getters['app_api_apps/getAllApps'] + .find((app) => app.id === appId.value) ?? null + if (exApp) { + return exApp + } + } + return store.getAppById(appId.value)! +}) const hasRating = computed(() => app.value.appstoreData?.ratingNumOverall > 5) const rating = computed(() => app.value.appstoreData?.ratingNumRecent > 5 ? app.value.appstoreData.ratingRecent From 6e3ad1e7cc173cac1dd2774bf9ca8c56cd371ac4 Mon Sep 17 00:00:00 2001 From: Andrey Borysenko Date: Fri, 11 Oct 2024 17:12:49 +0300 Subject: [PATCH 03/14] WIP: add Daemon badge in app sidebar (2) Signed-off-by: Andrey Borysenko --- .../src/components/AppList/AppDaemonBadge.vue | 37 +++++++++++++++++++ apps/settings/src/views/AppStoreSidebar.vue | 2 + 2 files changed, 39 insertions(+) create mode 100644 apps/settings/src/components/AppList/AppDaemonBadge.vue diff --git a/apps/settings/src/components/AppList/AppDaemonBadge.vue b/apps/settings/src/components/AppList/AppDaemonBadge.vue new file mode 100644 index 0000000000000..1a46d3c7ebb70 --- /dev/null +++ b/apps/settings/src/components/AppList/AppDaemonBadge.vue @@ -0,0 +1,37 @@ + + + + + + diff --git a/apps/settings/src/views/AppStoreSidebar.vue b/apps/settings/src/views/AppStoreSidebar.vue index 676458d74591c..fc2b94d8ee055 100644 --- a/apps/settings/src/views/AppStoreSidebar.vue +++ b/apps/settings/src/views/AppStoreSidebar.vue @@ -26,6 +26,7 @@
    +
    @@ -52,6 +53,7 @@ import AppDetailsTab from '../components/AppStoreSidebar/AppDetailsTab.vue' import AppReleasesTab from '../components/AppStoreSidebar/AppReleasesTab.vue' import AppDeployDaemonTab from '../components/AppStoreSidebar/AppDeployDaemonTab.vue' import AppLevelBadge from '../components/AppList/AppLevelBadge.vue' +import AppDaemonBadge from '../components/AppList/AppDaemonBadge.vue' import { useAppIcon } from '../composables/useAppIcon.ts' import { useStore } from '../store' From 61a5b2c59877d1a192cf6edafc23c5c4ded27b80 Mon Sep 17 00:00:00 2001 From: Andrey Borysenko Date: Fri, 11 Oct 2024 18:19:52 +0300 Subject: [PATCH 04/14] WIP: use global mutation, since app_api_apps is namespaced Signed-off-by: Andrey Borysenko --- apps/settings/src/store/app_api_apps.js | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/apps/settings/src/store/app_api_apps.js b/apps/settings/src/store/app_api_apps.js index 8d246a15e813b..11ecd30a0d6ae 100644 --- a/apps/settings/src/store/app_api_apps.js +++ b/apps/settings/src/store/app_api_apps.js @@ -128,14 +128,6 @@ const mutations = { Vue.set(state.loading, id, false) // eslint-disable-line }, - setDaemonAccessible(state, value) { - state.daemonAccessible = value - }, - - setDefaultDaemon(state, value) { - Vue.set(state, 'defaultDaemon', value) // eslint-disable-line - }, - setAppStatus(state, { appId, status }) { const app = state.apps.find(app => app.id === appId) if (status.type === 'install' && status.deploy === 100 && status.action === '') { @@ -247,7 +239,7 @@ const actions = { }) context.commit('APPS_API_FAILURE', { appId, error }) }) - }).catch((error) => context.commit('API_FAILURE', { appId, error })) + }).catch((error) => context.commit('API_FAILURE', { appId, error }, { root: true })) }, forceEnableApp(context, { appId }) { @@ -267,7 +259,7 @@ const actions = { }) context.commit('APPS_API_FAILURE', { appId, error }) }) - }).catch((error) => context.commit('API_FAILURE', { appId, error })) + }).catch((error) => context.commit('API_FAILURE', { appId, error }, { root: true })) }, disableApp(context, { appId }) { @@ -284,7 +276,7 @@ const actions = { context.commit('stopLoading', appId) context.commit('APPS_API_FAILURE', { appId, error }) }) - }).catch((error) => context.commit('API_FAILURE', { appId, error })) + }).catch((error) => context.commit('API_FAILURE', { appId, error }, { root: true })) }, uninstallApp(context, { appId, removeData }) { @@ -300,7 +292,7 @@ const actions = { context.commit('stopLoading', appId) context.commit('APPS_API_FAILURE', { appId, error }) }) - }).catch((error) => context.commit('API_FAILURE', { appId, error })) + }).catch((error) => context.commit('API_FAILURE', { appId, error }, { root: true })) }, updateApp(context, { appId }) { @@ -320,7 +312,7 @@ const actions = { context.commit('stopLoading', 'install') context.commit('APPS_API_FAILURE', { appId, error }) }) - }).catch((error) => context.commit('API_FAILURE', { appId, error })) + }).catch((error) => context.commit('API_FAILURE', { appId, error }, { root: true })) }, getAllApps(context) { @@ -331,7 +323,7 @@ const actions = { context.commit('stopLoading', 'list') return true }) - .catch((error) => context.commit('API_FAILURE', error)) + .catch((error) => context.commit('API_FAILURE', error, { root: true })) }, async getCategories(context, { shouldRefetchCategories = false } = {}) { @@ -349,7 +341,7 @@ const actions = { context.commit('stopLoading', 'categories') return false } catch (error) { - context.commit('API_FAILURE', error) + context.commit('API_FAILURE', error, { root: true }) } } return context.state.gettingCategoriesPromise @@ -374,7 +366,7 @@ const actions = { } }) .catch((error) => { - context.commit('API_FAILURE', error) + context.commit('API_FAILURE', error, { root: true }) context.commit('unregisterApp', { appId }) context.dispatch('updateAppsStatus') }) From 63b7cfa120b7e17087c5b7fd676a923768b763df Mon Sep 17 00:00:00 2001 From: Andrey Borysenko Date: Fri, 11 Oct 2024 18:33:10 +0300 Subject: [PATCH 05/14] WIP: add missing state checks Signed-off-by: Andrey Borysenko --- apps/settings/src/components/AppList/AppItem.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/settings/src/components/AppList/AppItem.vue b/apps/settings/src/components/AppList/AppItem.vue index 7d89f50b8976a..96f8160534f7a 100644 --- a/apps/settings/src/components/AppList/AppItem.vue +++ b/apps/settings/src/components/AppList/AppItem.vue @@ -90,7 +90,7 @@ {{ t('settings', 'Remove') }} {{ disableButtonText }} From 7c1cf5c6bbdaa81959bc10cf2d9fff4e3a1e74fb Mon Sep 17 00:00:00 2001 From: Andrey Borysenko Date: Fri, 11 Oct 2024 18:33:23 +0300 Subject: [PATCH 06/14] WIP: minor fixes Signed-off-by: Andrey Borysenko --- apps/settings/src/store/app_api_apps.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/settings/src/store/app_api_apps.js b/apps/settings/src/store/app_api_apps.js index 11ecd30a0d6ae..4e505e6d8c8fd 100644 --- a/apps/settings/src/store/app_api_apps.js +++ b/apps/settings/src/store/app_api_apps.js @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import axios from '@nextcloud/axios' import api from './api.js' import Vue from 'vue' import { generateUrl } from '@nextcloud/router' @@ -195,7 +196,7 @@ const actions = { return api.requireAdmin().then((response) => { context.commit('startLoading', appId) context.commit('startLoading', 'install') - return api.post(generateUrl(`/apps/app_api/apps/enable/${appId}`)) + return axios.post(generateUrl(`/apps/app_api/apps/enable/${appId}`)) .then((response) => { context.commit('stopLoading', appId) context.commit('stopLoading', 'install') @@ -374,6 +375,10 @@ const actions = { updateAppsStatus(context) { clearInterval(context.getters.getStatusUpdater) // clear previous interval if exists + const initializingOrDeployingApps = context.getters.getInitializingOrDeployingApps + if (initializingOrDeployingApps.length === 0) { + return + } context.commit('setIntervalUpdater', setInterval(() => { const initializingOrDeployingApps = context.getters.getInitializingOrDeployingApps console.debug('initializingOrDeployingApps', initializingOrDeployingApps) From 6c58692c196cae86410b3c3e3df64e7279262cab Mon Sep 17 00:00:00 2001 From: Andrey Borysenko Date: Mon, 14 Oct 2024 16:46:35 +0300 Subject: [PATCH 07/14] fix(ci): resolve eslint errors Signed-off-by: Andrey Borysenko --- apps/settings/src/app-types.ts | 14 ++++++------ apps/settings/src/components/AppList.vue | 19 ++++++++-------- .../src/components/AppList/AppDaemonBadge.vue | 2 +- .../src/components/AppList/AppItem.vue | 6 ++--- .../AppStoreSidebar/AppDeployDaemonTab.vue | 1 - .../AppStoreSidebar/AppDetailsTab.vue | 6 ++--- apps/settings/src/mixins/AppManagement.js | 22 +++++++++---------- apps/settings/src/store/app_api_apps.js | 5 ++++- apps/settings/src/store/apps.js | 2 +- apps/settings/src/store/index.js | 4 ++-- apps/settings/src/views/AppStore.vue | 9 ++++---- apps/settings/src/views/AppStoreSidebar.vue | 2 +- 12 files changed, 47 insertions(+), 45 deletions(-) diff --git a/apps/settings/src/app-types.ts b/apps/settings/src/app-types.ts index 77a1b6797f3cd..3ce2aa1648c9b 100644 --- a/apps/settings/src/app-types.ts +++ b/apps/settings/src/app-types.ts @@ -53,13 +53,6 @@ export interface IAppstoreApp { releases?: IAppstoreAppRelease[] } -export interface IAppstoreExApp extends IAppstoreApp { - daemon: IDeployDaemon, - status: IExAppStatus, - error: string, - app_api: boolean, -} - export interface IComputeDevice { id: string, label: string, @@ -90,3 +83,10 @@ export interface IExAppStatus { init_start_time: number, type: string, } + +export interface IAppstoreExApp extends IAppstoreApp { + daemon: IDeployDaemon, + status: IExAppStatus, + error: string, + app_api: boolean, +} diff --git a/apps/settings/src/components/AppList.vue b/apps/settings/src/components/AppList.vue index 9f53cba25e314..b9199553a9484 100644 --- a/apps/settings/src/components/AppList.vue +++ b/apps/settings/src/components/AppList.vue @@ -168,10 +168,10 @@ export default { return this.apps.filter(app => app.update).length }, loading() { - if (!this.$store.getters['app_api_apps/isAppApiEnabled']) { + if (!this.$store.getters['appApiApps/isAppApiEnabled']) { return this.$store.getters.loading('list') } - return this.$store.getters.loading('list') || this.$store.getters['app_api_apps/loading']('list') + return this.$store.getters.loading('list') || this.$store.getters['appApiApps/loading']('list') }, hasPendingUpdate() { return this.apps.filter(app => app.update).length > 0 @@ -181,7 +181,7 @@ export default { }, apps() { // Exclude ExApps from the list if AppAPI is disabled - const exApps = this.$store.getters.isAppApiEnabled ? this.$store.getters['app_api_apps/getAllApps'] : [] + const exApps = this.$store.getters.isAppApiEnabled ? this.$store.getters['appApiApps/getAllApps'] : [] const apps = [...this.$store.getters.getAllApps, ...exApps] .filter(app => app.name.toLowerCase().search(this.search.toLowerCase()) !== -1) .sort(function(a, b) { @@ -310,13 +310,12 @@ export default { this.apps .filter(app => app.update) .map(app => limit(() => { - let type = 'updateApp' - if (app?.app_api) { - type = 'app_api_apps/updateApp' - } - this.$store.dispatch(type, { appId: app.id }) - }), - ) + let type = 'updateApp' + if (app?.app_api) { + type = 'appApiApps/updateApp' + } + this.$store.dispatch(type, { appId: app.id }) + })) }, }, } diff --git a/apps/settings/src/components/AppList/AppDaemonBadge.vue b/apps/settings/src/components/AppList/AppDaemonBadge.vue index 1a46d3c7ebb70..1a5cd5bbea3c1 100644 --- a/apps/settings/src/components/AppList/AppDaemonBadge.vue +++ b/apps/settings/src/components/AppList/AppDaemonBadge.vue @@ -16,7 +16,7 @@ import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js import { mdiFileChart } from '@mdi/js' import type { IDeployDaemon } from '../../app-types.ts' -const props = defineProps<{ +defineProps<{ daemon?: IDeployDaemon }>() diff --git a/apps/settings/src/components/AppList/AppItem.vue b/apps/settings/src/components/AppList/AppItem.vue index 96f8160534f7a..4ff51e938d271 100644 --- a/apps/settings/src/components/AppList/AppItem.vue +++ b/apps/settings/src/components/AppList/AppItem.vue @@ -16,9 +16,9 @@ :headers="getDataItemHeaders(`app-table-col-icon`)">
    + :path="mdiCogOutline()" + :size="listView ? 24 : 48" + style="min-width: auto; min-height: auto; height: 100%;" /> () -
    + :checked="removeData" + :disabled="installing || isLoading || !defaultDeployDaemonAccessible" + @update:checked="toggleRemoveData"> {{ t('settings', 'Delete data on remove') }}
diff --git a/apps/settings/src/mixins/AppManagement.js b/apps/settings/src/mixins/AppManagement.js index d3ebc98db4ae5..0254b4b717216 100644 --- a/apps/settings/src/mixins/AppManagement.js +++ b/apps/settings/src/mixins/AppManagement.js @@ -13,13 +13,13 @@ export default { }, installing() { if (this.app?.app_api) { - return this.app && this.$store.getters['app_api_apps/loading']('install') + return this.app && this.$store.getters['appApiApps/loading']('install') } return this.$store.getters.loading('install') }, isLoading() { if (this.app?.app_api) { - return this.app && this.$store.getters['app_api_apps/loading'](this.app.id) + return this.app && this.$store.getters['appApiApps/loading'](this.app.id) } return this.app && this.$store.getters.loading(this.app.id) }, @@ -108,9 +108,9 @@ export default { return true } if (this.app?.daemon?.accepts_deploy_id === 'docker-install') { - return this.$store.getters['app_api_apps/getDaemonAccessible'] === true + return this.$store.getters['appApiApps/getDaemonAccessible'] === true } - return this.$store.getters['app_api_apps/getDaemonAccessible'] + return this.$store.getters['appApiApps/getDaemonAccessible'] } return true }, @@ -136,7 +136,7 @@ export default { if (this.app?.app_api) { return false } - return this.app.groups.length || this.groupCheckedAppsData; + return this.app.groups.length || this.groupCheckedAppsData }, setGroupLimit() { if (this.app?.app_api) { @@ -179,7 +179,7 @@ export default { forceEnable(appId) { let type = 'forceEnableApp' if (this.app?.app_api) { - type = 'app_api_apps/forceEnableApp' + type = 'appApiApps/forceEnableApp' } this.$store.dispatch(type, { appId, groups: [] }) .then((response) => { rebuildNavigation() }) @@ -188,7 +188,7 @@ export default { enable(appId) { let type = 'enableApp' if (this.app?.app_api) { - type = 'app_api_apps/enableApp' + type = 'appApiApps/enableApp' } this.$store.dispatch(type, { appId, groups: [] }) .then((response) => { rebuildNavigation() }) @@ -197,7 +197,7 @@ export default { disable(appId) { let type = 'disableApp' if (this.app?.app_api) { - type = 'app_api_apps/disableApp' + type = 'appApiApps/disableApp' } this.$store.dispatch(type, { appId }) .then((response) => { rebuildNavigation() }) @@ -207,7 +207,7 @@ export default { let type = 'uninstallApp' let payload = { appId } if (this.app?.app_api) { - type = 'app_api_apps/uninstallApp' + type = 'appApiApps/uninstallApp' payload = { appId, removeData } } this.$store.dispatch(type, payload) @@ -217,7 +217,7 @@ export default { install(appId) { let type = 'enableApp' if (this.app?.app_api) { - type = 'app_api_apps/enableApp' + type = 'appApiApps/enableApp' } this.$store.dispatch(type, { appId }) .then((response) => { rebuildNavigation() }) @@ -226,7 +226,7 @@ export default { update(appId) { let type = 'updateApp' if (this.app?.app_api) { - type = 'app_api_apps/updateApp' + type = 'appApiApps/updateApp' } this.$store.dispatch(type, { appId }) .then((response) => { rebuildNavigation() }) diff --git a/apps/settings/src/store/app_api_apps.js b/apps/settings/src/store/app_api_apps.js index 4e505e6d8c8fd..390f1f4db1a32 100644 --- a/apps/settings/src/store/app_api_apps.js +++ b/apps/settings/src/store/app_api_apps.js @@ -392,5 +392,8 @@ const actions = { export default { namespaced: true, // we will use AppAPI store module explicitly, since methods names are the same, we need to scope it - state, mutations, getters, actions + state, + mutations, + getters, + actions, } diff --git a/apps/settings/src/store/apps.js b/apps/settings/src/store/apps.js index 5de9628f947fb..7364f96fae2ec 100644 --- a/apps/settings/src/store/apps.js +++ b/apps/settings/src/store/apps.js @@ -102,7 +102,7 @@ const mutations = { state.apps.find(app => app.id === appId).installed = false state.apps.find(app => app.id === appId).canUnInstall = false state.apps.find(app => app.id === appId).canInstall = true - if (app.id === 'app_api') { + if (appId === 'app_api') { state.appApiEnabled = false } }, diff --git a/apps/settings/src/store/index.js b/apps/settings/src/store/index.js index 2cf8767b9046d..62dab7246fcc6 100644 --- a/apps/settings/src/store/index.js +++ b/apps/settings/src/store/index.js @@ -7,7 +7,7 @@ import Vue from 'vue' import Vuex, { Store } from 'vuex' import users from './users.js' import apps from './apps.js' -import app_api_apps from './app_api_apps.js' +import appApiApps from './app_api_apps.js' import settings from './users-settings.js' import oc from './oc.js' import { showError } from '@nextcloud/dialogs' @@ -36,7 +36,7 @@ export const useStore = () => { modules: { users, apps, - app_api_apps, + appApiApps, settings, oc, }, diff --git a/apps/settings/src/views/AppStore.vue b/apps/settings/src/views/AppStore.vue index 5be2b118b9f55..0c922ce729689 100644 --- a/apps/settings/src/views/AppStore.vue +++ b/apps/settings/src/views/AppStore.vue @@ -59,17 +59,18 @@ onBeforeMount(() => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (instance?.proxy as any).$store.dispatch('getCategories', { shouldRefetchCategories: true }); // eslint-disable-next-line @typescript-eslint/no-explicit-any - (instance?.proxy as any).$store.dispatch('getAllApps'); + (instance?.proxy as any).$store.dispatch('getAllApps') + // eslint-disable-next-line @typescript-eslint/no-explicit-any if ((instance?.proxy as any).$store.getters.isAppApiEnabled) { // eslint-disable-next-line @typescript-eslint/no-explicit-any - (instance?.proxy as any).$store.dispatch('app_api_apps/getAllApps'); + (instance?.proxy as any).$store.dispatch('appApiApps/getAllApps'); // eslint-disable-next-line @typescript-eslint/no-explicit-any - (instance?.proxy as any).$store.dispatch('app_api_apps/updateAppsStatus'); + (instance?.proxy as any).$store.dispatch('appApiApps/updateAppsStatus') } }) onBeforeUnmount(() => { // eslint-disable-next-line @typescript-eslint/no-explicit-any - clearInterval((instance?.proxy as any).$store.getters('app_api_apps/getStatusUpdater')); + clearInterval((instance?.proxy as any).$store.getters('appApiApps/getStatusUpdater')) }) diff --git a/apps/settings/src/views/AppStoreSidebar.vue b/apps/settings/src/views/AppStoreSidebar.vue index fc2b94d8ee055..bb60e425308a2 100644 --- a/apps/settings/src/views/AppStoreSidebar.vue +++ b/apps/settings/src/views/AppStoreSidebar.vue @@ -65,7 +65,7 @@ const legacyStore = useStore() const appId = computed(() => route.params.id ?? '') const app = computed(() => { if (legacyStore.getters.isAppApiEnabled) { - const exApp = legacyStore.getters['app_api_apps/getAllApps'] + const exApp = legacyStore.getters['appApiApps/getAllApps'] .find((app) => app.id === appId.value) ?? null if (exApp) { return exApp From 011f2125a07af93d19c5181012642620a8c89023 Mon Sep 17 00:00:00 2001 From: Andrey Borysenko Date: Mon, 14 Oct 2024 17:19:07 +0300 Subject: [PATCH 08/14] fix(ci): suppress UndefinedClass for AppAPI related classes since it's bundled Signed-off-by: Andrey Borysenko --- apps/settings/lib/Controller/AppSettingsController.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/settings/lib/Controller/AppSettingsController.php b/apps/settings/lib/Controller/AppSettingsController.php index 29b5d2e11b018..1930c38efeebb 100644 --- a/apps/settings/lib/Controller/AppSettingsController.php +++ b/apps/settings/lib/Controller/AppSettingsController.php @@ -104,6 +104,9 @@ public function viewApps(): TemplateResponse { return $templateResponse; } + /** + * @psalm-suppress UndefinedClass + */ private function provideAppApiState(): void { $appApiEnabled = $this->appManager->isInstalled('app_api'); $this->initialState->provideInitialState('appApiEnabled', $appApiEnabled); From 698bbb6dbe4bd614bb79485042c62a2fc6cf332b Mon Sep 17 00:00:00 2001 From: Andrey Borysenko Date: Thu, 17 Oct 2024 03:38:05 +0300 Subject: [PATCH 09/14] WIP: address review comments, small fixes Signed-off-by: Andrey Borysenko --- .../lib/Controller/AppSettingsController.php | 42 ++++--------------- apps/settings/src/app-types.ts | 25 ++++++----- .../src/components/AppList/AppItem.vue | 8 +++- .../AppStoreSidebar/AppDeployDaemonTab.vue | 10 ++--- apps/settings/src/composables/useAppIcon.ts | 18 +++++--- apps/settings/src/mixins/AppManagement.js | 36 ++++++++-------- apps/settings/src/store/app_api_apps.js | 4 +- apps/settings/src/store/apps.js | 2 +- apps/settings/src/views/AppStoreSidebar.vue | 10 ++++- 9 files changed, 74 insertions(+), 81 deletions(-) diff --git a/apps/settings/lib/Controller/AppSettingsController.php b/apps/settings/lib/Controller/AppSettingsController.php index 1930c38efeebb..9909367992039 100644 --- a/apps/settings/lib/Controller/AppSettingsController.php +++ b/apps/settings/lib/Controller/AppSettingsController.php @@ -90,7 +90,12 @@ public function viewApps(): TemplateResponse { $this->initialState->provideInitialState('appstoreDeveloperDocs', $this->urlGenerator->linkToDocs('developer-manual')); $this->initialState->provideInitialState('appstoreUpdateCount', count($this->getAppsWithUpdates())); - $this->provideAppApiState(); + if ($this->appManager->isInstalled('app_api')) { + try { + Server::get(\OCA\AppAPI\Service\ExAppsPageService::class)->provideAppApiState($this->initialState); + } catch (\Psr\Container\NotFoundExceptionInterface|\Psr\Container\ContainerExceptionInterface $e) { + } + } $policy = new ContentSecurityPolicy(); $policy->addAllowedImageDomain('https://usercontent.apps.nextcloud.com'); @@ -104,40 +109,6 @@ public function viewApps(): TemplateResponse { return $templateResponse; } - /** - * @psalm-suppress UndefinedClass - */ - private function provideAppApiState(): void { - $appApiEnabled = $this->appManager->isInstalled('app_api'); - $this->initialState->provideInitialState('appApiEnabled', $appApiEnabled); - $daemonConfigAccessible = false; - $defaultDaemonConfig = null; - - if ($appApiEnabled) { - $exAppFetcher = Server::get(\OCA\AppAPI\Fetcher\ExAppFetcher::class); - $this->initialState->provideInitialState('appstoreExAppUpdateCount', count($exAppFetcher->getExAppsWithUpdates())); - - $defaultDaemonConfigName = $this->config->getAppValue('app_api', 'default_daemon_config'); - if ($defaultDaemonConfigName !== '') { - $daemonConfigService = Server::get(\OCA\AppAPI\Service\DaemonConfigService::class); - $daemonConfig = $daemonConfigService->getDaemonConfigByName($defaultDaemonConfigName); - if ($daemonConfig !== null) { - $defaultDaemonConfig = $daemonConfig->jsonSerialize(); - unset($defaultDaemonConfig['deploy_config']['haproxy_password']); - $dockerActions = Server::get(\OCA\AppAPI\DeployActions\DockerActions::class); - $dockerActions->initGuzzleClient($daemonConfig); - $daemonConfigAccessible = $dockerActions->ping($dockerActions->buildDockerUrl($daemonConfig)); - if (!$daemonConfigAccessible) { - $this->logger->warning(sprintf('Deploy daemon "%s" is not accessible by Nextcloud. Please verify its configuration', $daemonConfig->getName())); - } - } - } - } - - $this->initialState->provideInitialState('defaultDaemonConfigAccessible', $daemonConfigAccessible); - $this->initialState->provideInitialState('defaultDaemonConfig', $defaultDaemonConfig); - } - /** * Get all active entries for the app discover section */ @@ -451,6 +422,7 @@ private function getAppsForCategory($requestedCategory = ''): array { $formattedApps[] = [ 'id' => $app['id'], + 'app_api' => false, 'name' => $app['translations'][$currentLanguage]['name'] ?? $app['translations']['en']['name'], 'description' => $app['translations'][$currentLanguage]['description'] ?? $app['translations']['en']['description'], 'summary' => $app['translations'][$currentLanguage]['summary'] ?? $app['translations']['en']['summary'], diff --git a/apps/settings/src/app-types.ts b/apps/settings/src/app-types.ts index 3ce2aa1648c9b..da7a62e47cf58 100644 --- a/apps/settings/src/app-types.ts +++ b/apps/settings/src/app-types.ts @@ -41,6 +41,7 @@ export interface IAppstoreApp { preview?: string screenshot?: string + app_api: boolean active: boolean internal: boolean removeable: boolean @@ -48,6 +49,8 @@ export interface IAppstoreApp { canInstall: boolean canUninstall: boolean isCompatible: boolean + needsDownload: boolean + update: string | null appstoreData: Record releases?: IAppstoreAppRelease[] @@ -75,18 +78,18 @@ export interface IDeployDaemon { } export interface IExAppStatus { - action: string, - deploy: number, - deploy_start_time: number, - error: string, - init: number, - init_start_time: number, - type: string, + action: string + deploy: number + deploy_start_time: number + error: string + init: number + init_start_time: number + type: string } export interface IAppstoreExApp extends IAppstoreApp { - daemon: IDeployDaemon, - status: IExAppStatus, - error: string, - app_api: boolean, + daemon: IDeployDaemon | null | undefined + status: IExAppStatus | Record + error: string + removable: boolean } diff --git a/apps/settings/src/components/AppList/AppItem.vue b/apps/settings/src/components/AppList/AppItem.vue index 4ff51e938d271..6ef6eba1d9e34 100644 --- a/apps/settings/src/components/AppList/AppItem.vue +++ b/apps/settings/src/components/AppList/AppItem.vue @@ -14,8 +14,8 @@ -
- + @@ -79,6 +79,7 @@ {{ t('settings', 'Update to {update}', {update:app.update}) }} @@ -176,6 +177,9 @@ export default { withSidebar() { return !!this.$route.params.id }, + shouldDisplayDefaultIcon() { + return this.listView && !this.app.preview || !this.listView && !this.screenshotLoaded + }, }, watch: { '$route.params.id'(id) { diff --git a/apps/settings/src/components/AppStoreSidebar/AppDeployDaemonTab.vue b/apps/settings/src/components/AppStoreSidebar/AppDeployDaemonTab.vue index 8eb8f943ea4e4..36087cdd61744 100644 --- a/apps/settings/src/components/AppStoreSidebar/AppDeployDaemonTab.vue +++ b/apps/settings/src/components/AppStoreSidebar/AppDeployDaemonTab.vue @@ -12,11 +12,11 @@
-

{{ t('app_api', 'Deploy Daemon') }}

-

{{ t('app_api', 'Type') }}: {{ app?.daemon.accepts_deploy_id }}

-

{{ t('app_api', 'Name') }}: {{ app?.daemon.name }}

-

{{ t('app_api', 'Display Name') }}: {{ app?.daemon.display_name }}

-

{{ t('app_api', 'GPUs support') }}: {{ app?.daemon.deploy_config?.computeDevice?.id !== 'cpu' || 'false' }}

+

{{ t('settings', 'Deploy Daemon') }}

+

{{ t('settings', 'Type') }}: {{ app?.daemon.accepts_deploy_id }}

+

{{ t('settings', 'Name') }}: {{ app?.daemon.name }}

+

{{ t('settings', 'Display Name') }}: {{ app?.daemon.display_name }}

+

{{ t('settings', 'GPUs support') }}: {{ app?.daemon.deploy_config?.computeDevice?.id !== 'cpu' || 'false' }}

diff --git a/apps/settings/src/composables/useAppIcon.ts b/apps/settings/src/composables/useAppIcon.ts index 76efea267a83f..60c3ee6e3ea40 100644 --- a/apps/settings/src/composables/useAppIcon.ts +++ b/apps/settings/src/composables/useAppIcon.ts @@ -5,7 +5,7 @@ import type { Ref } from 'vue' import type { IAppstoreApp } from '../app-types.ts' -import { mdiCog } from '@mdi/js' +import { mdiCog, mdiCogOutline } from '@mdi/js' import { computed, ref, watchEffect } from 'vue' import AppstoreCategoryIcons from '../constants/AppstoreCategoryIcons.ts' import logger from '../logger.ts' @@ -23,11 +23,17 @@ export function useAppIcon(app: Ref) { * Fallback value if no app icon available */ const categoryIcon = computed(() => { - const path = [app.value?.category ?? []].flat() - .map((name) => AppstoreCategoryIcons[name]) - .filter((icon) => !!icon) - .at(0) - ?? mdiCog + let path: string + if (app.value?.app_api) { + // Use different default icon for ExApps (AppAPI) + path = mdiCogOutline + } else { + path = [app.value?.category ?? []].flat() + .map((name) => AppstoreCategoryIcons[name]) + .filter((icon) => !!icon) + .at(0) + ?? (!app.value?.app_api ? mdiCog : mdiCogOutline) + } return path ? `` : null }) diff --git a/apps/settings/src/mixins/AppManagement.js b/apps/settings/src/mixins/AppManagement.js index 0254b4b717216..985b8237a447e 100644 --- a/apps/settings/src/mixins/AppManagement.js +++ b/apps/settings/src/mixins/AppManagement.js @@ -42,26 +42,26 @@ export default { return false }, updateButtonText() { - if (this.app?.daemon?.accepts_deploy_id === 'manual-install') { - return t('app_api', 'manual-install apps cannot be updated') + if (this.app?.app_api && this.app?.daemon?.accepts_deploy_id === 'manual-install') { + return t('settings', 'manual-install apps cannot be updated') } - return '' + return t('settings', 'Update to {version}', { version: this.app?.update }) }, enableButtonText() { if (this.app?.app_api) { - if (this.app && Object.hasOwn(this.app?.status, 'action') && this.app.status.action === 'deploy') { - return t('app_api', '{progress}% Deploying', { progress: this.app.status?.deploy }) + if (this.app && this.app?.status?.action && this.app?.status?.action === 'deploy') { + return t('settings', '{progress}% Deploying …', { progress: this.app?.status?.deploy ?? 0 }) } - if (this.app && Object.hasOwn(this.app?.status, 'action') && this.app.status.action === 'init') { - return t('app_api', '{progress}% Initializing', { progress: this.app.status?.init }) + if (this.app && this.app?.status?.action && this.app?.status?.action === 'init') { + return t('settings', '{progress}% Initializing …', { progress: this.app?.status?.init ?? 0 }) } - if (this.app && Object.hasOwn(this.app?.status, 'action') && this.app.status.action === 'healthcheck') { - return t('app_api', 'Healthchecking') + if (this.app && this.app?.status?.action && this.app?.status?.action === 'healthcheck') { + return t('settings', 'Health checking') } if (this.app.needsDownload) { - return t('app_api', 'Deploy and Enable') + return t('settings', 'Deploy and Enable') } - return t('app_api', 'Enable') + return t('settings', 'Enable') } else { if (this.app.needsDownload) { return t('settings', 'Download and enable') @@ -71,17 +71,17 @@ export default { }, disableButtonText() { if (this.app?.app_api) { - if (this.app && Object.hasOwn(this.app?.status, 'action') && this.app.status.action === 'deploy') { - return t('app_api', '{progress}% Deploying', { progress: this.app.status?.deploy }) + if (this.app && this.app?.status?.action && this.app?.status?.action === 'deploy') { + return t('settings', '{progress}% Deploying …', { progress: this.app?.status?.deploy }) } - if (this.app && Object.hasOwn(this.app?.status, 'action') && this.app.status.action === 'init') { - return t('app_api', '{progress}% Initializing', { progress: this.app.status?.init }) + if (this.app && this.app?.status?.action && this.app?.status?.action === 'init') { + return t('settings', '{progress}% Initializing …', { progress: this.app?.status?.init }) } - if (this.app && Object.hasOwn(this.app?.status, 'action') && this.app.status.action === 'healthcheck') { - return t('app_api', 'Healthchecking') + if (this.app && this.app?.status?.action && this.app?.status?.action === 'healthcheck') { + return t('settings', 'Health checking') } } - return t('app_api', 'Disable') + return t('settings', 'Disable') }, forceEnableButtonText() { if (this.app.needsDownload) { diff --git a/apps/settings/src/store/app_api_apps.js b/apps/settings/src/store/app_api_apps.js index 390f1f4db1a32..7470c8c44b189 100644 --- a/apps/settings/src/store/app_api_apps.js +++ b/apps/settings/src/store/app_api_apps.js @@ -25,7 +25,7 @@ const state = { const mutations = { APPS_API_FAILURE(state, error) { - showError(t('app_api', 'An error occurred during the request. Unable to proceed.') + '
' + error.error.response.data.data.message, { isHTML: true }) + showError(t('settings', 'An error occurred during the request. Unable to proceed.') + '
' + error.error.response.data.data.message, { isHTML: true }) console.error(state, error) }, @@ -227,7 +227,7 @@ const actions = { .catch(() => { context.commit('setError', { appId: [appId], - error: t('app_api', 'Error: This app cannot be enabled because it makes the server unstable'), + error: t('settings', 'Error: This app cannot be enabled because it makes the server unstable'), }) }) }) diff --git a/apps/settings/src/store/apps.js b/apps/settings/src/store/apps.js index 7364f96fae2ec..c58651a3cf5fb 100644 --- a/apps/settings/src/store/apps.js +++ b/apps/settings/src/store/apps.js @@ -16,7 +16,7 @@ const state = { updateCount: loadState('settings', 'appstoreUpdateCount', 0), loading: {}, gettingCategoriesPromise: null, - appApiEnabled: loadState('settings', 'appApiEnabled'), + appApiEnabled: loadState('settings', 'appApiEnabled', false), } const mutations = { diff --git a/apps/settings/src/views/AppStoreSidebar.vue b/apps/settings/src/views/AppStoreSidebar.vue index bb60e425308a2..c003174a1d298 100644 --- a/apps/settings/src/views/AppStoreSidebar.vue +++ b/apps/settings/src/views/AppStoreSidebar.vue @@ -84,7 +84,15 @@ const { appIcon } = useAppIcon(app) /** * The second text line shown on the sidebar */ -const licenseText = computed(() => app.value ? t('settings', 'Version {version}, {license}-licensed', { version: app.value.version, license: app.value.licence.toString().toUpperCase() }) : '') +const licenseText = computed(() => { + if (!app.value) { + return '' + } + if (app.value.license !== '') { + return t('settings', 'Version {version}, {license}-licensed', { version: app.value.version, license: app.value.licence.toString().toUpperCase() }) + } + return t('settings', 'Version {version}', { version: app.value.version }) +}) const activeTab = ref('details') watch([app], () => { activeTab.value = 'details' }) From bd74dc500ab3789925a466886cbedd4e87f7a807 Mon Sep 17 00:00:00 2001 From: Andrey Borysenko Date: Mon, 21 Oct 2024 15:39:49 +0300 Subject: [PATCH 10/14] WIP: migrate to Pinia, minor fixes Signed-off-by: Andrey Borysenko --- apps/settings/src/components/AppList.vue | 12 +- .../src/components/AppList/AppItem.vue | 6 +- .../AppStoreSidebar/AppDeployDaemonTab.vue | 8 +- .../AppStoreSidebar/AppDetailsTab.vue | 14 +- apps/settings/src/composables/useAppIcon.ts | 6 +- apps/settings/src/mixins/AppManagement.js | 96 +++--- apps/settings/src/store/app-api-store.ts | 295 ++++++++++++++++++ apps/settings/src/store/index.js | 2 - apps/settings/src/views/AppStore.vue | 11 +- apps/settings/src/views/AppStoreSidebar.vue | 4 +- 10 files changed, 392 insertions(+), 62 deletions(-) create mode 100644 apps/settings/src/store/app-api-store.ts diff --git a/apps/settings/src/components/AppList.vue b/apps/settings/src/components/AppList.vue index b9199553a9484..7002c3481b307 100644 --- a/apps/settings/src/components/AppList.vue +++ b/apps/settings/src/components/AppList.vue @@ -143,6 +143,7 @@ import { subscribe, unsubscribe } from '@nextcloud/event-bus' import AppItem from './AppList/AppItem.vue' import pLimit from 'p-limit' import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' +import { useAppApiStore } from '../store/app-api-store' export default { name: 'AppList', @@ -158,6 +159,13 @@ export default { }, }, + setup() { + const appApiStore = useAppApiStore() + return { + appApiStore, + } + }, + data() { return { search: '', @@ -171,7 +179,7 @@ export default { if (!this.$store.getters['appApiApps/isAppApiEnabled']) { return this.$store.getters.loading('list') } - return this.$store.getters.loading('list') || this.$store.getters['appApiApps/loading']('list') + return this.$store.getters.loading('list') || this.appApiStore.getLoading('list') }, hasPendingUpdate() { return this.apps.filter(app => app.update).length > 0 @@ -181,7 +189,7 @@ export default { }, apps() { // Exclude ExApps from the list if AppAPI is disabled - const exApps = this.$store.getters.isAppApiEnabled ? this.$store.getters['appApiApps/getAllApps'] : [] + const exApps = this.$store.getters.isAppApiEnabled ? this.appApiStore.getAllApps : [] const apps = [...this.$store.getters.getAllApps, ...exApps] .filter(app => app.name.toLowerCase().search(this.search.toLowerCase()) !== -1) .sort(function(a, b) { diff --git a/apps/settings/src/components/AppList/AppItem.vue b/apps/settings/src/components/AppList/AppItem.vue index 6ef6eba1d9e34..b450d0237ae26 100644 --- a/apps/settings/src/components/AppList/AppItem.vue +++ b/apps/settings/src/components/AppList/AppItem.vue @@ -83,7 +83,7 @@ @click.stop="update(app.id)"> {{ t('settings', 'Update to {update}', {update:app.update}) }} - {{ disableButtonText }} @@ -178,7 +178,7 @@ export default { return !!this.$route.params.id }, shouldDisplayDefaultIcon() { - return this.listView && !this.app.preview || !this.listView && !this.screenshotLoaded + return (this.listView && !this.app.preview) || (!this.listView && !this.screenshotLoaded) }, }, watch: { diff --git a/apps/settings/src/components/AppStoreSidebar/AppDeployDaemonTab.vue b/apps/settings/src/components/AppStoreSidebar/AppDeployDaemonTab.vue index 36087cdd61744..a082ab326cc35 100644 --- a/apps/settings/src/components/AppStoreSidebar/AppDeployDaemonTab.vue +++ b/apps/settings/src/components/AppStoreSidebar/AppDeployDaemonTab.vue @@ -16,7 +16,8 @@

{{ t('settings', 'Type') }}: {{ app?.daemon.accepts_deploy_id }}

{{ t('settings', 'Name') }}: {{ app?.daemon.name }}

{{ t('settings', 'Display Name') }}: {{ app?.daemon.display_name }}

-

{{ t('settings', 'GPUs support') }}: {{ app?.daemon.deploy_config?.computeDevice?.id !== 'cpu' || 'false' }}

+

{{ t('settings', 'GPUs support') }}: {{ gpuSupport }}

+

{{ t('settings', 'Compute device') }}: {{ app?.daemon?.deploy_config?.computeDevice?.label }}

@@ -28,10 +29,13 @@ import NcAppSidebarTab from '@nextcloud/vue/dist/Components/NcAppSidebarTab.js' import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' import { mdiFileChart } from '@mdi/js' +import { ref } from 'vue' -defineProps<{ +const props = defineProps<{ app: IAppstoreExApp, }>() + +const gpuSupport = ref(props.app?.daemon?.deploy_config?.computeDevice?.id !== 'cpu' || false)