mirror of
https://github.com/portainer/portainer.git
synced 2025-08-02 20:35:25 +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,5 +1,5 @@
|
|||
import { renderWithQueryClient, within } from '@/react-tools/test-utils';
|
||||
import { UserContext } from '@/portainer/hooks/useUser';
|
||||
import { UserContext } from '@/react/hooks/useUser';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
import { server, rest } from '@/setup-tests/server';
|
||||
import {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Package } from 'react-feather';
|
||||
|
||||
import { useEnvironmentId } from '@/portainer/hooks/useEnvironmentId';
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
import { DashboardItem } from '@@/DashboardItem';
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { UserContext } from '@/portainer/hooks/useUser';
|
||||
import { UserContext } from '@/react/hooks/useUser';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
import { renderWithQueryClient } from '@/react-tools/test-utils';
|
||||
|
||||
|
|
|
@ -3,9 +3,9 @@ import { useRouter } from '@uirouter/react';
|
|||
|
||||
import { ContainerInstanceFormValues } from '@/react/azure/types';
|
||||
import * as notifications from '@/portainer/services/notifications';
|
||||
import { useUser } from '@/portainer/hooks/useUser';
|
||||
import { useUser } from '@/react/hooks/useUser';
|
||||
import { AccessControlForm } from '@/react/portainer/access-control/AccessControlForm';
|
||||
import { useEnvironmentId } from '@/portainer/hooks/useEnvironmentId';
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Input, Select } from '@@/form-components/Input';
|
||||
|
|
|
@ -6,7 +6,7 @@ import {
|
|||
Subscription,
|
||||
} from '@/react/azure/types';
|
||||
import { parseAccessControlFormData } from '@/react/portainer/access-control/utils';
|
||||
import { useUser } from '@/portainer/hooks/useUser';
|
||||
import { useUser } from '@/react/hooks/useUser';
|
||||
import { useProvider } from '@/react/azure/queries/useProvider';
|
||||
import { useResourceGroups } from '@/react/azure/queries/useResourceGroups';
|
||||
import { useSubscriptions } from '@/react/azure/queries/useSubscriptions';
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
|
||||
import { useEnvironmentId } from '@/portainer/hooks/useEnvironmentId';
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { AccessControlPanel } from '@/react/portainer/access-control/AccessControlPanel/AccessControlPanel';
|
||||
import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel';
|
||||
import { ResourceControlType } from '@/react/portainer/access-control/types';
|
||||
|
|
|
@ -8,9 +8,9 @@ import {
|
|||
import { useRowSelectColumn } from '@lineup-lite/hooks';
|
||||
import { Box, Plus, Trash2 } from 'react-feather';
|
||||
|
||||
import { useDebounce } from '@/portainer/hooks/useDebounce';
|
||||
import { useDebounce } from '@/react/hooks/useDebounce';
|
||||
import { ContainerGroup } from '@/react/azure/types';
|
||||
import { Authorized } from '@/portainer/hooks/useUser';
|
||||
import { Authorized } from '@/react/hooks/useUser';
|
||||
import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm';
|
||||
|
||||
import { PaginationControls } from '@@/PaginationControls';
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { useMutation, useQueryClient } from 'react-query';
|
||||
|
||||
import { deleteContainerGroup } from '@/react/azure/services/container-groups.service';
|
||||
import { useEnvironmentId } from '@/portainer/hooks/useEnvironmentId';
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { promiseSequence } from '@/portainer/helpers/promise-utils';
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Meta } from '@storybook/react';
|
||||
|
||||
import { Edition, FeatureId } from '@/portainer/feature-flags/enums';
|
||||
import { init as initFeatureService } from '@/portainer/feature-flags/feature-flags.service';
|
||||
import { Edition, FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
import { init as initFeatureService } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
|
||||
import { BEFeatureIndicator, Props } from './BEFeatureIndicator';
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ import { Briefcase } from 'react-feather';
|
|||
|
||||
import './BEFeatureIndicator.css';
|
||||
|
||||
import { FeatureId } from '@/portainer/feature-flags/enums';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
|
||||
import { getFeatureDetails } from './utils';
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
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';
|
||||
|
||||
const BE_URL = 'https://www.portainer.io/business-upsell?from=';
|
||||
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { Meta } from '@storybook/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { init as initFeatureService } from '@/portainer/feature-flags/feature-flags.service';
|
||||
import { Edition, FeatureId } from '@/portainer/feature-flags/enums';
|
||||
import { init as initFeatureService } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
import { Edition, FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
|
||||
import { BoxSelector } from './BoxSelector';
|
||||
import { BoxSelectorOption } from './types';
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Meta } from '@storybook/react';
|
||||
|
||||
import { init as initFeatureService } from '@/portainer/feature-flags/feature-flags.service';
|
||||
import { Edition, FeatureId } from '@/portainer/feature-flags/enums';
|
||||
import { init as initFeatureService } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
import { Edition, FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
|
||||
import { BoxSelectorItem } from './BoxSelectorItem';
|
||||
import { BoxSelectorOption } from './types';
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import clsx from 'clsx';
|
||||
|
||||
import { isLimitedToBE } from '@/portainer/feature-flags/feature-flags.service';
|
||||
import { isLimitedToBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
import { Icon } from '@/react/components/Icon';
|
||||
|
||||
import './BoxSelectorItem.css';
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import ReactTooltip from 'react-tooltip';
|
||||
import { HelpCircle } from 'react-feather';
|
||||
|
||||
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 type { FeatureId } from '@/portainer/feature-flags/enums';
|
||||
import type { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
|
||||
import { IconProps } from '@@/Icon';
|
||||
|
||||
|
|
44
app/react/components/EdgeIndicator.test.tsx
Normal file
44
app/react/components/EdgeIndicator.test.tsx
Normal file
|
@ -0,0 +1,44 @@
|
|||
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;
|
||||
}
|
114
app/react/components/EdgeIndicator.tsx
Normal file
114
app/react/components/EdgeIndicator.tsx
Normal file
|
@ -0,0 +1,114 @@
|
|||
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,7 +1,7 @@
|
|||
import { Meta, Story } from '@storybook/react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { UserContext } from '@/portainer/hooks/useUser';
|
||||
import { UserContext } from '@/react/hooks/useUser';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
|
||||
import { HeaderContainer } from './HeaderContainer';
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { UserContext } from '@/portainer/hooks/useUser';
|
||||
import { UserContext } from '@/react/hooks/useUser';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
import { render } from '@/react-tools/test-utils';
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ import { useEffect, useState } from 'react';
|
|||
import { useStore } from 'zustand';
|
||||
|
||||
import { AutomationTestingProps } from '@/types';
|
||||
import { useUser } from '@/portainer/hooks/useUser';
|
||||
import { useUser } from '@/react/hooks/useUser';
|
||||
import { ToastNotification } from '@/react/portainer/notifications/types';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Meta, Story } from '@storybook/react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { UserContext } from '@/portainer/hooks/useUser';
|
||||
import { UserContext } from '@/react/hooks/useUser';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
|
||||
import { PageHeader } from './PageHeader';
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { UserContext } from '@/portainer/hooks/useUser';
|
||||
import { UserContext } from '@/react/hooks/useUser';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
import { render } from '@/react-tools/test-utils';
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ import clsx from 'clsx';
|
|||
import { User, ChevronDown } from 'react-feather';
|
||||
|
||||
import { AutomationTestingProps } from '@/types';
|
||||
import { useUser } from '@/portainer/hooks/useUser';
|
||||
import { useUser } from '@/react/hooks/useUser';
|
||||
|
||||
import styles from './HeaderTitle.module.css';
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Search } from 'react-feather';
|
||||
|
||||
import { useLocalStorage } from '@/portainer/hooks/useLocalStorage';
|
||||
import { useLocalStorage } from '@/react/hooks/useLocalStorage';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
|
|
|
@ -2,7 +2,7 @@ import { Search } from 'react-feather';
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { useLocalStorage } from '@/portainer/hooks/useLocalStorage';
|
||||
import { useLocalStorage } from '@/react/hooks/useLocalStorage';
|
||||
import { AutomationTestingProps } from '@/types';
|
||||
|
||||
interface Props extends AutomationTestingProps {
|
||||
|
|
|
@ -8,7 +8,7 @@ import {
|
|||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { useLocalStorage } from '@/portainer/hooks/useLocalStorage';
|
||||
import { useLocalStorage } from '@/react/hooks/useLocalStorage';
|
||||
|
||||
interface TableSettingsContextInterface<T> {
|
||||
settings: T;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import clsx from 'clsx';
|
||||
|
||||
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 { BEFeatureIndicator } from '@@/BEFeatureIndicator';
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import clsx from 'clsx';
|
||||
|
||||
import { FeatureId } from '@/portainer/feature-flags/enums';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
|
||||
import { Tooltip } from '@@/Tip/Tooltip';
|
||||
|
||||
|
|
24
app/react/components/form-components/yup-file-validation.ts
Normal file
24
app/react/components/form-components/yup-file-validation.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
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;
|
||||
}
|
||||
}
|
|
@ -10,7 +10,7 @@ import {
|
|||
} from 'react-feather';
|
||||
|
||||
import * as notifications from '@/portainer/services/notifications';
|
||||
import { useAuthorizations, Authorized } from '@/portainer/hooks/useUser';
|
||||
import { useAuthorizations, Authorized } from '@/react/hooks/useUser';
|
||||
import { confirmContainerDeletion } from '@/portainer/services/modal.service/prompt';
|
||||
import { setPortainerAgentTargetHeader } from '@/portainer/services/http-request.helper';
|
||||
import {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { CellProps, Column } from 'react-table';
|
||||
|
||||
import type { DockerContainer } from '@/react/docker/containers/types';
|
||||
import { useEnvironmentId } from '@/portainer/hooks/useEnvironmentId';
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { useContainerGpus } from '@/react/docker/containers/queries/gpus';
|
||||
|
||||
export const gpus: Column<DockerContainer> = {
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { CellProps, Column } from 'react-table';
|
||||
|
||||
import { useAuthorizations } from '@/portainer/hooks/useUser';
|
||||
import { useAuthorizations } from '@/react/hooks/useUser';
|
||||
import { isOfflineEndpoint } from '@/portainer/helpers/endpointHelper';
|
||||
import { useCurrentEnvironment } from '@/portainer/hooks/useCurrentEnvironment';
|
||||
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
|
||||
import { ContainerQuickActions } from '@/react/docker/containers/components/ContainerQuickActions';
|
||||
import { DockerContainer } from '@/react/docker/containers/types';
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import create from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
import { keyBuilder } from '@/portainer/hooks/useLocalStorage';
|
||||
import { keyBuilder } from '@/react/hooks/useLocalStorage';
|
||||
import {
|
||||
paginationSettings,
|
||||
sortableSettings,
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import clsx from 'clsx';
|
||||
|
||||
import { ContainerStatus } from '@/react/docker/containers/types';
|
||||
import { Authorized } from '@/portainer/hooks/useUser';
|
||||
import { Authorized } from '@/react/hooks/useUser';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
import { Link } from '@@/Link';
|
||||
|
|
|
@ -3,7 +3,7 @@ import { useRouter, useCurrentStateAndParams } from '@uirouter/react';
|
|||
import { useQueryClient } from 'react-query';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { useEnvironmentId } from '@/portainer/hooks/useEnvironmentId';
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm';
|
||||
import { AccessControlPanel } from '@/react/portainer/access-control/AccessControlPanel/AccessControlPanel';
|
||||
import { ResourceControlType } from '@/react/portainer/access-control/types';
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { renderWithQueryClient } from '@/react-tools/test-utils';
|
||||
import { UserContext } from '@/portainer/hooks/useUser';
|
||||
import { UserContext } from '@/react/hooks/useUser';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
|
||||
import { NetworkContainer } from '../types';
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Authorized } from '@/portainer/hooks/useUser';
|
||||
import { Authorized } from '@/react/hooks/useUser';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { Icon } from '@/react/components/Icon';
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { render } from '@/react-tools/test-utils';
|
||||
import { UserContext } from '@/portainer/hooks/useUser';
|
||||
import { UserContext } from '@/react/hooks/useUser';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
|
||||
import { DockerNetwork } from '../types';
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Fragment } from 'react';
|
||||
|
||||
import DockerNetworkHelper from '@/docker/helpers/networkHelper';
|
||||
import { Authorized } from '@/portainer/hooks/useUser';
|
||||
import { Authorized } from '@/react/hooks/useUser';
|
||||
|
||||
import { Table, TableContainer, TableTitle } from '@@/datatables';
|
||||
import { DetailsTable } from '@@/DetailsTable';
|
||||
|
|
8
app/react/hooks/useCurrentEnvironment.ts
Normal file
8
app/react/hooks/useCurrentEnvironment.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { useEnvironment } from '@/react/portainer/environments/queries/useEnvironment';
|
||||
|
||||
import { useEnvironmentId } from './useEnvironmentId';
|
||||
|
||||
export function useCurrentEnvironment() {
|
||||
const id = useEnvironmentId();
|
||||
return useEnvironment(id);
|
||||
}
|
15
app/react/hooks/useDebounce.ts
Normal file
15
app/react/hooks/useDebounce.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
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;
|
||||
}
|
13
app/react/hooks/useEnvironmentId.ts
Normal file
13
app/react/hooks/useEnvironmentId.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
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;
|
||||
}
|
46
app/react/hooks/useLocalStorage.ts
Normal file
46
app/react/hooks/useLocalStorage.ts
Normal file
|
@ -0,0 +1,46 @@
|
|||
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));
|
||||
}
|
14
app/react/hooks/usePaginationLimitState.ts
Normal file
14
app/react/hooks/usePaginationLimitState.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
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}`;
|
||||
}
|
||||
}
|
37
app/react/hooks/useUIState.tsx
Normal file
37
app/react/hooks/useUIState.tsx
Normal file
|
@ -0,0 +1,37 @@
|
|||
import create from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
import { keyBuilder } from '@/react/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') }
|
||||
)
|
||||
);
|
180
app/react/hooks/useUser.tsx
Normal file
180
app/react/hooks/useUser.tsx
Normal file
|
@ -0,0 +1,180 @@
|
|||
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 '@/portainer/users/user.service';
|
||||
import { User, UserId } from '@/portainer/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,7 +1,7 @@
|
|||
import create from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
import { keyBuilder } from '@/portainer/hooks/useLocalStorage';
|
||||
import { keyBuilder } from '@/react/hooks/useLocalStorage';
|
||||
import {
|
||||
paginationSettings,
|
||||
sortableSettings,
|
||||
|
|
|
@ -2,7 +2,7 @@ import { useState, useEffect, useMemo, ReactNode } from 'react';
|
|||
import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { useEnvironmentId } from '@/portainer/hooks/useEnvironmentId';
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { useConfigurations } from '@/react/kubernetes/configs/queries';
|
||||
import { useNamespaces } from '@/react/kubernetes/namespaces/queries';
|
||||
import { useServices } from '@/react/kubernetes/networks/services/queries';
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { Plus, Trash2 } from 'react-feather';
|
||||
import { useRouter } from '@uirouter/react';
|
||||
|
||||
import { useEnvironmentId } from '@/portainer/hooks/useEnvironmentId';
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { useNamespaces } from '@/react/kubernetes/namespaces/queries';
|
||||
import { useAuthorizations, Authorized } from '@/portainer/hooks/useUser';
|
||||
import { useAuthorizations, Authorized } from '@/react/hooks/useUser';
|
||||
import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm';
|
||||
|
||||
import { Datatable } from '@@/datatables';
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { CellProps, Column } from 'react-table';
|
||||
|
||||
import { Authorized } from '@/portainer/hooks/useUser';
|
||||
import { Authorized } from '@/react/hooks/useUser';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import create from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
import { keyBuilder } from '@/portainer/hooks/useLocalStorage';
|
||||
import { keyBuilder } from '@/react/hooks/useLocalStorage';
|
||||
import {
|
||||
paginationSettings,
|
||||
sortableSettings,
|
||||
|
|
38
app/react/portainer/HomeView/BackupFailedPanel.test.tsx
Normal file
38
app/react/portainer/HomeView/BackupFailedPanel.test.tsx
Normal file
|
@ -0,0 +1,38 @@
|
|||
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();
|
||||
});
|
42
app/react/portainer/HomeView/BackupFailedPanel.tsx
Normal file
42
app/react/portainer/HomeView/BackupFailedPanel.tsx
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { useQuery } from 'react-query';
|
||||
|
||||
import { error as notifyError } from '@/portainer/services/notifications';
|
||||
import { getBackupStatus } from '@/portainer/services/api/backup.service';
|
||||
import { isoDate } from '@/portainer/filters/filters';
|
||||
|
||||
import { InformationPanel } from '@@/InformationPanel';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
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 };
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
.root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
12
app/react/portainer/HomeView/EdgeLoadingSpinner.tsx
Normal file
12
app/react/portainer/HomeView/EdgeLoadingSpinner.tsx
Normal file
|
@ -0,0 +1,12 @@
|
|||
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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
.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;
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
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;
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
import {
|
||||
EnvironmentGroup,
|
||||
EnvironmentGroupId,
|
||||
} from '@/react/portainer/environments/environment-groups/types';
|
||||
import { Environment } from '@/react/portainer/environments/types';
|
||||
import { UserContext } from '@/react/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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,213 @@
|
|||
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 '@/react/hooks/useUser';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
import { Link } from '@@/Link';
|
||||
import { Button } from '@@/buttons';
|
||||
import { EdgeIndicator } from '@@/EdgeIndicator';
|
||||
|
||||
import { EnvironmentIcon } from './EnvironmentIcon';
|
||||
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 '';
|
||||
}
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,134 @@
|
|||
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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
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';
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { EnvironmentItem } from './EnvironmentItem';
|
|
@ -0,0 +1,80 @@
|
|||
.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;
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
import { Environment } from '@/react/portainer/environments/types';
|
||||
import { UserContext } from '@/react/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;
|
||||
}
|
545
app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx
Normal file
545
app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx
Normal file
|
@ -0,0 +1,545 @@
|
|||
import { ReactNode, useEffect, useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { RefreshCcw } from 'react-feather';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { usePaginationLimitState } from '@/react/hooks/usePaginationLimitState';
|
||||
import {
|
||||
Environment,
|
||||
EnvironmentType,
|
||||
EnvironmentStatus,
|
||||
PlatformType,
|
||||
EdgeTypes,
|
||||
} from '@/react/portainer/environments/types';
|
||||
import { EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types';
|
||||
import { useDebounce } from '@/react/hooks/useDebounce';
|
||||
import {
|
||||
refetchIfAnyOffline,
|
||||
useEnvironmentList,
|
||||
} from '@/react/portainer/environments/queries/useEnvironmentList';
|
||||
import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
|
||||
import { useTags } from '@/portainer/tags/queries';
|
||||
import { useAgentVersionsList } from '@/react/portainer/environments/queries/useAgentVersionsList';
|
||||
import { EnvironmentsQueryParams } from '@/react/portainer/environments/environment.service';
|
||||
import { useUser } from '@/react/hooks/useUser';
|
||||
|
||||
import { TableFooter } from '@@/datatables/TableFooter';
|
||||
import { TableActions, TableContainer, TableTitle } from '@@/datatables';
|
||||
import {
|
||||
FilterSearchBar,
|
||||
useSearchBarState,
|
||||
} from '@@/datatables/FilterSearchBar';
|
||||
import { Button } from '@@/buttons';
|
||||
import { PaginationControls } from '@@/PaginationControls';
|
||||
|
||||
import { SortbySelector } from './SortbySelector';
|
||||
import { HomepageFilter, useHomePageFilter } from './HomepageFilter';
|
||||
import { Filter } from './types';
|
||||
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;
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
import { components, OptionProps } from 'react-select';
|
||||
|
||||
import { useLocalStorage } from '@/react/hooks/useLocalStorage';
|
||||
|
||||
import { Select } from '@@/form-components/ReactSelect';
|
||||
|
||||
import { Filter } from './types';
|
||||
|
||||
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}`;
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
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}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
.checkbox {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
|
@ -0,0 +1,170 @@
|
|||
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 '@/react/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.`;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
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;
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { KubeconfigButton } from './KubeconfigButton';
|
|
@ -0,0 +1,24 @@
|
|||
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,9 +1,8 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Filter } from '@/portainer/home/types';
|
||||
|
||||
import { Select } from '@@/form-components/ReactSelect';
|
||||
|
||||
import { Filter } from './types';
|
||||
import styles from './SortbySelector.module.css';
|
||||
|
||||
interface Props {
|
10
app/react/portainer/HomeView/EnvironmentList/index.ts
Normal file
10
app/react/portainer/HomeView/EnvironmentList/index.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { react2angular } from '@/react-tools/react2angular';
|
||||
|
||||
import { EnvironmentList } from './EnvironmentList';
|
||||
|
||||
export { EnvironmentList };
|
||||
|
||||
export const EnvironmentListAngular = react2angular(EnvironmentList, [
|
||||
'onClickItem',
|
||||
'onRefresh',
|
||||
]);
|
4
app/react/portainer/HomeView/EnvironmentList/types.ts
Normal file
4
app/react/portainer/HomeView/EnvironmentList/types.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export interface Filter<T = number> {
|
||||
value: T;
|
||||
label: string;
|
||||
}
|
86
app/react/portainer/HomeView/HomeView.tsx
Normal file
86
app/react/portainer/HomeView/HomeView.tsx
Normal file
|
@ -0,0 +1,86 @@
|
|||
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 * as notifications from '@/portainer/services/notifications';
|
||||
import { confirmAsync } from '@/portainer/services/modal.service/confirm';
|
||||
import { buildTitle } from '@/portainer/services/modal.service/utils';
|
||||
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
|
||||
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',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
47
app/react/portainer/HomeView/LicenseNodePanel.test.tsx
Normal file
47
app/react/portainer/HomeView/LicenseNodePanel.test.tsx
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { server, rest } from '@/setup-tests/server';
|
||||
import { renderWithQueryClient } from '@/react-tools/test-utils';
|
||||
import { LicenseType } from '@/portainer/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();
|
||||
});
|
56
app/react/portainer/HomeView/LicenseNodePanel.tsx
Normal file
56
app/react/portainer/HomeView/LicenseNodePanel.tsx
Normal file
|
@ -0,0 +1,56 @@
|
|||
import { useQuery } from 'react-query';
|
||||
|
||||
import { error as notifyError } from '@/portainer/services/notifications';
|
||||
import { LicenseType } from '@/portainer/license-management/types';
|
||||
import { useLicenseInfo } from '@/portainer/license-management/use-license.service';
|
||||
import { getNodesCount } from '@/portainer/services/api/status.service';
|
||||
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { InformationPanel } from '@@/InformationPanel';
|
||||
|
||||
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 };
|
||||
}
|
57
app/react/portainer/HomeView/MotdPanel.tsx
Normal file
57
app/react/portainer/HomeView/MotdPanel.tsx
Normal file
|
@ -0,0 +1,57 @@
|
|||
import { useQuery } from 'react-query';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { useUIState } from '@/react/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];
|
||||
})
|
||||
);
|
||||
}
|
15
app/react/portainer/HomeView/home.service.ts
Normal file
15
app/react/portainer/HomeView/home.service.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import axios, { parseAxiosError } from '@/portainer/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
app/react/portainer/HomeView/index.ts
Normal file
1
app/react/portainer/HomeView/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { HomeView } from './HomeView';
|
7
app/react/portainer/HomeView/types.ts
Normal file
7
app/react/portainer/HomeView/types.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
export interface Motd {
|
||||
Title: string;
|
||||
Message: string;
|
||||
Hash: string;
|
||||
Style?: string;
|
||||
ContentLayout?: Record<string, string>;
|
||||
}
|
|
@ -2,7 +2,7 @@ import { Meta, Story } from '@storybook/react';
|
|||
import { useMemo, useState } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
|
||||
import { UserContext } from '@/portainer/hooks/useUser';
|
||||
import { UserContext } from '@/react/hooks/useUser';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
|
||||
import { parseAccessControlFormData } from '../utils';
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { server, rest } from '@/setup-tests/server';
|
||||
import { UserContext } from '@/portainer/hooks/useUser';
|
||||
import { UserContext } from '@/react/hooks/useUser';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
import { renderWithQueryClient, within } from '@/react-tools/test-utils';
|
||||
import { Team, TeamId } from '@/react/portainer/users/teams/types';
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { useUser } from '@/portainer/hooks/useUser';
|
||||
import { useUser } from '@/react/hooks/useUser';
|
||||
|
||||
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
|
||||
import { SwitchField } from '@@/form-components/SwitchField';
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { useReducer } from 'react';
|
||||
|
||||
import { useUser } from '@/portainer/hooks/useUser';
|
||||
import { useUser } from '@/react/hooks/useUser';
|
||||
import { Icon } from '@/react/components/Icon';
|
||||
import { TeamMembership, TeamRole } from '@/react/portainer/users/teams/types';
|
||||
import { useUserMembership } from '@/portainer/users/queries';
|
||||
|
|
|
@ -3,7 +3,7 @@ import clsx from 'clsx';
|
|||
import { useMutation } from 'react-query';
|
||||
import { object } from 'yup';
|
||||
|
||||
import { useUser } from '@/portainer/hooks/useUser';
|
||||
import { useUser } from '@/react/hooks/useUser';
|
||||
import { confirmAsync } from '@/portainer/services/modal.service/confirm';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { useCallback } from 'react';
|
||||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { useUser } from '@/portainer/hooks/useUser';
|
||||
import { useUser } from '@/react/hooks/useUser';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { BoxSelector } from '@@/BoxSelector';
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
import axios, { parseAxiosError } from '@/portainer/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;
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
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;
|
||||
}
|
14
app/react/portainer/environments/environment-groups/types.ts
Normal file
14
app/react/portainer/environments/environment-groups/types.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
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,6 +1,6 @@
|
|||
import { Gpu } from '@/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/GpusList';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { type EnvironmentGroupId } from '@/portainer/environment-groups/types';
|
||||
import { type EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types';
|
||||
import { type TagId } from '@/portainer/tags/types';
|
||||
|
||||
import { type Environment, EnvironmentCreationTypes } from '../types';
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue