1
0
Fork 0
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:
Chaim Lev-Ari 2022-11-13 10:10:18 +02:00 committed by GitHub
parent 30e23ea5b4
commit 78dcba614d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
192 changed files with 200 additions and 211 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

@ -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> = {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

View 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}`;
}
}

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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();
});

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

View file

@ -0,0 +1,9 @@
.root {
width: 100%;
height: 100%;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

@ -0,0 +1,9 @@
.checkbox {
padding-left: 0.5rem;
}
.dialog {
display: flex;
justify-content: center;
align-items: center;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,10 @@
import { react2angular } from '@/react-tools/react2angular';
import { EnvironmentList } from './EnvironmentList';
export { EnvironmentList };
export const EnvironmentListAngular = react2angular(EnvironmentList, [
'onClickItem',
'onRefresh',
]);

View file

@ -0,0 +1,4 @@
export interface Filter<T = number> {
value: T;
label: string;
}

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

View 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();
});

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

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

View 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'
);
}
}

View file

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

View file

@ -0,0 +1,7 @@
export interface Motd {
Title: string;
Message: string;
Hash: string;
Style?: string;
ContentLayout?: Record<string, string>;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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[];
}

View file

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