From b1d77860cf5079e313640e69b1887ddedeb86c3d Mon Sep 17 00:00:00 2001 From: mgoworko <37329559+mgoworko@users.noreply.github.com> Date: Wed, 4 Dec 2024 11:55:59 +0100 Subject: [PATCH] Add notifications related to currently displayed scenario (#7184) --- .../client/cypress/e2e/connectionError.cy.ts | 4 +- .../client/src/containers/Notifications.tsx | 6 +- designer/client/src/http/HttpService.ts | 5 +- .../ui/api/NotificationApiHttpService.scala | 12 +- .../api/ScenarioActivityApiHttpService.scala | 17 +-- .../NotificationApiEndpoints.scala | 10 +- .../ui/notifications/Notification.scala | 21 ++- .../notifications/NotificationService.scala | 86 ++++++++++-- .../DbScenarioActivityRepository.scala | 7 +- .../ScenarioActivityRepository.scala | 4 +- ...oActivityRepositoryAuditLogDecorator.scala | 5 +- .../FetchScenarioActivityService.scala | 73 ++++++++++ .../server/AkkaHttpBasedRouteProvider.scala | 18 ++- .../ui/util/ScenarioActivityUtils.scala | 22 +++ ...tificationApiHttpServiceBusinessSpec.scala | 4 +- .../NotificationServiceTest.scala | 131 ++++++++++++++++-- .../ScenarioAttachmentServiceSpec.scala | 5 +- docs-internal/api/nu-designer-openapi.yaml | 12 +- docs/Changelog.md | 1 + .../periodic/PeriodicProcessService.scala | 8 +- .../db/PeriodicProcessesRepository.scala | 2 +- .../util/DeterministicUUIDFromLong.scala | 26 ++++ 22 files changed, 408 insertions(+), 71 deletions(-) create mode 100644 designer/server/src/main/scala/pl/touk/nussknacker/ui/process/scenarioactivity/FetchScenarioActivityService.scala create mode 100644 engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/util/DeterministicUUIDFromLong.scala diff --git a/designer/client/cypress/e2e/connectionError.cy.ts b/designer/client/cypress/e2e/connectionError.cy.ts index 5e0aef6aca9..08f295fda04 100644 --- a/designer/client/cypress/e2e/connectionError.cy.ts +++ b/designer/client/cypress/e2e/connectionError.cy.ts @@ -59,13 +59,13 @@ describe("Connection error", () => { cy.get("[model-id='filter']").dblclick(); cy.wait("@validation"); - cy.intercept("/api/notifications", { statusCode: 502 }); + cy.intercept("/api/notifications?scenarioName=*", { statusCode: 502 }); cy.contains(/Backend connection issue/).should("be.visible"); cy.get("body").matchImage({ screenshotConfig, }); - cy.intercept("/api/notifications", (req) => { + cy.intercept("/api/notifications?scenarioName=*", (req) => { req.continue(); }); diff --git a/designer/client/src/containers/Notifications.tsx b/designer/client/src/containers/Notifications.tsx index da56f81c8ad..09f5175678d 100644 --- a/designer/client/src/containers/Notifications.tsx +++ b/designer/client/src/containers/Notifications.tsx @@ -51,8 +51,6 @@ const handleRefresh = } toRefresh.forEach((data) => { switch (data) { - case "versions": - return dispatch(getScenarioActivities(scenarioName)); case "activity": return dispatch(getScenarioActivities(scenarioName)); case "state": @@ -90,7 +88,7 @@ export function Notifications(): JSX.Element { const currentScenarioName = useSelector(getProcessName); const refresh = useCallback(() => { - HttpService.loadBackendNotifications() + HttpService.loadBackendNotifications(currentScenarioName) .then((notifications) => { handleChangeConnectionError(null); dispatch(updateBackendNotifications(notifications.map(({ id }) => id))); @@ -124,7 +122,7 @@ export function Notifications(): JSX.Element { type NotificationType = "info" | "error" | "success"; -type DataToRefresh = "versions" | "activity" | "state"; +type DataToRefresh = "activity" | "state"; export type BackendNotification = { id: string; diff --git a/designer/client/src/http/HttpService.ts b/designer/client/src/http/HttpService.ts index 38285a9523e..04e021388ec 100644 --- a/designer/client/src/http/HttpService.ts +++ b/designer/client/src/http/HttpService.ts @@ -180,8 +180,9 @@ class HttpService { this.#notificationActions = na; } - loadBackendNotifications(): Promise { - return api.get("/notifications").then((d) => { + loadBackendNotifications(scenarioName: string | undefined): Promise { + const path = scenarioName !== undefined ? `/notifications?scenarioName=${scenarioName}` : `/notifications`; + return api.get(path).then((d) => { return d.data; }); } diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/NotificationApiHttpService.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/NotificationApiHttpService.scala index 6f7a41b4626..17ebea5f636 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/NotificationApiHttpService.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/NotificationApiHttpService.scala @@ -3,6 +3,7 @@ package pl.touk.nussknacker.ui.api import com.typesafe.scalalogging.LazyLogging import pl.touk.nussknacker.ui.api.description.NotificationApiEndpoints import pl.touk.nussknacker.ui.notifications.NotificationService +import pl.touk.nussknacker.ui.notifications.NotificationService.NotificationsScope import pl.touk.nussknacker.ui.security.api.AuthManager import scala.concurrent.ExecutionContext @@ -21,8 +22,15 @@ class NotificationApiHttpService( expose { notificationApiEndpoints.notificationEndpoint .serverSecurityLogic(authorizeKnownUser[Unit]) - .serverLogic { implicit loggedUser => _ => - notificationService.notifications + .serverLogic { implicit loggedUser => processNameOpt => + val scope = processNameOpt match { + case Some(processName) => + NotificationsScope.NotificationsForLoggedUserAndScenario(loggedUser, processName) + case None => + NotificationsScope.NotificationsForLoggedUser(loggedUser) + } + notificationService + .notifications(scope) .map { notificationList => success(notificationList) } } } diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/ScenarioActivityApiHttpService.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/ScenarioActivityApiHttpService.scala index a669b48acbb..56f73440b71 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/ScenarioActivityApiHttpService.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/ScenarioActivityApiHttpService.scala @@ -18,7 +18,6 @@ import pl.touk.nussknacker.ui.api.description.scenarioActivity.Dtos.ScenarioActi import pl.touk.nussknacker.ui.api.description.scenarioActivity.Dtos.{Comment => _, _} import pl.touk.nussknacker.ui.api.description.scenarioActivity.{Dtos, Endpoints} import pl.touk.nussknacker.ui.process.ProcessService.GetScenarioWithDetailsOptions -import pl.touk.nussknacker.ui.process.deployment.DeploymentManagerDispatcher import pl.touk.nussknacker.ui.process.repository.activities.ScenarioActivityRepository import pl.touk.nussknacker.ui.process.repository.activities.ScenarioActivityRepository.{ CommentModificationMetadata, @@ -26,6 +25,7 @@ import pl.touk.nussknacker.ui.process.repository.activities.ScenarioActivityRepo ModifyCommentError } import pl.touk.nussknacker.ui.process.repository.{DBIOActionRunner, DeploymentComment} +import pl.touk.nussknacker.ui.process.scenarioactivity.FetchScenarioActivityService import pl.touk.nussknacker.ui.process.{ProcessService, ScenarioAttachmentService} import pl.touk.nussknacker.ui.security.api.{AuthManager, LoggedUser} import pl.touk.nussknacker.ui.server.HeadersSupport.ContentDisposition @@ -39,7 +39,7 @@ import scala.concurrent.{ExecutionContext, Future} class ScenarioActivityApiHttpService( authManager: AuthManager, - deploymentManagerDispatcher: DeploymentManagerDispatcher, + fetchScenarioActivityService: FetchScenarioActivityService, scenarioActivityRepository: ScenarioActivityRepository, scenarioService: ProcessService, scenarioAuthorizer: AuthorizeProcess, @@ -240,18 +240,10 @@ class ScenarioActivityApiHttpService( private def fetchActivities( processIdWithName: ProcessIdWithName - )(implicit loggedUser: LoggedUser): EitherT[Future, ScenarioActivityError, List[Dtos.ScenarioActivity]] = + )(implicit loggedUser: LoggedUser): EitherT[Future, ScenarioActivityError, List[Dtos.ScenarioActivity]] = { EitherT.right { for { - generalActivities <- dbioActionRunner.run(scenarioActivityRepository.findActivities(processIdWithName.id)) - deploymentManager <- deploymentManagerDispatcher.deploymentManager(processIdWithName) - deploymentManagerSpecificActivities <- deploymentManager match { - case Some(manager: ManagerSpecificScenarioActivitiesStoredByManager) => - manager.managerSpecificScenarioActivities(processIdWithName) - case Some(_) | None => - Future.successful(List.empty) - } - combinedActivities = generalActivities ++ deploymentManagerSpecificActivities + combinedActivities <- fetchScenarioActivityService.fetchActivities(processIdWithName, after = None) // The API endpoint returning scenario activities does not yet have support for filtering. We made a decision to: // - for activities not related to deployments: always display them on FE // - for activities related to batch deployments: always display them on FE @@ -268,6 +260,7 @@ class ScenarioActivityApiHttpService( sortedResult = combinedSuccessfulActivities.map(toDto).toList.sortBy(_.date) } yield sortedResult } + } private def toDto(scenarioComment: ScenarioComment): Dtos.ScenarioActivityComment = scenarioComment match { case ScenarioComment.WithContent(comment, _, _) => diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/description/NotificationApiEndpoints.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/description/NotificationApiEndpoints.scala index ecf77d98b7c..df2f41e7328 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/description/NotificationApiEndpoints.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/description/NotificationApiEndpoints.scala @@ -8,16 +8,17 @@ import pl.touk.nussknacker.ui.notifications.{DataToRefresh, Notification} import sttp.model.StatusCode.Ok import sttp.tapir.EndpointIO.Example import sttp.tapir.json.circe.jsonBody -import sttp.tapir.{EndpointInput, statusCode} +import sttp.tapir.{Codec, CodecFormat, EndpointInput, query, statusCode} class NotificationApiEndpoints(auth: EndpointInput[AuthCredentials]) extends BaseEndpointDefinitions { - lazy val notificationEndpoint: SecuredEndpoint[Unit, Unit, List[Notification], Any] = + lazy val notificationEndpoint: SecuredEndpoint[Option[ProcessName], Unit, List[Notification], Any] = baseNuApiEndpoint .summary("Endpoint to display notifications") .tag("Notifications") .get .in("notifications") + .in(query[Option[ProcessName]]("scenarioName")) .out( statusCode(Ok).and( jsonBody[List[Notification]].example( @@ -30,7 +31,6 @@ class NotificationApiEndpoints(auth: EndpointInput[AuthCredentials]) extends Bas message = "Deployment finished", `type` = None, toRefresh = List( - DataToRefresh(DataToRefresh.versions.id), DataToRefresh(DataToRefresh.activity.id), DataToRefresh(DataToRefresh.state.id) ) @@ -42,4 +42,8 @@ class NotificationApiEndpoints(auth: EndpointInput[AuthCredentials]) extends Bas ) .withSecurity(auth) + private implicit val processNameCodec: Codec[String, ProcessName, CodecFormat.TextPlain] = { + Codec.string.map(str => ProcessName(str))(_.value) + } + } diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/notifications/Notification.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/notifications/Notification.scala index fead2cc35c6..9662d532656 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/notifications/Notification.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/notifications/Notification.scala @@ -48,7 +48,7 @@ object Notification { Some(name), s"${displayableActionName(actionName)} finished", None, - List(DataToRefresh.versions, DataToRefresh.activity, DataToRefresh.state) + List(DataToRefresh.activity, DataToRefresh.state) ) } @@ -64,7 +64,22 @@ object Notification { Some(name), s"${displayableActionName(actionName)} execution finished", None, - List(DataToRefresh.versions, DataToRefresh.activity, DataToRefresh.state) + List(DataToRefresh.activity, DataToRefresh.state) + ) + } + + def scenarioStateUpdateNotification( + id: String, + activityName: String, + name: ProcessName + ): Notification = { + // We don't want to display this notification, because it causes the activities toolbar to refresh + Notification( + id = id, + scenarioName = Some(name), + message = activityName, + `type` = None, + toRefresh = List(DataToRefresh.activity, DataToRefresh.state) ) } @@ -92,5 +107,5 @@ object DataToRefresh extends Enumeration { implicit val typeDecoder: Decoder[DataToRefresh.Value] = Decoder.decodeEnumeration(DataToRefresh) type DataToRefresh = Value - val versions, activity, state = Value + val activity, state = Value } diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/notifications/NotificationService.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/notifications/NotificationService.scala index 40e13ac38fa..2eba42d8466 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/notifications/NotificationService.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/notifications/NotificationService.scala @@ -1,10 +1,14 @@ package pl.touk.nussknacker.ui.notifications -import pl.touk.nussknacker.engine.api.deployment.{ProcessActionState, ScenarioActionName} +import pl.touk.nussknacker.engine.api.deployment._ +import pl.touk.nussknacker.engine.api.process.ProcessName +import pl.touk.nussknacker.ui.notifications.NotificationService.NotificationsScope import pl.touk.nussknacker.ui.process.repository.{DBIOActionRunner, ScenarioActionRepository} +import pl.touk.nussknacker.ui.process.scenarioactivity.FetchScenarioActivityService import pl.touk.nussknacker.ui.security.api.LoggedUser +import pl.touk.nussknacker.ui.util.ScenarioActivityUtils.ScenarioActivityOps -import java.time.Clock +import java.time.{Clock, Instant} import scala.concurrent.duration.FiniteDuration import scala.concurrent.{ExecutionContext, Future} @@ -12,34 +16,70 @@ final case class NotificationConfig(duration: FiniteDuration) trait NotificationService { - def notifications(implicit user: LoggedUser, ec: ExecutionContext): Future[List[Notification]] + def notifications(scope: NotificationsScope)( + implicit ec: ExecutionContext + ): Future[List[Notification]] + +} + +object NotificationService { + + sealed trait NotificationsScope + + object NotificationsScope { + + final case class NotificationsForLoggedUser( + user: LoggedUser, + ) extends NotificationsScope + + final case class NotificationsForLoggedUserAndScenario( + user: LoggedUser, + processName: ProcessName, + ) extends NotificationsScope + + } } class NotificationServiceImpl( + fetchScenarioActivityService: FetchScenarioActivityService, scenarioActionRepository: ScenarioActionRepository, dbioRunner: DBIOActionRunner, config: NotificationConfig, clock: Clock = Clock.systemUTC() ) extends NotificationService { - override def notifications(implicit user: LoggedUser, ec: ExecutionContext): Future[List[Notification]] = { + override def notifications( + scope: NotificationsScope, + )(implicit ec: ExecutionContext): Future[List[Notification]] = { val now = clock.instant() val limit = now.minusMillis(config.duration.toMillis) - dbioRunner - .run( - scenarioActionRepository.getUserActionsAfter( - user, - Set(ScenarioActionName.Deploy, ScenarioActionName.Cancel), - ProcessActionState.FinishedStates + ProcessActionState.Failed, - limit - ) + scope match { + case NotificationsScope.NotificationsForLoggedUser(user) => + notificationsForUserActions(user, limit) + case NotificationsScope.NotificationsForLoggedUserAndScenario(user, processName) => + for { + notificationsForUserActions <- notificationsForUserActions(user, limit) + notificationsForScenarioActivities <- notificationsForScenarioActivities(user, processName, limit) + } yield notificationsForUserActions ++ notificationsForScenarioActivities + } + + } + + private def notificationsForUserActions(user: LoggedUser, limit: Instant)( + implicit ec: ExecutionContext + ): Future[List[Notification]] = dbioRunner.run { + scenarioActionRepository + .getUserActionsAfter( + user, + Set(ScenarioActionName.Deploy, ScenarioActionName.Cancel), + ProcessActionState.FinishedStates + ProcessActionState.Failed, + limit ) .map(_.map { case (action, scenarioName) => action.state match { case ProcessActionState.Finished => - Notification - .actionFinishedNotification(action.id.toString, action.actionName, scenarioName) + Notification.actionFinishedNotification(action.id.toString, action.actionName, scenarioName) case ProcessActionState.Failed => Notification .actionFailedNotification(action.id.toString, action.actionName, scenarioName, action.failureMessage) @@ -54,4 +94,22 @@ class NotificationServiceImpl( }) } + private def notificationsForScenarioActivities(user: LoggedUser, processName: ProcessName, limit: Instant)( + implicit ec: ExecutionContext + ): Future[List[Notification]] = { + for { + allActivities <- fetchScenarioActivityService.fetchActivities(processName, Some(limit))(user).value.map { + case Right(activities) => activities + case Left(_) => List.empty + } + notificationsForScenarioActivities = allActivities.map { activity => + Notification.scenarioStateUpdateNotification( + s"${activity.scenarioActivityId.value.toString}_${activity.lastModifiedAt.toEpochMilli}", + activity.activityType.entryName, + processName + ) + } + } yield notificationsForScenarioActivities + } + } diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/activities/DbScenarioActivityRepository.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/activities/DbScenarioActivityRepository.scala index 69623e63bb7..71f68ed5e0b 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/activities/DbScenarioActivityRepository.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/activities/DbScenarioActivityRepository.scala @@ -45,8 +45,9 @@ class DbScenarioActivityRepository private (override protected val dbRef: DbRef, def findActivities( scenarioId: ProcessId, + after: Option[Instant], ): DB[Seq[ScenarioActivity]] = { - doFindActivities(scenarioId).map(_.map(_._2)) + doFindActivities(scenarioId, after).map(_.map(_._2)) } def addActivity( @@ -199,7 +200,7 @@ class DbScenarioActivityRepository private (override protected val dbRef: DbRef, for { attachmentEntities <- findAttachments(processId) attachments = attachmentEntities.map(toDto) - activities <- doFindActivities(processId) + activities <- doFindActivities(processId, after = None) comments = activities.flatMap { case (id, activity) => toComment(id, activity) } } yield Legacy.ProcessActivity( comments = comments.toList, @@ -221,9 +222,11 @@ class DbScenarioActivityRepository private (override protected val dbRef: DbRef, private def doFindActivities( scenarioId: ProcessId, + after: Option[Instant], ): DB[Seq[(Long, ScenarioActivity)]] = { scenarioActivityTable .filter(_.scenarioId === scenarioId) + .filterOpt(after)((table, after) => table.createdAt > Timestamp.from(after)) // ScenarioActivity in domain represents a single, immutable event, so we interpret only finished operations as ScenarioActivities .filter { entity => entity.state.isEmpty || diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/activities/ScenarioActivityRepository.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/activities/ScenarioActivityRepository.scala index 563a89c9fad..97a84b7a0e4 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/activities/ScenarioActivityRepository.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/activities/ScenarioActivityRepository.scala @@ -1,7 +1,6 @@ package pl.touk.nussknacker.ui.process.repository.activities import db.util.DBIOActionInstances.DB -import pl.touk.nussknacker.engine.api.Comment import pl.touk.nussknacker.engine.api.deployment._ import pl.touk.nussknacker.engine.api.process.{ProcessId, VersionId} import pl.touk.nussknacker.ui.api.description.scenarioActivity.Dtos.Legacy @@ -15,7 +14,7 @@ import pl.touk.nussknacker.ui.process.repository.activities.ScenarioActivityRepo import pl.touk.nussknacker.ui.security.api.LoggedUser import pl.touk.nussknacker.ui.util.LoggedUserUtils.Ops -import java.time.Clock +import java.time.{Clock, Instant} trait ScenarioActivityRepository { @@ -23,6 +22,7 @@ trait ScenarioActivityRepository { def findActivities( scenarioId: ProcessId, + after: Option[Instant] = None, ): DB[Seq[ScenarioActivity]] def addActivity( diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/activities/ScenarioActivityRepositoryAuditLogDecorator.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/activities/ScenarioActivityRepositoryAuditLogDecorator.scala index db90542c6a5..4a4638d017a 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/activities/ScenarioActivityRepositoryAuditLogDecorator.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/activities/ScenarioActivityRepositoryAuditLogDecorator.scala @@ -15,7 +15,7 @@ import pl.touk.nussknacker.ui.process.repository.activities.ScenarioActivityRepo import pl.touk.nussknacker.ui.security.api.LoggedUser import pl.touk.nussknacker.ui.util.FunctorUtils._ -import java.time.Clock +import java.time.{Clock, Instant} import scala.concurrent.ExecutionContext class ScenarioActivityRepositoryAuditLogDecorator( @@ -80,7 +80,8 @@ class ScenarioActivityRepositoryAuditLogDecorator( def findActivities( scenarioId: ProcessId, - ): DB[Seq[ScenarioActivity]] = underlying.findActivities(scenarioId) + after: Option[Instant], + ): DB[Seq[ScenarioActivity]] = underlying.findActivities(scenarioId, after) def findAttachments( scenarioId: ProcessId, diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/scenarioactivity/FetchScenarioActivityService.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/scenarioactivity/FetchScenarioActivityService.scala new file mode 100644 index 00000000000..3b9674575bb --- /dev/null +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/scenarioactivity/FetchScenarioActivityService.scala @@ -0,0 +1,73 @@ +package pl.touk.nussknacker.ui.process.scenarioactivity + +import cats.data.EitherT +import pl.touk.nussknacker.engine.api.deployment.{ManagerSpecificScenarioActivitiesStoredByManager, ScenarioActivity} +import pl.touk.nussknacker.engine.api.process.{ProcessIdWithName, ProcessName} +import pl.touk.nussknacker.ui.process.deployment.DeploymentManagerDispatcher +import pl.touk.nussknacker.ui.process.repository.activities.ScenarioActivityRepository +import pl.touk.nussknacker.ui.process.repository.{DBIOActionRunner, FetchingProcessRepository} +import pl.touk.nussknacker.ui.process.scenarioactivity.FetchScenarioActivityService.ScenarioActivityFetchError +import pl.touk.nussknacker.ui.process.scenarioactivity.FetchScenarioActivityService.ScenarioActivityFetchError.NoScenario +import pl.touk.nussknacker.ui.security.api.LoggedUser + +import java.time.Instant +import scala.concurrent.{ExecutionContext, Future} +import scala.math.Ordered.orderingToOrdered + +// TODO: In current implementation, we do not have a single ScenarioActivity-related service. +// - there is repository, that also encapsulates some very basic logic of validations/conversions +// - there is a logging decorator for that repository +// - the logic of fetching activities is used in multiple places, so it is encapsulated in this FetchScenarioActivityService +// - a complete ScenarioActivityService should be implemented, that would encapsulate logic from this service, and from repository and its decorator +class FetchScenarioActivityService( + deploymentManagerDispatcher: DeploymentManagerDispatcher, + scenarioActivityRepository: ScenarioActivityRepository, + fetchingProcessRepository: FetchingProcessRepository[Future], + dbioActionRunner: DBIOActionRunner, +)(implicit executionContext: ExecutionContext) { + + def fetchActivities( + processName: ProcessName, + after: Option[Instant], + )(implicit loggedUser: LoggedUser): EitherT[Future, ScenarioActivityFetchError, List[ScenarioActivity]] = { + for { + scenarioId <- getScenarioIdByName(processName) + activities <- EitherT.right(fetchActivities(ProcessIdWithName(scenarioId, processName), after)) + } yield activities + } + + def fetchActivities( + processIdWithName: ProcessIdWithName, + after: Option[Instant], + )(implicit loggedUser: LoggedUser): Future[List[ScenarioActivity]] = { + for { + generalActivities <- dbioActionRunner.run(scenarioActivityRepository.findActivities(processIdWithName.id, after)) + deploymentManager <- deploymentManagerDispatcher.deploymentManager(processIdWithName) + deploymentManagerSpecificActivities <- deploymentManager match { + case Some(manager: ManagerSpecificScenarioActivitiesStoredByManager) => + manager + .managerSpecificScenarioActivities(processIdWithName) + .map(_.filter { activity => after.forall(after => activity.date > after) }) + case Some(_) | None => + Future.successful(List.empty) + } + } yield generalActivities.toList ++ deploymentManagerSpecificActivities + } + + private def getScenarioIdByName(scenarioName: ProcessName) = { + EitherT.fromOptionF( + fetchingProcessRepository.fetchProcessId(scenarioName), + NoScenario(scenarioName) + ) + } + +} + +object FetchScenarioActivityService { + sealed trait ScenarioActivityFetchError + + object ScenarioActivityFetchError { + final case class NoScenario(scenarioName: ProcessName) extends ScenarioActivityFetchError + } + +} diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/server/AkkaHttpBasedRouteProvider.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/server/AkkaHttpBasedRouteProvider.scala index 822727dc6d4..bb1356a2319 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/server/AkkaHttpBasedRouteProvider.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/server/AkkaHttpBasedRouteProvider.scala @@ -69,6 +69,7 @@ import pl.touk.nussknacker.ui.process.processingtype.loader.ProcessingTypeDataLo import pl.touk.nussknacker.ui.process.processingtype.provider.ReloadableProcessingTypeDataProvider import pl.touk.nussknacker.ui.process.repository._ import pl.touk.nussknacker.ui.process.repository.activities.{DbScenarioActivityRepository, ScenarioActivityRepository} +import pl.touk.nussknacker.ui.process.scenarioactivity.FetchScenarioActivityService import pl.touk.nussknacker.ui.process.test.{PreliminaryScenarioTestDataSerDe, ScenarioTestService} import pl.touk.nussknacker.ui.process.version.{ScenarioGraphVersionRepository, ScenarioGraphVersionService} import pl.touk.nussknacker.ui.processreport.ProcessCounter @@ -313,8 +314,19 @@ class AkkaHttpBasedRouteProvider( processService, fragmentRepository ) - val notificationService = new NotificationServiceImpl(actionRepository, dbioRunner, notificationsConfig) - val processAuthorizer = new AuthorizeProcess(futureProcessRepository) + val fetchScenarioActivityService = new FetchScenarioActivityService( + dmDispatcher, + scenarioActivityRepository, + futureProcessRepository, + dbioRunner, + ) + val notificationService = new NotificationServiceImpl( + fetchScenarioActivityService, + actionRepository, + dbioRunner, + notificationsConfig + ) + val processAuthorizer = new AuthorizeProcess(futureProcessRepository) val appApiHttpService = new AppApiHttpService( config = resolvedConfig, authManager = authManager, @@ -399,7 +411,7 @@ class AkkaHttpBasedRouteProvider( val scenarioActivityApiHttpService = new ScenarioActivityApiHttpService( authManager = authManager, - deploymentManagerDispatcher = dmDispatcher, + fetchScenarioActivityService = fetchScenarioActivityService, scenarioActivityRepository = scenarioActivityRepository, scenarioService = processService, scenarioAuthorizer = processAuthorizer, diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/util/ScenarioActivityUtils.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/util/ScenarioActivityUtils.scala index 8de25560374..8b12682cc88 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/util/ScenarioActivityUtils.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/util/ScenarioActivityUtils.scala @@ -38,6 +38,28 @@ object ScenarioActivityUtils { } } + def lastModifiedAt: Instant = { + scenarioActivity match { + case activity: ScenarioActivity.ScenarioCreated => activity.date + case activity: ScenarioActivity.ScenarioArchived => activity.date + case activity: ScenarioActivity.ScenarioUnarchived => activity.date + case activity: ScenarioActivity.ScenarioDeployed => activity.comment.lastModifiedAt + case activity: ScenarioActivity.ScenarioPaused => activity.comment.lastModifiedAt + case activity: ScenarioActivity.ScenarioCanceled => activity.comment.lastModifiedAt + case activity: ScenarioActivity.ScenarioModified => activity.comment.lastModifiedAt + case activity: ScenarioActivity.ScenarioNameChanged => activity.date + case activity: ScenarioActivity.CommentAdded => activity.comment.lastModifiedAt + case activity: ScenarioActivity.AttachmentAdded => activity.date + case activity: ScenarioActivity.ChangedProcessingMode => activity.date + case activity: ScenarioActivity.IncomingMigration => activity.date + case activity: ScenarioActivity.OutgoingMigration => activity.date + case activity: ScenarioActivity.PerformedSingleExecution => activity.comment.lastModifiedAt + case activity: ScenarioActivity.PerformedScheduledExecution => activity.date + case activity: ScenarioActivity.AutomaticUpdate => activity.date + case activity: ScenarioActivity.CustomAction => activity.comment.lastModifiedAt + } + } + } } diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/NotificationApiHttpServiceBusinessSpec.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/NotificationApiHttpServiceBusinessSpec.scala index bc8c5cb0b8e..8173db040ee 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/NotificationApiHttpServiceBusinessSpec.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/NotificationApiHttpServiceBusinessSpec.scala @@ -54,14 +54,14 @@ class NotificationApiHttpServiceBusinessSpec | "scenarioName": "$scenarioName", | "message": "Deployment finished", | "type": null, - | "toRefresh": [ "versions", "activity", "state" ] + | "toRefresh": [ "activity", "state" ] |}, |{ | "id": "^\\\\w{8}-\\\\w{4}-\\\\w{4}-\\\\w{4}-\\\\w{12}$$", | "scenarioName": "$scenarioName", | "message": "Cancel finished", | "type": null, - | "toRefresh": [ "versions", "activity", "state" ] + | "toRefresh": [ "activity", "state" ] |}]""".stripMargin ) ) diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/ui/notifications/NotificationServiceTest.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/notifications/NotificationServiceTest.scala index 9e85e9f8acd..c94af946b06 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/ui/notifications/NotificationServiceTest.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/notifications/NotificationServiceTest.scala @@ -21,19 +21,25 @@ import pl.touk.nussknacker.engine.deployment.{ ExternalDeploymentId } import pl.touk.nussknacker.test.base.db.WithHsqlDbTesting +import pl.touk.nussknacker.test.config.WithSimplifiedDesignerConfig.TestProcessingType.Streaming +import pl.touk.nussknacker.test.mock.MockDeploymentManager +import pl.touk.nussknacker.test.utils.domain.TestFactory.mapProcessingTypeDataProvider import pl.touk.nussknacker.test.utils.domain.{ProcessTestData, TestFactory} import pl.touk.nussknacker.test.utils.scalas.DBIOActionValues import pl.touk.nussknacker.test.{EitherValuesDetailedMessage, PatientScalaFutures} import pl.touk.nussknacker.ui.listener.ProcessChangeListener +import pl.touk.nussknacker.ui.notifications.NotificationService.NotificationsScope import pl.touk.nussknacker.ui.process.deployment.LoggedUserConversions._ import pl.touk.nussknacker.ui.process.deployment._ import pl.touk.nussknacker.ui.process.processingtype.provider.ProcessingTypeDataProvider import pl.touk.nussknacker.ui.process.repository.ProcessRepository.CreateProcessAction +import pl.touk.nussknacker.ui.process.repository.activities.DbScenarioActivityRepository import pl.touk.nussknacker.ui.process.repository.{ DBIOActionRunner, DbScenarioActionRepository, ScenarioWithDetailsEntity } +import pl.touk.nussknacker.ui.process.scenarioactivity.FetchScenarioActivityService import pl.touk.nussknacker.ui.security.api.LoggedUser import pl.touk.nussknacker.ui.validation.UIProcessValidator @@ -57,10 +63,25 @@ class NotificationServiceTest private implicit val system: ActorSystem = ActorSystem(getClass.getSimpleName) override protected val dbioRunner: DBIOActionRunner = DBIOActionRunner(testDbRef) - private var currentInstant: Instant = Instant.ofEpochMilli(0) - private val clock: Clock = clockForInstant(() => currentInstant) - private val processRepository = TestFactory.newFetchingProcessRepository(testDbRef) - private val writeProcessRepository = TestFactory.newWriteProcessRepository(testDbRef, clock) + private var currentInstant: Instant = Instant.ofEpochMilli(0) + private val clock: Clock = clockForInstant(() => currentInstant) + private val processRepository = TestFactory.newFutureFetchingScenarioRepository(testDbRef) + private val dbProcessRepository = TestFactory.newFetchingProcessRepository(testDbRef) + private val writeProcessRepository = TestFactory.newWriteProcessRepository(testDbRef, clock) + private val scenarioActivityRepository = DbScenarioActivityRepository.create(testDbRef, clock) + private val dm: MockDeploymentManager = new MockDeploymentManager + + private val dmDispatcher = new DeploymentManagerDispatcher( + mapProcessingTypeDataProvider(Streaming.stringify -> dm), + processRepository, + ) + + private val scenarioActivityService = new FetchScenarioActivityService( + deploymentManagerDispatcher = dmDispatcher, + scenarioActivityRepository = scenarioActivityRepository, + fetchingProcessRepository = processRepository, + dbioActionRunner = dbioRunner + ) private val actionRepository = DbScenarioActionRepository.create( @@ -68,7 +89,7 @@ class NotificationServiceTest ProcessingTypeDataProvider.withEmptyCombinedData(Map.empty) ) - private val expectedRefreshAfterSuccess = List(DataToRefresh.versions, DataToRefresh.activity, DataToRefresh.state) + private val expectedRefreshAfterSuccess = List(DataToRefresh.activity, DataToRefresh.state) private val expectedRefreshAfterFail = List(DataToRefresh.state) test("Should return only events for user in given time") { @@ -80,7 +101,9 @@ class NotificationServiceTest val (deploymentService, notificationService) = createServices(deploymentManager) def notificationsFor(user: LoggedUser): List[Notification] = - notificationService.notifications(user, global).futureValue + notificationService + .notifications(NotificationsScope.NotificationsForLoggedUser(user))(global) + .futureValue def deployProcess( givenDeployResult: Try[Option[ExternalDeploymentId]], @@ -123,6 +146,79 @@ class NotificationServiceTest notificationsFor(userForFail).map(_.toRefresh) shouldBe Symbol("empty") } + test("Should return events for user and for scenario in given time") { + val processName = ProcessName("fooProcess") + val id = saveSampleProcess(processName) + val processIdWithName = ProcessIdWithName(id, processName) + + val deploymentManager = mock[DeploymentManager] + val (deploymentService, notificationService) = createServices(deploymentManager) + + def notificationsFor(user: LoggedUser): List[Notification] = + notificationService + .notifications(NotificationsScope.NotificationsForLoggedUserAndScenario(user, processName))(global) + .futureValue + + def deployProcess( + givenDeployResult: Try[Option[ExternalDeploymentId]], + user: LoggedUser + ): Option[ExternalDeploymentId] = { + when( + deploymentManager.processCommand(any[DMRunDeploymentCommand]) + ).thenReturn(Future.fromTry(givenDeployResult)) + when(deploymentManager.processStateDefinitionManager).thenReturn(SimpleProcessStateDefinitionManager) + when(deploymentManager.customActionsDefinitions).thenReturn(Nil) + deploymentService + .processCommand( + RunDeploymentCommand( + commonData = CommonCommandData(processIdWithName, None, user), + nodesDeploymentData = NodesDeploymentData.empty, + stateRestoringStrategy = StateRestoringStrategy.RestoreStateFromReplacedJobSavepoint + ) + ) + .flatten + .futureValue + } + + val firstUser = TestFactory.adminUser("firstUser", "firstUser") + val secondUser = TestFactory.adminUser("secondUser", "secondUser") + + deployProcess(Success(None), firstUser) + deployProcess(Success(None), secondUser) + + val notifications = notificationsFor(secondUser) + notifications shouldBe List( + Notification( + notifications(0).id, + Some(processName), + "Deployment finished", + None, + List(DataToRefresh.activity, DataToRefresh.state) + ), + Notification( + notifications(1).id, + Some(processName), + "SCENARIO_CREATED", + None, + List(DataToRefresh.activity, DataToRefresh.state) + ), + Notification( + notifications(2).id, + Some(processName), + "SCENARIO_DEPLOYED", + None, + List(DataToRefresh.activity, DataToRefresh.state) + ), + Notification( + notifications(3).id, + Some(processName), + "SCENARIO_DEPLOYED", + None, + List(DataToRefresh.activity, DataToRefresh.state) + ) + ) + } + test("should refresh after action execution finished") { val processName = ProcessName("process-execution-finished") val id = saveSampleProcess(processName) @@ -158,14 +254,21 @@ class NotificationServiceTest val user = TestFactory.adminUser("fooUser", "fooUser") deployProcess(Success(None), user) - val notificationsAfterDeploy = notificationService.notifications(user, global).futureValue + val notificationsAfterDeploy = + notificationService + .notifications(NotificationsScope.NotificationsForLoggedUser(user))(global) + .futureValue + notificationsAfterDeploy should have length 1 val deployNotificationId = notificationsAfterDeploy.head.id deploymentService .markActionExecutionFinished("Streaming", passedDeploymentId.value.toActionIdOpt.value) .futureValue - val notificationAfterExecutionFinished = notificationService.notifications(user, global).futureValue + val notificationAfterExecutionFinished = + notificationService + .notifications(NotificationsScope.NotificationsForLoggedUser(user))(global) + .futureValue // old notification about deployment is replaced by notification about deployment execution finished which has other id notificationAfterExecutionFinished should have length 1 notificationAfterExecutionFinished.head.id should not equal deployNotificationId @@ -191,11 +294,17 @@ class NotificationServiceTest val managerDispatcher = mock[DeploymentManagerDispatcher] when(managerDispatcher.deploymentManager(any[String])(any[LoggedUser])).thenReturn(Some(deploymentManager)) when(managerDispatcher.deploymentManagerUnsafe(any[String])(any[LoggedUser])).thenReturn(deploymentManager) - val config = NotificationConfig(20 minutes) - val notificationService = new NotificationServiceImpl(actionRepository, dbioRunner, config, clock) + val config = NotificationConfig(20 minutes) + val notificationService = new NotificationServiceImpl( + scenarioActivityService, + actionRepository, + dbioRunner, + config, + clock + ) val deploymentService = new DeploymentService( managerDispatcher, - processRepository, + dbProcessRepository, actionRepository, dbioRunner, mock[ProcessingTypeDataProvider[UIProcessValidator, _]], diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/ScenarioAttachmentServiceSpec.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/ScenarioAttachmentServiceSpec.scala index cd590e40810..20141ee982f 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/ScenarioAttachmentServiceSpec.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/ScenarioAttachmentServiceSpec.scala @@ -19,7 +19,7 @@ import pl.touk.nussknacker.ui.security.api.{LoggedUser, RealLoggedUser} import slick.dbio.DBIO import java.io.ByteArrayInputStream -import java.time.Clock +import java.time.{Clock, Instant} import scala.concurrent.{ExecutionContext, ExecutionContextExecutor} import scala.util.Random @@ -59,7 +59,8 @@ private object TestProcessActivityRepository extends ScenarioActivityRepository override def clock: Clock = Clock.systemUTC() - override def findActivities(scenarioId: ProcessId): DB[Seq[ScenarioActivity]] = notSupported("findActivities") + override def findActivities(scenarioId: ProcessId, after: Option[Instant]): DB[Seq[ScenarioActivity]] = + notSupported("findActivities") override def addActivity(scenarioActivity: ScenarioActivity): DB[ScenarioActivityId] = notSupported("addActivity") diff --git a/docs-internal/api/nu-designer-openapi.yaml b/docs-internal/api/nu-designer-openapi.yaml index be566e061b4..819f3508bd0 100644 --- a/docs-internal/api/nu-designer-openapi.yaml +++ b/docs-internal/api/nu-designer-openapi.yaml @@ -2067,6 +2067,13 @@ paths: type: - string - 'null' + - name: scenarioName + in: query + required: false + schema: + type: + - string + - 'null' responses: '200': description: '' @@ -2084,11 +2091,11 @@ paths: scenarioName: scenario1 message: Deployment finished toRefresh: - - versions - activity - state '400': - description: 'Invalid value for: header Nu-Impersonate-User-Identity' + description: 'Invalid value for: header Nu-Impersonate-User-Identity, Invalid + value for: query parameter scenarioName' content: text/plain: schema: @@ -5023,7 +5030,6 @@ components: title: DataToRefresh type: string enum: - - versions - activity - state DateParameterEditor: diff --git a/docs/Changelog.md b/docs/Changelog.md index 3f964f3dbfe..6c5f0d471e9 100644 --- a/docs/Changelog.md +++ b/docs/Changelog.md @@ -27,6 +27,7 @@ * [#7165](https://github.com/TouK/nussknacker/pull/7165) Added PerformSingleExecution scenario action * Added support for PerformSingleExecution action in DeploymentManager and in GUI * Improved scenario state management to include information about current and deployed versions and allow more customization +* [#7184](https://github.com/TouK/nussknacker/pull/7184) Improve Nu Designer API notifications endpoint, to include events related to currently displayed scenario ### 1.18.1 (Not released yet) diff --git a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicProcessService.scala b/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicProcessService.scala index e346c92ec08..2b5968270f7 100644 --- a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicProcessService.scala +++ b/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicProcessService.scala @@ -27,6 +27,8 @@ import pl.touk.nussknacker.engine.management.periodic.db.PeriodicProcessesReposi import pl.touk.nussknacker.engine.management.periodic.model.PeriodicProcessDeploymentStatus.PeriodicProcessDeploymentStatus import pl.touk.nussknacker.engine.management.periodic.model._ import pl.touk.nussknacker.engine.management.periodic.service._ +import pl.touk.nussknacker.engine.management.periodic.util.DeterministicUUIDFromLong +import pl.touk.nussknacker.engine.management.periodic.util.DeterministicUUIDFromLong.longUUID import pl.touk.nussknacker.engine.util.AdditionalComponentConfigsForRuntimeExtractor import java.time.chrono.ChronoLocalDateTime @@ -75,7 +77,11 @@ class PeriodicProcessService( activities = deploymentsWithStatuses.map { case (deployment, metadata) => ScenarioActivity.PerformedScheduledExecution( scenarioId = ScenarioId(processIdWithName.id.value), - scenarioActivityId = ScenarioActivityId.random, + // The periodic process executions are stored in the PeriodicProcessService datasource, with ids of type Long + // We need the ScenarioActivityId to be a unique UUID, generated in an idempotent way from Long id. + // It is important, because if the ScenarioActivityId would change, the activity may be treated as a new one, + // and, for example, GUI may have to refresh more often than necessary . + scenarioActivityId = ScenarioActivityId(DeterministicUUIDFromLong.longUUID(deployment.id.value)), user = ScenarioUser.internalNuUser, date = metadata.dateDeployed.getOrElse(metadata.dateFinished), scenarioVersionId = Some(ScenarioVersionId.from(deployment.periodicProcess.processVersion.versionId)), diff --git a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/db/PeriodicProcessesRepository.scala b/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/db/PeriodicProcessesRepository.scala index 267f6316fbf..daf2096a0e8 100644 --- a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/db/PeriodicProcessesRepository.scala +++ b/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/db/PeriodicProcessesRepository.scala @@ -15,7 +15,7 @@ import slick.dbio.{DBIOAction, Effect, NoStream} import slick.jdbc.PostgresProfile.api._ import slick.jdbc.{JdbcBackend, JdbcProfile} -import java.time.{Clock, LocalDateTime} +import java.time.{Clock, Instant, LocalDateTime, ZoneId} import scala.concurrent.{ExecutionContext, Future} import scala.language.higherKinds diff --git a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/util/DeterministicUUIDFromLong.scala b/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/util/DeterministicUUIDFromLong.scala new file mode 100644 index 00000000000..3f1a80b09b4 --- /dev/null +++ b/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/util/DeterministicUUIDFromLong.scala @@ -0,0 +1,26 @@ +package pl.touk.nussknacker.engine.management.periodic.util + +import java.util.UUID + +// This util generates UUID for given Long value. +object DeterministicUUIDFromLong { + + // Seed bytes are the base of Long -> UUID transformation. + // The mapping is deterministic and does not change, as long as the seedBytes are the same + private val seedBytes: Array[Byte] = { + val bytes = Array[Byte](119, -29, 31, -68, 44, -126, -89, 11, 97, 87, 54, -47, 39, -73, 28, 101) + require(bytes.length == 16, "Seed bytes must be exactly 16 bytes long") + bytes + } + + def longUUID(long: Long): UUID = { + require(long >= 0, "Input value must be non-negative") + val idBytes = BigInt(long).toByteArray.padTo(8, 0.toByte) + for (i <- seedBytes.indices) { + val targetIndex = i % 8 + idBytes(targetIndex) = (idBytes(targetIndex) ^ seedBytes(i)).toByte + } + UUID.nameUUIDFromBytes(idBytes) + } + +}