mirror of
https://github.com/portainer/portainer.git
synced 2025-08-05 05:45:22 +02:00
refactor(environments): migrate table to react [EE-4702] (#8882)
This commit is contained in:
parent
f7dd73b0f7
commit
caf87bb0b5
28 changed files with 492 additions and 411 deletions
|
@ -126,7 +126,7 @@ export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
|
|||
pageLimit,
|
||||
...queryWithSort,
|
||||
},
|
||||
refetchIfAnyOffline
|
||||
{ refetchInterval: refetchIfAnyOffline }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
@ -0,0 +1,104 @@
|
|||
import { HardDrive, Plus, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useEnvironmentList } from '@/react/portainer/environments/queries';
|
||||
import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
|
||||
|
||||
import { Datatable } from '@@/datatables';
|
||||
import { createPersistedStore } from '@@/datatables/types';
|
||||
import { Button } from '@@/buttons';
|
||||
import { Link } from '@@/Link';
|
||||
import { useTableState } from '@@/datatables/useTableState';
|
||||
|
||||
import { isBE } from '../../feature-flags/feature-flags.service';
|
||||
import { refetchIfAnyOffline } from '../queries/useEnvironmentList';
|
||||
|
||||
import { columns } from './columns';
|
||||
import { EnvironmentListItem } from './types';
|
||||
import { ImportFdoDeviceButton } from './ImportFdoDeviceButton';
|
||||
|
||||
const tableKey = 'environments';
|
||||
const settingsStore = createPersistedStore(tableKey, 'Name');
|
||||
|
||||
export function EnvironmentsDatatable({
|
||||
onRemove,
|
||||
}: {
|
||||
onRemove: (environments: Array<EnvironmentListItem>) => void;
|
||||
}) {
|
||||
const tableState = useTableState(settingsStore, tableKey);
|
||||
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const groupsQuery = useGroups();
|
||||
const { environments, isLoading, totalCount } = useEnvironmentList(
|
||||
{
|
||||
search: tableState.search,
|
||||
excludeSnapshots: true,
|
||||
page: page + 1,
|
||||
pageLimit: tableState.pageSize,
|
||||
sort: tableState.sortBy.id,
|
||||
order: tableState.sortBy.desc ? 'desc' : 'asc',
|
||||
},
|
||||
{ enabled: groupsQuery.isSuccess, refetchInterval: refetchIfAnyOffline }
|
||||
);
|
||||
|
||||
const environmentsWithGroups = environments.map<EnvironmentListItem>(
|
||||
(env) => {
|
||||
const groupId = env.GroupId;
|
||||
const group = groupsQuery.data?.find((g) => g.Id === groupId);
|
||||
return {
|
||||
...env,
|
||||
GroupName: group?.Name,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<Datatable
|
||||
title="Environments"
|
||||
titleIcon={HardDrive}
|
||||
dataset={environmentsWithGroups}
|
||||
columns={columns}
|
||||
settingsManager={tableState}
|
||||
pageCount={Math.ceil(totalCount / tableState.pageSize)}
|
||||
onPageChange={setPage}
|
||||
isLoading={isLoading}
|
||||
totalCount={totalCount}
|
||||
renderTableActions={(selectedRows) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
color="dangerlight"
|
||||
disabled={selectedRows.length === 0}
|
||||
onClick={() => onRemove(selectedRows)}
|
||||
icon={Trash2}
|
||||
className="!m-0"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
|
||||
<ImportFdoDeviceButton />
|
||||
|
||||
{isBE && (
|
||||
<Button
|
||||
as={Link}
|
||||
color="secondary"
|
||||
icon={Plus}
|
||||
props={{ to: 'portainer.endpoints.edgeAutoCreateScript' }}
|
||||
>
|
||||
Auto onboarding
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
as={Link}
|
||||
props={{ to: 'portainer.environments.new' }}
|
||||
icon={Plus}
|
||||
className="!m-0"
|
||||
>
|
||||
Add environment
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
58
app/react/portainer/environments/ListView/ListView.tsx
Normal file
58
app/react/portainer/environments/ListView/ListView.tsx
Normal file
|
@ -0,0 +1,58 @@
|
|||
import { useStore } from 'zustand';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { environmentStore } from '@/react/hooks/current-environment-store';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
import { confirmDelete } from '@@/modals/confirm';
|
||||
|
||||
import { Environment } from '../types';
|
||||
|
||||
import { EnvironmentsDatatable } from './EnvironmentsDatatable';
|
||||
import { useDeleteEnvironmentsMutation } from './useDeleteEnvironmentsMutation';
|
||||
|
||||
export function ListView() {
|
||||
const constCurrentEnvironmentStore = useStore(environmentStore);
|
||||
const deletionMutation = useDeleteEnvironmentsMutation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Environments"
|
||||
breadcrumbs="Environment management"
|
||||
reload
|
||||
/>
|
||||
|
||||
<EnvironmentsDatatable onRemove={handleRemove} />
|
||||
</>
|
||||
);
|
||||
|
||||
async function handleRemove(environments: Array<Environment>) {
|
||||
const confirmed = await confirmDelete(
|
||||
'This action will remove all configurations associated to your environment(s). Continue?'
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = constCurrentEnvironmentStore.environmentId;
|
||||
// If the current endpoint was deleted, then clean endpoint store
|
||||
if (environments.some((e) => e.Id === id)) {
|
||||
constCurrentEnvironmentStore.clear();
|
||||
}
|
||||
|
||||
deletionMutation.mutate(
|
||||
environments.map((e) => e.Id),
|
||||
{
|
||||
onSuccess() {
|
||||
notifySuccess(
|
||||
'Environments successfully removed',
|
||||
_.map(environments, 'Name').join(', ')
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
import { CellContext } from '@tanstack/react-table';
|
||||
import { Users } from 'lucide-react';
|
||||
|
||||
import { EnvironmentStatus } from '@/react/portainer/environments/types';
|
||||
|
||||
import { Button } from '@@/buttons';
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
import { EnvironmentListItem } from '../types';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const actions = columnHelper.display({
|
||||
header: 'Actions',
|
||||
cell: Cell,
|
||||
});
|
||||
|
||||
function Cell({
|
||||
row: { original: environment },
|
||||
}: CellContext<EnvironmentListItem, unknown>) {
|
||||
if (
|
||||
environment.Status === EnvironmentStatus.Provisioning ||
|
||||
environment.Status === EnvironmentStatus.Error
|
||||
) {
|
||||
return <>-</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
as={Link}
|
||||
props={{
|
||||
to: 'portainer.endpoints.endpoint.access',
|
||||
params: { id: environment.Id },
|
||||
}}
|
||||
color="link"
|
||||
icon={Users}
|
||||
>
|
||||
Manage access
|
||||
</Button>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
|
||||
import { EnvironmentListItem } from '../types';
|
||||
|
||||
export const columnHelper = createColumnHelper<EnvironmentListItem>();
|
15
app/react/portainer/environments/ListView/columns/index.ts
Normal file
15
app/react/portainer/environments/ListView/columns/index.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { actions } from './actions';
|
||||
import { columnHelper } from './helper';
|
||||
import { name } from './name';
|
||||
import { type } from './type';
|
||||
import { url } from './url';
|
||||
|
||||
export const columns = [
|
||||
name,
|
||||
type,
|
||||
url,
|
||||
columnHelper.accessor('GroupName', {
|
||||
header: 'Group Name',
|
||||
}),
|
||||
actions,
|
||||
];
|
21
app/react/portainer/environments/ListView/columns/name.tsx
Normal file
21
app/react/portainer/environments/ListView/columns/name.tsx
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { EnvironmentStatus } from '@/react/portainer/environments/types';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const name = columnHelper.accessor('Name', {
|
||||
header: 'Name',
|
||||
cell: ({ getValue, row: { original: environment } }) => {
|
||||
const name = getValue();
|
||||
if (environment.Status === EnvironmentStatus.Provisioning) {
|
||||
return name;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to="portainer.endpoints.endpoint" params={{ id: environment.Id }}>
|
||||
{name}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
});
|
28
app/react/portainer/environments/ListView/columns/type.tsx
Normal file
28
app/react/portainer/environments/ListView/columns/type.tsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { CellContext } from '@tanstack/react-table';
|
||||
|
||||
import { environmentTypeIcon } from '@/portainer/filters/filters';
|
||||
import {
|
||||
Environment,
|
||||
EnvironmentType,
|
||||
} from '@/react/portainer/environments/types';
|
||||
import { getPlatformTypeName } from '@/react/portainer/environments/utils';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const type = columnHelper.accessor('Type', {
|
||||
header: 'Type',
|
||||
cell: Cell,
|
||||
});
|
||||
|
||||
function Cell({ getValue }: CellContext<Environment, EnvironmentType>) {
|
||||
const type = getValue();
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
<Icon icon={environmentTypeIcon(type)} />
|
||||
{getPlatformTypeName(type)}
|
||||
</span>
|
||||
);
|
||||
}
|
116
app/react/portainer/environments/ListView/columns/url.tsx
Normal file
116
app/react/portainer/environments/ListView/columns/url.tsx
Normal file
|
@ -0,0 +1,116 @@
|
|||
import { CellContext } from '@tanstack/react-table';
|
||||
import { AlertCircle, HelpCircle, Settings } from 'lucide-react';
|
||||
|
||||
import {
|
||||
EnvironmentStatus,
|
||||
EnvironmentStatusMessage,
|
||||
EnvironmentType,
|
||||
} from '@/react/portainer/environments/types';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
|
||||
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
|
||||
import { Button } from '@@/buttons';
|
||||
|
||||
import { EnvironmentListItem } from '../types';
|
||||
import { useUpdateEnvironmentMutation } from '../../queries/useUpdateEnvironmentMutation';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const url = columnHelper.accessor('URL', {
|
||||
header: 'URL',
|
||||
cell: Cell,
|
||||
});
|
||||
|
||||
function Cell({
|
||||
row: { original: environment },
|
||||
}: CellContext<EnvironmentListItem, string>) {
|
||||
const mutation = useUpdateEnvironmentMutation();
|
||||
|
||||
if (
|
||||
environment.Type !== EnvironmentType.EdgeAgentOnDocker &&
|
||||
environment.Status !== EnvironmentStatus.Provisioning
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
{environment.URL}
|
||||
{environment.StatusMessage.Summary &&
|
||||
environment.StatusMessage.Detail && (
|
||||
<div className="ml-2 inline-block">
|
||||
<span className="text-danger vertical-center inline-flex">
|
||||
<AlertCircle className="lucide" aria-hidden="true" />
|
||||
<span>{environment.StatusMessage.Summary}</span>
|
||||
</span>
|
||||
<TooltipWithChildren
|
||||
message={
|
||||
<div>
|
||||
{environment.StatusMessage.Detail}
|
||||
{environment.URL && (
|
||||
<div className="mt-2 text-right">
|
||||
<Button
|
||||
color="link"
|
||||
className="small !ml-0 p-0"
|
||||
onClick={handleDismissButton}
|
||||
>
|
||||
<span className="text-muted-light">
|
||||
Dismiss error (still visible in logs)
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
position="bottom"
|
||||
>
|
||||
<span className="vertical-center inline-flex text-base">
|
||||
<HelpCircle className="lucide ml-1" aria-hidden="true" />
|
||||
</span>
|
||||
</TooltipWithChildren>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (environment.Type === 4) {
|
||||
return <>-</>;
|
||||
}
|
||||
|
||||
if (environment.Status === 3) {
|
||||
const status = (
|
||||
<span className="vertical-center inline-flex text-base">
|
||||
<Settings className="lucide animate-spin-slow" />
|
||||
{environment.StatusMessage.Summary}
|
||||
</span>
|
||||
);
|
||||
if (!environment.StatusMessage.Detail) {
|
||||
return status;
|
||||
}
|
||||
return (
|
||||
<TooltipWithChildren
|
||||
message={environment.StatusMessage.Detail}
|
||||
position="bottom"
|
||||
>
|
||||
{status}
|
||||
</TooltipWithChildren>
|
||||
);
|
||||
}
|
||||
|
||||
return <>-</>;
|
||||
|
||||
function handleDismissButton() {
|
||||
mutation.mutate(
|
||||
{
|
||||
id: environment.Id,
|
||||
payload: {
|
||||
IsSetStatusMessage: true,
|
||||
StatusMessage: {} as EnvironmentStatusMessage,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifySuccess('Success', 'Error dismissed successfully');
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
1
app/react/portainer/environments/ListView/index.ts
Normal file
1
app/react/portainer/environments/ListView/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { ListView } from './ListView';
|
5
app/react/portainer/environments/ListView/types.ts
Normal file
5
app/react/portainer/environments/ListView/types.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { Environment } from '@/react/portainer/environments/types';
|
||||
|
||||
export type EnvironmentListItem = {
|
||||
GroupName?: string;
|
||||
} & Environment;
|
|
@ -0,0 +1,36 @@
|
|||
import { useMutation, useQueryClient } from 'react-query';
|
||||
|
||||
import { promiseSequence } from '@/portainer/helpers/promise-utils';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import {
|
||||
mutationOptions,
|
||||
withError,
|
||||
withInvalidate,
|
||||
} from '@/react-tools/react-query';
|
||||
|
||||
import { buildUrl } from '../environment.service/utils';
|
||||
import { EnvironmentId } from '../types';
|
||||
|
||||
export function useDeleteEnvironmentsMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(
|
||||
(environments: EnvironmentId[]) =>
|
||||
promiseSequence(
|
||||
environments.map(
|
||||
(environmentId) => () => deleteEnvironment(environmentId)
|
||||
)
|
||||
),
|
||||
mutationOptions(
|
||||
withError('Unable to delete environment(s)'),
|
||||
withInvalidate(queryClient, [['environments']])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteEnvironment(id: EnvironmentId) {
|
||||
try {
|
||||
await axios.delete(buildUrl(id));
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to delete environment');
|
||||
}
|
||||
}
|
|
@ -39,12 +39,18 @@ export function refetchIfAnyOffline(data?: GetEndpointsResponse) {
|
|||
|
||||
export function useEnvironmentList(
|
||||
{ page = 1, pageLimit = 100, sort, order, ...query }: Query = {},
|
||||
refetchInterval?:
|
||||
| number
|
||||
| false
|
||||
| ((data?: GetEndpointsResponse) => false | number),
|
||||
staleTime = 0,
|
||||
enabled = true
|
||||
{
|
||||
enabled,
|
||||
refetchInterval,
|
||||
staleTime,
|
||||
}: {
|
||||
refetchInterval?:
|
||||
| number
|
||||
| false
|
||||
| ((data?: GetEndpointsResponse) => false | number);
|
||||
staleTime?: number;
|
||||
enabled?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
const { isLoading, data } = useQuery(
|
||||
[
|
||||
|
|
|
@ -1,21 +1,23 @@
|
|||
import { useMutation, useQueryClient } from 'react-query';
|
||||
import { useQueryClient, useMutation } from 'react-query';
|
||||
|
||||
import { withError, withInvalidate } from '@/react-tools/react-query';
|
||||
import {
|
||||
EnvironmentId,
|
||||
EnvironmentStatusMessage,
|
||||
Environment,
|
||||
} from '@/react/portainer/environments/types';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { TagId } from '@/portainer/tags/types';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
|
||||
import { EnvironmentGroupId } from '../environment-groups/types';
|
||||
import { buildUrl } from '../environment.service/utils';
|
||||
import { EnvironmentId, Environment } from '../types';
|
||||
|
||||
import { queryKeys } from './query-keys';
|
||||
|
||||
export function useUpdateEnvironmentMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(updateEnvironment, {
|
||||
onSuccess(data, { id }) {
|
||||
queryClient.invalidateQueries(queryKeys.item(id));
|
||||
},
|
||||
...withInvalidate(queryClient, [queryKeys.base()]),
|
||||
...withError('Unable to update environment'),
|
||||
});
|
||||
}
|
||||
|
@ -38,6 +40,9 @@ export interface UpdatePayload {
|
|||
AzureApplicationID: string;
|
||||
AzureTenantID: string;
|
||||
AzureAuthenticationKey: string;
|
||||
|
||||
IsSetStatusMessage: boolean;
|
||||
StatusMessage: Partial<EnvironmentStatusMessage>;
|
||||
}
|
||||
|
||||
async function updateEnvironment({
|
||||
|
|
|
@ -131,6 +131,10 @@ interface EndpointChangeWindow {
|
|||
StartTime: string;
|
||||
EndTime: string;
|
||||
}
|
||||
export interface EnvironmentStatusMessage {
|
||||
Summary: string;
|
||||
Detail: string;
|
||||
}
|
||||
|
||||
export type Environment = {
|
||||
Agent: { Version: string };
|
||||
|
@ -163,6 +167,10 @@ export type Environment = {
|
|||
|
||||
/** GitOps update change window restriction for stacks and apps */
|
||||
ChangeWindow: EndpointChangeWindow;
|
||||
/**
|
||||
* A message that describes the status. Should be included for Status Provisioning or Error.
|
||||
*/
|
||||
StatusMessage: EnvironmentStatusMessage;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -4,9 +4,9 @@ import { EdgeTypes, EnvironmentId } from '@/react/portainer/environments/types';
|
|||
export function useEnvironments(environmentsIds: Array<EnvironmentId>) {
|
||||
const environmentsQuery = useEnvironmentList(
|
||||
{ endpointIds: environmentsIds, types: EdgeTypes },
|
||||
undefined,
|
||||
undefined,
|
||||
environmentsIds.length > 0
|
||||
{
|
||||
enabled: environmentsIds.length > 0,
|
||||
}
|
||||
);
|
||||
|
||||
return environmentsQuery.environments;
|
||||
|
|
|
@ -28,19 +28,21 @@ interface Props {
|
|||
export function WizardEndpointsList({ environmentIds }: Props) {
|
||||
const { environments } = useEnvironmentList(
|
||||
{ endpointIds: environmentIds },
|
||||
(environments) => {
|
||||
if (!environments) {
|
||||
return false;
|
||||
}
|
||||
{
|
||||
refetchInterval: (environments) => {
|
||||
if (!environments) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!environments.value.some(isUnassociatedEdgeEnvironment)) {
|
||||
return false;
|
||||
}
|
||||
if (!environments.value.some(isUnassociatedEdgeEnvironment)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ENVIRONMENTS_POLLING_INTERVAL;
|
||||
},
|
||||
0,
|
||||
environmentIds.length > 0
|
||||
return ENVIRONMENTS_POLLING_INTERVAL;
|
||||
},
|
||||
|
||||
enabled: environmentIds.length > 0,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
@ -76,8 +76,10 @@ function useFetchLocalEnvironment() {
|
|||
pageLimit: 1,
|
||||
types: [EnvironmentType.Docker, EnvironmentType.KubernetesLocal],
|
||||
},
|
||||
false,
|
||||
Infinity
|
||||
{
|
||||
refetchInterval: false,
|
||||
staleTime: Infinity,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue