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 <matias.spinarolli@portainer.io>
pull/6891/head
Chaim Lev-Ari 2022-07-26 21:44:08 +02:00 committed by GitHub
parent b059641c80
commit 82b848af0c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
97 changed files with 1723 additions and 1430 deletions

View File

@ -1 +0,0 @@
export { CreateContainerInstanceForm } from './CreateContainerInstanceForm';

View File

@ -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<T extends string | number>(arr: Option<T>[]) {
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),
};
}

View File

@ -1,11 +0,0 @@
import angular from 'angular';
import { CreateContainerInstanceViewAngular } from './CreateContainerInstanceView';
export const containerInstancesModule = angular
.module('portainer.azure.containerInstances', [])
.component(
'createContainerInstanceView',
CreateContainerInstanceViewAngular
).name;

View File

@ -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 (
<>
<PageHeader title="Home" breadcrumbs={[{ label: 'Dashboard' }]} />
<div className="mx-4">
{subscriptionsQuery.data && (
<DashboardGrid>
<DashboardItem
value={subscriptionsCount as number}
icon="fa fa-th-list"
type="Subscription"
/>
{!resourceGroupsQuery.isError && !resourceGroupsQuery.isLoading && (
<DashboardItem
value={resourceGroupsCount}
icon="fa fa-th-list"
type="Resource group"
/>
)}
</DashboardGrid>
)}
</div>
</>
);
}
export const DashboardViewAngular = r2a(DashboardView, []);

View File

@ -1 +0,0 @@
export { DashboardViewAngular, DashboardView } from './DashboardView';

View File

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

View File

@ -1,114 +0,0 @@
<div class="datatable">
<rd-widget>
<rd-widget-body classes="no-padding">
<div class="toolBar">
<div class="toolBarTitle"> <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px"></i> {{ $ctrl.titleText }} </div>
</div>
<div class="actionBar">
<button type="button" class="btn btn-sm btn-danger" ng-disabled="$ctrl.state.selectedItemCount === 0" ng-click="$ctrl.removeAction($ctrl.state.selectedItems)">
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
</button>
<button type="button" class="btn btn-sm btn-primary" ui-sref="azure.containerinstances.new">
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add container
</button>
</div>
<div class="searchBar">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input
type="text"
class="searchInput"
ng-model="$ctrl.state.textFilter"
ng-change="$ctrl.onTextFilterChange()"
placeholder="Search..."
auto-focus
ng-model-options="{ debounce: 300 }"
/>
</div>
<div class="table-responsive">
<table class="table table-hover table-filters nowrap-cells">
<thead>
<tr>
<th>
<span class="md-checkbox">
<input id="select_all" type="checkbox" ng-model="$ctrl.state.selectAll" ng-change="$ctrl.selectAll()" />
<label for="select_all"></label>
</span>
<a ng-click="$ctrl.changeOrderBy('Name')">
Name
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>
<a ng-click="$ctrl.changeOrderBy('Location')">
Location
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Location' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Location' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th> Published Ports </th>
<th>
<a ng-click="$ctrl.changeOrderBy('ResourceControl.Ownership')">
Ownership
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ResourceControl.Ownership' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ResourceControl.Ownership' && $ctrl.state.reverseOrder"></i>
</a>
</th>
</tr>
</thead>
<tbody>
<tr
dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))"
ng-class="{ active: item.Checked }"
>
<td>
<span class="md-checkbox">
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-click="$ctrl.selectItem(item, $event)" />
<label for="select_{{ $index }}"></label>
</span>
<a ui-sref="azure.containerinstances.container({ id: item.Id })">{{ item.Name | truncate: 50 }}</a>
</td>
<td>{{ item.Location }}</td>
<td>
<a ng-if="item.Ports.length > 0" ng-repeat="p in item.Ports" class="image-tag" ng-href="http://{{ item.IPAddress }}:{{ p.host }}" target="_blank">
<i class="fa fa-external-link-alt" aria-hidden="true"></i> {{ item.IPAddress }}:{{ p.host }}
</a>
<span ng-if="item.Ports.length == 0">-</span>
</td>
<td>
<span>
<i ng-class="item.ResourceControl.Ownership | ownershipicon" aria-hidden="true"></i>
{{ item.ResourceControl.Ownership ? item.ResourceControl.Ownership : item.ResourceControl.Ownership = $ctrl.RCO.ADMINISTRATORS }}
</span>
</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="3" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="3" class="text-center text-muted">No container available.</td>
</tr>
</tbody>
</table>
</div>
<div class="footer" ng-if="$ctrl.dataset">
<div class="infoBar" ng-if="$ctrl.state.selectedItemCount !== 0"> {{ $ctrl.state.selectedItemCount }} item(s) selected </div>
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span style="margin-right: 5px"> Items per page </span>
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()" data-cy="component-paginationSelect">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</span>
<dir-pagination-controls max-size="5"></dir-pagination-controls>
</form>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>

View File

@ -1,13 +0,0 @@
angular.module('portainer.azure').component('containergroupsDatatable', {
templateUrl: './containerGroupsDatatable.html',
controller: 'GenericDatatableController',
bindings: {
titleText: '@',
titleIcon: '@',
dataset: '<',
tableKey: '@',
orderBy: '@',
reverseOrder: '<',
removeAction: '<',
},
});

91
app/azure/index.ts Normal file
View File

@ -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<void>) => Promise<void>,
$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);
}

View File

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

View File

@ -1,6 +0,0 @@
export function LocationViewModel(data) {
this.Id = data.id;
this.SubscriptionId = data.subscriptionId;
this.DisplayName = data.displayName;
this.Name = data.name;
}

View File

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

View File

@ -1,6 +0,0 @@
export function ResourceGroupViewModel(data, subscriptionId) {
this.Id = data.id;
this.SubscriptionId = subscriptionId;
this.Name = data.name;
this.Location = data.location;
}

View File

@ -1,4 +0,0 @@
export function SubscriptionViewModel(data) {
this.Id = data.subscriptionId;
this.Name = data.displayName;
}

View File

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

View File

