mirror of
https://github.com/portainer/portainer.git
synced 2025-08-07 14:55:27 +02:00
refactor(portainer): move to react [EE-3350] (#7915)
This commit is contained in:
parent
30e23ea5b4
commit
78dcba614d
192 changed files with 200 additions and 211 deletions
|
@ -1,9 +1,11 @@
|
|||
import _ from 'lodash-es';
|
||||
|
||||
import featureFlagModule from '@/react/portainer/feature-flags';
|
||||
|
||||
import './rbac';
|
||||
|
||||
import componentsModule from './components';
|
||||
import settingsModule from './settings';
|
||||
import featureFlagModule from './feature-flags';
|
||||
import userActivityModule from './user-activity';
|
||||
import servicesModule from './services';
|
||||
import { reactModule } from './react';
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { FeatureId } from '@/portainer/feature-flags/enums';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
|
||||
import { getFeatureDetails } from '@@/BEFeatureIndicator/utils';
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { FeatureId } from '@/portainer/feature-flags/enums';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
|
||||
import { BoxSelectorOption } from '@@/BoxSelector/types';
|
||||
import { IconProps } from '@@/Icon';
|
||||
|
|
|
@ -2,7 +2,7 @@ import _ from 'lodash-es';
|
|||
import angular from 'angular';
|
||||
|
||||
import { RoleTypes } from '@/portainer/rbac/models/role';
|
||||
import { isLimitedToBE } from '@/portainer/feature-flags/feature-flags.service';
|
||||
import { isLimitedToBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
|
||||
class PorAccessManagementController {
|
||||
/* @ngInject */
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { FeatureId } from '@/portainer/feature-flags/enums';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
import { PortainerEndpointTypes } from 'Portainer/models/endpoint/models';
|
||||
|
||||
angular.module('portainer.docker').controller('RegistriesDatatableController', RegistriesDatatableController);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { FeatureId } from '@/portainer/feature-flags/enums';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
|
||||
class GitFormAutoUpdateFieldsetController {
|
||||
/* @ngInject */
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import uuidv4 from 'uuid/v4';
|
||||
import { RepositoryMechanismTypes } from 'Kubernetes/models/deploy';
|
||||
import { FeatureId } from 'Portainer/feature-flags/enums';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
class StackRedeployGitFormController {
|
||||
/* @ngInject */
|
||||
constructor($async, $state, $compile, $scope, StackService, ModalService, Notifications, WebhookHelper, FormHelper) {
|
||||
|
|
|
@ -1,35 +0,0 @@
|
|||
import axios, { parseAxiosError } from '../services/axios';
|
||||
|
||||
import { EnvironmentGroup, EnvironmentGroupId } from './types';
|
||||
|
||||
export async function getGroup(id: EnvironmentGroupId) {
|
||||
try {
|
||||
const { data: group } = await axios.get<EnvironmentGroup>(buildUrl(id));
|
||||
return group;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, '');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGroups() {
|
||||
try {
|
||||
const { data: groups } = await axios.get<EnvironmentGroup[]>(buildUrl());
|
||||
return groups;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, '');
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(id?: EnvironmentGroupId, action?: string) {
|
||||
let url = '/endpoint_groups';
|
||||
|
||||
if (id) {
|
||||
url += `/${id}`;
|
||||
}
|
||||
|
||||
if (action) {
|
||||
url += `/${action}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
|
@ -1,29 +0,0 @@
|
|||
import { useQuery } from 'react-query';
|
||||
|
||||
import { error as notifyError } from '@/portainer/services/notifications';
|
||||
|
||||
import { EnvironmentGroup, EnvironmentGroupId } from './types';
|
||||
import { getGroup, getGroups } from './environment-groups.service';
|
||||
|
||||
export function useGroups() {
|
||||
return useQuery<EnvironmentGroup[]>(['environment-groups'], getGroups);
|
||||
}
|
||||
|
||||
export function useGroup<T = EnvironmentGroup>(
|
||||
groupId: EnvironmentGroupId,
|
||||
select?: (group: EnvironmentGroup) => T
|
||||
) {
|
||||
const { data } = useQuery(
|
||||
['environment-groups', groupId],
|
||||
() => getGroup(groupId),
|
||||
{
|
||||
staleTime: 50,
|
||||
select,
|
||||
onError(error) {
|
||||
notifyError('Failed loading group', error as Error);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
import { TagId } from '@/portainer/tags/types';
|
||||
|
||||
export type EnvironmentGroupId = number;
|
||||
|
||||
export interface EnvironmentGroup {
|
||||
// Environment(Endpoint) group Identifier
|
||||
Id: EnvironmentGroupId;
|
||||
// Environment(Endpoint) group name
|
||||
Name: string;
|
||||
// Description associated to the environment(endpoint) group
|
||||
Description: string;
|
||||
// List of tags associated to this environment(endpoint) group
|
||||
TagIds: TagId[];
|
||||
}
|
|
@ -1,35 +0,0 @@
|
|||
export enum Edition {
|
||||
CE,
|
||||
BE,
|
||||
}
|
||||
|
||||
export enum FeatureState {
|
||||
HIDDEN,
|
||||
VISIBLE,
|
||||
LIMITED_BE,
|
||||
}
|
||||
|
||||
export enum FeatureId {
|
||||
K8S_RESOURCE_POOL_LB_QUOTA = 'k8s-resourcepool-Ibquota',
|
||||
K8S_RESOURCE_POOL_STORAGE_QUOTA = 'k8s-resourcepool-storagequota',
|
||||
K8S_CREATE_FROM_KUBECONFIG = 'k8s-create-from-kubeconfig',
|
||||
KAAS_PROVISIONING = 'kaas-provisioning',
|
||||
NOMAD = 'nomad',
|
||||
RBAC_ROLES = 'rbac-roles',
|
||||
REGISTRY_MANAGEMENT = 'registry-management',
|
||||
K8S_SETUP_DEFAULT = 'k8s-setup-default',
|
||||
S3_BACKUP_SETTING = 's3-backup-setting',
|
||||
HIDE_INTERNAL_AUTHENTICATION_PROMPT = 'hide-internal-authentication-prompt',
|
||||
TEAM_MEMBERSHIP = 'team-membership',
|
||||
HIDE_INTERNAL_AUTH = 'hide-internal-auth',
|
||||
EXTERNAL_AUTH_LDAP = 'external-auth-ldap',
|
||||
ACTIVITY_AUDIT = 'activity-audit',
|
||||
FORCE_REDEPLOYMENT = 'force-redeployment',
|
||||
HIDE_AUTO_UPDATE_WINDOW = 'hide-auto-update-window',
|
||||
STACK_PULL_IMAGE = 'stack-pull-image',
|
||||
STACK_WEBHOOK = 'stack-webhook',
|
||||
CONTAINER_WEBHOOK = 'container-webhook',
|
||||
POD_SECURITY_POLICY_CONSTRAINT = 'pod-security-policy-constraint',
|
||||
HIDE_DOCKER_HUB_ANONYMOUS = 'hide-docker-hub-anonymous',
|
||||
CUSTOM_LOGIN_BANNER = 'custom-login-banner',
|
||||
}
|
|
@ -1,78 +0,0 @@
|
|||
.form-control.limited-be {
|
||||
border-color: var(--BE-only);
|
||||
}
|
||||
|
||||
.form-control.limited-be.no-border {
|
||||
border-color: var(--border-form-control-color);
|
||||
}
|
||||
|
||||
button.limited-be,
|
||||
button[disabled].limited-be.oauth-save-settings-button {
|
||||
background-color: var(--BE-only);
|
||||
border-color: var(--BE-only);
|
||||
}
|
||||
|
||||
button.limited-be.oauth-save-settings-button {
|
||||
background-color: var(--blue-2);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
ng-form.limited-be,
|
||||
form.limited-be,
|
||||
div.limited-be {
|
||||
border: solid 2px var(--BE-only);
|
||||
border-radius: 8px;
|
||||
pointer-events: none;
|
||||
touch-action: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.limited-be-content {
|
||||
background: rgba(247, 144, 9, 0.1);
|
||||
opacity: 0.5;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.limited-be-link {
|
||||
z-index: 5;
|
||||
position: relative;
|
||||
width: 270px;
|
||||
height: 40px;
|
||||
top: 0px;
|
||||
right: 0px;
|
||||
float: right;
|
||||
border-top-right-radius: 8px;
|
||||
border-bottom-left-radius: 8px;
|
||||
background-color: var(--ui-warning-5);
|
||||
color: var(--ui-warning-9);
|
||||
padding: 5px 10px;
|
||||
touch-action: auto;
|
||||
cursor: hand;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.limited-be-link a {
|
||||
cursor: hand;
|
||||
pointer-events: auto;
|
||||
color: var(--ui-warning-9);
|
||||
}
|
||||
|
||||
.limited-be-link a:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--ui-warning-9);
|
||||
}
|
||||
|
||||
.overlay {
|
||||
background-image: url(../../assets/ico/lock.svg);
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.limited-be input,
|
||||
.limited-be .widget-body {
|
||||
background: rgba(247, 144, 9, 0.05);
|
||||
}
|
||||
|
||||
.form-control.limited-be[disabled] {
|
||||
background-color: transparent !important;
|
||||
}
|
|
@ -1,68 +0,0 @@
|
|||
import { Edition, FeatureId, FeatureState } from './enums';
|
||||
|
||||
export const isBE = process.env.PORTAINER_EDITION === 'BE';
|
||||
interface ServiceState {
|
||||
currentEdition: Edition;
|
||||
features: Record<FeatureId, Edition>;
|
||||
}
|
||||
|
||||
const state: ServiceState = {
|
||||
currentEdition: Edition.CE,
|
||||
features: <Record<FeatureId, Edition>>{},
|
||||
};
|
||||
|
||||
export async function init(edition: Edition) {
|
||||
// will be loaded on runtime
|
||||
const currentEdition = edition;
|
||||
const features = {
|
||||
[FeatureId.K8S_RESOURCE_POOL_LB_QUOTA]: Edition.BE,
|
||||
[FeatureId.K8S_RESOURCE_POOL_STORAGE_QUOTA]: Edition.BE,
|
||||
[FeatureId.K8S_CREATE_FROM_KUBECONFIG]: Edition.BE,
|
||||
[FeatureId.KAAS_PROVISIONING]: Edition.BE,
|
||||
[FeatureId.NOMAD]: Edition.BE,
|
||||
[FeatureId.ACTIVITY_AUDIT]: Edition.BE,
|
||||
[FeatureId.EXTERNAL_AUTH_LDAP]: Edition.BE,
|
||||
[FeatureId.HIDE_INTERNAL_AUTH]: Edition.BE,
|
||||
[FeatureId.HIDE_INTERNAL_AUTHENTICATION_PROMPT]: Edition.BE,
|
||||
[FeatureId.K8S_SETUP_DEFAULT]: Edition.BE,
|
||||
[FeatureId.RBAC_ROLES]: Edition.BE,
|
||||
[FeatureId.REGISTRY_MANAGEMENT]: Edition.BE,
|
||||
[FeatureId.S3_BACKUP_SETTING]: Edition.BE,
|
||||
[FeatureId.TEAM_MEMBERSHIP]: Edition.BE,
|
||||
[FeatureId.FORCE_REDEPLOYMENT]: Edition.BE,
|
||||
[FeatureId.HIDE_AUTO_UPDATE_WINDOW]: Edition.BE,
|
||||
[FeatureId.STACK_PULL_IMAGE]: Edition.BE,
|
||||
[FeatureId.STACK_WEBHOOK]: Edition.BE,
|
||||
[FeatureId.CONTAINER_WEBHOOK]: Edition.BE,
|
||||
[FeatureId.POD_SECURITY_POLICY_CONSTRAINT]: Edition.BE,
|
||||
[FeatureId.HIDE_DOCKER_HUB_ANONYMOUS]: Edition.BE,
|
||||
[FeatureId.CUSTOM_LOGIN_BANNER]: Edition.BE,
|
||||
};
|
||||
|
||||
state.currentEdition = currentEdition;
|
||||
state.features = features;
|
||||
}
|
||||
|
||||
export function selectShow(featureId?: FeatureId) {
|
||||
if (!featureId) {
|
||||
return FeatureState.VISIBLE;
|
||||
}
|
||||
|
||||
if (!state.features[featureId]) {
|
||||
return FeatureState.HIDDEN;
|
||||
}
|
||||
|
||||
if (state.features[featureId] <= state.currentEdition) {
|
||||
return FeatureState.VISIBLE;
|
||||
}
|
||||
|
||||
if (state.features[featureId] === Edition.BE) {
|
||||
return FeatureState.LIMITED_BE;
|
||||
}
|
||||
|
||||
return FeatureState.HIDDEN;
|
||||
}
|
||||
|
||||
export function isLimitedToBE(featureId?: FeatureId) {
|
||||
return selectShow(featureId) === FeatureState.LIMITED_BE;
|
||||
}
|
|
@ -1,16 +0,0 @@
|
|||
export const K8S_RESOURCE_POOL_LB_QUOTA = 'k8s-resourcepool-Ibquota';
|
||||
export const K8S_RESOURCE_POOL_STORAGE_QUOTA = 'k8s-resourcepool-storagequota';
|
||||
export const RBAC_ROLES = 'rbac-roles';
|
||||
export const REGISTRY_MANAGEMENT = 'registry-management';
|
||||
export const K8S_SETUP_DEFAULT = 'k8s-setup-default';
|
||||
export const S3_BACKUP_SETTING = 's3-backup-setting';
|
||||
export const HIDE_INTERNAL_AUTHENTICATION_PROMPT = 'hide-internal-authentication-prompt';
|
||||
export const TEAM_MEMBERSHIP = 'team-membership';
|
||||
export const HIDE_INTERNAL_AUTH = 'hide-internal-auth';
|
||||
export const EXTERNAL_AUTH_LDAP = 'external-auth-ldap';
|
||||
export const ACTIVITY_AUDIT = 'activity-audit';
|
||||
export const HIDE_AUTO_UPDATE_WINDOW = 'hide-auto-update-window';
|
||||
export const FORCE_REDEPLOYMENT = 'force-redeployment';
|
||||
export const STACK_PULL_IMAGE = 'stack-pull-image';
|
||||
export const STACK_WEBHOOK = 'stack-webhook';
|
||||
export const CONTAINER_WEBHOOK = 'container-webhook';
|
|
@ -1,8 +0,0 @@
|
|||
import angular from 'angular';
|
||||
|
||||
import { limitedFeatureDirective } from './limited-feature.directive';
|
||||
import './feature-flags.css';
|
||||
|
||||
export default angular
|
||||
.module('portainer.feature-flags', [])
|
||||
.directive('limitedFeatureDir', limitedFeatureDirective).name;
|
|
@ -1,44 +0,0 @@
|
|||
import _ from 'lodash';
|
||||
import { IAttributes, IDirective, IScope } from 'angular';
|
||||
|
||||
import { FeatureState } from '@/portainer/feature-flags/enums';
|
||||
|
||||
import { selectShow } from './feature-flags.service';
|
||||
|
||||
const BASENAME = 'limitedFeature';
|
||||
|
||||
/* @ngInject */
|
||||
export function limitedFeatureDirective(): IDirective {
|
||||
return {
|
||||
restrict: 'A',
|
||||
link,
|
||||
};
|
||||
|
||||
function link(scope: IScope, elem: JQLite, attrs: IAttributes) {
|
||||
const { limitedFeatureDir: featureId } = attrs;
|
||||
|
||||
if (!featureId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const limitedFeatureAttrs = Object.keys(attrs)
|
||||
.filter((attr) => attr.startsWith(BASENAME) && attr !== `${BASENAME}Dir`)
|
||||
.map((attr) => [_.kebabCase(attr.replace(BASENAME, '')), attrs[attr]]);
|
||||
|
||||
const state = selectShow(featureId);
|
||||
|
||||
if (state === FeatureState.HIDDEN) {
|
||||
elem.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === FeatureState.VISIBLE) {
|
||||
return;
|
||||
}
|
||||
|
||||
limitedFeatureAttrs.forEach(([attr, value = attr]) => {
|
||||
const currentValue = elem.attr(attr) || '';
|
||||
elem.attr(attr, `${currentValue} ${value}`.trim());
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,32 +0,0 @@
|
|||
import { useRouter } from '@uirouter/react';
|
||||
|
||||
import { usePublicSettings } from '@/react/portainer/settings/queries';
|
||||
|
||||
export enum FeatureFlag {
|
||||
EdgeRemoteUpdate = 'edgeRemoteUpdate',
|
||||
}
|
||||
|
||||
export function useFeatureFlag(
|
||||
flag: FeatureFlag,
|
||||
{ onSuccess }: { onSuccess?: (isEnabled: boolean) => void } = {}
|
||||
) {
|
||||
return usePublicSettings<boolean>({
|
||||
select: (settings) => settings.Features[flag],
|
||||
onSuccess,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRedirectFeatureFlag(
|
||||
flag: FeatureFlag,
|
||||
to = 'portainer.home'
|
||||
) {
|
||||
const router = useRouter();
|
||||
|
||||
useFeatureFlag(flag, {
|
||||
onSuccess(isEnabled) {
|
||||
if (!isEnabled) {
|
||||
router.stateService.go(to);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
|
@ -1,24 +0,0 @@
|
|||
import { mixed } from 'yup';
|
||||
import { MixedSchema } from 'yup/lib/mixed';
|
||||
|
||||
type FileSchema = MixedSchema<File | undefined>;
|
||||
|
||||
export function file(): FileSchema {
|
||||
return mixed();
|
||||
}
|
||||
|
||||
export function withFileSize(fileValidation: FileSchema, maxSize: number) {
|
||||
return fileValidation.test(
|
||||
'fileSize',
|
||||
'Selected file is too big.',
|
||||
validateFileSize
|
||||
);
|
||||
|
||||
function validateFileSize(file?: File) {
|
||||
if (!file) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return file.size <= maxSize;
|
||||
}
|
||||
}
|
|
@ -1,38 +0,0 @@
|
|||
import { server, rest } from '@/setup-tests/server';
|
||||
import { renderWithQueryClient } from '@/react-tools/test-utils';
|
||||
import { isoDate } from '@/portainer/filters/filters';
|
||||
|
||||
import { BackupFailedPanel } from './BackupFailedPanel';
|
||||
|
||||
test('when backup failed, should show message', async () => {
|
||||
const timestamp = 1500;
|
||||
server.use(
|
||||
rest.get('/api/backup/s3/status', (req, res, ctx) =>
|
||||
res(ctx.json({ Failed: true, TimestampUTC: timestamp }))
|
||||
)
|
||||
);
|
||||
|
||||
const { findByText } = renderWithQueryClient(<BackupFailedPanel />);
|
||||
|
||||
await expect(
|
||||
findByText(
|
||||
`The latest automated backup has failed at ${isoDate(
|
||||
timestamp
|
||||
)}. For details please see the log files and have a look at the`,
|
||||
{ exact: false }
|
||||
)
|
||||
).resolves.toBeVisible();
|
||||
});
|
||||
|
||||
test("when user is using less nodes then allowed he shouldn't see message", async () => {
|
||||
server.use(
|
||||
rest.get('/api/backup/s3/status', (req, res, ctx) =>
|
||||
res(ctx.json({ Failed: false }))
|
||||
)
|
||||
);
|
||||
const { findByText } = renderWithQueryClient(<BackupFailedPanel />);
|
||||
|
||||
await expect(
|
||||
findByText('The latest automated backup has failed at', { exact: false })
|
||||
).rejects.toBeTruthy();
|
||||
});
|
|
@ -1,43 +0,0 @@
|
|||
import { useQuery } from 'react-query';
|
||||
|
||||
import { error as notifyError } from '@/portainer/services/notifications';
|
||||
|
||||
import { InformationPanel } from '@@/InformationPanel';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
import { getBackupStatus } from '../services/api/backup.service';
|
||||
import { isoDate } from '../filters/filters';
|
||||
|
||||
export function BackupFailedPanel() {
|
||||
const { status, isLoading } = useBackupStatus();
|
||||
|
||||
if (isLoading || !status || !status.Failed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<InformationPanel title="Information">
|
||||
<TextTip>
|
||||
The latest automated backup has failed at {isoDate(status.TimestampUTC)}
|
||||
. For details please see the log files and have a look at the{' '}
|
||||
<Link to="portainer.settings">settings</Link> to verify the backup
|
||||
configuration.
|
||||
</TextTip>
|
||||
</InformationPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function useBackupStatus() {
|
||||
const { data, isLoading } = useQuery(
|
||||
['backup', 'status'],
|
||||
() => getBackupStatus(),
|
||||
{
|
||||
onError(error) {
|
||||
notifyError('Failure', error as Error, 'Failed to get license info');
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return { status: data, isLoading };
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
.root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
|
@ -1,12 +0,0 @@
|
|||
import clsx from 'clsx';
|
||||
|
||||
import styles from './EdgeLoadingSpinner.module.css';
|
||||
|
||||
export function EdgeLoadingSpinner() {
|
||||
return (
|
||||
<div className={clsx('row', styles.root)}>
|
||||
Connecting to the Edge environment...
|
||||
<i className="fa fa-cog fa-spin space-left" />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,29 +0,0 @@
|
|||
import { Zap } from 'react-feather';
|
||||
|
||||
import { EnvironmentType } from '@/react/portainer/environments/types';
|
||||
import {
|
||||
isAgentEnvironment,
|
||||
isEdgeEnvironment,
|
||||
} from '@/react/portainer/environments/utils';
|
||||
|
||||
interface Props {
|
||||
type: EnvironmentType;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export function AgentVersionTag({ type, version }: Props) {
|
||||
if (!isAgentEnvironment(type)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="space-x-1">
|
||||
<span>
|
||||
+ <Zap className="icon icon-xs vertical-center" aria-hidden="true" />
|
||||
</span>
|
||||
<span>{isEdgeEnvironment(type) ? 'Edge Agent' : 'Agent'}</span>
|
||||
|
||||
<span>{version}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
|
@ -1,44 +0,0 @@
|
|||
import { createMockEnvironment } from '@/react-tools/test-mocks';
|
||||
import { renderWithQueryClient } from '@/react-tools/test-utils';
|
||||
import { rest, server } from '@/setup-tests/server';
|
||||
|
||||
import { EdgeIndicator } from './EdgeIndicator';
|
||||
|
||||
test('when edge id is not set, should show unassociated label', async () => {
|
||||
const { queryByLabelText } = await renderComponent();
|
||||
|
||||
const unassociatedLabel = queryByLabelText('unassociated');
|
||||
|
||||
expect(unassociatedLabel).toBeVisible();
|
||||
});
|
||||
|
||||
// test('given edge id and last checkin is set, should show heartbeat', async () => {
|
||||
// const { queryByLabelText } = await renderComponent('id', 1);
|
||||
|
||||
// expect(queryByLabelText('edge-heartbeat')).toBeVisible();
|
||||
// expect(queryByLabelText('edge-last-checkin')).toBeVisible();
|
||||
// });
|
||||
|
||||
async function renderComponent(
|
||||
edgeId = '',
|
||||
lastCheckInDate = 0,
|
||||
checkInInterval = 0,
|
||||
queryDate = 0
|
||||
) {
|
||||
server.use(rest.get('/api/settings', (req, res, ctx) => res(ctx.json({}))));
|
||||
|
||||
const environment = createMockEnvironment();
|
||||
|
||||
environment.EdgeID = edgeId;
|
||||
environment.LastCheckInDate = lastCheckInDate;
|
||||
environment.EdgeCheckinInterval = checkInInterval;
|
||||
environment.QueryDate = queryDate;
|
||||
|
||||
const queries = renderWithQueryClient(
|
||||
<EdgeIndicator environment={environment} showLastCheckInDate />
|
||||
);
|
||||
|
||||
await expect(queries.findByRole('status')).resolves.toBeVisible();
|
||||
|
||||
return queries;
|
||||
}
|
|
@ -1,114 +0,0 @@
|
|||
import clsx from 'clsx';
|
||||
|
||||
import { isoDateFromTimestamp } from '@/portainer/filters/filters';
|
||||
import { Environment } from '@/react/portainer/environments/types';
|
||||
import { usePublicSettings } from '@/react/portainer/settings/queries';
|
||||
import { PublicSettingsViewModel } from '@/portainer/models/settings';
|
||||
|
||||
interface Props {
|
||||
showLastCheckInDate?: boolean;
|
||||
environment: Environment;
|
||||
}
|
||||
|
||||
export function EdgeIndicator({
|
||||
environment,
|
||||
|
||||
showLastCheckInDate = false,
|
||||
}: Props) {
|
||||
const associated = !!environment.EdgeID;
|
||||
|
||||
const isValid = useHasHeartbeat(environment, associated);
|
||||
|
||||
if (isValid === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!associated) {
|
||||
return (
|
||||
<span role="status" aria-label="edge-status">
|
||||
<span className="label label-default" aria-label="unassociated">
|
||||
<s>associated</s>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span role="status" aria-label="edge-status">
|
||||
<span
|
||||
className={clsx('label', {
|
||||
'label-danger': !isValid,
|
||||
'label-success': isValid,
|
||||
})}
|
||||
aria-label="edge-heartbeat"
|
||||
>
|
||||
heartbeat
|
||||
</span>
|
||||
|
||||
{showLastCheckInDate && !!environment.LastCheckInDate && (
|
||||
<span
|
||||
className="space-left small text-muted"
|
||||
aria-label="edge-last-checkin"
|
||||
>
|
||||
{isoDateFromTimestamp(environment.LastCheckInDate)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function useHasHeartbeat(environment: Environment, associated: boolean) {
|
||||
const settingsQuery = usePublicSettings({ enabled: associated });
|
||||
|
||||
if (!associated) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { LastCheckInDate, QueryDate } = environment;
|
||||
|
||||
const settings = settingsQuery.data;
|
||||
|
||||
if (!settings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const checkInInterval = getCheckinInterval(environment, settings);
|
||||
|
||||
if (checkInInterval && QueryDate && LastCheckInDate) {
|
||||
return QueryDate - LastCheckInDate <= checkInInterval * 2 + 20;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function getCheckinInterval(
|
||||
environment: Environment,
|
||||
settings: PublicSettingsViewModel
|
||||
) {
|
||||
const asyncMode = environment.Edge.AsyncMode;
|
||||
|
||||
if (asyncMode) {
|
||||
const intervals = [
|
||||
environment.Edge.PingInterval > 0
|
||||
? environment.Edge.PingInterval
|
||||
: settings.Edge.PingInterval,
|
||||
environment.Edge.SnapshotInterval > 0
|
||||
? environment.Edge.SnapshotInterval
|
||||
: settings.Edge.SnapshotInterval,
|
||||
environment.Edge.CommandInterval > 0
|
||||
? environment.Edge.CommandInterval
|
||||
: settings.Edge.CommandInterval,
|
||||
].filter((n) => n > 0);
|
||||
|
||||
return intervals.length > 0 ? Math.min(...intervals) : 60;
|
||||
}
|
||||
|
||||
if (
|
||||
!environment.EdgeCheckinInterval ||
|
||||
environment.EdgeCheckinInterval === 0
|
||||
) {
|
||||
return settings.Edge.CheckinInterval;
|
||||
}
|
||||
|
||||
return environment.EdgeCheckinInterval;
|
||||
}
|
|
@ -1,45 +0,0 @@
|
|||
import clsx from 'clsx';
|
||||
|
||||
import { environmentTypeIcon } from '@/portainer/filters/filters';
|
||||
import dockerEdge from '@/assets/images/edge_endpoint.png';
|
||||
import kube from '@/assets/images/kubernetes_endpoint.png';
|
||||
import kubeEdge from '@/assets/images/kubernetes_edge_endpoint.png';
|
||||
import { EnvironmentType } from '@/react/portainer/environments/types';
|
||||
import azure from '@/assets/ico/vendor/azure.svg';
|
||||
import docker from '@/assets/ico/vendor/docker.svg';
|
||||
|
||||
interface Props {
|
||||
type: EnvironmentType;
|
||||
}
|
||||
|
||||
export function EnvironmentIcon({ type }: Props) {
|
||||
switch (type) {
|
||||
case EnvironmentType.AgentOnDocker:
|
||||
case EnvironmentType.Docker:
|
||||
return (
|
||||
<img src={docker} width="60" alt="azure endpoint" aria-hidden="true" />
|
||||
);
|
||||
case EnvironmentType.Azure:
|
||||
return (
|
||||
<img src={azure} width="60" alt="azure endpoint" aria-hidden="true" />
|
||||
);
|
||||
case EnvironmentType.EdgeAgentOnDocker:
|
||||
return (
|
||||
<img src={dockerEdge} alt="docker edge endpoint" aria-hidden="true" />
|
||||
);
|
||||
case EnvironmentType.KubernetesLocal:
|
||||
case EnvironmentType.AgentOnKubernetes:
|
||||
return <img src={kube} alt="kubernetes endpoint" aria-hidden="true" />;
|
||||
case EnvironmentType.EdgeAgentOnKubernetes:
|
||||
return (
|
||||
<img src={kubeEdge} alt="kubernetes edge endpoint" aria-hidden="true" />
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<i
|
||||
className={clsx('fa-4x', 'blue-icon', environmentTypeIcon(type))}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,24 +0,0 @@
|
|||
.root {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.wrapperButton {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
outline: initial;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.edit-button {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 5px;
|
||||
}
|
|
@ -1,66 +0,0 @@
|
|||
import { Story } from '@storybook/react';
|
||||
|
||||
import {
|
||||
Environment,
|
||||
EnvironmentStatus,
|
||||
EnvironmentType,
|
||||
} from '@/react/portainer/environments/types';
|
||||
import { createMockEnvironment } from '@/react-tools/test-mocks';
|
||||
|
||||
import { EnvironmentItem } from './EnvironmentItem';
|
||||
|
||||
export default {
|
||||
component: EnvironmentItem,
|
||||
title: 'Home/EnvironmentList/EnvironmentItem',
|
||||
};
|
||||
|
||||
interface Args {
|
||||
environment: Environment;
|
||||
}
|
||||
|
||||
function Template({ environment }: Args) {
|
||||
return <EnvironmentItem environment={environment} onClick={() => {}} />;
|
||||
}
|
||||
|
||||
export const DockerEnvironment: Story<Args> = Template.bind({});
|
||||
DockerEnvironment.args = {
|
||||
environment: mockEnvironment(EnvironmentType.Docker),
|
||||
};
|
||||
|
||||
export const DockerAgentEnvironment: Story<Args> = Template.bind({});
|
||||
DockerAgentEnvironment.args = {
|
||||
environment: mockEnvironment(EnvironmentType.AgentOnDocker),
|
||||
};
|
||||
|
||||
export const DockerEdgeEnvironment: Story<Args> = Template.bind({});
|
||||
DockerEdgeEnvironment.args = {
|
||||
environment: mockEnvironment(EnvironmentType.EdgeAgentOnDocker),
|
||||
};
|
||||
|
||||
export const AzureEnvironment: Story<Args> = Template.bind({});
|
||||
AzureEnvironment.args = {
|
||||
environment: mockEnvironment(EnvironmentType.Azure),
|
||||
};
|
||||
|
||||
export const KubernetesLocalEnvironment: Story<Args> = Template.bind({});
|
||||
KubernetesLocalEnvironment.args = {
|
||||
environment: mockEnvironment(EnvironmentType.KubernetesLocal),
|
||||
};
|
||||
|
||||
export const KubernetesAgentEnvironment: Story<Args> = Template.bind({});
|
||||
KubernetesAgentEnvironment.args = {
|
||||
environment: mockEnvironment(EnvironmentType.AgentOnKubernetes),
|
||||
};
|
||||
|
||||
export const KubernetesEdgeEnvironment: Story<Args> = Template.bind({});
|
||||
KubernetesEdgeEnvironment.args = {
|
||||
environment: mockEnvironment(EnvironmentType.EdgeAgentOnKubernetes),
|
||||
};
|
||||
|
||||
function mockEnvironment(type: EnvironmentType): Environment {
|
||||
const env = createMockEnvironment();
|
||||
env.Type = type;
|
||||
env.Status = EnvironmentStatus.Up;
|
||||
|
||||
return env;
|
||||
}
|
|
@ -1,53 +0,0 @@
|
|||
import {
|
||||
EnvironmentGroup,
|
||||
EnvironmentGroupId,
|
||||
} from '@/portainer/environment-groups/types';
|
||||
import { Environment } from '@/react/portainer/environments/types';
|
||||
import { UserContext } from '@/portainer/hooks/useUser';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
import { Tag } from '@/portainer/tags/types';
|
||||
import { createMockEnvironment } from '@/react-tools/test-mocks';
|
||||
import { renderWithQueryClient } from '@/react-tools/test-utils';
|
||||
import { server, rest } from '@/setup-tests/server';
|
||||
|
||||
import { EnvironmentItem } from './EnvironmentItem';
|
||||
|
||||
test('loads component', async () => {
|
||||
const env = createMockEnvironment();
|
||||
const { getByText } = renderComponent(env);
|
||||
|
||||
expect(getByText(env.Name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows group name', async () => {
|
||||
const groupName = 'group-name';
|
||||
const groupId: EnvironmentGroupId = 14;
|
||||
|
||||
const env = createMockEnvironment();
|
||||
env.GroupId = groupId;
|
||||
|
||||
const { findByText } = renderComponent(env, { Name: groupName });
|
||||
|
||||
await expect(findByText(groupName)).resolves.toBeVisible();
|
||||
});
|
||||
|
||||
function renderComponent(
|
||||
env: Environment,
|
||||
group: Partial<EnvironmentGroup> = { Name: 'group' },
|
||||
isAdmin = false,
|
||||
tags: Tag[] = []
|
||||
) {
|
||||
const user = new UserViewModel({ Username: 'test', Role: isAdmin ? 1 : 2 });
|
||||
|
||||
server.use(rest.get('/api/tags', (req, res, ctx) => res(ctx.json(tags))));
|
||||
|
||||
return renderWithQueryClient(
|
||||
<UserContext.Provider value={{ user }}>
|
||||
<EnvironmentItem
|
||||
onClick={() => {}}
|
||||
environment={env}
|
||||
groupName={group.Name}
|
||||
/>
|
||||
</UserContext.Provider>
|
||||
);
|
||||
}
|
|
@ -1,213 +0,0 @@
|
|||
import clsx from 'clsx';
|
||||
import _ from 'lodash';
|
||||
import { Edit2, Tag, Cpu } from 'react-feather';
|
||||
|
||||
import {
|
||||
isoDateFromTimestamp,
|
||||
humanize,
|
||||
stripProtocol,
|
||||
} from '@/portainer/filters/filters';
|
||||
import {
|
||||
type Environment,
|
||||
PlatformType,
|
||||
} from '@/react/portainer/environments/types';
|
||||
import {
|
||||
getPlatformType,
|
||||
isDockerEnvironment,
|
||||
isEdgeEnvironment,
|
||||
} from '@/react/portainer/environments/utils';
|
||||
import type { TagId } from '@/portainer/tags/types';
|
||||
import { useTags } from '@/portainer/tags/queries';
|
||||
import { useUser } from '@/portainer/hooks/useUser';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
import { Link } from '@@/Link';
|
||||
import { Button } from '@@/buttons';
|
||||
|
||||
import { EnvironmentIcon } from './EnvironmentIcon';
|
||||
import { EdgeIndicator } from './EdgeIndicator';
|
||||
import { EnvironmentStats } from './EnvironmentStats';
|
||||
import styles from './EnvironmentItem.module.css';
|
||||
import { EnvironmentStatusBadge } from './EnvironmentStatusBadge';
|
||||
|
||||
interface Props {
|
||||
environment: Environment;
|
||||
groupName?: string;
|
||||
onClick(environment: Environment): void;
|
||||
}
|
||||
|
||||
export function EnvironmentItem({ environment, onClick, groupName }: Props) {
|
||||
const { isAdmin } = useUser();
|
||||
const isEdge = isEdgeEnvironment(environment.Type);
|
||||
|
||||
const snapshotTime = getSnapshotTime(environment);
|
||||
|
||||
const tags = useEnvironmentTagNames(environment.TagIds);
|
||||
const route = getRoute(environment);
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClick(environment)}
|
||||
className={styles.wrapperButton}
|
||||
>
|
||||
<Link
|
||||
className={clsx('blocklist-item no-link', styles.item)}
|
||||
to={route}
|
||||
params={{
|
||||
endpointId: environment.Id,
|
||||
id: environment.Id,
|
||||
}}
|
||||
>
|
||||
<div className="blocklist-item-box">
|
||||
<span className={clsx('blocklist-item-logo', 'endpoint-item')}>
|
||||
<EnvironmentIcon type={environment.Type} />
|
||||
</span>
|
||||
<span className="col-sm-12">
|
||||
<div className="blocklist-item-line endpoint-item">
|
||||
<span>
|
||||
<span className="blocklist-item-title endpoint-item">
|
||||
{environment.Name}
|
||||
</span>
|
||||
<span className="space-left blocklist-item-subtitle">
|
||||
{isEdge ? (
|
||||
<EdgeIndicator
|
||||
environment={environment}
|
||||
showLastCheckInDate
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<EnvironmentStatusBadge status={environment.Status} />
|
||||
<span className="space-left small text-muted">
|
||||
{snapshotTime}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
{groupName && (
|
||||
<span className="small space-right">
|
||||
<span>Group: </span>
|
||||
<span>{groupName}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<EnvironmentStats environment={environment} />
|
||||
<div className="blocklist-item-line endpoint-item">
|
||||
<span className="small text-muted space-x-2">
|
||||
{isDockerEnvironment(environment.Type) && (
|
||||
<span>
|
||||
{environment.Snapshots.length > 0 && (
|
||||
<span className="small text-muted vertical-center">
|
||||
<Cpu
|
||||
className="icon icon-sm space-right"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{environment.Snapshots[0].TotalCPU} CPU
|
||||
<Icon
|
||||
icon="svg-memory"
|
||||
className="icon icon-sm space-right"
|
||||
/>
|
||||
{humanize(environment.Snapshots[0].TotalMemory)} RAM
|
||||
<Cpu
|
||||
className="icon icon-sm space-right"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{environment.Gpus?.length} GPU
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<span className="vertical-center">
|
||||
<Tag
|
||||
className="icon icon-sm space-right"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{tags}
|
||||
</span>
|
||||
</span>
|
||||
{!isEdge && (
|
||||
<span className="small text-muted">
|
||||
{stripProtocol(environment.URL)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<Link
|
||||
to="portainer.endpoints.endpoint"
|
||||
params={{ id: environment.Id }}
|
||||
className={styles.editButton}
|
||||
>
|
||||
<Button color="link">
|
||||
<Edit2 className="icon icon-md" aria-hidden="true" />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useEnvironmentTagNames(tagIds?: TagId[]) {
|
||||
const { tags, isLoading } = useTags((tags) => {
|
||||
if (!tagIds) {
|
||||
return [];
|
||||
}
|
||||
return _.compact(
|
||||
tagIds
|
||||
.map((id) => tags.find((tag) => tag.ID === id))
|
||||
.map((tag) => tag?.Name)
|
||||
);
|
||||
});
|
||||
|
||||
if (tags && tags.length > 0) {
|
||||
return tags.join(', ');
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return 'Loading tags...';
|
||||
}
|
||||
|
||||
return 'No tags';
|
||||
}
|
||||
|
||||
function getSnapshotTime(environment: Environment) {
|
||||
const platform = getPlatformType(environment.Type);
|
||||
|
||||
switch (platform) {
|
||||
case PlatformType.Docker:
|
||||
return environment.Snapshots.length > 0
|
||||
? isoDateFromTimestamp(environment.Snapshots[0].Time)
|
||||
: null;
|
||||
case PlatformType.Kubernetes:
|
||||
return environment.Kubernetes.Snapshots &&
|
||||
environment.Kubernetes.Snapshots.length > 0
|
||||
? isoDateFromTimestamp(environment.Kubernetes.Snapshots[0].Time)
|
||||
: null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getRoute(environment: Environment) {
|
||||
if (isEdgeEnvironment(environment.Type) && !environment.EdgeID) {
|
||||
return 'portainer.endpoints.endpoint';
|
||||
}
|
||||
|
||||
const platform = getPlatformType(environment.Type);
|
||||
|
||||
switch (platform) {
|
||||
case PlatformType.Azure:
|
||||
return 'azure.dashboard';
|
||||
case PlatformType.Docker:
|
||||
return 'docker.dashboard';
|
||||
case PlatformType.Kubernetes:
|
||||
return 'kubernetes.dashboard';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
|
@ -1,40 +0,0 @@
|
|||
import {
|
||||
Environment,
|
||||
PlatformType,
|
||||
} from '@/react/portainer/environments/types';
|
||||
import { getPlatformType } from '@/react/portainer/environments/utils';
|
||||
|
||||
import { EnvironmentStatsDocker } from './EnvironmentStatsDocker';
|
||||
import { EnvironmentStatsKubernetes } from './EnvironmentStatsKubernetes';
|
||||
|
||||
interface Props {
|
||||
environment: Environment;
|
||||
}
|
||||
|
||||
export function EnvironmentStats({ environment }: Props) {
|
||||
const platform = getPlatformType(environment.Type);
|
||||
switch (platform) {
|
||||
case PlatformType.Kubernetes:
|
||||
return (
|
||||
<EnvironmentStatsKubernetes
|
||||
snapshots={environment.Kubernetes.Snapshots || []}
|
||||
type={environment.Type}
|
||||
agentVersion={environment.Agent.Version}
|
||||
/>
|
||||
);
|
||||
case PlatformType.Docker:
|
||||
return (
|
||||
<EnvironmentStatsDocker
|
||||
snapshots={environment.Snapshots}
|
||||
type={environment.Type}
|
||||
agentVersion={environment.Agent.Version}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="blocklist-item-line endpoint-item">
|
||||
<span className="blocklist-item-desc">-</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,134 +0,0 @@
|
|||
import {
|
||||
DockerSnapshot,
|
||||
EnvironmentType,
|
||||
} from '@/react/portainer/environments/types';
|
||||
import { addPlural } from '@/portainer/helpers/strings';
|
||||
|
||||
import { AgentVersionTag } from './AgentVersionTag';
|
||||
import { Stat } from './EnvironmentStatsItem';
|
||||
|
||||
interface Props {
|
||||
snapshots: DockerSnapshot[];
|
||||
type: EnvironmentType;
|
||||
agentVersion: string;
|
||||
}
|
||||
|
||||
export function EnvironmentStatsDocker({
|
||||
snapshots = [],
|
||||
type,
|
||||
agentVersion,
|
||||
}: Props) {
|
||||
if (snapshots.length === 0) {
|
||||
return (
|
||||
<div className="blocklist-item-line endpoint-item">
|
||||
<span className="blocklist-item-desc">No snapshot available</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const snapshot = snapshots[0];
|
||||
|
||||
return (
|
||||
<div className="blocklist-item-line endpoint-item">
|
||||
<span className="blocklist-item-desc">
|
||||
<Stat
|
||||
value={addPlural(snapshot.StackCount, 'stack')}
|
||||
icon="layers"
|
||||
featherIcon
|
||||
/>
|
||||
|
||||
{!!snapshot.Swarm && (
|
||||
<Stat
|
||||
value={addPlural(snapshot.ServiceCount, 'service')}
|
||||
icon="shuffle"
|
||||
featherIcon
|
||||
/>
|
||||
)}
|
||||
|
||||
<ContainerStats
|
||||
running={snapshot.RunningContainerCount}
|
||||
stopped={snapshot.StoppedContainerCount}
|
||||
healthy={snapshot.HealthyContainerCount}
|
||||
unhealthy={snapshot.UnhealthyContainerCount}
|
||||
/>
|
||||
<Stat
|
||||
value={addPlural(snapshot.VolumeCount, 'volume')}
|
||||
icon="database"
|
||||
featherIcon
|
||||
/>
|
||||
<Stat
|
||||
value={addPlural(snapshot.ImageCount, 'image')}
|
||||
icon="list"
|
||||
featherIcon
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span className="small text-muted space-x-2 vertical-center">
|
||||
<span>
|
||||
{snapshot.Swarm ? 'Swarm' : 'Standalone'} {snapshot.DockerVersion}
|
||||
</span>
|
||||
{snapshot.Swarm && (
|
||||
<Stat
|
||||
value={addPlural(snapshot.NodeCount, 'node')}
|
||||
icon="hard-drive"
|
||||
featherIcon
|
||||
/>
|
||||
)}
|
||||
<AgentVersionTag version={agentVersion} type={type} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ContainerStatsProps {
|
||||
running: number;
|
||||
stopped: number;
|
||||
healthy: number;
|
||||
unhealthy: number;
|
||||
}
|
||||
|
||||
function ContainerStats({
|
||||
running,
|
||||
stopped,
|
||||
healthy,
|
||||
unhealthy,
|
||||
}: ContainerStatsProps) {
|
||||
const containersCount = running + stopped;
|
||||
|
||||
return (
|
||||
<Stat
|
||||
value={addPlural(containersCount, 'container')}
|
||||
icon="box"
|
||||
featherIcon
|
||||
>
|
||||
{containersCount > 0 && (
|
||||
<span className="space-x-2 space-right">
|
||||
<Stat
|
||||
value={running}
|
||||
icon="power"
|
||||
featherIcon
|
||||
iconClass="icon-success"
|
||||
/>
|
||||
<Stat
|
||||
value={stopped}
|
||||
icon="power"
|
||||
featherIcon
|
||||
iconClass="icon-danger"
|
||||
/>
|
||||
<Stat
|
||||
value={healthy}
|
||||
icon="heart"
|
||||
featherIcon
|
||||
iconClass="icon-success"
|
||||
/>
|
||||
<Stat
|
||||
value={unhealthy}
|
||||
icon="heart"
|
||||
featherIcon
|
||||
iconClass="icon-warning"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</Stat>
|
||||
);
|
||||
}
|
|
@ -1,30 +0,0 @@
|
|||
import clsx from 'clsx';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { Icon, IconProps } from '@/react/components/Icon';
|
||||
|
||||
interface Props extends IconProps {
|
||||
value: string | number;
|
||||
icon: string;
|
||||
iconClass?: string;
|
||||
}
|
||||
|
||||
export function Stat({
|
||||
value,
|
||||
icon,
|
||||
children,
|
||||
featherIcon,
|
||||
iconClass,
|
||||
}: PropsWithChildren<Props>) {
|
||||
return (
|
||||
<span className="vertical-center space-right">
|
||||
<Icon
|
||||
className={clsx('icon icon-sm space-right', iconClass)}
|
||||
icon={icon}
|
||||
feather={featherIcon}
|
||||
/>
|
||||
<span>{value}</span>
|
||||
{children && <span className="space-left">{children}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
|
@ -1,55 +0,0 @@
|
|||
import {
|
||||
EnvironmentType,
|
||||
KubernetesSnapshot,
|
||||
} from '@/react/portainer/environments/types';
|
||||
import { humanize } from '@/portainer/filters/filters';
|
||||
import { addPlural } from '@/portainer/helpers/strings';
|
||||
|
||||
import { AgentVersionTag } from './AgentVersionTag';
|
||||
import { Stat } from './EnvironmentStatsItem';
|
||||
|
||||
interface Props {
|
||||
snapshots?: KubernetesSnapshot[];
|
||||
type: EnvironmentType;
|
||||
agentVersion: string;
|
||||
}
|
||||
|
||||
export function EnvironmentStatsKubernetes({
|
||||
snapshots = [],
|
||||
type,
|
||||
agentVersion,
|
||||
}: Props) {
|
||||
if (snapshots.length === 0) {
|
||||
return (
|
||||
<div className="blocklist-item-line endpoint-item">
|
||||
<span className="blocklist-item-desc">No snapshot available</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const snapshot = snapshots[0];
|
||||
|
||||
return (
|
||||
<div className="blocklist-item-line endpoint-item">
|
||||
<span className="blocklist-item-desc space-x-1">
|
||||
<Stat icon="cpu" featherIcon value={`${snapshot.TotalCPU} CPU`} />
|
||||
|
||||
<Stat
|
||||
icon="svg-memory"
|
||||
featherIcon
|
||||
value={`${humanize(snapshot.TotalMemory)} RAM`}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span className="small text-muted space-x-2 vertical-center">
|
||||
<span>Kubernetes {snapshot.KubernetesVersion}</span>
|
||||
<Stat
|
||||
value={addPlural(snapshot.NodeCount, 'node')}
|
||||
icon="hard-drive"
|
||||
featherIcon
|
||||
/>
|
||||
<AgentVersionTag type={type} version={agentVersion} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
import clsx from 'clsx';
|
||||
|
||||
import { EnvironmentStatus } from '@/react/portainer/environments/types';
|
||||
|
||||
interface Props {
|
||||
status: EnvironmentStatus;
|
||||
}
|
||||
|
||||
export function EnvironmentStatusBadge({ status }: Props) {
|
||||
return (
|
||||
<span className={clsx('label', `label-${environmentStatusBadge(status)}`)}>
|
||||
{status === EnvironmentStatus.Up ? 'up' : 'down'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function environmentStatusBadge(status: EnvironmentStatus) {
|
||||
if (status === EnvironmentStatus.Down) {
|
||||
return 'danger';
|
||||
}
|
||||
return 'success';
|
||||
}
|
|
@ -1,2 +0,0 @@
|
|||
export { EnvironmentItem } from './EnvironmentItem';
|
||||
export { EdgeIndicator } from './EdgeIndicator';
|
|
@ -1,80 +0,0 @@
|
|||
.actionBar {
|
||||
margin-left: 15px !important;
|
||||
margin-right: 15px !important;
|
||||
}
|
||||
|
||||
.actionBar .description {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.refresh-environments-button {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
padding: 0px 5px;
|
||||
}
|
||||
|
||||
.filter-container input[type='text'] {
|
||||
background: none !important;
|
||||
border: 0px !important;
|
||||
}
|
||||
|
||||
.filter-left {
|
||||
margin-left: 10px;
|
||||
padding: 10px 0px;
|
||||
width: 15%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.filter-right {
|
||||
padding: 10px;
|
||||
width: 20%;
|
||||
right: 0;
|
||||
display: inline-block;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.filter-button {
|
||||
margin-left: 10px;
|
||||
padding: 10px 0px;
|
||||
width: 5%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.clear-button {
|
||||
white-space: nowrap;
|
||||
|
||||
border: 0px;
|
||||
padding: 5px 10px;
|
||||
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.refresh-button {
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.kubeconfig-button {
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.filterSearchbar {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filterSearchbar input[type='text'] {
|
||||
background: none !important;
|
||||
border: 0px !important;
|
||||
}
|
|
@ -1,58 +0,0 @@
|
|||
import { Environment } from '@/react/portainer/environments/types';
|
||||
import { UserContext } from '@/portainer/hooks/useUser';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
import { renderWithQueryClient } from '@/react-tools/test-utils';
|
||||
import { rest, server } from '@/setup-tests/server';
|
||||
|
||||
import { EnvironmentList } from './EnvironmentList';
|
||||
|
||||
test('when no environments for query should show empty list message', async () => {
|
||||
const { findByText } = await renderComponent(false, []);
|
||||
|
||||
await expect(findByText('No environments available.')).resolves.toBeVisible();
|
||||
});
|
||||
|
||||
test('when user is not admin and no environments at all should show empty list info message', async () => {
|
||||
const { findByText } = await renderComponent(false, []);
|
||||
|
||||
await expect(
|
||||
findByText(
|
||||
'You do not have access to any environment. Please contact your administrator.'
|
||||
)
|
||||
).resolves.toBeVisible();
|
||||
});
|
||||
|
||||
test('when user is an admin and no environments at all should show empty list info message', async () => {
|
||||
const { findByText } = await renderComponent(true);
|
||||
|
||||
await expect(
|
||||
findByText(/No environment available for management. Please head over the/)
|
||||
).resolves.toBeVisible();
|
||||
});
|
||||
|
||||
async function renderComponent(
|
||||
isAdmin = false,
|
||||
environments: Environment[] = []
|
||||
) {
|
||||
const user = new UserViewModel({ Username: 'test', Role: isAdmin ? 1 : 2 });
|
||||
|
||||
server.use(
|
||||
rest.get('/api/endpoints', (req, res, ctx) =>
|
||||
res(
|
||||
ctx.set('x-total-available', environments.length.toString()),
|
||||
ctx.set('x-total-count', environments.length.toString()),
|
||||
ctx.json(environments)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const queries = renderWithQueryClient(
|
||||
<UserContext.Provider value={{ user }}>
|
||||
<EnvironmentList onClickItem={jest.fn()} onRefresh={jest.fn()} />
|
||||
</UserContext.Provider>
|
||||
);
|
||||
|
||||
await expect(queries.findByText('Environments')).resolves.toBeVisible();
|
||||
|
||||
return queries;
|
||||
}
|
|
@ -1,548 +0,0 @@
|
|||
import { ReactNode, useEffect, useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { RefreshCcw } from 'react-feather';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { usePaginationLimitState } from '@/portainer/hooks/usePaginationLimitState';
|
||||
import {
|
||||
Environment,
|
||||
EnvironmentType,
|
||||
EnvironmentStatus,
|
||||
PlatformType,
|
||||
EdgeTypes,
|
||||
} from '@/react/portainer/environments/types';
|
||||
import { EnvironmentGroupId } from '@/portainer/environment-groups/types';
|
||||
import {
|
||||
HomepageFilter,
|
||||
useHomePageFilter,
|
||||
} from '@/portainer/home/HomepageFilter';
|
||||
import { useDebounce } from '@/portainer/hooks/useDebounce';
|
||||
import {
|
||||
refetchIfAnyOffline,
|
||||
useEnvironmentList,
|
||||
} from '@/react/portainer/environments/queries/useEnvironmentList';
|
||||
import { useGroups } from '@/portainer/environment-groups/queries';
|
||||
import { useTags } from '@/portainer/tags/queries';
|
||||
import { Filter } from '@/portainer/home/types';
|
||||
import { useAgentVersionsList } from '@/react/portainer/environments/queries/useAgentVersionsList';
|
||||
import { EnvironmentsQueryParams } from '@/react/portainer/environments/environment.service';
|
||||
import { useUser } from '@/portainer/hooks/useUser';
|
||||
|
||||
import { TableFooter } from '@@/datatables/TableFooter';
|
||||
import { TableActions, TableContainer, TableTitle } from '@@/datatables';
|
||||
import { SortbySelector } from '@@/datatables/SortbySelector';
|
||||
import {
|
||||
FilterSearchBar,
|
||||
useSearchBarState,
|
||||
} from '@@/datatables/FilterSearchBar';
|
||||
import { Button } from '@@/buttons';
|
||||
import { PaginationControls } from '@@/PaginationControls';
|
||||
|
||||
import { EnvironmentItem } from './EnvironmentItem';
|
||||
import { KubeconfigButton } from './KubeconfigButton';
|
||||
import { NoEnvironmentsInfoPanel } from './NoEnvironmentsInfoPanel';
|
||||
import styles from './EnvironmentList.module.css';
|
||||
|
||||
interface Props {
|
||||
onClickItem(environment: Environment): void;
|
||||
onRefresh(): void;
|
||||
}
|
||||
|
||||
const status = [
|
||||
{ value: EnvironmentStatus.Up, label: 'Up' },
|
||||
{ value: EnvironmentStatus.Down, label: 'Down' },
|
||||
];
|
||||
|
||||
const sortByOptions = [
|
||||
{ value: 1, label: 'Name' },
|
||||
{ value: 2, label: 'Group' },
|
||||
{ value: 3, label: 'Status' },
|
||||
];
|
||||
|
||||
enum ConnectionType {
|
||||
API,
|
||||
Agent,
|
||||
EdgeAgent,
|
||||
EdgeDevice,
|
||||
}
|
||||
|
||||
const storageKey = 'home_endpoints';
|
||||
|
||||
export function EnvironmentList({ onClickItem, onRefresh }: Props) {
|
||||
const { isAdmin } = useUser();
|
||||
|
||||
const [platformTypes, setPlatformTypes] = useHomePageFilter<
|
||||
Filter<PlatformType>[]
|
||||
>('platformType', []);
|
||||
const [searchBarValue, setSearchBarValue] = useSearchBarState(storageKey);
|
||||
const [pageLimit, setPageLimit] = usePaginationLimitState(storageKey);
|
||||
const [page, setPage] = useState(1);
|
||||
const debouncedTextFilter = useDebounce(searchBarValue);
|
||||
|
||||
const [connectionTypes, setConnectionTypes] = useHomePageFilter<
|
||||
Filter<ConnectionType>[]
|
||||
>('connectionTypes', []);
|
||||
|
||||
const [statusFilter, setStatusFilter] = useHomePageFilter<
|
||||
EnvironmentStatus[]
|
||||
>('status', []);
|
||||
const [tagFilter, setTagFilter] = useHomePageFilter<number[]>('tag', []);
|
||||
const [groupFilter, setGroupFilter] = useHomePageFilter<EnvironmentGroupId[]>(
|
||||
'group',
|
||||
[]
|
||||
);
|
||||
const [sortByFilter, setSortByFilter] = useSearchBarState('sortBy');
|
||||
const [sortByDescending, setSortByDescending] = useHomePageFilter(
|
||||
'sortOrder',
|
||||
false
|
||||
);
|
||||
const [sortByButton, setSortByButton] = useHomePageFilter(
|
||||
'sortByButton',
|
||||
false
|
||||
);
|
||||
|
||||
const [statusState, setStatusState] = useHomePageFilter<Filter[]>(
|
||||
'status_state',
|
||||
[]
|
||||
);
|
||||
const [tagState, setTagState] = useHomePageFilter<Filter[]>('tag_state', []);
|
||||
const [groupState, setGroupState] = useHomePageFilter<Filter[]>(
|
||||
'group_state',
|
||||
[]
|
||||
);
|
||||
const [sortByState, setSortByState] = useHomePageFilter<Filter | undefined>(
|
||||
'sort_by_state',
|
||||
undefined
|
||||
);
|
||||
const [agentVersions, setAgentVersions] = useHomePageFilter<Filter<string>[]>(
|
||||
'agentVersions',
|
||||
[]
|
||||
);
|
||||
|
||||
const groupsQuery = useGroups();
|
||||
|
||||
const environmentsQueryParams: EnvironmentsQueryParams = {
|
||||
types: getTypes(
|
||||
platformTypes.map((p) => p.value),
|
||||
connectionTypes.map((p) => p.value)
|
||||
),
|
||||
search: debouncedTextFilter,
|
||||
status: statusFilter,
|
||||
tagIds: tagFilter?.length ? tagFilter : undefined,
|
||||
groupIds: groupFilter,
|
||||
edgeDevice: false,
|
||||
tagsPartialMatch: true,
|
||||
agentVersions: agentVersions.map((a) => a.value),
|
||||
};
|
||||
|
||||
const tagsQuery = useTags();
|
||||
|
||||
const { isLoading, environments, totalCount, totalAvailable } =
|
||||
useEnvironmentList(
|
||||
{
|
||||
page,
|
||||
pageLimit,
|
||||
sort: sortByFilter,
|
||||
order: sortByDescending ? 'desc' : 'asc',
|
||||
...environmentsQueryParams,
|
||||
},
|
||||
refetchIfAnyOffline
|
||||
);
|
||||
|
||||
const agentVersionsQuery = useAgentVersionsList();
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [searchBarValue]);
|
||||
|
||||
const groupOptions = [...(groupsQuery.data || [])];
|
||||
const uniqueGroup = [
|
||||
...new Map(groupOptions.map((item) => [item.Id, item])).values(),
|
||||
].map(({ Id: value, Name: label }) => ({
|
||||
value,
|
||||
label,
|
||||
}));
|
||||
|
||||
const tagOptions = [...(tagsQuery.tags || [])];
|
||||
const uniqueTag = [
|
||||
...new Map(tagOptions.map((item) => [item.ID, item])).values(),
|
||||
].map(({ ID: value, Name: label }) => ({
|
||||
value,
|
||||
label,
|
||||
}));
|
||||
|
||||
const connectionTypeOptions = getConnectionTypeOptions(platformTypes);
|
||||
const platformTypeOptions = getPlatformTypeOptions(connectionTypes);
|
||||
|
||||
return (
|
||||
<>
|
||||
{totalAvailable === 0 && <NoEnvironmentsInfoPanel isAdmin={isAdmin} />}
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<TableContainer>
|
||||
<TableTitle icon="hard-drive" featherIcon label="Environments" />
|
||||
|
||||
<TableActions className={styles.actionBar}>
|
||||
<div className={styles.description}>
|
||||
Click on an environment to manage
|
||||
</div>
|
||||
<div className={styles.actionButton}>
|
||||
<div className={styles.refreshButton}>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
onClick={onRefresh}
|
||||
data-cy="home-refreshEndpointsButton"
|
||||
size="medium"
|
||||
color="secondary"
|
||||
className={clsx(
|
||||
'vertical-center !ml-0',
|
||||
styles.refreshEnvironmentsButton
|
||||
)}
|
||||
>
|
||||
<RefreshCcw
|
||||
className="feather icon-sm icon-white"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.kubeconfigButton}>
|
||||
<KubeconfigButton
|
||||
environments={environments}
|
||||
envQueryParams={{
|
||||
...environmentsQueryParams,
|
||||
sort: sortByFilter,
|
||||
order: sortByDescending ? 'desc' : 'asc',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={clsx(styles.filterSearchbar, 'ml-3')}>
|
||||
<FilterSearchBar
|
||||
value={searchBarValue}
|
||||
onChange={setSearchBarValue}
|
||||
placeholder="Search by name, group, tag, status, URL..."
|
||||
data-cy="home-endpointsSearchInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TableActions>
|
||||
<div className={styles.filterContainer}>
|
||||
<div className={styles.filterLeft}>
|
||||
<HomepageFilter
|
||||
filterOptions={platformTypeOptions}
|
||||
onChange={setPlatformTypes}
|
||||
placeHolder="Platform"
|
||||
value={platformTypes}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.filterLeft}>
|
||||
<HomepageFilter
|
||||
filterOptions={connectionTypeOptions}
|
||||
onChange={setConnectionTypes}
|
||||
placeHolder="Connection Type"
|
||||
value={connectionTypes}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.filterLeft}>
|
||||
<HomepageFilter
|
||||
filterOptions={status}
|
||||
onChange={statusOnChange}
|
||||
placeHolder="Status"
|
||||
value={statusState}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.filterLeft}>
|
||||
<HomepageFilter
|
||||
filterOptions={uniqueTag}
|
||||
onChange={tagOnChange}
|
||||
placeHolder="Tags"
|
||||
value={tagState}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.filterLeft}>
|
||||
<HomepageFilter
|
||||
filterOptions={uniqueGroup}
|
||||
onChange={groupOnChange}
|
||||
placeHolder="Groups"
|
||||
value={groupState}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.filterLeft}>
|
||||
<HomepageFilter<string>
|
||||
filterOptions={
|
||||
agentVersionsQuery.data?.map((v) => ({
|
||||
label: v,
|
||||
value: v,
|
||||
})) || []
|
||||
}
|
||||
onChange={setAgentVersions}
|
||||
placeHolder="Agent Version"
|
||||
value={agentVersions}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.clearButton}
|
||||
onClick={clearFilter}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
<div className={styles.filterRight}>
|
||||
<SortbySelector
|
||||
filterOptions={sortByOptions}
|
||||
onChange={sortOnchange}
|
||||
onDescending={sortOnDescending}
|
||||
placeHolder="Sort By"
|
||||
sortByDescending={sortByDescending}
|
||||
sortByButton={sortByButton}
|
||||
value={sortByState}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="blocklist" data-cy="home-endpointList">
|
||||
{renderItems(
|
||||
isLoading,
|
||||
totalCount,
|
||||
environments.map((env) => (
|
||||
<EnvironmentItem
|
||||
key={env.Id}
|
||||
environment={env}
|
||||
groupName={
|
||||
groupsQuery.data?.find((g) => g.Id === env.GroupId)?.Name
|
||||
}
|
||||
onClick={onClickItem}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TableFooter>
|
||||
<PaginationControls
|
||||
showAll={totalCount <= 100}
|
||||
pageLimit={pageLimit}
|
||||
page={page}
|
||||
onPageChange={setPage}
|
||||
totalCount={totalCount}
|
||||
onPageLimitChange={setPageLimit}
|
||||
/>
|
||||
</TableFooter>
|
||||
</TableContainer>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
function getTypes(
|
||||
platformTypes: PlatformType[],
|
||||
connectionTypes: ConnectionType[]
|
||||
) {
|
||||
if (platformTypes.length === 0 && connectionTypes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const typesByPlatform = {
|
||||
[PlatformType.Docker]: [
|
||||
EnvironmentType.Docker,
|
||||
EnvironmentType.AgentOnDocker,
|
||||
EnvironmentType.EdgeAgentOnDocker,
|
||||
],
|
||||
[PlatformType.Azure]: [EnvironmentType.Azure],
|
||||
[PlatformType.Kubernetes]: [
|
||||
EnvironmentType.KubernetesLocal,
|
||||
EnvironmentType.AgentOnKubernetes,
|
||||
EnvironmentType.EdgeAgentOnKubernetes,
|
||||
],
|
||||
};
|
||||
|
||||
const typesByConnection = {
|
||||
[ConnectionType.API]: [
|
||||
EnvironmentType.Azure,
|
||||
EnvironmentType.KubernetesLocal,
|
||||
EnvironmentType.Docker,
|
||||
],
|
||||
[ConnectionType.Agent]: [
|
||||
EnvironmentType.AgentOnDocker,
|
||||
EnvironmentType.AgentOnKubernetes,
|
||||
],
|
||||
[ConnectionType.EdgeAgent]: EdgeTypes,
|
||||
[ConnectionType.EdgeDevice]: EdgeTypes,
|
||||
};
|
||||
|
||||
const selectedTypesByPlatform = platformTypes.flatMap(
|
||||
(platformType) => typesByPlatform[platformType]
|
||||
);
|
||||
const selectedTypesByConnection = connectionTypes.flatMap(
|
||||
(connectionType) => typesByConnection[connectionType]
|
||||
);
|
||||
|
||||
if (selectedTypesByPlatform.length === 0) {
|
||||
return selectedTypesByConnection;
|
||||
}
|
||||
|
||||
if (selectedTypesByConnection.length === 0) {
|
||||
return selectedTypesByPlatform;
|
||||
}
|
||||
|
||||
return _.intersection(selectedTypesByConnection, selectedTypesByPlatform);
|
||||
}
|
||||
|
||||
function statusOnChange(filterOptions: Filter[]) {
|
||||
setStatusState(filterOptions);
|
||||
if (filterOptions.length === 0) {
|
||||
setStatusFilter([]);
|
||||
} else {
|
||||
const filteredStatus = [
|
||||
...new Set(
|
||||
filterOptions.map(
|
||||
(filterOptions: { value: number }) => filterOptions.value
|
||||
)
|
||||
),
|
||||
];
|
||||
setStatusFilter(filteredStatus);
|
||||
}
|
||||
}
|
||||
|
||||
function groupOnChange(filterOptions: Filter[]) {
|
||||
setGroupState(filterOptions);
|
||||
if (filterOptions.length === 0) {
|
||||
setGroupFilter([]);
|
||||
} else {
|
||||
const filteredGroups = [
|
||||
...new Set(
|
||||
filterOptions.map(
|
||||
(filterOptions: { value: number }) => filterOptions.value
|
||||
)
|
||||
),
|
||||
];
|
||||
setGroupFilter(filteredGroups);
|
||||
}
|
||||
}
|
||||
|
||||
function tagOnChange(filterOptions: Filter[]) {
|
||||
setTagState(filterOptions);
|
||||
if (filterOptions.length === 0) {
|
||||
setTagFilter([]);
|
||||
} else {
|
||||
const filteredTags = [
|
||||
...new Set(
|
||||
filterOptions.map(
|
||||
(filterOptions: { value: number }) => filterOptions.value
|
||||
)
|
||||
),
|
||||
];
|
||||
setTagFilter(filteredTags);
|
||||
}
|
||||
}
|
||||
|
||||
function clearFilter() {
|
||||
setPlatformTypes([]);
|
||||
setStatusState([]);
|
||||
setStatusFilter([]);
|
||||
setTagState([]);
|
||||
setTagFilter([]);
|
||||
setGroupState([]);
|
||||
setGroupFilter([]);
|
||||
setAgentVersions([]);
|
||||
setConnectionTypes([]);
|
||||
}
|
||||
|
||||
function sortOnchange(filterOptions: Filter) {
|
||||
if (filterOptions !== null) {
|
||||
setSortByFilter(filterOptions.label);
|
||||
setSortByButton(true);
|
||||
setSortByState(filterOptions);
|
||||
} else {
|
||||
setSortByFilter('');
|
||||
setSortByButton(true);
|
||||
setSortByState(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function sortOnDescending() {
|
||||
setSortByDescending(!sortByDescending);
|
||||
}
|
||||
}
|
||||
|
||||
function getConnectionTypeOptions(platformTypes: Filter<PlatformType>[]) {
|
||||
const platformTypeConnectionType = {
|
||||
[PlatformType.Docker]: [
|
||||
ConnectionType.API,
|
||||
ConnectionType.Agent,
|
||||
ConnectionType.EdgeAgent,
|
||||
ConnectionType.EdgeDevice,
|
||||
],
|
||||
[PlatformType.Azure]: [ConnectionType.API],
|
||||
[PlatformType.Kubernetes]: [
|
||||
ConnectionType.Agent,
|
||||
ConnectionType.EdgeAgent,
|
||||
ConnectionType.EdgeDevice,
|
||||
],
|
||||
};
|
||||
|
||||
const connectionTypesDefaultOptions = [
|
||||
{ value: ConnectionType.API, label: 'API' },
|
||||
{ value: ConnectionType.Agent, label: 'Agent' },
|
||||
{ value: ConnectionType.EdgeAgent, label: 'Edge Agent' },
|
||||
];
|
||||
|
||||
if (platformTypes.length === 0) {
|
||||
return connectionTypesDefaultOptions;
|
||||
}
|
||||
|
||||
return _.compact(
|
||||
_.intersection(
|
||||
...platformTypes.map((p) => platformTypeConnectionType[p.value])
|
||||
).map((c) => connectionTypesDefaultOptions.find((o) => o.value === c))
|
||||
);
|
||||
}
|
||||
|
||||
function getPlatformTypeOptions(connectionTypes: Filter<ConnectionType>[]) {
|
||||
const platformDefaultOptions = [
|
||||
{ value: PlatformType.Docker, label: 'Docker' },
|
||||
{ value: PlatformType.Azure, label: 'Azure' },
|
||||
{ value: PlatformType.Kubernetes, label: 'Kubernetes' },
|
||||
];
|
||||
|
||||
if (connectionTypes.length === 0) {
|
||||
return platformDefaultOptions;
|
||||
}
|
||||
|
||||
const connectionTypePlatformType = {
|
||||
[ConnectionType.API]: [PlatformType.Docker, PlatformType.Azure],
|
||||
[ConnectionType.Agent]: [PlatformType.Docker, PlatformType.Kubernetes],
|
||||
[ConnectionType.EdgeAgent]: [PlatformType.Kubernetes, PlatformType.Docker],
|
||||
[ConnectionType.EdgeDevice]: [PlatformType.Docker, PlatformType.Kubernetes],
|
||||
};
|
||||
|
||||
return _.compact(
|
||||
_.intersection(
|
||||
...connectionTypes.map((p) => connectionTypePlatformType[p.value])
|
||||
).map((c) => platformDefaultOptions.find((o) => o.value === c))
|
||||
);
|
||||
}
|
||||
|
||||
function renderItems(
|
||||
isLoading: boolean,
|
||||
totalCount: number,
|
||||
|
||||
items: ReactNode
|
||||
) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="text-center text-muted" data-cy="home-loadingEndpoints">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!totalCount) {
|
||||
return (
|
||||
<div className="text-center text-muted" data-cy="home-noEndpoints">
|
||||
No environments available.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
|
@ -1,73 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { Download } from 'react-feather';
|
||||
|
||||
import { Environment } from '@/react/portainer/environments/types';
|
||||
import { isKubernetesEnvironment } from '@/react/portainer/environments/utils';
|
||||
import { trackEvent } from '@/angulartics.matomo/analytics-services';
|
||||
import { Query } from '@/react/portainer/environments/queries/useEnvironmentList';
|
||||
|
||||
import { Button } from '@@/buttons';
|
||||
|
||||
import { KubeconfigPrompt } from './KubeconfigPrompt';
|
||||
|
||||
import '@reach/dialog/styles.css';
|
||||
|
||||
export interface Props {
|
||||
environments: Environment[];
|
||||
envQueryParams: Query;
|
||||
}
|
||||
export function KubeconfigButton({ environments, envQueryParams }: Props) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
if (!environments) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isKubeconfigButtonVisible(environments)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={handleClick} size="medium" className="!ml-3">
|
||||
<Download className="feather icon-white" aria-hidden="true" />{' '}
|
||||
Kubeconfig
|
||||
</Button>
|
||||
{prompt()}
|
||||
</>
|
||||
);
|
||||
|
||||
function handleClick() {
|
||||
if (!environments) {
|
||||
return;
|
||||
}
|
||||
|
||||
trackEvent('kubernetes-kubectl-kubeconfig-multi', {
|
||||
category: 'kubernetes',
|
||||
});
|
||||
|
||||
setIsOpen(true);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
function isKubeconfigButtonVisible(environments: Environment[]) {
|
||||
if (window.location.protocol !== 'https:') {
|
||||
return false;
|
||||
}
|
||||
return environments.some((env) => isKubernetesEnvironment(env.Type));
|
||||
}
|
||||
|
||||
function prompt() {
|
||||
return (
|
||||
isOpen && (
|
||||
<KubeconfigPrompt
|
||||
envQueryParams={envQueryParams}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
.checkbox {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
|
@ -1,170 +0,0 @@
|
|||
import { X } from 'react-feather';
|
||||
import clsx from 'clsx';
|
||||
import { useState } from 'react';
|
||||
import { DialogOverlay } from '@reach/dialog';
|
||||
|
||||
import { downloadKubeconfigFile } from '@/react/kubernetes/services/kubeconfig.service';
|
||||
import * as notifications from '@/portainer/services/notifications';
|
||||
import { EnvironmentType } from '@/react/portainer/environments/types';
|
||||
import { usePaginationLimitState } from '@/portainer/hooks/usePaginationLimitState';
|
||||
import { usePublicSettings } from '@/react/portainer/settings/queries';
|
||||
import {
|
||||
Query,
|
||||
useEnvironmentList,
|
||||
} from '@/react/portainer/environments/queries/useEnvironmentList';
|
||||
|
||||
import { PaginationControls } from '@@/PaginationControls';
|
||||
import { Checkbox } from '@@/form-components/Checkbox';
|
||||
import { Button } from '@@/buttons';
|
||||
|
||||
import { useSelection } from './KubeconfigSelection';
|
||||
import styles from './KubeconfigPrompt.module.css';
|
||||
import '@reach/dialog/styles.css';
|
||||
|
||||
export interface KubeconfigPromptProps {
|
||||
envQueryParams: Query;
|
||||
onClose: () => void;
|
||||
}
|
||||
const storageKey = 'home_endpoints';
|
||||
|
||||
export function KubeconfigPrompt({
|
||||
envQueryParams,
|
||||
onClose,
|
||||
}: KubeconfigPromptProps) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageLimit, setPageLimit] = usePaginationLimitState(storageKey);
|
||||
|
||||
const expiryQuery = usePublicSettings({
|
||||
select: (settings) => expiryMessage(settings.KubeconfigExpiry),
|
||||
});
|
||||
|
||||
const { selection, toggle: toggleSelection, selectionSize } = useSelection();
|
||||
const { environments, totalCount } = useEnvironmentList({
|
||||
...envQueryParams,
|
||||
page,
|
||||
pageLimit,
|
||||
types: [
|
||||
EnvironmentType.KubernetesLocal,
|
||||
EnvironmentType.AgentOnKubernetes,
|
||||
EnvironmentType.EdgeAgentOnKubernetes,
|
||||
],
|
||||
});
|
||||
const isAllPageSelected = environments.every((env) => selection[env.Id]);
|
||||
|
||||
return (
|
||||
<DialogOverlay
|
||||
className={styles.dialog}
|
||||
aria-label="Kubeconfig View"
|
||||
role="dialog"
|
||||
>
|
||||
<div className="modal-dialog">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<button type="button" className="close" onClick={onClose}>
|
||||
<X />
|
||||
</button>
|
||||
<h5 className="modal-title">Download kubeconfig file</h5>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<form className="bootbox-form">
|
||||
<div className="bootbox-prompt-message">
|
||||
<span>
|
||||
Select the kubernetes environments to add to the kubeconfig
|
||||
file. You may select across multiple pages.
|
||||
</span>
|
||||
<span className="space-left">{expiryQuery.data}</span>
|
||||
</div>
|
||||
</form>
|
||||
<br />
|
||||
<div className="h-8 flex items-center">
|
||||
<Checkbox
|
||||
id="settings-container-truncate-name"
|
||||
label="Select all (in this page)"
|
||||
checked={isAllPageSelected}
|
||||
onChange={handleSelectAll}
|
||||
/>
|
||||
</div>
|
||||
<div className="datatable">
|
||||
<div className="bootbox-checkbox-list">
|
||||
{environments.map((env) => (
|
||||
<div
|
||||
key={env.Id}
|
||||
className={clsx(
|
||||
styles.checkbox,
|
||||
'h-8 flex items-center pt-1'
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id={`${env.Id}`}
|
||||
label={`${env.Name} (${env.URL})`}
|
||||
checked={!!selection[env.Id]}
|
||||
onChange={() =>
|
||||
toggleSelection(env.Id, !selection[env.Id])
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="pt-3 flex justify-end w-full">
|
||||
<PaginationControls
|
||||
showAll={totalCount <= 100}
|
||||
page={page}
|
||||
onPageChange={setPage}
|
||||
pageLimit={pageLimit}
|
||||
onPageLimitChange={setPageLimit}
|
||||
totalCount={totalCount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<Button onClick={onClose} color="default">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleDownload} disabled={selectionSize < 1}>
|
||||
Download File
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogOverlay>
|
||||
);
|
||||
|
||||
function handleSelectAll() {
|
||||
environments.forEach((env) => toggleSelection(env.Id, !isAllPageSelected));
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
confirmKubeconfigSelection();
|
||||
}
|
||||
|
||||
async function confirmKubeconfigSelection() {
|
||||
if (selectionSize === 0) {
|
||||
notifications.warning('No environment was selected', '');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await downloadKubeconfigFile(Object.keys(selection).map(Number));
|
||||
onClose();
|
||||
} catch (e) {
|
||||
notifications.error('Failed downloading kubeconfig file', e as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function expiryMessage(expiry: string) {
|
||||
const prefix = 'The kubeconfig file will';
|
||||
switch (expiry) {
|
||||
case '24h':
|
||||
return `${prefix} expire in 1 day.`;
|
||||
case '168h':
|
||||
return `${prefix} expire in 7 days.`;
|
||||
case '720h':
|
||||
return `${prefix} expire in 30 days.`;
|
||||
case '8640h':
|
||||
return `${prefix} expire in 1 year.`;
|
||||
case '0':
|
||||
default:
|
||||
return `${prefix} not expire.`;
|
||||
}
|
||||
}
|
|
@ -1,27 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
export function useSelection() {
|
||||
const [selection, setSelection] = useState<Record<EnvironmentId, boolean>>(
|
||||
{}
|
||||
);
|
||||
|
||||
const selectionSize = Object.keys(selection).length;
|
||||
|
||||
return { selection, toggle, selectionSize };
|
||||
|
||||
function toggle(id: EnvironmentId, selected: boolean) {
|
||||
setSelection((prevSelection) => {
|
||||
const newSelection = { ...prevSelection };
|
||||
|
||||
if (!selected) {
|
||||
delete newSelection[id];
|
||||
} else {
|
||||
newSelection[id] = true;
|
||||
}
|
||||
|
||||
return newSelection;
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
export { KubeconfigButton } from './KubeconfigButton';
|
|
@ -1,24 +0,0 @@
|
|||
import { InformationPanel } from '@@/InformationPanel';
|
||||
import { Link } from '@@/Link';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
export function NoEnvironmentsInfoPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
return (
|
||||
<InformationPanel title="Information">
|
||||
<TextTip>
|
||||
{isAdmin ? (
|
||||
<span>
|
||||
No environment available for management. Please head over the{' '}
|
||||
<Link to="portainer.wizard.endpoints">environment wizard</Link> to
|
||||
add an environment.
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
You do not have access to any environment. Please contact your
|
||||
administrator.
|
||||
</span>
|
||||
)}
|
||||
</TextTip>
|
||||
</InformationPanel>
|
||||
);
|
||||
}
|
|
@ -1,10 +0,0 @@
|
|||
import { react2angular } from '@/react-tools/react2angular';
|
||||
|
||||
import { EnvironmentList } from './EnvironmentList';
|
||||
|
||||
export { EnvironmentList };
|
||||
|
||||
export const EnvironmentListAngular = react2angular(EnvironmentList, [
|
||||
'onClickItem',
|
||||
'onRefresh',
|
||||
]);
|
|
@ -1,87 +0,0 @@
|
|||
import { useRouter } from '@uirouter/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Environment } from '@/react/portainer/environments/types';
|
||||
import { snapshotEndpoints } from '@/react/portainer/environments/environment.service';
|
||||
import { isEdgeEnvironment } from '@/react/portainer/environments/utils';
|
||||
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
|
||||
import * as notifications from '../services/notifications';
|
||||
import { confirmAsync } from '../services/modal.service/confirm';
|
||||
import { buildTitle } from '../services/modal.service/utils';
|
||||
|
||||
import { EnvironmentList } from './EnvironmentList';
|
||||
import { EdgeLoadingSpinner } from './EdgeLoadingSpinner';
|
||||
import { MotdPanel } from './MotdPanel';
|
||||
import { LicenseNodePanel } from './LicenseNodePanel';
|
||||
import { BackupFailedPanel } from './BackupFailedPanel';
|
||||
|
||||
export function HomeView() {
|
||||
const [connectingToEdgeEndpoint, setConnectingToEdgeEndpoint] =
|
||||
useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
reload
|
||||
title="Home"
|
||||
breadcrumbs={[{ label: 'Environments' }]}
|
||||
/>
|
||||
|
||||
{process.env.PORTAINER_EDITION !== 'CE' && <LicenseNodePanel />}
|
||||
|
||||
<MotdPanel />
|
||||
|
||||
{process.env.PORTAINER_EDITION !== 'CE' && <BackupFailedPanel />}
|
||||
|
||||
{connectingToEdgeEndpoint ? (
|
||||
<EdgeLoadingSpinner />
|
||||
) : (
|
||||
<EnvironmentList
|
||||
onClickItem={handleClickItem}
|
||||
onRefresh={confirmTriggerSnapshot}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
async function confirmTriggerSnapshot() {
|
||||
const result = await confirmEndpointSnapshot();
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await snapshotEndpoints();
|
||||
notifications.success('Success', 'Environments updated');
|
||||
router.stateService.reload();
|
||||
} catch (err) {
|
||||
notifications.error(
|
||||
'Failure',
|
||||
err as Error,
|
||||
'An error occurred during environment snapshot'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickItem(environment: Environment) {
|
||||
if (isEdgeEnvironment(environment.Type)) {
|
||||
setConnectingToEdgeEndpoint(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmEndpointSnapshot() {
|
||||
return confirmAsync({
|
||||
title: buildTitle('Are you sure?'),
|
||||
message:
|
||||
'Triggering a manual refresh will poll each environment to retrieve its information, this may take a few moments.',
|
||||
buttons: {
|
||||
confirm: {
|
||||
label: 'Continue',
|
||||
className: 'btn-primary',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
|
@ -1,59 +0,0 @@
|
|||
import { components, OptionProps } from 'react-select';
|
||||
|
||||
import { useLocalStorage } from '@/portainer/hooks/useLocalStorage';
|
||||
import { Filter } from '@/portainer/home/types';
|
||||
|
||||
import { Select } from '@@/form-components/ReactSelect';
|
||||
|
||||
interface Props<TValue = number> {
|
||||
filterOptions?: Filter<TValue>[];
|
||||
onChange: (filterOptions: Filter<TValue>[]) => void;
|
||||
placeHolder: string;
|
||||
value: Filter<TValue>[];
|
||||
}
|
||||
|
||||
function Option<TValue = number>(props: OptionProps<Filter<TValue>, true>) {
|
||||
const { isSelected, label } = props;
|
||||
return (
|
||||
<div>
|
||||
<components.Option
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...props}
|
||||
>
|
||||
<input type="checkbox" checked={isSelected} onChange={() => null} />{' '}
|
||||
<label>{label}</label>
|
||||
</components.Option>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomepageFilter<TValue = number>({
|
||||
filterOptions = [],
|
||||
onChange,
|
||||
placeHolder,
|
||||
value,
|
||||
}: Props<TValue>) {
|
||||
return (
|
||||
<Select
|
||||
closeMenuOnSelect={false}
|
||||
placeholder={placeHolder}
|
||||
options={filterOptions}
|
||||
value={value}
|
||||
isMulti
|
||||
components={{ Option }}
|
||||
onChange={(option) => onChange([...option])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function useHomePageFilter<T>(
|
||||
key: string,
|
||||
defaultValue: T
|
||||
): [T, (value: T) => void] {
|
||||
const filterKey = keyBuilder(key);
|
||||
return useLocalStorage(filterKey, defaultValue, sessionStorage);
|
||||
}
|
||||
|
||||
function keyBuilder(key: string) {
|
||||
return `datatable_home_filter_type_${key}`;
|
||||
}
|
|
@ -1,48 +0,0 @@
|
|||
import { server, rest } from '@/setup-tests/server';
|
||||
import { renderWithQueryClient } from '@/react-tools/test-utils';
|
||||
|
||||
import { LicenseType } from '../license-management/types';
|
||||
|
||||
import { LicenseNodePanel } from './LicenseNodePanel';
|
||||
|
||||
test('when user is using more nodes then allowed he should see message', async () => {
|
||||
const allowed = 2;
|
||||
const used = 5;
|
||||
server.use(
|
||||
rest.get('/api/licenses/info', (req, res, ctx) =>
|
||||
res(ctx.json({ nodes: allowed, type: LicenseType.Subscription }))
|
||||
),
|
||||
rest.get('/api/status/nodes', (req, res, ctx) =>
|
||||
res(ctx.json({ nodes: used }))
|
||||
)
|
||||
);
|
||||
|
||||
const { findByText } = renderWithQueryClient(<LicenseNodePanel />);
|
||||
|
||||
await expect(
|
||||
findByText(
|
||||
/The number of nodes for your license has been exceeded. Please contact your administrator./
|
||||
)
|
||||
).resolves.toBeVisible();
|
||||
});
|
||||
|
||||
test("when user is using less nodes then allowed he shouldn't see message", async () => {
|
||||
const allowed = 5;
|
||||
const used = 2;
|
||||
server.use(
|
||||
rest.get('/api/licenses/info', (req, res, ctx) =>
|
||||
res(ctx.json({ nodes: allowed, type: LicenseType.Subscription }))
|
||||
),
|
||||
rest.get('/api/status/nodes', (req, res, ctx) =>
|
||||
res(ctx.json({ nodes: used }))
|
||||
)
|
||||
);
|
||||
|
||||
const { findByText } = renderWithQueryClient(<LicenseNodePanel />);
|
||||
|
||||
await expect(
|
||||
findByText(
|
||||
/The number of nodes for your license has been exceeded. Please contact your administrator./
|
||||
)
|
||||
).rejects.toBeTruthy();
|
||||
});
|
|
@ -1,57 +0,0 @@
|
|||
import { useQuery } from 'react-query';
|
||||
|
||||
import { error as notifyError } from '@/portainer/services/notifications';
|
||||
|
||||
import { InformationPanel } from '@@/InformationPanel';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { LicenseType } from '../license-management/types';
|
||||
import { useLicenseInfo } from '../license-management/use-license.service';
|
||||
import { getNodesCount } from '../services/api/status.service';
|
||||
|
||||
export function LicenseNodePanel() {
|
||||
const nodesValid = useNodesValid();
|
||||
|
||||
if (nodesValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<InformationPanel title="License node allowance exceeded">
|
||||
<TextTip>
|
||||
The number of nodes for your license has been exceeded. Please contact
|
||||
your administrator.
|
||||
</TextTip>
|
||||
</InformationPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function useNodesValid() {
|
||||
const { isLoading: isLoadingNodes, nodesCount } = useNodesCounts();
|
||||
|
||||
const { isLoading: isLoadingLicense, info } = useLicenseInfo();
|
||||
if (
|
||||
isLoadingLicense ||
|
||||
isLoadingNodes ||
|
||||
!info ||
|
||||
info.type === LicenseType.Trial
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return nodesCount <= info.nodes;
|
||||
}
|
||||
|
||||
function useNodesCounts() {
|
||||
const { isLoading, data } = useQuery(
|
||||
['status', 'nodes'],
|
||||
() => getNodesCount(),
|
||||
{
|
||||
onError(error) {
|
||||
notifyError('Failure', error as Error, 'Failed to get nodes count');
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return { nodesCount: data || 0, isLoading };
|
||||
}
|
|
@ -1,57 +0,0 @@
|
|||
import { useQuery } from 'react-query';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { useUIState } from '@/portainer/hooks/useUIState';
|
||||
|
||||
import { InformationPanel } from '@@/InformationPanel';
|
||||
|
||||
import { getMotd } from './home.service';
|
||||
|
||||
export function MotdPanel() {
|
||||
const motd = useMotd();
|
||||
|
||||
const uiStateStore = useUIState();
|
||||
|
||||
if (
|
||||
!motd ||
|
||||
motd.Message === '' ||
|
||||
motd.Hash === uiStateStore.dismissedInfoHash
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!!motd.Style && <style>{motd.Style}</style>}
|
||||
<InformationPanel
|
||||
onDismiss={() => onDismiss(motd.Hash)}
|
||||
title={motd.Title}
|
||||
wrapperStyle={camelCaseKeys(motd.ContentLayout)}
|
||||
bodyClassName="motd-body"
|
||||
>
|
||||
<span className="text-muted">
|
||||
{/* eslint-disable-next-line react/no-danger */}
|
||||
<p dangerouslySetInnerHTML={{ __html: motd.Message }} />
|
||||
</span>
|
||||
</InformationPanel>
|
||||
</>
|
||||
);
|
||||
|
||||
function onDismiss(hash: string) {
|
||||
uiStateStore.dismissMotd(hash);
|
||||
}
|
||||
}
|
||||
|
||||
function useMotd() {
|
||||
const { data } = useQuery('motd', () => getMotd());
|
||||
return data;
|
||||
}
|
||||
|
||||
function camelCaseKeys(obj: Record<string, string> = {}) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).map(([key, value]) => {
|
||||
const camelCased = _.camelCase(key);
|
||||
return [camelCased, value];
|
||||
})
|
||||
);
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
import axios, { parseAxiosError } from '../services/axios';
|
||||
|
||||
import { Motd } from './types';
|
||||
|
||||
export async function getMotd() {
|
||||
try {
|
||||
const { data } = await axios.get<Motd>('/motd');
|
||||
return data;
|
||||
} catch (err) {
|
||||
throw parseAxiosError(
|
||||
err as Error,
|
||||
'Unable to retrieve information message'
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
export { HomeView } from './HomeView';
|
|
@ -1,12 +0,0 @@
|
|||
export interface Motd {
|
||||
Title: string;
|
||||
Message: string;
|
||||
Hash: string;
|
||||
Style?: string;
|
||||
ContentLayout?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface Filter<T = number> {
|
||||
value: T;
|
||||
label: string;
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
import { useEnvironment } from '@/react/portainer/environments/queries/useEnvironment';
|
||||
|
||||
import { useEnvironmentId } from './useEnvironmentId';
|
||||
|
||||
export function useCurrentEnvironment() {
|
||||
const id = useEnvironmentId();
|
||||
return useEnvironment(id);
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useDebounce<T>(value: T, delay = 500): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedValue(value), delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
|
||||
export function useEnvironmentId() {
|
||||
const {
|
||||
params: { endpointId: environmentId },
|
||||
} = useCurrentStateAndParams();
|
||||
|
||||
if (!environmentId) {
|
||||
throw new Error('endpointId url param is required');
|
||||
}
|
||||
|
||||
return environmentId;
|
||||
}
|
|
@ -1,46 +0,0 @@
|
|||
import { useState, useCallback, useMemo } from 'react';
|
||||
|
||||
const localStoragePrefix = 'portainer';
|
||||
|
||||
export function keyBuilder(key: string) {
|
||||
return `${localStoragePrefix}.${key}`;
|
||||
}
|
||||
|
||||
export function useLocalStorage<T>(
|
||||
key: string,
|
||||
defaultValue: T,
|
||||
storage = localStorage
|
||||
): [T, (value: T) => void] {
|
||||
const [value, setValue] = useState(get<T>(key, defaultValue, storage));
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value) => {
|
||||
setValue(value);
|
||||
set<T>(key, value, storage);
|
||||
},
|
||||
[key, storage]
|
||||
);
|
||||
|
||||
return useMemo(() => [value, handleChange], [value, handleChange]);
|
||||
}
|
||||
|
||||
export function get<T>(
|
||||
key: string,
|
||||
defaultValue: T,
|
||||
storage = localStorage
|
||||
): T {
|
||||
const value = storage.getItem(keyBuilder(key));
|
||||
if (!value) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
export function set<T>(key: string, value: T, storage = localStorage) {
|
||||
storage.setItem(keyBuilder(key), JSON.stringify(value));
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
import { useLocalStorage } from './useLocalStorage';
|
||||
|
||||
export function usePaginationLimitState(
|
||||
key: string
|
||||
): [number, (value: number) => void] {
|
||||
const paginationKey = paginationKeyBuilder(key);
|
||||
const [pageLimit, setPageLimit] = useLocalStorage(paginationKey, 10);
|
||||
|
||||
return [pageLimit, setPageLimit];
|
||||
|
||||
function paginationKeyBuilder(key: string) {
|
||||
return `datatable_pagination_${key}`;
|
||||
}
|
||||
}
|
|
@ -1,37 +0,0 @@
|
|||
import create from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
import { keyBuilder } from '@/portainer/hooks/useLocalStorage';
|
||||
|
||||
interface UIState {
|
||||
dismissedInfoPanels: Record<string, boolean>;
|
||||
dismissInfoPanel(id: string): void;
|
||||
|
||||
dismissedInfoHash: string;
|
||||
dismissMotd(hash: string): void;
|
||||
|
||||
dismissedUpdateVersion: string;
|
||||
dismissUpdateVersion(version: string): void;
|
||||
}
|
||||
|
||||
export const useUIState = create<UIState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
dismissedInfoPanels: {},
|
||||
dismissInfoPanel(id: string) {
|
||||
set((state) => ({
|
||||
dismissedInfoPanels: { ...state.dismissedInfoPanels, [id]: true },
|
||||
}));
|
||||
},
|
||||
dismissedInfoHash: '',
|
||||
dismissMotd(hash: string) {
|
||||
set({ dismissedInfoHash: hash });
|
||||
},
|
||||
dismissedUpdateVersion: '',
|
||||
dismissUpdateVersion(version: string) {
|
||||
set({ dismissedUpdateVersion: version });
|
||||
},
|
||||
}),
|
||||
{ name: keyBuilder('NEW_UI_STATE') }
|
||||
)
|
||||
);
|
|
@ -1,181 +0,0 @@
|
|||
import jwtDecode from 'jwt-decode';
|
||||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
import {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
useMemo,
|
||||
PropsWithChildren,
|
||||
} from 'react';
|
||||
|
||||
import { isAdmin } from '@/portainer/users/user.helpers';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { getUser } from '../users/user.service';
|
||||
import { User, UserId } from '../users/types';
|
||||
|
||||
import { useLocalStorage } from './useLocalStorage';
|
||||
|
||||
interface State {
|
||||
user?: User;
|
||||
}
|
||||
|
||||
export const UserContext = createContext<State | null>(null);
|
||||
UserContext.displayName = 'UserContext';
|
||||
|
||||
export function useUser() {
|
||||
const context = useContext(UserContext);
|
||||
|
||||
if (context === null) {
|
||||
throw new Error('should be nested under UserProvider');
|
||||
}
|
||||
|
||||
const { user } = context;
|
||||
if (typeof user === 'undefined') {
|
||||
throw new Error('should be authenticated');
|
||||
}
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
user,
|
||||
isAdmin: isAdmin(user),
|
||||
}),
|
||||
[user]
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuthorizations(
|
||||
authorizations: string | string[],
|
||||
forceEnvironmentId?: EnvironmentId,
|
||||
adminOnlyCE = false
|
||||
) {
|
||||
const { user } = useUser();
|
||||
const {
|
||||
params: { endpointId },
|
||||
} = useCurrentStateAndParams();
|
||||
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hasAuthorizations(
|
||||
user,
|
||||
authorizations,
|
||||
forceEnvironmentId || endpointId,
|
||||
adminOnlyCE
|
||||
);
|
||||
}
|
||||
|
||||
export function isEnvironmentAdmin(
|
||||
user: User,
|
||||
environmentId: EnvironmentId,
|
||||
adminOnlyCE = true
|
||||
) {
|
||||
return hasAuthorizations(
|
||||
user,
|
||||
['EndpointResourcesAccess'],
|
||||
environmentId,
|
||||
adminOnlyCE
|
||||
);
|
||||
}
|
||||
|
||||
export function hasAuthorizations(
|
||||
user: User,
|
||||
authorizations: string | string[],
|
||||
environmentId?: EnvironmentId,
|
||||
adminOnlyCE = false
|
||||
) {
|
||||
const authorizationsArray =
|
||||
typeof authorizations === 'string' ? [authorizations] : authorizations;
|
||||
|
||||
if (authorizationsArray.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (process.env.PORTAINER_EDITION === 'CE') {
|
||||
return !adminOnlyCE || isAdmin(user);
|
||||
}
|
||||
|
||||
if (!environmentId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isAdmin(user)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
!user.EndpointAuthorizations ||
|
||||
!user.EndpointAuthorizations[environmentId]
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const userEndpointAuthorizations = user.EndpointAuthorizations[environmentId];
|
||||
return authorizationsArray.some(
|
||||
(authorization) => userEndpointAuthorizations[authorization]
|
||||
);
|
||||
}
|
||||
|
||||
interface AuthorizedProps {
|
||||
authorizations: string | string[];
|
||||
environmentId?: EnvironmentId;
|
||||
adminOnlyCE?: boolean;
|
||||
childrenUnauthorized?: ReactNode;
|
||||
}
|
||||
|
||||
export function Authorized({
|
||||
authorizations,
|
||||
environmentId,
|
||||
adminOnlyCE = false,
|
||||
children,
|
||||
childrenUnauthorized = null,
|
||||
}: PropsWithChildren<AuthorizedProps>) {
|
||||
const isAllowed = useAuthorizations(
|
||||
authorizations,
|
||||
environmentId,
|
||||
adminOnlyCE
|
||||
);
|
||||
|
||||
return isAllowed ? <>{children}</> : <>{childrenUnauthorized}</>;
|
||||
}
|
||||
|
||||
interface UserProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function UserProvider({ children }: UserProviderProps) {
|
||||
const [jwt] = useLocalStorage('JWT', '');
|
||||
const [user, setUser] = useState<User>();
|
||||
|
||||
useEffect(() => {
|
||||
if (jwt !== '') {
|
||||
const tokenPayload = jwtDecode(jwt) as { id: number };
|
||||
|
||||
loadUser(tokenPayload.id);
|
||||
}
|
||||
}, [jwt]);
|
||||
|
||||
const providerState = useMemo(() => ({ user }), [user]);
|
||||
|
||||
if (jwt === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!providerState.user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<UserContext.Provider value={providerState}>
|
||||
{children}
|
||||
</UserContext.Provider>
|
||||
);
|
||||
|
||||
async function loadUser(id: UserId) {
|
||||
const user = await getUser(id);
|
||||
setUser(user);
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
import { Edit } from 'react-feather';
|
||||
|
||||
import { FeatureId } from '@/portainer/feature-flags/enums';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
import Microsoft from '@/assets/ico/vendor/microsoft.svg?c';
|
||||
import Google from '@/assets/ico/vendor/google.svg?c';
|
||||
import Github from '@/assets/ico/vendor/github.svg?c';
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { baseHref } from '@/portainer/helpers/pathHelper';
|
||||
import { isLimitedToBE } from '@/portainer/feature-flags/feature-flags.service';
|
||||
import { FeatureId } from '@/portainer/feature-flags/enums';
|
||||
import { isLimitedToBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
import providers, { getProviderByUrl } from './providers';
|
||||
|
||||
const MS_TENANT_ID_PLACEHOLDER = 'TENANT_ID';
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import _ from 'lodash-es';
|
||||
import { isLimitedToBE } from '@/portainer/feature-flags/feature-flags.service';
|
||||
import { isLimitedToBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
|
||||
import { getEnvironments } from '@/react/portainer/environments/environment.service';
|
||||
import AccessViewerPolicyModel from '../../models/access';
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import angular from 'angular';
|
||||
|
||||
import { HomeView } from '@/portainer/home';
|
||||
import { HomeView } from '@/react/portainer/HomeView';
|
||||
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||
import { r2a } from '@/react-tools/react2angular';
|
||||
import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||
|
|
|
@ -3,7 +3,7 @@ import { loadProgressBar } from 'axios-progress-bar';
|
|||
|
||||
import 'axios-progress-bar/dist/nprogress.css';
|
||||
import PortainerError from '@/portainer/error';
|
||||
import { get as localStorageGet } from '@/portainer/hooks/useLocalStorage';
|
||||
import { get as localStorageGet } from '@/react/hooks/useLocalStorage';
|
||||
|
||||
import {
|
||||
portainerAgentManagerOperation,
|
||||
|
|
|
@ -4,7 +4,7 @@ import sanitize from 'sanitize-html';
|
|||
import jwtDecode from 'jwt-decode';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { get as localStorageGet } from '@/portainer/hooks/useLocalStorage';
|
||||
import { get as localStorageGet } from '@/react/hooks/useLocalStorage';
|
||||
import { notificationsStore } from '@/react/portainer/notifications/notifications-store';
|
||||
import { ToastNotification } from '@/react/portainer/notifications/types';
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import _ from 'lodash-es';
|
||||
|
||||
import { FeatureId } from '@/portainer/feature-flags/enums';
|
||||
import { isLimitedToBE } from '@/portainer/feature-flags/feature-flags.service';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
import { isLimitedToBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
|
||||
export default class AdSettingsController {
|
||||
/* @ngInject */
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { FeatureId } from '@/portainer/feature-flags/enums';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
|
||||
export default class LdapSettingsCustomController {
|
||||
constructor() {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { FeatureId } from '@/portainer/feature-flags/enums';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
|
||||
export default class LdapSettingsOpenLDAPController {
|
||||
/* @ngInject */
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Edit } from 'react-feather';
|
||||
|
||||
import { FeatureId } from '@/portainer/feature-flags/enums';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
import Openldap from '@/assets/ico/vendor/openldap.svg?c';
|
||||
|
||||
import { BadgeIcon } from '@@/BadgeIcon';
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import moment from 'moment';
|
||||
|
||||
import { FeatureId } from '@/portainer/feature-flags/enums';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
export default class ActivityLogsViewController {
|
||||
/* @ngInject */
|
||||
constructor($async, $scope, Notifications) {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import moment from 'moment';
|
||||
|
||||
import { FeatureId } from '@/portainer/feature-flags/enums';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
|
||||
export default class AuthLogsViewController {
|
||||
/* @ngInject */
|
||||
|
|
|
@ -2,7 +2,7 @@ import _ from 'lodash';
|
|||
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
|
||||
import { TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants';
|
||||
import { getTemplateVariables, intersectVariables } from '@/react/portainer/custom-templates/components/utils';
|
||||
import { isBE } from '@/portainer/feature-flags/feature-flags.service';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
|
||||
class CreateCustomTemplateViewController {
|
||||
/* @ngInject */
|
||||
|
|
|
@ -2,7 +2,7 @@ import _ from 'lodash-es';
|
|||
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
|
||||
import { TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants';
|
||||
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
|
||||
import { isBE } from '@/portainer/feature-flags/feature-flags.service';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
|
||||
class CustomTemplatesViewController {
|
||||
/* @ngInject */
|
||||
|
|
|
@ -3,7 +3,7 @@ import { ResourceControlViewModel } from '@/react/portainer/access-control/model
|
|||
|
||||
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
|
||||
import { getTemplateVariables, intersectVariables } from '@/react/portainer/custom-templates/components/utils';
|
||||
import { isBE } from '@/portainer/feature-flags/feature-flags.service';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
|
||||
class EditCustomTemplateViewController {
|
||||
/* @ngInject */
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import angular from 'angular';
|
||||
|
||||
import { FeatureId } from '@/portainer/feature-flags/enums';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
|
||||
class EndpointAccessController {
|
||||
/* @ngInject */
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { FeatureId } from '@/portainer/feature-flags/enums';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
|
||||
angular.module('portainer.app').controller('GroupAccessController', [
|
||||
'$scope',
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { ArrowDownCircle } from 'react-feather';
|
||||
|
||||
import { FeatureId } from '@/portainer/feature-flags/enums';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
import Microsoft from '@/assets/ico/vendor/microsoft.svg?c';
|
||||
import Ldap from '@/assets/ico/ldap.svg?c';
|
||||
import OAuth from '@/assets/ico/oauth.svg?c';
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { DownloadCloud, UploadCloud } from 'react-feather';
|
||||
|
||||
import { FeatureId } from '@/portainer/feature-flags/enums';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
|
||||
import { BadgeIcon } from '@@/BadgeIcon';
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import angular from 'angular';
|
||||
|
||||
import { FeatureId } from '@/portainer/feature-flags/enums';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
import { options } from './options';
|
||||
|
||||
angular.module('portainer.app').controller('SettingsController', [
|
||||
|
|
|
@ -4,8 +4,8 @@ import uuidv4 from 'uuid/v4';
|
|||
import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel';
|
||||
import { STACK_NAME_VALIDATION_REGEX } from '@/constants';
|
||||
import { RepositoryMechanismTypes } from '@/kubernetes/models/deploy';
|
||||
import { FeatureId } from 'Portainer/feature-flags/enums';
|
||||
import { isBE } from '@/portainer/feature-flags/feature-flags.service';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
|
||||
|
||||
angular
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { ResourceControlType } from '@/react/portainer/access-control/types';
|
||||
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
|
||||
import { FeatureId } from 'Portainer/feature-flags/enums';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
import { getEnvironments } from '@/react/portainer/environments/environment.service';
|
||||
import { StackStatus, StackType } from '@/react/docker/stacks/types';
|
||||
import { extractContainerNames } from '@/portainer/helpers/stackHelper';
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue