Tell an admin who is connected before they restart the server

Nothing could answer it. 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. 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. Fair input to an average session length,
useless as a list of who is here.

ActiveUserTracker records a sighting from the authentication filter, the one
place every authenticated request passes. In memory rather than a column: 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 but the correct answer. Per instance therefore,
which is what this is deployed as; behind replicas each would report its own
callers, and the fix then is to ask each of them.

GET /stats/activity returns that alongside the runs a restart would interrupt -
RUNNING, SUSPENDED and WAITING, every state that is neither initial nor final.
WAITING belongs there precisely because it looks idle from the outside: someone
is mid-task on the other side of it, and it is the most expensive thing to lose.
The two are reported separately because they fail differently - an active user
loses an unsaved edit and comes back, an in-flight run is gone.

POST /auth/heartbeat exists only to give the editor something to call: it
answers nothing, the filter having already recorded the sighting. Without it
anyone reading a flow or typing a prompt makes no request for minutes and would
be invisible, which is the person a restart interrupts worst.

Logout was excluded from the authentication filter, so its @AuthenticationPrincipal
was always null and recordLogout never ran - sessions only ever closed on the
same user's next login. Removed from the exclusion list: SecurityConfig still
permits it, so it keeps working with a bad or missing token, and it now both
records the logout and drops the person from the presence map immediately
rather than letting them age out.

Window is five minutes, PRESENCE_WINDOW_SECONDS, wide enough to survive a
couple of missed heartbeats.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-22 10:11:23 +02:00
parent 085ab55413
commit 5f0f98c345
11 changed files with 627 additions and 1 deletions

View File

@ -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.
*
* <p>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<String> 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);
}

View File

@ -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<Void> 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) {

View File

@ -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<SystemUsageStatsView> 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<ServerActivityView> 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<List<String>> getAllUserStats(@AuthenticationPrincipal LoginEntity userDetails) {

View File

@ -0,0 +1,106 @@
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - 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.
*
* <p>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".
*
* <p>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.
*
* <p>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<String, Instant> 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.
*
* <p>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<Presence> 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) {
}
}

View File

@ -0,0 +1,128 @@
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - 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.
*
* <p>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<ActiveUserTracker.Presence> present = activeUserTracker.active();
List<ActiveUserView> activeUsers = present.isEmpty() ? List.of() : describe(present);
List<InFlightExecutionView> 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.
*
* <p>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<ActiveUserView> describe(List<ActiveUserTracker.Presence> present) {
Map<String, LoginEntity> 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.
*
* <p>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<InFlightExecutionView> 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());
}
}

View File

@ -0,0 +1,25 @@
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - 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) {
}

View File

@ -0,0 +1,25 @@
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - 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.
*
* <p>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) {
}

View File

@ -0,0 +1,27 @@
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - 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.
*
* <p>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<ActiveUserView> activeUsers,
int inFlightExecutionCount,
List<InFlightExecutionView> inFlightExecutions) {
}

View File

@ -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}

View File

@ -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<LoginEntity> 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<ServerActivityView> 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

View File

@ -0,0 +1,134 @@
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - 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<ActiveUserTracker.Presence> 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());
}
}