Skip to content

Commit 0337d14

Browse files
committed
[Nu-7702] Warn user when remote scenario version is newer
1 parent 2314ca7 commit 0337d14

File tree

12 files changed

+665
-24
lines changed

12 files changed

+665
-24
lines changed

designer/client/src/components/toolbars/process/buttons/SaveButton.tsx

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,67 @@
1+
import { defaults } from "lodash";
12
import React from "react";
23
import { useTranslation } from "react-i18next";
34
import { useSelector } from "react-redux";
5+
46
import Icon from "../../../../assets/img/toolbarButtons/save.svg";
5-
import { getProcessName, getProcessUnsavedNewName, isProcessRenamed, isSaveDisabled } from "../../../../reducers/selectors/graph";
7+
import HttpService from "../../../../http/HttpService";
8+
import {
9+
getProcessName,
10+
getProcessUnsavedNewName,
11+
getProcessVersionId,
12+
isProcessRenamed,
13+
isSaveDisabled,
14+
} from "../../../../reducers/selectors/graph";
615
import { getCapabilities } from "../../../../reducers/selectors/other";
716
import { useWindows, WindowKind } from "../../../../windowManager";
817
import { ToolbarButton } from "../../../toolbarComponents/toolbarButtons";
9-
import { ToolbarButtonProps } from "../../types";
18+
import type { ToolbarButtonProps } from "../../types";
1019
function SaveButton(props: ToolbarButtonProps): JSX.Element {
1120
const { t } = useTranslation();
1221
const { disabled, type } = props;
1322
const capabilities = useSelector(getCapabilities);
1423
const saveDisabled = useSelector(isSaveDisabled);
1524

1625
const processName = useSelector(getProcessName);
26+
const processVersionId = useSelector(getProcessVersionId);
1727
const unsavedNewName = useSelector(getProcessUnsavedNewName);
1828
const isRenamed = useSelector(isProcessRenamed);
1929
const title = isRenamed
2030
? t("saveProcess.renameTitle", "Save scenario as {{name}}", { name: unsavedNewName })
2131
: t("saveProcess.title", "Save scenario {{name}}", { name: processName });
2232

23-
const { open } = useWindows();
24-
const onClick = () =>
25-
open({
26-
title,
27-
isModal: true,
28-
shouldCloseOnEsc: true,
29-
kind: WindowKind.saveProcess,
33+
const { open, confirm } = useWindows();
34+
const onClick = async () => {
35+
await HttpService.validateProcessVersion(processName, processVersionId).then((res) => {
36+
if (!res.data.isLatest) {
37+
confirm({
38+
text: t(
39+
"panels.actions.confirm-unsafe-save.message",
40+
`Your local scenario version #${processVersionId} is outdated. There is newer version #${res.data.latestVersion} available. Are you sure you want to override it?`,
41+
),
42+
confirmText: t("panels.actions.confirm-unsafe-save.confirmButton", "Confirm"),
43+
denyText: t("panels.actions.confirm-unsafe-save.cancelButton", "Cancel"),
44+
onConfirmCallback: (confirmed) => {
45+
if (confirmed) {
46+
open({
47+
title,
48+
isModal: true,
49+
shouldCloseOnEsc: true,
50+
kind: WindowKind.saveProcess,
51+
});
52+
}
53+
},
54+
});
55+
} else {
56+
open({
57+
title,
58+
isModal: true,
59+
shouldCloseOnEsc: true,
60+
kind: WindowKind.saveProcess,
61+
});
62+
}
3063
});
64+
};
3165

3266
const available = !disabled && !saveDisabled && capabilities.write;
3367

designer/client/src/components/toolbars/scenarioActions/buttons/DeployButton.tsx

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,27 @@
11
import React from "react";
22
import { useTranslation } from "react-i18next";
33
import { useDispatch, useSelector } from "react-redux";
4+
45
import { disableToolTipsHighlight, enableToolTipsHighlight, loadProcessState } from "../../../../actions/nk";
56
import Icon from "../../../../assets/img/toolbarButtons/deploy.svg";
6-
import HttpService, { NodesDeploymentData } from "../../../../http/HttpService";
7+
import type { NodesDeploymentData } from "../../../../http/HttpService";
8+
import HttpService from "../../../../http/HttpService";
79
import {
810
getProcessName,
11+
getProcessVersionId,
912
hasError,
1013
isDeployPossible,
1114
isSaveDisabled,
1215
isValidationResultPresent,
1316
} from "../../../../reducers/selectors/graph";
1417
import { getCapabilities } from "../../../../reducers/selectors/other";
18+
import { ACTION_DIALOG_WIDTH } from "../../../../stylesheets/variables";
1519
import { useWindows } from "../../../../windowManager";
1620
import { WindowKind } from "../../../../windowManager";
17-
import { ToggleProcessActionModalData } from "../../../modals/DeployProcessDialog";
21+
import type { ToggleProcessActionModalData } from "../../../modals/DeployProcessDialog";
22+
import type { ProcessName, ProcessVersionId } from "../../../Process/types";
1823
import { ToolbarButton } from "../../../toolbarComponents/toolbarButtons";
19-
import { ToolbarButtonProps } from "../../types";
20-
import { ACTION_DIALOG_WIDTH } from "../../../../stylesheets/variables";
21-
import { ProcessName, ProcessVersionId } from "../../../Process/types";
24+
import type { ToolbarButtonProps } from "../../types";
2225

2326
export default function DeployButton(props: ToolbarButtonProps) {
2427
const dispatch = useDispatch();
@@ -27,6 +30,7 @@ export default function DeployButton(props: ToolbarButtonProps) {
2730
const hasErrors = useSelector(hasError);
2831
const validationResultPresent = useSelector(isValidationResultPresent);
2932
const processName = useSelector(getProcessName);
33+
const processVersionId = useSelector(getProcessVersionId);
3034
const capabilities = useSelector(getCapabilities);
3135
const { disabled, type } = props;
3236

@@ -42,26 +46,51 @@ export default function DeployButton(props: ToolbarButtonProps) {
4246
const deployMouseOver = hasErrors ? () => dispatch(enableToolTipsHighlight()) : null;
4347
const deployMouseOut = hasErrors ? () => dispatch(disableToolTipsHighlight()) : null;
4448

45-
const { open } = useWindows();
49+
const { open, confirm } = useWindows();
4650

4751
const message = t("panels.actions.deploy.dialog", "Deploy scenario {{name}}", { name: processName });
4852
const action = (name: ProcessName, versionId: ProcessVersionId, comment: string, nodesDeploymentData?: NodesDeploymentData) =>
4953
HttpService.deploy(name, comment, nodesDeploymentData).finally(() => dispatch(loadProcessState(name, versionId)));
5054

51-
return (
52-
<ToolbarButton
53-
name={t("panels.actions.deploy.button", "deploy")}
54-
disabled={!available}
55-
icon={<Icon />}
56-
title={deployToolTip}
57-
onClick={() =>
55+
const handleOnClick = async () => {
56+
await HttpService.validateProcessVersion(processName, processVersionId).then((res) => {
57+
if (res.data.latestDeployedVersion !== null && res.data.latestDeployedVersion > processVersionId) {
58+
confirm({
59+
text: t(
60+
"panels.actions.confirm-unsafe-deployment.message",
61+
`You're trying to deploy scenario in version #${processVersionId} while there is ongoing deployment in newer version #${res.data.latestDeployedVersion}. Are you sure you would like to perform this action?`,
62+
),
63+
confirmText: t("panels.actions.confirm-unsafe-deployment.confirmButton", "Confirm"),
64+
denyText: t("panels.actions.confirm-unsafe-deployment.cancelButton", "Cancel"),
65+
onConfirmCallback: (confirmed) => {
66+
if (confirmed) {
67+
open<ToggleProcessActionModalData>({
68+
title: message,
69+
kind: WindowKind.deployWithParameters,
70+
width: ACTION_DIALOG_WIDTH,
71+
meta: { action, displayWarnings: true },
72+
});
73+
}
74+
},
75+
});
76+
} else {
5877
open<ToggleProcessActionModalData>({
5978
title: message,
6079
kind: WindowKind.deployWithParameters,
6180
width: ACTION_DIALOG_WIDTH,
6281
meta: { action, displayWarnings: true },
63-
})
82+
});
6483
}
84+
});
85+
};
86+
87+
return (
88+
<ToolbarButton
89+
name={t("panels.actions.deploy.button", "deploy")}
90+
disabled={!available}
91+
icon={<Icon />}
92+
title={deployToolTip}
93+
onClick={handleOnClick}
6594
onMouseOver={deployMouseOver}
6695
onMouseOut={deployMouseOut}
6796
type={type}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export type ProcessVersionValidationResponse = {
2+
processName: string;
3+
isLatest: boolean;
4+
localVersion: number;
5+
latestDeployedVersion: number | null;
6+
latestVersion: number;
7+
};

designer/client/src/http/HttpService.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { handleAxiosError } from "../devHelpers";
3131
import { Dimensions, StickyNote } from "../common/StickyNote";
3232
import { STICKY_NOTE_DEFAULT_COLOR } from "../components/graph/EspNode/stickyNote";
3333
import { ScenarioActionResult, ScenarioActionResultType } from "../components/toolbars/scenarioActions/buttons/types";
34+
import { ProcessVersionValidationResponse } from "../components/versionControl/types";
3435

3536
type HealthCheckProcessDeploymentType = {
3637
status: string;
@@ -596,6 +597,17 @@ class HttpService {
596597
);
597598
}
598599

600+
validateProcessVersion(processName: string, localVersion: number): Promise<AxiosResponse<ProcessVersionValidationResponse>> {
601+
const data = { localVersion: localVersion };
602+
return api
603+
.post<ProcessVersionValidationResponse>(`/versionControl/${processName}/versionValidation`, data)
604+
.catch((error) =>
605+
Promise.reject(
606+
this.#addError(i18next.t("notification.error.cannotValidateProcessVersion", "Cannot validate process version"), error),
607+
),
608+
);
609+
}
610+
599611
getExpressionSuggestions(processingType: string, request: ExpressionSuggestionRequest): Promise<AxiosResponse<ExpressionSuggestion[]>> {
600612
const promise = api.post<ExpressionSuggestion[]>(`/parameters/${encodeURIComponent(processingType)}/suggestions`, request);
601613
promise.catch((error) =>
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package pl.touk.nussknacker.ui.api
2+
3+
import cats.data.EitherT
4+
import com.typesafe.scalalogging.LazyLogging
5+
import pl.touk.nussknacker.engine.api.process.{ProcessId, ProcessIdWithName, ProcessName}
6+
import pl.touk.nussknacker.ui.api.description.VersionControlApiEndpoints
7+
import pl.touk.nussknacker.ui.api.description.VersionControlApiEndpoints.Dtos._
8+
import pl.touk.nussknacker.ui.api.description.VersionControlApiEndpoints.VersionControlError
9+
import pl.touk.nussknacker.ui.api.description.VersionControlApiEndpoints.VersionControlError.{
10+
MissingProcessId,
11+
MissingProcessVersion
12+
}
13+
import pl.touk.nussknacker.ui.process.{ProcessService, ScenarioQuery, ScenarioVersionQuery}
14+
import pl.touk.nussknacker.ui.process.ProcessService.{GetScenarioWithDetailsOptions, SkipScenarioGraph}
15+
import pl.touk.nussknacker.ui.process.repository.ScenarioVersionMetadata
16+
import pl.touk.nussknacker.ui.security.api.AuthManager
17+
18+
import scala.concurrent.{ExecutionContext, Future}
19+
20+
class VersionControlApiHttpService(
21+
authManager: AuthManager,
22+
processService: ProcessService
23+
)(implicit executionContext: ExecutionContext)
24+
extends BaseHttpService(authManager)
25+
with LazyLogging {
26+
private val securityInput = authManager.authenticationEndpointInput()
27+
28+
private val endpoints = new VersionControlApiEndpoints(securityInput)
29+
30+
expose {
31+
endpoints.versionValidationEndpoint
32+
.serverSecurityLogic(authorizeKnownUser[VersionControlError])
33+
.serverLogicEitherT { implicit loggedUser =>
34+
{ case (scenarioName, processVersionValidationRequest) =>
35+
for {
36+
pid <- EitherT.fromOptionF(processService.getProcessId(scenarioName), MissingProcessId(scenarioName.value))
37+
38+
processWithDetails <- EitherT.right(
39+
processService.getLatestProcessWithDetails(
40+
ProcessIdWithName(pid, scenarioName),
41+
GetScenarioWithDetailsOptions(SkipScenarioGraph, false)
42+
)
43+
)
44+
45+
localVersion = processVersionValidationRequest.localVersion
46+
latestDeployedVersion = processWithDetails.lastDeployedAction.map(_.processVersionId).map(_.value)
47+
latestVersion = processWithDetails.processVersionId.value
48+
49+
response = ProcessVersionValidationResponseDto(
50+
processName = scenarioName.value,
51+
isLatest = localVersion == latestVersion,
52+
localVersion = localVersion,
53+
latestDeployedVersion = latestDeployedVersion,
54+
latestVersion = latestVersion
55+
)
56+
} yield response
57+
}
58+
}
59+
}
60+
61+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package pl.touk.nussknacker.ui.api.description
2+
3+
import derevo.circe.{decoder, encoder}
4+
import derevo.derive
5+
import pl.touk.nussknacker.engine.api.process.ProcessName
6+
import pl.touk.nussknacker.restmodel.BaseEndpointDefinitions
7+
import pl.touk.nussknacker.restmodel.BaseEndpointDefinitions.SecuredEndpoint
8+
import pl.touk.nussknacker.security.AuthCredentials
9+
import pl.touk.nussknacker.ui.api.TapirCodecs.ScenarioNameCodec._
10+
import pl.touk.nussknacker.ui.api.description.VersionControlApiEndpoints.VersionControlError
11+
import pl.touk.nussknacker.ui.api.description.VersionControlApiEndpoints.VersionControlError.{
12+
MissingProcessId,
13+
MissingProcessVersion
14+
}
15+
import sttp.model.StatusCode.BadRequest
16+
import sttp.tapir._
17+
import sttp.tapir.EndpointIO.Example
18+
import sttp.tapir.derevo.schema
19+
import sttp.tapir.json.circe._
20+
21+
class VersionControlApiEndpoints(auth: EndpointInput[AuthCredentials]) extends BaseEndpointDefinitions {
22+
23+
import VersionControlApiEndpoints.Dtos._
24+
25+
lazy val versionValidationEndpoint: SecuredEndpoint[
26+
(ProcessName, ProcessVersionValidationRequestDto),
27+
VersionControlError,
28+
ProcessVersionValidationResponseDto,
29+
Any
30+
] =
31+
baseNuApiEndpoint
32+
.summary("Checks if local scenario version is the newest one")
33+
.tag("Version Control")
34+
.post
35+
.in("versionControl" / path[ProcessName]("scenarioName") / "versionValidation")
36+
.in(
37+
jsonBody[ProcessVersionValidationRequestDto]
38+
.example(
39+
ProcessVersionValidationRequestDto(
40+
localVersion = 24
41+
)
42+
)
43+
)
44+
.out(
45+
jsonBody[ProcessVersionValidationResponseDto]
46+
.examples(
47+
List(
48+
Example.of(
49+
name = Some("Newer process version is available"),
50+
value = ProcessVersionValidationResponseDto(
51+
processName = "p1",
52+
isLatest = false,
53+
localVersion = 51,
54+
latestDeployedVersion = None,
55+
latestVersion = 52
56+
)
57+
),
58+
Example.of(
59+
name = Some("Process local version is the latest one"),
60+
value = ProcessVersionValidationResponseDto(
61+
processName = "p2",
62+
isLatest = true,
63+
localVersion = 52,
64+
latestDeployedVersion = Some(48),
65+
latestVersion = 52
66+
)
67+
)
68+
)
69+
)
70+
)
71+
.errorOut(
72+
oneOf[VersionControlError](
73+
oneOfVariant(
74+
BadRequest,
75+
plainBody[MissingProcessVersion]
76+
),
77+
oneOfVariant(
78+
BadRequest,
79+
plainBody[MissingProcessId]
80+
)
81+
)
82+
)
83+
.withSecurity(auth)
84+
85+
}
86+
87+
object VersionControlApiEndpoints {
88+
89+
object Dtos {
90+
91+
@derive(schema, encoder, decoder)
92+
final case class ProcessVersionValidationRequestDto(localVersion: Int)
93+
94+
@derive(schema, encoder, decoder)
95+
final case class ProcessVersionValidationResponseDto(
96+
processName: String,
97+
isLatest: Boolean,
98+
localVersion: Long,
99+
latestDeployedVersion: Option[Long],
100+
latestVersion: Long
101+
)
102+
103+
}
104+
105+
sealed trait VersionControlError
106+
107+
object VersionControlError {
108+
case class MissingProcessVersion(processName: String) extends VersionControlError
109+
case class MissingProcessId(processName: String) extends VersionControlError
110+
111+
implicit val missingProcessVersionCodec: Codec[String, MissingProcessVersion, CodecFormat.TextPlain] =
112+
BaseEndpointDefinitions.toTextPlainCodecSerializationOnly[MissingProcessVersion](e =>
113+
s"Missing scenario process version for process: ${e.processName}"
114+
)
115+
116+
implicit val missingProcessIdCodec: Codec[String, MissingProcessId, CodecFormat.TextPlain] =
117+
BaseEndpointDefinitions.toTextPlainCodecSerializationOnly[MissingProcessId](e =>
118+
s"Missing process id for scenario with name: ${e.processName}"
119+
)
120+
121+
}
122+
123+
}

0 commit comments

Comments
 (0)