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 cc8e0a5 feat: add service capabilities and interaction enhancements
(#462)
cc8e0a5 is described below
commit cc8e0a5a7509d94b52aec8e4aa970d18a757519d
Author: Loyal-Young <[email protected]>
AuthorDate: Wed Jul 22 20:22:15 2026 +0800
feat: add service capabilities and interaction enhancements (#462)
Consolidated service/interaction work
(#462,#468,#469,#464,#470,#471,#472,#488).
---
web/src/api/ai.ts | 9 +-
web/src/api/client.ts | 8 +-
web/src/api/cluster.test.ts | 59 ++++++++++++-
web/src/config.ts | 3 +
web/src/pages/ai/chatDraft.test.ts | 34 +++++++
web/src/pages/ai/chatDraft.ts | 32 +++++++
web/src/pages/ai/index.tsx | 176 +++++++++++++++++--------------------
web/src/pages/cluster/certs.tsx | 36 +++++++-
web/src/pages/home/index.tsx | 12 ++-
web/src/stores/authStorage.test.ts | 51 +++++++++++
web/src/stores/authStorage.ts | 54 ++++++++++++
11 files changed, 371 insertions(+), 103 deletions(-)
diff --git a/web/src/api/ai.ts b/web/src/api/ai.ts
index 547b3b4..f2d7beb 100644
--- a/web/src/api/ai.ts
+++ b/web/src/api/ai.ts
@@ -31,6 +31,13 @@ export interface AiExecuteRequest {
tools?: string[];
}
+export interface AiChatRequest {
+ message: string;
+ mode: string;
+ model: string;
+ conversationId?: string;
+}
+
interface AiStreamPayload {
content?: unknown;
text?: unknown;
@@ -76,7 +83,7 @@ function emitEvent(event: string, onChunk: (text: string) =>
void): boolean {
// ─── AI ─────────────────────────────────────────────────────────
export async function chatStream(
- data: AiExecuteRequest,
+ data: AiChatRequest,
onChunk: (text: string) => void,
signal?: AbortSignal,
) {
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
index a87d762..6cebadb 100644
--- a/web/src/api/client.ts
+++ b/web/src/api/client.ts
@@ -17,6 +17,8 @@
import axios from 'axios';
import { message } from 'antd';
+import { clearAuthSession, TOKEN_STORAGE_KEY } from '../stores/authStorage';
+import { API_BASE_URL } from '../config';
const SUCCESS_BUSINESS_CODES = new Set([0, 200]);
@@ -40,14 +42,14 @@ function getBusinessError(data: unknown): string | null {
}
const client = axios.create({
- baseURL: '/api',
+ baseURL: API_BASE_URL,
timeout: 30000,
});
// Request interceptor: attach Authorization header
client.interceptors.request.use(
(config) => {
- const token = localStorage.getItem('token');
+ const token = localStorage.getItem(TOKEN_STORAGE_KEY);
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
@@ -68,7 +70,7 @@ client.interceptors.response.use(
},
(error) => {
if (error.response?.status === 401) {
- localStorage.removeItem('token');
+ clearAuthSession();
window.location.href = '/';
}
return Promise.reject(error);
diff --git a/web/src/api/cluster.test.ts b/web/src/api/cluster.test.ts
index 49a35e5..7019f8b 100644
--- a/web/src/api/cluster.test.ts
+++ b/web/src/api/cluster.test.ts
@@ -18,7 +18,19 @@
import MockAdapter from 'axios-mock-adapter';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import client from './client';
-import { createK8sCert, deleteK8sCert, listK8sCerts, updateK8sCert } from
'./cluster';
+import {
+ createK8sCert,
+ createNameServer,
+ deleteK8sCert,
+ deleteNameServer,
+ listK8sCerts,
+ renewK8sCert,
+ restartNameServer,
+ restartProxy,
+ updateK8sCert,
+ updateNameServer,
+ upgradeNameServer,
+} from './cluster';
import type { K8sCertInfo } from './cluster';
const mock = new MockAdapter(client);
@@ -72,6 +84,16 @@ describe('K8s certificate API', () => {
await expect(updateK8sCert({ id: cert.id, issuer: 'vault'
})).resolves.toEqual(updated);
});
+ it('renews a certificate using its id', async () => {
+ const renewed = { ...cert, daysRemaining: 365, status: 'valid' };
+ mock.onPost('/k8s-certs/renew').reply((config) => {
+ expect(JSON.parse(config.data)).toEqual({ id: cert.id });
+ return [200, { code: 200, message: 'success', data: renewed }];
+ });
+
+ await expect(renewK8sCert(cert.id)).resolves.toEqual(renewed);
+ });
+
it('sends the certificate id when deleting', async () => {
mock.onPost('/k8s-certs/delete').reply((config) => {
expect(JSON.parse(config.data)).toEqual({ id: cert.id });
@@ -80,4 +102,39 @@ describe('K8s certificate API', () => {
await expect(deleteK8sCert(cert.id)).resolves.toBeUndefined();
});
+
+ it('sends NameServer operation payloads to their endpoints', async () => {
+ const target = { clusterId: 'cluster-1', addr: '127.0.0.1:9876' };
+ const requests = [
+ ['/nameservers/restart', target],
+ ['/nameservers/upgrade', { ...target, version: '5.4.0' }],
+ ['/nameservers/create', target],
+ ['/nameservers/update', { ...target, newAddr: '127.0.0.2:9876' }],
+ ['/nameservers/delete', target],
+ ] as const;
+ requests.forEach(([url, body]) => {
+ mock.onPost(url).reply((config) => {
+ expect(JSON.parse(config.data)).toEqual(body);
+ return [200, { code: 200, data: null }];
+ });
+ });
+
+ await expect(restartNameServer(target)).resolves.toBeUndefined();
+ await expect(upgradeNameServer({ ...target, version: '5.4.0'
})).resolves.toBeUndefined();
+ await expect(createNameServer(target)).resolves.toBeUndefined();
+ await expect(
+ updateNameServer({ ...target, newAddr: '127.0.0.2:9876' }),
+ ).resolves.toBeUndefined();
+ await expect(deleteNameServer(target)).resolves.toBeUndefined();
+ });
+
+ it('sends the proxy restart target', async () => {
+ const target = { clusterId: 'cluster-1', addr: '127.0.0.1:8081' };
+ mock.onPost('/proxies/restart').reply((config) => {
+ expect(JSON.parse(config.data)).toEqual(target);
+ return [200, { code: 200, data: null }];
+ });
+
+ await expect(restartProxy(target)).resolves.toBeUndefined();
+ });
});
diff --git a/web/src/config.ts b/web/src/config.ts
index ec4e364..8d58a72 100644
--- a/web/src/config.ts
+++ b/web/src/config.ts
@@ -5,3 +5,6 @@
*/
export const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true';
+
+/** API prefix for browser requests. Defaults to the reverse-proxy friendly
`/api`. */
+export const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ||
'/api').replace(/\/$/, '');
diff --git a/web/src/pages/ai/chatDraft.test.ts
b/web/src/pages/ai/chatDraft.test.ts
new file mode 100644
index 0000000..6f1e09e
--- /dev/null
+++ b/web/src/pages/ai/chatDraft.test.ts
@@ -0,0 +1,34 @@
+/*
+ * 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.
+ */
+
+import { describe, expect, it } from 'vitest';
+import { getChatDraft } from './chatDraft';
+
+describe('AI chat draft navigation state', () => {
+ it('normalizes a prompt and preserves a selected model', () => {
+ expect(getChatDraft({ prompt: ' 检查集群状态 ', model: 'qwen3.7-max'
})).toEqual({
+ prompt: '检查集群状态',
+ model: 'qwen3.7-max',
+ });
+ });
+
+ it('rejects invalid or empty navigation state', () => {
+ expect(getChatDraft(null)).toBeNull();
+ expect(getChatDraft({ prompt: ' ' })).toBeNull();
+ expect(getChatDraft({ prompt: 42 })).toBeNull();
+ });
+});
diff --git a/web/src/pages/ai/chatDraft.ts b/web/src/pages/ai/chatDraft.ts
new file mode 100644
index 0000000..115692a
--- /dev/null
+++ b/web/src/pages/ai/chatDraft.ts
@@ -0,0 +1,32 @@
+/*
+ * 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.
+ */
+
+export interface ChatDraft {
+ prompt: string;
+ model?: string;
+}
+
+export function getChatDraft(state: unknown): ChatDraft | null {
+ if (typeof state !== 'object' || state === null) return null;
+ const candidate = state as Record<string, unknown>;
+ if (typeof candidate.prompt !== 'string' || !candidate.prompt.trim()) return
null;
+
+ return {
+ prompt: candidate.prompt.trim(),
+ ...(typeof candidate.model === 'string' && candidate.model ? { model:
candidate.model } : {}),
+ };
+}
diff --git a/web/src/pages/ai/index.tsx b/web/src/pages/ai/index.tsx
index 8c8e38a..e0e645c 100644
--- a/web/src/pages/ai/index.tsx
+++ b/web/src/pages/ai/index.tsx
@@ -16,6 +16,7 @@
*/
import { useState, useRef, useEffect, useCallback } from 'react';
+import { useLocation, useNavigate } from 'react-router-dom';
import {
Card,
Button,
@@ -29,10 +30,13 @@ import {
Flex,
Divider,
Select,
+ message,
} from 'antd';
import { ArrowUp, Sparkle, SlidersHorizontal, CaretDown } from
'@phosphor-icons/react';
import type { ColumnsType } from 'antd/es/table';
import { useLang } from '../../i18n/LangContext';
+import { chatStream } from '../../api/ai';
+import { getChatDraft } from './chatDraft';
const { Text, Paragraph } = Typography;
@@ -96,86 +100,7 @@ const modelOptions = [
/* ─── Mock Data ─── */
-const topicColumns: ColumnsType<TopicRow> = [
- { title: 'Topic 名称', dataIndex: 'name', key: 'name' },
- {
- title: '类型',
- dataIndex: 'type',
- key: 'type',
- render: (type: string) => {
- const map: Record<string, { label: string; color: string }> = {
- NORMAL: { label: '普通', color: 'default' },
- FIFO: { label: '顺序', color: 'blue' },
- DELAY: { label: '延迟', color: 'orange' },
- TRANSACTION: { label: '事务', color: 'purple' },
- };
- const cfg = map[type] || { label: type, color: 'default' };
- return <Tag color={cfg.color}>{cfg.label}</Tag>;
- },
- },
- { title: '队列数', dataIndex: 'queues', key: 'queues', width: 80, align:
'center' },
-];
-
-const mockTopicData: TopicRow[] = [
- { key: '1', name: 'order-create', type: 'TRANSACTION', queues: 16 },
- { key: '2', name: 'payment-notify', type: 'NORMAL', queues: 12 },
- { key: '3', name: 'user-login-event', type: 'NORMAL', queues: 8 },
- { key: '4', name: 'inventory-sync', type: 'FIFO', queues: 8 },
- { key: '5', name: 'promo-push', type: 'DELAY', queues: 4 },
-];
-
-const initialMessages: Message[] = [
- {
- id: 'm1',
- role: 'user',
- text: '查看生产集群-杭州的 Topic 列表',
- },
- {
- id: 'm2',
- role: 'ai',
- toolCall: { name: 'list_topics', label: '🔧 list_topics' },
- tableData: mockTopicData,
- tableColumns: topicColumns,
- summary: '生产集群-杭州 共有 128 个 Topic,以上为按吞吐量排序的前 5 个。',
- },
- {
- id: 'm3',
- role: 'user',
- text: 'order-create 这个 Topic 最近 1 小时的堆积情况',
- },
- {
- id: 'm4',
- role: 'ai',
- toolCall: { name: 'get_topic_lag', label: '🔧 get_topic_lag' },
- stats: [
- { title: '当前堆积', value: '2,340', color: '#1677ff' },
- { title: '消费速率', value: '856', suffix: '/s', color: '#52c41a' },
- { title: '预计追平', value: '2.7', suffix: 's', color: '#722ed1' },
- ],
- summary: 'order-create 消费状态良好,堆积量较低,消费速率稳定。',
- },
- {
- id: 'm5',
- role: 'user',
- text: '帮我创建一个事务类型的 Topic,名称 order-refund',
- },
- {
- id: 'm6',
- role: 'ai',
- toolCall: { name: 'create_topic', label: '🔧 create_topic (dry-run)' },
- descriptions: [
- { label: '名称', value: 'order-refund' },
- { label: '类型', value: 'TRANSACTION' },
- { label: '队列数', value: '16' },
- { label: '集群', value: '生产集群-杭州' },
- ],
- summary: '已生成创建预览,确认后将执行创建操作。',
- actions: [
- { label: '确认创建', type: 'primary' },
- { label: '取消', type: 'default' },
- ],
- },
-];
+const initialMessages: Message[] = [];
/* ─── Quick Actions ─── */
@@ -346,12 +271,17 @@ const AiMessage = ({ msg }: { msg: Message }) => (
const AiPage = () => {
const { t } = useLang();
+ const location = useLocation();
+ const navigate = useNavigate();
const [messages, setMessages] = useState<Message[]>(initialMessages);
const [inputValue, setInputValue] = useState('');
const [loading, setLoading] = useState(false);
const [selectedModel, setSelectedModel] = useState('qwen3.7-max');
const chatEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
+ const abortControllerRef = useRef<AbortController | null>(null);
+ const conversationIdRef = useRef<string | null>(null);
+ const consumedDraftRef = useRef(false);
const scrollToBottom = useCallback(() => {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
@@ -361,6 +291,19 @@ const AiPage = () => {
scrollToBottom();
}, [messages, scrollToBottom]);
+ useEffect(() => {
+ const draft = getChatDraft(location.state);
+ if (!draft || consumedDraftRef.current) return;
+ consumedDraftRef.current = true;
+
+ void Promise.resolve().then(() => {
+ setInputValue(draft.prompt);
+ if (draft.model) setSelectedModel(draft.model);
+ navigate('/ai', { replace: true, state: null });
+ textareaRef.current?.focus();
+ });
+ }, [location.state, navigate]);
+
/* ─── Auto-resize textarea ─── */
useEffect(() => {
const ta = textareaRef.current;
@@ -373,35 +316,75 @@ const AiPage = () => {
return () => ta.removeEventListener('input', handler);
}, []);
- const handleSend = useCallback(() => {
+ useEffect(() => {
+ return () => abortControllerRef.current?.abort();
+ }, []);
+
+ const handleSend = useCallback(async () => {
const text = inputValue.trim();
if (!text || loading) return;
+ if (!conversationIdRef.current) {
+ conversationIdRef.current = `conversation-${Date.now()}`;
+ }
+
const userMsg: Message = {
id: `user-${Date.now()}`,
role: 'user',
text,
};
- setMessages((prev) => [...prev, userMsg]);
+ const responseId = `ai-${Date.now()}`;
+ setMessages((prev) => [...prev, userMsg, { id: responseId, role: 'ai',
summary: '' }]);
setInputValue('');
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
setLoading(true);
-
- // Mock AI response after brief delay
- setTimeout(() => {
- const aiMsg: Message = {
- id: `ai-${Date.now()}`,
- role: 'ai',
- toolCall: { name: 'process_query', label: '🔧 process_query' },
- summary: `已收到你的问题:「${text}」。AI 助手正在分析并调用相关 MCP 工具,结果将在此处展示。(当前为演示模式)`,
- };
- setMessages((prev) => [...prev, aiMsg]);
+ const controller = new AbortController();
+ abortControllerRef.current = controller;
+
+ try {
+ await chatStream(
+ {
+ message: text,
+ mode: 'chat',
+ model: selectedModel,
+ conversationId: conversationIdRef.current,
+ },
+ (chunk) => {
+ setMessages((prev) =>
+ prev.map((item) =>
+ item.id === responseId ? { ...item, summary: `${item.summary ??
''}${chunk}` } : item,
+ ),
+ );
+ },
+ controller.signal,
+ );
+ } catch (error) {
+ if (controller.signal.aborted) {
+ setMessages((prev) =>
+ prev.map((item) =>
+ item.id === responseId && !item.summary ? { ...item, summary:
'回答已停止。' } : item,
+ ),
+ );
+ } else {
+ setMessages((prev) =>
+ prev.map((item) =>
+ item.id === responseId ? { ...item, summary: 'AI 服务暂时不可用,请稍后重试。' }
: item,
+ ),
+ );
+ message.error(error instanceof Error ? error.message : 'AI 请求失败');
+ }
+ } finally {
+ if (abortControllerRef.current === controller)
abortControllerRef.current = null;
setLoading(false);
- }, 1200);
- }, [inputValue, loading]);
+ }
+ }, [inputValue, loading, selectedModel]);
+
+ const handleStop = useCallback(() => {
+ abortControllerRef.current?.abort();
+ }, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
@@ -609,6 +592,11 @@ const AiPage = () => {
>
<ArrowUp size={19} weight="bold" />
</button>
+ {loading && (
+ <Button size="small" onClick={handleStop}>
+ 停止
+ </Button>
+ )}
</div>
</div>
</div>
diff --git a/web/src/pages/cluster/certs.tsx b/web/src/pages/cluster/certs.tsx
index e515546..dfa0da2 100644
--- a/web/src/pages/cluster/certs.tsx
+++ b/web/src/pages/cluster/certs.tsx
@@ -31,13 +31,14 @@ import {
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
-import { EditOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons';
+import { EditOutlined, DeleteOutlined, PlusOutlined, SyncOutlined } from
'@ant-design/icons';
import PageHeader from '../../components/PageHeader';
import type { K8sCertInfo } from '../../api/cluster';
import {
createK8sCert,
deleteK8sCert,
listK8sCerts,
+ renewK8sCert,
updateK8sCert,
} from '../../services/clusterService';
@@ -56,6 +57,7 @@ const K8sCertsPage = () => {
const [certs, setCerts] = useState<K8sCertInfo[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
+ const [renewingId, setRenewingId] = useState<string | null>(null);
const [certSearch, setCertSearch] = useState('');
const [certTypeFilter, setCertTypeFilter] = useState<string>('');
const [editModalOpen, setEditModalOpen] = useState(false);
@@ -136,6 +138,20 @@ const K8sCertsPage = () => {
return matchSearch && matchType;
});
+ const renewCert = async (cert: K8sCertInfo) => {
+ setRenewingId(cert.id);
+ try {
+ const renewed = await renewK8sCert(cert.id);
+ setCerts((prev) => prev.map((item) => (item.id === renewed.id ? renewed
: item)));
+ message.success(`证书「${renewed.name}」已续期`);
+ } catch (error) {
+ message.error(getErrorMessage(error));
+ throw error;
+ } finally {
+ setRenewingId(null);
+ }
+ };
+
const certColumns: ColumnsType<K8sCertInfo> = [
{
title: 'K8s 集群名称',
@@ -225,7 +241,7 @@ const K8sCertsPage = () => {
{
title: '操作',
key: 'action',
- width: 200,
+ width: 270,
render: (_: unknown, record: K8sCertInfo) => (
<Flex gap={6}>
<Button
@@ -247,6 +263,22 @@ const K8sCertsPage = () => {
>
编辑
</Button>
+ <Button
+ size="small"
+ icon={<SyncOutlined />}
+ loading={renewingId === record.id}
+ onClick={() => {
+ Modal.confirm({
+ title: '确认续期',
+ content: `确定要为证书 "${record.name}" 续期一年吗?`,
+ okText: '续期',
+ cancelText: '取消',
+ onOk: () => renewCert(record),
+ });
+ }}
+ >
+ 续期
+ </Button>
<Button
size="small"
icon={<DeleteOutlined />}
diff --git a/web/src/pages/home/index.tsx b/web/src/pages/home/index.tsx
index dcd9d2a..a83e309 100644
--- a/web/src/pages/home/index.tsx
+++ b/web/src/pages/home/index.tsx
@@ -66,6 +66,7 @@ const modelOptions = [
const HomePage = () => {
const [activeMode, setActiveMode] = useState('query');
const [selectedModel, setSelectedModel] = useState('qwen3.7-max');
+ const [inputValue, setInputValue] = useState('');
const [indicatorStyle, setIndicatorStyle] = useState({ width: 83, left: 6 });
const textareaRef = useRef<HTMLTextAreaElement>(null);
const modeBarRef = useRef<HTMLDivElement>(null);
@@ -117,10 +118,15 @@ const HomePage = () => {
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
- navigate('/ai');
+ handlePromptSubmit();
}
};
+ const handlePromptSubmit = () => {
+ const prompt = inputValue.trim();
+ navigate('/ai', { state: prompt ? { prompt, model: selectedModel } : null
});
+ };
+
return (
<ConfigProvider theme={{ algorithm: theme.defaultAlgorithm }}>
<div
@@ -321,6 +327,8 @@ const HomePage = () => {
ref={textareaRef}
className="chat-input"
placeholder="向 RocketMQ Bot 提问,全程加密、安全、可信"
+ value={inputValue}
+ onChange={(event) => setInputValue(event.target.value)}
onKeyDown={handleKeyDown}
/>
<RobotOutlined
@@ -359,7 +367,7 @@ const HomePage = () => {
<div className="shrink-0 flex items-center gap-1">
<button
className="flex items-center justify-center w-9 h-9
rounded-full bg-gradient-to-r from-purple-500 to-violet-600 text-white
shadow-lg hover:shadow-xl transition-all hover:scale-105"
- onClick={() => navigate('/ai')}
+ onClick={handlePromptSubmit}
>
<ArrowUp size={19} weight="bold" />
</button>
diff --git a/web/src/stores/authStorage.test.ts
b/web/src/stores/authStorage.test.ts
new file mode 100644
index 0000000..9348dbe
--- /dev/null
+++ b/web/src/stores/authStorage.test.ts
@@ -0,0 +1,51 @@
+/*
+ * 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.
+ */
+
+import { afterEach, describe, expect, it } from 'vitest';
+import {
+ clearAuthSession,
+ persistAuthSession,
+ readAuthSession,
+ TOKEN_STORAGE_KEY,
+ USER_STORAGE_KEY,
+} from './authStorage';
+
+describe('auth session storage', () => {
+ afterEach(() => {
+ localStorage.clear();
+ });
+
+ it('persists the token and user together', () => {
+ persistAuthSession('token-1', 'studio-admin');
+
+ expect(readAuthSession()).toEqual({ token: 'token-1', user: 'studio-admin'
});
+ });
+
+ it('does not restore an orphaned user without a token', () => {
+ localStorage.setItem(USER_STORAGE_KEY, 'studio-admin');
+
+ expect(readAuthSession()).toEqual({ token: null, user: null });
+ });
+
+ it('clears every persisted session key', () => {
+ persistAuthSession('token-1', 'studio-admin');
+ clearAuthSession();
+
+ expect(localStorage.getItem(TOKEN_STORAGE_KEY)).toBeNull();
+ expect(localStorage.getItem(USER_STORAGE_KEY)).toBeNull();
+ });
+});
diff --git a/web/src/stores/authStorage.ts b/web/src/stores/authStorage.ts
new file mode 100644
index 0000000..8aa22d5
--- /dev/null
+++ b/web/src/stores/authStorage.ts
@@ -0,0 +1,54 @@
+/*
+ * 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.
+ */
+
+export const TOKEN_STORAGE_KEY = 'token';
+export const USER_STORAGE_KEY = 'rocketmq-studio-user';
+
+export interface AuthSession {
+ token: string | null;
+ user: string | null;
+}
+
+export function readAuthSession(): AuthSession {
+ try {
+ const token = localStorage.getItem(TOKEN_STORAGE_KEY);
+ return {
+ token,
+ user: token ? localStorage.getItem(USER_STORAGE_KEY) : null,
+ };
+ } catch {
+ return { token: null, user: null };
+ }
+}
+
+export function persistAuthSession(token: string, user: string): void {
+ try {
+ localStorage.setItem(TOKEN_STORAGE_KEY, token);
+ localStorage.setItem(USER_STORAGE_KEY, user);
+ } catch {
+ // The in-memory store remains usable when browser storage is unavailable.
+ }
+}
+
+export function clearAuthSession(): void {
+ try {
+ localStorage.removeItem(TOKEN_STORAGE_KEY);
+ localStorage.removeItem(USER_STORAGE_KEY);
+ } catch {
+ // The caller still clears the in-memory store.
+ }
+}