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:
parent
dfc1a7b1d7
commit
11571fd6ea
24 changed files with 652 additions and 281 deletions
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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(', ');
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { EnvironmentsDatatable } from './EnvironmentsDatatable';
|
|
@ -0,0 +1,7 @@
|
|||
import { Environment } from '@/react/portainer/environments/types';
|
||||
|
||||
import { EdgeStackStatus } from '../../types';
|
||||
|
||||
export type EdgeStackEnvironment = Environment & {
|
||||
StackStatus: EdgeStackStatus;
|
||||
};
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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, '');
|
||||
}
|
||||
}
|
|
@ -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, '');
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue