1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-26 00:39:41 +02:00

refactor(edge/stacks): migrate envs table to react [EE-5613] (#9093)

This commit is contained in:
Chaim Lev-Ari 2023-06-25 12:38:43 +07:00 committed by GitHub
parent dfc1a7b1d7
commit 11571fd6ea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 652 additions and 281 deletions

View file

@ -0,0 +1,30 @@
import { useCurrentStateAndParams } from '@uirouter/react';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { useLogsStatus } from './useLogsStatus';
interface Props {
environmentId: EnvironmentId;
}
export function ActionStatus({ environmentId }: Props) {
const {
params: { stackId: edgeStackId },
} = useCurrentStateAndParams();
const logsStatusQuery = useLogsStatus(edgeStackId, environmentId);
return <>{getStatusText(logsStatusQuery.data)}</>;
}
function getStatusText(status?: 'pending' | 'collected' | 'idle') {
switch (status) {
case 'collected':
return 'Logs available for download';
case 'pending':
return 'Logs marked for collection, please wait until the logs are available';
default:
return null;
}
}

View file

@ -0,0 +1,39 @@
import { Search } from 'lucide-react';
import { useCurrentStateAndParams } from '@uirouter/react';
import { Environment } from '@/react/portainer/environments/types';
import { Button } from '@@/buttons';
import { Link } from '@@/Link';
import { Icon } from '@@/Icon';
import { LogsActions } from './LogsActions';
interface Props {
environment: Environment;
}
export function EnvironmentActions({ environment }: Props) {
const {
params: { stackId: edgeStackId },
} = useCurrentStateAndParams();
return (
<div className="space-x-2">
{environment.Snapshots.length > 0 && (
<Link
to="edge.browse.containers"
params={{ environmentId: environment.Id, edgeStackId }}
className="!text-inherit hover:!no-underline"
>
<Button color="none" title="Browse Snapshot">
<Icon icon={Search} className="searchIcon" />
</Button>
</Link>
)}
{environment.Edge.AsyncMode && (
<LogsActions environmentId={environment.Id} edgeStackId={edgeStackId} />
)}
</div>
);
}

View file

@ -0,0 +1,113 @@
import { useCurrentStateAndParams } from '@uirouter/react';
import { HardDrive } from 'lucide-react';
import { useMemo, useState } from 'react';
import { EdgeStackStatus, StatusType } from '@/react/edge/edge-stacks/types';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { useParamState } from '@/react/hooks/useParamState';
import { Datatable } from '@@/datatables';
import { useTableStateWithoutStorage } from '@@/datatables/useTableState';
import { PortainerSelect } from '@@/form-components/PortainerSelect';
import { useEdgeStack } from '../../queries/useEdgeStack';
import { EdgeStackEnvironment } from './types';
import { columns } from './columns';
export function EnvironmentsDatatable() {
const {
params: { stackId },
} = useCurrentStateAndParams();
const edgeStackQuery = useEdgeStack(stackId);
const [page, setPage] = useState(0);
const [statusFilter, setStatusFilter] = useParamState<StatusType>(
'status',
parseStatusFilter
);
const tableState = useTableStateWithoutStorage('name');
const endpointsQuery = useEnvironmentList({
pageLimit: tableState.pageSize,
page,
search: tableState.search,
sort: tableState.sortBy.id as 'Group' | 'Name',
order: tableState.sortBy.desc ? 'desc' : 'asc',
edgeStackId: stackId,
edgeStackStatus: statusFilter,
});
const environments: Array<EdgeStackEnvironment> = useMemo(
() =>
endpointsQuery.environments.map((env) => ({
...env,
StackStatus:
edgeStackQuery.data?.Status[env.Id] ||
({
Details: {
Pending: true,
Acknowledged: false,
ImagesPulled: false,
Error: false,
Ok: false,
RemoteUpdateSuccess: false,
Remove: false,
},
EndpointID: env.Id,
Error: '',
} satisfies EdgeStackStatus),
})),
[edgeStackQuery.data?.Status, endpointsQuery.environments]
);
return (
<Datatable
columns={columns}
isLoading={endpointsQuery.isLoading}
dataset={environments}
settingsManager={tableState}
title="Environments Status"
titleIcon={HardDrive}
onPageChange={setPage}
emptyContentLabel="No environment available."
disableSelect
description={
isBE && (
<div className="w-1/4">
<PortainerSelect<StatusType | undefined>
isClearable
bindToBody
value={statusFilter}
onChange={(e) => setStatusFilter(e || undefined)}
options={[
{ value: 'Pending', label: 'Pending' },
{ value: 'Acknowledged', label: 'Acknowledged' },
{ value: 'ImagesPulled', label: 'Images pre-pulled' },
{ value: 'Ok', label: 'Deployed' },
{ value: 'Error', label: 'Failed' },
]}
/>
</div>
)
}
/>
);
}
function parseStatusFilter(status: string | undefined): StatusType | undefined {
switch (status) {
case 'Pending':
return 'Pending';
case 'Acknowledged':
return 'Acknowledged';
case 'ImagesPulled':
return 'ImagesPulled';
case 'Ok':
return 'Ok';
case 'Error':
return 'Error';
default:
return undefined;
}
}

View file

@ -0,0 +1,112 @@
import clsx from 'clsx';
import { notifySuccess } from '@/portainer/services/notifications';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { Button } from '@@/buttons';
import { Icon } from '@@/Icon';
import { EdgeStack } from '../../types';
import { useCollectLogsMutation } from './useCollectLogsMutation';
import { useDeleteLogsMutation } from './useDeleteLogsMutation';
import { useDownloadLogsMutation } from './useDownloadLogsMutation';
import { useLogsStatus } from './useLogsStatus';
interface Props {
environmentId: EnvironmentId;
edgeStackId: EdgeStack['Id'];
}
export function LogsActions({ environmentId, edgeStackId }: Props) {
const logsStatusQuery = useLogsStatus(edgeStackId, environmentId);
const collectLogsMutation = useCollectLogsMutation();
const downloadLogsMutation = useDownloadLogsMutation();
const deleteLogsMutation = useDeleteLogsMutation();
if (!logsStatusQuery.isSuccess) {
return null;
}
const status = logsStatusQuery.data;
const collecting = collectLogsMutation.isLoading || status === 'pending';
return (
<>
<Button color="none" title="Retrieve logs" onClick={handleCollectLogs}>
<Icon
icon={clsx({
'file-text': !collecting,
loader: collecting,
})}
/>
</Button>
<Button
color="none"
title="Download logs"
disabled={status !== 'collected'}
onClick={handleDownloadLogs}
>
<Icon
icon={clsx({
'download-cloud': !downloadLogsMutation.isLoading,
loader: downloadLogsMutation.isLoading,
})}
/>
</Button>
<Button
color="none"
title="Delete logs"
disabled={status !== 'collected'}
onClick={handleDeleteLogs}
>
<Icon
icon={clsx({
delete: !deleteLogsMutation.isLoading,
loader: deleteLogsMutation.isLoading,
})}
/>
</Button>
</>
);
function handleCollectLogs() {
if (status === 'pending') {
return;
}
collectLogsMutation.mutate(
{
edgeStackId,
environmentId,
},
{
onSuccess() {
notifySuccess('Success', 'Logs Collection started');
},
}
);
}
function handleDownloadLogs() {
downloadLogsMutation.mutate({
edgeStackId,
environmentId,
});
}
function handleDeleteLogs() {
deleteLogsMutation.mutate(
{
edgeStackId,
environmentId,
},
{
onSuccess() {
notifySuccess('Success', 'Logs Deleted');
},
}
);
}
}

View file

@ -0,0 +1,106 @@
import { CellContext, createColumnHelper } from '@tanstack/react-table';
import { ChevronDown, ChevronRight } from 'lucide-react';
import clsx from 'clsx';
import { useState } from 'react';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { Button } from '@@/buttons';
import { Icon } from '@@/Icon';
import { EdgeStackStatus } from '../../types';
import { EnvironmentActions } from './EnvironmentActions';
import { ActionStatus } from './ActionStatus';
import { EdgeStackEnvironment } from './types';
const columnHelper = createColumnHelper<EdgeStackEnvironment>();
export const columns = [
columnHelper.accessor('Name', {
id: 'name',
header: 'Name',
}),
columnHelper.accessor((env) => endpointStatusLabel(env.StackStatus), {
id: 'status',
header: 'Status',
}),
columnHelper.accessor((env) => env.StackStatus.Error, {
id: 'error',
header: 'Error',
cell: ErrorCell,
}),
...(isBE
? [
columnHelper.display({
id: 'actions',
header: 'Actions',
cell({ row: { original: env } }) {
return <EnvironmentActions environment={env} />;
},
}),
columnHelper.display({
id: 'actionStatus',
header: 'Action Status',
cell({ row: { original: env } }) {
return <ActionStatus environmentId={env.Id} />;
},
}),
]
: []),
];
function ErrorCell({ getValue }: CellContext<EdgeStackEnvironment, string>) {
const [isExpanded, setIsExpanded] = useState(false);
const value = getValue();
if (!value) {
return '-';
}
return (
<Button
className="flex cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="pt-0.5 pr-1">
<Icon icon={isExpanded ? ChevronDown : ChevronRight} />
</div>
<div
className={clsx('overflow-hidden whitespace-normal', {
'h-[1.5em]': isExpanded,
})}
>
{value}
</div>
</Button>
);
}
function endpointStatusLabel(status: EdgeStackStatus) {
const details = (status && status.Details) || {};
const labels = [];
if (details.Acknowledged) {
labels.push('Acknowledged');
}
if (details.ImagesPulled) {
labels.push('Images pre-pulled');
}
if (details.Ok) {
labels.push('Deployed');
}
if (details.Error) {
labels.push('Failed');
}
if (!labels.length) {
labels.push('Pending');
}
return labels.join(', ');
}

View file

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

View file

@ -0,0 +1,7 @@
import { Environment } from '@/react/portainer/environments/types';
import { EdgeStackStatus } from '../../types';
export type EdgeStackEnvironment = Environment & {
StackStatus: EdgeStackStatus;
};

View file

@ -0,0 +1,35 @@
import { useMutation, useQueryClient } from 'react-query';
import { EnvironmentId } from '@/react/portainer/environments/types';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withError } from '@/react-tools/react-query';
import { EdgeStack } from '../../types';
import { logsStatusQueryKey } from './useLogsStatus';
export function useCollectLogsMutation() {
const queryClient = useQueryClient();
return useMutation(collectLogs, {
onSuccess(data, variables) {
return queryClient.invalidateQueries(
logsStatusQueryKey(variables.edgeStackId, variables.environmentId)
);
},
...withError('Unable to retrieve logs'),
});
}
interface CollectLogs {
edgeStackId: EdgeStack['Id'];
environmentId: EnvironmentId;
}
async function collectLogs({ edgeStackId, environmentId }: CollectLogs) {
try {
await axios.put(`/edge_stacks/${edgeStackId}/logs/${environmentId}`);
} catch (error) {
throw parseAxiosError(error as Error, 'Unable to start logs collection');
}
}

