1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-06 06:15:22 +02:00

fix(app/edge-jobs): edge job results page crash at scale (#954)

This commit is contained in:
LP B 2025-08-04 17:10:46 +02:00 committed by GitHub
parent d306d7a983
commit a472de1919
27 changed files with 2595 additions and 107 deletions

View file

@ -1,18 +1,16 @@
import { List } from 'lucide-react';
import { useMemo } from 'react';
import { Environment } from '@/react/portainer/environments/types';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { queryOptionsFromTableState } from '@/react/common/api/listQueryParams';
import { Datatable } from '@@/datatables';
import { useTableState } from '@@/datatables/useTableState';
import { withMeta } from '@@/datatables/extend-options/withMeta';
import { mergeOptions } from '@@/datatables/extend-options/mergeOptions';
import { EdgeJob, JobResult, LogsStatus } from '../../types';
import { EdgeJob, LogsStatus } from '../../types';
import { useJobResults } from '../../queries/jobResults/useJobResults';
import { columns } from './columns';
import { columns, sortOptions } from './columns';
import { createStore } from './datatable-store';
const tableKey = 'edge-job-results';
@ -22,8 +20,9 @@ export function ResultsDatatable({ jobId }: { jobId: EdgeJob['Id'] }) {
const tableState = useTableState(store, tableKey);
const jobResultsQuery = useJobResults(jobId, {
...queryOptionsFromTableState({ ...tableState }, sortOptions),
refetchInterval(dataset) {
const anyCollecting = dataset?.some(
const anyCollecting = dataset?.data.some(
(r) => r.LogsStatus === LogsStatus.Pending
);
@ -35,40 +34,17 @@ export function ResultsDatatable({ jobId }: { jobId: EdgeJob['Id'] }) {
},
});
const environmentIds = jobResultsQuery.data?.map(
(result) => result.EndpointId
);
const environmentsQuery = useEnvironmentList(
{ endpointIds: environmentIds },
{ enabled: !!environmentIds && !jobResultsQuery.isLoading }
);
const dataset = useMemo(
() =>
jobResultsQuery.isLoading || environmentsQuery.isLoading
? []
: associateEndpointsToResults(
jobResultsQuery.data || [],
environmentsQuery.environments
),
[
environmentsQuery.environments,
environmentsQuery.isLoading,
jobResultsQuery.data,
jobResultsQuery.isLoading,
]
);
const dataset = jobResultsQuery.data?.data || [];
return (
<Datatable
disableSelect
columns={columns}
dataset={dataset}
isLoading={jobResultsQuery.isLoading || environmentsQuery.isLoading}
title="Results"
titleIcon={List}
columns={columns}
disableSelect
dataset={dataset}
settingsManager={tableState}
isLoading={jobResultsQuery.isLoading}
extendTableOptions={mergeOptions(
withMeta({
table: 'edge-job-results',
@ -76,21 +52,11 @@ export function ResultsDatatable({ jobId }: { jobId: EdgeJob['Id'] }) {
})
)}
data-cy="edge-job-results-datatable"
isServerSidePagination
page={tableState.page}
onPageChange={tableState.setPage}
onSearchChange={() => tableState.setPage(0)}
totalCount={jobResultsQuery.data?.totalCount || 0}
/>
);
}
function associateEndpointsToResults(
results: Array<JobResult>,
environments: Array<Environment>
) {
return results.map((result) => {
const environment = environments.find(
(environment) => environment.Id === result.EndpointId
);
return {
...result,
Endpoint: environment,
};
});
}

View file

@ -1,18 +1,20 @@
import { CellContext, createColumnHelper } from '@tanstack/react-table';
import { sortOptionsFromColumns } from '@/react/common/api/sort.types';
import { Button } from '@@/buttons';
import { LogsStatus } from '../../types';
import { JobResult, LogsStatus } from '../../types';
import { useDownloadLogsMutation } from '../../queries/jobResults/useDownloadLogsMutation';
import { useClearLogsMutation } from '../../queries/jobResults/useClearLogsMutation';
import { useCollectLogsMutation } from '../../queries/jobResults/useCollectLogsMutation';
import { DecoratedJobResult, getTableMeta } from './types';
import { getTableMeta } from './types';
const columnHelper = createColumnHelper<DecoratedJobResult>();
const columnHelper = createColumnHelper<JobResult>();
export const columns = [
columnHelper.accessor('Endpoint.Name', {
columnHelper.accessor('EndpointName', {
header: 'Environment',
meta: {
className: 'w-1/2',
@ -30,7 +32,7 @@ export const columns = [
function ActionsCell({
row: { original: item },
table,
}: CellContext<DecoratedJobResult, unknown>) {
}: CellContext<JobResult, unknown>) {
const tableMeta = getTableMeta(table.options.meta);
const id = tableMeta.jobId;
@ -51,13 +53,13 @@ function ActionsCell({
<>
<Button
onClick={() => downloadLogsMutation.mutate(item.EndpointId)}
data-cy={`edge-job-download-logs-${item.Endpoint?.Name}`}
data-cy={`edge-job-download-logs-${item.EndpointName}`}
>
Download logs
</Button>
<Button
onClick={() => clearLogsMutations.mutate(item.EndpointId)}
data-cy={`edge-job-clear-logs-${item.Endpoint?.Name}`}
data-cy={`edge-job-clear-logs-${item.EndpointName}`}
>
Clear logs
</Button>
@ -68,10 +70,12 @@ function ActionsCell({
return (
<Button
onClick={() => collectLogsMutation.mutate(item.EndpointId)}
data-cy={`edge-job-retrieve-logs-${item.Endpoint?.Name}`}
data-cy={`edge-job-retrieve-logs-${item.EndpointName}`}
>
Retrieve logs
</Button>
);
}
}
export const sortOptions = sortOptionsFromColumns(columns);

View file

@ -3,12 +3,18 @@ import {
createPersistedStore,
BasicTableSettings,
RefreshableTableSettings,
BackendPaginationTableSettings,
backendPaginationSettings,
} from '@@/datatables/types';
interface TableSettings extends BasicTableSettings, RefreshableTableSettings {}
interface TableSettings
extends BasicTableSettings,
RefreshableTableSettings,
BackendPaginationTableSettings {}
export function createStore(storageKey: string) {
return createPersistedStore<TableSettings>(storageKey, undefined, (set) => ({
...refreshableSettings(set),
...backendPaginationSettings(set),
}));
}

View file

@ -1,10 +1,4 @@
import { Environment } from '@/react/portainer/environments/types';
import { EdgeJob, JobResult } from '../../types';
export interface DecoratedJobResult extends JobResult {
Endpoint?: Environment;
}
import { EdgeJob } from '../../types';
interface TableMeta {
table: 'edge-job-results';

View file

@ -1,35 +1,54 @@
import { useQuery } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import {
PaginatedResults,
withPaginationHeaders,
} from '@/react/common/api/pagination.types';
import {
BaseQueryOptions,
BaseQueryParams,
queryParamsFromQueryOptions,
} from '@/react/common/api/listQueryParams';
import { EdgeJob, JobResult } from '../../types';
import { sortOptions } from '../../ItemView/ResultsDatatable/columns';
import { queryKeys } from './query-keys';
import { buildUrl } from './build-url';
type QueryOptions = BaseQueryOptions<typeof sortOptions>;
type RefetchInterval =
| number
| false
| ((data: PaginatedResults<Array<JobResult>> | undefined) => number | false);
export function useJobResults(
id: EdgeJob['Id'],
{
refetchInterval,
...query
}: {
refetchInterval?:
| number
| false
| ((data: Array<JobResult> | undefined) => number | false);
} = {}
refetchInterval?: RefetchInterval;
} & QueryOptions = {}
) {
return useQuery({
queryKey: queryKeys.base(id),
queryFn: () => getJobResults(id),
queryKey: [...queryKeys.base(id), query],
queryFn: () => getJobResults(id, queryParamsFromQueryOptions(query)),
refetchInterval,
});
}
async function getJobResults(id: EdgeJob['Id']) {
try {
const { data } = await axios.get<Array<JobResult>>(buildUrl({ id }));
type QueryParams = BaseQueryParams<typeof sortOptions>;
return data;
async function getJobResults(id: EdgeJob['Id'], params?: QueryParams) {
try {
const response = await axios.get<Array<JobResult>>(
`edge_jobs/${id}/tasks`,
{ params }
);
return withPaginationHeaders(response);
} catch (err) {
throw parseAxiosError(err, 'Failed fetching edge job results');
}

View file

@ -1,4 +1,7 @@
import { EnvironmentId } from '@/react/portainer/environments/types';
import {
Environment,
EnvironmentId,
} from '@/react/portainer/environments/types';
export interface EdgeJob {
Id: number;
@ -28,5 +31,6 @@ interface EndpointMeta {
export interface JobResult {
Id: string;
EndpointId: EnvironmentId;
EndpointName: Environment['Name'];
LogsStatus: LogsStatus;
}