@ -1,6 +1,14 @@
import angular from 'angular'; import angular from 'angular';
export const viewsModule = angular.module( import { r2a } from '@/react-tools/react2angular';
'portainer.azure.react.views', import { CreateView } from '@/react/azure/container-instances/CreateView';
[] import { ItemView } from '@/react/azure/container-instances/ItemView';
).name; 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<ProviderResponse>(url, {
params: { 'api-version': '2018-02-01' },
});
return parseViewModel(data);
} catch (error) {
throw parseAxiosError(
error as Error,
'Unable to retrieve provider',
azureErrorParser
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,131 +0,0 @@
<page-header title="'Container Instance'" breadcrumbs="[{label:'Container instances', link:'azure.containerinstances'}, $ctrl.container.Name]"> </page-header>
<div class="row" ng-if="!$ctrl.state.loading">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<div class="form-horizontal" autocomplete="off">
<div class="col-sm-12 form-section-title"> Azure settings </div>
<!-- subscription-input -->
<div class="form-group">
<label for="azure_subscription" class="col-sm-2 control-label text-left">Subscription</label>
<div class="col-sm-10">
<input type="text" class="form-control" ng-model="$ctrl.subscription.Name" disabled />
</div>
</div>
<!-- !subscription-input -->
<!-- resourcegroup-input -->
<div class="form-group">
<label for="azure_resourcegroup" class="col-sm-2 control-label text-left">Resource group</label>
<div class="col-sm-10">
<input type="text" class="form-control" ng-model="$ctrl.resourceGroup.Name" disabled />
</div>
</div>
<!-- !resourcegroup-input -->
<!-- location-input -->
<div class="form-group">
<label for="azure_location" class="col-sm-2 control-label text-left">Location</label>
<div class="col-sm-10">
<input type="text" class="form-control" ng-model="$ctrl.container.Location" disabled />
</div>
</div>
<!-- !location-input -->
<div class="col-sm-12 form-section-title"> Container configuration </div>
<!-- name-input -->
<div class="form-group">
<label for="container_name" class="col-sm-2 control-label text-left">Name</label>
<div class="col-sm-10">
<input type="text" class="form-control" ng-model="$ctrl.container.Name" disabled />
</div>
</div>
<!-- !name-input -->
<!-- image-input -->
<div class="form-group">
<label for="image_name" class="col-sm-2 control-label text-left">Image</label>
<div class="col-sm-10">
<input type="text" class="form-control" ng-model="$ctrl.container.Image" disabled />
</div>
</div>
<!-- !image-input -->
<!-- os-input -->
<div class="form-group">
<label for="container_os" class="col-sm-2 control-label text-left">OS</label>
<div class="col-sm-10">
<input type="text" class="form-control" ng-model="$ctrl.container.OSType" disabled />
</div>
</div>
<!-- !os-input -->
<!-- port-mapping -->
<div class="form-group" ng-if="$ctrl.container.Ports.length > 0">
<div class="col-sm-12">
<label class="control-label text-left">Port mapping</label>
</div>
<!-- port-mapping-input-list -->
<div class="col-sm-12 form-inline" style="margin-top: 10px">
<div ng-repeat="binding in $ctrl.container.Ports" style="margin-top: 2px">
<!-- host-port -->
<div class="input-group col-sm-4 input-group-sm">
<span class="input-group-addon">host</span>
<input type="text" class="form-control" ng-model="binding.host" placeholder="e.g. 80" disabled />
</div>
<!-- !host-port -->
<span style="margin: 0 10px 0 10px">
<i class="fa fa-long-arrow-alt-right" aria-hidden="true"></i>
</span>
<!-- container-port -->
<div class="input-group col-sm-4 input-group-sm">
<span class="input-group-addon">container</span>
<input type="text" class="form-control" ng-model="binding.container" placeholder="e.g. 80" disabled />
</div>
<!-- !container-port -->
<!-- protocol-actions -->
<div class="input-group col-sm-3 input-group-sm">
<div class="btn-group btn-group-sm">
<label class="btn btn-primary" ng-model="binding.protocol" uib-btn-radio="'TCP'" disabled>TCP</label>
<label class="btn btn-primary" ng-model="binding.protocol" uib-btn-radio="'UDP'" disabled>UDP</label>
</div>
</div>
<!-- !protocol-actions -->
</div>
</div>
<!-- !port-mapping-input-list -->
</div>
<!-- !port-mapping -->
<!-- public-ip -->
<div class="form-group" ng-if="$ctrl.container.AllocatePublicIP">
<label for="public_ip" class="col-sm-2 control-label text-left"> Public IP </label>
<div class="col-sm-10">
<input type="text" class="form-control" ng-model="$ctrl.container.IPAddress" disabled />
</div>
</div>
<!-- public-ip -->
<div class="col-sm-12 form-section-title"> Container resources </div>
<!-- cpu-input -->
<div class="form-group">
<label for="container_cpu" class="col-sm-2 control-label text-left">CPU</label>
<div class="col-sm-10">
<input type="text" class="form-control" ng-model="$ctrl.container.CPU" disabled />
</div>
</div>
<!-- !cpu-input -->
<!-- memory-input -->
<div class="form-group">
<label for="container_memory" class="col-sm-2 control-label text-left">Memory</label>
<div class="col-sm-10">
<input type="text" class="form-control" ng-model="$ctrl.container.Memory" disabled />
</div>
</div>
<!-- !memory-input -->
</div>
</rd-widget-body>
</rd-widget>
</div>
<access-control-panel
ng-if="$ctrl.container"
resource-id="$ctrl.container.Id"
resource-control="$ctrl.container.ResourceControl"
resource-type="$ctrl.resourceType"
on-update-success="($ctrl.onUpdateSuccess)"
></access-control-panel>
</div>

View File

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

View File

@ -1,6 +0,0 @@
import ContainerInstanceDetailsController from './containerInstanceDetailsController.js';
angular.module('portainer.azure').component('containerInstanceDetails', {
templateUrl: './containerInstanceDetails.html',
controller: ContainerInstanceDetailsController,
});

View File

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

View File

@ -1,14 +0,0 @@
<page-header title="'Container list'" breadcrumbs="['Container instances']" reload="true"> </page-header>
<div class="row">
<div class="col-sm-12">
<containergroups-datatable
title-text="Containers"
title-icon="fa-cubes"
dataset="containerGroups"
table-key="containergroups"
order-by="Name"
remove-action="deleteAction"
></containergroups-datatable>
</div>
</div>

View File

@ -9,7 +9,7 @@ import './matomo-setup';
import analyticsModule from './angulartics.matomo'; import analyticsModule from './angulartics.matomo';
import './agent'; import './agent';
import './azure/_module'; import { azureModule } from './azure';
import './docker/__module'; import './docker/__module';
import './edge/__module'; import './edge/__module';
import './portainer/__module'; import './portainer/__module';
@ -44,7 +44,7 @@ angular
'luegg.directives', 'luegg.directives',
'portainer.app', 'portainer.app',
'portainer.agent', 'portainer.agent',
'portainer.azure', azureModule,
'portainer.docker', 'portainer.docker',
'portainer.kubernetes', 'portainer.kubernetes',
nomadModule, nomadModule,

View File

@ -11,6 +11,7 @@ import homeModule from './home';
import { accessControlModule } from './access-control'; import { accessControlModule } from './access-control';
import { reactModule } from './react'; import { reactModule } from './react';
import { sidebarModule } from './react/views/sidebar'; import { sidebarModule } from './react/views/sidebar';
import environmentsModule from './environments';
async function initAuthentication(authManager, Authentication, $rootScope, $state) { async function initAuthentication(authManager, Authentication, $rootScope, $state) {
authManager.checkAuthOnRefresh(); authManager.checkAuthOnRefresh();
@ -42,6 +43,7 @@ angular
accessControlModule, accessControlModule,
reactModule, reactModule,
sidebarModule, sidebarModule,
environmentsModule,
]) ])
.config([ .config([
'$stateRegistryProvider', '$stateRegistryProvider',

View File

@ -20,7 +20,7 @@ interface Props {
resourceType: ResourceControlType; resourceType: ResourceControlType;
resourceId: ResourceId; resourceId: ResourceId;
disableOwnershipChange?: boolean; disableOwnershipChange?: boolean;
onUpdateSuccess(): void; onUpdateSuccess(): Promise<void>;
} }
export function AccessControlPanel({ export function AccessControlPanel({
@ -80,8 +80,8 @@ export function AccessControlPanel({
</div> </div>
); );
function handleUpdateSuccess() { async function handleUpdateSuccess() {
onUpdateSuccess(); await onUpdateSuccess();
toggleEditMode(); toggleEditMode();
} }

View File

@ -28,7 +28,7 @@ interface Props {
resourceId: ResourceId; resourceId: ResourceId;
resourceControl?: ResourceControlViewModel; resourceControl?: ResourceControlViewModel;
onCancelClick(): void; onCancelClick(): void;
onUpdateSuccess(): void; onUpdateSuccess(): Promise<void>;
} }
export function AccessControlPanelForm({ export function AccessControlPanelForm({
@ -52,6 +52,9 @@ export function AccessControlPanelForm({
meta: { meta: {
error: { title: 'Failure', message: 'Unable to update access control' }, error: { title: 'Failure', message: 'Unable to update access control' },
}, },
onSuccess() {
return onUpdateSuccess();
},
} }
); );
@ -115,7 +118,6 @@ export function AccessControlPanelForm({
updateAccess.mutate(accessControl, { updateAccess.mutate(accessControl, {
onSuccess() { onSuccess() {
notifySuccess('Access control successfully updated'); notifySuccess('Access control successfully updated');
onUpdateSuccess();
}, },
}); });
} }

View File

@ -37,7 +37,7 @@ export class ResourceControlViewModel {
} }
} }
function determineOwnership(resourceControl: ResourceControlResponse) { export function determineOwnership(resourceControl: ResourceControlResponse) {
if (resourceControl.Public) { if (resourceControl.Public) {
return ResourceControlOwnership.PUBLIC; return ResourceControlOwnership.PUBLIC;
} }

View File

@ -1,8 +1,8 @@
angular.module('portainer.azure').component('azureEndpointConfig', { export const azureEndpointConfig = {
bindings: { bindings: {
applicationId: '=', applicationId: '=',
tenantId: '=', tenantId: '=',
authenticationKey: '=', authenticationKey: '=',
}, },
templateUrl: './azureEndpointConfig.html', templateUrl: './azureEndpointConfig.html',
}); };

View File

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

View File

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

View File

View File

@ -17,64 +17,60 @@ jest.mock('@uirouter/react', () => ({
})); }));
test('dashboard items should render correctly', async () => { 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(); expect(subscriptionsItem).toBeVisible();
const subscriptionElements = within(subscriptionsItem); const subscriptionElements = within(subscriptionsItem);
expect(subscriptionElements.getByLabelText('value')).toBeVisible(); expect(subscriptionElements.getByLabelText('value')).toBeVisible();
expect(subscriptionElements.getByRole('img', { hidden: true })).toHaveClass(
'fa-th-list'
);
expect(subscriptionElements.getByLabelText('resourceType')).toHaveTextContent( expect(subscriptionElements.getByLabelText('resourceType')).toHaveTextContent(
'Subscriptions' 'Subscriptions'
); );
const resourceGroupsItem = getByLabelText('Resource group'); const resourceGroupsItem = await findByLabelText('Resource group');
expect(resourceGroupsItem).toBeVisible(); expect(resourceGroupsItem).toBeVisible();
const resourceGroupElements = within(resourceGroupsItem); const resourceGroupElements = within(resourceGroupsItem);
expect(resourceGroupElements.getByLabelText('value')).toBeVisible(); expect(resourceGroupElements.getByLabelText('value')).toBeVisible();
expect(resourceGroupElements.getByRole('img', { hidden: true })).toHaveClass(
'fa-th-list'
);
expect( expect(
resourceGroupElements.getByLabelText('resourceType') resourceGroupElements.getByLabelText('resourceType')
).toHaveTextContent('Resource groups'); ).toHaveTextContent('Resource groups');
}); });
test('when there are no subscriptions, should show 0 subscriptions and 0 resource groups', async () => { 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'); 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'); expect(resourceGroupElements.getByLabelText('value')).toHaveTextContent('0');
}); });
test('when there is subscription & resource group data, should display these', async () => { 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'); 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'); expect(resourceGroupElements.getByLabelText('value')).toHaveTextContent('2');
}); });
test('should correctly show total number of resource groups across multiple subscriptions', async () => { 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-1': 2,
'subscription-2': 3, 'subscription-2': 3,
}); });
const resourceGroupElements = within(getByLabelText('Resource group')); const resourceGroupElements = within(await findByLabelText('Resource group'));
expect(resourceGroupElements.getByLabelText('value')).toHaveTextContent('5'); 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( const { queryByLabelText } = await renderComponent(
1, 1,
{ 'subscription-1': 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 () => { test('when only resource groups fail to load, still show the subscriptions', async () => {
const { queryByLabelText } = await renderComponent( const { queryByLabelText, findByLabelText } = await renderComponent(
1, 1,
{ 'subscription-1': 1 }, { 'subscription-1': 1 },
200, 200,
500 500
); );
expect(queryByLabelText('Subscription')).toBeInTheDocument(); await expect(findByLabelText('Subscription')).resolves.toBeInTheDocument();
expect(queryByLabelText('Resource group')).not.toBeInTheDocument(); expect(queryByLabelText('Resource group')).not.toBeInTheDocument();
}); });

View File

@ -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 (
<>
<PageHeader title="Home" breadcrumbs={[{ label: 'Dashboard' }]} />
<div className="mx-4">
{subscriptionsQuery.data && (
<DashboardGrid>
<DashboardItem
value={subscriptionsCount as number}
icon={SubscriptionsIcon}
type="Subscription"
/>
{!resourceGroupsQuery.isError && !resourceGroupsQuery.isLoading && (
<DashboardItem
value={resourceGroupsCount}
icon={Package}
type="Resource group"
/>
)}
</DashboardGrid>
)}
</div>
</>
);
}

View File

@ -0,0 +1,3 @@
<svg width="22" height="20" viewBox="0 0 22 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20.8641 8.08333H1.69743M10.3224 16.7083L17.7974 16.7083C18.8709 16.7083 19.4076 16.7083 19.8176 16.4994C20.1782 16.3157 20.4714 16.0225 20.6552 15.6618C20.8641 15.2518 20.8641 14.7151 20.8641 13.6417V6.35833C20.8641 5.2849 20.8641 4.74818 20.6552 4.33819C20.4714 3.97754 20.1782 3.68433 19.8176 3.50057C19.4076 3.29167 18.8709 3.29167 17.7974 3.29167H16.0724M10.3224 16.7083L12.2391 18.625M10.3224 16.7083L12.2391 14.7917M6.48909 16.7083H4.76409C3.69066 16.7083 3.15394 16.7083 2.74394 16.4994C2.3833 16.3157 2.09009 16.0225 1.90633 15.6618C1.69743 15.2518 1.69743 14.7151 1.69743 13.6417V6.35833C1.69743 5.2849 1.69743 4.74818 1.90633 4.33818C2.09009 3.97754 2.3833 3.68433 2.74394 3.50057C3.15394 3.29167 3.69066 3.29167 4.76409 3.29167H12.2391M12.2391 3.29167L10.3224 5.20833M12.2391 3.29167L10.3224 1.375" stroke="#344054" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1008 B

View File

@ -0,0 +1 @@
export { DashboardView } from './DashboardView';

View File

@ -1,43 +1,45 @@
import { Field, Form, Formik } from 'formik'; 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 * as notifications from '@/portainer/services/notifications';
import { useUser } from '@/portainer/hooks/useUser'; import { useUser } from '@/portainer/hooks/useUser';
import { AccessControlForm } from '@/portainer/access-control/AccessControlForm'; import { AccessControlForm } from '@/portainer/access-control/AccessControlForm';
import { useEnvironmentId } from '@/portainer/hooks/useEnvironmentId';
import { FormControl } from '@@/form-components/FormControl'; import { FormControl } from '@@/form-components/FormControl';
import { Input, Select } from '@@/form-components/Input'; import { Input, Select } from '@@/form-components/Input';
import { FormSectionTitle } from '@@/form-components/FormSectionTitle'; import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
import { LoadingButton } from '@@/buttons/LoadingButton'; import { LoadingButton } from '@@/buttons/LoadingButton';
import { InputListError } from '@@/form-components/InputList/InputList';
import { validationSchema } from './CreateContainerInstanceForm.validation'; import { validationSchema } from './CreateContainerInstanceForm.validation';
import { PortMapping, PortsMappingField } from './PortsMappingField'; import { PortsMappingField } from './PortsMappingField';
import { useLoadFormState } from './useLoadFormState'; import { useFormState, useLoadFormState } from './useLoadFormState';
import { import {
getSubscriptionLocations, getSubscriptionLocations,
getSubscriptionResourceGroups, getSubscriptionResourceGroups,
} from './utils'; } from './utils';
import { useCreateInstance } from './useCreateInstanceMutation'; import { useCreateInstanceMutation } from './useCreateInstanceMutation';
export function CreateContainerInstanceForm() { export function CreateContainerInstanceForm() {
const { const environmentId = useEnvironmentId();
params: { endpointId: environmentId },
} = useCurrentStateAndParams();
if (!environmentId) {
throw new Error('endpointId url param is required');
}
const { isAdmin } = useUser(); const { isAdmin } = useUser();
const { initialValues, isLoading, providers, subscriptions, resourceGroups } = const { providers, subscriptions, resourceGroups, isLoading } =
useLoadFormState(environmentId, isAdmin); useLoadFormState(environmentId);
const { initialValues, subscriptionOptions } = useFormState(
subscriptions,
resourceGroups,
providers
);
const router = useRouter(); const router = useRouter();
const { mutateAsync } = useCreateInstance(resourceGroups, environmentId); const { mutateAsync } = useCreateInstanceMutation(
resourceGroups,
environmentId
);
if (isLoading) { if (isLoading) {
return null; return null;
@ -71,7 +73,7 @@ export function CreateContainerInstanceForm() {
name="subscription" name="subscription"
as={Select} as={Select}
id="subscription-input" id="subscription-input"
options={subscriptions} options={subscriptionOptions}
/> />
</FormControl> </FormControl>
@ -143,7 +145,7 @@ export function CreateContainerInstanceForm() {
<PortsMappingField <PortsMappingField
value={values.ports} value={values.ports}
onChange={(value) => setFieldValue('ports', value)} onChange={(value) => setFieldValue('ports', value)}
errors={errors.ports as InputListError<PortMapping>[]} errors={errors.ports}
/> />
<div className="form-group"> <div className="form-group">

View File

@ -1,11 +1,9 @@
import { r2a } from '@/react-tools/react2angular';
import { PageHeader } from '@@/PageHeader'; import { PageHeader } from '@@/PageHeader';
import { Widget, WidgetBody } from '@@/Widget'; import { Widget, WidgetBody } from '@@/Widget';
import { CreateContainerInstanceForm } from './CreateContainerInstanceForm'; import { CreateContainerInstanceForm } from './CreateContainerInstanceForm';
export function CreateContainerInstanceView() { export function CreateView() {
return ( return (
<> <>
<PageHeader <PageHeader
@ -28,8 +26,3 @@ export function CreateContainerInstanceView() {
</> </>
); );
} }
export const CreateContainerInstanceViewAngular = r2a(
CreateContainerInstanceView,
[]
);

View File

@ -1,29 +1,36 @@
import { FormikErrors } from 'formik';
import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector'; import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector';
import { FormError } from '@@/form-components/FormError'; import { FormError } from '@@/form-components/FormError';
import { InputGroup } from '@@/form-components/InputGroup'; import { InputGroup } from '@@/form-components/InputGroup';
import { InputList } from '@@/form-components/InputList'; import { InputList } from '@@/form-components/InputList';
import { import { ItemProps } from '@@/form-components/InputList/InputList';
InputListError,
ItemProps,
} from '@@/form-components/InputList/InputList';
import styles from './PortsMappingField.module.css'; import styles from './PortsMappingField.module.css';
type Protocol = 'TCP' | 'UDP'; type Protocol = 'TCP' | 'UDP';
export interface PortMapping { export interface PortMapping {
host: string; host?: number;
container: string; container?: number;
protocol: Protocol; protocol: Protocol;
} }
interface Props { interface Props {
value: PortMapping[]; value: PortMapping[];
onChange(value: PortMapping[]): void; onChange?(value: PortMapping[]): void;
errors?: InputListError<PortMapping>[] | string; errors?: FormikErrors<PortMapping>[] | string | string[];
disabled?: boolean;
readOnly?: boolean;
} }
export function PortsMappingField({ value, onChange, errors }: Props) { export function PortsMappingField({
value,
onChange = () => {},
errors,
disabled,
readOnly,
}: Props) {
return ( return (
<> <>
<InputList<PortMapping> <InputList<PortMapping>
@ -31,9 +38,15 @@ export function PortsMappingField({ value, onChange, errors }: Props) {
value={value} value={value}
onChange={onChange} onChange={onChange}
addLabel="map additional port" addLabel="map additional port"
itemBuilder={() => ({ host: '', container: '', protocol: 'TCP' })} itemBuilder={() => ({
host: 0,
container: 0,
protocol: 'TCP',
})}
item={Item} item={Item}
errors={errors} errors={errors}
disabled={disabled}
readOnly={readOnly}
/> />
{typeof errors === 'string' && ( {typeof errors === 'string' && (
<div className="form-group col-md-12"> <div className="form-group col-md-12">
@ -44,7 +57,13 @@ export function PortsMappingField({ value, onChange, errors }: Props) {
); );
} }
function Item({ onChange, item, error }: ItemProps<PortMapping>) { function Item({
onChange,
item,
error,
disabled,
readOnly,
}: ItemProps<PortMapping>) {
return ( return (
<div className={styles.item}> <div className={styles.item}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -53,7 +72,12 @@ function Item({ onChange, item, error }: ItemProps<PortMapping>) {
<InputGroup.Input <InputGroup.Input
placeholder="e.g. 80" placeholder="e.g. 80"
value={item.host} value={item.host}
onChange={(e) => handleChange('host', e.target.value)} onChange={(e) =>
handleChange('host', parseInt(e.target.value || '0', 10))
}
disabled={disabled}
readOnly={readOnly}
type="number"
/> />
</InputGroup> </InputGroup>
@ -66,7 +90,12 @@ function Item({ onChange, item, error }: ItemProps<PortMapping>) {
<InputGroup.Input <InputGroup.Input
placeholder="e.g. 80" placeholder="e.g. 80"
value={item.container} value={item.container}
onChange={(e) => handleChange('container', e.target.value)} onChange={(e) =>
handleChange('container', parseInt(e.target.value || '0', 10))
}
disabled={disabled}
readOnly={readOnly}
type="number"
/> />
</InputGroup> </InputGroup>
@ -74,6 +103,8 @@ function Item({ onChange, item, error }: ItemProps<PortMapping>) {
onChange={(value) => handleChange('protocol', value)} onChange={(value) => handleChange('protocol', value)}
value={item.protocol} value={item.protocol}
options={[{ value: 'TCP' }, { value: 'UDP' }]} options={[{ value: 'TCP' }, { value: 'UDP' }]}
disabled={disabled}
readOnly={readOnly}
/> />
</div> </div>
{!!error && ( {!!error && (
@ -84,7 +115,7 @@ function Item({ onChange, item, error }: ItemProps<PortMapping>) {
</div> </div>
); );
function handleChange(name: string, value: string) { function handleChange(name: keyof PortMapping, value: string | number) {
onChange({ ...item, [name]: value }); onChange({ ...item, [name]: value });
} }
} }

View File

@ -0,0 +1 @@
export { CreateView } from './CreateView';

View File

@ -1,18 +1,19 @@
import { useMutation, useQueryClient } from 'react-query'; 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 { EnvironmentId } from '@/portainer/environments/types';
import PortainerError from '@/portainer/error'; import PortainerError from '@/portainer/error';
import { import {
ContainerGroup, ContainerGroup,
ContainerInstanceFormValues, ContainerInstanceFormValues,
ResourceGroup, ResourceGroup,
} from '@/azure/types'; } from '@/react/azure/types';
import { applyResourceControl } from '@/portainer/access-control/access-control.service'; import { applyResourceControl } from '@/portainer/access-control/access-control.service';
import { getSubscriptionResourceGroups } from './utils'; import { getSubscriptionResourceGroups } from './utils';
export function useCreateInstance( export function useCreateInstanceMutation(
resourceGroups: { resourceGroups: {
[k: string]: ResourceGroup[]; [k: string]: ResourceGroup[];
}, },
@ -52,7 +53,9 @@ export function useCreateInstance(
const accessControlData = values.accessControl; const accessControlData = values.accessControl;
await applyResourceControl(accessControlData, resourceControl); await applyResourceControl(accessControlData, resourceControl);
queryClient.invalidateQueries(['azure', 'container-instances']); return queryClient.invalidateQueries(
queryKeys.subscriptions(environmentId)
);
}, },
} }
); );

View File

@ -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<string, ResourceGroup[]> = {},
providers: Record<string, ProviderViewModel> = {}
) {
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<T>(arr: { value: T }[]) {
if (arr.length === 0) {
return undefined;
}
return arr[0].value;
}
}

View File

@ -1,5 +1,4 @@
import { ProviderViewModel } from '@/azure/models/provider'; import { ProviderViewModel, ResourceGroup } from '@/react/azure/types';
import { ResourceGroup } from '@/azure/types';
export function getSubscriptionResourceGroups( export function getSubscriptionResourceGroups(
subscriptionId?: string, subscriptionId?: string,

View File

@ -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 (
<>
<PageHeader
title="Container Instance"
breadcrumbs={[
{ link: 'azure.containerinstances', label: 'Container instances' },
{ label: container.name },
]}
/>
<div className="row">
<div className="col-sm-12">
<Widget>
<WidgetBody className="form-horizontal">
<FormSectionTitle>Azure settings</FormSectionTitle>
<FormControl label="Subscription" inputId="subscription-input">
<Input
name="subscription"
id="subscription-input"
value={container.subscriptionName}
readOnly
/>
</FormControl>
<FormControl label="Resource group" inputId="resourceGroup-input">
<Input
name="resourceGroup"
id="resourceGroup-input"
value={container.resourceGroupName}
readOnly
/>
</FormControl>
<FormControl label="Location" inputId="location-input">
<Input
name="location"
id="location-input"
value={container.location}
readOnly
/>
</FormControl>
<FormSectionTitle>Container configuration</FormSectionTitle>
<FormControl label="Name" inputId="name-input">
<Input
name="name"
id="name-input"
readOnly
value={container.name}
/>
</FormControl>
<FormControl label="Image" inputId="image-input">
<Input
name="image"
id="image-input"
value={container.imageName}
readOnly
/>
</FormControl>
<FormControl label="OS" inputId="os-input">
<Input
name="os"
id="os-input"
readOnly
value={container.osType}
/>
</FormControl>
<PortsMappingField value={container.ports} readOnly />
<FormControl label="Public IP" inputId="public-ip">
<Input
name="public-ip"
id="public-ip"
readOnly
value={container.ipAddress}
/>
</FormControl>
<FormSectionTitle>Container Resources</FormSectionTitle>
<FormControl label="CPU" inputId="cpu-input">
<Input
name="cpu"
id="cpu-input"
type="number"
placeholder="1"
readOnly
value={container.cpu}
/>
</FormControl>
<FormControl label="Memory" inputId="cpu-input">
<Input
name="memory"
id="memory-input"
type="number"
placeholder="1"
readOnly
value={container.memory}
/>
</FormControl>
</WidgetBody>
</Widget>
</div>
</div>
<AccessControlPanel
onUpdateSuccess={() =>
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,
};
}
}

View File

@ -0,0 +1 @@
export { ItemView } from './ItemView';

View File

@ -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<TableSettings>();
const [searchBarValue, setSearchBarValue] = useSearchBarState(tableKey);
const columns = useColumns();
const {
getTableProps,
getTableBodyProps,
headerGroups,
page,
prepareRow,
selectedFlatRows,
gotoPage,
setPageSize,
setGlobalFilter,
state: { pageIndex, pageSize },
} = useTable<ContainerGroup>(
{
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 (
<div className="row">
<div className="col-sm-12">
<TableContainer>
<TableTitle icon={Box} label="Containers">
<SearchBar
value={searchBarValue}
onChange={handleSearchBarChange}
/>
<TableActions>
<Authorized authorizations="AzureContainerGroupDelete">
<Button
color="dangerlight"
disabled={selectedFlatRows.length === 0}
onClick={() =>
handleRemoveClick(
selectedFlatRows.map((row) => row.original.id)
)
}
icon={Trash2}
>
Remove
</Button>
</Authorized>
<Authorized authorizations="AzureContainerGroupCreate">
<Link to="azure.containerinstances.new" className="space-left">
<Button icon={Plus}>Add container</Button>
</Link>
</Authorized>
</TableActions>
</TableTitle>
<Table
className={tableProps.className}
role={tableProps.role}
style={tableProps.style}
>
<thead>
{headerGroups.map((headerGroup) => {
const { key, className, role, style } =
headerGroup.getHeaderGroupProps();
return (
<TableHeaderRow<ContainerGroup>
key={key}
className={className}
role={role}
style={style}
headers={headerGroup.headers}
onSortChange={handleSortChange}
/>
);
})}
</thead>
<tbody
className={tbodyProps.className}
role={tbodyProps.role}
style={tbodyProps.style}
>
<Table.Content
prepareRow={prepareRow}
renderRow={(row, { key, className, role, style }) => (
<TableRow<ContainerGroup>
cells={row.cells}
key={key}
className={className}
role={role}
style={style}
/>
)}
rows={page}
emptyContent="No container available."
/>
</tbody>
</Table>
<TableFooter>
<SelectedRowsCount value={selectedFlatRows.length} />
<PaginationControls
showAll
pageLimit={pageSize}
page={pageIndex + 1}
onPageChange={(p) => gotoPage(p - 1)}
totalCount={dataset.length}
onPageLimitChange={handlePageSizeChange}
/>
</TableFooter>
</TableContainer>
</div>
</div>
);
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 },
}));
}
}

View File

@ -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 (
<>
<PageHeader
breadcrumbs="Container instances"
reload
title="Container list"
/>
<TableSettingsProvider defaults={defaultSettings} storageKey={tableKey}>
<ContainersDatatable
tableKey={tableKey}
dataset={groupsQuery.containerGroups}
onRemoveClick={handleRemove}
/>
</TableSettingsProvider>
</>
);
}
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');
},
});
}
}

View File

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

View File

@ -0,0 +1,13 @@
import { Column } from 'react-table';
import { ContainerGroup } from '@/react/azure/types';
export const location: Column<ContainerGroup> = {
Header: 'Location',
accessor: (container) => container.location,
id: 'location',
disableFilters: true,
Filter: () => null,
canHide: true,
sortType: 'string',
};

View File

@ -0,0 +1,31 @@
import { CellProps, Column } from 'react-table';
import { ContainerGroup } from '@/react/azure/types';
import { Link } from '@@/Link';
export const name: Column<ContainerGroup> = {
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<ContainerGroup, string>) {
return (
<Link
to="azure.containerinstances.container"
params={{ id: container.id }}
className="hover:underline"
>
{name}
</Link>
);
}

View File

@ -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<ContainerGroup> = {
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 (
<>
<i
className={clsx(ownershipIcon(value), 'space-right')}
aria-hidden="true"
/>
{value}
</>
);
}

View File

@ -0,0 +1,34 @@
import { CellProps, Column } from 'react-table';
import { ContainerGroup } from '@/react/azure/types';
import { getPorts } from '@/react/azure/utils';
export const ports: Column<ContainerGroup> = {
Header: 'Published Ports',
accessor: (container) => getPorts(container),
id: 'ports',
disableFilters: true,
Filter: () => null,
canHide: true,
sortType: 'string',
Cell: PortsCell,
};
function PortsCell({
value: ports,
row: { original: container },
}: CellProps<ContainerGroup, ReturnType<typeof getPorts>>) {
const ip = container.properties.ipAddress
? container.properties.ipAddress.ip
: '';
if (ports.length === 0 || !ip) {
return '-';
}
return ports.map((port) => (
<a className="image-tag" href={`http://${ip}:${port.host}`} key={port.host}>
<i className="fa fa-external-link-alt" aria-hidden="true" /> {ip}:
{port.host}
</a>
));
}

View File

@ -0,0 +1 @@
export { ListView } from './ListView';

View File

@ -0,0 +1,8 @@
import {
PaginationTableSettings,
SortableTableSettings,
} from '@@/datatables/types';
export interface TableSettings
extends PaginationTableSettings,
SortableTableSettings {}

View File

@ -0,0 +1,47 @@
import { EnvironmentId } from '@/portainer/environments/types';
export const queryKeys = {
subscriptions: (environmentId: EnvironmentId) =>
['azure', environmentId, 'subscriptions'] as const,
subscription: (environmentId: EnvironmentId, subscriptionId: string) =>
[...queryKeys.subscriptions(environmentId), subscriptionId] as const,
resourceGroups: (environmentId: EnvironmentId, subscriptionId: string) =>
[
...queryKeys.subscription(environmentId, subscriptionId),
'resourceGroups',
] as const,
resourceGroup: (
environmentId: EnvironmentId,
subscriptionId: string,
resourceGroupName: string
) =>
[
...queryKeys.resourceGroups(environmentId, subscriptionId),
resourceGroupName,
] as const,
provider: (environmentId: EnvironmentId, subscriptionId: string) =>
[
...queryKeys.subscription(environmentId, subscriptionId),
'provider',
] as const,
containerGroups: (environmentId: EnvironmentId, subscriptionId: string) =>
[
...queryKeys.subscription(environmentId, subscriptionId),
'containerGroups',
] as const,
containerGroup: (
environmentId: EnvironmentId,
subscriptionId: string,
resourceGroupName: string,
containerGroupName: string
) =>
[
...queryKeys.resourceGroup(
environmentId,
subscriptionId,
resourceGroupName
),
'containerGroups',
containerGroupName,
] as const,
};

View File

@ -0,0 +1,59 @@
import { useQuery } from 'react-query';
import { EnvironmentId } from '@/portainer/environments/types';
import { withError } from '@/react-tools/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { ContainerGroup } from '../types';
import { queryKeys } from './query-keys';
import { buildContainerGroupUrl } from './utils';
export function useContainerGroup(
environmentId: EnvironmentId,
subscriptionId: string,
resourceGroupName: string,
containerGroupName: string
) {
return useQuery(
queryKeys.containerGroup(
environmentId,
subscriptionId,
resourceGroupName,
containerGroupName
),
() =>
getContainerGroup(
environmentId,
subscriptionId,
resourceGroupName,
containerGroupName
),
{
...withError('Unable to retrieve Azure container group'),
}
);
}
async function getContainerGroup(
environmentId: EnvironmentId,
subscriptionId: string,
resourceGroupName: string,
containerGroupName: string
) {
try {
const { data } = await axios.get<ContainerGroup>(
buildContainerGroupUrl(
environmentId,
subscriptionId,
resourceGroupName,
containerGroupName
),
{ params: { 'api-version': '2018-04-01' } }
);
return data;
} catch (e) {
throw parseAxiosError(e as Error);
}
}

View File

@ -0,0 +1,59 @@
import _ from 'lodash';
import { useMemo } from 'react';
import { useQueries } from 'react-query';
import { withError } from '@/react-tools/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/portainer/environments/types';
import { Subscription, ContainerGroup } from '../types';
import { queryKeys } from './query-keys';
import { buildContainerGroupUrl } from './utils';
export function useContainerGroups(
environmentId: EnvironmentId,
subscriptions: Subscription[] = [],
enabled?: boolean
) {
const queries = useQueries(
useMemo(
() =>
subscriptions.map((subscription) => ({
queryKey: queryKeys.containerGroups(
environmentId,
subscription.subscriptionId
),
queryFn: async () =>
getContainerGroups(environmentId, subscription.subscriptionId),
...withError('Unable to retrieve Azure container groups'),
enabled,
})),
[subscriptions, enabled, environmentId]
)
);
return useMemo(
() => ({
containerGroups: _.flatMap(_.compact(queries.map((q) => q.data))),
isLoading: queries.some((q) => q.isLoading),
}),
[queries]
);
}
export async function getContainerGroups(
environmentId: EnvironmentId,
subscriptionId: string
) {
try {
const { data } = await axios.get<{ value: ContainerGroup[] }>(
buildContainerGroupUrl(environmentId, subscriptionId),
{ params: { 'api-version': '2018-04-01' } }
);
return data.value;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to retrieve container groups');
}
}

View File

@ -0,0 +1,87 @@
import _ from 'lodash';
import { useQueries } from 'react-query';
import { EnvironmentId } from '@/portainer/environments/types';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withError } from '@/react-tools/react-query';
import { ProviderViewModel, Subscription } from '../types';
import { azureErrorParser } from '../services/utils';
import { queryKeys } from './query-keys';
export function useProvider(
environmentId: EnvironmentId,
subscriptions: Subscription[] = []
) {
const queries = useQueries(
subscriptions.map((subscription) => ({
queryKey: queryKeys.provider(environmentId, subscription.subscriptionId),
queryFn: async () => {
const provider = await getContainerInstanceProvider(
environmentId,
subscription.subscriptionId
);
return [subscription.subscriptionId, provider] as const;
},
...withError('Unable to retrieve Azure providers'),
}))
);
return {
providers: Object.fromEntries(
_.compact(
queries.map((q) => {
if (q.data) {
return q.data;
}
return null;
})
)
),
isLoading: queries.some((q) => q.isLoading),
};
}
interface ResourceType {
resourceType: 'containerGroups' | string;
locations: string[];
}
interface ProviderResponse {
id: string;
namespace: string;
resourceTypes: ResourceType[];
}
async function getContainerInstanceProvider(
environmentId: EnvironmentId,
subscriptionId: string
) {
try {
const url = `/endpoints/${environmentId}/azure/subscriptions/${subscriptionId}/providers/Microsoft.ContainerInstance`;
const { data } = await axios.get<ProviderResponse>(url, {
params: { 'api-version': '2018-02-01' },
});
return parseViewModel(data);
} catch (error) {
throw parseAxiosError(
error as Error,
'Unable to retrieve provider',
azureErrorParser
);
}
}
function parseViewModel({
id,
namespace,
resourceTypes,
}: ProviderResponse): ProviderViewModel {
const containerGroupType = _.find(resourceTypes, {
resourceType: 'containerGroups',
});
const { locations = [] } = containerGroupType || {};
return { id, namespace, locations };
}

View File

@ -0,0 +1,46 @@
import { useQuery } from 'react-query';
import { EnvironmentId } from '@/portainer/environments/types';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withError } from '@/react-tools/react-query';
import { azureErrorParser } from '../services/utils';
import { ResourceGroup } from '../types';
import { queryKeys } from './query-keys';
import { buildResourceGroupUrl } from './utils';
export function useResourceGroup(
environmentId: EnvironmentId,
subscriptionId: string,
resourceGroupName: string
) {
return useQuery(
queryKeys.resourceGroup(environmentId, subscriptionId, resourceGroupName),
() => getResourceGroup(environmentId, subscriptionId, resourceGroupName),
{
...withError('Unable to retrieve Azure resource group'),
}
);
}
export async function getResourceGroup(
environmentId: EnvironmentId,
subscriptionId: string,
resourceGroupName: string
) {
try {
const { data } = await axios.get<ResourceGroup>(
buildResourceGroupUrl(environmentId, subscriptionId, resourceGroupName),
{ params: { 'api-version': '2018-02-01' } }
);
return data;
} catch (err) {
throw parseAxiosError(
err as Error,
'Unable to retrieve resource group',
azureErrorParser
);
}
}

View File

@ -0,0 +1,72 @@
import _ from 'lodash';
import { useQueries } from 'react-query';
import { EnvironmentId } from '@/portainer/environments/types';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withError } from '@/react-tools/react-query';
import { azureErrorParser } from '../services/utils';
import { Subscription, ResourceGroup } from '../types';
import { queryKeys } from './query-keys';
import { buildResourceGroupUrl } from './utils';
export function useResourceGroups(
environmentId: EnvironmentId,
subscriptions: Subscription[] = []
) {
const queries = useQueries(
subscriptions.map((subscription) => ({
queryKey: queryKeys.resourceGroups(
environmentId,
subscription.subscriptionId
),
queryFn: async () => {
const groups = await getResourceGroups(
environmentId,
subscription.subscriptionId
);
return [subscription.subscriptionId, groups] as const;
},
...withError('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),
};
}
async function getResourceGroups(
environmentId: EnvironmentId,
subscriptionId: string
) {
try {
const {
data: { value },
} = await axios.get<{ value: ResourceGroup[] }>(
buildResourceGroupUrl(environmentId, subscriptionId),
{ params: { 'api-version': '2018-02-01' } }
);
return value;
} catch (err) {
throw parseAxiosError(
err as Error,
'Unable to retrieve resource groups',
azureErrorParser
);
}
}

View File

@ -0,0 +1,44 @@
import { useQuery } from 'react-query';
import { EnvironmentId } from '@/portainer/environments/types';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withError } from '@/react-tools/react-query';
import { Subscription } from '../types';
import { azureErrorParser } from '../services/utils';
import { queryKeys } from './query-keys';
import { buildSubscriptionsUrl } from './utils';
export function useSubscription(
environmentId: EnvironmentId,
subscriptionId: string
) {
return useQuery(
queryKeys.subscription(environmentId, subscriptionId),
() => getSubscription(environmentId, subscriptionId),
{
...withError('Unable to retrieve Azure subscription'),
}
);
}
async function getSubscription(
environmentId: EnvironmentId,
subscriptionId: string
) {
try {
const { data } = await axios.get<Subscription>(
buildSubscriptionsUrl(environmentId, subscriptionId),
{ params: { 'api-version': '2016-06-01' } }
);
return data;
} catch (e) {
throw parseAxiosError(
e as Error,
'Unable to retrieve subscription',
azureErrorParser
);
}
}

View File

@ -0,0 +1,37 @@
import { useQuery } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/portainer/environments/types';
import { withError } from '@/react-tools/react-query';
import { azureErrorParser } from '../services/utils';
import { Subscription } from '../types';
import { queryKeys } from './query-keys';
import { buildSubscriptionsUrl } from './utils';
export function useSubscriptions(environmentId: EnvironmentId) {
return useQuery(
queryKeys.subscriptions(environmentId),
() => getSubscriptions(environmentId),
{
...withError('Unable to retrieve Azure subscriptions'),
}
);
}
async function getSubscriptions(environmentId: EnvironmentId) {
try {
const { data } = await axios.get<{ value: Subscription[] }>(
buildSubscriptionsUrl(environmentId),
{ params: { 'api-version': '2016-06-01' } }
);
return data.value;
} catch (e) {
throw parseAxiosError(
e as Error,
'Unable to retrieve subscriptions',
azureErrorParser
);
}
}

View File

@ -0,0 +1,51 @@
import { EnvironmentId } from '@/portainer/environments/types';
export function buildSubscriptionsUrl(
environmentId: EnvironmentId,
id?: string
) {
let url = `/endpoints/${environmentId}/azure/subscriptions`;
if (id) {
url += `/${id}`;
}
return url;
}
export function buildResourceGroupUrl(
environmentId: EnvironmentId,
subscriptionId: string,
resourceGroupName?: string
) {
let url = `${buildSubscriptionsUrl(
environmentId,
subscriptionId
)}/resourcegroups`;
if (resourceGroupName) {
url += `/${resourceGroupName}`;
}
return url;
}
export function buildContainerGroupUrl(
environmentId: EnvironmentId,
subscriptionId: string,
resourceGroupName?: string,
containerGroupName?: string
) {
let url = buildSubscriptionsUrl(environmentId, subscriptionId);
if (resourceGroupName) {
url += `/resourceGroups/${resourceGroupName}`;
}
url += `/providers/Microsoft.ContainerInstance/containerGroups`;
if (containerGroupName) {
url += `/${containerGroupName}`;
}
return url;
}

View File

@ -1,6 +1,7 @@
import { EnvironmentId } from '@/portainer/environments/types'; import { EnvironmentId } from '@/portainer/environments/types';
import axios, { parseAxiosError } from '@/portainer/services/axios'; import axios, { parseAxiosError } from '@/portainer/services/axios';
import { buildContainerGroupUrl } from '../queries/utils';
import { ContainerGroup, ContainerInstanceFormValues } from '../types'; import { ContainerGroup, ContainerInstanceFormValues } from '../types';
export async function createContainerGroup( export async function createContainerGroup(
@ -12,7 +13,12 @@ export async function createContainerGroup(
const payload = transformToPayload(model); const payload = transformToPayload(model);
try { try {
const { data } = await axios.put<ContainerGroup>( const { data } = await axios.put<ContainerGroup>(
buildUrl(environmentId, subscriptionId, resourceGroupName, model.name), buildContainerGroupUrl(
environmentId,
subscriptionId,
resourceGroupName,
model.name
),
payload, payload,
{ params: { 'api-version': '2018-04-01' } } { params: { 'api-version': '2018-04-01' } }
); );
@ -22,13 +28,17 @@ export async function createContainerGroup(
} }
} }
function buildUrl( export async function deleteContainerGroup(
environmentId: EnvironmentId, environmentId: EnvironmentId,
subscriptionId: string, containerGroupId: string
resourceGroupName: string,
containerGroupName: string
) { ) {
return `/endpoints/${environmentId}/azure/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.ContainerInstance/containerGroups/${containerGroupName}`; try {
await axios.delete(`/endpoints/${environmentId}/azure${containerGroupId}`, {
params: { 'api-version': '2018-04-01' },
});
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to remove container group');
}
} }
function transformToPayload(model: ContainerInstanceFormValues) { function transformToPayload(model: ContainerInstanceFormValues) {

View File

@ -1,7 +1,7 @@
import { AccessControlFormData } from '@/portainer/access-control/types'; import { AccessControlFormData } from '@/portainer/access-control/types';
import { PortainerMetadata } from '@/react/docker/types'; import { PortainerMetadata } from '@/react/docker/types';
import { PortMapping } from './ContainerInstances/CreateContainerInstanceForm/PortsMappingField'; import { PortMapping } from './container-instances/CreateView/PortsMappingField';
type OS = 'Linux' | 'Windows'; type OS = 'Linux' | 'Windows';
@ -33,7 +33,7 @@ interface Container {
} }
interface ContainerGroupProperties { interface ContainerGroupProperties {
containers: Container[]; containers: (Container | undefined)[];
instanceView: { instanceView: {
events: unknown[]; events: unknown[];
state: 'pending' | string; state: 'pending' | string;
@ -42,18 +42,19 @@ interface ContainerGroupProperties {
dnsNameLabelReusePolicy: string; dnsNameLabelReusePolicy: string;
ports: { port: number; protocol: 'TCP' | 'UDP' }[]; ports: { port: number; protocol: 'TCP' | 'UDP' }[];
type: 'Public' | 'Private'; type: 'Public' | 'Private';
ip: string;
}; };
osType: OS; osType: OS;
} }
export interface ContainerGroup { export type ContainerGroup = {
id: string; id: string;
name: string; name: string;
location: string; location: string;
type: string; type: string;
properties: ContainerGroupProperties; properties: ContainerGroupProperties;
Portainer: PortainerMetadata; Portainer?: PortainerMetadata;
} };
export interface Subscription { export interface Subscription {
subscriptionId: string; subscriptionId: string;
@ -67,13 +68,8 @@ export interface ResourceGroup {
subscriptionId: string; subscriptionId: string;
} }
interface ResourceType { export interface ProviderViewModel {
resourceType: 'containerGroups' | string;
locations: string[];
}
export interface ProviderResponse {
id: string; id: string;
namespace: string; namespace: string;
resourceTypes: ResourceType[]; locations: string[];
} }

20
app/react/azure/utils.ts Normal file
View File

@ -0,0 +1,20 @@
import { ContainerGroup } from './types';
export function getPorts(containerGroup: ContainerGroup) {
const addressPorts = containerGroup.properties.ipAddress
? containerGroup.properties.ipAddress.ports
: [];
const container = containerGroup.properties.containers.length
? containerGroup.properties.containers[0]
: null;
const containerPorts = container ? container.properties.ports : [];
return addressPorts.map((binding, index) => {
const port = containerPorts[index] ? containerPorts[index].port : undefined;
return {
container: port,
host: binding.port,
protocol: binding.protocol,
};
});
}

View File

@ -10,16 +10,20 @@ export interface Crumb {
linkParams?: Record<string, unknown>; linkParams?: Record<string, unknown>;
} }
interface Props { interface Props {
breadcrumbs: (Crumb | string)[]; breadcrumbs: (Crumb | string)[] | string;
} }
export function Breadcrumbs({ breadcrumbs }: Props) { export function Breadcrumbs({ breadcrumbs }: Props) {
const breadcrumbsArray = Array.isArray(breadcrumbs)
? breadcrumbs
: [breadcrumbs];
return ( return (
<div className="breadcrumb-links"> <div className="breadcrumb-links">
{breadcrumbs.map((crumb, index) => ( {breadcrumbsArray.map((crumb, index) => (
<Fragment key={index}> <Fragment key={index}>
{renderCrumb(crumb)} {renderCrumb(crumb)}
{index !== breadcrumbs.length - 1 ? ' > ' : ''} {index !== breadcrumbsArray.length - 1 ? ' > ' : ''}
</Fragment> </Fragment>
))} ))}
</div> </div>

View File

@ -14,7 +14,7 @@ interface Props {
reload?: boolean; reload?: boolean;
loading?: boolean; loading?: boolean;
onReload?(): Promise<void> | void; onReload?(): Promise<void> | void;
breadcrumbs?: Crumb[]; breadcrumbs?: (Crumb | string)[] | string;
title: string; title: string;
} }

View File

@ -5,10 +5,11 @@ import styles from './AddButton.module.css';
export interface Props { export interface Props {
className?: string; className?: string;
label: string; label: string;
disabled?: boolean;
onClick: () => void; onClick: () => void;
} }
export function AddButton({ label, onClick, className }: Props) { export function AddButton({ label, onClick, className, disabled }: Props) {
return ( return (
<button <button
className={clsx( className={clsx(
@ -20,6 +21,7 @@ export function AddButton({ label, onClick, className }: Props) {
)} )}
type="button" type="button"
onClick={onClick} onClick={onClick}
disabled={disabled}
> >
<i className="fa fa-plus-circle space-right" aria-hidden="true" /> {label} <i className="fa fa-plus-circle space-right" aria-hidden="true" /> {label}
</button> </button>

View File

@ -1,13 +1,11 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { Icon } from '@/react/components/Icon'; import { Icon, IconProps } from '@@/Icon';
import { useTableContext } from './TableContainer'; import { useTableContext } from './TableContainer';
interface Props { interface Props extends IconProps {
icon: string;
label: string; label: string;
featherIcon?: boolean;
} }
export function TableTitle({ export function TableTitle({

View File

@ -25,7 +25,7 @@ interface UseRowSelectTableInstance<D extends DefaultType = DefaultType>
isAllRowSelected: boolean; isAllRowSelected: boolean;
selectSubRows: boolean; selectSubRows: boolean;
getSubRows(row: Row<D>): Row<D>[]; getSubRows(row: Row<D>): Row<D>[];
isRowSelectable(row: Row<D>): boolean; isRowSelectable?(row: Row<D>): boolean;
} }
const pluginName = 'useRowSelect'; const pluginName = 'useRowSelect';
@ -73,7 +73,10 @@ function defaultGetToggleRowSelectedProps<D extends DefaultType>(
props: D, props: D,
{ instance, row }: { instance: UseRowSelectTableInstance<D>; row: Row<D> } { instance, row }: { instance: UseRowSelectTableInstance<D>; row: Row<D> }
) { ) {
const { manualRowSelectedKey = 'isSelected' } = instance; const {
manualRowSelectedKey = 'isSelected',
isRowSelectable = defaultIsRowSelectable,
} = instance;
let checked = false; let checked = false;
if (row.original && row.original[manualRowSelectedKey]) { if (row.original && row.original[manualRowSelectedKey]) {
@ -94,7 +97,7 @@ function defaultGetToggleRowSelectedProps<D extends DefaultType>(
checked, checked,
title: 'Toggle Row Selected', title: 'Toggle Row Selected',
indeterminate: row.isSomeSelected, indeterminate: row.isSomeSelected,
disabled: !instance.isRowSelectable(row), disabled: !isRowSelectable(row),
}, },
]; ];
} }
@ -317,7 +320,7 @@ function useInstance<D extends Record<string, unknown>>(
dispatch, dispatch,
page, page,
getSubRows, getSubRows,
isRowSelectable, isRowSelectable = defaultIsRowSelectable,
} = instance; } = instance;
ensurePluginOrder( ensurePluginOrder(
@ -474,5 +477,5 @@ function getRowIsSelected<D extends Record<string, unknown>>(
} }
function defaultIsRowSelectable<D extends DefaultType>(row: Row<D>) { function defaultIsRowSelectable<D extends DefaultType>(row: Row<D>) {
return !!row.original.disabled; return !row.original.disabled;
} }

View File

@ -15,6 +15,8 @@ interface Props<T> {
onChange(value: T): void; onChange(value: T): void;
options: Option<T>[]; options: Option<T>[];
size?: Size; size?: Size;
disabled?: boolean;
readOnly?: boolean;
} }
export function ButtonSelector<T extends string | number>({ export function ButtonSelector<T extends string | number>({
@ -22,6 +24,8 @@ export function ButtonSelector<T extends string | number>({
onChange, onChange,
size, size,
options, options,
disabled,
readOnly,
}: Props<T>) { }: Props<T>) {
return ( return (
<ButtonGroup size={size} className={styles.group}> <ButtonGroup size={size} className={styles.group}>
@ -30,6 +34,8 @@ export function ButtonSelector<T extends string | number>({
key={option.value} key={option.value}
selected={value === option.value} selected={value === option.value}
onChange={() => onChange(option.value)} onChange={() => onChange(option.value)}
disabled={disabled}
readOnly={readOnly}
> >
{option.label || option.value.toString()} {option.label || option.value.toString()}
</OptionItem> </OptionItem>
@ -41,17 +47,32 @@ export function ButtonSelector<T extends string | number>({
interface OptionItemProps { interface OptionItemProps {
selected: boolean; selected: boolean;
onChange(): void; onChange(): void;
disabled?: boolean;
readOnly?: boolean;
} }
function OptionItem({ function OptionItem({
selected, selected,
children, children,
onChange, onChange,
disabled,
readOnly,
}: PropsWithChildren<OptionItemProps>) { }: PropsWithChildren<OptionItemProps>) {
return ( return (
<label className={clsx('btn btn-primary', { active: selected })}> <label
className={clsx('btn btn-primary', {
active: selected,
disabled: readOnly || disabled,
})}
>
{children} {children}
<input type="radio" checked={selected} onChange={onChange} /> <input
type="radio"
checked={selected}
onChange={onChange}
disabled={disabled}
readOnly={readOnly}
/>
</label> </label>
); );
} }

View File

@ -1,5 +1,6 @@
import { ComponentType } from 'react'; import { ComponentType } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import { FormikErrors } from 'formik';
import { AddButton, Button } from '@@/buttons'; import { AddButton, Button } from '@@/buttons';
import { Tooltip } from '@@/Tip/Tooltip'; import { Tooltip } from '@@/Tip/Tooltip';
@ -11,12 +12,12 @@ import { FormError } from '../FormError';
import styles from './InputList.module.css'; import styles from './InputList.module.css';
import { arrayMove } from './utils'; import { arrayMove } from './utils';
export type InputListError<T> = Record<keyof T, string>;
export interface ItemProps<T> { export interface ItemProps<T> {
item: T; item: T;
onChange(value: T): void; onChange(value: T): void;
error?: InputListError<T>; error?: string | FormikErrors<T>;
disabled?: boolean;
readOnly?: boolean;
} }
type Key = string | number; type Key = string | number;
type ChangeType = 'delete' | 'create' | 'update'; type ChangeType = 'delete' | 'create' | 'update';
@ -36,7 +37,7 @@ type OnChangeEvent<T> =
type RenderItemFunction<T> = ( type RenderItemFunction<T> = (
item: T, item: T,
onChange: (value: T) => void, onChange: (value: T) => void,
error?: InputListError<T> error?: string | FormikErrors<T>
) => React.ReactNode; ) => React.ReactNode;
interface Props<T> { interface Props<T> {
@ -50,9 +51,11 @@ interface Props<T> {
addLabel?: string; addLabel?: string;
itemKeyGetter?(item: T, index: number): Key; itemKeyGetter?(item: T, index: number): Key;
movable?: boolean; movable?: boolean;
errors?: InputListError<T>[] | string; errors?: FormikErrors<T>[] | string | string[];
textTip?: string; textTip?: string;
isAddButtonHidden?: boolean; isAddButtonHidden?: boolean;
disabled?: boolean;
readOnly?: boolean;
} }
export function InputList<T = DefaultType>({ export function InputList<T = DefaultType>({
@ -69,6 +72,8 @@ export function InputList<T = DefaultType>({
errors, errors,
textTip, textTip,
isAddButtonHidden = false, isAddButtonHidden = false,
disabled,
readOnly,
}: Props<T>) { }: Props<T>) {
return ( return (
<div className={clsx('form-group', styles.root)}> <div className={clsx('form-group', styles.root)}>
@ -77,11 +82,12 @@ export function InputList<T = DefaultType>({
{label} {label}
{tooltip && <Tooltip message={tooltip} />} {tooltip && <Tooltip message={tooltip} />}
</div> </div>
{!isAddButtonHidden && ( {!(isAddButtonHidden || readOnly) && (
<AddButton <AddButton
label={addLabel} label={addLabel}
className="space-left" className="space-left"
onClick={handleAdd} onClick={handleAdd}
disabled={disabled}
/> />
)} )}
</div> </div>
@ -107,6 +113,8 @@ export function InputList<T = DefaultType>({
item={item} item={item}
onChange={(value: T) => handleChangeItem(key, value)} onChange={(value: T) => handleChangeItem(key, value)}
error={error} error={error}
disabled={disabled}
readOnly={readOnly}
/> />
) : ( ) : (
renderItem( renderItem(
@ -116,11 +124,11 @@ export function InputList<T = DefaultType>({
) )
)} )}
<div className={clsx(styles.itemActions, 'items-start')}> <div className={clsx(styles.itemActions, 'items-start')}>
{movable && ( {!readOnly && movable && (
<> <>
<Button <Button
size="small" size="small"
disabled={index === 0} disabled={disabled || index === 0}
onClick={() => handleMoveUp(index)} onClick={() => handleMoveUp(index)}
> >
<i className="fa fa-arrow-up" aria-hidden="true" /> <i className="fa fa-arrow-up" aria-hidden="true" />
@ -128,20 +136,23 @@ export function InputList<T = DefaultType>({
<Button <Button
size="small" size="small"
type="button" type="button"
disabled={index === value.length - 1} disabled={disabled || index === value.length - 1}
onClick={() => handleMoveDown(index)} onClick={() => handleMoveDown(index)}
> >
<i className="fa fa-arrow-down" aria-hidden="true" /> <i className="fa fa-arrow-down" aria-hidden="true" />
</Button> </Button>
</> </>
)} )}
{!readOnly && (
<Button <Button
color="danger" color="danger"
size="small" size="small"
onClick={() => handleRemoveItem(key, item)} onClick={() => handleRemoveItem(key, item)}
disabled={disabled}
> >
<i className="fa fa-trash" aria-hidden="true" /> <i className="fa fa-trash" aria-hidden="true" />
</Button> </Button>
)}
</div> </div>
</div> </div>
); );
@ -210,13 +221,21 @@ function defaultItemBuilder(): DefaultType {
return { value: '' }; return { value: '' };
} }
function DefaultItem({ item, onChange, error }: ItemProps<DefaultType>) { function DefaultItem({
item,
onChange,
error,
disabled,
readOnly,
}: ItemProps<DefaultType>) {
return ( return (
<> <>
<Input <Input
value={item.value} value={item.value}
onChange={(e) => onChange({ value: e.target.value })} onChange={(e) => onChange({ value: e.target.value })}
className={styles.defaultItem} className={styles.defaultItem}
disabled={disabled}
readOnly={readOnly}
/> />
{error && <FormError>{error}</FormError>} {error && <FormError>{error}</FormError>}
</> </>
@ -226,7 +245,7 @@ function DefaultItem({ item, onChange, error }: ItemProps<DefaultType>) {
function renderDefaultItem( function renderDefaultItem(
item: DefaultType, item: DefaultType,
onChange: (value: DefaultType) => void, onChange: (value: DefaultType) => void,
error?: InputListError<DefaultType> error?: FormikErrors<DefaultType>
) { ) {
return <DefaultItem item={item} onChange={onChange} error={error} />; return <DefaultItem item={item} onChange={onChange} error={error} />;
} }

View File

@ -1,10 +1,9 @@
import { FormikErrors } from 'formik';
import { FormError } from '@@/form-components/FormError'; import { FormError } from '@@/form-components/FormError';
import { Input } from '@@/form-components/Input'; import { Input } from '@@/form-components/Input';
import { InputList } from '@@/form-components/InputList'; import { InputList } from '@@/form-components/InputList';
import { import { ItemProps } from '@@/form-components/InputList/InputList';
InputListError,
ItemProps,
} from '@@/form-components/InputList/InputList';
export interface VariableDefinition { export interface VariableDefinition {
name: string; name: string;
@ -16,7 +15,7 @@ export interface VariableDefinition {
interface Props { interface Props {
value: VariableDefinition[]; value: VariableDefinition[];
onChange: (value: VariableDefinition[]) => void; onChange: (value: VariableDefinition[]) => void;
errors?: InputListError<VariableDefinition>[] | string; errors?: FormikErrors<VariableDefinition>[];
isVariablesNamesFromParent?: boolean; isVariablesNamesFromParent?: boolean;
} }
@ -57,6 +56,8 @@ interface DefinitionItemProps extends ItemProps<VariableDefinition> {
} }
function Item({ item, onChange, error, isNameReadonly }: DefinitionItemProps) { function Item({ item, onChange, error, isNameReadonly }: DefinitionItemProps) {
const errorObj = typeof error === 'object' ? error : {};
return ( return (
<div className="flex gap-2"> <div className="flex gap-2">
<div> <div>
@ -67,7 +68,7 @@ function Item({ item, onChange, error, isNameReadonly }: DefinitionItemProps) {
placeholder="Name (e.g var_name)" placeholder="Name (e.g var_name)"
readOnly={isNameReadonly} readOnly={isNameReadonly}
/> />
{error?.name && <FormError>{error.name}</FormError>} {errorObj?.name && <FormError>{errorObj.name}</FormError>}
</div> </div>
<div> <div>
<Input <Input
@ -76,7 +77,7 @@ function Item({ item, onChange, error, isNameReadonly }: DefinitionItemProps) {
placeholder="Label" placeholder="Label"
name="label" name="label"
/> />
{error?.label && <FormError>{error.label}</FormError>} {errorObj?.label && <FormError>{errorObj.label}</FormError>}
</div> </div>
<div> <div>
<Input <Input
@ -85,7 +86,7 @@ function Item({ item, onChange, error, isNameReadonly }: DefinitionItemProps) {
onChange={handleChange} onChange={handleChange}
placeholder="Description" placeholder="Description"
/> />
{error?.description && <FormError>{error.description}</FormError>} {errorObj?.description && <FormError>{errorObj.description}</FormError>}
</div> </div>
<div> <div>
<Input <Input
@ -94,7 +95,9 @@ function Item({ item, onChange, error, isNameReadonly }: DefinitionItemProps) {
placeholder="Default Value" placeholder="Default Value"
name="defaultValue" name="defaultValue"
/> />
{error?.defaultValue && <FormError>{error.defaultValue}</FormError>} {errorObj?.defaultValue && (
<FormError>{errorObj.defaultValue}</FormError>
)}
</div> </div>
</div> </div>
); );

View File

@ -83,8 +83,8 @@ module.exports = {
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
moduleNameMapper: { moduleNameMapper: {
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '<rootDir>/app/__mocks__/fileMock.js', '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '<rootDir>/app/__mocks__/fileMock.js',
'\\.(css|less)$': '<rootDir>/app/__mocks__/styleMock.js',
'\\.svg\\?c$': '<rootDir>/app/__mocks__/svg.js', '\\.svg\\?c$': '<rootDir>/app/__mocks__/svg.js',
'\\.(css|less)$': '<rootDir>/app/__mocks__/styleMock.js',
'^@@/(.*)$': '<rootDir>/app/react/components/$1', '^@@/(.*)$': '<rootDir>/app/react/components/$1',
'^@/(.*)$': '<rootDir>/app/$1', '^@/(.*)$': '<rootDir>/app/$1',
'^Agent/(.*)?': '<rootDir>/app/agent/$1', '^Agent/(.*)?': '<rootDir>/app/agent/$1',