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()); + } +}