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
commit a354b1408b15ae9e4ecd4afbaf162ee7fb345040 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 547b3b44..f2d7beb9 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 a87d762c..6cebadbd 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 49a35e52..7019f8b0 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 ec4e3644..8d58a72c 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 00000000..6f1e09e8 --- /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 00000000..115692ad --- /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 8c8e38a4..e0e645c0 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 e5155467..dfa0da2c 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 dcd9d2a7..a83e3095 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 00000000..9348dbed --- /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 00000000..8aa22d57 --- /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. + } +}