View file

@ -0,0 +1,40 @@
import { useMutation, useQueryClient } from 'react-query';
import { EnvironmentId } from '@/react/portainer/environments/types';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withError } from '@/react-tools/react-query';
import { EdgeStack } from '../../types';
import { logsStatusQueryKey } from './useLogsStatus';
export function useDeleteLogsMutation() {
const queryClient = useQueryClient();
return useMutation(deleteLogs, {
onSuccess(data, variables) {
return queryClient.invalidateQueries(
logsStatusQueryKey(variables.edgeStackId, variables.environmentId)
);
},
...withError('Unable to delete logs'),
});
}
interface DeleteLogs {
edgeStackId: EdgeStack['Id'];
environmentId: EnvironmentId;
}
async function deleteLogs({ edgeStackId, environmentId }: DeleteLogs) {
try {
await axios.delete(`/edge_stacks/${edgeStackId}/logs/${environmentId}`, {
responseType: 'blob',
headers: {
Accept: 'text/yaml',
},
});
} catch (e) {
throw parseAxiosError(e as Error, '');
}
}

View file

@ -0,0 +1,41 @@
import { saveAs } from 'file-saver';
import { useMutation } from 'react-query';
import { EnvironmentId } from '@/react/portainer/environments/types';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { mutationOptions, withError } from '@/react-tools/react-query';
import { EdgeStack } from '../../types';
export function useDownloadLogsMutation() {
return useMutation(
downloadLogs,
mutationOptions(withError('Unable to download logs'))
);
}
interface DownloadLogs {
edgeStackId: EdgeStack['Id'];
environmentId: EnvironmentId;
}
async function downloadLogs({ edgeStackId, environmentId }: DownloadLogs) {
try {
const { headers, data } = await axios.get<Blob>(
`/edge_stacks/${edgeStackId}/logs/${environmentId}/file`,
{
responseType: 'blob',
headers: {
Accept: 'text/yaml',
},
}
);
const contentDispositionHeader = headers['content-disposition'];
const filename = contentDispositionHeader
.replace('attachment; filename=', '')
.trim();
saveAs(data, filename);
} catch (e) {
throw parseAxiosError(e as Error, '');
}
}

