diff --git a/src/components/ui/Pagination/NumberedPagination/NumberedPagination.spec.tsx b/src/components/ui/Pagination/NumberedPagination/NumberedPagination.spec.tsx
index a370739c..02777a14 100644
--- a/src/components/ui/Pagination/NumberedPagination/NumberedPagination.spec.tsx
+++ b/src/components/ui/Pagination/NumberedPagination/NumberedPagination.spec.tsx
@@ -29,6 +29,7 @@ const NumberedPaginationWithDefaultProps = (
onPageChange={props.onPageChange || (() => {})}
onItemsPerPageChange={props.onItemsPerPageChange || (() => {})}
style={props.style}
+ hasNextPage={props.hasNextPage}
/>
);
@@ -117,4 +118,50 @@ describe("NumberedPagination", () => {
expect(options[index].textContent).toBe(option.toString());
});
});
+
+ describe("API pagination mode (hasNextPage prop)", () => {
+ it("shows 'of N' total derived from itemsCount even when hasNextPage prop is provided", () => {
+ render(
+
,
+ );
+ expect(
+ TestUtils.select("NumberedPagination__PageNumber")?.textContent,
+ ).toBe("Page 1 of 10");
+ });
+
+ it("disables Next button when hasNextPage is false even if itemsCount suggests more pages", () => {
+ const onPageChange = jest.fn();
+ render(
+
,
+ );
+ const nextButton = screen.getByRole("button", { name: "Next" });
+ expect(nextButton).toHaveProperty("disabled", true);
+ });
+
+ it("enables Next button when hasNextPage is true", () => {
+ const onPageChange = jest.fn();
+ render(
+
,
+ );
+ const nextButton = screen.getByRole("button", { name: "Next" });
+ expect(nextButton).not.toHaveProperty("disabled", true);
+ });
+ });
});
diff --git a/src/components/ui/Pagination/NumberedPagination/NumberedPagination.tsx b/src/components/ui/Pagination/NumberedPagination/NumberedPagination.tsx
index 28fb60f8..f432d762 100644
--- a/src/components/ui/Pagination/NumberedPagination/NumberedPagination.tsx
+++ b/src/components/ui/Pagination/NumberedPagination/NumberedPagination.tsx
@@ -61,6 +61,7 @@ type Props = {
itemsPerPageOptions: number[];
onPageChange: (newPage: number) => void;
onItemsPerPageChange?: (event: React.ChangeEvent
) => void;
+ hasNextPage?: boolean;
};
@observer
@@ -68,7 +69,11 @@ class NumberedPagination extends React.Component {
render() {
const { itemsCount, currentPage, itemsPerPage } = this.props;
const totalPages = Math.max(1, Math.ceil(itemsCount / itemsPerPage));
- const hasNextPage = currentPage * itemsPerPage < itemsCount;
+ const computedHasNextPage = currentPage * itemsPerPage < itemsCount;
+ const hasNextPage =
+ this.props.hasNextPage !== undefined
+ ? this.props.hasNextPage
+ : computedHasNextPage;
return (
{
{
- if (currentPage < totalPages) {
+ if (hasNextPage) {
this.props.onPageChange(currentPage + 1);
}
}}
diff --git a/src/sources/DeploymentSource.ts b/src/sources/DeploymentSource.ts
index 05251d57..974f2ee8 100644
--- a/src/sources/DeploymentSource.ts
+++ b/src/sources/DeploymentSource.ts
@@ -59,13 +59,29 @@ class DeploymentSourceUtils {
}
class DeploymentSource {
- async getDeployments(skipLog?: boolean): Promise {
+ async getDeployments(options?: {
+ skipLog?: boolean;
+ quietError?: boolean;
+ limit?: number;
+ marker?: string | null;
+ }): Promise {
+ const params: string[] = [];
+ if (options?.marker) {
+ params.push(`marker=${encodeURIComponent(options.marker)}`);
+ }
+ if (options?.limit !== undefined) {
+ params.push(`limit=${options.limit}`);
+ }
+ const queryString = params.length > 0 ? `?${params.join("&")}` : "";
const response = await Api.send({
- url: `${configLoader.config.servicesUrls.coriolis}/${Api.projectId}/deployments`,
- skipLog,
+ url: `${configLoader.config.servicesUrls.coriolis}/${Api.projectId}/deployments${queryString}`,
+ skipLog: options?.skipLog,
+ quietError: options?.quietError,
});
const deployments = response.data.deployments;
- DeploymentSourceUtils.sortDeployments(deployments);
+ if (options?.limit === undefined) {
+ DeploymentSourceUtils.sortDeployments(deployments);
+ }
return deployments;
}
diff --git a/src/sources/TransferSource.ts b/src/sources/TransferSource.ts
index 66d2a85f..dc55fbb7 100644
--- a/src/sources/TransferSource.ts
+++ b/src/sources/TransferSource.ts
@@ -104,17 +104,29 @@ export class TransferSourceUtils {
}
class TransferSource {
- async getTransfers(
- skipLog?: boolean,
- quietError?: boolean,
- ): Promise {
+ async getTransfers(options?: {
+ skipLog?: boolean;
+ quietError?: boolean;
+ limit?: number;
+ marker?: string | null;
+ }): Promise {
+ const params: string[] = [];
+ if (options?.marker) {
+ params.push(`marker=${encodeURIComponent(options.marker)}`);
+ }
+ if (options?.limit !== undefined) {
+ params.push(`limit=${options.limit}`);
+ }
+ const queryString = params.length > 0 ? `?${params.join("&")}` : "";
const response = await Api.send({
- url: `${configLoader.config.servicesUrls.coriolis}/${Api.projectId}/transfers`,
- skipLog,
- quietError,
+ url: `${configLoader.config.servicesUrls.coriolis}/${Api.projectId}/transfers${queryString}`,
+ skipLog: options?.skipLog,
+ quietError: options?.quietError,
});
const transfers: TransferItem[] = response.data.transfers;
- TransferSourceUtils.sortTransfers(transfers);
+ if (options?.limit === undefined) {
+ TransferSourceUtils.sortTransfers(transfers);
+ }
return transfers;
}
@@ -144,6 +156,30 @@ class TransferSource {
return transfer;
}
+ async getExecutions(
+ transferId: string,
+ options?: {
+ limit?: number;
+ marker?: string | null;
+ quietError?: boolean;
+ },
+ ): Promise {
+ const params: string[] = [];
+ if (options?.marker) {
+ params.push(`marker=${encodeURIComponent(options.marker)}`);
+ }
+ if (options?.limit !== undefined) {
+ params.push(`limit=${options.limit}`);
+ }
+ const queryString = params.length > 0 ? `?${params.join("&")}` : "";
+ const response = await Api.send({
+ url: `${configLoader.config.servicesUrls.coriolis}/${Api.projectId}/transfers/${transferId}/executions${queryString}`,
+ quietError: options?.quietError,
+ });
+ const executions: Execution[] = response.data.executions;
+ return TransferSourceUtils.filterDeletedExecutions(executions);
+ }
+
async getExecutionTasks(options: {
transferId: string;
executionId?: string;
diff --git a/src/stores/DeploymentStore.ts b/src/stores/DeploymentStore.ts
index 2f24080e..a751358b 100644
--- a/src/stores/DeploymentStore.ts
+++ b/src/stores/DeploymentStore.ts
@@ -36,8 +36,37 @@ class DeploymentStore {
@observable detailsLoading = true;
+ @observable deploymentsPage = 1;
+
+ @observable deploymentsHasNextPage = false;
+
+ @observable deploymentsItemsPerPage = 25;
+
deploymentsLoaded = false;
+ private deploymentPageMarkers: (string | null)[] = [null];
+
+ @action resetDeploymentPagination(): void {
+ this.deploymentsPage = 1;
+ this.deploymentsHasNextPage = false;
+ this.deploymentPageMarkers = [null];
+ }
+
+ @action async setDeploymentsPage(page: number): Promise {
+ this.deploymentsPage = page;
+ await this.getDeployments({ showLoading: true });
+ }
+
+ @action async setDeploymentsItemsPerPage(
+ itemsPerPage: number,
+ ): Promise {
+ this.deploymentsItemsPerPage = itemsPerPage;
+ this.deploymentsPage = 1;
+ this.deploymentPageMarkers = [null];
+ this.deploymentsHasNextPage = false;
+ await this.getDeployments({ showLoading: true });
+ }
+
@action async getDeployments(options?: {
showLoading?: boolean;
skipLog?: boolean;
@@ -46,16 +75,44 @@ class DeploymentStore {
this.loading = true;
}
+ const marker = this.deploymentPageMarkers[this.deploymentsPage - 1] ?? null;
+ const isPaginationRequest = marker !== null;
+
try {
- const deployments = await DeploymentSource.getDeployments(
- options && options.skipLog,
- );
+ const raw = await DeploymentSource.getDeployments({
+ skipLog: options?.skipLog,
+ quietError: isPaginationRequest,
+ limit: this.deploymentsItemsPerPage,
+ marker,
+ });
+ if (isPaginationRequest && raw.length === 0) {
+ runInAction(() => {
+ this.deploymentsHasNextPage = false;
+ this.deploymentsPage = Math.max(1, this.deploymentsPage - 1);
+ this.loading = false;
+ });
+ return;
+ }
+ const hasNextPage = raw.length === this.deploymentsItemsPerPage;
+ const nextMarker = raw.length > 0 ? raw[raw.length - 1].id : null;
runInAction(() => {
- this.deployments = deployments;
+ this.deployments = raw;
+ this.deploymentsHasNextPage = hasNextPage;
+ if (nextMarker !== null) {
+ this.deploymentPageMarkers[this.deploymentsPage] = nextMarker;
+ }
this.loading = false;
this.deploymentsLoaded = true;
});
} catch (ex) {
+ if (isPaginationRequest) {
+ runInAction(() => {
+ this.deploymentsHasNextPage = false;
+ this.deploymentsPage = Math.max(1, this.deploymentsPage - 1);
+ this.loading = false;
+ });
+ return;
+ }
runInAction(() => {
this.loading = false;
});
diff --git a/src/stores/TransferStore.ts b/src/stores/TransferStore.ts
index 1c660555..396a10f6 100644
--- a/src/stores/TransferStore.ts
+++ b/src/stores/TransferStore.ts
@@ -14,7 +14,10 @@ along with this program. If not, see .
import { observable, action, runInAction } from "mobx";
-import TransferSource from "@src/sources/TransferSource";
+import TransferSource, {
+ TransferSourceUtils,
+ sortTasks,
+} from "@src/sources/TransferSource";
import type {
UpdateData,
TransferItem,
@@ -69,9 +72,115 @@ class TransferStore {
@observable transfersWithDisksLoading = false;
+ @observable transfersPage = 1;
+
+ @observable transfersHasNextPage = false;
+
+ @observable transfersItemsPerPage = 25;
+
transfersLoaded = false;
- addExecution: { transferId: string; execution: Execution } | null = null;
+ private transferPageMarkers: (string | null)[] = [null];
+
+ @observable executionsList: Execution[] = [];
+
+ @observable executionsHasOlderPage = false;
+
+ @observable executionsLoading = false;
+
+ executionsPageSize = 10;
+
+ @action resetTransferPagination(): void {
+ this.transfersPage = 1;
+ this.transfersHasNextPage = false;
+ this.transferPageMarkers = [null];
+ }
+
+ @action resetExecutionsPagination(): void {
+ this.executionsList = [];
+ this.executionsHasOlderPage = false;
+ this.executionsLoading = false;
+ }
+
+ @action async getTransferExecutions(options?: {
+ showLoading?: boolean;
+ polling?: boolean;
+ }): Promise {
+ const transferId = this.transferDetails?.id;
+ if (!transferId) {
+ return;
+ }
+
+ if (options?.showLoading) {
+ this.executionsLoading = true;
+ }
+
+ try {
+ const raw = await TransferSource.getExecutions(transferId, {
+ limit: this.executionsPageSize,
+ });
+ const hasOlderPage = raw.length === this.executionsPageSize;
+ TransferSourceUtils.sortExecutions(raw);
+ runInAction(() => {
+ this.executionsList = raw;
+ this.executionsHasOlderPage = hasOlderPage;
+ this.executionsLoading = false;
+ });
+ } catch (err) {
+ runInAction(() => {
+ this.executionsLoading = false;
+ });
+ console.error(err);
+ }
+ }
+
+ @action async loadOlderExecutions(): Promise {
+ const transferId = this.transferDetails?.id;
+ if (!transferId || !this.executionsHasOlderPage || this.executionsLoading) {
+ return;
+ }
+
+ const marker = this.executionsList[0]?.id;
+ if (!marker) {
+ return;
+ }
+
+ this.executionsLoading = true;
+
+ try {
+ const raw = await TransferSource.getExecutions(transferId, {
+ limit: this.executionsPageSize,
+ marker,
+ quietError: true,
+ });
+ const hasOlderPage = raw.length === this.executionsPageSize;
+ TransferSourceUtils.sortExecutions(raw);
+ runInAction(() => {
+ this.executionsList = [...raw, ...this.executionsList];
+ this.executionsHasOlderPage = hasOlderPage;
+ this.executionsLoading = false;
+ });
+ } catch (err) {
+ runInAction(() => {
+ this.executionsHasOlderPage = false;
+ this.executionsLoading = false;
+ });
+ console.error(err);
+ }
+ }
+
+ @action async setTransfersPage(page: number): Promise {
+ this.transfersPage = page;
+ await this.getTransfers({ showLoading: true });
+ }
+
+ @action async setTransfersItemsPerPage(itemsPerPage: number): Promise {
+ this.transfersItemsPerPage = itemsPerPage;
+ this.transfersPage = 1;
+ this.transferPageMarkers = [null];
+ this.transfersHasNextPage = false;
+ await this.getTransfers({ showLoading: true });
+ }
@action async getTransfers(options?: {
showLoading?: boolean;
@@ -84,12 +193,35 @@ class TransferStore {
this.loading = true;
}
+ const marker = this.transferPageMarkers[this.transfersPage - 1] ?? null;
+ const isPaginationRequest = marker !== null;
+
try {
- const transfers = await TransferSource.getTransfers(
- options && options.skipLog,
- options && options.quietError,
- );
- this.getTransfersSuccess(transfers);
+ const raw = await TransferSource.getTransfers({
+ skipLog: options?.skipLog,
+ quietError: options?.quietError || isPaginationRequest,
+ limit: this.transfersItemsPerPage,
+ marker,
+ });
+ if (isPaginationRequest && raw.length === 0) {
+ runInAction(() => {
+ this.transfersHasNextPage = false;
+ this.transfersPage = Math.max(1, this.transfersPage - 1);
+ });
+ return;
+ }
+ const hasNextPage = raw.length === this.transfersItemsPerPage;
+ const nextMarker = raw.length > 0 ? raw[raw.length - 1].id : null;
+ this.getTransfersSuccess(raw, hasNextPage, nextMarker);
+ } catch (err) {
+ if (isPaginationRequest) {
+ runInAction(() => {
+ this.transfersHasNextPage = false;
+ this.transfersPage = Math.max(1, this.transfersPage - 1);
+ });
+ return;
+ }
+ throw err;
} finally {
this.getTransfersDone();
}
@@ -128,6 +260,29 @@ class TransferStore {
runInAction(() => {
this.transferDetails = transfer;
+ let statusChanged = false;
+ const updatedList = this.executionsList.map(e => {
+ const fresh = transfer.executions?.find(te => te.id === e.id);
+ if (fresh && fresh.status !== e.status) {
+ statusChanged = true;
+ return { ...e, status: fresh.status };
+ }
+ return e;
+ });
+ if (statusChanged) {
+ this.executionsList = updatedList;
+ }
+
+ transfer.executions?.forEach(exec => {
+ const withTasks = exec as ExecutionTasks;
+ if (
+ Array.isArray(withTasks.tasks) &&
+ !this.executionsTasks.find(et => et.id === exec.id)
+ ) {
+ sortTasks(withTasks.tasks, TransferSourceUtils.sortTaskUpdates);
+ this.executionsTasks = [...this.executionsTasks, withTasks];
+ }
+ });
});
} finally {
runInAction(() => {
@@ -139,11 +294,20 @@ class TransferStore {
@action clearDetails() {
this.transferDetails = null;
this.currentlyLoadingExecution = "";
+ this.executionsTasks = [];
}
- @action getTransfersSuccess(transfers: TransferItem[]) {
+ @action getTransfersSuccess(
+ transfers: TransferItem[],
+ hasNextPage = false,
+ nextMarker: string | null = null,
+ ) {
this.transfersLoaded = true;
this.transfers = transfers;
+ this.transfersHasNextPage = hasNextPage;
+ if (nextMarker !== null) {
+ this.transferPageMarkers[this.transfersPage] = nextMarker;
+ }
}
@action getTransfersDone() {
@@ -151,7 +315,7 @@ class TransferStore {
this.backgroundLoading = false;
}
- private currentlyLoadingExecution = "";
+ currentlyLoadingExecution = "";
@action async getExecutionTasks(options: {
transferId: string;
@@ -171,8 +335,13 @@ class TransferStore {
}
if (
- !this.executionsTasks.find(e => e.id === this.currentlyLoadingExecution)
+ !polling &&
+ this.executionsTasks.find(e => e.id === this.currentlyLoadingExecution)
) {
+ return;
+ }
+
+ if (!polling) {
this.executionsTasksLoading = true;
}
@@ -213,6 +382,18 @@ class TransferStore {
execution,
);
this.transferDetails = updatedTransfer;
+
+ if (!this.executionsList.find(e => e.id === execution.id)) {
+ this.executionsList = [...this.executionsList, execution];
+ }
+
+ const withTasks = execution as ExecutionTasks;
+ if (Array.isArray(withTasks.tasks)) {
+ this.executionsTasks = [
+ ...this.executionsTasks.filter(e => e.id !== execution.id),
+ withTasks,
+ ];
+ }
}
this.getExecutionTasks({
transferId: transferId,
@@ -228,6 +409,13 @@ class TransferStore {
force?: boolean;
}): Promise {
await TransferSource.cancelExecution(options);
+ runInAction(() => {
+ if (options.executionId) {
+ this.executionsList = this.executionsList.map(e =>
+ e.id === options.executionId ? { ...e, status: "CANCELLING" } : e,
+ );
+ }
+ });
if (options.force) {
notificationStore.alert("Force cancelled", "success");
} else {
@@ -241,6 +429,13 @@ class TransferStore {
): Promise {
await TransferSource.deleteExecution(transferId, executionId);
this.deleteExecutionSuccess(transferId, executionId);
+ if (
+ this.executionsList.length === 0 &&
+ this.transferDetails?.id === transferId
+ ) {
+ this.resetExecutionsPagination();
+ await this.getTransferExecutions({ showLoading: true });
+ }
}
@action deleteExecutionSuccess(transferId: string, executionId: string) {
@@ -252,6 +447,10 @@ class TransferStore {
];
this.transferDetails.executions = executions;
}
+ this.executionsList = this.executionsList.filter(e => e.id !== executionId);
+ this.executionsTasks = this.executionsTasks.filter(
+ e => e.id !== executionId,
+ );
if (executionId === this.currentlyLoadingExecution) {
this.currentlyLoadingExecution = "";
}