diff --git a/build.gradle.kts b/build.gradle.kts index 33af969e..2f5ce0be 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -61,7 +61,7 @@ dependencies { implementation("com.google.guava:guava:33.0.0-jre") - implementation("com.google.protobuf:protobuf-java:4.32.0") + implementation("com.google.protobuf:protobuf-java:4.32.1") implementation("io.etcd:jetcd-core:0.8.5") @@ -91,7 +91,7 @@ dependencies { implementation("org.jgrapht:jgrapht-core:1.5.2") - implementation("org.pkl-lang:pkl-config-java:0.29.0") + implementation("org.pkl-lang:pkl-config-java:0.29.0") // Get the container and its dimensions implementation("org.pkl-lang:pkl-codegen-java:0.29.0") testImplementation(platform("org.junit:junit-bom:5.9.1")) @@ -103,6 +103,10 @@ dependencies { testImplementation("org.mockito.kotlin:mockito-kotlin:5.3.1") implementation(kotlin("stdlib-jdk8")) + + implementation("io.javalin:javalin:6.7.0") + + implementation("org.slf4j:slf4j-simple:2.0.12") } repositories { diff --git a/src/main/java/at/ac/uibk/dps/cirrina/cirrina/InvocationListener.kt b/src/main/java/at/ac/uibk/dps/cirrina/cirrina/InvocationListener.kt new file mode 100644 index 00000000..5a4ac62c --- /dev/null +++ b/src/main/java/at/ac/uibk/dps/cirrina/cirrina/InvocationListener.kt @@ -0,0 +1,7 @@ +package at.ac.uibk.dps.cirrina.cirrina + +import at.ac.uibk.dps.cirrina.execution.`object`.statemachine.StateMachine + +interface InvocationListener { + fun onServiceInvoked(sm: StateMachine, serviceType: String) +} diff --git a/src/main/java/at/ac/uibk/dps/cirrina/cirrina/Runtime.kt b/src/main/java/at/ac/uibk/dps/cirrina/cirrina/Runtime.kt index 6571387c..7efe1e7c 100644 --- a/src/main/java/at/ac/uibk/dps/cirrina/cirrina/Runtime.kt +++ b/src/main/java/at/ac/uibk/dps/cirrina/cirrina/Runtime.kt @@ -12,7 +12,9 @@ import at.ac.uibk.dps.cirrina.utils.Id import com.google.common.flogger.FluentLogger import io.opentelemetry.api.OpenTelemetry import java.net.URI -import kotlinx.coroutines.Dispatchers +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.Executors +import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.runBlocking @@ -43,6 +45,8 @@ class Runtime( /** Top-level extent. */ val extent = Extent(persistentContext) + val invocationListeners = CopyOnWriteArrayList() + init { val collaborativeStateMachineClass = CollaborativeStateMachineClassBuilder.from(CsmParser.parseCsml(main)).build() @@ -88,8 +92,23 @@ class Runtime( stateMachines.firstOrNull { it.stateMachineInstanceId == stateMachineId } /** Run all state machines (blocking). */ - fun run() = runBlocking { - stateMachines.map { instance -> async(Dispatchers.Default) { instance.run() } }.awaitAll() + fun run() { + if (System.getenv("CIRRINA_UI_ENABLED").equals("true", ignoreCase = true)) { + VisualizationServer(this@Runtime).start() + } + + runBlocking { + stateMachines + .map { instance -> + async( + Executors.newFixedThreadPool(System.getenv("CIRRINA_THREAD_COUNT")?.toIntOrNull() ?: 8) + .asCoroutineDispatcher() + ) { + instance.run() + } + } + .awaitAll() + } } // Recursively builds all state machine instances and returns them in a flat list. @@ -113,4 +132,14 @@ class Runtime( instance.setNestedStateMachineIds(nestedInstances.map { it.stateMachineInstanceId }) return listOf(instance) + nestedInstances } + + /** Allow other components to register themselves to receive service invocation events. */ + fun addInvocationListener(listener: InvocationListener) { + invocationListeners.add(listener) + } + + /** Fire an event to all registered listeners. */ + fun fireServiceInvoked(sm: StateMachine, serviceType: String) { + invocationListeners.forEach { it.onServiceInvoked(sm, serviceType) } + } } diff --git a/src/main/java/at/ac/uibk/dps/cirrina/cirrina/VisualizationServer.kt b/src/main/java/at/ac/uibk/dps/cirrina/cirrina/VisualizationServer.kt new file mode 100644 index 00000000..b2e8cea9 --- /dev/null +++ b/src/main/java/at/ac/uibk/dps/cirrina/cirrina/VisualizationServer.kt @@ -0,0 +1,252 @@ +package at.ac.uibk.dps.cirrina.cirrina + +import at.ac.uibk.dps.cirrina.classes.transition.OnTransitionClass +import at.ac.uibk.dps.cirrina.execution.`object`.action.InvokeAction +import at.ac.uibk.dps.cirrina.execution.`object`.action.RaiseAction +import at.ac.uibk.dps.cirrina.execution.`object`.statemachine.StateMachine +import com.fasterxml.jackson.databind.ObjectMapper +import io.javalin.Javalin +import io.javalin.http.staticfiles.Location +import io.javalin.websocket.WsContext +import java.util.concurrent.ConcurrentHashMap +import kotlin.concurrent.thread + +class VisualizationServer(private val runtime: Runtime) : InvocationListener { + private val objectMapper = ObjectMapper() + private val userSessions = ConcurrentHashMap() + + // Store the hash of the last payload sent + @Volatile private var lastBroadcastHash = 0 + + fun start() { + val app = + Javalin.create { config -> + config.staticFiles.add { staticFileConfig -> + staticFileConfig.directory = "/public/dist" + staticFileConfig.location = Location.CLASSPATH + } + } + .start(7070) + + app.ws("/visual-socket") { ws -> + ws.onConnect { ctx -> + userSessions[ctx] = "user" + try { + ctx.send( + objectMapper.writeValueAsString( + mapOf("type" to "initialState", "payload" to buildJson()) + ) + ) + } catch (e: Exception) { + println("Error sending initial state: ${e.message}") + } + } + ws.onClose { ctx -> userSessions.remove(ctx) } + } + runtime.addInvocationListener(this) + startUpdateThread() + } + + private fun startUpdateThread() { + thread(isDaemon = true) { + while (true) { + if (userSessions.isNotEmpty()) { + try { + val newPayload = buildJson() + val newHash = newPayload.hashCode() + + // Broadcast only if the hash has changed + if (newHash != lastBroadcastHash) { + lastBroadcastHash = newHash + val message = + objectMapper.writeValueAsString( + mapOf("type" to "statusUpdate", "payload" to newPayload) + ) + userSessions.keys.forEach { it.send(message) } + } + } catch (e: Exception) { + println("Error broadcasting data ${e.message}") + } + } + Thread.sleep(250) + } + } + } + + /** Build JSON used to send to the client */ + private fun buildJson(): Map { + val stateMachineInstances = runtime.stateMachines + if (stateMachineInstances.isEmpty()) { + return mapOf("nodes" to emptyList(), "links" to emptyList()) + } + + val nodes = mutableListOf>() + val links = mutableSetOf>() + val allServiceTypes = mutableSetOf() + val raisedEvents = mutableListOf>() + val allOnTransitions = mutableListOf>() + val invokeActionClass = InvokeAction::class.java as Class + val raiseActionClass = RaiseAction::class.java as Class + + for (sm in stateMachineInstances) { + val smId = sm.stateMachineInstanceId.toString() + val smName = sm.stateMachineClass.name + val currentState = sm.activeState + val currentStateName = currentState?.stateObject?.name + + // Add Instance Node + nodes.add(mapOf("id" to smId, "label" to smName, "group" to "instance")) + + // Add Parent Link if one exists + if (sm.parentStateMachine != null) { + links.add( + mapOf( + "source" to sm.parentStateMachine.stateMachineInstanceId.toString(), + "target" to smId, + "type" to "nested", + ) + ) + } + + for (state in sm.stateMachineClass.vertexSet()) { + val isActive = state.name == currentStateName + val nodeId = "$smId::${state.name}" + val nodeData = + mutableMapOf( + "id" to nodeId, + "label" to state.name, + "group" to "state", + "isActive" to isActive, + "isTerminal" to state.isTerminal, + ) + if (isActive && sm.extent != null) { + nodeData["context"] = + currentState.extent.all.associate { it.name() to it.value().toString() } + } + // Add State Node + nodes.add(nodeData) + + // Add contains Link + if (state.isInitial) { + links.add(mapOf("source" to smId, "target" to nodeId, "type" to "contains")) + } + + // Collect actions from this state + val invokeActions = state.getActionsOfType(invokeActionClass) + val raiseActions = state.getActionsOfType(raiseActionClass) + + // Add invokes Links from state + invokeActions.forEach { action -> + allServiceTypes.add(action.serviceType) + links.add( + mapOf( + "source" to nodeId, + "target" to "service::${action.serviceType}", + "type" to "invokes", + ) + ) + } + + // Collect Raised Events + raiseActions.forEach { action -> + raisedEvents.add(mapOf("raisingMachine" to smId, "event" to action.event.name)) + } + + // Collect OnTransitions + for (transition in sm.stateMachineClass.findOnTransitionsFromState(state)) { + allOnTransitions.add(Triple(smId, transition, sm)) + } + } + + for (transition in sm.stateMachineClass.edgeSet()) { + val sourceState = sm.stateMachineClass.getEdgeSource(transition) + val targetState = sm.stateMachineClass.getEdgeTarget(transition) + + if (sourceState != null && targetState != null) { + // Add transition Link + links.add( + mapOf( + "source" to "$smId::${sourceState.name}", + "target" to "$smId::${targetState.name}", + "type" to "transition", + ) + ) + } + + // Collect actions from transition + if (sourceState != null) { + val invokeActions = transition.getActionsOfType(invokeActionClass) + invokeActions.forEach { action -> + allServiceTypes.add(action.serviceType) + links.add( + mapOf( + "source" to "$smId::${sourceState.name}", + "target" to "service::${action.serviceType}", + "type" to "invokes", + ) + ) + } + } + } + } + + // Add Service Nodes + allServiceTypes.forEach { serviceName -> + nodes.add( + mapOf("id" to "service::$serviceName", "label" to serviceName, "group" to "service") + ) + } + // Build lookup map of raised events + val eventRaiserMap = + raisedEvents + .groupBy({ it["event"] as String }, { it["raisingMachine"] as String }) + .mapValues { it.value.toSet() } + + // Process all collected transitions + allOnTransitions.forEach { (smId, transition, sm) -> + val eventName = transition?.eventName + // Find all machines that raised this event + val raisingMachines = eventRaiserMap[eventName] + + raisingMachines?.forEach { raisingMachineId -> + links.add( + mapOf( + "source" to raisingMachineId, + "target" to smId, + "event" to eventName as Any, + "type" to "event-link", + ) + ) + } + } + + // Convert links Set to List + return mapOf("nodes" to nodes, "links" to links.toList()) + } + + /** + * Broadcast message on a new service invocation + * + * @param sm StateMachine which invoked the service + * @param serviceType Service which got invoked + */ + override fun onServiceInvoked(sm: StateMachine, serviceType: String) { + val sourceState = sm.activeState ?: return + val sourceId = "${sm.stateMachineInstanceId}::${sourceState.stateObject.name}" + + // Create the message map + val message = + mapOf( + "type" to "invocation", + "payload" to mapOf("sourceId" to sourceId, "targetId" to "service::$serviceType"), + ) + + // Broadcast + try { + val jsonMessage = objectMapper.writeValueAsString(message) + userSessions.keys.forEach { it.send(jsonMessage) } + } catch (e: Exception) { + println("Error broadcasting invocation: ${e.message}") + } + } +} diff --git a/src/main/java/at/ac/uibk/dps/cirrina/classes/collaborativestatemachine/CollaborativeStateMachineClassBuilder.java b/src/main/java/at/ac/uibk/dps/cirrina/classes/collaborativestatemachine/CollaborativeStateMachineClassBuilder.java index aa796f7b..a161c8e7 100644 --- a/src/main/java/at/ac/uibk/dps/cirrina/classes/collaborativestatemachine/CollaborativeStateMachineClassBuilder.java +++ b/src/main/java/at/ac/uibk/dps/cirrina/classes/collaborativestatemachine/CollaborativeStateMachineClassBuilder.java @@ -9,6 +9,7 @@ import at.ac.uibk.dps.cirrina.execution.object.event.Event; import com.google.common.collect.HashBasedTable; import com.google.common.collect.Table; + import java.io.IOException; import java.util.ArrayList; import java.util.List; @@ -171,6 +172,12 @@ public CollaborativeStateMachineClass build() throws IllegalArgumentException { persistentContext = ContextBuilder.from(csml.getPersistentContext()) .inMemoryContext(true) .build(); + + csml.getStateMachines().forEach(sm -> { + transferVariables(sm.getPersistentContext(), persistentContext); + sm.getStates().forEach(state -> transferVariables(state.getPersistentContext(), persistentContext)); + }); + } catch (IOException ignored) { throw new IllegalStateException(); } @@ -189,4 +196,25 @@ public CollaborativeStateMachineClass build() throws IllegalArgumentException { throw new IllegalStateException(); } } + + /** + * Transfers variables from source context to persistent context + * + * @param sourceContext Given source context + * @param persistentContext Given persistent context + */ + private void transferVariables( + Csml.ContextDescription sourceContext, + Context persistentContext + ) { + if (sourceContext == null) return; + + sourceContext.getVariables().forEach(variable -> { + try { + persistentContext.create(variable.getName(), variable.getValue()); + } catch (IOException e) { + throw new RuntimeException("Failed to create persistent variable: " + variable.getName(), e); + } + }); + } } diff --git a/src/main/java/at/ac/uibk/dps/cirrina/classes/state/StateClass.java b/src/main/java/at/ac/uibk/dps/cirrina/classes/state/StateClass.java index 8ac9a1ab..7b547b1a 100644 --- a/src/main/java/at/ac/uibk/dps/cirrina/classes/state/StateClass.java +++ b/src/main/java/at/ac/uibk/dps/cirrina/classes/state/StateClass.java @@ -7,6 +7,7 @@ import at.ac.uibk.dps.cirrina.io.plantuml.Exportable; import at.ac.uibk.dps.cirrina.io.plantuml.PlantUmlVisitor; import jakarta.annotation.Nullable; + import java.util.Collection; import java.util.List; import java.util.Optional; @@ -35,6 +36,16 @@ public final class StateClass implements Exportable { */ private final @Nullable ContextDescription localContextDescription; + /** + * Persistent context description, can be null in case the state has no declared persistent context. + */ + private final @Nullable ContextDescription persistentContextDescription; + + /** + * Static context description, can be null in case the state has no declared static context. + */ + private final @Nullable ContextDescription staticContextDescription; + /** * Flag that indicates whether this state is initial. */ @@ -76,6 +87,8 @@ public final class StateClass implements Exportable { this.name = baseParameters.name; this.localContextDescription = baseParameters.localContextClass; + this.persistentContextDescription = baseParameters.persistentContextClass; + this.staticContextDescription = baseParameters.staticContextClass; this.initial = baseParameters.initial; this.terminal = baseParameters.terminal; @@ -99,6 +112,8 @@ public final class StateClass implements Exportable { this.name = baseState.name; this.localContextDescription = baseState.localContextDescription; + this.persistentContextDescription = baseState.persistentContextDescription; + this.staticContextDescription = baseState.staticContextDescription; this.initial = childParameters.initial || baseState.initial; this.terminal = childParameters.terminal || baseState.terminal; @@ -186,6 +201,24 @@ public Optional getLocalContextDescription() { return Optional.ofNullable(localContextDescription); } + /** + * Returns the persistent context description. + * + * @return Persistent context description. + */ + public Optional getPersistentContextDescription() { + return Optional.ofNullable(persistentContextDescription); + } + + /** + * Returns the static context description. + * + * @return Static context description. + */ + public Optional getStaticContextDescription() { + return Optional.ofNullable(staticContextDescription); + } + /** * Returns the entry action graph. * @@ -239,20 +272,24 @@ public List getActionsOfType(Class type) { /** * Base state parameters. * - * @param parentStateMachineId ID of the parent state machine class. - * @param name Name of the state. - * @param localContextClass Local context class. - * @param initial Is initial. - * @param terminal Is terminal. - * @param entryActions Entry actions. - * @param exitActions Exit actions. - * @param whileActions While actions. - * @param afterActions After actions. + * @param parentStateMachineId ID of the parent state machine class. + * @param name Name of the state. + * @param localContextClass Local context class. + * @param persistentContextClass Persistent context class. + * @param staticContextClass Static context class. + * @param initial Is initial. + * @param terminal Is terminal. + * @param entryActions Entry actions. + * @param exitActions Exit actions. + * @param whileActions While actions. + * @param afterActions After actions. */ record BaseParameters( UUID parentStateMachineId, String name, @Nullable ContextDescription localContextClass, + @Nullable ContextDescription persistentContextClass, + @Nullable ContextDescription staticContextClass, boolean initial, boolean terminal, List entryActions, diff --git a/src/main/java/at/ac/uibk/dps/cirrina/classes/state/StateClassBuilder.java b/src/main/java/at/ac/uibk/dps/cirrina/classes/state/StateClassBuilder.java index 5c335810..c131e321 100644 --- a/src/main/java/at/ac/uibk/dps/cirrina/classes/state/StateClassBuilder.java +++ b/src/main/java/at/ac/uibk/dps/cirrina/classes/state/StateClassBuilder.java @@ -5,6 +5,7 @@ import at.ac.uibk.dps.cirrina.execution.object.action.Action; import at.ac.uibk.dps.cirrina.execution.object.action.ActionBuilder; import at.ac.uibk.dps.cirrina.execution.object.action.TimeoutAction; + import java.util.List; import java.util.UUID; import java.util.function.Function; @@ -98,6 +99,8 @@ public StateClass build() throws IllegalArgumentException { parentStateMachineId, stateDescription.getName(), stateDescription.getLocalContext(), + stateDescription.getPersistentContext(), + stateDescription.getStaticContext(), stateDescription.isInitial(), stateDescription.isTerminal(), entryActions, diff --git a/src/main/java/at/ac/uibk/dps/cirrina/classes/statemachine/StateMachineClass.java b/src/main/java/at/ac/uibk/dps/cirrina/classes/statemachine/StateMachineClass.java index 2098567f..06b5cb96 100644 --- a/src/main/java/at/ac/uibk/dps/cirrina/classes/statemachine/StateMachineClass.java +++ b/src/main/java/at/ac/uibk/dps/cirrina/classes/statemachine/StateMachineClass.java @@ -9,12 +9,13 @@ import at.ac.uibk.dps.cirrina.io.plantuml.Exportable; import at.ac.uibk.dps.cirrina.io.plantuml.PlantUmlVisitor; import jakarta.annotation.Nullable; +import org.jgrapht.graph.DirectedPseudograph; + import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.UUID; import java.util.stream.Stream; -import org.jgrapht.graph.DirectedPseudograph; /** * State machine class, represents the structure of a state machine. @@ -46,6 +47,11 @@ public final class StateMachineClass */ private final @Nullable ContextDescription localContextClass; + /** + * The persistent context class, can be null in case no persistent context has been declared. + */ + private final @Nullable ContextDescription persistentContextClass; + /** * Initializes this state machine class instance. * @@ -55,6 +61,7 @@ public final class StateMachineClass super(TransitionClass.class); this.name = parameters.name; this.localContextClass = parameters.localContextClass; + this.persistentContextClass = parameters.persistentContextClass; this.nestedStateMachineClasses = Collections.unmodifiableList( parameters.nestedStateMachineClasses ); @@ -119,6 +126,22 @@ public List findOnTransitionsFromStateByEventName( .toList(); } + /** + * Returns the transitions from a state. + * + * @param fromStateClass From state. + * @return The list of on-transitions. + */ + public List findOnTransitionsFromState( + StateClass fromStateClass + ) { + return outgoingEdgesOf(fromStateClass) + .stream() + .filter(OnTransitionClass.class::isInstance) + .map(OnTransitionClass.class::cast) + .toList(); + } + /** * Returns the transitions from a state that are not event-triggered. * @@ -168,6 +191,27 @@ public Optional getLocalContextClass() { return Optional.ofNullable(localContextClass); } + /** + * Returns the persistent context class or empty. + * + * @return Persistent context class or empty. + */ + public Optional getPersistentContext() { + return Optional.ofNullable(persistentContextClass); + } + + /** + * Returns all static context descriptions from all states in this state machine. + * + * @return List of static context descriptions. + */ + public List getAllStaticContexts() { + return vertexSet() + .stream() + .flatMap(stateClass -> stateClass.getStaticContextDescription().stream()) + .toList(); + } + /** * Returns the initial state of this state machine. * @@ -218,6 +262,7 @@ public List getOutputEvents() { record Parameters( String name, @Nullable ContextDescription localContextClass, + @Nullable ContextDescription persistentContextClass, List nestedStateMachineClasses ) {} } diff --git a/src/main/java/at/ac/uibk/dps/cirrina/classes/statemachine/StateMachineClassBuilder.java b/src/main/java/at/ac/uibk/dps/cirrina/classes/statemachine/StateMachineClassBuilder.java index 0773fe26..0ad5da5b 100644 --- a/src/main/java/at/ac/uibk/dps/cirrina/classes/statemachine/StateMachineClassBuilder.java +++ b/src/main/java/at/ac/uibk/dps/cirrina/classes/statemachine/StateMachineClassBuilder.java @@ -5,6 +5,7 @@ import at.ac.uibk.dps.cirrina.csm.Csml.StateDescription; import at.ac.uibk.dps.cirrina.csm.Csml.StateMachineDescription; import at.ac.uibk.dps.cirrina.csm.Csml.TransitionDescription; + import java.util.List; import java.util.Optional; import java.util.function.Consumer; @@ -65,6 +66,7 @@ private StateMachineClass buildBase() throws IllegalArgumentException { var parameters = new StateMachineClass.Parameters( stateMachineDescription.getName(), stateMachineDescription.getLocalContext(), + stateMachineDescription.getPersistentContext(), nestedStateMachines ); diff --git a/src/main/java/at/ac/uibk/dps/cirrina/execution/command/ActionInvokeCommand.java b/src/main/java/at/ac/uibk/dps/cirrina/execution/command/ActionInvokeCommand.java index ad42123b..427880f8 100644 --- a/src/main/java/at/ac/uibk/dps/cirrina/execution/command/ActionInvokeCommand.java +++ b/src/main/java/at/ac/uibk/dps/cirrina/execution/command/ActionInvokeCommand.java @@ -1,9 +1,5 @@ package at.ac.uibk.dps.cirrina.execution.command; -import static at.ac.uibk.dps.cirrina.tracing.SemanticConvention.COUNTER_INVOCATIONS; -import static at.ac.uibk.dps.cirrina.tracing.SemanticConvention.GAUGE_ACTION_INVOKE_LATENCY; -import static at.ac.uibk.dps.cirrina.tracing.SemanticConvention.GAUGE_EVENT_RESPONSE_TIME_INCLUSIVE; - import at.ac.uibk.dps.cirrina.csm.Csml.EventChannel; import at.ac.uibk.dps.cirrina.execution.object.action.InvokeAction; import at.ac.uibk.dps.cirrina.execution.object.context.ContextVariable; @@ -13,10 +9,13 @@ import at.ac.uibk.dps.cirrina.execution.service.ServiceImplementation; import at.ac.uibk.dps.cirrina.utils.Time; import com.google.common.flogger.FluentLogger; + import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; +import static at.ac.uibk.dps.cirrina.tracing.SemanticConvention.*; + /** * Action invoke command, performs a service type invocation. *

@@ -58,6 +57,10 @@ public List execute() throws UnsupportedOperationException { List input = prepareInput(extent); + final var stateMachineInstance = executionContext.scope().getStateMachine(); + final var runtime = stateMachineInstance.getRuntime(); + runtime.fireServiceInvoked(stateMachineInstance, invokeAction.getServiceType()); + // Invoke (asynchronously) serviceImplementation .invoke(input, executionContext.scope().getId()) diff --git a/src/main/java/at/ac/uibk/dps/cirrina/execution/command/ActionTimeoutResetCommand.java b/src/main/java/at/ac/uibk/dps/cirrina/execution/command/ActionTimeoutResetCommand.java index d149c109..2a6928f2 100644 --- a/src/main/java/at/ac/uibk/dps/cirrina/execution/command/ActionTimeoutResetCommand.java +++ b/src/main/java/at/ac/uibk/dps/cirrina/execution/command/ActionTimeoutResetCommand.java @@ -1,6 +1,7 @@ package at.ac.uibk.dps.cirrina.execution.command; import at.ac.uibk.dps.cirrina.execution.object.action.TimeoutResetAction; + import java.util.List; public final class ActionTimeoutResetCommand extends ActionCommand { @@ -17,7 +18,7 @@ public final class ActionTimeoutResetCommand extends ActionCommand { @Override public List execute() throws UnsupportedOperationException { - // Handled in StateMachine + this.executionContext.timeoutActionManager().stop(timeoutResetAction.getAction()); return List.of(); } diff --git a/src/main/java/at/ac/uibk/dps/cirrina/execution/command/ExecutionContext.java b/src/main/java/at/ac/uibk/dps/cirrina/execution/command/ExecutionContext.java index dd53f474..41fcd95d 100644 --- a/src/main/java/at/ac/uibk/dps/cirrina/execution/command/ExecutionContext.java +++ b/src/main/java/at/ac/uibk/dps/cirrina/execution/command/ExecutionContext.java @@ -3,10 +3,12 @@ import at.ac.uibk.dps.cirrina.execution.object.event.Event; import at.ac.uibk.dps.cirrina.execution.object.event.EventListener; import at.ac.uibk.dps.cirrina.execution.object.statemachine.StateMachineEventHandler; +import at.ac.uibk.dps.cirrina.execution.object.statemachine.TimeoutActionManager; import at.ac.uibk.dps.cirrina.execution.service.ServiceImplementationSelector; import at.ac.uibk.dps.cirrina.tracing.Counters; import at.ac.uibk.dps.cirrina.tracing.Gauges; import jakarta.annotation.Nullable; + import java.util.Objects; public record ExecutionContext( @@ -15,6 +17,7 @@ public record ExecutionContext( ServiceImplementationSelector serviceImplementationSelector, StateMachineEventHandler eventHandler, EventListener eventListener, + TimeoutActionManager timeoutActionManager, Gauges gauges, Counters counters, boolean isWhile @@ -27,6 +30,7 @@ public record ExecutionContext( ); Objects.requireNonNull(eventHandler, "StateMachineEventHandler cannot be null"); Objects.requireNonNull(eventListener, "EventListener cannot be null"); + Objects.requireNonNull(timeoutActionManager, "TimeoutActionManager cannot be null"); Objects.requireNonNull(gauges, "Gauges cannot be null"); Objects.requireNonNull(counters, "Counters cannot be null"); } @@ -38,6 +42,7 @@ public ExecutionContext withScope(Scope scope) { serviceImplementationSelector, eventHandler, eventListener, + timeoutActionManager, gauges, counters, isWhile @@ -51,6 +56,7 @@ public ExecutionContext withIsWhile(boolean isWhile) { serviceImplementationSelector, eventHandler, eventListener, + timeoutActionManager, gauges, counters, isWhile diff --git a/src/main/java/at/ac/uibk/dps/cirrina/execution/command/Scope.java b/src/main/java/at/ac/uibk/dps/cirrina/execution/command/Scope.java index 4af9b88b..4a9e5248 100644 --- a/src/main/java/at/ac/uibk/dps/cirrina/execution/command/Scope.java +++ b/src/main/java/at/ac/uibk/dps/cirrina/execution/command/Scope.java @@ -1,9 +1,11 @@ package at.ac.uibk.dps.cirrina.execution.command; import at.ac.uibk.dps.cirrina.execution.object.context.Extent; +import at.ac.uibk.dps.cirrina.execution.object.statemachine.StateMachine; public interface Scope { Extent getExtent(); String getId(); + StateMachine getStateMachine(); } diff --git a/src/main/java/at/ac/uibk/dps/cirrina/execution/object/context/ContextBuilder.java b/src/main/java/at/ac/uibk/dps/cirrina/execution/object/context/ContextBuilder.java index 146d82f2..f08ccb43 100644 --- a/src/main/java/at/ac/uibk/dps/cirrina/execution/object/context/ContextBuilder.java +++ b/src/main/java/at/ac/uibk/dps/cirrina/execution/object/context/ContextBuilder.java @@ -3,6 +3,7 @@ import at.ac.uibk.dps.cirrina.csm.Csml.ContextDescription; import at.ac.uibk.dps.cirrina.execution.object.expression.ExpressionBuilder; import jakarta.annotation.Nullable; + import java.io.IOException; /** @@ -49,6 +50,23 @@ public static ContextBuilder from(ContextDescription contextDescription) { return new ContextBuilder(contextDescription); } + /** + * Reset the context to default values + * + * @param contextToReset Context which should be reset + * @param contextDescription ContextDescription holding the default values + * @throws IOException + */ + public static void resetContextToDefault( + Context contextToReset, + ContextDescription contextDescription + ) throws IOException { + contextToReset.deleteAll(); + ContextBuilder contextBuilder = new ContextBuilder(contextDescription); + contextBuilder.context = contextToReset; + contextBuilder.build(); + } + /** * Build an in-memory context. * diff --git a/src/main/java/at/ac/uibk/dps/cirrina/execution/object/context/Extent.java b/src/main/java/at/ac/uibk/dps/cirrina/execution/object/context/Extent.java index 703c60b1..3993f479 100644 --- a/src/main/java/at/ac/uibk/dps/cirrina/execution/object/context/Extent.java +++ b/src/main/java/at/ac/uibk/dps/cirrina/execution/object/context/Extent.java @@ -67,6 +67,18 @@ public Context getHigh() { return extent.getLast(); } + public List getAll() throws IOException { + final var allVariables = new java.util.HashMap(); + + for (final var context : this.extent) { + for (final var variable : context.getAll()) { + allVariables.put(variable.name(), variable); + } + } + + return new ArrayList<>(allVariables.values()); + } + public Optional resolve(String name) { return extent .reversed() diff --git a/src/main/java/at/ac/uibk/dps/cirrina/execution/object/expression/Utility.java b/src/main/java/at/ac/uibk/dps/cirrina/execution/object/expression/Utility.java new file mode 100644 index 00000000..dc9054ca --- /dev/null +++ b/src/main/java/at/ac/uibk/dps/cirrina/execution/object/expression/Utility.java @@ -0,0 +1,127 @@ +package at.ac.uibk.dps.cirrina.execution.object.expression; + +import com.google.common.base.CharMatcher; + +import java.util.*; + +public final class Utility { + + public static byte[] genRandPayload(int[] sizes) { + final var rand = new Random(); + + final var randomIndex = rand.nextInt(sizes.length); + final var selectedSize = sizes[randomIndex]; + + return new byte[selectedSize]; + } + + public static String appendToMap(String map, String key, T value) { + + Map> newMap = (Objects.equals(map, "{:}")) ? new HashMap<>() : convertStringToMap(map); + newMap.computeIfAbsent(key, k -> new ArrayList<>()); + List newList = newMap.get(key); + newList.add(value.toString()); + newMap.put(key, newList); + return convertMapToString(newMap); + } + public static String replaceInMap(String map, String key, T value){ + Map> newMap = (Objects.equals(map, "{:}")) ? new HashMap<>() : convertStringToMap(map); + newMap.computeIfAbsent(key, k -> new ArrayList<>()); + newMap.put(key, List.of(value.toString())); + return convertMapToString(newMap); + } + + public static Map> convertStringToMap(String data) { + Map> map = new HashMap<>(); + + data = data.trim(); + if (data.startsWith("{") && data.endsWith("}")) { + data = data.substring(1, data.length() - 1); + } + if (data.isEmpty()) return map; + + int bracketLevel = 0; + StringBuilder token = new StringBuilder(); + List pairs = new ArrayList<>(); + + for (char c : data.toCharArray()) { + if (c == '[') bracketLevel++; + if (c == ']') bracketLevel--; + if (c == ',' && bracketLevel == 0) { + pairs.add(token.toString().trim()); + token.setLength(0); + } else { + token.append(c); + } + } + if (!token.isEmpty()) { + pairs.add(token.toString().trim()); + } + + for (String pair : pairs) { + String[] keyValue = pair.split("=", 2); + if (keyValue.length != 2) continue; + String key = CharMatcher.anyOf("[]{} ").removeFrom(keyValue[0]); + String value = CharMatcher.anyOf("[]{} ").removeFrom(keyValue[1]); + map.put(key, new ArrayList<>(Collections.singletonList(value))); + } + + return map; + } + + public static String convertMapToString(Map map) { + StringBuilder mapAsString = new StringBuilder("{"); + for (String key : map.keySet()) { + mapAsString.append(key + "=" + map.get(key) + ", "); + } + mapAsString.delete(mapAsString.length()-2, mapAsString.length()).append("}"); + return mapAsString.toString(); + } + + public static long now() { + return System.currentTimeMillis() / 1000L; + } + + public static ArrayList addToList(ArrayList list, T valueOne, T valueTwo) { + list.add(valueOne); + list.add(valueTwo); + + return list; + } + + public static ArrayList removeFromList(ArrayList list, T valueOne, T valueTwo) { + list.remove(valueOne); + list.remove(valueTwo); + + return list; + } + + public static int busyWait(int duration) throws InterruptedException { + int test = 0; + + for (int k = 0; k < duration; k++) { + Math.random(); + test++; + } + + return test; + } + + public static ArrayList addToList(ArrayList list, T value) { + list.add(value); + + return list; + } + + public static ArrayList removeFirst(ArrayList list) { + list.removeFirst(); + + return list; + } + + public static T takeRandom(ArrayList list) { + Collections.shuffle(list); + + return list.getFirst(); + } +} diff --git a/src/main/java/at/ac/uibk/dps/cirrina/execution/object/state/State.java b/src/main/java/at/ac/uibk/dps/cirrina/execution/object/state/State.java index 7ca74b9f..02113bcd 100644 --- a/src/main/java/at/ac/uibk/dps/cirrina/execution/object/state/State.java +++ b/src/main/java/at/ac/uibk/dps/cirrina/execution/object/state/State.java @@ -1,21 +1,27 @@ package at.ac.uibk.dps.cirrina.execution.object.state; import at.ac.uibk.dps.cirrina.classes.state.StateClass; +import at.ac.uibk.dps.cirrina.csm.Csml.ContextDescription; import at.ac.uibk.dps.cirrina.execution.command.ActionCommand; import at.ac.uibk.dps.cirrina.execution.command.CommandFactory; import at.ac.uibk.dps.cirrina.execution.command.Scope; import at.ac.uibk.dps.cirrina.execution.object.action.TimeoutAction; import at.ac.uibk.dps.cirrina.execution.object.context.Context; +import at.ac.uibk.dps.cirrina.execution.object.context.ContextBuilder; import at.ac.uibk.dps.cirrina.execution.object.context.Extent; -import at.ac.uibk.dps.cirrina.execution.object.context.InMemoryContext; import at.ac.uibk.dps.cirrina.execution.object.statemachine.StateMachine; +import jakarta.annotation.Nullable; +import org.jgrapht.traverse.TopologicalOrderIterator; + +import java.io.IOException; import java.util.ArrayList; import java.util.List; -import org.jgrapht.traverse.TopologicalOrderIterator; public final class State implements Scope { - private final Context localContext = new InMemoryContext(true); + private final Context localContext; + private final Context staticContext; + private final @Nullable ContextDescription localContextDescription; private final StateClass stateClassObject; @@ -24,11 +30,32 @@ public final class State implements Scope { public State(StateClass stateClassObject, StateMachine parent) { this.stateClassObject = stateClassObject; this.parent = parent; + this.localContextDescription = stateClassObject.getLocalContextDescription().orElse(null); + try { + this.localContext = stateClassObject + .getLocalContextDescription() + .map(ContextBuilder::from) + .orElseGet(ContextBuilder::from) + .inMemoryContext(true) + .build(); + } catch (IOException ignored) { + throw new IllegalStateException("Failed to build local context for state: " + stateClassObject.getName()); + } + try { + this.staticContext = stateClassObject + .getStaticContextDescription() + .map(ContextBuilder::from) + .orElseGet(ContextBuilder::from) + .inMemoryContext(true) + .build(); + } catch (IOException ignored) { + throw new IllegalStateException("Failed to build static context for state: " + stateClassObject.getName()); + } } @Override public Extent getExtent() { - return parent.getExtent().extend(localContext); + return parent.getExtent().extend(staticContext).extend(localContext); } @Override @@ -36,6 +63,11 @@ public String getId() { return parent.getId(); } + @Override + public StateMachine getStateMachine() { + return this.parent; + } + public StateClass getStateObject() { return stateClassObject; } @@ -79,4 +111,12 @@ public List getTimeoutActionObjects() { return timeoutActionObjects; } + + public void resetLocalContext() { + try { + ContextBuilder.resetContextToDefault(localContext, localContextDescription); + } catch (IOException ignored) { + throw new IllegalStateException("Failed to reset local context for state: " + stateClassObject.getName()); + } + } } diff --git a/src/main/java/at/ac/uibk/dps/cirrina/execution/object/statemachine/StateMachine.java b/src/main/java/at/ac/uibk/dps/cirrina/execution/object/statemachine/StateMachine.java index 72c3d0c9..586ed68c 100644 --- a/src/main/java/at/ac/uibk/dps/cirrina/execution/object/statemachine/StateMachine.java +++ b/src/main/java/at/ac/uibk/dps/cirrina/execution/object/statemachine/StateMachine.java @@ -1,26 +1,11 @@ package at.ac.uibk.dps.cirrina.execution.object.statemachine; -import static at.ac.uibk.dps.cirrina.tracing.SemanticConvention.COUNTER_EVENTS_HANDLED; -import static at.ac.uibk.dps.cirrina.tracing.SemanticConvention.COUNTER_EVENTS_RECEIVED; -import static at.ac.uibk.dps.cirrina.tracing.SemanticConvention.COUNTER_INVOCATIONS; -import static at.ac.uibk.dps.cirrina.tracing.SemanticConvention.COUNTER_STATE_MACHINE_INSTANCES; -import static at.ac.uibk.dps.cirrina.tracing.SemanticConvention.GAUGE_ACTION_DATA_LATENCY; -import static at.ac.uibk.dps.cirrina.tracing.SemanticConvention.GAUGE_ACTION_INVOKE_LATENCY; -import static at.ac.uibk.dps.cirrina.tracing.SemanticConvention.GAUGE_ACTION_RAISE_LATENCY; -import static at.ac.uibk.dps.cirrina.tracing.SemanticConvention.GAUGE_EVENT_RESPONSE_TIME_EXCLUSIVE; -import static at.ac.uibk.dps.cirrina.tracing.SemanticConvention.GAUGE_EVENT_RESPONSE_TIME_INCLUSIVE; - import at.ac.uibk.dps.cirrina.cirrina.Runtime; import at.ac.uibk.dps.cirrina.classes.state.StateClass; import at.ac.uibk.dps.cirrina.classes.statemachine.StateMachineClass; import at.ac.uibk.dps.cirrina.classes.transition.TransitionClass; import at.ac.uibk.dps.cirrina.csm.Csml.EventChannel; -import at.ac.uibk.dps.cirrina.execution.command.ActionCommand; -import at.ac.uibk.dps.cirrina.execution.command.ActionRaiseCommand; -import at.ac.uibk.dps.cirrina.execution.command.ActionTimeoutResetCommand; -import at.ac.uibk.dps.cirrina.execution.command.CommandFactory; -import at.ac.uibk.dps.cirrina.execution.command.ExecutionContext; -import at.ac.uibk.dps.cirrina.execution.command.Scope; +import at.ac.uibk.dps.cirrina.execution.command.*; import at.ac.uibk.dps.cirrina.execution.object.action.TimeoutAction; import at.ac.uibk.dps.cirrina.execution.object.context.Context; import at.ac.uibk.dps.cirrina.execution.object.context.ContextBuilder; @@ -37,17 +22,18 @@ import at.ac.uibk.dps.cirrina.utils.Time; import com.google.common.flogger.FluentLogger; import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.Tracer; import jakarta.annotation.Nullable; import jakarta.validation.constraints.NotNull; +import org.apache.commons.lang3.builder.ToStringBuilder; + import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Queue; +import java.util.*; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.stream.Collectors; -import org.apache.commons.lang3.builder.ToStringBuilder; + +import static at.ac.uibk.dps.cirrina.tracing.SemanticConvention.*; public final class StateMachine implements Runnable, EventListener, Scope { @@ -105,9 +91,9 @@ public final class StateMachine implements Runnable, EventListener, Scope { private final Gauges gauges; private final Counters counters; - + private final Tracer tracer; + private final OpenTelemetry openTelemetry; private State activeState; - private List nestedStateMachineIds = new ArrayList<>(); /** @@ -138,6 +124,7 @@ public StateMachine( this.stateMachineClass = stateMachineClass; this.serviceImplementationSelector = serviceImplementationSelector; this.parentStateMachine = parentStateMachine; + this.openTelemetry = openTelemetry; stateMachineEventHandler = new StateMachineEventHandler(this, this.runtime.getEventHandler()); @@ -180,6 +167,9 @@ public StateMachine( counters.addCounter(COUNTER_EVENTS_HANDLED); counters.addCounter(COUNTER_INVOCATIONS); counters.addCounter(COUNTER_STATE_MACHINE_INSTANCES); + + // Create tracer + this.tracer = openTelemetry.getTracer("at.ac.uibk.dps.cirrina.statemachine"); } /** @@ -194,7 +184,6 @@ public boolean onReceiveEvent(Event event) { if (isTerminated()) { return false; } - // Increment events received counter counters .getCounter(COUNTER_EVENTS_RECEIVED) @@ -262,6 +251,7 @@ private CommandFactory stateMachineScopedCommandFactory( serviceImplementationSelector, // Service implementation selector stateMachineEventHandler, // Event handler this, // Event listener + this.timeoutActionManager, // Timeout Manager gauges, // Gauges counters, // Counters false // Is while? @@ -289,9 +279,10 @@ private CommandFactory stateScopedCommandFactory( serviceImplementationSelector, // Service implementation selector stateMachineEventHandler, // Event handler this, // Event listener + this.timeoutActionManager, // Timeout manager gauges, // Gauges counters, // Counters - false // Is while? + isWhile // Is while? ) ); } @@ -397,13 +388,6 @@ private void execute(List actionCommands) throws UnsupportedOpera // Execute and acquire new commands final var newCommands = actionCommand.execute(); - // KLUDGE: There may be a nicer solution here, one suggestion would be to move the timeout action manager to the execution context - if (actionCommand instanceof ActionTimeoutResetCommand) { - stopTimeoutAction( - ((ActionTimeoutResetCommand) actionCommand).getTimeoutResetAction().getAction() - ); - } - // Execute any subsequent command execute(newCommands); } @@ -539,10 +523,15 @@ private void doTransition(Transition transition, @Nullable Event raisingEvent) if (transition.isElse()) { return; } - + CommandFactory commandFactory; + if (transition.isInternalTransition()){ + commandFactory = stateScopedCommandFactory(activeState, raisingEvent, false); + } else { + commandFactory = stateMachineScopedCommandFactory(this, raisingEvent); + } // Gather action commands final var transitionActionCommands = transition.getActionCommands( - stateMachineScopedCommandFactory(this, raisingEvent) + commandFactory ); // Execute in order @@ -563,12 +552,14 @@ private void doTransition(Transition transition, @Nullable Event raisingEvent) * @param enteringState StateClass instance that is being entered. * @param raisingEvent The raising event or null. * @throws UnsupportedOperationException If the entry/while actions could not be executed. - * @throws UnsupportedOperationException If the timeout actions could not be started. + * @throws UnsupportedOperationException If the timeout actions could not be started.c * @throws UnsupportedOperationException If the states could not be switched. * @throws UnsupportedOperationException If an always transition could not be selected. */ private Optional doEnter(State enteringState, @Nullable Event raisingEvent) throws UnsupportedOperationException, IllegalArgumentException { + enteringState.resetLocalContext(); + // Gather action commands final var entryActionCommands = enteringState.getEntryActionCommands( stateScopedCommandFactory(enteringState, raisingEvent, false) @@ -689,7 +680,6 @@ private void handleTransition(@NotNull Transition transition, @Nullable Event ra private Optional handleEvent(Event event) throws InterruptedException, UnsupportedOperationException { logger.atFiner().log("State machine '%s': Handling event '%s'", this, event); - // Increment events received counter counters .getCounter(COUNTER_EVENTS_HANDLED) @@ -707,7 +697,7 @@ private Optional handleEvent(Event event) } // Create a temporary extent that contains the event data - final var extent = getExtent().extend(eventDataContext); + final var extent = activeState.getExtent().extend(eventDataContext); final var onTransition = trySelectOnTransition(event, extent); @@ -755,8 +745,10 @@ public void run() { // TransitionClass into the initial state var nextTransition = doEnter(initialStateInstance, null); - while (!isTerminated()) { + var startTime = 0.0; + var type = ""; + Span span = null; Event event = null; // Wait for a next event, if no transition is selected. No transition is selected initially if the initial state has no selectable @@ -768,6 +760,12 @@ public void run() { } event = eventQueue.poll(); } + startTime = Time.timeInMillisecondsSinceEpoch(); + type = determineEventType(event); + span = + tracer.spanBuilder("process_event_" + type) + .setAttribute("event.channel", event.getChannel().toString()) + .startSpan(); nextTransition = handleEvent(event); } @@ -777,14 +775,15 @@ public void run() { // event again if (nextTransition.isPresent()) { handleTransition(nextTransition.get(), event); - nextTransition = Optional.empty(); } // Record event handling time if (event != null) { - final var delta = Time.timeInMillisecondsSinceEpoch() - event.getCreatedTime(); - + final var delta = Time.timeInMillisecondsSinceEpoch() - startTime; + span.setAttribute("event.type", type); + span.setAttribute("event.processing_time", delta); + span.end(); gauges .getGauge(GAUGE_EVENT_RESPONSE_TIME_EXCLUSIVE) .set(delta, gauges.attributesForEvent(event.getChannel().toString())); @@ -803,6 +802,19 @@ public void run() { counters.getCounter(COUNTER_STATE_MACHINE_INSTANCES).add(-1, counters.attributesForInstances()); } + + private String determineEventType(Event event) { + if (event.getData() == null) return "noop"; + + for (var variable : event.getData()) { + if ("traceId".equals(variable.name()) + && variable.value() != null + && !variable.value().toString().isEmpty()) { + return "train"; + } + } + return "noop"; + } /** * Returns this scope's extent. * @@ -811,8 +823,8 @@ public void run() { @Override public Extent getExtent() { return Optional.ofNullable(parentStateMachine) - .map(parent -> parent.getExtent().extend(localContext)) - .orElseGet(() -> runtime.getExtent().extend(localContext)); + .map(parent -> parent.getExtent().extend(localContext)) + .orElseGet(() -> runtime.getExtent().extend(localContext)); } @Override @@ -820,6 +832,15 @@ public String getId() { return getStateMachineInstanceId().toString(); } + @Override + public StateMachine getStateMachine() { + return this; + } + + public Runtime getRuntime() { + return this.runtime; + } + @Override public String toString() { return new ToStringBuilder(this) @@ -846,6 +867,19 @@ public StateMachineClass getStateMachineClass() { return stateMachineClass; } + /** + * Returns the active State + * + * @return State object. + */ + public State getActiveState() { return activeState;} + + public StateMachine getParentStateMachine(){ + return parentStateMachine; + } + + public List getNestedStateMachineIds() { return nestedStateMachineIds; } + /** * Sets the collection of nested state machine instance IDs. * @@ -854,4 +888,6 @@ public StateMachineClass getStateMachineClass() { public void setNestedStateMachineIds(List nestedStateMachineIds) { this.nestedStateMachineIds = nestedStateMachineIds; } + + public OpenTelemetry getOpenTelemetry() {return this.openTelemetry;} } diff --git a/src/main/resources/public/GraphLayout.ts b/src/main/resources/public/GraphLayout.ts new file mode 100644 index 00000000..32ca3e07 --- /dev/null +++ b/src/main/resources/public/GraphLayout.ts @@ -0,0 +1,344 @@ +import * as d3 from "d3"; +import type {Node as GraphNode, Link as GraphLink, GraphData} from "./types.ts"; + +/** Encapsulate all graph layout logic. */ +export class GraphLayoutEngine { + private readonly width: number; + private readonly height: number; + + // Layout Parameters + private readonly LAYOUT_PARAMS = { + SPACING_Y: 350, + RECT_HEIGHT: 150, + COLUMN_WIDTH: 100, + COLUMN_GAP: 100, + }; + + constructor(width: number, height: number) { + this.width = width; + this.height = height; + } + + /** + * Calculate and assign the layout coordinates for all nodes in the graph. + * + * @param data The complete GraphData object containing all nodes and links. + * @returns The parameters of the main layout ellipse. + */ + public calculateLayout(data: GraphData): { radiusX: number; radiusY: number } { + const nodes = data.nodes; + const links = data.links; + + // Find connected components + const components = this.findConnectedComponents(nodes, links, false); + type ComponentNode = { + nodes: GraphNode[]; + x: number; + y: number; + angle: number; + radius: number; + }; + + // Compute initial elliptical positions and radius + const componentCount = components.length; + const radiusX = (this.width * 0.4) + componentCount * 100; + const radiusY = (this.height * 0.4) + componentCount * 100; + + const componentNodes: ComponentNode[] = components.map((componentArray, i) => { + const angle = (2 * Math.PI * i) / componentCount; + + // Estimate Combined Hull Radius + const nestedComponents = this.findConnectedComponents(componentArray, links, true); + let combinedRadius: number; + + if (nestedComponents.length <= 1) { + combinedRadius = nestedComponents.length === 1 + ? this.getIndividualHullRadius(nestedComponents[0], links) + : 150; + } else { + combinedRadius = Math.sqrt(nestedComponents.map(hullNodes => this.getIndividualHullRadius(hullNodes, links)).reduce((acc, r) => acc + (r * r), 0)) * 1.25; + } + + return { + nodes: componentArray, + angle, + x: this.width / 2 + radiusX * Math.cos(angle), + y: this.height / 2 + radiusY * Math.sin(angle), + radius: Math.max(combinedRadius, 200), + }; + }); + + // Layout Nodes + const individualHullCenters = new Map(); + const individualHullNodes = new Map(); + + componentNodes.forEach((componentNode) => { + const combinedHullCenterX = componentNode.x ?? this.width / 2; + const combinedHullCenterY = componentNode.y ?? this.height / 2; + const nestedComponents = this.findConnectedComponents(componentNode.nodes, links, true); + + // Found one individual hull inside + if (nestedComponents.length <= 1) { + const hullArray = nestedComponents[0] || componentNode.nodes; + const instanceNode = hullArray.find(n => n.group === 'instance'); + if (instanceNode) { + individualHullCenters.set(instanceNode.id, {x: combinedHullCenterX, y: combinedHullCenterY}); + individualHullNodes.set(instanceNode.id, hullArray); + } + } + // Found multiple individual hulls inside + else { + type NestedSimNode = { + id: string; + nodes: GraphNode[]; + x: number; + y: number; + radius: number; + }; + + const nestedMetaNodes: NestedSimNode[] = nestedComponents.map((individualHullArray, j) => { + const componentRadius = this.getIndividualHullRadius(individualHullArray, links) + const nodeId = individualHullArray.find(n => n.group === 'instance')?.id || `nested-${Math.random()}`; + individualHullNodes.set(nodeId, individualHullArray); + + return { + id: nodeId, + nodes: individualHullArray, + x: combinedHullCenterX + 5 * Math.cos((2 * Math.PI * j) / nestedComponents.length), + y: combinedHullCenterY + 5 * Math.sin((2 * Math.PI * j) / nestedComponents.length), + radius: componentRadius, + }; + }); + + // Run a simulation for individual hulls inside combined hull + const nestedSim = d3.forceSimulation(nestedMetaNodes) + .force("collide", d3.forceCollide(d => d.radius * 0.7).strength(0.3)) + .force("center", d3.forceCenter(combinedHullCenterX, combinedHullCenterY).strength(0.5)) + .stop() + .tick(300); + + nestedMetaNodes.forEach(meta => { + individualHullCenters.set(meta.id, {x: meta.x ?? combinedHullCenterX, y: meta.y ?? combinedHullCenterY}); + }); + } + }); + // Apply layout to each individual hull + individualHullNodes.forEach((individualHullArray, instanceId) => { + const compCenterX = individualHullCenters.get(instanceId)?.x ?? this.width / 2; + const compCenterY = individualHullCenters.get(instanceId)?.y ?? this.height / 2; + + const instanceNode = individualHullArray.find(n => n.group === "instance"); + if (!instanceNode) return; + + const initialNode = individualHullArray.find(n => + n.group !== "instance" && + links.some(l => (l.source as GraphNode).id === instanceNode.id && (l.target as GraphNode).id === n.id) + ); + + const terminalNodes = individualHullArray.filter(n => n.isTerminal); + const intermediateNodes = individualHullArray.filter(n => n !== instanceNode && n !== initialNode && !n.isTerminal && n.group !== "service"); + + const gridCols = intermediateNodes.length > 0 ? Math.ceil(Math.sqrt(intermediateNodes.length)) : 0; + const gridWidth = gridCols > 0 ? (gridCols - 1) * (this.LAYOUT_PARAMS.COLUMN_WIDTH + this.LAYOUT_PARAMS.COLUMN_GAP) + this.LAYOUT_PARAMS.COLUMN_WIDTH : 0; + + let totalComponentWidth = this.LAYOUT_PARAMS.COLUMN_WIDTH; + if (initialNode) totalComponentWidth += this.LAYOUT_PARAMS.COLUMN_GAP + this.LAYOUT_PARAMS.COLUMN_WIDTH; + if (gridWidth > 0) totalComponentWidth += this.LAYOUT_PARAMS.COLUMN_GAP + gridWidth; + if (terminalNodes.length > 0) totalComponentWidth += this.LAYOUT_PARAMS.COLUMN_GAP + this.LAYOUT_PARAMS.COLUMN_WIDTH; + + let currentX = compCenterX - totalComponentWidth / 2; + const instanceX = currentX + this.LAYOUT_PARAMS.COLUMN_WIDTH / 2; + + currentX += this.LAYOUT_PARAMS.COLUMN_WIDTH + this.LAYOUT_PARAMS.COLUMN_GAP; + const initialX = initialNode ? currentX + this.LAYOUT_PARAMS.COLUMN_WIDTH / 2 : 0; + + if (initialNode) currentX += this.LAYOUT_PARAMS.COLUMN_WIDTH + this.LAYOUT_PARAMS.COLUMN_GAP; + const gridStartX = gridWidth > 0 ? currentX : 0; + + currentX += gridWidth + this.LAYOUT_PARAMS.COLUMN_GAP; + const terminalX = terminalNodes.length > 0 ? currentX + this.LAYOUT_PARAMS.COLUMN_WIDTH / 2 : 0; + + const distribute = (nodesToDistribute: GraphNode[], xPos: number) => { + const count = nodesToDistribute.length; + if (count === 0) return; + nodesToDistribute.forEach((node, j) => { + node.x = xPos; + node.y = count === 1 ? compCenterY : compCenterY - this.LAYOUT_PARAMS.RECT_HEIGHT / 2 + j * (this.LAYOUT_PARAMS.RECT_HEIGHT / Math.max(1, count - 1)); + }); + }; + + const distributeInGrid = (nodesToDistribute: GraphNode[], startX: number, w: number) => { + const count = nodesToDistribute.length; + if (count === 0) return; + const cols = Math.ceil(Math.sqrt(count)); + const rows = Math.ceil(count / cols); + nodesToDistribute.forEach((node, idx) => { + const colIndex = idx % cols; + const rowIndex = Math.floor(idx / cols); + node.x = cols === 1 ? startX + w / 2 : startX + (colIndex * (w / (cols - 1))); + node.y = rows === 1 ? compCenterY : compCenterY - this.LAYOUT_PARAMS.RECT_HEIGHT / 2 + rowIndex * (this.LAYOUT_PARAMS.RECT_HEIGHT / (rows - 1)); + }); + }; + + // Apply the layout for this individual hulls nodes + distribute([instanceNode], instanceX); + if (initialNode) distribute([initialNode], initialX); + distributeInGrid(intermediateNodes, gridStartX, gridWidth); + distribute(terminalNodes, terminalX); + }); + + return {radiusX, radiusY}; + } + + /** + * Calculate the positions for filtered visible components and arrange them on an ellipse + * + * @param nodes The visible nodes. + * @param links The visible links. + * @param width The SVG width. + * @param height The SVG height. + * @returns The radii of the new ellipse. + */ + public positionVisibleComponents( + nodes: GraphNode[], + links: GraphLink[], + width: number, + height: number + ): { radiusX: number, radiusY: number } { + const visibleComponents = this.findConnectedComponents(nodes, links, false); + const componentCount = visibleComponents.length; + const radiusX = (width * 0.4) + componentCount * 100; + const radiusY = (height * 0.4) + componentCount * 100; + + // Move components to new positions + visibleComponents.forEach((componentNodes, i: number) => { + const dx = (width / 2 + radiusX * Math.cos((2 * Math.PI * i) / componentCount)) - (Math.min(...componentNodes.map(n => n.x!)) + Math.max(...componentNodes.map(n => n.x!))) / 2; + const dy = (height / 2 + radiusY * Math.sin((2 * Math.PI * i) / componentCount)) - (Math.min(...componentNodes.map(n => n.y!)) + Math.max(...componentNodes.map(n => n.y!))) / 2; + + componentNodes.forEach(node => { + node.x = node.x! + dx; + node.y = node.y! + dy; + }); + }); + + return {radiusX, radiusY}; + } + + /** + * Calculate and assign layout coordinates for service nodes around a hull. + * + * @param hullNodes The nodes of the hull to circle. + * @param servicesToLayout The service nodes to position + * @returns The x coordinate of the center of the hull + */ + public layoutServiceNodes( + hullNodes: GraphNode[], + servicesToLayout: GraphNode[] + ): { cx: number } { + const nonServiceNodes = hullNodes.filter(n => n.group !== "service"); + const minX = d3.min(nonServiceNodes, n => n.x ?? 0) ?? 0; + const maxX = d3.max(nonServiceNodes, n => n.x ?? 0) ?? 0; + const minY = d3.min(nonServiceNodes, n => n.y ?? 0) ?? 0; + const maxY = d3.max(nonServiceNodes, n => n.y ?? 0) ?? 0; + + const cx = (minX + maxX) / 2; + const cy = (minY + maxY) / 2; + + const hullRadiusX = Math.max(maxX - minX, 100) / 2 + 150; + const hullRadiusY = Math.max(maxY - minY, 100) / 2 + 150; + + const angleStep = (2 * Math.PI) / (servicesToLayout.length || 1); + servicesToLayout.forEach((node, i) => { + node.x = cx + hullRadiusX * Math.cos(i * angleStep); + node.y = cy + hullRadiusY * Math.sin(i * angleStep); + }); + + return {cx}; + } + + /** + * Find connected components in the graph + * + * @param nodes Nodes in the graph + * @param links Links connecting those nodes + * @param nested Flag whether to ignore nested relation (find hulls for each state machine) or not (treat parent and child state machine as one for hull creation) + * @private + */ + public findConnectedComponents(nodes: GraphNode[], links: GraphLink[], nested: boolean): GraphNode[][] { + const filteredLinks = links.filter((link) => link.type !== "event-link"); + const components: GraphNode[][] = []; + const visited = new Set(); + const nodeMap = new Map(nodes.map((n) => [n.id, n])); + const adj = new Map(); + nodes.forEach((n) => adj.set(n.id, [])); + + filteredLinks.forEach((link) => { + if (link.type === "nested" && nested) return; + const sourceId = typeof link.source === "string" ? link.source : link.source.id; + const targetId = typeof link.target === "string" ? link.target : link.target.id; + if (adj.has(sourceId) && adj.has(targetId)) { + adj.get(sourceId)!.push(targetId); + adj.get(targetId)!.push(sourceId); + } + }); + + for (const node of nodes) { + if (visited.has(node.id)) continue; + const component: GraphNode[] = []; + const queue: GraphNode[] = [node]; + visited.add(node.id); + while (queue.length > 0) { + const cur = queue.shift()!; + component.push(cur); + const neighbors = adj.get(cur.id) || []; + for (const nid of neighbors) { + if (!visited.has(nid)) { + visited.add(nid); + const neighborNode = nodeMap.get(nid); + if (neighborNode) queue.push(neighborNode); + } + } + } + components.push(component); + } + return components; + } + + /** + * Estimate the layout radius of an individual state machine hull. + * + * @param individualHullArray The array of GraphNode objects that belong to this specific hull + * @param allLinks Complete list of links in the graph + */ + private getIndividualHullRadius(individualHullArray: GraphNode[], allLinks: GraphLink[]): number { + const instanceNode = individualHullArray.find(n => n.group === "instance"); + + // Find nodes connected to this instance + const initialNode = individualHullArray.find(n => + n.group !== "instance" && + allLinks.some(l => (l.source as GraphNode).id === instanceNode?.id && (l.target as GraphNode).id === n.id) + ); + const terminalNodes = individualHullArray.filter(n => n.isTerminal); + const intermediateNodes = individualHullArray.filter(n => + n !== instanceNode && + n !== initialNode && + !n.isTerminal && + n.group !== "service" + ); + + const gridCols = intermediateNodes.length > 0 ? Math.ceil(Math.sqrt(intermediateNodes.length)) : 0; + const gridWidth = gridCols > 0 + ? (gridCols - 1) * (this.LAYOUT_PARAMS.COLUMN_WIDTH + this.LAYOUT_PARAMS.COLUMN_GAP) + this.LAYOUT_PARAMS.COLUMN_WIDTH + : 0; + + let totalComponentWidth = this.LAYOUT_PARAMS.COLUMN_WIDTH; + if (initialNode) totalComponentWidth += this.LAYOUT_PARAMS.COLUMN_GAP + this.LAYOUT_PARAMS.COLUMN_WIDTH; + if (gridWidth > 0) totalComponentWidth += this.LAYOUT_PARAMS.COLUMN_GAP + gridWidth; + if (terminalNodes.length > 0) totalComponentWidth += this.LAYOUT_PARAMS.COLUMN_GAP + this.LAYOUT_PARAMS.COLUMN_WIDTH; + + const totalComponentHeight = this.LAYOUT_PARAMS.RECT_HEIGHT; + + // Estimate radius as half the max dimension plus padding + return Math.max(totalComponentWidth, totalComponentHeight) / 2 + 30; + } +} \ No newline at end of file diff --git a/src/main/resources/public/GraphRenderer.ts b/src/main/resources/public/GraphRenderer.ts new file mode 100644 index 00000000..64bf32ca --- /dev/null +++ b/src/main/resources/public/GraphRenderer.ts @@ -0,0 +1,591 @@ +import * as d3 from "d3"; +import type {Node as GraphNode, Link as GraphLink, Hull as Hull, GraphData} from "./types.ts"; + +/** Manage all D3 drawing and DOM manipulations. */ +export class GraphRenderer { + // D3 Selections + public svg: d3.Selection; + public g: d3.Selection; + public tooltip: d3.Selection; + + // Element Groups + public linkGroup!: d3.Selection; + public nodeGroup!: d3.Selection; + public hullGroup!: d3.Selection; + public layoutEllipseGroup!: d3.Selection; + public checkboxListGroup!: d3.Selection; + public serviceCheckboxListGroup!: d3.Selection; + public legend!: d3.Selection; + + // Scales + public color: d3.ScaleOrdinal; + + // State + private flashingNodes = new Set(); + + constructor( + container: HTMLElement, + width: number, + height: number + ) { + // Initialize D3 elements + this.svg = d3.select(container) + .append("svg") + .attr("viewBox", `0 0 ${width} ${height}`); + + this.g = this.svg.append("g"); + this.tooltip = d3.select("#tooltip"); + + this.color = d3.scaleOrdinal(d3.schemePaired); + + // Run all setup methods + this.initDefs(); + this.initGroups(width, height); + this.initLegend(width); + this.initCheckboxes(width); + } + + /** Append the main elements for layering the graph. */ + public initGroups(width: number, height: number): void { + this.layoutEllipseGroup = this.g.insert("g", ".hulls").attr("class", "ellipse"); + this.layoutEllipseGroup.append("ellipse") + .attr("class", "layout-path-ellipse") + .attr("cx", width / 2) + .attr("cy", height / 2); + + this.linkGroup = this.g.append("g").attr("class", "links"); + this.nodeGroup = this.g.append("g").attr("class", "nodes"); + this.hullGroup = this.g.insert("g", ".links").attr("class", "hulls"); + } + + /** Create the legend. */ + public initLegend(width: number): void { + this.color = d3.scaleOrdinal(d3.schemePaired); + this.legend = this.svg + .append("g") + .attr("class", "legend") + .attr("transform", `translate(${width - 250}, 20)`); + + type LegendItem = + | { type: "title"; text: string } + | { type: "node"; group: GraphNode["group"]; text: string } + | { type: "style"; fill: string; stroke?: string; text: string } + | { type: "link"; linkType: GraphLink["type"]; text: string } + | { type: "eventBox"; text: string; color: string } + | { type: "spacer" }; + + const legendData: LegendItem[] = [ + {type: "title", text: "Node Types"}, + {type: "node", group: "instance", text: "State Machine Instance"}, + {type: "node", group: "state", text: "State"}, + {type: "node", group: "service", text: "Service"}, + {type: "spacer"}, + {type: "title", text: "Node Styles"}, + {type: "style", fill: "#ff050d", text: "Terminal State"}, + {type: "style", fill: "#ff7f0e", stroke: "green", text: "Active State"}, + {type: "style", fill: "#FFD700", text: "Invoked Service (Flash)"}, + {type: "spacer"}, + {type: "title", text: "Link Types"}, + {type: "link", linkType: "transition", text: "Transition"}, + {type: "link", linkType: "contains", text: "Contains"}, + {type: "spacer"}, + {type: "title", text: "Events"}, + {type: "eventBox", text: "Raises Event", color: "steelblue"}, + {type: "eventBox", text: "Receives Event", color: "crimson"}, + ]; + + const legendItem = this.legend + .selectAll(".legend-item") + .data(legendData) + .join("g") + .attr("class", "legend-item") + .attr("transform", (_d, i) => `translate(0, ${i * 22})`); + + legendItem + .filter((d) => d.type === "title") + .append("text") + .attr("class", "legend-title") + .attr("y", 8) + .text((d) => (d as any).text); + + legendItem + .filter((d) => d.type === "node") + .append("circle") + .attr("r", 8) + .attr("cy", 4) + .style("fill", (d) => this.color((d as any).group)); + + legendItem + .filter((d) => d.type === "style") + .append("circle") + .attr("r", 8) + .attr("cy", 4) + .style("fill", (d) => (d as any).fill) + .style("stroke", (d) => (d as any).stroke || "none") + .style("stroke-width", (d) => ((d as any).stroke ? 2 : 0)); + + legendItem + .filter((d) => d.type === "link") + .append("line") + .attr("x1", 0) + .attr("x2", 15) + .attr("y1", 4) + .attr("y2", 4) + .style("stroke", (d) => ((d as any).linkType === "contains" ? "#414743" : "#999")) + .style("stroke-width", 2) + .style("stroke-dasharray", (d) => ((d as any).linkType === "invokes" ? "3,3" : null)) + .attr("marker-end", (d) => ((d as any).linkType === "transition" ? "url(#arrowhead)" : null)); + + legendItem + .filter((d) => !["title", "spacer", "eventBox"].includes(d.type)) + .append("text") + .attr("x", 25) + .attr("y", 9) + .text((d) => (d as any).text); + + legendItem.filter((d) => d.type === "eventBox").each(function (d) { + const g = d3.select(this); + const padding = 6; + const lineHeight = 15; + + const text = g + .append("text") + .text((d as any).text) + .attr("x", padding) + .attr("y", lineHeight) + .attr("font-size", 12) + .attr("fill", (d as any).color); + + const node = text.node(); + if (!node) return; + const bbox = node.getBBox(); + + g.insert("rect", "text") + .attr("x", bbox.x - padding / 2) + .attr("y", bbox.y - padding / 2) + .attr("width", bbox.width + padding) + .attr("height", bbox.height + padding) + .attr("rx", 4) + .attr("ry", 4) + .attr("fill", "rgba(255,255,255,0.9)") + .attr("stroke", "#999") + .attr("stroke-width", 1); + }); + } + + /** Create the foreignObject groups for checkboxes. */ + public initCheckboxes(width: number): void { + this.checkboxListGroup = this.svg.selectAll("g.checkbox-list") + .data([null]) + .join("g") + .attr("class", "checkbox-list") + .attr("transform", `translate(${width - 240}, 450)`); + + this.serviceCheckboxListGroup = this.svg.selectAll("g.service-checkbox-list") + .data([null]) + .join("g") + .attr("class", "service-checkbox-list checkbox-list") + .attr("transform", `translate(${width - 240}, 650)`); + } + + /** Clear the graph when no nodes are visible. */ + public clearGraph(): void { + this.hullGroup.selectAll(".hull").data([]).join("rect"); + this.g.selectAll(".event-info").data([]).join(e => e.remove()); + this.layoutEllipseGroup.select(".layout-path-ellipse").style("stroke", "none"); + } + + /** Draw the main layout ellipse. */ + public drawLayoutEllipse(radiusX: number, radiusY: number): void { + this.layoutEllipseGroup.select(".layout-path-ellipse") + .attr("rx", radiusX) + .attr("ry", radiusY) + .style("fill", "none") + .style("stroke", "rgba(255, 0, 0, 0.5)") + .style("stroke-width", 2) + .style("stroke-dasharray", "10,10"); + } + + /** Draw and updates the hulls. */ + public drawHulls( + individualHulls: Hull[], + combinedHulls: Hull[], + transform: d3.ZoomTransform + ): void { + this.hullGroup.selectAll(".hull-individual") + .data(individualHulls, (d: Hull) => d.id) + .join("rect") + .attr("class", "hull-individual hull") + .style("fill-opacity", 0.15) + .style("stroke-dasharray", null); + + this.hullGroup.selectAll(".hull-combined") + .data(combinedHulls, (d: Hull) => d.id) + .join("rect") + .attr("class", "hull-combined hull") + .style("fill-opacity", 0.01) + .style("stroke-dasharray", "10,10") + .attr("filter", () => { + return transform.k < 0.45 ? "url(#hull-shadow)" : null; + }); + } + + /** Draw and updates the event info boxes. */ + public drawEventInfo( + nodesWithEvents: GraphNode[], + raisedBy: Record, + receivedBy: Record + ): void { + const infoGroups = this.g.selectAll(".event-info") + .data(nodesWithEvents, d => d.id) + .join( + enter => { + const gEnter = enter.append("g").attr("class", "event-info"); + gEnter.append("rect").attr("class", "event-info-box").attr("rx", 6).attr("ry", 6).attr("stroke", "#999").attr("stroke-width", 1); + return gEnter; + }, + update => update, + exit => exit.remove() + ); + infoGroups.each(function (d) { + const group = d3.select(this); + const raised = raisedBy[d.id] || []; + const received = receivedBy[d.id] || []; + const lines: string[] = []; + const lineColors: string[] = []; + if (raised.length) { + lines.push(raised.join(", ")); + lineColors.push("steelblue"); + } + if (received.length) { + lines.push(received.join(", ")); + lineColors.push("crimson"); + } + + const padding = 6, lineHeight = 15; + const textWrapper = group.selectAll(".event-info-box-wrapper").data([null]) + .join("g").attr("class", "event-info-box-wrapper"); + + const texts = textWrapper.selectAll(".event-info-text") + .data(lines) + .join( + enter => enter.append("text").attr("class", "event-info-text") + .attr("font-size", 12), + update => update, + exit => exit.remove() + ) + .text(t => t) + .attr("x", padding) + .attr("y", (_t, i) => padding + (i + 1) * lineHeight - 2) + .attr("fill", (_t, i) => lineColors[i]); + + group.select(".event-info-box").lower(); + const wrapperNode = textWrapper.node(); + if (!wrapperNode) return; + const bbox = wrapperNode.getBBox(); + group.select(".event-info-box") + .attr("x", bbox.x - padding / 2) + .attr("y", bbox.y - padding / 2) + .attr("width", bbox.width + padding) + .attr("height", bbox.height + padding) + .attr("fill", "rgba(255,255,255,0.9)"); + + group.attr("transform", `translate(${d.x}, ${d.y! - bbox.height - 70})`); + }); + } + + /** Draw and positions the service nodes around the selection. */ + public drawServiceNodes( + services: GraphNode[], + cx: number, + duration: number + ): void { + const serviceSel = this.nodeGroup + .selectAll("g.service-node") + .data(services, d => (d as GraphNode).id); + + const serviceEnter = serviceSel.enter() + .append("g") + .attr("class", "node service-node") + .attr("transform", d => `translate(${d.x ?? 0},${d.y ?? 0})`) + .style("opacity", 0); + + serviceEnter.append("circle") + .attr("r", 12) + .style("fill", (d) => (this.flashingNodes.has(d.id) ? "#FFD700" : this.color(d.group))) + .style("stroke", "#555") + .style("stroke-width", 2); + + serviceEnter.append("text") + .text(d => d.label) + .attr("x", d => (d.x ?? 0) < cx ? -18 : 18) + .attr("y", 5) + .attr("text-anchor", d => (d.x ?? 0) < cx ? "end" : "start") + .style("font-size", "12px"); + + serviceSel.merge(serviceEnter).select("text") + .transition() + .duration(duration) + .attr("x", d => (d.x ?? 0) < cx ? -18 : 18) + .attr("text-anchor", d => (d.x ?? 0) < cx ? "end" : "start"); + + serviceSel.merge(serviceEnter) + .transition() + .duration(duration) + .attr("transform", d => `translate(${d.x ?? 0},${d.y ?? 0})`) + .style("opacity", 1); + + serviceSel.exit() + .transition() + .duration(duration) + .style("opacity", 0) + .remove(); + } + + /** Bind node data and sets up tooltip interactions. */ + public setupNodes(nodes: GraphNode[]): void { + const sel = this.nodeGroup + .selectAll("g.node") + .data(nodes.filter((n) => n.group !== "service"), (d) => (d as GraphNode).id) + .join( + (enter) => { + const group = enter.append("g").attr("class", "node"); + group.append("circle").attr("r", (d) => (d.group === "instance" ? 20 : 15)); + group.append("text").text((d) => d.label).attr("x", 22).attr("y", 5); + return group; + }, + (update) => update, + (exit) => exit.remove() + ) as d3.Selection; + + sel.on("mouseover", (event: MouseEvent, d: GraphNode) => { + this.tooltip + .transition() + .duration(200) + .style("opacity", 0.9); + + let tooltipText = `ID: ${d.label}\nGroup: ${d.group}`; + if (d.context) { + tooltipText += `\n\nContext:\n${JSON.stringify(d.context, null, 2)}`; + } + + this.tooltip + .html(tooltipText.replace(/\n/g, "
")) + .style("left", event.pageX + 15 + "px") + .style("top", event.pageY - 28 + "px"); + }).on("mouseout", () => { + this.tooltip.transition().duration(500).style("opacity", 0); + }); + } + + /** Draw and update the links. */ + public drawLinks(links: GraphLink[], getLinkKey: (d: GraphLink) => string): void { + this.linkGroup.selectAll("line") + .data(links.filter(l => l.type !== "event-link"), getLinkKey) + .join("line") + .style("stroke-width", d => d.type === "nested" ? 3 : d.type === "invokes" ? 0 : 1.5) + .style("stroke", d => d.type === "nested" ? "purple" : d.type === "contains" ? "#414743" : "#999") + .style("stroke-dasharray", d => d.type === "invokes" || d.type === "nested" ? "5,5" : null) + .attr("marker-end", d => d.type === "transition" || d.type === "nested" ? "url(#arrowhead)" : null); + } + + /** Apply styles to nodes. */ + public applyNodeStyles(): void { + this.nodeGroup + .selectAll("g.node") + .select("circle") + .transition() + .duration(150) + .style("fill", d => { + if (this.flashingNodes.has(d.id)) return "#FFD700"; + if (d.isTerminal) return "#ff050d"; + return this.color(d.group); + }) + .style("stroke", d => (d.isActive ? "darkgreen" : (d.group === "service") ? "#555" : "#fff")) + .style("stroke-width", d => (d.isActive ? 4 : 2)); + } + + /** Reposition all graph elements */ + public positionGraph(): void { + // Hulls + this.hullGroup.selectAll(".hull-individual") + .attr("x", d => d3.min(d.nodes, n => n.x! - (n.group === "instance" ? 20 : 15) - 20) ?? 0) + .attr("y", d => d3.min(d.nodes, n => n.y! - (n.group === "instance" ? 20 : 15) - 20) ?? 0) + .attr("width", d => { + const minX = d3.min(d.nodes, n => n.x! - (n.group === "instance" ? 20 : 15) - 20) ?? 0; + const maxX = d3.max(d.nodes, n => n.x! + (n.group === "instance" ? 20 : 15) + 20) ?? 0; + return maxX - minX; + }) + .attr("height", d => { + const minY = d3.min(d.nodes, n => n.y! - (n.group === "instance" ? 20 : 15) - 20) ?? 0; + const maxY = d3.max(d.nodes, n => n.y! + (n.group === "instance" ? 20 : 15) + 20) ?? 0; + return maxY - minY; + }) + .style("stroke", d => { + const instanceNode = d.nodes.find(n => n.group === "instance"); + return instanceNode ? this.color(instanceNode.group) : "#aaa"; + }); + + this.hullGroup.selectAll(".hull-combined") + .attr("x", d => d3.min(d.nodes, n => n.x! - (n.group === "instance" ? 20 : 15) - 20) ?? 0) + .attr("y", d => d3.min(d.nodes, n => n.y! - (n.group === "instance" ? 20 : 15) - 20) ?? 0) + .attr("width", d => { + const minX = d3.min(d.nodes, n => n.x! - (n.group === "instance" ? 20 : 15) - 20) ?? 0; + const maxX = d3.max(d.nodes, n => n.x! + (n.group === "instance" ? 20 : 15) + 20) ?? 0; + return maxX - minX; + }) + .attr("height", d => { + const minY = d3.min(d.nodes, n => n.y! - (n.group === "instance" ? 20 : 15) - 20) ?? 0; + const maxY = d3.max(d.nodes, n => n.y! + (n.group === "instance" ? 20 : 15) + 20) ?? 0; + return maxY - minY; + }) + .style("stroke", d => { + const instanceNode = d.nodes.find(n => n.group === "instance"); + return instanceNode ? this.color(instanceNode.group) : "#aaa"; + }); + + // Links and Nodes + this.linkGroup.selectAll("line").attr("x1", (d: any) => d.source.x).attr("y1", (d: any) => d.source.y).attr("x2", (d: any) => d.target.x).attr("y2", (d: any) => d.target.y); + this.nodeGroup.selectAll("g").attr("transform", (d: any) => `translate(${d.x},${d.y})`); + } + + /** Apply fading/highlighting to hulls and nodes for selection. */ + public applySelectionView( + hullToSelect: Hull, + visibleIds: Set, + duration: number + ): void { + this.hullGroup.selectAll(".hull-combined").attr("filter", null); + const selectedInstanceId = hullToSelect.nodes.find(n => n.group === 'instance')?.id; + + this.hullGroup.selectAll("rect.hull") + .transition() + .duration(duration) + .style("opacity", d => { + const currentInstanceId = d.nodes.find(n => n.group === 'instance')?.id; + if (currentInstanceId && currentInstanceId === selectedInstanceId && d.hullType === hullToSelect.hullType) { + return 1; + } + if (hullToSelect.hullType === "combined") { + const shared = d.nodes.some(n => visibleIds.has(n.id)); + return shared ? 1 : 0.15; + } + return 0.02; + }) + .style("stroke", d => { + const instanceNode = d.nodes.find(n => n.group === "instance"); + return instanceNode ? this.color(instanceNode.group) : "#aaa"; + }) + .style("stroke-width", d => { + const currentInstanceId = d.nodes.find(n => n.group === 'instance')?.id; + if (currentInstanceId && currentInstanceId === selectedInstanceId && d.hullType === hullToSelect.hullType) { + return 1.5; + } + if (hullToSelect.hullType === "combined" && d.nodes.some(n => visibleIds.has(n.id))) { + return 1.5; + } + return 1; + }); + + this.nodeGroup.selectAll("g.node:not(.service-node)") + .transition() + .duration(duration) + .style("opacity", d => visibleIds.has(d.id) ? 1 : 0.15); + + this.g.selectAll(".event-info") + .each(function (d: any) { + const group = d3.select(this); + group.selectAll(".event-info-box, .event-info-text") + .transition() + .duration(duration) + .style("opacity", visibleIds.has(d.id) ? 1 : 0.15); + }); + } + + /** Attach click handlers to the hull rectangles. */ + public setupHullInteractions( + hulls: Hull[], + onClick: (clickedHull: Hull) => void + ): void { + this.hullGroup.selectAll("rect.hull") + .style("cursor", "pointer") + .on("click", (event, clickedHull) => { + event.stopPropagation(); + onClick(clickedHull); + }); + } + + /** Flash a service node. */ + public flashServiceNode(targetId: string): void { + this.flashingNodes.add(targetId); + this.applyNodeStyles(); + window.setTimeout(() => { + this.flashingNodes.delete(targetId); + this.applyNodeStyles(); + }, 1000); + } + + /** Define SVG markers and the shadow for hulls */ + private initDefs(): void { + const defs = this.svg.append("defs"); + + defs.append("marker") + .attr("id", "arrowhead") + .attr("viewBox", "0 -5 10 10") + .attr("refX", 25) + .attr("refY", 0) + .attr("markerWidth", 6) + .attr("markerHeight", 6) + .attr("orient", "auto") + .append("path") + .attr("d", "M0,-5L10,0L0,5") + .attr("fill", "#999"); + + const filter = defs.append("filter") + .attr("id", "hull-shadow") + .attr("x", "-40%") + .attr("y", "-40%") + .attr("width", "180%") + .attr("height", "180%"); + + // Boost alpha + filter.append("feColorMatrix") + .attr("in", "SourceGraphic") + .attr("type", "matrix") + .attr("values", "1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 100 0") + .attr("result", "boostedInput"); + + // Blur the boosted shape + filter.append("feGaussianBlur") + .attr("in", "boostedInput") + .attr("stdDeviation", "10") + .attr("result", "blurredShape"); + + // Create the shadow + filter.append("feComposite") + .attr("operator", "out") + .attr("in", "blurredShape") + .attr("in2", "boostedInput") + .attr("result", "shadowMask"); + + // Define the color and opacity + filter.append("feFlood") + .attr("flood-color", "#4ebfbb") + .attr("flood-opacity", 0.5) + .attr("result", "shadowColor"); + + // Cut the shadow so only outside glow remains + filter.append("feComposite") + .attr("operator", "in") + .attr("in", "shadowColor") + .attr("in2", "shadowMask") + .attr("result", "finalShadow"); + + // Merge shadow and the original + const merge = filter.append("feMerge"); + merge.append("feMergeNode") + .attr("in", "finalShadow") + merge.append("feMergeNode") + .attr("in", "SourceGraphic"); + } +} \ No newline at end of file diff --git a/src/main/resources/public/GraphState.ts b/src/main/resources/public/GraphState.ts new file mode 100644 index 00000000..e7fcffe3 --- /dev/null +++ b/src/main/resources/public/GraphState.ts @@ -0,0 +1,200 @@ +import type {Node as GraphNode, Link as GraphLink, Hull as Hull, GraphData} from "./types.ts"; +import type {GraphLayoutEngine} from "./GraphLayout.ts"; + +// Interface for the filtered data +export interface VisibleGraph { + nodes: GraphNode[]; + links: GraphLink[]; + combinedHulls: Hull[]; + individualHulls: Hull[]; + nodesWithEvents: GraphNode[]; + raisedBy: Record; + receivedBy: Record; +} + +/** Manage all application state and data filtering logic. */ +export class GraphState { + public visible: VisibleGraph = { + nodes: [], + links: [], + combinedHulls: [], + individualHulls: [], + nodesWithEvents: [], + raisedBy: {}, + receivedBy: {}, + }; + // State Properties + private fullGraphData: GraphData = {nodes: [], links: []}; + private selectedHull: Hull | null = null; + + constructor() { + } + + // Mutate State + public setFullData(data: GraphData): void { + this.fullGraphData = data; + } + + public getFullData(): GraphData { + return this.fullGraphData; + } + + public setSelectedHull(hull: Hull | null): void { + this.selectedHull = hull; + } + + public getSelectedHull(): Hull | null { + return this.selectedHull; + } + + /** Take the user selection, filter the full data, and store the result in its property. */ + public updateVisibleData( + checkedInstanceIds: Set, + layoutEngine: GraphLayoutEngine + ): void { + // Filter nodes and links based on checkboxes + const {nodes, links} = this.filterVisibleData(checkedInstanceIds); + + // Calculate hull data + const {combinedHulls, individualHulls} = this.calculateHullData(nodes, links, layoutEngine); + + // Calculate event data + const {nodesWithEvents, raisedBy, receivedBy} = this.calculateEventData(nodes, links); + + // Store all results + this.visible = { + nodes, + links, + combinedHulls, + individualHulls, + nodesWithEvents, + raisedBy, + receivedBy, + }; + } + + /** Get the visible nodes and links based on checkbox selection. */ + private filterVisibleData(checkedInstanceIds: Set): { nodes: GraphNode[], links: GraphLink[] } { + let filteredNodes = this.fullGraphData.nodes.filter(n => { + if (n.group === "instance") { + return checkedInstanceIds.has(n.id); + } + return n.group !== "service"; + }); + + // Build adjacency map for BFS + const fullNodeMap = new Map(filteredNodes.map(n => [n.id, n])); + const adj = new Map(); + filteredNodes.forEach(n => adj.set(n.id, [])); + + this.fullGraphData.links.forEach(link => { + const sourceId = typeof link.source === "object" ? link.source.id : link.source; + const targetId = typeof link.target === "object" ? link.target.id : link.target; + if (fullNodeMap.has(sourceId) && fullNodeMap.has(targetId)) { + adj.get(sourceId)!.push(targetId); + adj.get(targetId)!.push(sourceId); + } + }); + + // BFS from checked instance nodes + const visited = new Set(); + const queue = [...filteredNodes.filter(n => n.group === "instance").map(n => n.id)]; + while (queue.length > 0) { + const id = queue.shift()!; + if (!visited.has(id)) { + visited.add(id); + const neighbors = adj.get(id) || []; + neighbors.forEach(nid => { + if (!visited.has(nid)) queue.push(nid); + }); + } + } + + // Clone the visible nodes and links + const originalNodes = new Map(this.fullGraphData.nodes.map(n => [n.id, n])); + let nodes = Array.from(visited).map(id => { + const originalNode = originalNodes.get(id)!; + return {...originalNode}; // Clone + }); + + const nodeMap = new Map(nodes.map(d => [d.id, d])); + let links = this.fullGraphData.links.filter(l => { + const sourceId = typeof l.source === 'object' ? l.source.id : l.source; + const targetId = typeof l.target === 'object' ? l.target.id : l.target; + return visited.has(sourceId as string) && visited.has(targetId as string); + }) + .map(l => ({...l})) + .filter(l => { + if (typeof l.source === "string") l.source = nodeMap.get(l.source) || (l.source as string); + if (typeof l.target === "string") l.target = nodeMap.get(l.target) || (l.target as string); + return typeof l.source === "object" && typeof l.target === "object"; + }) as GraphLink[]; + + return {nodes, links}; + } + + /** Calculate hull data based on visible nodes/links. */ + private calculateHullData( + nodes: GraphNode[], + links: GraphLink[], + layoutEngine: GraphLayoutEngine + ): { combinedHulls: Hull[], individualHulls: Hull[] } { + const combinedHulls: Hull[] = layoutEngine.findConnectedComponents(nodes, links, false) + .map(component => { + const key = component.map(n => n.id).sort().join("-"); + return { + id: `hull-${key}`, + nodes: component, + hullType: "combined" as const + }; + }) + .filter(c => c.nodes.find(n => n.group === "instance") !== undefined); + + const individualHulls = layoutEngine.findConnectedComponents(nodes, links, true) + .map(component => { + const key = component.map(n => n.id).sort().join("-"); + return { + id: `hull-${key}`, + nodes: component, + hullType: "individual" as const + }; + }) + .filter(c => c.nodes.find(n => n.group === "instance") !== undefined) + .filter(h => { + const key = h.id.replace('hull-ind-', ''); + return !new Set(combinedHulls.map(h => h.id.replace('hull-com-', ''))).has(key); + }); + + return {combinedHulls, individualHulls}; + } + + /** Calculates event data for info boxes. */ + private calculateEventData(nodes: GraphNode[], links: GraphLink[]): { + nodesWithEvents: GraphNode[], + raisedBy: Record, + receivedBy: Record + } { + const raisedBy: Record = {}; + const receivedBy: Record = {}; + links.filter(l => l.type === "event-link").forEach(l => { + const sourceId = typeof l.source === "object" ? l.source.id : l.source; + const targetId = typeof l.target === "object" ? l.target.id : l.target; + if (sourceId) { + raisedBy[sourceId] = raisedBy[sourceId] || []; + if (l.event && !raisedBy[sourceId].includes(l.event)) raisedBy[sourceId].push(l.event); + } + if (targetId) { + receivedBy[targetId] = receivedBy[targetId] || []; + if (l.event && !receivedBy[targetId].includes(l.event)) receivedBy[targetId].push(l.event); + } + }); + + const nodesWithEvents = nodes.filter(n => + n.group === "instance" && + ((raisedBy[n.id] && raisedBy[n.id].length > 0) || + (receivedBy[n.id] && receivedBy[n.id].length > 0)) + ); + + return {nodesWithEvents, raisedBy, receivedBy}; + } +} \ No newline at end of file diff --git a/src/main/resources/public/index.html b/src/main/resources/public/index.html new file mode 100644 index 00000000..b8f43c92 --- /dev/null +++ b/src/main/resources/public/index.html @@ -0,0 +1,19 @@ + + + + + Cirrina Runtime Status + + + +
+
+ + + + + + \ No newline at end of file diff --git a/src/main/resources/public/main.ts b/src/main/resources/public/main.ts new file mode 100644 index 00000000..94d259c2 --- /dev/null +++ b/src/main/resources/public/main.ts @@ -0,0 +1,534 @@ +import * as d3 from "d3"; +import type {Node as GraphNode, Link as GraphLink, Hull as Hull, GraphData, WebSocketMessage} from "./types.ts"; +import {GraphLayoutEngine} from './GraphLayout.ts'; +import {GraphRenderer} from './GraphRenderer.ts'; +import {GraphState} from "./GraphState.ts"; + +/** Manage the D3 visualization. */ +class GraphVisualizer { + + // Core Properties + private readonly container: HTMLElement; + private readonly width: number; + private readonly height: number; + private socket: WebSocket; + private readonly layoutEngine: GraphLayoutEngine; + private renderer: GraphRenderer; + private state: GraphState + + // State + private currentTransform: d3.ZoomTransform = d3.zoomIdentity; + private zoomBehavior: d3.ZoomBehavior; + + constructor(containerId: string) { + const el = document.getElementById(containerId); + if (!el) { + throw new Error(`Element with id '${containerId}' not found.`); + } + this.container = el; + this.width = this.container.clientWidth; + this.height = this.container.clientHeight; + + this.layoutEngine = new GraphLayoutEngine(this.width, this.height); + this.renderer = new GraphRenderer(this.container, this.width, this.height) + this.state = new GraphState() + + // Init zoom + this.zoomBehavior = this.initZoom(); + this.applyInitialZoom(); + + // Init WebSocket + this.socket = this.initSocket(); + } + + /** Flash a service node.*/ + public flashServiceNode(payload: { targetId: string }): void { + this.renderer.flashServiceNode(payload.targetId) + } + + /** Re-layout graph by filtering checkboxes */ + public updateGraph(): void { + // Get user input + const checkedInstanceIds = new Set(); + this.renderer.checkboxListGroup.selectAll("g.checkbox-item input:checked") + .each(function (d) { + checkedInstanceIds.add(d.id); + }); + + // Update State + this.state.updateVisibleData(checkedInstanceIds, this.layoutEngine); + const {nodes, links, combinedHulls, individualHulls, nodesWithEvents, raisedBy, receivedBy} = this.state.visible; + + // Layout + if (nodes.length === 0) { + this.renderer.clearGraph(); + this.renderer.positionGraph(); + return; + } + + // Calculate new positions for visible components + const {radiusX, radiusY} = this.layoutEngine.positionVisibleComponents( + nodes, links, this.width, this.height + ); + this.renderer.drawLayoutEllipse(radiusX, radiusY); + + // Render + this.renderer.drawHulls(individualHulls, combinedHulls, this.currentTransform); + this.renderer.drawEventInfo(nodesWithEvents, raisedBy, receivedBy); + this.renderer.setupNodes(nodes); + this.renderer.drawLinks(links, this.getLinkKey); + this.renderer.applyNodeStyles(); + this.renderer.positionGraph(); + + // Setup interactions + this.renderer.setupHullInteractions( + [...individualHulls, ...combinedHulls], + (clickedHull) => this.onHullClicked(clickedHull) + ); + this.updateSelectedHull(); + } + + /** + * Calculate and assign the layout coordinates for all nodes in the graph. + * + * @param data The complete GraphData object containing all nodes and links. + */ + public calculateLayout(data: GraphData) { + const {radiusX, radiusY} = this.layoutEngine.calculateLayout(data); + this.renderer.drawLayoutEllipse(radiusX, radiusY) + } + + /** Handle click event on a hull. */ + private onHullClicked(clickedHull: Hull): void { + const selectedHull = this.state.getSelectedHull(); + + if (clickedHull.id === selectedHull?.id) { + this.state.setSelectedHull(null); + this.resetGraphView(); + } else { + this.state.setSelectedHull(clickedHull); + this.applyGraphView(clickedHull); + } + } + + /** Re-apply selected hull view if one was selected. */ + private updateSelectedHull(): void { + const selectedHull = this.state.getSelectedHull(); + if (!selectedHull) return; + + // Get visible nodes and links from state + const {nodes, links} = this.state.visible; + + const selectedInstanceId = selectedHull.nodes.find(n => n.group === "instance")?.id; + if (selectedInstanceId) { + const components = (selectedHull.hullType === "individual") + ? this.layoutEngine.findConnectedComponents(nodes, links, true) + : this.layoutEngine.findConnectedComponents(nodes, links, false); + + const newComponents: Hull[] = components.map(c => ({ + id: `hull-${c.map(n => n.id).sort().join("-")}`, + nodes: c, + hullType: "combined" + })); + + // Find the new hull object from visible components + const newSelectedComponent = newComponents.find(c => c.nodes.some(n => n.id === selectedInstanceId)); + if (newSelectedComponent) { + this.state.setSelectedHull(newSelectedComponent); + this.applyGraphView(newSelectedComponent, 0); + } else { + // The selected hull is no longer visible + this.state.setSelectedHull(null); + this.resetGraphView(0); + } + } else { + // Hull has no instance node + this.state.setSelectedHull(null); + this.resetGraphView(0); + } + } + + /** Define and apply the zoom behavior. */ + private initZoom(): d3.ZoomBehavior { + const zoom = d3.zoom() + .filter((event: UIEvent) => { + const target = event.target as Element; + return !(target.closest(".legend") || target.closest(".checkbox-list")); + }) + .on("start.performance", (event: { transform: d3.ZoomTransform }) => { + if (event.transform.k >= 0.45) { + this.renderer.hullGroup.selectAll(".hull-combined").attr("filter", null); + } + }) + .on("zoom", (event: { transform: any; }) => { + this.currentTransform = event.transform; + this.renderer.g.attr("transform", String(event.transform)); + }) + .on("end.performance", (event: { transform: d3.ZoomTransform; }) => { + if (event.transform.k < 0.45) { + this.renderer.hullGroup.selectAll(".hull-combined").attr("filter", "url(#hull-shadow)"); + } + }); + this.renderer.svg.call(zoom); + return zoom; + } + + /** Apply the initial zoomed-out transform. */ + private applyInitialZoom(): void { + const initialScale = 0.4; + const initialTransform = d3.zoomIdentity + .translate((this.width / 2) * (1 - initialScale), (this.height / 4) * (1 - initialScale)) + .scale(initialScale); + + this.renderer.svg.call(this.zoomBehavior.transform as any, initialTransform); + } + + /** Initialize and connect the WebSocket. */ + private initSocket(): WebSocket { + const socket = new WebSocket(`ws://${window.location.host}/visual-socket`); + + socket.addEventListener("open", () => { + console.log("WebSocket connection established for live updates."); + }); + + socket.addEventListener("message", (event) => { + try { + const parsed = JSON.parse(event.data) as WebSocketMessage | any; + switch (parsed.type) { + case "initialState": + case "statusUpdate": + this.state.setFullData(parsed.payload as GraphData); + this.calculateLayout(this.state.getFullData()); + this.setupStateMachineCheckbox( + this.state.getFullData().nodes.filter(n => n.group === "instance") + ); + this.updateGraph(); + break; + case "invocation": + if (parsed.payload?.targetId) this.flashServiceNode(parsed.payload); + break; + + default: + console.warn("Unhandled socket message type:", parsed.type); + } + } catch (error) { + console.error("Error processing WebSocket data:", error); + } + }); + + socket.addEventListener("close", (event) => { + console.log("WebSocket connection closed. Attempting to reconnect in 2 seconds."); + window.setTimeout(() => { + console.log("Reconnecting socket..."); + this.socket = this.initSocket(); + }, 2000); + }); + + socket.addEventListener("error", (error) => { + console.error("WebSocket error:", error); + }); + + return socket; + } + + /** + * Generates a unique string key for a given GraphLink object, needed to track which DOM element corresponds to which link + * + * @param d One link object from the graph data + */ + private getLinkKey(d: GraphLink): string { + const getId = (v?: string | GraphNode | null) => { + if (!v) return ""; + if (typeof v === "string") return v; + if (typeof v === "object" && "id" in v) return v.id; + return ""; + }; + + if ((d as any).type === "event-link") { + const src = (d as any).sourceSM || ""; + const tgt = (d as any).targetSM || ""; + const ev = (d as any).event || ""; + return `${src}-${tgt}-${ev}`; + } + + const sourceId = getId(d.source); + const targetId = getId(d.target); + return `${sourceId}-${targetId}`; + } + + /** + * Apply a selected view, highlighting a specific hull and its contents. + * + * @param hullToSelect Specific hull which should be highlighted + * @param duration Duration the transition should take, if 0 does not apply zoom + * @private + */ + private applyGraphView(hullToSelect: Hull, duration: number = 300): void { + // Setup view + const visibleIds = new Set(hullToSelect.nodes.map(n => n.id)); + this.renderer.checkboxListGroup.style("display", "none"); + this.renderer.applySelectionView(hullToSelect, visibleIds, duration); + + // Find visible Services + const allServices = this.state.getFullData().nodes.filter(n => n.group === "service"); + const visibleServiceIds = new Set(); + const allNodesMap = new Map(this.state.getFullData().nodes.map(n => [n.id, n])); + + this.state.getFullData().links.forEach(link => { + const sourceId = typeof link.source === 'object' ? link.source.id : (link.source as string); + const targetId = typeof link.target === 'object' ? link.target.id : (link.target as string); + const sourceNode = allNodesMap.get(sourceId); + const targetNode = allNodesMap.get(targetId); + if (sourceNode && targetNode) { + if (visibleIds.has(sourceNode.id) && targetNode.group === "service") { + visibleServiceIds.add(targetNode.id); + } + if (visibleIds.has(targetNode.id) && sourceNode.group === "service") { + visibleServiceIds.add(sourceNode.id); + } + } + }); + + let visibleServices = allServices.filter(n => visibleServiceIds.has(n.id)); + + // Render and update Service checkboxes + this.renderer.serviceCheckboxListGroup.style("display", null); + + // Build the service checkboxes + this.setupDynamicServiceCheckboxes(visibleServices); + this.updateServiceCheckboxDisabledState(10); + + // Filter visible Services by checkbox selection + visibleServices = visibleServices.filter(n => { + const checkBox = document.getElementById(`check-service-${n.id}`) as HTMLInputElement | null; + return !checkBox || checkBox.checked; + }); + + // Layout Service Nodes + const {cx} = this.layoutEngine.layoutServiceNodes(hullToSelect.nodes, visibleServices); + + // Render Service Nodes + this.renderer.drawServiceNodes(visibleServices, cx, duration); + + // Zoom on click + if (duration > 0) { + const nonServiceNodes = hullToSelect.nodes.filter(n => n.group !== "service"); + const minX = d3.min(nonServiceNodes, n => (n.x ?? 0) - (n.group === "instance" ? 20 : 15) - 20) ?? 0; + const maxX = d3.max(nonServiceNodes, n => (n.x ?? 0) + (n.group === "instance" ? 20 : 15) + 20) ?? 0; + const minY = d3.min(nonServiceNodes, n => (n.y ?? 0) - (n.group === "instance" ? 20 : 15) - 20) ?? 0; + const maxY = d3.max(nonServiceNodes, n => (n.y ?? 0) + (n.group === "instance" ? 20 : 15) + 20) ?? 0; + + const hullCenterX = (minX + maxX) / 2; + const hullCenterY = (minY + maxY) / 2; + + const newTransform = d3.zoomIdentity + .translate(this.width / 2 - hullCenterX, this.height / 2 - hullCenterY) + .scale(1.1); + + this.currentTransform = newTransform; + this.renderer.svg.transition() + .duration(duration) + .call(this.zoomBehavior.transform as any, newTransform); + } + } + + /** + * Reset graph to default view + * + * @param duration Duration the transition takes, if 0 does not reset zoom + */ + private resetGraphView(duration: number = 300): void { + if (this.currentTransform) + this.renderer.hullGroup.selectAll("rect.hull") + .transition() + .duration(duration) + .style("opacity", d => d.hullType === "combined" ? 1 : 1) + .style("stroke", d => { + const instanceNode = d.nodes.find(n => n.group === "instance"); + return instanceNode ? this.renderer.color(instanceNode.group) : "#aaa"; + }) + .style("stroke-width", 1); + + this.renderer.nodeGroup.selectAll("g") + .transition() + .duration(duration) + .style("opacity", 1); + + this.renderer.linkGroup.selectAll("line") + .transition() + .duration(duration) + .style("opacity", 1); + + this.renderer.g.selectAll(".event-info") + .each(function (d: any) { + const group = d3.select(this); + group.selectAll(".event-info-box, .event-info-text") + .transition() + .duration(duration) + .style("opacity", 1); + }); + + this.renderer.checkboxListGroup.style("display", null); + this.renderer.drawServiceNodes([], 0, 0) + + // Hide the container and destroy all dynamic checkboxes + this.renderer.serviceCheckboxListGroup.style("display", "none"); + this.renderer.serviceCheckboxListGroup + .selectAll("g.checkbox-item") + .data([]) + .join(e => e.remove()); + + // Reset zoom only if this was an interactive reset + if (duration > 0) { + // Re-apply the initial zoom + const initialScale = 0.4; + const initialTransform = d3.zoomIdentity + .translate((this.width / 2) * (1 - initialScale), (this.height / 4) * (1 - initialScale)) + .scale(initialScale); + + this.renderer.svg.transition() + .duration(duration) + .call(this.zoomBehavior.transform as any, initialTransform); + + this.renderer.hullGroup.selectAll(".hull-combined") + .attr("filter", "url(#hull-shadow)"); + } + } + + /** + * Build the service checkbox list dynamically for the selected hull. + * + * @param services The list of service nodes to display. + */ + private setupDynamicServiceCheckboxes(services: GraphNode[]): void { + this.renderer.serviceCheckboxListGroup.selectAll("g.checkbox-item") + .data(services, (d: GraphNode): string => d.id) + .join( + enter => { + const g = enter.append("g") + .attr("class", "checkbox-item") + .attr("transform", (d, i) => `translate(0, ${i * 25})`); + + const fo = g.append("foreignObject") + .attr("width", 230).attr("height", 22); + + const div = fo.append("xhtml:div"); + + div.append("xhtml:input") + .attr("type", "checkbox") + .attr("id", d => `check-service-${d.id}`) + .property("checked", true) + .on("change", () => { + this.updateGraph(); + }); + + div.append("xhtml:label") + .attr("for", d => `check-service-${d.id}`) + .style("margin-left", "5px") + .style("font-family", "sans-serif") + .style("font-size", "14px") + .style("color", "#333") + .text(d => d.label); + + return g; + }, + update => + update.attr("transform", (d, i) => `translate(0, ${i * 25})`), + exit => + exit.remove() + ); + } + + /** + * Set up checkboxes for state machine selection + * + * @param nodes All nodes in the graph, including the instance nodes + */ + private setupStateMachineCheckbox(nodes: GraphNode[]): void { + const instanceNodes = nodes.filter(n => n.group === 'instance'); + this.renderer.checkboxListGroup.selectAll("g.checkbox-item") + .data(instanceNodes, (d: GraphNode): string => d.id) + .join( + enter => { + const g = enter.append("g") + .attr("class", "checkbox-item") + .attr("transform", (d, i) => `translate(0, ${i * 25})`); + + const fo = g.append("foreignObject") + .attr("width", 230).attr("height", 22); + + const div = fo.append("xhtml:div"); + + div.append("xhtml:input") + .attr("type", "checkbox") + .attr("id", d => `check-${d.id}`) + .property("checked", (d, i) => i < 10) + .on("change", () => { + this.updateGraph(); + this.updateCheckboxDisabledState(10); + }); + + div.append("xhtml:label") + .attr("for", d => `check-${d.id}`) + .style("margin-left", "5px") + .style("font-family", "sans-serif") + .style("font-size", "14px") + .style("color", "#333") + .text(d => d.label); + + return g; + }, + update => + update.attr("transform", (d, i) => `translate(0, ${i * 25})`), + exit => + exit.remove() + ); + + this.updateCheckboxDisabledState(10); + } + + /** + * Guarantee only a certain amount of state machines can be selected at once + * + * @param maxSelectedItems maximum number of items which can be checked at once + */ + private updateCheckboxDisabledState(maxSelectedItems: number): void { + const allCheckboxes = this.renderer.checkboxListGroup.selectAll("g.checkbox-item input"); + const checkedCount = allCheckboxes.filter(":checked").size(); + + allCheckboxes.property("disabled", function () { + const isChecked = (this as HTMLInputElement).checked; + return checkedCount >= maxSelectedItems && !isChecked; + }); + } + + /** + * Guarantee only a certain amount of services can be selected at once + * + * @param maxSelectedItems maximum number of items which can be checked at once + */ + private updateServiceCheckboxDisabledState(maxSelectedItems: number): void { + // Select all service checkboxes + const allServiceCheckboxes = this.renderer.serviceCheckboxListGroup + .selectAll("g.checkbox-item input"); + + // Filter to only visible ones + const visibleServiceCheckboxes = allServiceCheckboxes + .filter((d, i, g) => { + const inputElement = g[i] as HTMLInputElement; + const parentG = inputElement.closest('g.checkbox-item'); + return !!(parentG && window.getComputedStyle(parentG).display !== "none"); + }); + + // Count from only the visible, checked set + const checkedCount = visibleServiceCheckboxes.filter(":checked").size(); + + // Apply disabled only to the visible set + visibleServiceCheckboxes.property("disabled", function () { + const isChecked = (this as HTMLInputElement).checked; + return checkedCount >= maxSelectedItems && !isChecked; + }); + } +} + +export default GraphVisualizer; diff --git a/src/main/resources/public/style.css b/src/main/resources/public/style.css new file mode 100644 index 00000000..00cdc2f4 --- /dev/null +++ b/src/main/resources/public/style.css @@ -0,0 +1,74 @@ +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + background-color: #f4f4f9; + color: #333; + margin: 0; + display: flex; + flex-direction: column; + align-items: center; +} + +h1 { + font-weight: 300; +} + +#graph-container { + width: 100vw; + height: 100vh; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + background-color: #fff; +} + +.link { + stroke: #999; + stroke-opacity: 0.6; +} + +.node circle { + stroke: #aa5a; + stroke-width: 2px; +} + +.node text { + pointer-events: none; + font-size: 12px; + font-family: sans-serif; + font-weight: bold; + paint-order: stroke; + stroke: #fff; + stroke-width: 3px; + stroke-linecap: butt; + stroke-linejoin: round; +} + +.node:hover { + cursor: pointer; +} + +#tooltip { + position: absolute; + text-align: left; + padding: 10px; + font: 12px sans-serif; + background: #222; + color: white; + border: 0px; + border-radius: 8px; + pointer-events: none; + opacity: 0; + white-space: pre; + transition: opacity 0.2s; +} + +.hull { + fill-opacity: 0.15; + stroke: #666; + stroke-width: 1px; + stroke-linejoin: round; + +} + +.legend-title { + font-weight: bold; + font-size: 14px; +} \ No newline at end of file diff --git a/src/main/resources/public/types.ts b/src/main/resources/public/types.ts new file mode 100644 index 00000000..b5d82623 --- /dev/null +++ b/src/main/resources/public/types.ts @@ -0,0 +1,44 @@ +/** Define the structure for a single node in the D3 graph. */ +export interface Node { + id: string; + label: string; + group: 'instance' | 'state' | 'service'; + smId: string | null; + isActive?: boolean; + isTerminal?: boolean; + isInitial?: boolean; + context?: Record; + x?: number; + y?: number; + vx?: number; + vy?: number; +} + +/** + * Define a link between two nodes. + * The source and target can be string IDs + * or full Node objects (after D3 processes them). + */ +export interface Link { + source: string | Node; + target: string | Node; + type: 'contains' | 'transition' | 'invokes' | 'nested' | 'event-link'; + event?: string; +} + +/** Represent the entire graph data structure for a full state update. */ +export interface GraphData { + nodes: Node[]; + links: Link[]; +} + +export interface Hull { + id: string; + nodes: Node[]; + hullType: "individual" | "combined"; +} + +/** Defines all possible message types that can be received over the WebSocket. */ +export type WebSocketMessage = + | { type: 'statusUpdate'; payload: GraphData } + | { type: 'invocation'; payload: { sourceId: string, targetId: string } } \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..e0924d27 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "node", + "outDir": "./src/main/resources/public/dist", + "rootDir": "./src/main/resources/public", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "sourceMap": true, + "allowImportingTsExtensions": true + }, + "include": ["src/main/resources/public/**/*.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 00000000..62a11ca2 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + root: './src/main/resources/public', + build: { + outDir: './dist', + emptyOutDir: true + } +})