From 82b848af0cdadb6ab57580bea6b431a4109c7796 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Tue, 26 Jul 2022 21:44:08 +0200 Subject: [PATCH] refactor(azure): migrate module to react [EE-2782] (#6689) * refactor(azure): migrate module to react [EE-2782] * fix(azure): remove optional chain * feat(azure): apply new icons in dashboard * feat(azure): apply new icons in dashboard * feat(ui): allow single string for breadcrumbs * refactor(azure/containers): use Table.content * feat(azure/containers): implement new ui [EE-3538] * fix(azure/containers): use correct icon * chore(tests): mock svg as component * fix(azure): fix tests Co-authored-by: matias.spinarolli --- .../CreateContainerInstanceForm/index.ts | 1 - .../useLoadFormState.ts | 172 ----------- app/azure/ContainerInstances/index.ts | 11 - app/azure/Dashboard/DashboardView.tsx | 79 ------ app/azure/Dashboard/index.ts | 1 - app/azure/_module.js | 86 ------ .../containerGroupsDatatable.html | 114 -------- .../containerGroupsDatatable.js | 13 - app/azure/index.ts | 91 ++++++ app/azure/models/container_group.js | 50 ---- app/azure/models/location.js | 6 - app/azure/models/provider.ts | 21 -- app/azure/models/resource_group.js | 6 - app/azure/models/subscription.js | 4 - app/azure/queries.ts | 70 ----- app/azure/react/views/index.ts | 16 +- app/azure/rest/azure.js | 20 -- app/azure/rest/container_group.js | 48 ---- app/azure/rest/location.js | 18 -- app/azure/rest/provider.js | 18 -- app/azure/rest/resource_group.js | 19 -- app/azure/rest/subscription.js | 18 -- app/azure/services/azureService.js | 75 ----- app/azure/services/containerGroupService.js | 35 --- app/azure/services/locationService.js | 29 -- app/azure/services/provider.service.ts | 29 -- app/azure/services/resource-groups.service.ts | 42 --- app/azure/services/resourceGroupService.js | 18 -- app/azure/services/subscription.service.ts | 30 -- app/azure/services/subscriptionService.js | 14 - .../containerInstanceDetails.html | 131 --------- .../containerInstanceDetailsController.js | 45 --- .../container-instance-details/index.js | 6 - .../containerInstancesController.js | 44 --- .../containerinstances.html | 14 - app/index.js | 4 +- app/portainer/__module.js | 2 + .../AccessControlPanel/AccessControlPanel.tsx | 6 +- .../AccessControlPanelForm.tsx | 6 +- .../models/ResourceControlViewModel.ts | 2 +- .../azure-endpoint-config.js | 4 +- .../azureEndpointConfig.html | 0 app/portainer/environments/index.ts | 7 + app/portainer/services/types.ts | 11 + app/react/azure/.keep | 0 .../DashboardView}/DashboardView.test.tsx | 36 ++- .../azure/DashboardView/DashboardView.tsx | 53 ++++ .../azure/DashboardView/icon-subscription.svg | 3 + app/react/azure/DashboardView/index.ts | 1 + app/react/azure/container-instances/.keep | 0 .../container-instances/CreateView/.keep | 0 .../CreateContainerInstanceForm.test.tsx | 0 .../CreateContainerInstanceForm.tsx | 40 +-- .../CreateContainerInstanceForm.validation.ts | 0 .../CreateView/CreateView.tsx} | 9 +- .../CreateView}/PortsMappingField.module.css | 0 .../CreateView}/PortsMappingField.tsx | 59 +++- .../PortsMappingField.validation.ts | 0 .../container-instances/CreateView/index.ts | 1 + .../CreateView}/useCreateInstanceMutation.tsx | 11 +- .../CreateView/useLoadFormState.ts | 85 ++++++ .../container-instances/CreateView}/utils.ts | 3 +- .../azure/container-instances/ItemView/.keep | 0 .../container-instances/ItemView/ItemView.tsx | 266 ++++++++++++++++++ .../container-instances/ItemView/index.ts | 1 + .../azure/container-instances/ListView/.keep | 0 .../ListView/ContainersDatatable.tsx | 216 ++++++++++++++ .../container-instances/ListView/ListView.tsx | 97 +++++++ .../ListView/columns/index.ts | 10 + .../ListView/columns/location.ts | 13 + .../ListView/columns/name.tsx | 31 ++ .../ListView/columns/ownership.tsx | 37 +++ .../ListView/columns/ports.tsx | 34 +++ .../container-instances/ListView/index.ts | 1 + .../container-instances/ListView/types.ts | 8 + app/react/azure/queries/query-keys.ts | 47 ++++ app/react/azure/queries/useContainerGroup.ts | 59 ++++ app/react/azure/queries/useContainerGroups.ts | 59 ++++ app/react/azure/queries/useProvider.ts | 87 ++++++ app/react/azure/queries/useResourceGroup.ts | 46 +++ app/react/azure/queries/useResourceGroups.ts | 72 +++++ app/react/azure/queries/useSubscription.ts | 44 +++ app/react/azure/queries/useSubscriptions.ts | 37 +++ app/react/azure/queries/utils.ts | 51 ++++ .../services/container-groups.service.ts | 22 +- app/{ => react}/azure/services/utils.ts | 0 app/{ => react}/azure/types.ts | 20 +- app/react/azure/utils.ts | 20 ++ .../PageHeader/Breadcrumbs/Breadcrumbs.tsx | 10 +- .../components/PageHeader/PageHeader.tsx | 2 +- app/react/components/buttons/AddButton.tsx | 4 +- .../components/datatables/TableTitle.tsx | 6 +- .../components/datatables/useRowSelect.ts | 13 +- .../ButtonSelector/ButtonSelector.tsx | 25 +- .../form-components/InputList/InputList.tsx | 55 ++-- ...ustomTemplatesVariablesDefinitionField.tsx | 21 +- jest.config.js | 2 +- 97 files changed, 1723 insertions(+), 1430 deletions(-) delete mode 100644 app/azure/ContainerInstances/CreateContainerInstanceForm/index.ts delete mode 100644 app/azure/ContainerInstances/CreateContainerInstanceForm/useLoadFormState.ts delete mode 100644 app/azure/ContainerInstances/index.ts delete mode 100644 app/azure/Dashboard/DashboardView.tsx delete mode 100644 app/azure/Dashboard/index.ts delete mode 100644 app/azure/_module.js delete mode 100644 app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html delete mode 100644 app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.js create mode 100644 app/azure/index.ts delete mode 100644 app/azure/models/container_group.js delete mode 100644 app/azure/models/location.js delete mode 100644 app/azure/models/provider.ts delete mode 100644 app/azure/models/resource_group.js delete mode 100644 app/azure/models/subscription.js delete mode 100644 app/azure/queries.ts delete mode 100644 app/azure/rest/azure.js delete mode 100644 app/azure/rest/container_group.js delete mode 100644 app/azure/rest/location.js delete mode 100644 app/azure/rest/provider.js delete mode 100644 app/azure/rest/resource_group.js delete mode 100644 app/azure/rest/subscription.js delete mode 100644 app/azure/services/azureService.js delete mode 100644 app/azure/services/containerGroupService.js delete mode 100644 app/azure/services/locationService.js delete mode 100644 app/azure/services/provider.service.ts delete mode 100644 app/azure/services/resource-groups.service.ts delete mode 100644 app/azure/services/resourceGroupService.js delete mode 100644 app/azure/services/subscription.service.ts delete mode 100644 app/azure/services/subscriptionService.js delete mode 100644 app/azure/views/containerinstances/container-instance-details/containerInstanceDetails.html delete mode 100644 app/azure/views/containerinstances/container-instance-details/containerInstanceDetailsController.js delete mode 100644 app/azure/views/containerinstances/container-instance-details/index.js delete mode 100644 app/azure/views/containerinstances/containerInstancesController.js delete mode 100644 app/azure/views/containerinstances/containerinstances.html rename app/{azure/components => portainer/environments}/azure-endpoint-config/azure-endpoint-config.js (64%) rename app/{azure/components => portainer/environments}/azure-endpoint-config/azureEndpointConfig.html (100%) create mode 100644 app/portainer/environments/index.ts create mode 100644 app/portainer/services/types.ts delete mode 100644 app/react/azure/.keep rename app/{azure/Dashboard => react/azure/DashboardView}/DashboardView.test.tsx (76%) create mode 100644 app/react/azure/DashboardView/DashboardView.tsx create mode 100644 app/react/azure/DashboardView/icon-subscription.svg create mode 100644 app/react/azure/DashboardView/index.ts delete mode 100644 app/react/azure/container-instances/.keep delete mode 100644 app/react/azure/container-instances/CreateView/.keep rename app/{azure/ContainerInstances/CreateContainerInstanceForm => react/azure/container-instances/CreateView}/CreateContainerInstanceForm.test.tsx (100%) rename app/{azure/ContainerInstances/CreateContainerInstanceForm => react/azure/container-instances/CreateView}/CreateContainerInstanceForm.tsx (86%) rename app/{azure/ContainerInstances/CreateContainerInstanceForm => react/azure/container-instances/CreateView}/CreateContainerInstanceForm.validation.ts (100%) rename app/{azure/ContainerInstances/CreateContainerInstanceView.tsx => react/azure/container-instances/CreateView/CreateView.tsx} (76%) rename app/{azure/ContainerInstances/CreateContainerInstanceForm => react/azure/container-instances/CreateView}/PortsMappingField.module.css (100%) rename app/{azure/ContainerInstances/CreateContainerInstanceForm => react/azure/container-instances/CreateView}/PortsMappingField.tsx (60%) rename app/{azure/ContainerInstances/CreateContainerInstanceForm => react/azure/container-instances/CreateView}/PortsMappingField.validation.ts (100%) create mode 100644 app/react/azure/container-instances/CreateView/index.ts rename app/{azure/ContainerInstances/CreateContainerInstanceForm => react/azure/container-instances/CreateView}/useCreateInstanceMutation.tsx (83%) create mode 100644 app/react/azure/container-instances/CreateView/useLoadFormState.ts rename app/{azure/ContainerInstances/CreateContainerInstanceForm => react/azure/container-instances/CreateView}/utils.ts (87%) delete mode 100644 app/react/azure/container-instances/ItemView/.keep create mode 100644 app/react/azure/container-instances/ItemView/ItemView.tsx create mode 100644 app/react/azure/container-instances/ItemView/index.ts delete mode 100644 app/react/azure/container-instances/ListView/.keep create mode 100644 app/react/azure/container-instances/ListView/ContainersDatatable.tsx create mode 100644 app/react/azure/container-instances/ListView/ListView.tsx create mode 100644 app/react/azure/container-instances/ListView/columns/index.ts create mode 100644 app/react/azure/container-instances/ListView/columns/location.ts create mode 100644 app/react/azure/container-instances/ListView/columns/name.tsx create mode 100644 app/react/azure/container-instances/ListView/columns/ownership.tsx create mode 100644 app/react/azure/container-instances/ListView/columns/ports.tsx create mode 100644 app/react/azure/container-instances/ListView/index.ts create mode 100644 app/react/azure/container-instances/ListView/types.ts create mode 100644 app/react/azure/queries/query-keys.ts create mode 100644 app/react/azure/queries/useContainerGroup.ts create mode 100644 app/react/azure/queries/useContainerGroups.ts create mode 100644 app/react/azure/queries/useProvider.ts create mode 100644 app/react/azure/queries/useResourceGroup.ts create mode 100644 app/react/azure/queries/useResourceGroups.ts create mode 100644 app/react/azure/queries/useSubscription.ts create mode 100644 app/react/azure/queries/useSubscriptions.ts create mode 100644 app/react/azure/queries/utils.ts rename app/{ => react}/azure/services/container-groups.service.ts (76%) rename app/{ => react}/azure/services/utils.ts (100%) rename app/{ => react}/azure/types.ts (79%) create mode 100644 app/react/azure/utils.ts diff --git a/app/azure/ContainerInstances/CreateContainerInstanceForm/index.ts b/app/azure/ContainerInstances/CreateContainerInstanceForm/index.ts deleted file mode 100644 index 7b61077c8..000000000 --- a/app/azure/ContainerInstances/CreateContainerInstanceForm/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { CreateContainerInstanceForm } from './CreateContainerInstanceForm'; diff --git a/app/azure/ContainerInstances/CreateContainerInstanceForm/useLoadFormState.ts b/app/azure/ContainerInstances/CreateContainerInstanceForm/useLoadFormState.ts deleted file mode 100644 index ad421b9a8..000000000 --- a/app/azure/ContainerInstances/CreateContainerInstanceForm/useLoadFormState.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { useQueries, useQuery } from 'react-query'; -import { useEffect } from 'react'; - -import * as notifications from '@/portainer/services/notifications'; -import PortainerError from '@/portainer/error'; -import { EnvironmentId } from '@/portainer/environments/types'; -import { getResourceGroups } from '@/azure/services/resource-groups.service'; -import { getSubscriptions } from '@/azure/services/subscription.service'; -import { getContainerInstanceProvider } from '@/azure/services/provider.service'; -import { ContainerInstanceFormValues, Subscription } from '@/azure/types'; -import { parseAccessControlFormData } from '@/portainer/access-control/utils'; - -import { Option } from '@@/form-components/Input/Select'; - -import { - getSubscriptionLocations, - getSubscriptionResourceGroups, -} from './utils'; - -export function useLoadFormState( - environmentId: EnvironmentId, - isUserAdmin: boolean -) { - const { subscriptions, isLoading: isLoadingSubscriptions } = - useSubscriptions(environmentId); - const { resourceGroups, isLoading: isLoadingResourceGroups } = - useResourceGroups(environmentId, subscriptions); - const { providers, isLoading: isLoadingProviders } = useProviders( - environmentId, - subscriptions - ); - - const subscriptionOptions = - subscriptions?.map((s) => ({ - value: s.subscriptionId, - label: s.displayName, - })) || []; - - const initSubscriptionId = getFirstValue(subscriptionOptions); - - const subscriptionResourceGroups = getSubscriptionResourceGroups( - initSubscriptionId, - resourceGroups - ); - - const subscriptionLocations = getSubscriptionLocations( - initSubscriptionId, - providers - ); - - const initialValues: ContainerInstanceFormValues = { - name: '', - location: getFirstValue(subscriptionLocations), - subscription: initSubscriptionId, - resourceGroup: getFirstValue(subscriptionResourceGroups), - image: '', - os: 'Linux', - memory: 1, - cpu: 1, - ports: [{ container: '80', host: '80', protocol: 'TCP' }], - allocatePublicIP: true, - accessControl: parseAccessControlFormData(isUserAdmin), - }; - - return { - isUserAdmin, - initialValues, - subscriptions: subscriptionOptions, - resourceGroups, - providers, - isLoading: - isLoadingProviders || isLoadingResourceGroups || isLoadingSubscriptions, - }; - - function getFirstValue(arr: Option[]) { - if (arr.length === 0) { - return undefined; - } - - return arr[0].value; - } -} - -function useSubscriptions(environmentId: EnvironmentId) { - const { data, isError, error, isLoading } = useQuery( - 'azure.subscriptions', - () => getSubscriptions(environmentId) - ); - - useEffect(() => { - if (isError) { - notifications.error( - 'Failure', - error as PortainerError, - 'Unable to retrieve Azure resources' - ); - } - }, [isError, error]); - - return { subscriptions: data || [], isLoading }; -} - -function useResourceGroups( - environmentId: EnvironmentId, - subscriptions: Subscription[] -) { - const queries = useQueries( - subscriptions.map((subscription) => ({ - queryKey: ['azure.resourceGroups', subscription.subscriptionId], - queryFn: () => - getResourceGroups(environmentId, subscription.subscriptionId), - })) - ); - - useEffect(() => { - const failedQuery = queries.find((q) => q.error); - if (failedQuery) { - notifications.error( - 'Failure', - failedQuery.error as PortainerError, - 'Unable to retrieve Azure resources' - ); - } - }, [queries]); - - return { - resourceGroups: Object.fromEntries( - queries.map((q, index) => [ - subscriptions[index].subscriptionId, - q.data || [], - ]) - ), - isLoading: queries.some((q) => q.isLoading), - }; -} - -function useProviders( - environmentId: EnvironmentId, - subscriptions: Subscription[] -) { - const queries = useQueries( - subscriptions.map((subscription) => ({ - queryKey: [ - 'azure.containerInstanceProvider', - subscription.subscriptionId, - ], - queryFn: () => - getContainerInstanceProvider( - environmentId, - subscription.subscriptionId - ), - })) - ); - - useEffect(() => { - const failedQuery = queries.find((q) => q.error); - if (failedQuery) { - notifications.error( - 'Failure', - failedQuery.error as PortainerError, - 'Unable to retrieve Azure resources' - ); - } - }, [queries]); - - return { - providers: Object.fromEntries( - queries.map((q, index) => [subscriptions[index].subscriptionId, q.data]) - ), - isLoading: queries.some((q) => q.isLoading), - }; -} diff --git a/app/azure/ContainerInstances/index.ts b/app/azure/ContainerInstances/index.ts deleted file mode 100644 index 54236b999..000000000 --- a/app/azure/ContainerInstances/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import angular from 'angular'; - -import { CreateContainerInstanceViewAngular } from './CreateContainerInstanceView'; - -export const containerInstancesModule = angular - .module('portainer.azure.containerInstances', []) - - .component( - 'createContainerInstanceView', - CreateContainerInstanceViewAngular - ).name; diff --git a/app/azure/Dashboard/DashboardView.tsx b/app/azure/Dashboard/DashboardView.tsx deleted file mode 100644 index b3286feb3..000000000 --- a/app/azure/Dashboard/DashboardView.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { useEffect } from 'react'; - -import { useEnvironmentId } from '@/portainer/hooks/useEnvironmentId'; -import { error as notifyError } from '@/portainer/services/notifications'; -import PortainerError from '@/portainer/error'; -import { r2a } from '@/react-tools/react2angular'; - -import { DashboardItem } from '@@/DashboardItem'; -import { PageHeader } from '@@/PageHeader'; -import { DashboardGrid } from '@@/DashboardItem/DashboardGrid'; - -import { useResourceGroups, useSubscriptions } from '../queries'; - -export function DashboardView() { - const environmentId = useEnvironmentId(); - - const subscriptionsQuery = useSubscriptions(environmentId); - useEffect(() => { - if (subscriptionsQuery.isError) { - notifyError( - 'Failure', - subscriptionsQuery.error as PortainerError, - 'Unable to retrieve subscriptions' - ); - } - }, [subscriptionsQuery.error, subscriptionsQuery.isError]); - - const resourceGroupsQuery = useResourceGroups( - environmentId, - subscriptionsQuery.data - ); - useEffect(() => { - if (resourceGroupsQuery.isError && resourceGroupsQuery.error) { - notifyError( - 'Failure', - resourceGroupsQuery.error as PortainerError, - `Unable to retrieve resource groups` - ); - } - }, [resourceGroupsQuery.error, resourceGroupsQuery.isError]); - - const isLoading = - subscriptionsQuery.isLoading || resourceGroupsQuery.isLoading; - if (isLoading) { - return null; - } - - const subscriptionsCount = subscriptionsQuery?.data?.length; - const resourceGroupsCount = Object.values( - resourceGroupsQuery?.resourceGroups - ).flatMap((x) => Object.values(x)).length; - - return ( - <> - - -
- {subscriptionsQuery.data && ( - - - {!resourceGroupsQuery.isError && !resourceGroupsQuery.isLoading && ( - - )} - - )} -
- - ); -} - -export const DashboardViewAngular = r2a(DashboardView, []); diff --git a/app/azure/Dashboard/index.ts b/app/azure/Dashboard/index.ts deleted file mode 100644 index a344c4fc4..000000000 --- a/app/azure/Dashboard/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { DashboardViewAngular, DashboardView } from './DashboardView'; diff --git a/app/azure/_module.js b/app/azure/_module.js deleted file mode 100644 index 90eb8f37a..000000000 --- a/app/azure/_module.js +++ /dev/null @@ -1,86 +0,0 @@ -import angular from 'angular'; - -import { DashboardViewAngular } from './Dashboard/DashboardView'; -import { containerInstancesModule } from './ContainerInstances'; -import { reactModule } from './react'; - -angular - .module('portainer.azure', ['portainer.app', containerInstancesModule, reactModule]) - .config([ - '$stateRegistryProvider', - function ($stateRegistryProvider) { - 'use strict'; - - var azure = { - name: 'azure', - url: '/azure', - parent: 'endpoint', - abstract: true, - onEnter: /* @ngInject */ function onEnter($async, $state, endpoint, EndpointProvider, Notifications, StateManager) { - return $async(async () => { - if (endpoint.Type !== 3) { - $state.go('portainer.home'); - return; - } - try { - EndpointProvider.setEndpointID(endpoint.Id); - EndpointProvider.setEndpointPublicURL(endpoint.PublicURL); - EndpointProvider.setOfflineModeFromStatus(endpoint.Status); - await StateManager.updateEndpointState(endpoint, []); - } catch (e) { - Notifications.error('Failed loading environment', e); - $state.go('portainer.home', {}, { reload: true }); - } - }); - }, - }; - - var containerInstances = { - name: 'azure.containerinstances', - url: '/containerinstances', - views: { - 'content@': { - templateUrl: './views/containerinstances/containerinstances.html', - controller: 'AzureContainerInstancesController', - }, - }, - }; - - var containerInstance = { - name: 'azure.containerinstances.container', - url: '/:id', - views: { - 'content@': { - component: 'containerInstanceDetails', - }, - }, - }; - - var containerInstanceCreation = { - name: 'azure.containerinstances.new', - url: '/new/', - views: { - 'content@': { - component: 'createContainerInstanceView', - }, - }, - }; - - var dashboard = { - name: 'azure.dashboard', - url: '/dashboard', - views: { - 'content@': { - component: 'dashboardView', - }, - }, - }; - - $stateRegistryProvider.register(azure); - $stateRegistryProvider.register(containerInstances); - $stateRegistryProvider.register(containerInstance); - $stateRegistryProvider.register(containerInstanceCreation); - $stateRegistryProvider.register(dashboard); - }, - ]) - .component('dashboardView', DashboardViewAngular); diff --git a/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html b/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html deleted file mode 100644 index abcc2e92a..000000000 --- a/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html +++ /dev/null @@ -1,114 +0,0 @@ -
- - -
-
{{ $ctrl.titleText }}
-
-
- - -
- -
- - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - Name - - - - - - Location - - - - Published Ports - - Ownership - - - -
- - - - - {{ item.Name | truncate: 50 }} - {{ item.Location }} - - {{ item.IPAddress }}:{{ p.host }} - - - - - - - {{ item.ResourceControl.Ownership ? item.ResourceControl.Ownership : item.ResourceControl.Ownership = $ctrl.RCO.ADMINISTRATORS }} - -
Loading...
No container available.
-
- -
-
-
diff --git a/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.js b/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.js deleted file mode 100644 index 22f77137c..000000000 --- a/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.js +++ /dev/null @@ -1,13 +0,0 @@ -angular.module('portainer.azure').component('containergroupsDatatable', { - templateUrl: './containerGroupsDatatable.html', - controller: 'GenericDatatableController', - bindings: { - titleText: '@', - titleIcon: '@', - dataset: '<', - tableKey: '@', - orderBy: '@', - reverseOrder: '<', - removeAction: '<', - }, -}); diff --git a/app/azure/index.ts b/app/azure/index.ts new file mode 100644 index 000000000..cb005b21a --- /dev/null +++ b/app/azure/index.ts @@ -0,0 +1,91 @@ +import angular from 'angular'; +import { StateRegistry, StateService } from '@uirouter/angularjs'; + +import { Environment } from '@/portainer/environments/types'; +import { notifyError } from '@/portainer/services/notifications'; +import { EndpointProvider, StateManager } from '@/portainer/services/types'; + +import { reactModule } from './react'; + +export const azureModule = angular + .module('portainer.azure', [reactModule]) + .config(config).name; + +/* @ngInject */ +function config($stateRegistryProvider: StateRegistry) { + const azure = { + name: 'azure', + url: '/azure', + parent: 'endpoint', + abstract: true, + onEnter: /* @ngInject */ function onEnter( + $async: (fn: () => Promise) => Promise, + $state: StateService, + endpoint: Environment, + EndpointProvider: EndpointProvider, + StateManager: StateManager + ) { + return $async(async () => { + if (endpoint.Type !== 3) { + $state.go('portainer.home'); + return; + } + try { + EndpointProvider.setEndpointID(endpoint.Id); + EndpointProvider.setEndpointPublicURL(endpoint.PublicURL); + EndpointProvider.setOfflineModeFromStatus(endpoint.Status); + await StateManager.updateEndpointState(endpoint); + } catch (e) { + notifyError('Failed loading environment', e as Error); + $state.go('portainer.home', {}, { reload: true }); + } + }); + }, + }; + + const containerInstances = { + name: 'azure.containerinstances', + url: '/containerinstances', + views: { + 'content@': { + component: 'containerInstancesView', + }, + }, + }; + + const containerInstance = { + name: 'azure.containerinstances.container', + url: '/:id', + views: { + 'content@': { + component: 'containerInstanceView', + }, + }, + }; + + const containerInstanceCreation = { + name: 'azure.containerinstances.new', + url: '/new/', + views: { + 'content@': { + component: 'createContainerInstanceView', + }, + }, + }; + + const dashboard = { + name: 'azure.dashboard', + url: '/dashboard', + views: { + 'content@': { + component: 'dashboardView', + }, + }, + }; + + $stateRegistryProvider.register(azure); + $stateRegistryProvider.register(containerInstances); + $stateRegistryProvider.register(containerInstance); + $stateRegistryProvider.register(containerInstanceCreation); + $stateRegistryProvider.register(dashboard); +} diff --git a/app/azure/models/container_group.js b/app/azure/models/container_group.js deleted file mode 100644 index b30f49109..000000000 --- a/app/azure/models/container_group.js +++ /dev/null @@ -1,50 +0,0 @@ -import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel'; -import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel'; - -export function ContainerGroupDefaultModel() { - this.Location = ''; - this.OSType = 'Linux'; - this.Name = ''; - this.Image = ''; - this.AllocatePublicIP = true; - this.Ports = [ - { - container: 80, - host: 80, - protocol: 'TCP', - }, - ]; - this.CPU = 1; - this.Memory = 1; - this.AccessControlData = new AccessControlFormData(); -} - -export function ContainerGroupViewModel(data) { - const addressPorts = data.properties.ipAddress ? data.properties.ipAddress.ports : []; - const container = data.properties.containers.length ? data.properties.containers[0] : {}; - const containerPorts = container ? container.properties.ports : []; - - this.Id = data.id; - this.Name = data.name; - this.Location = data.location; - this.IPAddress = data.properties.ipAddress ? data.properties.ipAddress.ip : ''; - this.Ports = addressPorts.length - ? addressPorts.map((binding, index) => { - const port = (containerPorts[index] && containerPorts[index].port) || undefined; - return { - container: port, - host: binding.port, - protocol: binding.protocol, - }; - }) - : []; - this.Image = container.properties.image || ''; - this.OSType = data.properties.osType; - this.AllocatePublicIP = data.properties.ipAddress && data.properties.ipAddress.type === 'Public'; - this.CPU = container.properties.resources.requests.cpu; - this.Memory = container.properties.resources.requests.memoryInGB; - - if (data.Portainer && data.Portainer.ResourceControl) { - this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); - } -} diff --git a/app/azure/models/location.js b/app/azure/models/location.js deleted file mode 100644 index 6d4031331..000000000 --- a/app/azure/models/location.js +++ /dev/null @@ -1,6 +0,0 @@ -export function LocationViewModel(data) { - this.Id = data.id; - this.SubscriptionId = data.subscriptionId; - this.DisplayName = data.displayName; - this.Name = data.name; -} diff --git a/app/azure/models/provider.ts b/app/azure/models/provider.ts deleted file mode 100644 index 37524eb09..000000000 --- a/app/azure/models/provider.ts +++ /dev/null @@ -1,21 +0,0 @@ -import _ from 'lodash'; - -import { ProviderResponse } from '../types'; - -export interface ProviderViewModel { - id: string; - namespace: string; - locations: string[]; -} - -export function parseViewModel({ - id, - namespace, - resourceTypes, -}: ProviderResponse): ProviderViewModel { - const containerGroupType = _.find(resourceTypes, { - resourceType: 'containerGroups', - }); - const { locations = [] } = containerGroupType || {}; - return { id, namespace, locations }; -} diff --git a/app/azure/models/resource_group.js b/app/azure/models/resource_group.js deleted file mode 100644 index 894ce326d..000000000 --- a/app/azure/models/resource_group.js +++ /dev/null @@ -1,6 +0,0 @@ -export function ResourceGroupViewModel(data, subscriptionId) { - this.Id = data.id; - this.SubscriptionId = subscriptionId; - this.Name = data.name; - this.Location = data.location; -} diff --git a/app/azure/models/subscription.js b/app/azure/models/subscription.js deleted file mode 100644 index eb9bfaf52..000000000 --- a/app/azure/models/subscription.js +++ /dev/null @@ -1,4 +0,0 @@ -export function SubscriptionViewModel(data) { - this.Id = data.subscriptionId; - this.Name = data.displayName; -} diff --git a/app/azure/queries.ts b/app/azure/queries.ts deleted file mode 100644 index 373b70314..000000000 --- a/app/azure/queries.ts +++ /dev/null @@ -1,70 +0,0 @@ -import _ from 'lodash'; -import { useQueries, useQuery } from 'react-query'; - -import { EnvironmentId } from '@/portainer/environments/types'; - -import { getResourceGroups } from './services/resource-groups.service'; -import { getSubscriptions } from './services/subscription.service'; -import { Subscription } from './types'; - -export function useSubscriptions(environmentId: EnvironmentId) { - return useQuery( - 'azure.subscriptions', - () => getSubscriptions(environmentId), - { - meta: { - error: { - title: 'Failure', - message: 'Unable to retrieve Azure subscriptions', - }, - }, - } - ); -} - -export function useResourceGroups( - environmentId: EnvironmentId, - subscriptions: Subscription[] = [] -) { - const queries = useQueries( - subscriptions.map((subscription) => ({ - queryKey: [ - 'azure', - environmentId, - 'subscriptions', - subscription.subscriptionId, - 'resourceGroups', - ], - queryFn: async () => { - const groups = await getResourceGroups( - environmentId, - subscription.subscriptionId - ); - return [subscription.subscriptionId, groups] as const; - }, - meta: { - error: { - title: 'Failure', - message: 'Unable to retrieve Azure resource groups', - }, - }, - })) - ); - - return { - resourceGroups: Object.fromEntries( - _.compact( - queries.map((q) => { - if (q.data) { - return q.data; - } - - return null; - }) - ) - ), - isLoading: queries.some((q) => q.isLoading), - isError: queries.some((q) => q.isError), - error: queries.find((q) => q.error)?.error || null, - }; -} diff --git a/app/azure/react/views/index.ts b/app/azure/react/views/index.ts index de5e95edc..6234d1286 100644 --- a/app/azure/react/views/index.ts +++ b/app/azure/react/views/index.ts @@ -1,6 +1,14 @@ import angular from 'angular'; -export const viewsModule = angular.module( - 'portainer.azure.react.views', - [] -).name; +import { r2a } from '@/react-tools/react2angular'; +import { CreateView } from '@/react/azure/container-instances/CreateView'; +import { ItemView } from '@/react/azure/container-instances/ItemView'; +import { ListView } from '@/react/azure/container-instances/ListView'; +import { DashboardView } from '@/react/azure/DashboardView'; + +export const viewsModule = angular + .module('portainer.azure.react.views', []) + .component('containerInstanceView', r2a(ItemView, [])) + .component('createContainerInstanceView', r2a(CreateView, [])) + .component('containerInstancesView', r2a(ListView, [])) + .component('dashboardView', r2a(DashboardView, [])).name; diff --git a/app/azure/rest/azure.js b/app/azure/rest/azure.js deleted file mode 100644 index f463624d6..000000000 --- a/app/azure/rest/azure.js +++ /dev/null @@ -1,20 +0,0 @@ -angular.module('portainer.azure').factory('Azure', [ - '$http', - 'API_ENDPOINT_ENDPOINTS', - 'EndpointProvider', - function AzureFactory($http, API_ENDPOINT_ENDPOINTS, EndpointProvider) { - 'use strict'; - - var service = {}; - - service.delete = function (id, apiVersion) { - var url = API_ENDPOINT_ENDPOINTS + '/' + EndpointProvider.endpointID() + '/azure' + id + '?api-version=' + apiVersion; - return $http({ - method: 'DELETE', - url: url, - }); - }; - - return service; - }, -]); diff --git a/app/azure/rest/container_group.js b/app/azure/rest/container_group.js deleted file mode 100644 index f347be44e..000000000 --- a/app/azure/rest/container_group.js +++ /dev/null @@ -1,48 +0,0 @@ -angular.module('portainer.azure').factory('ContainerGroup', [ - '$resource', - 'API_ENDPOINT_ENDPOINTS', - 'EndpointProvider', - function ContainerGroupFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { - 'use strict'; - - var resource = {}; - - var base = $resource( - API_ENDPOINT_ENDPOINTS + '/:endpointId/azure/subscriptions/:subscriptionId/providers/Microsoft.ContainerInstance/containerGroups', - { - endpointId: EndpointProvider.endpointID, - 'api-version': '2018-04-01', - }, - { - query: { method: 'GET', params: { subscriptionId: '@subscriptionId' } }, - } - ); - - var withResourceGroup = $resource( - API_ENDPOINT_ENDPOINTS + - '/:endpointId/azure/subscriptions/:subscriptionId/resourceGroups/:resourceGroupName/providers/Microsoft.ContainerInstance/containerGroups/:containerGroupName', - { - endpointId: EndpointProvider.endpointID, - 'api-version': '2018-04-01', - }, - { - create: { - method: 'PUT', - params: { - subscriptionId: '@subscriptionId', - resourceGroupName: '@resourceGroupName', - containerGroupName: '@containerGroupName', - }, - }, - get: { - method: 'GET', - }, - } - ); - - resource.query = base.query; - resource.create = withResourceGroup.create; - resource.get = withResourceGroup.get; - return resource; - }, -]); diff --git a/app/azure/rest/location.js b/app/azure/rest/location.js deleted file mode 100644 index 7503d9fc9..000000000 --- a/app/azure/rest/location.js +++ /dev/null @@ -1,18 +0,0 @@ -angular.module('portainer.azure').factory('Location', [ - '$resource', - 'API_ENDPOINT_ENDPOINTS', - 'EndpointProvider', - function LocationFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { - 'use strict'; - return $resource( - API_ENDPOINT_ENDPOINTS + '/:endpointId/azure/subscriptions/:subscriptionId/locations', - { - endpointId: EndpointProvider.endpointID, - 'api-version': '2016-06-01', - }, - { - query: { method: 'GET', params: { subscriptionId: '@subscriptionId' } }, - } - ); - }, -]); diff --git a/app/azure/rest/provider.js b/app/azure/rest/provider.js deleted file mode 100644 index b8e76d81e..000000000 --- a/app/azure/rest/provider.js +++ /dev/null @@ -1,18 +0,0 @@ -angular.module('portainer.azure').factory('Provider', [ - '$resource', - 'API_ENDPOINT_ENDPOINTS', - 'EndpointProvider', - function ProviderFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { - 'use strict'; - return $resource( - API_ENDPOINT_ENDPOINTS + '/:endpointId/azure/subscriptions/:subscriptionId/providers/:providerNamespace', - { - endpointId: EndpointProvider.endpointID, - 'api-version': '2018-02-01', - }, - { - get: { method: 'GET', params: { subscriptionId: '@subscriptionId', providerNamespace: '@providerNamespace' } }, - } - ); - }, -]); diff --git a/app/azure/rest/resource_group.js b/app/azure/rest/resource_group.js deleted file mode 100644 index f1f9b520a..000000000 --- a/app/azure/rest/resource_group.js +++ /dev/null @@ -1,19 +0,0 @@ -angular.module('portainer.azure').factory('ResourceGroup', [ - '$resource', - 'API_ENDPOINT_ENDPOINTS', - 'EndpointProvider', - function ResourceGroupFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { - 'use strict'; - return $resource( - API_ENDPOINT_ENDPOINTS + '/:endpointId/azure/subscriptions/:subscriptionId/resourcegroups/:resourceGroupName', - { - endpointId: EndpointProvider.endpointID, - 'api-version': '2018-02-01', - }, - { - query: { method: 'GET', params: { subscriptionId: '@subscriptionId' } }, - get: { method: 'GET' }, - } - ); - }, -]); diff --git a/app/azure/rest/subscription.js b/app/azure/rest/subscription.js deleted file mode 100644 index 49278e3e0..000000000 --- a/app/azure/rest/subscription.js +++ /dev/null @@ -1,18 +0,0 @@ -angular.module('portainer.azure').factory('Subscription', [ - '$resource', - 'API_ENDPOINT_ENDPOINTS', - 'EndpointProvider', - function SubscriptionFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { - 'use strict'; - return $resource( - API_ENDPOINT_ENDPOINTS + '/:endpointId/azure/subscriptions/:id', - { - endpointId: EndpointProvider.endpointID, - 'api-version': '2016-06-01', - }, - { - get: { method: 'GET', params: { id: '@id' } }, - } - ); - }, -]); diff --git a/app/azure/services/azureService.js b/app/azure/services/azureService.js deleted file mode 100644 index 9a7a0425f..000000000 --- a/app/azure/services/azureService.js +++ /dev/null @@ -1,75 +0,0 @@ -import { ResourceGroupViewModel } from '../models/resource_group'; -import { SubscriptionViewModel } from '../models/subscription'; -import { getResourceGroups } from './resource-groups.service'; -import { getSubscriptions } from './subscription.service'; - -angular.module('portainer.azure').factory('AzureService', AzureService); - -/* @ngInject */ -export function AzureService($q, Azure, $async, EndpointProvider, ContainerGroupService) { - 'use strict'; - var service = {}; - - service.deleteContainerGroup = function (id) { - return Azure.delete(id, '2018-04-01'); - }; - - service.subscriptions = async function subscriptions() { - return $async(async () => { - const environmentId = EndpointProvider.endpointID(); - const subscriptions = await getSubscriptions(environmentId); - return subscriptions.map((s) => new SubscriptionViewModel(s)); - }); - }; - - service.resourceGroups = function resourceGroups(subscriptions) { - return $async(async () => { - return retrieveResourcesForEachSubscription(subscriptions, async (subscriptionId) => { - const environmentId = EndpointProvider.endpointID(); - - const resourceGroups = await getResourceGroups(environmentId, subscriptionId); - return resourceGroups.map((r) => new ResourceGroupViewModel(r, subscriptionId)); - }); - }); - }; - - service.containerGroups = function (subscriptions) { - return retrieveResourcesForEachSubscription(subscriptions, ContainerGroupService.containerGroups); - }; - - service.aggregate = function (resourcesBySubscription) { - var aggregatedResources = []; - Object.keys(resourcesBySubscription).forEach(function (key) { - aggregatedResources = aggregatedResources.concat(resourcesBySubscription[key]); - }); - return aggregatedResources; - }; - - function retrieveResourcesForEachSubscription(subscriptions, resourceQuery) { - var deferred = $q.defer(); - - var resources = {}; - - var resourceQueries = []; - for (var i = 0; i < subscriptions.length; i++) { - var subscription = subscriptions[i]; - resourceQueries.push(resourceQuery(subscription.Id)); - } - - $q.all(resourceQueries) - .then(function success(data) { - for (var i = 0; i < data.length; i++) { - var result = data[i]; - resources[subscriptions[i].Id] = result; - } - deferred.resolve(resources); - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to retrieve resources', err: err }); - }); - - return deferred.promise; - } - - return service; -} diff --git a/app/azure/services/containerGroupService.js b/app/azure/services/containerGroupService.js deleted file mode 100644 index d8f6532b9..000000000 --- a/app/azure/services/containerGroupService.js +++ /dev/null @@ -1,35 +0,0 @@ -import { ContainerGroupViewModel } from '../models/container_group'; - -angular.module('portainer.azure').factory('ContainerGroupService', [ - '$q', - 'ContainerGroup', - function ContainerGroupServiceFactory($q, ContainerGroup) { - 'use strict'; - var service = {}; - - service.containerGroups = function (subscriptionId) { - var deferred = $q.defer(); - - ContainerGroup.query({ subscriptionId: subscriptionId }) - .$promise.then(function success(data) { - var containerGroups = data.value.map(function (item) { - return new ContainerGroupViewModel(item); - }); - deferred.resolve(containerGroups); - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to retrieve container groups', err: err }); - }); - - return deferred.promise; - }; - - service.containerGroup = containerGroup; - async function containerGroup(subscriptionId, resourceGroupName, containerGroupName) { - const containerGroup = await ContainerGroup.get({ subscriptionId, resourceGroupName, containerGroupName }).$promise; - return new ContainerGroupViewModel(containerGroup); - } - - return service; - }, -]); diff --git a/app/azure/services/locationService.js b/app/azure/services/locationService.js deleted file mode 100644 index a21e7fa0a..000000000 --- a/app/azure/services/locationService.js +++ /dev/null @@ -1,29 +0,0 @@ -import { LocationViewModel } from '../models/location'; - -angular.module('portainer.azure').factory('LocationService', [ - '$q', - 'Location', - function LocationServiceFactory($q, Location) { - 'use strict'; - var service = {}; - - service.locations = function (subscriptionId) { - var deferred = $q.defer(); - - Location.query({ subscriptionId: subscriptionId }) - .$promise.then(function success(data) { - var locations = data.value.map(function (item) { - return new LocationViewModel(item); - }); - deferred.resolve(locations); - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to retrieve locations', err: err }); - }); - - return deferred.promise; - }; - - return service; - }, -]); diff --git a/app/azure/services/provider.service.ts b/app/azure/services/provider.service.ts deleted file mode 100644 index 69c388429..000000000 --- a/app/azure/services/provider.service.ts +++ /dev/null @@ -1,29 +0,0 @@ -// import { ContainerInstanceProviderViewModel } from '../models/provider'; - -import { EnvironmentId } from '@/portainer/environments/types'; -import axios, { parseAxiosError } from '@/portainer/services/axios'; - -import { parseViewModel } from '../models/provider'; -import { ProviderResponse } from '../types'; - -import { azureErrorParser } from './utils'; - -export async function getContainerInstanceProvider( - environmentId: EnvironmentId, - subscriptionId: string -) { - try { - const url = `/endpoints/${environmentId}/azure/subscriptions/${subscriptionId}/providers/Microsoft.ContainerInstance`; - const { data } = await axios.get(url, { - params: { 'api-version': '2018-02-01' }, - }); - - return parseViewModel(data); - } catch (error) { - throw parseAxiosError( - error as Error, - 'Unable to retrieve provider', - azureErrorParser - ); - } -} diff --git a/app/azure/services/resource-groups.service.ts b/app/azure/services/resource-groups.service.ts deleted file mode 100644 index 6c0813533..000000000 --- a/app/azure/services/resource-groups.service.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { EnvironmentId } from '@/portainer/environments/types'; -import axios, { parseAxiosError } from '@/portainer/services/axios'; - -import { ResourceGroup } from '../types'; - -import { azureErrorParser } from './utils'; - -export async function getResourceGroups( - environmentId: EnvironmentId, - subscriptionId: string -) { - try { - const { - data: { value }, - } = await axios.get<{ value: ResourceGroup[] }>( - buildUrl(environmentId, subscriptionId), - { params: { 'api-version': '2018-02-01' } } - ); - - return value; - } catch (err) { - throw parseAxiosError( - err as Error, - 'Unable to retrieve resource groups', - azureErrorParser - ); - } -} - -function buildUrl( - environmentId: EnvironmentId, - subscriptionId: string, - resourceGroupName?: string -) { - let url = `/endpoints/${environmentId}/azure/subscriptions/${subscriptionId}/resourcegroups`; - - if (resourceGroupName) { - url += `/${resourceGroupName}`; - } - - return url; -} diff --git a/app/azure/services/resourceGroupService.js b/app/azure/services/resourceGroupService.js deleted file mode 100644 index e4040ea5f..000000000 --- a/app/azure/services/resourceGroupService.js +++ /dev/null @@ -1,18 +0,0 @@ -import { ResourceGroupViewModel } from '../models/resource_group'; - -angular.module('portainer.azure').factory('ResourceGroupService', [ - '$q', - 'ResourceGroup', - function ResourceGroupServiceFactory($q, ResourceGroup) { - 'use strict'; - var service = {}; - - service.resourceGroup = resourceGroup; - async function resourceGroup(subscriptionId, resourceGroupName) { - const group = await ResourceGroup.get({ subscriptionId, resourceGroupName }).$promise; - return new ResourceGroupViewModel(group); - } - - return service; - }, -]); diff --git a/app/azure/services/subscription.service.ts b/app/azure/services/subscription.service.ts deleted file mode 100644 index d07e954f4..000000000 --- a/app/azure/services/subscription.service.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { EnvironmentId } from '@/portainer/environments/types'; -import axios, { parseAxiosError } from '@/portainer/services/axios'; - -import { Subscription } from '../types'; - -import { azureErrorParser } from './utils'; - -export async function getSubscriptions(environmentId: EnvironmentId) { - try { - const { data } = await axios.get<{ value: Subscription[] }>( - buildUrl(environmentId) - ); - return data.value; - } catch (e) { - throw parseAxiosError( - e as Error, - 'Unable to retrieve subscriptions', - azureErrorParser - ); - } -} - -function buildUrl(environmentId: EnvironmentId, id?: string) { - let url = `/endpoints/${environmentId}/azure/subscriptions?api-version=2016-06-01`; - if (id) { - url += `/${id}`; - } - - return url; -} diff --git a/app/azure/services/subscriptionService.js b/app/azure/services/subscriptionService.js deleted file mode 100644 index 289ba5451..000000000 --- a/app/azure/services/subscriptionService.js +++ /dev/null @@ -1,14 +0,0 @@ -import { SubscriptionViewModel } from '../models/subscription'; - -angular.module('portainer.azure').factory('SubscriptionService', [ - '$q', - 'Subscription', - function SubscriptionServiceFactory($q, Subscription) { - return { subscription }; - - async function subscription(id) { - const subscription = await Subscription.get({ id }).$promise; - return new SubscriptionViewModel(subscription); - } - }, -]); diff --git a/app/azure/views/containerinstances/container-instance-details/containerInstanceDetails.html b/app/azure/views/containerinstances/container-instance-details/containerInstanceDetails.html deleted file mode 100644 index 20d7c546b..000000000 --- a/app/azure/views/containerinstances/container-instance-details/containerInstanceDetails.html +++ /dev/null @@ -1,131 +0,0 @@ - - -
-
- - -
-
Azure settings
- -
- -
- -
-
- - -
- -
- -
-
- - -
- -
- -
-
- -
Container configuration
- -
- -
- -
-
- - -
- -
- -
-
- - -
- -
- -
-
- - -
-
- -
- -
-
- -
- host - -
- - - - - -
- container - -
- - -
-
- - -
-
- -
-
- -
- - -
- -
- -
-
- -
Container resources
- -
- -
- -
-
- - -
- -
- -
-
- -
-
-
-
- - -
diff --git a/app/azure/views/containerinstances/container-instance-details/containerInstanceDetailsController.js b/app/azure/views/containerinstances/container-instance-details/containerInstanceDetailsController.js deleted file mode 100644 index 985fd2a57..000000000 --- a/app/azure/views/containerinstances/container-instance-details/containerInstanceDetailsController.js +++ /dev/null @@ -1,45 +0,0 @@ -import { ResourceControlType } from '@/portainer/access-control/types'; - -class ContainerInstanceDetailsController { - /* @ngInject */ - constructor($state, AzureService, ContainerGroupService, Notifications, ResourceGroupService, SubscriptionService) { - Object.assign(this, { $state, AzureService, ContainerGroupService, Notifications, ResourceGroupService, SubscriptionService }); - - this.state = { - loading: false, - }; - - this.resourceType = ResourceControlType.ContainerGroup; - - this.container = null; - this.subscription = null; - this.resourceGroup = null; - this.onUpdateSuccess = this.onUpdateSuccess.bind(this); - } - - onUpdateSuccess() { - this.$state.reload(); - } - - async $onInit() { - this.state.loading = true; - const { id } = this.$state.params; - const { subscriptionId, resourceGroupId, containerGroupId } = parseId(id); - try { - this.subscription = await this.SubscriptionService.subscription(subscriptionId); - this.container = await this.ContainerGroupService.containerGroup(subscriptionId, resourceGroupId, containerGroupId); - this.resourceGroup = await this.ResourceGroupService.resourceGroup(subscriptionId, resourceGroupId); - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to retrieve container instance details'); - } - this.state.loading = false; - } -} - -function parseId(id) { - const [, subscriptionId, resourceGroupId, , containerGroupId] = id.match(/^\/subscriptions\/(.+)\/resourceGroups\/(.+)\/providers\/(.+)\/containerGroups\/(.+)$/); - - return { subscriptionId, resourceGroupId, containerGroupId }; -} - -export default ContainerInstanceDetailsController; diff --git a/app/azure/views/containerinstances/container-instance-details/index.js b/app/azure/views/containerinstances/container-instance-details/index.js deleted file mode 100644 index 8e3b3d179..000000000 --- a/app/azure/views/containerinstances/container-instance-details/index.js +++ /dev/null @@ -1,6 +0,0 @@ -import ContainerInstanceDetailsController from './containerInstanceDetailsController.js'; - -angular.module('portainer.azure').component('containerInstanceDetails', { - templateUrl: './containerInstanceDetails.html', - controller: ContainerInstanceDetailsController, -}); diff --git a/app/azure/views/containerinstances/containerInstancesController.js b/app/azure/views/containerinstances/containerInstancesController.js deleted file mode 100644 index 4863d5cac..000000000 --- a/app/azure/views/containerinstances/containerInstancesController.js +++ /dev/null @@ -1,44 +0,0 @@ -angular.module('portainer.azure').controller('AzureContainerInstancesController', [ - '$scope', - '$state', - 'AzureService', - 'Notifications', - function ($scope, $state, AzureService, Notifications) { - function initView() { - AzureService.subscriptions() - .then(function success(data) { - var subscriptions = data; - return AzureService.containerGroups(subscriptions); - }) - .then(function success(data) { - $scope.containerGroups = AzureService.aggregate(data); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to load container groups'); - }); - } - - $scope.deleteAction = function (selectedItems) { - var actionCount = selectedItems.length; - angular.forEach(selectedItems, function (item) { - AzureService.deleteContainerGroup(item.Id) - .then(function success() { - Notifications.success('Container group successfully removed', item.Name); - var index = $scope.containerGroups.indexOf(item); - $scope.containerGroups.splice(index, 1); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to remove container group'); - }) - .finally(function final() { - --actionCount; - if (actionCount === 0) { - $state.reload(); - } - }); - }); - }; - - initView(); - }, -]); diff --git a/app/azure/views/containerinstances/containerinstances.html b/app/azure/views/containerinstances/containerinstances.html deleted file mode 100644 index 517c6a159..000000000 --- a/app/azure/views/containerinstances/containerinstances.html +++ /dev/null @@ -1,14 +0,0 @@ - - -
-
- -
-
diff --git a/app/index.js b/app/index.js index 5610ac6db..5c7aa6a61 100644 --- a/app/index.js +++ b/app/index.js @@ -9,7 +9,7 @@ import './matomo-setup'; import analyticsModule from './angulartics.matomo'; import './agent'; -import './azure/_module'; +import { azureModule } from './azure'; import './docker/__module'; import './edge/__module'; import './portainer/__module'; @@ -44,7 +44,7 @@ angular 'luegg.directives', 'portainer.app', 'portainer.agent', - 'portainer.azure', + azureModule, 'portainer.docker', 'portainer.kubernetes', nomadModule, diff --git a/app/portainer/__module.js b/app/portainer/__module.js index 8377350bb..dccf8cf8a 100644 --- a/app/portainer/__module.js +++ b/app/portainer/__module.js @@ -11,6 +11,7 @@ import homeModule from './home'; import { accessControlModule } from './access-control'; import { reactModule } from './react'; import { sidebarModule } from './react/views/sidebar'; +import environmentsModule from './environments'; async function initAuthentication(authManager, Authentication, $rootScope, $state) { authManager.checkAuthOnRefresh(); @@ -42,6 +43,7 @@ angular accessControlModule, reactModule, sidebarModule, + environmentsModule, ]) .config([ '$stateRegistryProvider', diff --git a/app/portainer/access-control/AccessControlPanel/AccessControlPanel.tsx b/app/portainer/access-control/AccessControlPanel/AccessControlPanel.tsx index 656ae5336..ee4a0d763 100644 --- a/app/portainer/access-control/AccessControlPanel/AccessControlPanel.tsx +++ b/app/portainer/access-control/AccessControlPanel/AccessControlPanel.tsx @@ -20,7 +20,7 @@ interface Props { resourceType: ResourceControlType; resourceId: ResourceId; disableOwnershipChange?: boolean; - onUpdateSuccess(): void; + onUpdateSuccess(): Promise; } export function AccessControlPanel({ @@ -80,8 +80,8 @@ export function AccessControlPanel({ ); - function handleUpdateSuccess() { - onUpdateSuccess(); + async function handleUpdateSuccess() { + await onUpdateSuccess(); toggleEditMode(); } diff --git a/app/portainer/access-control/AccessControlPanel/AccessControlPanelForm.tsx b/app/portainer/access-control/AccessControlPanel/AccessControlPanelForm.tsx index 3fdc8c1d9..8e5e6a700 100644 --- a/app/portainer/access-control/AccessControlPanel/AccessControlPanelForm.tsx +++ b/app/portainer/access-control/AccessControlPanel/AccessControlPanelForm.tsx @@ -28,7 +28,7 @@ interface Props { resourceId: ResourceId; resourceControl?: ResourceControlViewModel; onCancelClick(): void; - onUpdateSuccess(): void; + onUpdateSuccess(): Promise; } export function AccessControlPanelForm({ @@ -52,6 +52,9 @@ export function AccessControlPanelForm({ meta: { error: { title: 'Failure', message: 'Unable to update access control' }, }, + onSuccess() { + return onUpdateSuccess(); + }, } ); @@ -115,7 +118,6 @@ export function AccessControlPanelForm({ updateAccess.mutate(accessControl, { onSuccess() { notifySuccess('Access control successfully updated'); - onUpdateSuccess(); }, }); } diff --git a/app/portainer/access-control/models/ResourceControlViewModel.ts b/app/portainer/access-control/models/ResourceControlViewModel.ts index 0d1ff2a36..b0940e38b 100644 --- a/app/portainer/access-control/models/ResourceControlViewModel.ts +++ b/app/portainer/access-control/models/ResourceControlViewModel.ts @@ -37,7 +37,7 @@ export class ResourceControlViewModel { } } -function determineOwnership(resourceControl: ResourceControlResponse) { +export function determineOwnership(resourceControl: ResourceControlResponse) { if (resourceControl.Public) { return ResourceControlOwnership.PUBLIC; } diff --git a/app/azure/components/azure-endpoint-config/azure-endpoint-config.js b/app/portainer/environments/azure-endpoint-config/azure-endpoint-config.js similarity index 64% rename from app/azure/components/azure-endpoint-config/azure-endpoint-config.js rename to app/portainer/environments/azure-endpoint-config/azure-endpoint-config.js index ff09f0908..02ab69e04 100644 --- a/app/azure/components/azure-endpoint-config/azure-endpoint-config.js +++ b/app/portainer/environments/azure-endpoint-config/azure-endpoint-config.js @@ -1,8 +1,8 @@ -angular.module('portainer.azure').component('azureEndpointConfig', { +export const azureEndpointConfig = { bindings: { applicationId: '=', tenantId: '=', authenticationKey: '=', }, templateUrl: './azureEndpointConfig.html', -}); +}; diff --git a/app/azure/components/azure-endpoint-config/azureEndpointConfig.html b/app/portainer/environments/azure-endpoint-config/azureEndpointConfig.html similarity index 100% rename from app/azure/components/azure-endpoint-config/azureEndpointConfig.html rename to app/portainer/environments/azure-endpoint-config/azureEndpointConfig.html diff --git a/app/portainer/environments/index.ts b/app/portainer/environments/index.ts new file mode 100644 index 000000000..0efeac57a --- /dev/null +++ b/app/portainer/environments/index.ts @@ -0,0 +1,7 @@ +import angular from 'angular'; + +import { azureEndpointConfig } from './azure-endpoint-config/azure-endpoint-config'; + +export default angular + .module('portainer.environments', []) + .component('azureEndpointConfig', azureEndpointConfig).name; diff --git a/app/portainer/services/types.ts b/app/portainer/services/types.ts new file mode 100644 index 000000000..c3c2e63d9 --- /dev/null +++ b/app/portainer/services/types.ts @@ -0,0 +1,11 @@ +import { Environment } from '../environments/types'; + +export interface EndpointProvider { + setEndpointID(id: Environment['Id']): void; + setEndpointPublicURL(url?: string): void; + setOfflineModeFromStatus(status: Environment['Status']): void; +} + +export interface StateManager { + updateEndpointState(endpoint: Environment): Promise; +} diff --git a/app/react/azure/.keep b/app/react/azure/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/azure/Dashboard/DashboardView.test.tsx b/app/react/azure/DashboardView/DashboardView.test.tsx similarity index 76% rename from app/azure/Dashboard/DashboardView.test.tsx rename to app/react/azure/DashboardView/DashboardView.test.tsx index 2df4692be..07376133e 100644 --- a/app/azure/Dashboard/DashboardView.test.tsx +++ b/app/react/azure/DashboardView/DashboardView.test.tsx @@ -17,64 +17,60 @@ jest.mock('@uirouter/react', () => ({ })); test('dashboard items should render correctly', async () => { - const { getByLabelText } = await renderComponent(); + const { findByLabelText } = await renderComponent(); - const subscriptionsItem = getByLabelText('Subscription'); + const subscriptionsItem = await findByLabelText('Subscription'); expect(subscriptionsItem).toBeVisible(); const subscriptionElements = within(subscriptionsItem); expect(subscriptionElements.getByLabelText('value')).toBeVisible(); - expect(subscriptionElements.getByRole('img', { hidden: true })).toHaveClass( - 'fa-th-list' - ); + expect(subscriptionElements.getByLabelText('resourceType')).toHaveTextContent( 'Subscriptions' ); - const resourceGroupsItem = getByLabelText('Resource group'); + const resourceGroupsItem = await findByLabelText('Resource group'); expect(resourceGroupsItem).toBeVisible(); const resourceGroupElements = within(resourceGroupsItem); expect(resourceGroupElements.getByLabelText('value')).toBeVisible(); - expect(resourceGroupElements.getByRole('img', { hidden: true })).toHaveClass( - 'fa-th-list' - ); + expect( resourceGroupElements.getByLabelText('resourceType') ).toHaveTextContent('Resource groups'); }); test('when there are no subscriptions, should show 0 subscriptions and 0 resource groups', async () => { - const { getByLabelText } = await renderComponent(); + const { findByLabelText } = await renderComponent(); - const subscriptionElements = within(getByLabelText('Subscription')); + const subscriptionElements = within(await findByLabelText('Subscription')); expect(subscriptionElements.getByLabelText('value')).toHaveTextContent('0'); - const resourceGroupElements = within(getByLabelText('Resource group')); + const resourceGroupElements = within(await findByLabelText('Resource group')); expect(resourceGroupElements.getByLabelText('value')).toHaveTextContent('0'); }); test('when there is subscription & resource group data, should display these', async () => { - const { getByLabelText } = await renderComponent(1, { 'subscription-1': 2 }); + const { findByLabelText } = await renderComponent(1, { 'subscription-1': 2 }); - const subscriptionElements = within(getByLabelText('Subscription')); + const subscriptionElements = within(await findByLabelText('Subscription')); expect(subscriptionElements.getByLabelText('value')).toHaveTextContent('1'); - const resourceGroupElements = within(getByLabelText('Resource group')); + const resourceGroupElements = within(await findByLabelText('Resource group')); expect(resourceGroupElements.getByLabelText('value')).toHaveTextContent('2'); }); test('should correctly show total number of resource groups across multiple subscriptions', async () => { - const { getByLabelText } = await renderComponent(2, { + const { findByLabelText } = await renderComponent(2, { 'subscription-1': 2, 'subscription-2': 3, }); - const resourceGroupElements = within(getByLabelText('Resource group')); + const resourceGroupElements = within(await findByLabelText('Resource group')); expect(resourceGroupElements.getByLabelText('value')).toHaveTextContent('5'); }); -test('when only subscriptions fail to load, dont show the dashboard', async () => { +test("when only subscriptions fail to load, don't show the dashboard", async () => { const { queryByLabelText } = await renderComponent( 1, { 'subscription-1': 1 }, @@ -86,13 +82,13 @@ test('when only subscriptions fail to load, dont show the dashboard', async () = }); test('when only resource groups fail to load, still show the subscriptions', async () => { - const { queryByLabelText } = await renderComponent( + const { queryByLabelText, findByLabelText } = await renderComponent( 1, { 'subscription-1': 1 }, 200, 500 ); - expect(queryByLabelText('Subscription')).toBeInTheDocument(); + await expect(findByLabelText('Subscription')).resolves.toBeInTheDocument(); expect(queryByLabelText('Resource group')).not.toBeInTheDocument(); }); diff --git a/app/react/azure/DashboardView/DashboardView.tsx b/app/react/azure/DashboardView/DashboardView.tsx new file mode 100644 index 000000000..52ef97d13 --- /dev/null +++ b/app/react/azure/DashboardView/DashboardView.tsx @@ -0,0 +1,53 @@ +import { Package } from 'react-feather'; + +import { useEnvironmentId } from '@/portainer/hooks/useEnvironmentId'; + +import { PageHeader } from '@@/PageHeader'; +import { DashboardItem } from '@@/DashboardItem'; +import { DashboardGrid } from '@@/DashboardItem/DashboardGrid'; + +import { useResourceGroups } from '../queries/useResourceGroups'; +import { useSubscriptions } from '../queries/useSubscriptions'; + +import SubscriptionsIcon from './icon-subscription.svg?c'; + +export function DashboardView() { + const environmentId = useEnvironmentId(); + + const subscriptionsQuery = useSubscriptions(environmentId); + + const resourceGroupsQuery = useResourceGroups( + environmentId, + subscriptionsQuery.data + ); + + const subscriptionsCount = subscriptionsQuery.data?.length; + const resourceGroupsCount = Object.values( + resourceGroupsQuery.resourceGroups + ).flatMap((x) => Object.values(x)).length; + + return ( + <> + + +
+ {subscriptionsQuery.data && ( + + + {!resourceGroupsQuery.isError && !resourceGroupsQuery.isLoading && ( + + )} + + )} +
+ + ); +} diff --git a/app/react/azure/DashboardView/icon-subscription.svg b/app/react/azure/DashboardView/icon-subscription.svg new file mode 100644 index 000000000..58b7a8588 --- /dev/null +++ b/app/react/azure/DashboardView/icon-subscription.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/react/azure/DashboardView/index.ts b/app/react/azure/DashboardView/index.ts new file mode 100644 index 000000000..ea829dbf3 --- /dev/null +++ b/app/react/azure/DashboardView/index.ts @@ -0,0 +1 @@ +export { DashboardView } from './DashboardView'; diff --git a/app/react/azure/container-instances/.keep b/app/react/azure/container-instances/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/react/azure/container-instances/CreateView/.keep b/app/react/azure/container-instances/CreateView/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/azure/ContainerInstances/CreateContainerInstanceForm/CreateContainerInstanceForm.test.tsx b/app/react/azure/container-instances/CreateView/CreateContainerInstanceForm.test.tsx similarity index 100% rename from app/azure/ContainerInstances/CreateContainerInstanceForm/CreateContainerInstanceForm.test.tsx rename to app/react/azure/container-instances/CreateView/CreateContainerInstanceForm.test.tsx diff --git a/app/azure/ContainerInstances/CreateContainerInstanceForm/CreateContainerInstanceForm.tsx b/app/react/azure/container-instances/CreateView/CreateContainerInstanceForm.tsx similarity index 86% rename from app/azure/ContainerInstances/CreateContainerInstanceForm/CreateContainerInstanceForm.tsx rename to app/react/azure/container-instances/CreateView/CreateContainerInstanceForm.tsx index 090f5b9ea..c4d176c06 100644 --- a/app/azure/ContainerInstances/CreateContainerInstanceForm/CreateContainerInstanceForm.tsx +++ b/app/react/azure/container-instances/CreateView/CreateContainerInstanceForm.tsx @@ -1,43 +1,45 @@ import { Field, Form, Formik } from 'formik'; -import { useCurrentStateAndParams, useRouter } from '@uirouter/react'; +import { useRouter } from '@uirouter/react'; -import { ContainerInstanceFormValues } from '@/azure/types'; +import { ContainerInstanceFormValues } from '@/react/azure/types'; import * as notifications from '@/portainer/services/notifications'; import { useUser } from '@/portainer/hooks/useUser'; import { AccessControlForm } from '@/portainer/access-control/AccessControlForm'; +import { useEnvironmentId } from '@/portainer/hooks/useEnvironmentId'; import { FormControl } from '@@/form-components/FormControl'; import { Input, Select } from '@@/form-components/Input'; import { FormSectionTitle } from '@@/form-components/FormSectionTitle'; import { LoadingButton } from '@@/buttons/LoadingButton'; -import { InputListError } from '@@/form-components/InputList/InputList'; import { validationSchema } from './CreateContainerInstanceForm.validation'; -import { PortMapping, PortsMappingField } from './PortsMappingField'; -import { useLoadFormState } from './useLoadFormState'; +import { PortsMappingField } from './PortsMappingField'; +import { useFormState, useLoadFormState } from './useLoadFormState'; import { getSubscriptionLocations, getSubscriptionResourceGroups, } from './utils'; -import { useCreateInstance } from './useCreateInstanceMutation'; +import { useCreateInstanceMutation } from './useCreateInstanceMutation'; export function CreateContainerInstanceForm() { - const { - params: { endpointId: environmentId }, - } = useCurrentStateAndParams(); - - if (!environmentId) { - throw new Error('endpointId url param is required'); - } - + const environmentId = useEnvironmentId(); const { isAdmin } = useUser(); - const { initialValues, isLoading, providers, subscriptions, resourceGroups } = - useLoadFormState(environmentId, isAdmin); + const { providers, subscriptions, resourceGroups, isLoading } = + useLoadFormState(environmentId); + + const { initialValues, subscriptionOptions } = useFormState( + subscriptions, + resourceGroups, + providers + ); const router = useRouter(); - const { mutateAsync } = useCreateInstance(resourceGroups, environmentId); + const { mutateAsync } = useCreateInstanceMutation( + resourceGroups, + environmentId + ); if (isLoading) { return null; @@ -71,7 +73,7 @@ export function CreateContainerInstanceForm() { name="subscription" as={Select} id="subscription-input" - options={subscriptions} + options={subscriptionOptions} /> @@ -143,7 +145,7 @@ export function CreateContainerInstanceForm() { setFieldValue('ports', value)} - errors={errors.ports as InputListError[]} + errors={errors.ports} />
diff --git a/app/azure/ContainerInstances/CreateContainerInstanceForm/CreateContainerInstanceForm.validation.ts b/app/react/azure/container-instances/CreateView/CreateContainerInstanceForm.validation.ts similarity index 100% rename from app/azure/ContainerInstances/CreateContainerInstanceForm/CreateContainerInstanceForm.validation.ts rename to app/react/azure/container-instances/CreateView/CreateContainerInstanceForm.validation.ts diff --git a/app/azure/ContainerInstances/CreateContainerInstanceView.tsx b/app/react/azure/container-instances/CreateView/CreateView.tsx similarity index 76% rename from app/azure/ContainerInstances/CreateContainerInstanceView.tsx rename to app/react/azure/container-instances/CreateView/CreateView.tsx index f23144392..a790d48f1 100644 --- a/app/azure/ContainerInstances/CreateContainerInstanceView.tsx +++ b/app/react/azure/container-instances/CreateView/CreateView.tsx @@ -1,11 +1,9 @@ -import { r2a } from '@/react-tools/react2angular'; - import { PageHeader } from '@@/PageHeader'; import { Widget, WidgetBody } from '@@/Widget'; import { CreateContainerInstanceForm } from './CreateContainerInstanceForm'; -export function CreateContainerInstanceView() { +export function CreateView() { return ( <> ); } - -export const CreateContainerInstanceViewAngular = r2a( - CreateContainerInstanceView, - [] -); diff --git a/app/azure/ContainerInstances/CreateContainerInstanceForm/PortsMappingField.module.css b/app/react/azure/container-instances/CreateView/PortsMappingField.module.css similarity index 100% rename from app/azure/ContainerInstances/CreateContainerInstanceForm/PortsMappingField.module.css rename to app/react/azure/container-instances/CreateView/PortsMappingField.module.css diff --git a/app/azure/ContainerInstances/CreateContainerInstanceForm/PortsMappingField.tsx b/app/react/azure/container-instances/CreateView/PortsMappingField.tsx similarity index 60% rename from app/azure/ContainerInstances/CreateContainerInstanceForm/PortsMappingField.tsx rename to app/react/azure/container-instances/CreateView/PortsMappingField.tsx index 1c50529a7..d925fee33 100644 --- a/app/azure/ContainerInstances/CreateContainerInstanceForm/PortsMappingField.tsx +++ b/app/react/azure/container-instances/CreateView/PortsMappingField.tsx @@ -1,29 +1,36 @@ +import { FormikErrors } from 'formik'; + import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector'; import { FormError } from '@@/form-components/FormError'; import { InputGroup } from '@@/form-components/InputGroup'; import { InputList } from '@@/form-components/InputList'; -import { - InputListError, - ItemProps, -} from '@@/form-components/InputList/InputList'; +import { ItemProps } from '@@/form-components/InputList/InputList'; import styles from './PortsMappingField.module.css'; type Protocol = 'TCP' | 'UDP'; export interface PortMapping { - host: string; - container: string; + host?: number; + container?: number; protocol: Protocol; } interface Props { value: PortMapping[]; - onChange(value: PortMapping[]): void; - errors?: InputListError[] | string; + onChange?(value: PortMapping[]): void; + errors?: FormikErrors[] | string | string[]; + disabled?: boolean; + readOnly?: boolean; } -export function PortsMappingField({ value, onChange, errors }: Props) { +export function PortsMappingField({ + value, + onChange = () => {}, + errors, + disabled, + readOnly, +}: Props) { return ( <> @@ -31,9 +38,15 @@ export function PortsMappingField({ value, onChange, errors }: Props) { value={value} onChange={onChange} addLabel="map additional port" - itemBuilder={() => ({ host: '', container: '', protocol: 'TCP' })} + itemBuilder={() => ({ + host: 0, + container: 0, + protocol: 'TCP', + })} item={Item} errors={errors} + disabled={disabled} + readOnly={readOnly} /> {typeof errors === 'string' && (
@@ -44,7 +57,13 @@ export function PortsMappingField({ value, onChange, errors }: Props) { ); } -function Item({ onChange, item, error }: ItemProps) { +function Item({ + onChange, + item, + error, + disabled, + readOnly, +}: ItemProps) { return (
@@ -53,7 +72,12 @@ function Item({ onChange, item, error }: ItemProps) { handleChange('host', e.target.value)} + onChange={(e) => + handleChange('host', parseInt(e.target.value || '0', 10)) + } + disabled={disabled} + readOnly={readOnly} + type="number" /> @@ -66,7 +90,12 @@ function Item({ onChange, item, error }: ItemProps) { handleChange('container', e.target.value)} + onChange={(e) => + handleChange('container', parseInt(e.target.value || '0', 10)) + } + disabled={disabled} + readOnly={readOnly} + type="number" /> @@ -74,6 +103,8 @@ function Item({ onChange, item, error }: ItemProps) { onChange={(value) => handleChange('protocol', value)} value={item.protocol} options={[{ value: 'TCP' }, { value: 'UDP' }]} + disabled={disabled} + readOnly={readOnly} />
{!!error && ( @@ -84,7 +115,7 @@ function Item({ onChange, item, error }: ItemProps) {
); - function handleChange(name: string, value: string) { + function handleChange(name: keyof PortMapping, value: string | number) { onChange({ ...item, [name]: value }); } } diff --git a/app/azure/ContainerInstances/CreateContainerInstanceForm/PortsMappingField.validation.ts b/app/react/azure/container-instances/CreateView/PortsMappingField.validation.ts similarity index 100% rename from app/azure/ContainerInstances/CreateContainerInstanceForm/PortsMappingField.validation.ts rename to app/react/azure/container-instances/CreateView/PortsMappingField.validation.ts diff --git a/app/react/azure/container-instances/CreateView/index.ts b/app/react/azure/container-instances/CreateView/index.ts new file mode 100644 index 000000000..74e592112 --- /dev/null +++ b/app/react/azure/container-instances/CreateView/index.ts @@ -0,0 +1 @@ +export { CreateView } from './CreateView'; diff --git a/app/azure/ContainerInstances/CreateContainerInstanceForm/useCreateInstanceMutation.tsx b/app/react/azure/container-instances/CreateView/useCreateInstanceMutation.tsx similarity index 83% rename from app/azure/ContainerInstances/CreateContainerInstanceForm/useCreateInstanceMutation.tsx rename to app/react/azure/container-instances/CreateView/useCreateInstanceMutation.tsx index 9fe68e766..0f9ffb4a8 100644 --- a/app/azure/ContainerInstances/CreateContainerInstanceForm/useCreateInstanceMutation.tsx +++ b/app/react/azure/container-instances/CreateView/useCreateInstanceMutation.tsx @@ -1,18 +1,19 @@ import { useMutation, useQueryClient } from 'react-query'; -import { createContainerGroup } from '@/azure/services/container-groups.service'; +import { createContainerGroup } from '@/react/azure/services/container-groups.service'; +import { queryKeys } from '@/react/azure/queries/query-keys'; import { EnvironmentId } from '@/portainer/environments/types'; import PortainerError from '@/portainer/error'; import { ContainerGroup, ContainerInstanceFormValues, ResourceGroup, -} from '@/azure/types'; +} from '@/react/azure/types'; import { applyResourceControl } from '@/portainer/access-control/access-control.service'; import { getSubscriptionResourceGroups } from './utils'; -export function useCreateInstance( +export function useCreateInstanceMutation( resourceGroups: { [k: string]: ResourceGroup[]; }, @@ -52,7 +53,9 @@ export function useCreateInstance( const accessControlData = values.accessControl; await applyResourceControl(accessControlData, resourceControl); - queryClient.invalidateQueries(['azure', 'container-instances']); + return queryClient.invalidateQueries( + queryKeys.subscriptions(environmentId) + ); }, } ); diff --git a/app/react/azure/container-instances/CreateView/useLoadFormState.ts b/app/react/azure/container-instances/CreateView/useLoadFormState.ts new file mode 100644 index 000000000..3bb932e0f --- /dev/null +++ b/app/react/azure/container-instances/CreateView/useLoadFormState.ts @@ -0,0 +1,85 @@ +import { EnvironmentId } from '@/portainer/environments/types'; +import { + ContainerInstanceFormValues, + ProviderViewModel, + ResourceGroup, + Subscription, +} from '@/react/azure/types'; +import { parseAccessControlFormData } from '@/portainer/access-control/utils'; +import { useIsAdmin } from '@/portainer/hooks/useUser'; +import { useProvider } from '@/react/azure/queries/useProvider'; +import { useResourceGroups } from '@/react/azure/queries/useResourceGroups'; +import { useSubscriptions } from '@/react/azure/queries/useSubscriptions'; + +import { + getSubscriptionLocations, + getSubscriptionResourceGroups, +} from './utils'; + +export function useLoadFormState(environmentId: EnvironmentId) { + const { data: subscriptions, isLoading: isLoadingSubscriptions } = + useSubscriptions(environmentId); + const { resourceGroups, isLoading: isLoadingResourceGroups } = + useResourceGroups(environmentId, subscriptions); + const { providers, isLoading: isLoadingProviders } = useProvider( + environmentId, + subscriptions + ); + + const isLoading = + isLoadingSubscriptions || isLoadingResourceGroups || isLoadingProviders; + + return { isLoading, subscriptions, resourceGroups, providers }; +} + +export function useFormState( + subscriptions: Subscription[] = [], + resourceGroups: Record = {}, + providers: Record = {} +) { + const isAdmin = useIsAdmin(); + + const subscriptionOptions = subscriptions.map((s) => ({ + value: s.subscriptionId, + label: s.displayName, + })); + + const initSubscriptionId = getFirstValue(subscriptionOptions); + + const subscriptionResourceGroups = getSubscriptionResourceGroups( + initSubscriptionId, + resourceGroups + ); + + const subscriptionLocations = getSubscriptionLocations( + initSubscriptionId, + providers + ); + + const initialValues: ContainerInstanceFormValues = { + name: '', + location: getFirstValue(subscriptionLocations), + subscription: initSubscriptionId, + resourceGroup: getFirstValue(subscriptionResourceGroups), + image: '', + os: 'Linux', + memory: 1, + cpu: 1, + ports: [{ container: 80, host: 80, protocol: 'TCP' }], + allocatePublicIP: true, + accessControl: parseAccessControlFormData(isAdmin), + }; + + return { + initialValues, + subscriptionOptions, + }; + + function getFirstValue(arr: { value: T }[]) { + if (arr.length === 0) { + return undefined; + } + + return arr[0].value; + } +} diff --git a/app/azure/ContainerInstances/CreateContainerInstanceForm/utils.ts b/app/react/azure/container-instances/CreateView/utils.ts similarity index 87% rename from app/azure/ContainerInstances/CreateContainerInstanceForm/utils.ts rename to app/react/azure/container-instances/CreateView/utils.ts index 24c1cde89..ff5e27d91 100644 --- a/app/azure/ContainerInstances/CreateContainerInstanceForm/utils.ts +++ b/app/react/azure/container-instances/CreateView/utils.ts @@ -1,5 +1,4 @@ -import { ProviderViewModel } from '@/azure/models/provider'; -import { ResourceGroup } from '@/azure/types'; +import { ProviderViewModel, ResourceGroup } from '@/react/azure/types'; export function getSubscriptionResourceGroups( subscriptionId?: string, diff --git a/app/react/azure/container-instances/ItemView/.keep b/app/react/azure/container-instances/ItemView/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/react/azure/container-instances/ItemView/ItemView.tsx b/app/react/azure/container-instances/ItemView/ItemView.tsx new file mode 100644 index 000000000..da12495e5 --- /dev/null +++ b/app/react/azure/container-instances/ItemView/ItemView.tsx @@ -0,0 +1,266 @@ +import { useCurrentStateAndParams } from '@uirouter/react'; +import { useQueryClient } from 'react-query'; + +import { useEnvironmentId } from '@/portainer/hooks/useEnvironmentId'; +import { AccessControlPanel } from '@/portainer/access-control/AccessControlPanel/AccessControlPanel'; +import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel'; +import { ResourceControlType } from '@/portainer/access-control/types'; +import { + ContainerGroup, + ResourceGroup, + Subscription, +} from '@/react/azure/types'; +import { useContainerGroup } from '@/react/azure/queries/useContainerGroup'; +import { useResourceGroup } from '@/react/azure/queries/useResourceGroup'; +import { useSubscription } from '@/react/azure/queries/useSubscription'; + +import { Input } from '@@/form-components/Input'; +import { Widget, WidgetBody } from '@@/Widget'; +import { PageHeader } from '@@/PageHeader'; +import { FormSectionTitle } from '@@/form-components/FormSectionTitle'; +import { FormControl } from '@@/form-components/FormControl'; + +import { PortsMappingField } from '../CreateView/PortsMappingField'; + +export function ItemView() { + const { + params: { id }, + } = useCurrentStateAndParams(); + const { subscriptionId, resourceGroupId, containerGroupId } = parseId(id); + + const environmentId = useEnvironmentId(); + + const queryClient = useQueryClient(); + + const subscriptionQuery = useSubscription(environmentId, subscriptionId); + const resourceGroupQuery = useResourceGroup( + environmentId, + subscriptionId, + resourceGroupId + ); + + const containerQuery = useContainerGroup( + environmentId, + subscriptionId, + resourceGroupId, + containerGroupId + ); + + if ( + !subscriptionQuery.isSuccess || + !resourceGroupQuery.isSuccess || + !containerQuery.isSuccess + ) { + return null; + } + + const container = aggregateContainerData( + subscriptionQuery.data, + resourceGroupQuery.data, + containerQuery.data + ); + + return ( + <> + + +
+
+ + + Azure settings + + + + + + + + + + + + + Container configuration + + + + + + + + + + + + + + + + + + + + Container Resources + + + + + + + + + + +
+
+ + + queryClient.invalidateQueries([ + 'azure', + environmentId, + 'subscriptions', + subscriptionId, + 'resourceGroups', + resourceGroupQuery.data.name, + 'containerGroups', + containerQuery.data.name, + ]) + } + resourceId={id} + resourceControl={container.resourceControl} + resourceType={ResourceControlType.ContainerGroup} + /> + + ); +} + +function parseId(id: string) { + const match = id.match( + /^\/subscriptions\/(.+)\/resourceGroups\/(.+)\/providers\/(.+)\/containerGroups\/(.+)$/ + ); + + if (!match) { + throw new Error('container id is missing details'); + } + + const [, subscriptionId, resourceGroupId, , containerGroupId] = match; + + return { subscriptionId, resourceGroupId, containerGroupId }; +} + +function aggregateContainerData( + subscription: Subscription, + resourceGroup: ResourceGroup, + containerGroup: ContainerGroup +) { + const containerInstanceData = aggregateContainerInstance(); + + const resourceControl = containerGroup.Portainer?.ResourceControl + ? new ResourceControlViewModel(containerGroup.Portainer.ResourceControl) + : undefined; + + return { + name: containerGroup.name, + subscriptionName: subscription.displayName, + resourceGroupName: resourceGroup.name, + location: containerGroup.location, + osType: containerGroup.properties.osType, + ipAddress: containerGroup.properties.ipAddress.ip, + resourceControl, + ...containerInstanceData, + }; + + function aggregateContainerInstance() { + const containerInstanceData = containerGroup.properties.containers[0]; + + if (!containerInstanceData) { + return { + ports: [], + }; + } + + const containerInstanceProperties = containerInstanceData.properties; + + const containerPorts = containerInstanceProperties.ports; + + const imageName = containerInstanceProperties.image; + + const ports = containerGroup.properties.ipAddress.ports.map( + (binding, index) => { + const port = + containerPorts && containerPorts[index] + ? containerPorts[index].port + : undefined; + return { + container: port, + host: binding.port, + protocol: binding.protocol, + }; + } + ); + + return { + imageName, + ports, + cpu: containerInstanceProperties.resources.cpu, + memory: containerInstanceProperties.resources.memoryInGB, + }; + } +} diff --git a/app/react/azure/container-instances/ItemView/index.ts b/app/react/azure/container-instances/ItemView/index.ts new file mode 100644 index 000000000..a09ab2dde --- /dev/null +++ b/app/react/azure/container-instances/ItemView/index.ts @@ -0,0 +1 @@ +export { ItemView } from './ItemView'; diff --git a/app/react/azure/container-instances/ListView/.keep b/app/react/azure/container-instances/ListView/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/react/azure/container-instances/ListView/ContainersDatatable.tsx b/app/react/azure/container-instances/ListView/ContainersDatatable.tsx new file mode 100644 index 000000000..b16ea057d --- /dev/null +++ b/app/react/azure/container-instances/ListView/ContainersDatatable.tsx @@ -0,0 +1,216 @@ +import { useEffect } from 'react'; +import { + useTable, + useSortBy, + useGlobalFilter, + usePagination, +} from 'react-table'; +import { useRowSelectColumn } from '@lineup-lite/hooks'; +import { Box, Plus, Trash2 } from 'react-feather'; + +import { useDebounce } from '@/portainer/hooks/useDebounce'; +import { ContainerGroup } from '@/react/azure/types'; +import { Authorized } from '@/portainer/hooks/useUser'; +import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm'; + +import { PaginationControls } from '@@/PaginationControls'; +import { + Table, + TableActions, + TableContainer, + TableHeaderRow, + TableRow, + TableTitle, +} from '@@/datatables'; +import { multiple } from '@@/datatables/filter-types'; +import { useTableSettings } from '@@/datatables/useTableSettings'; +import { SearchBar, useSearchBarState } from '@@/datatables/SearchBar'; +import { useRowSelect } from '@@/datatables/useRowSelect'; +import { Checkbox } from '@@/form-components/Checkbox'; +import { TableFooter } from '@@/datatables/TableFooter'; +import { SelectedRowsCount } from '@@/datatables/SelectedRowsCount'; +import { Button } from '@@/buttons'; +import { Link } from '@@/Link'; + +import { TableSettings } from './types'; +import { useColumns } from './columns'; + +export interface Props { + tableKey: string; + dataset: ContainerGroup[]; + onRemoveClick(containerIds: string[]): void; +} + +export function ContainersDatatable({ + dataset, + tableKey, + onRemoveClick, +}: Props) { + const { settings, setTableSettings } = useTableSettings(); + const [searchBarValue, setSearchBarValue] = useSearchBarState(tableKey); + + const columns = useColumns(); + const { + getTableProps, + getTableBodyProps, + headerGroups, + page, + prepareRow, + selectedFlatRows, + gotoPage, + setPageSize, + setGlobalFilter, + state: { pageIndex, pageSize }, + } = useTable( + { + defaultCanFilter: false, + columns, + data: dataset, + filterTypes: { multiple }, + initialState: { + pageSize: settings.pageSize || 10, + sortBy: [settings.sortBy], + globalFilter: searchBarValue, + }, + selectCheckboxComponent: Checkbox, + autoResetSelectedRows: false, + getRowId(row) { + return row.id; + }, + }, + useGlobalFilter, + useSortBy, + usePagination, + useRowSelect, + useRowSelectColumn + ); + + const debouncedSearchValue = useDebounce(searchBarValue); + + useEffect(() => { + setGlobalFilter(debouncedSearchValue); + }, [debouncedSearchValue, setGlobalFilter]); + + const tableProps = getTableProps(); + const tbodyProps = getTableBodyProps(); + + return ( +
+
+ + + + + + + + + + + + + + + + + + + {headerGroups.map((headerGroup) => { + const { key, className, role, style } = + headerGroup.getHeaderGroupProps(); + + return ( + + key={key} + className={className} + role={role} + style={style} + headers={headerGroup.headers} + onSortChange={handleSortChange} + /> + ); + })} + + + ( + + cells={row.cells} + key={key} + className={className} + role={role} + style={style} + /> + )} + rows={page} + emptyContent="No container available." + /> + +
+ + + + gotoPage(p - 1)} + totalCount={dataset.length} + onPageLimitChange={handlePageSizeChange} + /> + +
+
+
+ ); + + async function handleRemoveClick(containerIds: string[]) { + const confirmed = await confirmDeletionAsync( + 'Are you sure you want to delete the selected containers?' + ); + if (!confirmed) { + return null; + } + + return onRemoveClick(containerIds); + } + + function handlePageSizeChange(pageSize: number) { + setPageSize(pageSize); + setTableSettings((settings) => ({ ...settings, pageSize })); + } + + function handleSearchBarChange(value: string) { + setSearchBarValue(value); + } + + function handleSortChange(id: string, desc: boolean) { + setTableSettings((settings) => ({ + ...settings, + sortBy: { id, desc }, + })); + } +} diff --git a/app/react/azure/container-instances/ListView/ListView.tsx b/app/react/azure/container-instances/ListView/ListView.tsx new file mode 100644 index 000000000..3b0c01361 --- /dev/null +++ b/app/react/azure/container-instances/ListView/ListView.tsx @@ -0,0 +1,97 @@ +import { useMutation, useQueryClient } from 'react-query'; + +import { deleteContainerGroup } from '@/react/azure/services/container-groups.service'; +import { useEnvironmentId } from '@/portainer/hooks/useEnvironmentId'; +import { notifyError, notifySuccess } from '@/portainer/services/notifications'; +import { EnvironmentId } from '@/portainer/environments/types'; +import { promiseSequence } from '@/portainer/helpers/promise-utils'; +import { useContainerGroups } from '@/react/azure/queries/useContainerGroups'; +import { useSubscriptions } from '@/react/azure/queries/useSubscriptions'; + +import { PageHeader } from '@@/PageHeader'; +import { TableSettingsProvider } from '@@/datatables/useTableSettings'; + +import { ContainersDatatable } from './ContainersDatatable'; +import { TableSettings } from './types'; + +export function ListView() { + const defaultSettings: TableSettings = { + pageSize: 10, + sortBy: { id: 'state', desc: false }, + }; + + const tableKey = 'containergroups'; + + const environmentId = useEnvironmentId(); + + const subscriptionsQuery = useSubscriptions(environmentId); + + const groupsQuery = useContainerGroups( + environmentId, + subscriptionsQuery.data, + subscriptionsQuery.isSuccess + ); + + const { handleRemove } = useRemoveMutation(environmentId); + + if (groupsQuery.isLoading || subscriptionsQuery.isLoading) { + return null; + } + + return ( + <> + + + + + + ); +} + +function useRemoveMutation(environmentId: EnvironmentId) { + const queryClient = useQueryClient(); + + const deleteMutation = useMutation( + (containerGroupIds: string[]) => + promiseSequence( + containerGroupIds.map( + (id) => () => deleteContainerGroup(environmentId, id) + ) + ), + + { + onSuccess() { + return queryClient.invalidateQueries([ + 'azure', + environmentId, + 'subscriptions', + ]); + }, + onError(err) { + notifyError( + 'Failure', + err as Error, + 'Unable to remove container groups' + ); + }, + } + ); + + return { handleRemove }; + + async function handleRemove(groupIds: string[]) { + deleteMutation.mutate(groupIds, { + onSuccess: () => { + notifySuccess('Container groups successfully removed'); + }, + }); + } +} diff --git a/app/react/azure/container-instances/ListView/columns/index.ts b/app/react/azure/container-instances/ListView/columns/index.ts new file mode 100644 index 000000000..a9b259d5e --- /dev/null +++ b/app/react/azure/container-instances/ListView/columns/index.ts @@ -0,0 +1,10 @@ +import { useMemo } from 'react'; + +import { name } from './name'; +import { location } from './location'; +import { ports } from './ports'; +import { ownership } from './ownership'; + +export function useColumns() { + return useMemo(() => [name, location, ports, ownership], []); +} diff --git a/app/react/azure/container-instances/ListView/columns/location.ts b/app/react/azure/container-instances/ListView/columns/location.ts new file mode 100644 index 000000000..8079661da --- /dev/null +++ b/app/react/azure/container-instances/ListView/columns/location.ts @@ -0,0 +1,13 @@ +import { Column } from 'react-table'; + +import { ContainerGroup } from '@/react/azure/types'; + +export const location: Column = { + Header: 'Location', + accessor: (container) => container.location, + id: 'location', + disableFilters: true, + Filter: () => null, + canHide: true, + sortType: 'string', +}; diff --git a/app/react/azure/container-instances/ListView/columns/name.tsx b/app/react/azure/container-instances/ListView/columns/name.tsx new file mode 100644 index 000000000..a49e83c09 --- /dev/null +++ b/app/react/azure/container-instances/ListView/columns/name.tsx @@ -0,0 +1,31 @@ +import { CellProps, Column } from 'react-table'; + +import { ContainerGroup } from '@/react/azure/types'; + +import { Link } from '@@/Link'; + +export const name: Column = { + Header: 'Name', + accessor: (container) => container.name, + id: 'name', + Cell: NameCell, + disableFilters: true, + Filter: () => null, + canHide: true, + sortType: 'string', +}; + +export function NameCell({ + value: name, + row: { original: container }, +}: CellProps) { + return ( + + {name} + + ); +} diff --git a/app/react/azure/container-instances/ListView/columns/ownership.tsx b/app/react/azure/container-instances/ListView/columns/ownership.tsx new file mode 100644 index 000000000..f1aba0f1c --- /dev/null +++ b/app/react/azure/container-instances/ListView/columns/ownership.tsx @@ -0,0 +1,37 @@ +import { Column } from 'react-table'; +import clsx from 'clsx'; + +import { ownershipIcon } from '@/portainer/filters/filters'; +import { ResourceControlOwnership } from '@/portainer/access-control/types'; +import { ContainerGroup } from '@/react/azure/types'; +import { determineOwnership } from '@/portainer/access-control/models/ResourceControlViewModel'; + +export const ownership: Column = { + Header: 'Ownership', + id: 'ownership', + accessor: (row) => + row.Portainer && row.Portainer.ResourceControl + ? determineOwnership(row.Portainer.ResourceControl) + : ResourceControlOwnership.ADMINISTRATORS, + Cell: OwnershipCell, + disableFilters: true, + canHide: true, + sortType: 'string', + Filter: () => null, +}; + +interface Props { + value: 'public' | 'private' | 'restricted' | 'administrators'; +} + +function OwnershipCell({ value }: Props) { + return ( + <> +