mirror of https://github.com/portainer/portainer
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
parent
b059641c80
commit
82b848af0c
|
@ -1 +0,0 @@
|
|||
export { CreateContainerInstanceForm } from './CreateContainerInstanceForm';
|
|
@ -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),
|
||||
};
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
import angular from 'angular';
|
||||
|
||||
import { CreateContainerInstanceViewAngular } from './CreateContainerInstanceView';
|
||||
|
||||
export const containerInstancesModule = angular
|
||||
.module('portainer.azure.containerInstances', [])
|
||||
|
||||
.component(
|
||||
'createContainerInstanceView',
|
||||
CreateContainerInstanceViewAngular
|
||||
).name;
|
|
@ -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, []);
|
|
@ -1 +0,0 @@
|
|||
export { DashboardViewAngular, DashboardView } from './DashboardView';
|
|
@ -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);
|
|
@ -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>
|
|
@ -1,13 +0,0 @@
|
|||
angular.module('portainer.azure').component('containergroupsDatatable', {
|
||||
templateUrl: './containerGroupsDatatable.html',
|
||||
controller: 'GenericDatatableController',
|
||||
bindings: {
|
||||
titleText: '@',
|
||||
titleIcon: '@',
|
||||
dataset: '<',
|
||||
tableKey: '@',
|
||||
orderBy: '@',
|
||||
reverseOrder: '<',
|
||||
removeAction: '<',
|
||||
},
|
||||
});
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
export function LocationViewModel(data) {
|
||||
this.Id = data.id;
|
||||
this.SubscriptionId = data.subscriptionId;
|
||||
this.DisplayName = data.displayName;
|
||||
this.Name = data.name;
|
||||
}
|
|
@ -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 };
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
export function ResourceGroupViewModel(data, subscriptionId) {
|
||||
this.Id = data.id;
|
||||
this.SubscriptionId = subscriptionId;
|
||||
this.Name = data.name;
|
||||
this.Location = data.location;
|
||||
}
|
|
@ -1,4 +0,0 @@
|
|||
export function SubscriptionViewModel(data) {
|
||||
this.Id = data.subscriptionId;
|
||||
this.Name = data.displayName;
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -1,6 +1,14 @@
|
|||
import angular from 'angular';
|
||||
|
||||
export const viewsModule = angular.module(
|
||||
'portainer.azure.react.views',
|
||||
[]
|
||||
).name;
|
||||
import { r2a } from '@/react-tools/react2angular';
|
||||
import { CreateView } from '@/react/azure/container-instances/CreateView';
|
||||
import { ItemView } from '@/react/azure/container-instances/ItemView';
|
||||
import { ListView } from '@/react/azure/container-instances/ListView';
|
||||
import { DashboardView } from '@/react/azure/DashboardView';
|
||||
|
||||
export const viewsModule = angular
|
||||
.module('portainer.azure.react.views', [])
|
||||
.component('containerInstanceView', r2a(ItemView, []))
|
||||
.component('createContainerInstanceView', r2a(CreateView, []))
|
||||
.component('containerInstancesView', r2a(ListView, []))
|
||||
.component('dashboardView', r2a(DashboardView, [])).name;
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
]);
|
|
@ -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;
|
||||
},
|
||||
]);
|
|
@ -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' } },
|
||||
}
|
||||
);
|
||||
},
|
||||
]);
|
|
@ -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' } },
|
||||
}
|
||||
);
|
||||
},
|
||||
]);
|
|
@ -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' },
|
||||
}
|
||||
);
|
||||
},
|
||||
]);
|
|
@ -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' } },
|
||||
}
|
||||
);
|
||||
},
|
||||
]);
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
},
|
||||
]);
|
|
@ -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;
|
||||
},
|
||||
]);
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
},
|
||||
]);
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
]);
|
|
@ -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>
|
|
@ -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;
|
|
@ -1,6 +0,0 @@
|
|||
import ContainerInstanceDetailsController from './containerInstanceDetailsController.js';
|
||||
|
||||
angular.module('portainer.azure').component('containerInstanceDetails', {
|
||||
templateUrl: './containerInstanceDetails.html',
|
||||
controller: ContainerInstanceDetailsController,
|
||||
});
|
|
@ -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();
|
||||
},
|
||||
]);
|
|
@ -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>
|
|
@ -9,7 +9,7 @@ import './matomo-setup';
|
|||
import analyticsModule from './angulartics.matomo';
|
||||
|
||||
import './agent';
|
||||
import './azure/_module';
|
||||
import { azureModule } from './azure';
|
||||
import './docker/__module';
|
||||
import './edge/__module';
|
||||
import './portainer/__module';
|
||||
|
@ -44,7 +44,7 @@ angular
|
|||
'luegg.directives',
|
||||
'portainer.app',
|
||||
'portainer.agent',
|
||||
'portainer.azure',
|
||||
azureModule,
|
||||
'portainer.docker',
|
||||
'portainer.kubernetes',
|
||||
nomadModule,
|
||||
|
|
|
@ -11,6 +11,7 @@ import homeModule from './home';
|
|||
import { accessControlModule } from './access-control';
|
||||
import { reactModule } from './react';
|
||||
import { sidebarModule } from './react/views/sidebar';
|
||||
import environmentsModule from './environments';
|
||||
|
||||
async function initAuthentication(authManager, Authentication, $rootScope, $state) {
|
||||
authManager.checkAuthOnRefresh();
|
||||
|
@ -42,6 +43,7 @@ angular
|
|||
accessControlModule,
|
||||
reactModule,
|
||||
sidebarModule,
|
||||
environmentsModule,
|
||||
])
|
||||
.config([
|
||||
'$stateRegistryProvider',
|
||||
|
|
|
@ -20,7 +20,7 @@ interface Props {
|
|||
resourceType: ResourceControlType;
|
||||
resourceId: ResourceId;
|
||||
disableOwnershipChange?: boolean;
|
||||
onUpdateSuccess(): void;
|
||||
onUpdateSuccess(): Promise<void>;
|
||||
}
|
||||
|
||||
export function AccessControlPanel({
|
||||
|
@ -80,8 +80,8 @@ export function AccessControlPanel({
|
|||
</div>
|
||||
);
|
||||
|
||||
function handleUpdateSuccess() {
|
||||
onUpdateSuccess();
|
||||
async function handleUpdateSuccess() {
|
||||
await onUpdateSuccess();
|
||||
toggleEditMode();
|
||||
}
|
||||
|
||||
|
|
|
@ -28,7 +28,7 @@ interface Props {
|
|||
resourceId: ResourceId;
|
||||
resourceControl?: ResourceControlViewModel;
|
||||
onCancelClick(): void;
|
||||
onUpdateSuccess(): void;
|
||||
onUpdateSuccess(): Promise<void>;
|
||||
}
|
||||
|
||||
export function AccessControlPanelForm({
|
||||
|
@ -52,6 +52,9 @@ export function AccessControlPanelForm({
|
|||
meta: {
|
||||
error: { title: 'Failure', message: 'Unable to update access control' },
|
||||
},
|
||||
onSuccess() {
|
||||
return onUpdateSuccess();
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -115,7 +118,6 @@ export function AccessControlPanelForm({
|
|||
updateAccess.mutate(accessControl, {
|
||||
onSuccess() {
|
||||
notifySuccess('Access control successfully updated');
|
||||
onUpdateSuccess();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
@ -37,7 +37,7 @@ export class ResourceControlViewModel {
|
|||
}
|
||||
}
|
||||
|
||||
function determineOwnership(resourceControl: ResourceControlResponse) {
|
||||
export function determineOwnership(resourceControl: ResourceControlResponse) {
|
||||
if (resourceControl.Public) {
|
||||
return ResourceControlOwnership.PUBLIC;
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
angular.module('portainer.azure').component('azureEndpointConfig', {
|
||||
export const azureEndpointConfig = {
|
||||
bindings: {
|
||||
applicationId: '=',
|
||||
tenantId: '=',
|
||||
authenticationKey: '=',
|
||||
},
|
||||
templateUrl: './azureEndpointConfig.html',
|
||||
});
|
||||
};
|
|
@ -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;
|
|
@ -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>;
|
||||
}
|
|
@ -17,64 +17,60 @@ jest.mock('@uirouter/react', () => ({
|
|||
}));
|
||||
|
||||
test('dashboard items should render correctly', async () => {
|
||||
const { getByLabelText } = await renderComponent();
|
||||
const { findByLabelText } = await renderComponent();
|
||||
|
||||
const subscriptionsItem = getByLabelText('Subscription');
|
||||
const subscriptionsItem = await findByLabelText('Subscription');
|
||||
expect(subscriptionsItem).toBeVisible();
|
||||
|
||||
const subscriptionElements = within(subscriptionsItem);
|
||||
expect(subscriptionElements.getByLabelText('value')).toBeVisible();
|
||||
expect(subscriptionElements.getByRole('img', { hidden: true })).toHaveClass(
|
||||
'fa-th-list'
|
||||
);
|
||||
|
||||
expect(subscriptionElements.getByLabelText('resourceType')).toHaveTextContent(
|
||||
'Subscriptions'
|
||||
);
|
||||
|
||||
const resourceGroupsItem = getByLabelText('Resource group');
|
||||
const resourceGroupsItem = await findByLabelText('Resource group');
|
||||
expect(resourceGroupsItem).toBeVisible();
|
||||
|
||||
const resourceGroupElements = within(resourceGroupsItem);
|
||||
expect(resourceGroupElements.getByLabelText('value')).toBeVisible();
|
||||
expect(resourceGroupElements.getByRole('img', { hidden: true })).toHaveClass(
|
||||
'fa-th-list'
|
||||
);
|
||||
|
||||
expect(
|
||||
resourceGroupElements.getByLabelText('resourceType')
|
||||
).toHaveTextContent('Resource groups');
|
||||
});
|
||||
|
||||
test('when there are no subscriptions, should show 0 subscriptions and 0 resource groups', async () => {
|
||||
const { getByLabelText } = await renderComponent();
|
||||
const { findByLabelText } = await renderComponent();
|
||||
|
||||
const subscriptionElements = within(getByLabelText('Subscription'));
|
||||
const subscriptionElements = within(await findByLabelText('Subscription'));
|
||||
expect(subscriptionElements.getByLabelText('value')).toHaveTextContent('0');
|
||||
|
||||
const resourceGroupElements = within(getByLabelText('Resource group'));
|
||||
const resourceGroupElements = within(await findByLabelText('Resource group'));
|
||||
expect(resourceGroupElements.getByLabelText('value')).toHaveTextContent('0');
|
||||
});
|
||||
|
||||
test('when there is subscription & resource group data, should display these', async () => {
|
||||
const { getByLabelText } = await renderComponent(1, { 'subscription-1': 2 });
|
||||
const { findByLabelText } = await renderComponent(1, { 'subscription-1': 2 });
|
||||
|
||||
const subscriptionElements = within(getByLabelText('Subscription'));
|
||||
const subscriptionElements = within(await findByLabelText('Subscription'));
|
||||
expect(subscriptionElements.getByLabelText('value')).toHaveTextContent('1');
|
||||
|
||||
const resourceGroupElements = within(getByLabelText('Resource group'));
|
||||
const resourceGroupElements = within(await findByLabelText('Resource group'));
|
||||
expect(resourceGroupElements.getByLabelText('value')).toHaveTextContent('2');
|
||||
});
|
||||
|
||||
test('should correctly show total number of resource groups across multiple subscriptions', async () => {
|
||||
const { getByLabelText } = await renderComponent(2, {
|
||||
const { findByLabelText } = await renderComponent(2, {
|
||||
'subscription-1': 2,
|
||||
'subscription-2': 3,
|
||||
});
|
||||
|
||||
const resourceGroupElements = within(getByLabelText('Resource group'));
|
||||
const resourceGroupElements = within(await findByLabelText('Resource group'));
|
||||
expect(resourceGroupElements.getByLabelText('value')).toHaveTextContent('5');
|
||||
});
|
||||
|
||||
test('when only subscriptions fail to load, dont show the dashboard', async () => {
|
||||
test("when only subscriptions fail to load, don't show the dashboard", async () => {
|
||||
const { queryByLabelText } = await renderComponent(
|
||||
1,
|
||||
{ 'subscription-1': 1 },
|
||||
|
@ -86,13 +82,13 @@ test('when only subscriptions fail to load, dont show the dashboard', async () =
|
|||
});
|
||||
|
||||
test('when only resource groups fail to load, still show the subscriptions', async () => {
|
||||
const { queryByLabelText } = await renderComponent(
|
||||
const { queryByLabelText, findByLabelText } = await renderComponent(
|
||||
1,
|
||||
{ 'subscription-1': 1 },
|
||||
200,
|
||||
500
|
||||
);
|
||||
expect(queryByLabelText('Subscription')).toBeInTheDocument();
|
||||
await expect(findByLabelText('Subscription')).resolves.toBeInTheDocument();
|
||||
expect(queryByLabelText('Resource group')).not.toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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 |
|
@ -0,0 +1 @@
|
|||
export { DashboardView } from './DashboardView';
|
|
@ -1,43 +1,45 @@
|
|||
import { Field, Form, Formik } from 'formik';
|
||||
import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
|
||||
import { useRouter } from '@uirouter/react';
|
||||
|
||||
import { ContainerInstanceFormValues } from '@/azure/types';
|
||||
import { ContainerInstanceFormValues } from '@/react/azure/types';
|
||||
import * as notifications from '@/portainer/services/notifications';
|
||||
import { useUser } from '@/portainer/hooks/useUser';
|
||||
import { AccessControlForm } from '@/portainer/access-control/AccessControlForm';
|
||||
import { useEnvironmentId } from '@/portainer/hooks/useEnvironmentId';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Input, Select } from '@@/form-components/Input';
|
||||
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
|
||||
import { LoadingButton } from '@@/buttons/LoadingButton';
|
||||
import { InputListError } from '@@/form-components/InputList/InputList';
|
||||
|
||||
import { validationSchema } from './CreateContainerInstanceForm.validation';
|
||||
import { PortMapping, PortsMappingField } from './PortsMappingField';
|
||||
import { useLoadFormState } from './useLoadFormState';
|
||||
import { PortsMappingField } from './PortsMappingField';
|
||||
import { useFormState, useLoadFormState } from './useLoadFormState';
|
||||
import {
|
||||
getSubscriptionLocations,
|
||||
getSubscriptionResourceGroups,
|
||||
} from './utils';
|
||||
import { useCreateInstance } from './useCreateInstanceMutation';
|
||||
import { useCreateInstanceMutation } from './useCreateInstanceMutation';
|
||||
|
||||
export function CreateContainerInstanceForm() {
|
||||
const {
|
||||
params: { endpointId: environmentId },
|
||||
} = useCurrentStateAndParams();
|
||||
|
||||
if (!environmentId) {
|
||||
throw new Error('endpointId url param is required');
|
||||
}
|
||||
|
||||
const environmentId = useEnvironmentId();
|
||||
const { isAdmin } = useUser();
|
||||
|
||||
const { initialValues, isLoading, providers, subscriptions, resourceGroups } =
|
||||
useLoadFormState(environmentId, isAdmin);
|
||||
const { providers, subscriptions, resourceGroups, isLoading } =
|
||||
useLoadFormState(environmentId);
|
||||
|
||||
const { initialValues, subscriptionOptions } = useFormState(
|
||||
subscriptions,
|
||||
resourceGroups,
|
||||
providers
|
||||
);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { mutateAsync } = useCreateInstance(resourceGroups, environmentId);
|
||||
const { mutateAsync } = useCreateInstanceMutation(
|
||||
resourceGroups,
|
||||
environmentId
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
|
@ -71,7 +73,7 @@ export function CreateContainerInstanceForm() {
|
|||
name="subscription"
|
||||
as={Select}
|
||||
id="subscription-input"
|
||||
options={subscriptions}
|
||||
options={subscriptionOptions}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
|
@ -143,7 +145,7 @@ export function CreateContainerInstanceForm() {
|
|||
<PortsMappingField
|
||||
value={values.ports}
|
||||
onChange={(value) => setFieldValue('ports', value)}
|
||||
errors={errors.ports as InputListError<PortMapping>[]}
|
||||
errors={errors.ports}
|
||||
/>
|
||||
|
||||
<div className="form-group">
|
|
@ -1,11 +1,9 @@
|
|||
import { r2a } from '@/react-tools/react2angular';
|
||||
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
import { Widget, WidgetBody } from '@@/Widget';
|
||||
|
||||
import { CreateContainerInstanceForm } from './CreateContainerInstanceForm';
|
||||
|
||||
export function CreateContainerInstanceView() {
|
||||
export function CreateView() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
|
@ -28,8 +26,3 @@ export function CreateContainerInstanceView() {
|
|||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const CreateContainerInstanceViewAngular = r2a(
|
||||
CreateContainerInstanceView,
|
||||
[]
|
||||
);
|
|
@ -1,29 +1,36 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector';
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
import { InputGroup } from '@@/form-components/InputGroup';
|
||||
import { InputList } from '@@/form-components/InputList';
|
||||
import {
|
||||
InputListError,
|
||||
ItemProps,
|
||||
} from '@@/form-components/InputList/InputList';
|
||||
import { ItemProps } from '@@/form-components/InputList/InputList';
|
||||
|
||||
import styles from './PortsMappingField.module.css';
|
||||
|
||||
type Protocol = 'TCP' | 'UDP';
|
||||
|
||||
export interface PortMapping {
|
||||
host: string;
|
||||
container: string;
|
||||
host?: number;
|
||||
container?: number;
|
||||
protocol: Protocol;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: PortMapping[];
|
||||
onChange(value: PortMapping[]): void;
|
||||
errors?: InputListError<PortMapping>[] | string;
|
||||
onChange?(value: PortMapping[]): void;
|
||||
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 (
|
||||
<>
|
||||
<InputList<PortMapping>
|
||||
|
@ -31,9 +38,15 @@ export function PortsMappingField({ value, onChange, errors }: Props) {
|
|||
value={value}
|
||||
onChange={onChange}
|
||||
addLabel="map additional port"
|
||||
itemBuilder={() => ({ host: '', container: '', protocol: 'TCP' })}
|
||||
itemBuilder={() => ({
|
||||
host: 0,
|
||||
container: 0,
|
||||
protocol: 'TCP',
|
||||
})}
|
||||
item={Item}
|
||||
errors={errors}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
{typeof errors === 'string' && (
|
||||
<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 (
|
||||
<div className={styles.item}>
|
||||
<div className="flex items-center gap-2">
|
||||
|
@ -53,7 +72,12 @@ function Item({ onChange, item, error }: ItemProps<PortMapping>) {
|
|||
<InputGroup.Input
|
||||
placeholder="e.g. 80"
|
||||
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>
|
||||
|
||||
|
@ -66,7 +90,12 @@ function Item({ onChange, item, error }: ItemProps<PortMapping>) {
|
|||
<InputGroup.Input
|
||||
placeholder="e.g. 80"
|
||||
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>
|
||||
|
||||
|
@ -74,6 +103,8 @@ function Item({ onChange, item, error }: ItemProps<PortMapping>) {
|
|||
onChange={(value) => handleChange('protocol', value)}
|
||||
value={item.protocol}
|
||||
options={[{ value: 'TCP' }, { value: 'UDP' }]}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</div>
|
||||
{!!error && (
|
||||
|
@ -84,7 +115,7 @@ function Item({ onChange, item, error }: ItemProps<PortMapping>) {
|
|||
</div>
|
||||
);
|
||||
|
||||
function handleChange(name: string, value: string) {
|
||||
function handleChange(name: keyof PortMapping, value: string | number) {
|
||||
onChange({ ...item, [name]: value });
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { CreateView } from './CreateView';
|
|
@ -1,18 +1,19 @@
|
|||
import { useMutation, useQueryClient } from 'react-query';
|
||||
|
||||
import { createContainerGroup } from '@/azure/services/container-groups.service';
|
||||
import { createContainerGroup } from '@/react/azure/services/container-groups.service';
|
||||
import { queryKeys } from '@/react/azure/queries/query-keys';
|
||||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
import PortainerError from '@/portainer/error';
|
||||
import {
|
||||
ContainerGroup,
|
||||
ContainerInstanceFormValues,
|
||||
ResourceGroup,
|
||||
} from '@/azure/types';
|
||||
} from '@/react/azure/types';
|
||||
import { applyResourceControl } from '@/portainer/access-control/access-control.service';
|
||||
|
||||
import { getSubscriptionResourceGroups } from './utils';
|
||||
|
||||
export function useCreateInstance(
|
||||
export function useCreateInstanceMutation(
|
||||
resourceGroups: {
|
||||
[k: string]: ResourceGroup[];
|
||||
},
|
||||
|
@ -52,7 +53,9 @@ export function useCreateInstance(
|
|||
|
||||
const accessControlData = values.accessControl;
|
||||
await applyResourceControl(accessControlData, resourceControl);
|
||||
queryClient.invalidateQueries(['azure', 'container-instances']);
|
||||
return queryClient.invalidateQueries(
|
||||
queryKeys.subscriptions(environmentId)
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -1,5 +1,4 @@
|
|||
import { ProviderViewModel } from '@/azure/models/provider';
|
||||
import { ResourceGroup } from '@/azure/types';
|
||||
import { ProviderViewModel, ResourceGroup } from '@/react/azure/types';
|
||||
|
||||
export function getSubscriptionResourceGroups(
|
||||
subscriptionId?: string,
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { ItemView } from './ItemView';
|
|
@ -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 },
|
||||
}));
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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], []);
|
||||
}
|
|
@ -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',
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
));
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { ListView } from './ListView';
|
|
@ -0,0 +1,8 @@
|
|||
import {
|
||||
PaginationTableSettings,
|
||||
SortableTableSettings,
|
||||
} from '@@/datatables/types';
|
||||
|
||||
export interface TableSettings
|
||||
extends PaginationTableSettings,
|
||||
SortableTableSettings {}
|
|
@ -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,
|
||||
};
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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 };
|
||||
}
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
import { buildContainerGroupUrl } from '../queries/utils';
|
||||
import { ContainerGroup, ContainerInstanceFormValues } from '../types';
|
||||
|
||||
export async function createContainerGroup(
|
||||
|
@ -12,7 +13,12 @@ export async function createContainerGroup(
|
|||
const payload = transformToPayload(model);
|
||||
try {
|
||||
const { data } = await axios.put<ContainerGroup>(
|
||||
buildUrl(environmentId, subscriptionId, resourceGroupName, model.name),
|
||||
buildContainerGroupUrl(
|
||||
environmentId,
|
||||
subscriptionId,
|
||||
resourceGroupName,
|
||||
model.name
|
||||
),
|
||||
payload,
|
||||
{ params: { 'api-version': '2018-04-01' } }
|
||||
);
|
||||
|
@ -22,13 +28,17 @@ export async function createContainerGroup(
|
|||
}
|
||||
}
|
||||
|
||||
function buildUrl(
|
||||
export async function deleteContainerGroup(
|
||||
environmentId: EnvironmentId,
|
||||
subscriptionId: string,
|
||||
resourceGroupName: string,
|
||||
containerGroupName: string
|
||||
containerGroupId: 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) {
|
|
@ -1,7 +1,7 @@
|
|||
import { AccessControlFormData } from '@/portainer/access-control/types';
|
||||
import { PortainerMetadata } from '@/react/docker/types';
|
||||
|
||||
import { PortMapping } from './ContainerInstances/CreateContainerInstanceForm/PortsMappingField';
|
||||
import { PortMapping } from './container-instances/CreateView/PortsMappingField';
|
||||
|
||||
type OS = 'Linux' | 'Windows';
|
||||
|
||||
|
@ -33,7 +33,7 @@ interface Container {
|
|||
}
|
||||
|
||||
interface ContainerGroupProperties {
|
||||
containers: Container[];
|
||||
containers: (Container | undefined)[];
|
||||
instanceView: {
|
||||
events: unknown[];
|
||||
state: 'pending' | string;
|
||||
|
@ -42,18 +42,19 @@ interface ContainerGroupProperties {
|
|||
dnsNameLabelReusePolicy: string;
|
||||
ports: { port: number; protocol: 'TCP' | 'UDP' }[];
|
||||
type: 'Public' | 'Private';
|
||||
ip: string;
|
||||
};
|
||||
osType: OS;
|
||||
}
|
||||
|
||||
export interface ContainerGroup {
|
||||
export type ContainerGroup = {
|
||||
id: string;
|
||||
name: string;
|
||||
location: string;
|
||||
type: string;
|
||||
properties: ContainerGroupProperties;
|
||||
Portainer: PortainerMetadata;
|
||||
}
|
||||
Portainer?: PortainerMetadata;
|
||||
};
|
||||
|
||||
export interface Subscription {
|
||||
subscriptionId: string;
|
||||
|
@ -67,13 +68,8 @@ export interface ResourceGroup {
|
|||
subscriptionId: string;
|
||||
}
|
||||
|
||||
interface ResourceType {
|
||||
resourceType: 'containerGroups' | string;
|
||||
locations: string[];
|
||||
}
|
||||
|
||||
export interface ProviderResponse {
|
||||
export interface ProviderViewModel {
|
||||
id: string;
|
||||
namespace: string;
|
||||
resourceTypes: ResourceType[];
|
||||
locations: string[];
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
|
@ -10,16 +10,20 @@ export interface Crumb {
|
|||
linkParams?: Record<string, unknown>;
|
||||
}
|
||||
interface Props {
|
||||
breadcrumbs: (Crumb | string)[];
|
||||
breadcrumbs: (Crumb | string)[] | string;
|
||||
}
|
||||
|
||||
export function Breadcrumbs({ breadcrumbs }: Props) {
|
||||
const breadcrumbsArray = Array.isArray(breadcrumbs)
|
||||
? breadcrumbs
|
||||
: [breadcrumbs];
|
||||
|
||||
return (
|
||||
<div className="breadcrumb-links">
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
{breadcrumbsArray.map((crumb, index) => (
|
||||
<Fragment key={index}>
|
||||
{renderCrumb(crumb)}
|
||||
{index !== breadcrumbs.length - 1 ? ' > ' : ''}
|
||||
{index !== breadcrumbsArray.length - 1 ? ' > ' : ''}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
@ -14,7 +14,7 @@ interface Props {
|
|||
reload?: boolean;
|
||||
loading?: boolean;
|
||||
onReload?(): Promise<void> | void;
|
||||
breadcrumbs?: Crumb[];
|
||||
breadcrumbs?: (Crumb | string)[] | string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
|
|
|
@ -5,10 +5,11 @@ import styles from './AddButton.module.css';
|
|||
export interface Props {
|
||||
className?: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function AddButton({ label, onClick, className }: Props) {
|
||||
export function AddButton({ label, onClick, className, disabled }: Props) {
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
|
@ -20,6 +21,7 @@ export function AddButton({ label, onClick, className }: Props) {
|
|||
)}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<i className="fa fa-plus-circle space-right" aria-hidden="true" /> {label}
|
||||
</button>
|
||||
|
|
|
@ -1,13 +1,11 @@
|
|||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { Icon } from '@/react/components/Icon';
|
||||
import { Icon, IconProps } from '@@/Icon';
|
||||
|
||||
import { useTableContext } from './TableContainer';
|
||||
|
||||
interface Props {
|
||||
icon: string;
|
||||
interface Props extends IconProps {
|
||||
label: string;
|
||||
featherIcon?: boolean;
|
||||
}
|
||||
|
||||
export function TableTitle({
|
||||
|
|
|
@ -25,7 +25,7 @@ interface UseRowSelectTableInstance<D extends DefaultType = DefaultType>
|
|||
isAllRowSelected: boolean;
|
||||
selectSubRows: boolean;
|
||||
getSubRows(row: Row<D>): Row<D>[];
|
||||
isRowSelectable(row: Row<D>): boolean;
|
||||
isRowSelectable?(row: Row<D>): boolean;
|
||||
}
|
||||
|
||||
const pluginName = 'useRowSelect';
|
||||
|
@ -73,7 +73,10 @@ function defaultGetToggleRowSelectedProps<D extends DefaultType>(
|
|||
props: D,
|
||||
{ instance, row }: { instance: UseRowSelectTableInstance<D>; row: Row<D> }
|
||||
) {
|
||||
const { manualRowSelectedKey = 'isSelected' } = instance;
|
||||
const {
|
||||
manualRowSelectedKey = 'isSelected',
|
||||
isRowSelectable = defaultIsRowSelectable,
|
||||
} = instance;
|
||||
let checked = false;
|
||||
|
||||
if (row.original && row.original[manualRowSelectedKey]) {
|
||||
|
@ -94,7 +97,7 @@ function defaultGetToggleRowSelectedProps<D extends DefaultType>(
|
|||
checked,
|
||||
title: 'Toggle Row Selected',
|
||||
indeterminate: row.isSomeSelected,
|
||||
disabled: !instance.isRowSelectable(row),
|
||||
disabled: !isRowSelectable(row),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
@ -317,7 +320,7 @@ function useInstance<D extends Record<string, unknown>>(
|
|||
dispatch,
|
||||
page,
|
||||
getSubRows,
|
||||
isRowSelectable,
|
||||
isRowSelectable = defaultIsRowSelectable,
|
||||
} = instance;
|
||||
|
||||
ensurePluginOrder(
|
||||
|
@ -474,5 +477,5 @@ function getRowIsSelected<D extends Record<string, unknown>>(
|
|||
}
|
||||
|
||||
function defaultIsRowSelectable<D extends DefaultType>(row: Row<D>) {
|
||||
return !!row.original.disabled;
|
||||
return !row.original.disabled;
|
||||
}
|
||||
|
|
|
@ -15,6 +15,8 @@ interface Props<T> {
|
|||
onChange(value: T): void;
|
||||
options: Option<T>[];
|
||||
size?: Size;
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function ButtonSelector<T extends string | number>({
|
||||
|
@ -22,6 +24,8 @@ export function ButtonSelector<T extends string | number>({
|
|||
onChange,
|
||||
size,
|
||||
options,
|
||||
disabled,
|
||||
readOnly,
|
||||
}: Props<T>) {
|
||||
return (
|
||||
<ButtonGroup size={size} className={styles.group}>
|
||||
|
@ -30,6 +34,8 @@ export function ButtonSelector<T extends string | number>({
|
|||
key={option.value}
|
||||
selected={value === option.value}
|
||||
onChange={() => onChange(option.value)}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
>
|
||||
{option.label || option.value.toString()}
|
||||
</OptionItem>
|
||||
|
@ -41,17 +47,32 @@ export function ButtonSelector<T extends string | number>({
|
|||
interface OptionItemProps {
|
||||
selected: boolean;
|
||||
onChange(): void;
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
function OptionItem({
|
||||
selected,
|
||||
children,
|
||||
onChange,
|
||||
disabled,
|
||||
readOnly,
|
||||
}: PropsWithChildren<OptionItemProps>) {
|
||||
return (
|
||||
<label className={clsx('btn btn-primary', { active: selected })}>
|
||||
<label
|
||||
className={clsx('btn btn-primary', {
|
||||
active: selected,
|
||||
disabled: readOnly || disabled,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
<input type="radio" checked={selected} onChange={onChange} />
|
||||
<input
|
||||
type="radio"
|
||||
checked={selected}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { ComponentType } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { AddButton, Button } from '@@/buttons';
|
||||
import { Tooltip } from '@@/Tip/Tooltip';
|
||||
|
@ -11,12 +12,12 @@ import { FormError } from '../FormError';
|
|||
import styles from './InputList.module.css';
|
||||
import { arrayMove } from './utils';
|
||||
|
||||
export type InputListError<T> = Record<keyof T, string>;
|
||||
|
||||
export interface ItemProps<T> {
|
||||
item: T;
|
||||
onChange(value: T): void;
|
||||
error?: InputListError<T>;
|
||||
error?: string | FormikErrors<T>;
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
type Key = string | number;
|
||||
type ChangeType = 'delete' | 'create' | 'update';
|
||||
|
@ -36,7 +37,7 @@ type OnChangeEvent<T> =
|
|||
type RenderItemFunction<T> = (
|
||||
item: T,
|
||||
onChange: (value: T) => void,
|
||||
error?: InputListError<T>
|
||||
error?: string | FormikErrors<T>
|
||||
) => React.ReactNode;
|
||||
|
||||
interface Props<T> {
|
||||
|
@ -50,9 +51,11 @@ interface Props<T> {
|
|||
addLabel?: string;
|
||||
itemKeyGetter?(item: T, index: number): Key;
|
||||
movable?: boolean;
|
||||
errors?: InputListError<T>[] | string;
|
||||
errors?: FormikErrors<T>[] | string | string[];
|
||||
textTip?: string;
|
||||
isAddButtonHidden?: boolean;
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function InputList<T = DefaultType>({
|
||||
|
@ -69,6 +72,8 @@ export function InputList<T = DefaultType>({
|
|||
errors,
|
||||
textTip,
|
||||
isAddButtonHidden = false,
|
||||
disabled,
|
||||
readOnly,
|
||||
}: Props<T>) {
|
||||
return (
|
||||
<div className={clsx('form-group', styles.root)}>
|
||||
|
@ -77,11 +82,12 @@ export function InputList<T = DefaultType>({
|
|||
{label}
|
||||
{tooltip && <Tooltip message={tooltip} />}
|
||||
</div>
|
||||
{!isAddButtonHidden && (
|
||||
{!(isAddButtonHidden || readOnly) && (
|
||||
<AddButton
|
||||
label={addLabel}
|
||||
className="space-left"
|
||||
onClick={handleAdd}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
@ -107,6 +113,8 @@ export function InputList<T = DefaultType>({
|
|||
item={item}
|
||||
onChange={(value: T) => handleChangeItem(key, value)}
|
||||
error={error}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
) : (
|
||||
renderItem(
|
||||
|
@ -116,11 +124,11 @@ export function InputList<T = DefaultType>({
|
|||
)
|
||||
)}
|
||||
<div className={clsx(styles.itemActions, 'items-start')}>
|
||||
{movable && (
|
||||
{!readOnly && movable && (
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={index === 0}
|
||||
disabled={disabled || index === 0}
|
||||
onClick={() => handleMoveUp(index)}
|
||||
>
|
||||
<i className="fa fa-arrow-up" aria-hidden="true" />
|
||||
|
@ -128,20 +136,23 @@ export function InputList<T = DefaultType>({
|
|||
<Button
|
||||
size="small"
|
||||
type="button"
|
||||
disabled={index === value.length - 1}
|
||||
disabled={disabled || index === value.length - 1}
|
||||
onClick={() => handleMoveDown(index)}
|
||||
>
|
||||
<i className="fa fa-arrow-down" aria-hidden="true" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
color="danger"
|
||||
size="small"
|
||||
onClick={() => handleRemoveItem(key, item)}
|
||||
>
|
||||
<i className="fa fa-trash" aria-hidden="true" />
|
||||
</Button>
|
||||
{!readOnly && (
|
||||
<Button
|
||||
color="danger"
|
||||
size="small"
|
||||
onClick={() => handleRemoveItem(key, item)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<i className="fa fa-trash" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -210,13 +221,21 @@ function defaultItemBuilder(): DefaultType {
|
|||
return { value: '' };
|
||||
}
|
||||
|
||||
function DefaultItem({ item, onChange, error }: ItemProps<DefaultType>) {
|
||||
function DefaultItem({
|
||||
item,
|
||||
onChange,
|
||||
error,
|
||||
disabled,
|
||||
readOnly,
|
||||
}: ItemProps<DefaultType>) {
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
value={item.value}
|
||||
onChange={(e) => onChange({ value: e.target.value })}
|
||||
className={styles.defaultItem}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
{error && <FormError>{error}</FormError>}
|
||||
</>
|
||||
|
@ -226,7 +245,7 @@ function DefaultItem({ item, onChange, error }: ItemProps<DefaultType>) {
|
|||
function renderDefaultItem(
|
||||
item: DefaultType,
|
||||
onChange: (value: DefaultType) => void,
|
||||
error?: InputListError<DefaultType>
|
||||
error?: FormikErrors<DefaultType>
|
||||
) {
|
||||
return <DefaultItem item={item} onChange={onChange} error={error} />;
|
||||
}
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
import { Input } from '@@/form-components/Input';
|
||||
import { InputList } from '@@/form-components/InputList';
|
||||
import {
|
||||
InputListError,
|
||||
ItemProps,
|
||||
} from '@@/form-components/InputList/InputList';
|
||||
import { ItemProps } from '@@/form-components/InputList/InputList';
|
||||
|
||||
export interface VariableDefinition {
|
||||
name: string;
|
||||
|
@ -16,7 +15,7 @@ export interface VariableDefinition {
|
|||
interface Props {
|
||||
value: VariableDefinition[];
|
||||
onChange: (value: VariableDefinition[]) => void;
|
||||
errors?: InputListError<VariableDefinition>[] | string;
|
||||
errors?: FormikErrors<VariableDefinition>[];
|
||||
isVariablesNamesFromParent?: boolean;
|
||||
}
|
||||
|
||||
|
@ -57,6 +56,8 @@ interface DefinitionItemProps extends ItemProps<VariableDefinition> {
|
|||
}
|
||||
|
||||
function Item({ item, onChange, error, isNameReadonly }: DefinitionItemProps) {
|
||||
const errorObj = typeof error === 'object' ? error : {};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<div>
|
||||
|
@ -67,7 +68,7 @@ function Item({ item, onChange, error, isNameReadonly }: DefinitionItemProps) {
|
|||
placeholder="Name (e.g var_name)"
|
||||
readOnly={isNameReadonly}
|
||||
/>
|
||||
{error?.name && <FormError>{error.name}</FormError>}
|
||||
{errorObj?.name && <FormError>{errorObj.name}</FormError>}
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
|
@ -76,7 +77,7 @@ function Item({ item, onChange, error, isNameReadonly }: DefinitionItemProps) {
|
|||
placeholder="Label"
|
||||
name="label"
|
||||
/>
|
||||
{error?.label && <FormError>{error.label}</FormError>}
|
||||
{errorObj?.label && <FormError>{errorObj.label}</FormError>}
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
|
@ -85,7 +86,7 @@ function Item({ item, onChange, error, isNameReadonly }: DefinitionItemProps) {
|
|||
onChange={handleChange}
|
||||
placeholder="Description"
|
||||
/>
|
||||
{error?.description && <FormError>{error.description}</FormError>}
|
||||
{errorObj?.description && <FormError>{errorObj.description}</FormError>}
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
|
@ -94,7 +95,9 @@ function Item({ item, onChange, error, isNameReadonly }: DefinitionItemProps) {
|
|||
placeholder="Default Value"
|
||||
name="defaultValue"
|
||||
/>
|
||||
{error?.defaultValue && <FormError>{error.defaultValue}</FormError>}
|
||||
{errorObj?.defaultValue && (
|
||||
<FormError>{errorObj.defaultValue}</FormError>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -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
|
||||
moduleNameMapper: {
|
||||
'\\.(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',
|
||||
'\\.(css|less)$': '<rootDir>/app/__mocks__/styleMock.js',
|
||||
'^@@/(.*)$': '<rootDir>/app/react/components/$1',
|
||||
'^@/(.*)$': '<rootDir>/app/$1',
|
||||
'^Agent/(.*)?': '<rootDir>/app/agent/$1',
|
||||
|
|
Loading…
Reference in New Issue