View file

@ -0,0 +1,51 @@
import { useQuery } from 'react-query';
import { EnvironmentId } from '@/react/portainer/environments/types';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EdgeStack } from '@/react/edge/edge-stacks/types';
import { queryKeys } from '../../queries/query-keys';
export function logsStatusQueryKey(
edgeStackId: EdgeStack['Id'],
environmentId: EnvironmentId
) {
return [...queryKeys.item(edgeStackId), 'logs', environmentId] as const;
}
export function useLogsStatus(
edgeStackId: EdgeStack['Id'],
environmentId: EnvironmentId
) {
return useQuery(
logsStatusQueryKey(edgeStackId, environmentId),
() => getLogsStatus(edgeStackId, environmentId),
{
refetchInterval(status) {
if (status === 'pending') {
return 30 * 1000;
}
return false;
},
}
);
}
interface LogsStatusResponse {
status: 'collected' | 'idle' | 'pending';
}
async function getLogsStatus(
edgeStackId: EdgeStack['Id'],
environmentId: EnvironmentId
) {
try {
const { data } = await axios.get<LogsStatusResponse>(
`/edge_stacks/${edgeStackId}/logs/${environmentId}`
);
return data.status;
} catch (error) {
throw parseAxiosError(error as Error, 'Unable to retrieve logs status');
}
}