This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new 09b7b1f5c feat(studio): add per-user session details (#4231)
09b7b1f5c is described below
commit 09b7b1f5c46f510ceebdca93b2a16b24811fc6f0
Author: coder999o <[email protected]>
AuthorDate: Wed Sep 16 16:05:30 2026 +0800
feat(studio): add per-user session details (#4231)
---
.../apache/rocketmq/studio/auth/AuthService.java | 39 ++++
.../rocketmq/studio/auth/StudioUserController.java | 5 +
.../studio/auth/StudioUserSessionDetailVO.java | 37 ++++
.../AuthServiceSessionOverviewIntegrationTest.java | 64 ++++++
.../studio/auth/StudioUserControllerTest.java | 31 +++
web/src/api/studioUsers.test.ts | 26 +++
web/src/api/studioUsers.ts | 19 ++
web/src/pages/studio/UserManagement.tsx | 232 +++++++++++++++++++--
.../pages/studio/__tests__/UserManagement.test.tsx | 103 ++++++++-
9 files changed, 538 insertions(+), 18 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthService.java
b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthService.java
index dbc0b7dcc..a918ad129 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthService.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthService.java
@@ -274,6 +274,20 @@ public class AuthService {
.build();
}
+ public List<StudioUserSessionDetailVO> listActiveSessionsForUser(Long
userId) {
+ requireDatabaseBacked();
+ getUser(userId);
+ LocalDateTime current = now();
+ return sessionMapper.selectList(activeSessionQuery(current)
+ .select("id", "user_id", "last_seen_at", "expires_at",
"gmt_create")
+ .eq("user_id", userId)
+ .orderByDesc("last_seen_at")
+ .orderByAsc("id"))
+ .stream()
+ .map(session -> sessionDetail(session, current))
+ .toList();
+ }
+
public int revokeSessionsForUser(Long userId) {
requireDatabaseBacked();
getUser(userId);
@@ -503,6 +517,31 @@ public class AuthService {
.gt("expires_at", current);
}
+ private StudioUserSessionDetailVO sessionDetail(RmqStudioSession session,
LocalDateTime current) {
+ LocalDateTime expiresAt = session.getExpiresAt();
+ LocalDateTime lastSeenAt = session.getLastSeenAt();
+ return StudioUserSessionDetailVO.builder()
+ .id(session.getId())
+ .userId(session.getUserId())
+ .lastSeenAt(lastSeenAt)
+ .expiresAt(expiresAt)
+ .gmtCreate(session.getGmtCreate())
+ .remainingSeconds(nonNegativeSecondsBetween(current,
expiresAt))
+ .idleSeconds(lastSeenAt == null ? null :
nonNegativeSecondsBetween(lastSeenAt, current))
+ .expiringSoon(expiresAt != null
+ &&
!expiresAt.isAfter(current.plus(SESSION_EXPIRING_SOON_WINDOW)))
+ .stale(lastSeenAt != null
+ &&
lastSeenAt.isBefore(current.minus(STALE_SESSION_THRESHOLD)))
+ .build();
+ }
+
+ private long nonNegativeSecondsBetween(LocalDateTime start, LocalDateTime
end) {
+ if (start == null || end == null) {
+ return 0;
+ }
+ return Math.max(0, Duration.between(start, end).getSeconds());
+ }
+
/**
* Renders the bind-variable reference MyBatis-Plus resolves for a value
registered in the
* wrapper's {@code paramNameValuePairs}, so an aggregate expression can
be parameterized
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserController.java
b/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserController.java
index 7019e34de..406387a7e 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserController.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserController.java
@@ -44,6 +44,11 @@ public class StudioUserController {
return Result.ok(authService.getSessionOverview());
}
+ @GetMapping("/{userId}/sessions")
+ public Result<List<StudioUserSessionDetailVO>>
listActiveSessions(@PathVariable Long userId) {
+ return Result.ok(authService.listActiveSessionsForUser(userId));
+ }
+
@GetMapping
public Result<PageResult<StudioUserVO>> list(
@RequestParam(required = false) String search,
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserSessionDetailVO.java
b/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserSessionDetailVO.java
new file mode 100644
index 000000000..652407b34
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserSessionDetailVO.java
@@ -0,0 +1,37 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.auth;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+@Builder
+public class StudioUserSessionDetailVO {
+
+ private Long id;
+ private Long userId;
+ private LocalDateTime lastSeenAt;
+ private LocalDateTime expiresAt;
+ private LocalDateTime gmtCreate;
+ private long remainingSeconds;
+ private Long idleSeconds;
+ private boolean expiringSoon;
+ private boolean stale;
+}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceSessionOverviewIntegrationTest.java
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceSessionOverviewIntegrationTest.java
index 462f993bb..397062ea6 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceSessionOverviewIntegrationTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceSessionOverviewIntegrationTest.java
@@ -16,6 +16,7 @@
*/
package org.apache.rocketmq.studio.auth;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
import org.apache.rocketmq.studio.persistence.entity.RmqStudioSession;
import org.apache.rocketmq.studio.persistence.entity.RmqStudioUser;
import org.apache.rocketmq.studio.persistence.mapper.RmqStudioSessionMapper;
@@ -32,6 +33,7 @@ import java.util.HexFormat;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Runs the session overview aggregate against the real database, because the
bucket boundaries
@@ -92,6 +94,68 @@ class AuthServiceSessionOverviewIntegrationTest {
}
}
+ @Test
+ void
listActiveSessionsForUserReturnsOnlyThatUsersActiveSessionDetailsTest() {
+ LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
+ RmqStudioUser user = studioUser("session-detail-it-" +
System.nanoTime(), now);
+ RmqStudioUser otherUser = studioUser("other-session-detail-it-" +
System.nanoTime(), now);
+ userMapper.insert(user);
+ userMapper.insert(otherUser);
+ List<RmqStudioSession> inserted = new ArrayList<>();
+ try {
+ RmqStudioSession fresh = session(user.getId(), now.plusMinutes(30),
+ now.minusMinutes(1), null);
+ RmqStudioSession expiringAndStale = session(user.getId(),
now.plusMinutes(2),
+ now.minusMinutes(20), null);
+ inserted.add(fresh);
+ inserted.add(expiringAndStale);
+ inserted.add(session(user.getId(), now.plusMinutes(30),
now.minusMinutes(2), now));
+ inserted.add(session(user.getId(), now.minusMinutes(1),
now.minusMinutes(30), null));
+ inserted.add(session(otherUser.getId(), now.plusMinutes(30), now,
null));
+
+ List<StudioUserSessionDetailVO> details =
+ authService.listActiveSessionsForUser(user.getId());
+
+ assertThat(details)
+ .extracting(StudioUserSessionDetailVO::getId)
+ .containsExactly(fresh.getId(), expiringAndStale.getId());
+ assertThat(details)
+ .extracting(StudioUserSessionDetailVO::getUserId)
+ .containsOnly(user.getId());
+ assertThat(details.get(0).isExpiringSoon()).isFalse();
+ assertThat(details.get(0).isStale()).isFalse();
+
assertThat(details.get(0).getIdleSeconds()).isGreaterThanOrEqualTo(60L);
+ assertThat(details.get(0).getRemainingSeconds()).isGreaterThan(0L);
+ assertThat(details.get(1).isExpiringSoon()).isTrue();
+ assertThat(details.get(1).isStale()).isTrue();
+
assertThat(details.get(1).getIdleSeconds()).isGreaterThanOrEqualTo(20 * 60L);
+ assertThat(details.get(1).getRemainingSeconds()).isGreaterThan(0L);
+ } finally {
+ for (RmqStudioSession session : inserted) {
+ sessionMapper.deleteById(session.getId());
+ }
+ userMapper.deleteById(user.getId());
+ userMapper.deleteById(otherUser.getId());
+ }
+ }
+
+ @Test
+ void listActiveSessionsForUserFailsWhenTheUserDoesNotExistTest() {
+ assertThatThrownBy(() ->
authService.listActiveSessionsForUser(Long.MIN_VALUE))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("User not found");
+ }
+
+ private RmqStudioUser studioUser(String username, LocalDateTime now) {
+ RmqStudioUser user = new RmqStudioUser();
+ user.setUsername(username);
+ user.setPasswordHash("not-a-real-password-hash");
+ user.setAdmin(false);
+ user.setEnabled(true);
+ user.setPasswordChangedAt(now);
+ return user;
+ }
+
private RmqStudioSession session(Long userId, LocalDateTime expiresAt,
LocalDateTime lastSeenAt,
LocalDateTime revokedAt) {
RmqStudioSession session = new RmqStudioSession();
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/auth/StudioUserControllerTest.java
b/server/src/test/java/org/apache/rocketmq/studio/auth/StudioUserControllerTest.java
index 79a0642b5..c35cb9870 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/auth/StudioUserControllerTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/auth/StudioUserControllerTest.java
@@ -135,6 +135,37 @@ class StudioUserControllerTest extends
WebMvcAuthTestSupport {
verify(authService).getSessionOverview();
}
+ @Test
+ void listActiveSessionsReturnsSafeSessionDetails() throws Exception {
+ when(authService.listActiveSessionsForUser(7L))
+ .thenReturn(List.of(StudioUserSessionDetailVO.builder()
+ .id(19L)
+ .userId(7L)
+ .lastSeenAt(LocalDateTime.parse("2026-08-22T09:45:00"))
+ .expiresAt(LocalDateTime.parse("2026-08-22T09:50:00"))
+ .gmtCreate(LocalDateTime.parse("2026-08-22T09:15:00"))
+ .remainingSeconds(300)
+ .idleSeconds(60L)
+ .expiringSoon(true)
+ .stale(false)
+ .build()));
+
+ mockMvc.perform(get("/api/studio-users/7/sessions"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data[0].id").value(19))
+ .andExpect(jsonPath("$.data[0].userId").value(7))
+
.andExpect(jsonPath("$.data[0].lastSeenAt").value("2026-08-22T09:45:00"))
+
.andExpect(jsonPath("$.data[0].expiresAt").value("2026-08-22T09:50:00"))
+
.andExpect(jsonPath("$.data[0].gmtCreate").value("2026-08-22T09:15:00"))
+ .andExpect(jsonPath("$.data[0].remainingSeconds").value(300))
+ .andExpect(jsonPath("$.data[0].idleSeconds").value(60))
+ .andExpect(jsonPath("$.data[0].expiringSoon").value(true))
+ .andExpect(jsonPath("$.data[0].stale").value(false))
+ .andExpect(jsonPath("$.data[0].tokenHash").doesNotExist());
+
+ verify(authService).listActiveSessionsForUser(7L);
+ }
+
@Test
void revokeSessionsReturnsTheRevokedSessionCount() throws Exception {
when(authService.revokeSessionsForUser(7L)).thenReturn(3);
diff --git a/web/src/api/studioUsers.test.ts b/web/src/api/studioUsers.test.ts
index 4172d1585..191557c17 100644
--- a/web/src/api/studioUsers.test.ts
+++ b/web/src/api/studioUsers.test.ts
@@ -21,6 +21,7 @@ import client from './client';
import {
getStudioUserSessionOverview,
listAllStudioUsers as loadStudioUsersForExport,
+ listStudioUserSessions,
listStudioUsers,
revokeStudioUserSessions,
} from './studioUsers';
@@ -115,6 +116,31 @@ describe('studio users API', () => {
expect(mock.history.get[0].url).toBe('/studio-users/sessions/overview');
});
+ it('loads active Studio session details for a user', async () => {
+ mock.onGet('/studio-users/7/sessions').reply(200, {
+ code: 200,
+ data: [
+ {
+ id: 19,
+ userId: 7,
+ lastSeenAt: '2026-08-22T09:45:00',
+ expiresAt: '2026-08-22T09:50:00',
+ gmtCreate: '2026-08-22T09:15:00',
+ remainingSeconds: 300,
+ idleSeconds: 60,
+ expiringSoon: true,
+ stale: false,
+ },
+ ],
+ });
+
+ const sessions = await listStudioUserSessions(7);
+
+ expect(sessions).toHaveLength(1);
+ expect(sessions[0]).toMatchObject({ id: 19, userId: 7, expiringSoon: true
});
+ expect(mock.history.get[0].url).toBe('/studio-users/7/sessions');
+ });
+
it('revokes a users active Studio sessions', async () => {
mock.onPost('/studio-users/7/sessions/revoke').reply(200, {
code: 200,
diff --git a/web/src/api/studioUsers.ts b/web/src/api/studioUsers.ts
index f70a37b05..3e465fe59 100644
--- a/web/src/api/studioUsers.ts
+++ b/web/src/api/studioUsers.ts
@@ -62,6 +62,18 @@ export interface StudioUserSessionOverview {
staleSessionThresholdMinutes: number;
}
+export interface StudioUserSessionDetail {
+ id: number;
+ userId: number;
+ lastSeenAt?: string | null;
+ expiresAt?: string | null;
+ gmtCreate?: string | null;
+ remainingSeconds: number;
+ idleSeconds?: number | null;
+ expiringSoon: boolean;
+ stale: boolean;
+}
+
export async function listStudioUsers(query: StudioUserQuery = {}) {
const response = await client.get<{ data: StudioUserPage }>('/studio-users',
{
params: query,
@@ -76,6 +88,13 @@ export async function getStudioUserSessionOverview() {
return response.data.data;
}
+export async function listStudioUserSessions(userId: number) {
+ const response = await client.get<{ data: StudioUserSessionDetail[] }>(
+ `/studio-users/${userId}/sessions`,
+ );
+ return response.data.data;
+}
+
export const listAllStudioUsers = async (
query: StudioUserExportQuery = {},
): Promise<StudioUser[]> => {
diff --git a/web/src/pages/studio/UserManagement.tsx
b/web/src/pages/studio/UserManagement.tsx
index 633be58f2..bec63c911 100644
--- a/web/src/pages/studio/UserManagement.tsx
+++ b/web/src/pages/studio/UserManagement.tsx
@@ -18,6 +18,8 @@ import { useCallback, useEffect, useRef, useState } from
'react';
import {
Button,
Card,
+ Descriptions,
+ Drawer,
Flex,
Form,
Input,
@@ -32,7 +34,14 @@ import {
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
-import { DownloadSimple, Key, Plus, SignOut } from '@phosphor-icons/react';
+import {
+ ArrowClockwise,
+ DownloadSimple,
+ Key,
+ ListBullets,
+ Plus,
+ SignOut,
+} from '@phosphor-icons/react';
import { useNavigate } from 'react-router-dom';
import PageHeader from '../../components/PageHeader';
import InfoBanner from '../../components/InfoBanner';
@@ -41,15 +50,18 @@ import {
createStudioUser,
getStudioUserSessionOverview,
listAllStudioUsers as exportStudioUsers,
+ listStudioUserSessions,
listStudioUsers,
resetStudioUserPassword,
revokeStudioUserSessions,
setStudioUserEnabled,
type StudioUser,
+ type StudioUserSessionDetail,
type StudioUserSessionOverview,
} from '../../api/studioUsers';
import useAuthStore from '../../stores/authStore';
import { buildCsv, downloadCsv, type CsvColumn } from '../../utils/download';
+import { formatDelay } from '../../utils/format';
import { tableScrollX } from '../../utils/table';
interface CreateFormValues {
@@ -63,7 +75,8 @@ interface PasswordFormValues {
newPassword: string;
}
-const dateTime = (value?: string) => (value ? new Date(value).toLocaleString()
: '-');
+const dateTime = (value?: string | null) => (value ? new
Date(value).toLocaleString() : '-');
+const durationText = (value?: number | null) => (value == null ? '-' :
formatDelay(value));
const PAGE_SIZE_OPTIONS = [20, 50, 100];
type RoleFilter = 'admin' | 'reader';
@@ -87,6 +100,15 @@ const STUDIO_USER_EXPORT_COLUMNS: CsvColumn<StudioUser>[] =
[
{ header: 'Created At', value: (user) => dateTime(user.gmtCreate) },
{ header: 'Modified At', value: (user) => dateTime(user.gmtModified) },
];
+
+const sessionStatusTags = (session: StudioUserSessionDetail) => (
+ <Space size={4} wrap>
+ <Tag color="processing">活跃</Tag>
+ {session.expiringSoon && <Tag color="gold">即将过期</Tag>}
+ {session.stale && <Tag color="orange">长时间未活跃</Tag>}
+ </Space>
+);
+
const UserManagementPage = () => {
const navigate = useNavigate();
const admin = useAuthStore((state) => state.admin);
@@ -102,6 +124,9 @@ const UserManagementPage = () => {
const [statusFilter, setStatusFilter] = useState<StatusFilter>();
const [loading, setLoading] = useState(false);
const [sessionOverview, setSessionOverview] =
useState<StudioUserSessionOverview | null>(null);
+ const [sessionDrawerUser, setSessionDrawerUser] = useState<StudioUser |
null>(null);
+ const [sessionDetails, setSessionDetails] =
useState<StudioUserSessionDetail[]>([]);
+ const [sessionDetailsLoading, setSessionDetailsLoading] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [passwordTarget, setPasswordTarget] = useState<StudioUser |
null>(null);
const [userExporting, setUserExporting] = useState(false);
@@ -109,6 +134,7 @@ const UserManagementPage = () => {
const [createForm] = Form.useForm<CreateFormValues>();
const [passwordForm] = Form.useForm<PasswordFormValues>();
const requestSeqRef = useRef(0);
+ const sessionDetailsRequestSeqRef = useRef(0);
const mutatingUserIdsRef = useRef(new Set<number>());
useEffect(() => {
@@ -122,6 +148,8 @@ const UserManagementPage = () => {
setUsers([]);
setTotal(0);
setSessionOverview(null);
+ setSessionDrawerUser(null);
+ setSessionDetails([]);
return;
}
const requestId = ++requestSeqRef.current;
@@ -167,10 +195,46 @@ const UserManagementPage = () => {
useEffect(
() => () => {
requestSeqRef.current += 1;
+ sessionDetailsRequestSeqRef.current += 1;
},
[],
);
+ const loadSessionDetails = useCallback(async (record: StudioUser) => {
+ const requestId = ++sessionDetailsRequestSeqRef.current;
+ setSessionDetailsLoading(true);
+ try {
+ const details = await listStudioUserSessions(record.id);
+ if (requestId !== sessionDetailsRequestSeqRef.current) return;
+ setSessionDetails(details);
+ setSessionDrawerUser((current) =>
+ current?.id === record.id ? { ...current, activeSessionCount:
details.length } : current,
+ );
+ } catch {
+ if (requestId === sessionDetailsRequestSeqRef.current) {
+ setSessionDetails([]);
+ message.error('加载用户会话失败');
+ }
+ } finally {
+ if (requestId === sessionDetailsRequestSeqRef.current) {
+ setSessionDetailsLoading(false);
+ }
+ }
+ }, []);
+
+ const openSessionDrawer = (record: StudioUser) => {
+ setSessionDrawerUser(record);
+ setSessionDetails([]);
+ void loadSessionDetails(record);
+ };
+
+ const closeSessionDrawer = () => {
+ sessionDetailsRequestSeqRef.current += 1;
+ setSessionDrawerUser(null);
+ setSessionDetails([]);
+ setSessionDetailsLoading(false);
+ };
+
const createUser = async () => {
const values = await createForm.validateFields();
try {
@@ -254,6 +318,9 @@ const UserManagementPage = () => {
navigate('/login', { replace: true });
return;
}
+ if (sessionDrawerUser?.id === record.id) {
+ await loadSessionDetails({ ...record, activeSessionCount: 0 });
+ }
await loadUsers();
},
'注销用户会话失败',
@@ -282,30 +349,72 @@ const UserManagementPage = () => {
}
setUserExporting(false);
}, [admin, debouncedSearch, roleFilter, statusFilter]);
+ const sessionDetailColumns: ColumnsType<StudioUserSessionDetail> = [
+ { title: '会话 ID', dataIndex: 'id', width: 96 },
+ {
+ title: '状态',
+ key: 'status',
+ width: 172,
+ render: (_, record) => sessionStatusTags(record),
+ },
+ {
+ title: '最近活跃',
+ dataIndex: 'lastSeenAt',
+ width: 160,
+ ellipsis: true,
+ render: dateTime,
+ },
+ {
+ title: '已空闲',
+ dataIndex: 'idleSeconds',
+ width: 112,
+ render: durationText,
+ },
+ {
+ title: '过期时间',
+ dataIndex: 'expiresAt',
+ width: 160,
+ ellipsis: true,
+ render: dateTime,
+ },
+ {
+ title: '剩余有效期',
+ dataIndex: 'remainingSeconds',
+ width: 126,
+ render: durationText,
+ },
+ {
+ title: '创建时间',
+ dataIndex: 'gmtCreate',
+ width: 160,
+ ellipsis: true,
+ render: dateTime,
+ },
+ ];
// Declared widths total 1116px, which stays inside the usable content width
of a normal
// 1440px viewport (220px Sider plus page and Card padding), so the table
does not show a
// horizontal scrollbar by default. Columns whose text can be longer than
that truncate with
// the full value on hover instead of wrapping.
const columns: ColumnsType<StudioUser> = [
{ title: '用户名', dataIndex: 'username', width: 120, ellipsis: true },
- { title: '用户 ID', dataIndex: 'id', width: 88 },
+ { title: '用户 ID', dataIndex: 'id', width: 80 },
{
title: '权限',
dataIndex: 'admin',
- width: 92,
+ width: 88,
render: (value: boolean) => (value ? <Tag color="blue">管理员</Tag> :
<Tag>普通用户</Tag>),
},
{
title: '状态',
dataIndex: 'enabled',
- width: 92,
+ width: 88,
render: (value: boolean) =>
value ? <Tag color="green">已启用</Tag> : <Tag color="default">已禁用</Tag>,
},
{
title: '活跃会话',
dataIndex: 'activeSessionCount',
- width: 84,
+ width: 80,
render: (value?: number) => {
const count = value ?? 0;
return <Tag color={count > 0 ? 'processing' : 'default'}>{count}</Tag>;
@@ -314,33 +423,40 @@ const UserManagementPage = () => {
{
title: '最近活跃',
dataIndex: 'lastSessionSeenAt',
- width: 140,
+ width: 132,
ellipsis: true,
render: dateTime,
},
{
title: '最近过期',
dataIndex: 'nearestSessionExpiresAt',
- width: 140,
+ width: 132,
ellipsis: true,
render: dateTime,
},
{
title: '创建时间',
dataIndex: 'gmtCreate',
- width: 140,
+ width: 132,
ellipsis: true,
render: dateTime,
},
{
title: '操作',
key: 'actions',
- width: 220,
+ width: 264,
render: (_, record) => (
- <Space>
+ <Space size={4}>
<Button size="small" icon={<Key size={14} />} onClick={() =>
setPasswordTarget(record)}>
改密
</Button>
+ <Button
+ size="small"
+ icon={<ListBullets size={14} />}
+ onClick={() => openSessionDrawer(record)}
+ >
+ 会话
+ </Button>
<Popconfirm
title={`注销 ${record.username} 的活跃会话?`}
description="用户需要重新登录,账号状态不会改变。"
@@ -357,7 +473,7 @@ const UserManagementPage = () => {
disabled={(record.activeSessionCount ?? 0) === 0}
loading={mutatingUserIds.has(record.id)}
>
- 会话
+ 注销
</Button>
</Popconfirm>
<Switch
@@ -508,6 +624,98 @@ const UserManagementPage = () => {
</Card>
)}
+ <Drawer
+ title={sessionDrawerUser ? `${sessionDrawerUser.username} 的会话` :
'用户会话'}
+ width={1040}
+ open={sessionDrawerUser !== null}
+ onClose={closeSessionDrawer}
+ destroyOnHidden
+ extra={
+ sessionDrawerUser ? (
+ <Space>
+ <Button
+ icon={<ArrowClockwise size={16} />}
+ loading={sessionDetailsLoading}
+ onClick={() => void loadSessionDetails(sessionDrawerUser)}
+ >
+ 刷新
+ </Button>
+ <Popconfirm
+ title={`注销 ${sessionDrawerUser.username} 的活跃会话?`}
+ description="用户需要重新登录,账号状态不会改变。"
+ okText="注销"
+ cancelText="取消"
+ okButtonProps={{ danger: true }}
+ disabled={sessionDetails.length === 0}
+ onConfirm={() => void revokeSessions(sessionDrawerUser)}
+ >
+ <Button
+ danger
+ icon={<SignOut size={16} />}
+ disabled={sessionDetails.length === 0}
+ loading={mutatingUserIds.has(sessionDrawerUser.id)}
+ >
+ 注销全部
+ </Button>
+ </Popconfirm>
+ </Space>
+ ) : undefined
+ }
+ >
+ {sessionDrawerUser && (
+ <Space direction="vertical" size={16} style={{ width: '100%' }}>
+ <Descriptions
+ bordered
+ size="small"
+ column={2}
+ items={[
+ { key: 'userId', label: '用户 ID', children:
sessionDrawerUser.id },
+ {
+ key: 'role',
+ label: '权限',
+ children: sessionDrawerUser.admin ? '管理员' : '普通用户',
+ },
+ {
+ key: 'activeSessionCount',
+ label: '活跃会话',
+ children: sessionDetailsLoading ? '-' :
sessionDetails.length,
+ },
+ {
+ key: 'status',
+ label: '账号状态',
+ children: sessionDrawerUser.enabled ? '已启用' : '已禁用',
+ },
+ {
+ key: 'lastSessionSeenAt',
+ label: '最近活跃',
+ children: dateTime(sessionDrawerUser.lastSessionSeenAt),
+ },
+ {
+ key: 'nearestSessionExpiresAt',
+ label: '最近过期',
+ children:
dateTime(sessionDrawerUser.nearestSessionExpiresAt),
+ },
+ {
+ key: 'passwordChangedAt',
+ label: '密码修改时间',
+ children: dateTime(sessionDrawerUser.passwordChangedAt),
+ },
+ ]}
+ />
+ <Table
+ rowKey="id"
+ loading={sessionDetailsLoading}
+ columns={sessionDetailColumns}
+ dataSource={sessionDetails}
+ tableLayout="fixed"
+ pagination={false}
+ scroll={{ x: tableScrollX(sessionDetailColumns), y: 420 }}
+ locale={{ emptyText: '暂无活跃会话' }}
+ />
+ </Space>
+ )}
+ </Drawer>
+
<Modal
title="新建 Studio 用户"
open={createOpen}
diff --git a/web/src/pages/studio/__tests__/UserManagement.test.tsx
b/web/src/pages/studio/__tests__/UserManagement.test.tsx
index 29aba00b2..22a74a9d6 100644
--- a/web/src/pages/studio/__tests__/UserManagement.test.tsx
+++ b/web/src/pages/studio/__tests__/UserManagement.test.tsx
@@ -17,16 +17,18 @@
import { App } from 'antd';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
-import { act, fireEvent, render, screen, waitFor } from
'@testing-library/react';
+import { act, fireEvent, render, screen, waitFor, within } from
'@testing-library/react';
import userEvent, { type UserEvent } from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import {
getStudioUserSessionOverview,
listAllStudioUsers as downloadStudioUsers,
+ listStudioUserSessions,
listStudioUsers,
revokeStudioUserSessions,
setStudioUserEnabled,
type StudioUser,
+ type StudioUserSessionDetail,
} from '../../../api/studioUsers';
import { downloadCsv } from '../../../utils/download';
import UserManagementPage from '../UserManagement';
@@ -36,6 +38,7 @@ vi.mock('../../../api/studioUsers', () => ({
createStudioUser: vi.fn(),
getStudioUserSessionOverview: vi.fn(),
listAllStudioUsers: vi.fn(),
+ listStudioUserSessions: vi.fn(),
listStudioUsers: vi.fn(),
resetStudioUserPassword: vi.fn(),
revokeStudioUserSessions: vi.fn(),
@@ -75,6 +78,31 @@ const studioUserPage = {
size: 20,
};
+const sessionDetails: StudioUserSessionDetail[] = [
+ {
+ id: 19,
+ userId: 7,
+ lastSeenAt: '2026-08-22T09:45:00',
+ expiresAt: '2026-08-22T09:50:00',
+ gmtCreate: '2026-08-22T09:15:00',
+ remainingSeconds: 300,
+ idleSeconds: 60,
+ expiringSoon: true,
+ stale: false,
+ },
+ {
+ id: 20,
+ userId: 7,
+ lastSeenAt: '2026-08-22T09:20:00',
+ expiresAt: '2026-08-22T10:30:00',
+ gmtCreate: '2026-08-22T09:00:00',
+ remainingSeconds: 4200,
+ idleSeconds: 1500,
+ expiringSoon: false,
+ stale: true,
+ },
+];
+
const renderPage = () =>
render(
<MemoryRouter>
@@ -96,6 +124,11 @@ const applyAdminDisabledFilter = async (user: UserEvent,
keyword = 'ops') => {
await selectOption(user, '按权限筛选', '管理员');
await selectOption(user, '按状态筛选', '已禁用');
};
+const confirmRevokePopover = async (user: UserEvent) => {
+ await waitFor(() =>
expect(document.querySelector('.ant-popover')).toBeTruthy());
+ const popover = document.querySelector('.ant-popover') as HTMLElement;
+ await user.click(within(popover).getByRole('button', { name: /注\s*销/ }));
+};
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
@@ -125,6 +158,7 @@ describe('UserManagementPage', () => {
staleSessionThresholdMinutes: 15,
});
vi.mocked(downloadStudioUsers).mockResolvedValue(studioUserPage.items);
+ vi.mocked(listStudioUserSessions).mockResolvedValue(sessionDetails);
vi.mocked(revokeStudioUserSessions).mockResolvedValue({
userId: 7,
revokedSessionCount: 2,
@@ -220,7 +254,26 @@ describe('UserManagementPage', () => {
expect(downloadCsv).toHaveBeenCalledTimes(1);
});
- it('renders active session metadata and revokes sessions after
confirmation', async () => {
+ it('opens the active session detail drawer for a user', async () => {
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ renderPage();
+
+ await screen.findByText('operator');
+ await user.click(screen.getByRole('button', { name: '会话' }));
+
+ const drawer = await screen.findByRole('dialog', { name: 'operator 的会话' });
+ expect(listStudioUserSessions).toHaveBeenCalledWith(7);
+ expect(within(drawer).getAllByText('会话 ID').length).toBeGreaterThan(0);
+ expect(within(drawer).getByText('19')).toBeInTheDocument();
+ expect(within(drawer).getByText('20')).toBeInTheDocument();
+ expect(within(drawer).getByText('即将过期')).toBeInTheDocument();
+ expect(within(drawer).getByText('长时间未活跃')).toBeInTheDocument();
+ expect(within(drawer).getByText('5分钟')).toBeInTheDocument();
+ expect(within(drawer).getByText('1分钟')).toBeInTheDocument();
+ expect(within(drawer).queryByText(/token/i)).not.toBeInTheDocument();
+ });
+
+ it('revokes sessions after row confirmation', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderPage();
@@ -228,14 +281,52 @@ describe('UserManagementPage', () => {
expect(screen.getAllByText('2').length).toBeGreaterThan(0);
expect(screen.getByText(new
Date('2026-08-22T09:30:00').toLocaleString())).toBeInTheDocument();
- await user.click(screen.getByRole('button', { name: '会话' }));
+ await user.click(screen.getByRole('button', { name: '注销' }));
await screen.findByText('注销 operator 的活跃会话?');
- await user.click(screen.getByRole('button', { name: /注\s*销/ }));
+ await confirmRevokePopover(user);
await waitFor(() =>
expect(revokeStudioUserSessions).toHaveBeenCalledWith(7));
expect(listStudioUsers).toHaveBeenCalledTimes(2);
});
+ it('revokes sessions from the detail drawer and refreshes the detail list',
async () => {
+ vi.mocked(listStudioUserSessions)
+ .mockResolvedValueOnce(sessionDetails)
+ .mockResolvedValueOnce([]);
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ renderPage();
+
+ await screen.findByText('operator');
+ await user.click(screen.getByRole('button', { name: '会话' }));
+ const drawer = await screen.findByRole('dialog', { name: 'operator 的会话' });
+ await user.click(within(drawer).getByRole('button', { name: '注销全部' }));
+ await screen.findByText('注销 operator 的活跃会话?');
+ await confirmRevokePopover(user);
+
+ await waitFor(() =>
expect(revokeStudioUserSessions).toHaveBeenCalledWith(7));
+ await waitFor(() =>
expect(listStudioUserSessions).toHaveBeenCalledTimes(2));
+ expect(within(drawer).getByText('暂无活跃会话')).toBeInTheDocument();
+ });
+
+ it('refreshes the open session detail drawer', async () => {
+ vi.mocked(listStudioUserSessions)
+ .mockResolvedValueOnce(sessionDetails)
+ .mockResolvedValueOnce([sessionDetails[0]]);
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ renderPage();
+
+ await screen.findByText('operator');
+ await user.click(screen.getByRole('button', { name: '会话' }));
+ const drawer = await screen.findByRole('dialog', { name: 'operator 的会话' });
+ expect(within(drawer).getByText('20')).toBeInTheDocument();
+
+ await user.click(within(drawer).getByRole('button', { name: '刷新' }));
+
+ await waitFor(() =>
expect(listStudioUserSessions).toHaveBeenCalledTimes(2));
+ expect(within(drawer).getByText('19')).toBeInTheDocument();
+ expect(within(drawer).queryByText('20')).not.toBeInTheDocument();
+ });
+
it('blocks the row status switch while the same user revocation is in
flight', async () => {
let resolveRevoke!: () => void;
vi.mocked(revokeStudioUserSessions).mockImplementationOnce(
@@ -248,9 +339,9 @@ describe('UserManagementPage', () => {
renderPage();
await screen.findByText('operator');
- await user.click(screen.getByRole('button', { name: '会话' }));
+ await user.click(screen.getByRole('button', { name: '注销' }));
await screen.findByText('注销 operator 的活跃会话?');
- await user.click(screen.getByRole('button', { name: /注\s*销/ }));
+ await confirmRevokePopover(user);
await waitFor(() =>
expect(revokeStudioUserSessions).toHaveBeenCalledWith(7));
// Revocation and status updates share one in-flight guard, so the row is
blocked meanwhile.