diff --git a/src/main/java/it/cnr/isti/workflow/manager/auth/config/JwtAuthenticationFilter.java b/src/main/java/it/cnr/isti/workflow/manager/auth/config/JwtAuthenticationFilter.java
index ef3a62f..a1fb47f 100644
--- a/src/main/java/it/cnr/isti/workflow/manager/auth/config/JwtAuthenticationFilter.java
+++ b/src/main/java/it/cnr/isti/workflow/manager/auth/config/JwtAuthenticationFilter.java
@@ -19,6 +19,7 @@ import org.springframework.web.filter.OncePerRequestFilter;
import it.cnr.isti.workflow.manager.auth.repo.AuthRepository;
import it.cnr.isti.workflow.manager.auth.repo.LoginEntity;
+import it.cnr.isti.workflow.manager.presence.ActiveUserTracker;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
@@ -36,13 +37,23 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private AuthRepository authRepository;
+ @Autowired
+ private ActiveUserTracker activeUserTracker;
+
@Value("${app.auth.cookie.name:auth_token}")
private String authCookieName;
+ /**
+ * Paths that do not need - or cannot have - an authenticated principal.
+ *
+ *
Logout is deliberately not among them, though it used to be: excluded, the filter never
+ * ran and {@code @AuthenticationPrincipal} was always null there, so logging out recorded
+ * nothing. It is reachable with a bad or missing token either way - the filter only declines to
+ * set a principal, it never rejects - and the endpoint clears the cookie regardless.
+ */
private static final List EXCLUDED_PATHS = List.of(
"/auth/login",
"/auth/register",
- "/auth/logout",
"/auth/change-password",
"/health",
"/blocks/types",
@@ -76,6 +87,10 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
+ // The one place every authenticated request passes, and the only way to
+ // know who is out there: the token is stateless and nothing streams to the
+ // browser, so there is no session or connection to count instead.
+ activeUserTracker.seen(username);
} else {
logger.warn("JWT validation failed for user '{}' on {} {}", username, request.getMethod(), path);
}
diff --git a/src/main/java/it/cnr/isti/workflow/manager/controllers/AuthController.java b/src/main/java/it/cnr/isti/workflow/manager/controllers/AuthController.java
index 5b4ff01..c992ec3 100644
--- a/src/main/java/it/cnr/isti/workflow/manager/controllers/AuthController.java
+++ b/src/main/java/it/cnr/isti/workflow/manager/controllers/AuthController.java
@@ -43,6 +43,7 @@ import java.util.LinkedHashMap;
import java.util.Map;
import it.cnr.isti.workflow.manager.auth.repo.LoginEntity;
+import it.cnr.isti.workflow.manager.presence.ActiveUserTracker;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@@ -58,6 +59,9 @@ public class AuthController {
@Autowired
private AuthService authService;
+ @Autowired
+ private ActiveUserTracker activeUserTracker;
+
@Autowired
private TurnstileService turnstileService;
@@ -140,11 +144,27 @@ public class AuthController {
public ResponseEntity> logout(@AuthenticationPrincipal LoginEntity userDetails, HttpServletResponse servletResponse) {
if (userDetails != null) {
authService.recordLogout(userDetails.getUsername());
+ // Known to be gone rather than inferred to be, so drop them now instead of letting the
+ // sighting age out of the window.
+ activeUserTracker.forget(userDetails.getUsername());
}
clearAuthCookie(servletResponse);
return ResponseEntity.ok().build();
}
+ @PostMapping("/heartbeat")
+ @Operation(summary = "Heartbeat",
+ description = "Marks the caller as still present. Answers nothing: the point is the call itself.")
+ public ResponseEntity heartbeat(@AuthenticationPrincipal LoginEntity userDetails) {
+ // The authentication filter has already recorded the sighting - every authenticated request
+ // is one. This exists because the editor makes no other call while someone is simply
+ // reading or typing, and those are the people a restart interrupts.
+ if (userDetails == null) {
+ return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
+ }
+ return ResponseEntity.noContent().build();
+ }
+
@GetMapping("/me")
@Operation(summary = "Current user", description = "Returns the currently authenticated user profile for the active cookie-backed session.")
public ResponseEntity> me(@AuthenticationPrincipal LoginEntity userDetails) {
diff --git a/src/main/java/it/cnr/isti/workflow/manager/controllers/StatsController.java b/src/main/java/it/cnr/isti/workflow/manager/controllers/StatsController.java
index 51917c6..151bcf5 100644
--- a/src/main/java/it/cnr/isti/workflow/manager/controllers/StatsController.java
+++ b/src/main/java/it/cnr/isti/workflow/manager/controllers/StatsController.java
@@ -22,6 +22,8 @@ import it.cnr.isti.workflow.manager.auth.model.UserRole;
import it.cnr.isti.workflow.manager.auth.repo.LoginEntity;
import it.cnr.isti.workflow.manager.stats.UserStatsNotFoundException;
import it.cnr.isti.workflow.manager.stats.UserStatsService;
+import it.cnr.isti.workflow.manager.stats.ServerActivityService;
+import it.cnr.isti.workflow.manager.stats.model.ServerActivityView;
import it.cnr.isti.workflow.manager.stats.model.SystemUsageStatsView;
import it.cnr.isti.workflow.manager.stats.model.UserUsageStatsView;
@@ -33,6 +35,9 @@ public class StatsController {
@Autowired
private UserStatsService userStatsService;
+ @Autowired
+ private ServerActivityService serverActivityService;
+
@GetMapping()
@Operation(summary = "Get system usage stats", description = "Returns aggregated usage statistics for the whole system. Accessible only to admins.")
public ResponseEntity getSystemStats(@AuthenticationPrincipal LoginEntity userDetails) {
@@ -46,6 +51,20 @@ public class StatsController {
return ResponseEntity.ok(userStatsService.getSystemStats());
}
+ @GetMapping("/activity")
+ @Operation(summary = "Get current server activity",
+ description = "Who is using the server right now and which runs are in flight, for deciding whether a"
+ + " restart is safe. Accessible only to admins.")
+ public ResponseEntity getActivity(@AuthenticationPrincipal LoginEntity userDetails) {
+ if (userDetails == null) {
+ return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
+ }
+ if (!isAdmin(userDetails)) {
+ return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
+ }
+ return ResponseEntity.ok(serverActivityService.getActivity());
+ }
+
@GetMapping("/users")
@Operation(summary = "Get all user usage stats", description = "Returns aggregated usage statistics for all users. Accessible only to admins.")
public ResponseEntity> getAllUserStats(@AuthenticationPrincipal LoginEntity userDetails) {
diff --git a/src/main/java/it/cnr/isti/workflow/manager/presence/ActiveUserTracker.java b/src/main/java/it/cnr/isti/workflow/manager/presence/ActiveUserTracker.java
new file mode 100644
index 0000000..dac813c
--- /dev/null
+++ b/src/main/java/it/cnr/isti/workflow/manager/presence/ActiveUserTracker.java
@@ -0,0 +1,106 @@
+// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - ISTI-CNR
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
+
+package it.cnr.isti.workflow.manager.presence;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+/**
+ * Who has been talking to this server lately.
+ *
+ * Exists because nothing else can answer the question. Authentication is a stateless JWT, so
+ * there is no session table to count; nothing streams to the browser, so there is no open
+ * connection to count either. {@code LoginEntity.currentSessionStartedAt} looks like the answer and
+ * is not: it only clears on an explicit logout, so anyone who closed a tab stays "in session"
+ * indefinitely. It is a fair input to an average session length and a bad one for "who is here".
+ *
+ *
In memory rather than a column, for two reasons that point the same way. A write per request
+ * would put the busiest path in the application on the database to record something nobody reads
+ * between restarts; and after a restart "nobody is connected" is not lost state, it is the correct
+ * answer - which is the whole situation this was built for.
+ *
+ *
Per instance, therefore. One process is what this is deployed as; behind more than one
+ * replica each would report only its own callers, and the honest fix then is to ask each of them,
+ * not to pretend this map is shared.
+ */
+@Component
+public class ActiveUserTracker {
+
+ private final Map lastSeen = new ConcurrentHashMap<>();
+ private final Duration window;
+ private final Clock clock;
+
+ /**
+ * Annotated because the test constructor below makes two of them, and Spring will not pick one
+ * on its own.
+ */
+ @Autowired
+ public ActiveUserTracker(@Value("${app.presence.window-seconds:300}") long windowSeconds) {
+ this(Duration.ofSeconds(windowSeconds), Clock.systemUTC());
+ }
+
+ /** For tests, which need to move time rather than wait for it. */
+ ActiveUserTracker(Duration window, Clock clock) {
+ this.window = window;
+ this.clock = clock;
+ }
+
+ /**
+ * Records a sighting. Called from the authentication filter, so it is on every authenticated
+ * request: a map write and nothing else, because anything heavier here is paid by every call
+ * the application serves.
+ */
+ public void seen(String username) {
+ if (username == null || username.isBlank()) {
+ return;
+ }
+ lastSeen.put(username, clock.instant());
+ }
+
+ /**
+ * Drops someone immediately rather than letting them age out, for the one case where we know
+ * they are gone instead of inferring it: they logged out.
+ */
+ public void forget(String username) {
+ if (username != null) {
+ lastSeen.remove(username);
+ }
+ }
+
+ /**
+ * Everyone seen within the window, most recent first.
+ *
+ * Prunes as it reads. The map is bounded by the number of accounts that ever call in, which
+ * is small and does not grow on its own, so there is nothing here worth a scheduled sweep.
+ */
+ public List active() {
+ Instant now = clock.instant();
+ Instant oldest = now.minus(window);
+ lastSeen.entrySet().removeIf(entry -> entry.getValue().isBefore(oldest));
+ return lastSeen.entrySet().stream()
+ .map(entry -> new Presence(entry.getKey(), entry.getValue(),
+ Math.max(0, Duration.between(entry.getValue(), now).getSeconds())))
+ .sorted(Comparator.comparing(Presence::lastSeenAt).reversed())
+ .toList();
+ }
+
+ /** How far back {@link #active()} looks, so a caller can say what the number means. */
+ public Duration window() {
+ return window;
+ }
+
+ /** One person and when they were last heard from. */
+ public record Presence(String username, Instant lastSeenAt, long secondsSinceLastSeen) {
+ }
+}
diff --git a/src/main/java/it/cnr/isti/workflow/manager/stats/ServerActivityService.java b/src/main/java/it/cnr/isti/workflow/manager/stats/ServerActivityService.java
new file mode 100644
index 0000000..73a9951
--- /dev/null
+++ b/src/main/java/it/cnr/isti/workflow/manager/stats/ServerActivityService.java
@@ -0,0 +1,128 @@
+// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - ISTI-CNR
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
+
+package it.cnr.isti.workflow.manager.stats;
+
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import org.springframework.stereotype.Service;
+
+import it.cnr.isti.workflow.manager.auth.repo.AuthRepository;
+import it.cnr.isti.workflow.manager.auth.repo.LoginEntity;
+import it.cnr.isti.workflow.manager.executions.ExecutionKind;
+import it.cnr.isti.workflow.manager.executions.ExecutionStatus;
+import it.cnr.isti.workflow.manager.executions.persistence.ExecutionSnapshot;
+import it.cnr.isti.workflow.manager.executions.repo.ExecutionEntity;
+import it.cnr.isti.workflow.manager.executions.repo.ExecutionRepository;
+import it.cnr.isti.workflow.manager.presence.ActiveUserTracker;
+import it.cnr.isti.workflow.manager.stats.model.ActiveUserView;
+import it.cnr.isti.workflow.manager.stats.model.InFlightExecutionView;
+import it.cnr.isti.workflow.manager.stats.model.ServerActivityView;
+
+/**
+ * Answers "is anyone using this right now", for deciding whether to restart the server.
+ *
+ * Separate from {@link UserStatsService}, which reports history: how many flows an account has
+ * created, how long its sessions run. Nothing here is cumulative and none of it survives a restart,
+ * which is a different enough shape to keep apart.
+ */
+@Service
+public class ServerActivityService {
+
+ private final ActiveUserTracker activeUserTracker;
+ private final AuthRepository authRepository;
+ private final ExecutionRepository executionRepository;
+
+ public ServerActivityService(ActiveUserTracker activeUserTracker, AuthRepository authRepository,
+ ExecutionRepository executionRepository) {
+ this.activeUserTracker = activeUserTracker;
+ this.authRepository = authRepository;
+ this.executionRepository = executionRepository;
+ }
+
+ public ServerActivityView getActivity() {
+ List present = activeUserTracker.active();
+ List activeUsers = present.isEmpty() ? List.of() : describe(present);
+ List inFlight = inFlightExecutions();
+
+ return new ServerActivityView(
+ LocalDateTime.now(),
+ activeUserTracker.window().getSeconds(),
+ activeUsers.size(),
+ activeUsers,
+ inFlight.size(),
+ inFlight);
+ }
+
+ /**
+ * Puts a name and a role next to each sighting. One query for the handful of people present,
+ * not one per person.
+ *
+ * An account deleted while its owner was still calling in survives here as a bare username
+ * rather than disappearing: the point of the list is who is out there, and dropping a row
+ * because a join missed would quietly under-report exactly when something odd is going on.
+ */
+ private List describe(List present) {
+ Map accounts = authRepository
+ .findAllById(present.stream().map(ActiveUserTracker.Presence::username).toList())
+ .stream()
+ .collect(Collectors.toMap(LoginEntity::getUsername, Function.identity(), (first, second) -> first));
+
+ return present.stream()
+ .map(presence -> {
+ LoginEntity account = accounts.get(presence.username());
+ return new ActiveUserView(
+ presence.username(),
+ account == null ? null : account.getEmail(),
+ account == null ? null : account.effectiveRole().name(),
+ toLocalDateTime(presence.lastSeenAt()),
+ presence.secondsSinceLastSeen(),
+ account == null ? null : account.getCurrentSessionStartedAt());
+ })
+ .toList();
+ }
+
+ /**
+ * Started and not finished: RUNNING, SUSPENDED and WAITING, which is every state that is
+ * neither an initial one nor a final one.
+ *
+ * Filtered in memory because the status lives inside the serialised snapshot rather than in
+ * a column - the same reason {@code UserStatsService.getSystemStats()} already loads them all.
+ * Top-level runs only: a subflow's interruption is its parent's, and listing both would report
+ * one loss twice.
+ */
+ private List inFlightExecutions() {
+ return executionRepository.findByExecutionKind(ExecutionKind.TOP_LEVEL).stream()
+ .filter(execution -> isInFlight(statusOf(execution)))
+ .map(execution -> new InFlightExecutionView(
+ execution.getId(),
+ execution.getName(),
+ execution.getOwner(),
+ statusOf(execution).name(),
+ execution.getCreationTime(),
+ execution.getLastUpdateTime()))
+ .sorted(Comparator.comparingLong(InFlightExecutionView::lastUpdateTime).reversed())
+ .toList();
+ }
+
+ private static boolean isInFlight(ExecutionStatus status) {
+ return status != null && !status.isInitState() && !status.isFinalState();
+ }
+
+ private static ExecutionStatus statusOf(ExecutionEntity execution) {
+ ExecutionSnapshot snapshot = execution.getSnapshot();
+ return snapshot == null ? null : snapshot.getStatus();
+ }
+
+ private static LocalDateTime toLocalDateTime(Instant instant) {
+ return instant == null ? null : LocalDateTime.ofInstant(instant, ZoneId.systemDefault());
+ }
+}
diff --git a/src/main/java/it/cnr/isti/workflow/manager/stats/model/ActiveUserView.java b/src/main/java/it/cnr/isti/workflow/manager/stats/model/ActiveUserView.java
new file mode 100644
index 0000000..1d12534
--- /dev/null
+++ b/src/main/java/it/cnr/isti/workflow/manager/stats/model/ActiveUserView.java
@@ -0,0 +1,25 @@
+// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - ISTI-CNR
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
+
+package it.cnr.isti.workflow.manager.stats.model;
+
+import java.time.LocalDateTime;
+
+/**
+ * One person currently using the server.
+ *
+ * @param secondsSinceLastSeen how stale the sighting is, alongside the timestamp rather than
+ * instead of it: the reader is deciding whether to restart now, and "42 seconds ago" is the
+ * form that answers that without arithmetic.
+ * @param sessionStartedAt when they last logged in, null if the account has no open session
+ * recorded - it says how long they have been working, not whether they are here.
+ */
+public record ActiveUserView(
+ String username,
+ String email,
+ String role,
+ LocalDateTime lastSeenAt,
+ long secondsSinceLastSeen,
+ LocalDateTime sessionStartedAt) {
+}
diff --git a/src/main/java/it/cnr/isti/workflow/manager/stats/model/InFlightExecutionView.java b/src/main/java/it/cnr/isti/workflow/manager/stats/model/InFlightExecutionView.java
new file mode 100644
index 0000000..3470850
--- /dev/null
+++ b/src/main/java/it/cnr/isti/workflow/manager/stats/model/InFlightExecutionView.java
@@ -0,0 +1,25 @@
+// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - ISTI-CNR
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
+
+package it.cnr.isti.workflow.manager.stats.model;
+
+/**
+ * A run a restart would interrupt: started and not finished.
+ *
+ * Includes the ones parked on a human step, not only the ones burning CPU. A flow waiting for a
+ * person to answer is the most expensive thing to lose - someone is mid-task on the other side of
+ * it - and it is exactly the state that looks idle from the outside.
+ *
+ * @param status the execution's own status name, so WAITING and RUNNING stay distinguishable in the
+ * panel rather than being flattened into one "busy" count.
+ * @param lastUpdateTime epoch millis, matching what the execution API already publishes.
+ */
+public record InFlightExecutionView(
+ String executionId,
+ String name,
+ String owner,
+ String status,
+ long creationTime,
+ long lastUpdateTime) {
+}
diff --git a/src/main/java/it/cnr/isti/workflow/manager/stats/model/ServerActivityView.java b/src/main/java/it/cnr/isti/workflow/manager/stats/model/ServerActivityView.java
new file mode 100644
index 0000000..39d3124
--- /dev/null
+++ b/src/main/java/it/cnr/isti/workflow/manager/stats/model/ServerActivityView.java
@@ -0,0 +1,27 @@
+// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - ISTI-CNR
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
+
+package it.cnr.isti.workflow.manager.stats.model;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * What is going on right now, for the one question this is built to answer: is it safe to restart.
+ *
+ * Two lists rather than one number, because they fail differently. An active user loses an
+ * unsaved edit and comes back; an in-flight run is gone. Both are needed to make the call, and
+ * neither substitutes for the other.
+ *
+ * @param activeWindowSeconds how far back {@code activeUsers} looks. Published rather than assumed:
+ * "3 users" means nothing without it, and the value is configurable.
+ */
+public record ServerActivityView(
+ LocalDateTime generatedAt,
+ long activeWindowSeconds,
+ int activeUserCount,
+ List activeUsers,
+ int inFlightExecutionCount,
+ List inFlightExecutions) {
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index f5808ec..a74e9cd 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -43,6 +43,10 @@ app.security.default-key=dev-local-only-change-me-dev-local-only-change-me
app.security.require-explicit-key=${WFEDITOR_REQUIRE_EXPLICIT_SECRET:false}
app.ollama.internal.key=${OLLAMA_INTERNAL_KEY:ollama}
app.ollama.internal.url=${OLLAMA_INTERNAL_URL:http://localhost:11434/api}
+# How far back the admin activity panel looks when deciding who counts as present. Wide enough to
+# survive a missed heartbeat or two - the editor sends one a minute - without holding on to someone
+# who has actually gone.
+app.presence.window-seconds=${PRESENCE_WINDOW_SECONDS:300}
app.mcp.bridge.url=${MCP_BRIDGE_URL:http://localhost:8000}
app.mcp.bridge.open-timeout-seconds=${MCP_BRIDGE_OPEN_TIMEOUT_SECONDS:90}
app.mcp.bridge.query-timeout-seconds=${MCP_BRIDGE_QUERY_TIMEOUT_SECONDS:600}
diff --git a/src/test/java/it/cnr/isti/workflow/manager/controllers/StatsControllerTest.java b/src/test/java/it/cnr/isti/workflow/manager/controllers/StatsControllerTest.java
index 9b2d00c..0d946e5 100644
--- a/src/test/java/it/cnr/isti/workflow/manager/controllers/StatsControllerTest.java
+++ b/src/test/java/it/cnr/isti/workflow/manager/controllers/StatsControllerTest.java
@@ -7,6 +7,8 @@ package it.cnr.isti.workflow.manager.controllers;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.LocalDateTime;
import java.util.List;
@@ -32,6 +34,9 @@ import it.cnr.isti.workflow.manager.executions.repo.ExecutionRepository;
import it.cnr.isti.workflow.manager.flows.model.FlowData;
import it.cnr.isti.workflow.manager.flows.repo.FlowEntity;
import it.cnr.isti.workflow.manager.flows.repo.FlowRepository;
+import it.cnr.isti.workflow.manager.presence.ActiveUserTracker;
+import it.cnr.isti.workflow.manager.stats.model.InFlightExecutionView;
+import it.cnr.isti.workflow.manager.stats.model.ServerActivityView;
import it.cnr.isti.workflow.manager.stats.model.SystemUsageStatsView;
import it.cnr.isti.workflow.manager.stats.model.UserUsageStatsView;
@@ -52,6 +57,9 @@ public class StatsControllerTest {
@Autowired
private ExecutionRepository executionRepository;
+ @Autowired
+ private ActiveUserTracker activeUserTracker;
+
private List preservedUsers = List.of();
/**
@@ -75,6 +83,121 @@ public class StatsControllerTest {
executionRepository.deleteAll();
flowRepository.deleteAll();
authRepository.deleteAll();
+ clearPresence();
+ }
+
+ /**
+ * The tracker is a singleton shared with the rest of the suite, and every authenticated request
+ * any other test makes lands in it. Cleared through its own API rather than through a test-only
+ * hook on the production class.
+ */
+ private void clearPresence() {
+ activeUserTracker.active()
+ .forEach(presence -> activeUserTracker.forget(presence.username()));
+ }
+
+ private static LoginEntity admin(String username) {
+ return new LoginEntity(username, "ignored", username + "@example.com", UserRole.ADMIN);
+ }
+
+ private ExecutionEntity execution(String id, String owner, ExecutionStatus status, long lastUpdateTime) {
+ return ExecutionEntity.builder()
+ .id(id)
+ .name("Run " + id)
+ .owner(owner)
+ .creationTime(100L)
+ .lastUpdateTime(lastUpdateTime)
+ .flow(FlowData.builder().build())
+ .snapshot(ExecutionSnapshot.builder()
+ .status(status)
+ .providedAuthorizations(Map.of())
+ .build())
+ .build();
+ }
+
+ @Test
+ public void nonAdminCannotReadServerActivity() {
+ ResponseEntity response = statsController.getActivity(
+ new LoginEntity("plainuser", "ignored", "plain@example.com", UserRole.USER));
+
+ assertEquals(403, response.getStatusCode().value());
+ }
+
+ @Test
+ public void anonymousCannotReadServerActivity() {
+ assertEquals(401, statsController.getActivity(null).getStatusCode().value());
+ }
+
+ @Test
+ public void activitySaysWhoIsPresentAndHowStaleTheSightingIs() {
+ authRepository.save(new LoginEntity("presentuser", "hashed", "present@example.com", UserRole.USER));
+ authRepository.save(admin("activityadmin"));
+ activeUserTracker.seen("presentuser");
+
+ ServerActivityView activity = statsController.getActivity(admin("activityadmin")).getBody();
+
+ assertNotNull(activity);
+ assertEquals(1, activity.activeUserCount());
+ assertEquals("presentuser", activity.activeUsers().get(0).username());
+ assertEquals("present@example.com", activity.activeUsers().get(0).email());
+ assertEquals("USER", activity.activeUsers().get(0).role());
+ assertNotNull(activity.activeUsers().get(0).lastSeenAt());
+ // Published alongside the count, because "1 user" is meaningless without knowing since when.
+ assertEquals(300L, activity.activeWindowSeconds());
+ }
+
+ @Test
+ public void someoneWhoseAccountIsGoneIsStillReportedAsPresent() {
+ // Under-reporting here would be worst exactly when something odd is happening - an account
+ // deleted while its owner is still calling in is something an admin wants to see, not a row
+ // silently dropped because the join missed.
+ authRepository.save(admin("activityadmin2"));
+ activeUserTracker.seen("deleted-account");
+
+ ServerActivityView activity = statsController.getActivity(admin("activityadmin2")).getBody();
+
+ assertNotNull(activity);
+ assertEquals(1, activity.activeUserCount());
+ assertEquals("deleted-account", activity.activeUsers().get(0).username());
+ assertNull(activity.activeUsers().get(0).role());
+ }
+
+ @Test
+ public void activityListsOnlyTheRunsARestartWouldInterrupt() {
+ authRepository.save(admin("activityadmin3"));
+ executionRepository.save(execution("run-running", "someone", ExecutionStatus.RUNNING, 500L));
+ executionRepository.save(execution("run-waiting", "someone", ExecutionStatus.WAITING, 700L));
+ executionRepository.save(execution("run-suspended", "someone", ExecutionStatus.SUSPENDED, 600L));
+ executionRepository.save(execution("run-created", "someone", ExecutionStatus.CREATED, 800L));
+ executionRepository.save(execution("run-ready", "someone", ExecutionStatus.READY, 800L));
+ executionRepository.save(execution("run-success", "someone", ExecutionStatus.SUCCESS, 900L));
+ executionRepository.save(execution("run-error", "someone", ExecutionStatus.ERROR, 900L));
+ executionRepository.save(execution("run-cancelled", "someone", ExecutionStatus.CANCELLED, 900L));
+
+ ServerActivityView activity = statsController.getActivity(admin("activityadmin3")).getBody();
+
+ assertNotNull(activity);
+ // Not started and already finished are both safe to restart through; everything between is
+ // not. WAITING belongs here precisely because it looks idle from the outside.
+ assertEquals(List.of("run-waiting", "run-suspended", "run-running"),
+ activity.inFlightExecutions().stream().map(InFlightExecutionView::executionId).toList());
+ assertEquals(3, activity.inFlightExecutionCount());
+ assertEquals("WAITING", activity.inFlightExecutions().get(0).status());
+ assertEquals("someone", activity.inFlightExecutions().get(0).owner());
+ }
+
+ @Test
+ public void anIdleServerReportsNobodyRatherThanFailing() {
+ authRepository.save(admin("activityadmin4"));
+
+ ServerActivityView activity = statsController.getActivity(admin("activityadmin4")).getBody();
+
+ assertNotNull(activity);
+ assertEquals(0, activity.activeUserCount());
+ assertTrue(activity.activeUsers().isEmpty());
+ assertEquals(0, activity.inFlightExecutionCount());
+ assertTrue(activity.inFlightExecutions().isEmpty());
+ assertNotNull(activity.generatedAt());
}
@Test
diff --git a/src/test/java/it/cnr/isti/workflow/manager/presence/ActiveUserTrackerTest.java b/src/test/java/it/cnr/isti/workflow/manager/presence/ActiveUserTrackerTest.java
new file mode 100644
index 0000000..f8404f1
--- /dev/null
+++ b/src/test/java/it/cnr/isti/workflow/manager/presence/ActiveUserTrackerTest.java
@@ -0,0 +1,134 @@
+// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - ISTI-CNR
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
+
+package it.cnr.isti.workflow.manager.presence;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+/** Who counts as present, and for how long. */
+class ActiveUserTrackerTest {
+
+ /** Moves on command, because every question here is about elapsed time. */
+ private static final class MovableClock extends Clock {
+ private Instant now = Instant.parse("2026-09-22T10:00:00Z");
+
+ void advance(Duration amount) {
+ now = now.plus(amount);
+ }
+
+ @Override
+ public Instant instant() {
+ return now;
+ }
+
+ @Override
+ public ZoneId getZone() {
+ return ZoneOffset.UTC;
+ }
+
+ @Override
+ public Clock withZone(ZoneId zone) {
+ return this;
+ }
+ }
+
+ private final MovableClock clock = new MovableClock();
+ private final ActiveUserTracker tracker = new ActiveUserTracker(Duration.ofMinutes(5), clock);
+
+ @Test
+ void someoneJustSeenIsPresent() {
+ tracker.seen("lucio");
+
+ List active = tracker.active();
+
+ assertEquals(1, active.size());
+ assertEquals("lucio", active.get(0).username());
+ assertEquals(0, active.get(0).secondsSinceLastSeen());
+ }
+
+ @Test
+ void someoneWhoStoppedCallingAgesOutOfTheWindow() {
+ tracker.seen("lucio");
+ clock.advance(Duration.ofMinutes(4));
+
+ assertEquals(1, tracker.active().size(), "still within the window");
+
+ clock.advance(Duration.ofMinutes(2));
+
+ assertTrue(tracker.active().isEmpty(), "past it, and no longer reported as here");
+ }
+
+ @Test
+ void aFurtherSightingKeepsSomeonePresent() {
+ // The heartbeat case: a tab left open sends one a minute, which has to be enough to stay in
+ // the list indefinitely without any other request being made.
+ tracker.seen("lucio");
+ for (int minute = 0; minute < 20; minute++) {
+ clock.advance(Duration.ofMinutes(1));
+ tracker.seen("lucio");
+ }
+
+ assertEquals(1, tracker.active().size());
+ assertEquals(0, tracker.active().get(0).secondsSinceLastSeen());
+ }
+
+ @Test
+ void reportsHowStaleEachSightingIs() {
+ tracker.seen("lucio");
+ clock.advance(Duration.ofSeconds(42));
+
+ assertEquals(42, tracker.active().get(0).secondsSinceLastSeen());
+ }
+
+ @Test
+ void loggingOutDropsSomeoneImmediatelyRatherThanLettingThemAgeOut() {
+ tracker.seen("lucio");
+ tracker.forget("lucio");
+
+ assertTrue(tracker.active().isEmpty());
+ }
+
+ @Test
+ void ordersTheMostRecentlySeenFirst() {
+ tracker.seen("first");
+ clock.advance(Duration.ofSeconds(30));
+ tracker.seen("second");
+ clock.advance(Duration.ofSeconds(30));
+ tracker.seen("third");
+
+ assertEquals(List.of("third", "second", "first"),
+ tracker.active().stream().map(ActiveUserTracker.Presence::username).toList());
+ }
+
+ @Test
+ void forgetsWhatHasAgedOutRatherThanKeepingItForever() {
+ // Read-time pruning is the only sweep there is, so it has to actually remove the entry and
+ // not merely filter it out of the answer.
+ tracker.seen("lucio");
+ clock.advance(Duration.ofHours(1));
+ tracker.active();
+ clock.advance(Duration.ofHours(-1));
+
+ assertTrue(tracker.active().isEmpty(), "the stale entry was dropped, not hidden");
+ }
+
+ @Test
+ void ignoresAnAbsentUsernameInsteadOfTrackingOne() {
+ tracker.seen(null);
+ tracker.seen(" ");
+ tracker.forget(null);
+
+ assertTrue(tracker.active().isEmpty());
+ }
+}