diff --git a/web/src/api/dataSources.test.ts b/web/src/api/dataSources.test.ts new file mode 100644 index 00000000..7b0d3f3c --- /dev/null +++ b/web/src/api/dataSources.test.ts @@ -0,0 +1,61 @@ +/* + * 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 MockAdapter from 'axios-mock-adapter'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import client from './client'; +import { createDataSource, deleteDataSource, listDataSources, testDataSource, updateDataSource } from './settings'; +import type { DataSource } from './settings'; + +const mock = new MockAdapter(client); +const source: DataSource = { key: 'source-1', name: 'Prometheus', type: 'Prometheus', url: 'http://prometheus:9090', auth: 'None', status: 'healthy' }; + +describe('data sources API', () => { + beforeEach(() => { + mock.reset(); + vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) }); + }); + + afterEach(() => { + mock.reset(); + vi.unstubAllGlobals(); + }); + + it('loads and returns created or updated data sources', async () => { + mock.onGet('/settings/datasources').reply(200, { code: 200, data: [source] }); + mock.onPost('/settings/datasources/create').reply(200, { code: 200, data: source }); + mock.onPost('/settings/datasources/update').reply(200, { code: 200, data: source }); + + await expect(listDataSources()).resolves.toEqual([source]); + await expect(createDataSource({ name: source.name })).resolves.toEqual(source); + await expect(updateDataSource(source)).resolves.toEqual(source); + }); + + it('uses a key query parameter for deletion and sends test auth details', async () => { + mock.onPost('/settings/datasources/delete').reply((config) => { + expect(config.params).toEqual({ key: source.key }); + return [200, { code: 200, data: null }]; + }); + mock.onPost('/settings/datasources/test').reply((config) => { + expect(JSON.parse(config.data)).toEqual({ type: source.type, url: source.url, auth: source.auth }); + return [200, { code: 200, data: { success: true, message: 'Connection successful' } }]; + }); + + await expect(deleteDataSource(source.key)).resolves.toBeUndefined(); + await expect(testDataSource({ type: source.type, url: source.url, auth: source.auth })).resolves.toEqual({ success: true, message: 'Connection successful' }); + }); +}); diff --git a/web/src/api/settings.ts b/web/src/api/settings.ts index 8f32b235..1a026d84 100644 --- a/web/src/api/settings.ts +++ b/web/src/api/settings.ts @@ -29,11 +29,11 @@ export interface GeneralSettings { } export interface DataSource { - id: string; + key: string; name: string; type: string; url: string; - isDefault: boolean; + auth: string; status: string; } @@ -54,18 +54,20 @@ export async function listDataSources() { } export async function createDataSource(data: Partial) { - await client.post('/settings/datasources/create', data); + const res = await client.post<{ data: DataSource }>('/settings/datasources/create', data); + return res.data.data; } export async function updateDataSource(data: Partial) { - await client.post('/settings/datasources/update', data); + const res = await client.post<{ data: DataSource }>('/settings/datasources/update', data); + return res.data.data; } -export async function deleteDataSource(id: string) { - await client.post('/settings/datasources/delete', { id }); +export async function deleteDataSource(key: string) { + await client.post('/settings/datasources/delete', undefined, { params: { key } }); } -export async function testDataSource(data: { type: string; url: string }) { +export async function testDataSource(data: { type: string; url: string; auth?: string }) { const res = await client.post<{ data: { success: boolean; message: string } }>( '/settings/datasources/test', data, diff --git a/web/src/pages/settings/index.tsx b/web/src/pages/settings/index.tsx index 612d497e..f199ad41 100644 --- a/web/src/pages/settings/index.tsx +++ b/web/src/pages/settings/index.tsx @@ -15,7 +15,7 @@ * limitations under the License. */ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Button, Descriptions, @@ -49,49 +49,18 @@ import type { ColumnsType } from 'antd/es/table'; import PageHeader from '../../components/PageHeader'; import { useLang } from '../../i18n/LangContext'; import StatusBadge from '../../components/StatusBadge'; +import { + createDataSource, + deleteDataSource, + listDataSources, + testDataSource, + updateDataSource, +} from '../../api/settings'; +import type { DataSource } from '../../api/settings'; +import { STATUS_MAP } from '../../constants/theme'; const { Title, Text, Link: TypoLink } = Typography; -// ─── Types ────────────────────────────────────────────────────────────────── - -interface DataSource { - key: string; - name: string; - type: 'Prometheus' | 'VictoriaMetrics' | 'Thanos'; - url: string; - auth: string; - status: 'healthy' | 'error'; -} - -// ─── Mock Data ────────────────────────────────────────────────────────────── - -const mockDataSources: DataSource[] = [ - { - key: '1', - name: 'Prometheus 生产监控', - type: 'Prometheus', - url: 'http://prometheus.prod:9090', - auth: 'Bearer Token', - status: 'healthy', - }, - { - key: '2', - name: 'VictoriaMetrics 历史数据', - type: 'VictoriaMetrics', - url: 'http://vm.prod:8428', - auth: 'Basic Auth', - status: 'healthy', - }, - { - key: '3', - name: '测试环境 Prometheus', - type: 'Prometheus', - url: 'http://prometheus.test:9090', - auth: 'None', - status: 'error', - }, -]; - const typeTagColor: Record = { Prometheus: 'orange', VictoriaMetrics: 'blue', @@ -219,16 +188,88 @@ const GeneralSettingsTab = () => { // ─── Data Source Tab ──────────────────────────────────────────────────────── const DataSourceTab = () => { + const [dataSources, setDataSources] = useState([]); + const [loading, setLoading] = useState(true); const [modalOpen, setModalOpen] = useState(false); + const [editingDataSource, setEditingDataSource] = useState(null); const [dsForm] = Form.useForm(); const [testing, setTesting] = useState(false); - - const handleTestConnection = () => { + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + let cancelled = false; + void listDataSources() + .then((sources) => { + if (!cancelled) setDataSources(sources); + }) + .catch(() => { + if (!cancelled) message.error('数据源加载失败,请稍后重试'); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, []); + + const handleTestConnection = async (data: Pick) => { setTesting(true); - setTimeout(() => { + try { + const result = await testDataSource(data); + if (result.success) message.success(result.message); + else message.error(result.message); + } catch { + message.error('连接测试失败,请稍后重试'); + } finally { setTesting(false); - message.success('连接成功'); - }, 1200); + } + }; + + const openCreateModal = () => { + setEditingDataSource(null); + dsForm.resetFields(); + dsForm.setFieldValue('auth', 'None'); + setModalOpen(true); + }; + + const openEditModal = (dataSource: DataSource) => { + setEditingDataSource(dataSource); + dsForm.setFieldsValue(dataSource); + setModalOpen(true); + }; + + const handleSubmit = async () => { + try { + const values = await dsForm.validateFields(); + setSubmitting(true); + const saved = editingDataSource + ? await updateDataSource({ ...editingDataSource, ...values }) + : await createDataSource(values); + setDataSources((previous) => + editingDataSource + ? previous.map((dataSource) => (dataSource.key === saved.key ? saved : dataSource)) + : [...previous, saved], + ); + message.success(editingDataSource ? '数据源已更新' : '数据源已添加'); + setModalOpen(false); + dsForm.resetFields(); + } catch { + message.error('保存数据源失败,请稍后重试'); + } finally { + setSubmitting(false); + } + }; + + const handleDelete = async (dataSource: DataSource) => { + try { + await deleteDataSource(dataSource.key); + setDataSources((previous) => previous.filter((item) => item.key !== dataSource.key)); + message.success('数据源已删除'); + } catch { + message.error('删除数据源失败,请稍后重试'); + } }; const columns: ColumnsType = [ @@ -245,25 +286,26 @@ const DataSourceTab = () => { title: '状态', dataIndex: 'status', key: 'status', - render: (s: DataSource['status']) => , + render: (s: DataSource['status']) => , }, { title: '操作', key: 'action', - render: () => ( + render: (_: unknown, record: DataSource) => ( - - @@ -274,29 +316,30 @@ const DataSourceTab = () => { return ( <> - columns={columns} - dataSource={mockDataSources} + dataSource={dataSources} + rowKey="key" + loading={loading} pagination={false} size="middle" /> setModalOpen(false)} - onOk={() => { - dsForm.validateFields().then(() => { - message.success('数据源已添加'); - setModalOpen(false); - dsForm.resetFields(); - }); + onCancel={() => { + setModalOpen(false); + setEditingDataSource(null); + dsForm.resetFields(); }} + onOk={() => void handleSubmit()} + confirmLoading={submitting} destroyOnClose >
@@ -344,7 +387,12 @@ const DataSourceTab = () => {