feat: M1-01~03 admin pages — AI Gateway, Vector, Events deepening
Some checks failed
Deploy Admin Frontend / build-and-deploy (push) Failing after 7s
Some checks failed
Deploy Admin Frontend / build-and-deploy (push) Failing after 7s
M1-01 AI Gateway: - Tab-based layout: Overview, Routes CRUD, Provider toggle, Fallback logs M1-02 Vector: - New VectorAdmin page: collection stats, info panel, reindex trigger - Route /vector + menu entry under 系统运维 M1-03 Events: - Tab-based layout: Queue overview (with batch retry), Task statistics - Worker status panel, 16 task type configs table Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
6020867060
commit
f6917d63d3
@ -23,6 +23,7 @@ const AiGatewayPage = lazy(() => import("./pages/AiGateway"))
|
|||||||
const CSPage = lazy(() => import("./pages/ContentSafety"))
|
const CSPage = lazy(() => import("./pages/ContentSafety"))
|
||||||
const MetricsPage = lazy(() => import("./pages/Metrics"))
|
const MetricsPage = lazy(() => import("./pages/Metrics"))
|
||||||
const EventsPage = lazy(() => import("./pages/Events"))
|
const EventsPage = lazy(() => import("./pages/Events"))
|
||||||
|
const VectorAdminPage = lazy(() => import("./pages/VectorAdmin"))
|
||||||
const ServersPage = lazy(() => import("./pages/Servers"))
|
const ServersPage = lazy(() => import("./pages/Servers"))
|
||||||
const AuditLogPage = lazy(() => import("./pages/AuditLog"))
|
const AuditLogPage = lazy(() => import("./pages/AuditLog"))
|
||||||
const Dashboard = lazy(() => import('./pages/Dashboard'))
|
const Dashboard = lazy(() => import('./pages/Dashboard'))
|
||||||
@ -141,6 +142,10 @@ function App() {
|
|||||||
path="events"
|
path="events"
|
||||||
element={<PermissionGuard requiredRole="SUPER_ADMIN"><Suspense fallback={<PageLoading />}><EventsPage /></Suspense></PermissionGuard>}
|
element={<PermissionGuard requiredRole="SUPER_ADMIN"><Suspense fallback={<PageLoading />}><EventsPage /></Suspense></PermissionGuard>}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="vector"
|
||||||
|
element={<PermissionGuard requiredRole="SUPER_ADMIN"><Suspense fallback={<PageLoading />}><VectorAdminPage /></Suspense></PermissionGuard>}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="servers"
|
path="servers"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@ -37,6 +37,7 @@ export const adminMenuItems: AdminMenuItem[] = [
|
|||||||
{ path: '/ai-gateway', name: 'AI Gateway' },
|
{ path: '/ai-gateway', name: 'AI Gateway' },
|
||||||
{ path: '/servers', name: '服务器' },
|
{ path: '/servers', name: '服务器' },
|
||||||
{ path: '/events', name: '事件队列' },
|
{ path: '/events', name: '事件队列' },
|
||||||
|
{ path: '/vector', name: '向量检索' },
|
||||||
{ path: '/config', name: '配置管理' },
|
{ path: '/config', name: '配置管理' },
|
||||||
{ path: '/safety', name: '内容安全' },
|
{ path: '/safety', name: '内容安全' },
|
||||||
]},
|
]},
|
||||||
|
|||||||
@ -1,26 +1,167 @@
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useState } from 'react'
|
||||||
import { Card, Row, Col, Statistic, Table, Tag, Button, Typography } from 'antd'
|
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
|
||||||
import { ReloadOutlined, CloudOutlined } from '@ant-design/icons'
|
import { Card, Row, Col, Statistic, Table, Tag, Button, Typography, Tabs, Modal, Form, Input, InputNumber, Select, Switch, Space, App } from 'antd'
|
||||||
|
import { ReloadOutlined, CloudOutlined, PlusOutlined, EditOutlined, DeleteOutlined, StopOutlined, CheckCircleOutlined } from '@ant-design/icons'
|
||||||
import { api } from '@/services/http-client'
|
import { api } from '@/services/http-client'
|
||||||
|
|
||||||
const { Title, Text } = Typography
|
const { Title, Text } = Typography
|
||||||
|
|
||||||
export default function AiGatewayPage() {
|
export default function AiGatewayPage() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { data } = useQuery({ queryKey: ['ai-gateway', 'status'], queryFn: (): Promise<any> => api.get('/admin-api/ai-gateway/status'), staleTime: 30_000 })
|
const { message } = App.useApp()
|
||||||
|
const [activeTab, setActiveTab] = useState('overview')
|
||||||
|
const [routeModal, setRouteModal] = useState<{ open: boolean; editing?: any }>({ open: false })
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
|
||||||
const tierCols = [
|
const { data: status } = useQuery({
|
||||||
{ title: '级别', dataIndex: 'name', width: 80 },
|
queryKey: ['ai-gateway', 'status'],
|
||||||
{ title: '主模型', dataIndex: 'provider', width: 120 },
|
queryFn: (): Promise<any> => api.get('/admin-api/ai-gateway/status'),
|
||||||
{ title: '模型', dataIndex: 'model', width: 180 },
|
staleTime: 30_000,
|
||||||
{ title: '备用', dataIndex: 'fallback', width: 180, render: (v: string) => v || '-' },
|
})
|
||||||
|
|
||||||
|
const { data: routes, refetch: refetchRoutes } = useQuery({
|
||||||
|
queryKey: ['ai-gateway', 'routes'],
|
||||||
|
queryFn: (): Promise<any> => api.get('/admin-api/ai-gateway/routes'),
|
||||||
|
enabled: activeTab === 'routes',
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: providers, refetch: refetchProviders } = useQuery({
|
||||||
|
queryKey: ['ai-gateway', 'providers'],
|
||||||
|
queryFn: (): Promise<any> => api.get('/admin-api/ai-gateway/providers'),
|
||||||
|
enabled: activeTab === 'providers',
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: fallbackEvents } = useQuery({
|
||||||
|
queryKey: ['ai-gateway', 'fallback-events'],
|
||||||
|
queryFn: (): Promise<any> => api.get('/admin-api/ai-gateway/fallback-events'),
|
||||||
|
enabled: activeTab === 'fallback',
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createRoute = useMutation({
|
||||||
|
mutationFn: (values: any) => api.post('/admin-api/ai-gateway/routes', values),
|
||||||
|
onSuccess: () => { message.success('路由已创建'); refetchRoutes(); setRouteModal({ open: false }); form.resetFields() },
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteRoute = useMutation({
|
||||||
|
mutationFn: (id: string) => api.delete(`/admin-api/ai-gateway/routes/${id}`),
|
||||||
|
onSuccess: () => { message.success('路由已删除'); refetchRoutes() },
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleProvider = useMutation({
|
||||||
|
mutationFn: ({ name, enabled }: { name: string; enabled: boolean }) => api.put(`/admin-api/ai-gateway/providers/${name}`, { enabled }),
|
||||||
|
onSuccess: () => { message.success('Provider 已更新'); refetchProviders() },
|
||||||
|
})
|
||||||
|
|
||||||
|
const routeColumns = [
|
||||||
|
{ title: '级别', dataIndex: 'tier', width: 80, render: (v: string) => <Tag color={v==='strong'?'red':v==='primary'?'blue':'default'}>{v}</Tag> },
|
||||||
|
{ title: '任务类型', dataIndex: 'taskType', width: 100 },
|
||||||
|
{ title: '首选 Provider', dataIndex: 'preferredProvider', width: 120, render: (v: string) => <Tag>{v}</Tag> },
|
||||||
|
{ title: '首选模型', dataIndex: 'preferredModel', width: 180, ellipsis: true },
|
||||||
|
{ title: '备用 Provider', dataIndex: 'fallbackProvider', width: 120, render: (v: string) => <Tag>{v}</Tag> },
|
||||||
|
{ title: '备用模型', dataIndex: 'fallbackModel', width: 180, ellipsis: true },
|
||||||
|
{ title: '重试', dataIndex: 'maxRetries', width: 60, align: 'center' as const },
|
||||||
|
{ title: '状态', dataIndex: 'isActive', width: 70, render: (v: boolean) => v ? <Tag color="green">启用</Tag> : <Tag color="red">禁用</Tag> },
|
||||||
|
{ title: '操作', width: 80, render: (_: any, r: any) => (
|
||||||
|
<Button type="link" danger size="small" icon={<DeleteOutlined />} onClick={() => deleteRoute.mutate(r.id)} />
|
||||||
|
)},
|
||||||
]
|
]
|
||||||
|
|
||||||
const tiers = data?.tiers ? [
|
const providerColumns = [
|
||||||
{ name: 'cheap', ...data.tiers.cheap, fallback: data.tiers.cheap.fallback?.model || '-' },
|
{ title: 'Provider', dataIndex: 'name', width: 150, render: (v: string) => <Tag color={v==='deepseek'?'blue':v==='minimax'?'purple':'green'}>{v}</Tag> },
|
||||||
{ name: 'primary', ...data.tiers.primary, fallback: data.tiers.primary.fallback?.model || data.tiers.primary.fallback || '-' },
|
{ title: '状态', dataIndex: 'enabled', width: 80, render: (v: boolean, r: any) => (
|
||||||
{ name: 'strong', ...data.tiers.strong, fallback: data.tiers.strong.fallback?.model || '-' },
|
<Switch checked={v} onChange={(checked) => toggleProvider.mutate({ name: r.name, enabled: checked })} />
|
||||||
] : []
|
)},
|
||||||
|
{ title: 'Base URL', dataIndex: 'baseUrl', ellipsis: true },
|
||||||
|
{ title: '更新时间', dataIndex: 'updatedAt', width: 180 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const fallbackColumns = [
|
||||||
|
{ title: '时间', dataIndex: 'createdAt', width: 170 },
|
||||||
|
{ title: '级别', dataIndex: 'tier', width: 80 },
|
||||||
|
{ title: '来源', dataIndex: 'fromProvider', width: 100, render: (v: string, r: any) => <Text>{v}/{r.fromModel?.slice(0,30)}</Text> },
|
||||||
|
{ title: '切换到', dataIndex: 'toProvider', width: 100, render: (v: string, r: any) => <Text type="warning">{v}/{r.toModel?.slice(0,30)}</Text> },
|
||||||
|
{ title: '错误', dataIndex: 'errorMessage', ellipsis: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
const tabItems = [
|
||||||
|
{
|
||||||
|
key: 'overview', label: '概览',
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||||
|
<Col span={6}><Card size="small"><Statistic title="Provider" value={status?.providers?.length || 0} suffix="个" /></Card></Col>
|
||||||
|
<Col span={6}><Card size="small"><Statistic title="活跃路由" value={status?.activeRoutes || 0} suffix="条" /></Card></Col>
|
||||||
|
<Col span={6}><Card size="small"><Statistic title="降级事件" value={status?.fallbackEvents || 0} suffix="次" /></Card></Col>
|
||||||
|
<Col span={6}><Card size="small"><Statistic title="路由级别" value={3} suffix="层" /></Card></Col>
|
||||||
|
</Row>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Card size="small" title="Provider 列表">
|
||||||
|
<Table dataSource={(status?.providers || []).map((p: any) => (typeof p === 'string' ? { name: p, enabled: true } : p))} columns={[
|
||||||
|
{ title: '名称', dataIndex: 'name', render: (v: string) => <Tag color={v==='deepseek'?'blue':v==='minimax'?'purple':'green'}>{v}</Tag> },
|
||||||
|
{ title: '状态', dataIndex: 'enabled', render: (v: boolean) => v ? <Tag color="green">启用</Tag> : <Tag color="red">禁用</Tag> },
|
||||||
|
]} rowKey="name" pagination={false} size="small" />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Card size="small" title="模型路由">
|
||||||
|
<Table dataSource={(status?.routes || []).map((r: any) => (
|
||||||
|
{ tier: r.tier, taskType: r.taskType || '*', preferredProvider: r.preferred?.split('/')[0], preferredModel: r.preferred?.split('/').slice(1).join('/'), fallbackProvider: r.fallback?.split('/')[0], fallbackModel: r.fallback?.split('/').slice(1).join('/'), maxRetries: r.maxRetries }
|
||||||
|
))} columns={[
|
||||||
|
{ title: '级别', dataIndex: 'tier', width: 70, render: (v: string) => <Tag>{v}</Tag> },
|
||||||
|
{ title: '首选', dataIndex: 'preferred', width: 200, ellipsis: true, render: (_: any, r: any) => <Text>{r.preferredProvider}/{r.preferredModel?.slice(0,25)}</Text> },
|
||||||
|
{ title: '备用', dataIndex: 'fallback', width: 200, ellipsis: true, render: (_: any, r: any) => <Text>{r.fallbackProvider}/{r.fallbackModel?.slice(0,25)}</Text> },
|
||||||
|
]} rowKey="tier" pagination={false} size="small" />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'routes', label: '路由规则',
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} style={{ marginBottom: 16 }} onClick={() => { form.resetFields(); setRouteModal({ open: true }) }}>新增路由</Button>
|
||||||
|
<Table dataSource={routes || []} columns={routeColumns} rowKey="id" pagination={false} size="small" />
|
||||||
|
<Modal title={routeModal.editing ? '编辑路由' : '新增路由'} open={routeModal.open} onCancel={() => setRouteModal({ open: false })} onOk={() => form.submit()} confirmLoading={createRoute.isPending}>
|
||||||
|
<Form form={form} layout="vertical" onFinish={(v) => createRoute.mutate(v)}>
|
||||||
|
<Form.Item name="tier" label="级别" rules={[{ required: true }]}>
|
||||||
|
<Select options={[{ value: 'cheap', label: 'Cheap' }, { value: 'primary', label: 'Primary' }, { value: 'strong', label: 'Strong' }]} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="taskType" label="任务类型" initialValue="*">
|
||||||
|
<Input placeholder="* 表示所有" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="preferredProvider" label="首选 Provider" rules={[{ required: true }]}>
|
||||||
|
<Select options={[{ value: 'deepseek', label: 'DeepSeek' }, { value: 'minimax', label: 'MiniMax' }, { value: 'siliconflow', label: 'SiliconFlow' }]} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="preferredModel" label="首选模型" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="deepseek-v4-pro" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="fallbackProvider" label="备用 Provider" rules={[{ required: true }]}>
|
||||||
|
<Select options={[{ value: 'deepseek', label: 'DeepSeek' }, { value: 'minimax', label: 'MiniMax' }, { value: 'siliconflow', label: 'SiliconFlow' }]} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="fallbackModel" label="备用模型" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="deepseek-v4-flash" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="maxRetries" label="最大重试次数" initialValue={2}>
|
||||||
|
<InputNumber min={0} max={10} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'providers', label: 'Provider 管理',
|
||||||
|
children: <Table dataSource={providers || []} columns={providerColumns} rowKey="name" pagination={false} size="small" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'fallback', label: '降级日志',
|
||||||
|
children: <Table dataSource={fallbackEvents || []} columns={fallbackColumns} rowKey="id" pagination={{ pageSize: 20 }} size="small" />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@ -28,30 +169,7 @@ export default function AiGatewayPage() {
|
|||||||
<Title level={5} style={{ margin: 0 }}><CloudOutlined /> AI Gateway</Title>
|
<Title level={5} style={{ margin: 0 }}><CloudOutlined /> AI Gateway</Title>
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => qc.invalidateQueries({ queryKey: ['ai-gateway'] })}>刷新</Button>
|
<Button icon={<ReloadOutlined />} onClick={() => qc.invalidateQueries({ queryKey: ['ai-gateway'] })}>刷新</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
|
||||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
|
||||||
<Col span={6}><Card size="small"><Statistic title="Provider" value={data?.providers?.length || 0} suffix="个" /></Card></Col>
|
|
||||||
<Col span={6}><Card size="small"><Statistic title="Prompt 模板" value={data?.prompts?.length || 0} suffix="个" /></Card></Col>
|
|
||||||
<Col span={6}><Card size="small"><Statistic title="路由级别" value={3} suffix="层" /></Card></Col>
|
|
||||||
<Col span={6}><Card size="small"><Statistic title="重试次数" value={data?.retry?.primary || 3} suffix="次" /></Card></Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<Row gutter={[16, 16]}>
|
|
||||||
<Col span={12}>
|
|
||||||
<Card size="small" title="Provider 列表">
|
|
||||||
<Table dataSource={(data?.providers || []).map((p: string) => ({ name: p }))} columns={[{ title: '名称', dataIndex: 'name', render: (v: string) => <Tag color={v==='deepseek'?'blue':v==='minimax'?'purple':'green'}>{v}</Tag> }]} rowKey="name" pagination={false} size="small" />
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
<Col span={12}>
|
|
||||||
<Card size="small" title="模型路由">
|
|
||||||
<Table dataSource={tiers} columns={tierCols} rowKey="name" pagination={false} size="small" />
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<Card size="small" title="Prompt 模板" style={{ marginTop: 16 }}>
|
|
||||||
<Table dataSource={(data?.prompts || []).map((p: string) => ({ name: p }))} columns={[{ title: '名称', dataIndex: 'name', render: (v: string) => <Text code>{v}</Text> }]} rowKey="name" pagination={false} size="small" />
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Table, Button, Typography, App, Badge } from 'antd'
|
import { Table, Button, Typography, App, Badge, Card, Row, Col, Statistic, Space, Tabs, Popconfirm } from 'antd'
|
||||||
import { ReloadOutlined, RetweetOutlined, CloudServerOutlined } from '@ant-design/icons'
|
import { ReloadOutlined, RetweetOutlined, CloudServerOutlined, NodeIndexOutlined } from '@ant-design/icons'
|
||||||
import { getQueueOverview, getFailedJobs, retryJob } from '@/services/events-api'
|
import { api } from '@/services/http-client'
|
||||||
|
|
||||||
const { Title, Text } = Typography
|
const { Title, Text } = Typography
|
||||||
|
|
||||||
@ -10,20 +10,45 @@ function EventsPage() {
|
|||||||
const { message } = App.useApp()
|
const { message } = App.useApp()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const [selectedQueue, setSelectedQueue] = useState<string | null>(null)
|
const [selectedQueue, setSelectedQueue] = useState<string | null>(null)
|
||||||
|
const [activeTab, setActiveTab] = useState('queues')
|
||||||
|
|
||||||
|
const { data: overview } = useQuery({
|
||||||
|
queryKey: ['events', 'overview'],
|
||||||
|
queryFn: (): Promise<any> => api.get('/admin-api/events'),
|
||||||
|
staleTime: 10_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: stats } = useQuery({
|
||||||
|
queryKey: ['events', 'stats'],
|
||||||
|
queryFn: (): Promise<any> => api.get('/admin-api/events/stats'),
|
||||||
|
enabled: activeTab === 'stats',
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: workers } = useQuery({
|
||||||
|
queryKey: ['events', 'workers'],
|
||||||
|
queryFn: (): Promise<any> => api.get('/admin-api/events/workers'),
|
||||||
|
enabled: activeTab === 'stats',
|
||||||
|
refetchInterval: 15_000,
|
||||||
|
})
|
||||||
|
|
||||||
const { data: overview } = useQuery({ queryKey: ['events', 'overview'], queryFn: getQueueOverview, staleTime: 10_000 })
|
|
||||||
const { data: failed } = useQuery({
|
const { data: failed } = useQuery({
|
||||||
queryKey: ['events', 'failed', selectedQueue],
|
queryKey: ['events', 'failed', selectedQueue],
|
||||||
queryFn: () => selectedQueue ? getFailedJobs(selectedQueue) : null,
|
queryFn: () => selectedQueue ? api.get(`/admin-api/events/${selectedQueue}/failed`) : null,
|
||||||
enabled: !!selectedQueue,
|
enabled: !!selectedQueue,
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleRetry = async (queue: string, jobId: string) => {
|
const handleRetry = async (queue: string, jobId: string) => {
|
||||||
await retryJob(queue, jobId)
|
await api.post(`/admin-api/events/${queue}/jobs/${jobId}/retry`)
|
||||||
message.success('已重试')
|
message.success('已重试')
|
||||||
qc.invalidateQueries({ queryKey: ['events'] })
|
qc.invalidateQueries({ queryKey: ['events'] })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleBatchRetry = async (queue: string) => {
|
||||||
|
const res = await api.post(`/admin-api/events/${queue}/jobs/batch-retry`, { count: 50 })
|
||||||
|
message.success(`批量重试完成: ${res.data?.retried || 0}/${res.data?.total || 0}`)
|
||||||
|
qc.invalidateQueries({ queryKey: ['events'] })
|
||||||
|
}
|
||||||
|
|
||||||
const overviewColumns = [
|
const overviewColumns = [
|
||||||
{ title: '队列', dataIndex: 'name', width: 160 },
|
{ title: '队列', dataIndex: 'name', width: 160 },
|
||||||
{ title: '总计', dataIndex: 'total', width: 60, align: 'center' as const },
|
{ title: '总计', dataIndex: 'total', width: 60, align: 'center' as const },
|
||||||
@ -31,7 +56,7 @@ function EventsPage() {
|
|||||||
{ title: '进行中', dataIndex: 'active', width: 70, align: 'center' as const, render: (v: number) => <Badge count={v} showZero color="processing" /> },
|
{ title: '进行中', dataIndex: 'active', width: 70, align: 'center' as const, render: (v: number) => <Badge count={v} showZero color="processing" /> },
|
||||||
{ title: '完成', dataIndex: 'completed', width: 60, align: 'center' as const, render: (v: number) => <Text type="success">{v}</Text> },
|
{ title: '完成', dataIndex: 'completed', width: 60, align: 'center' as const, render: (v: number) => <Text type="success">{v}</Text> },
|
||||||
{ title: '失败', dataIndex: 'failed', width: 60, align: 'center' as const, render: (v: number, r: any) => (
|
{ title: '失败', dataIndex: 'failed', width: 60, align: 'center' as const, render: (v: number, r: any) => (
|
||||||
v > 0 ? <Button type="link" size="small" danger onClick={() => setSelectedQueue(r.name)}>{v}</Button> : <Text type="secondary">0</Text>
|
v > 0 ? <Button type="link" size="small" danger onClick={() => { setSelectedQueue(r.name); setActiveTab('queues') }}>{v}</Button> : <Text type="secondary">0</Text>
|
||||||
)},
|
)},
|
||||||
{ title: '延迟', dataIndex: 'delayed', width: 60, align: 'center' as const },
|
{ title: '延迟', dataIndex: 'delayed', width: 60, align: 'center' as const },
|
||||||
]
|
]
|
||||||
@ -46,24 +71,76 @@ function EventsPage() {
|
|||||||
)},
|
)},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const statColumns = [
|
||||||
|
{ title: '任务类型', dataIndex: 'label', width: 140 },
|
||||||
|
{ title: '类型标识', dataIndex: 'type', width: 160, render: (v: string) => <Text code>{v}</Text> },
|
||||||
|
{ title: '最大重试', dataIndex: 'maxRetries', width: 80, align: 'center' as const },
|
||||||
|
{ title: '超时(ms)', dataIndex: 'timeoutMs', width: 100, align: 'center' as const },
|
||||||
|
{ title: '7天失败数', dataIndex: 'failureCount7d', width: 100, align: 'center' as const, render: (v: number) => v > 0 ? <Badge count={v} color="red" /> : <Text type="secondary">0</Text> },
|
||||||
|
]
|
||||||
|
|
||||||
|
const workerColumns = [
|
||||||
|
{ title: 'Worker 名称', dataIndex: 'name', width: 180 },
|
||||||
|
{ title: '状态', dataIndex: 'status', width: 80, render: (v: string) => <Badge status={v === 'online' ? 'success' : v === 'offline' ? 'error' : 'warning'} text={v} /> },
|
||||||
|
{ title: '最后心跳', dataIndex: 'lastSeen', width: 200 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const totalFailed = overview?.queues?.reduce((s: number, q: any) => s + q.failed, 0) || 0
|
||||||
|
const totalWaiting = overview?.queues?.reduce((s: number, q: any) => s + q.waiting, 0) || 0
|
||||||
|
const totalCompleted = overview?.queues?.reduce((s: number, q: any) => s + q.completed, 0) || 0
|
||||||
|
|
||||||
|
const tabItems = [
|
||||||
|
{
|
||||||
|
key: 'queues', label: '队列概览',
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<Table dataSource={overview?.queues || []} columns={overviewColumns} rowKey="name" pagination={false} style={{ marginBottom: 24 }} />
|
||||||
|
|
||||||
|
{selectedQueue && (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||||
|
<Title level={5} style={{ margin: 0, fontSize: 14 }}>失败任务 · {selectedQueue}</Title>
|
||||||
|
<Space>
|
||||||
|
<Popconfirm title="确认重试所有失败任务?" onConfirm={() => handleBatchRetry(selectedQueue)}>
|
||||||
|
<Button size="small" type="primary" ghost icon={<RetweetOutlined />}>批量重试</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
<Button size="small" onClick={() => setSelectedQueue(null)}>关闭</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
<Table dataSource={failed?.jobs || []} columns={failedColumns} rowKey="id" pagination={false} size="small" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'stats', label: '任务统计',
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||||
|
<Col span={6}><Card size="small"><Statistic title="失败任务" value={totalFailed} valueStyle={{ color: totalFailed > 0 ? '#ff4d4f' : undefined }} /></Card></Col>
|
||||||
|
<Col span={6}><Card size="small"><Statistic title="等待中" value={totalWaiting} /></Card></Col>
|
||||||
|
<Col span={6}><Card size="small"><Statistic title="已完成" value={totalCompleted} /></Card></Col>
|
||||||
|
<Col span={6}><Card size="small"><Statistic title="Worker 节点" value={workers?.count || 0} suffix="个" /></Card></Col>
|
||||||
|
</Row>
|
||||||
|
<Card size="small" title="任务类型配置" style={{ marginBottom: 16 }}>
|
||||||
|
<Table dataSource={stats?.taskStats || []} columns={statColumns} rowKey="type" pagination={false} size="small" />
|
||||||
|
</Card>
|
||||||
|
<Card size="small" title={<><NodeIndexOutlined /> Worker 状态</>}>
|
||||||
|
<Table dataSource={workers?.workers || []} columns={workerColumns} rowKey="name" pagination={false} size="small" />
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
<Title level={5} style={{ margin: 0 }}><CloudServerOutlined /> 事件队列</Title>
|
<Title level={5} style={{ margin: 0 }}><CloudServerOutlined /> 事件队列</Title>
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => qc.invalidateQueries({ queryKey: ['events'] })}>刷新</Button>
|
<Button icon={<ReloadOutlined />} onClick={() => qc.invalidateQueries({ queryKey: ['events'] })}>刷新</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
|
||||||
<Table dataSource={overview?.queues || []} columns={overviewColumns} rowKey="name" pagination={false} style={{ marginBottom: 24 }} />
|
|
||||||
|
|
||||||
{selectedQueue && (
|
|
||||||
<>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}>
|
|
||||||
<Title level={5} style={{ margin: 0, fontSize: 14 }}>失败任务 · {selectedQueue}</Title>
|
|
||||||
<Button size="small" onClick={() => setSelectedQueue(null)}>关闭</Button>
|
|
||||||
</div>
|
|
||||||
<Table dataSource={failed?.jobs || []} columns={failedColumns} rowKey="id" pagination={false} size="small" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
82
src/pages/VectorAdmin.tsx
Normal file
82
src/pages/VectorAdmin.tsx
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Card, Row, Col, Statistic, Button, Typography, Descriptions, App } from 'antd'
|
||||||
|
import { ReloadOutlined, DatabaseOutlined, NodeIndexOutlined } from '@ant-design/icons'
|
||||||
|
import { api } from '@/services/http-client'
|
||||||
|
|
||||||
|
const { Title } = Typography
|
||||||
|
|
||||||
|
export default function VectorAdminPage() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const { message } = App.useApp()
|
||||||
|
|
||||||
|
const { data: coll, isLoading: collLoading } = useQuery({
|
||||||
|
queryKey: ['vector', 'collection'],
|
||||||
|
queryFn: (): Promise<any> => api.get('/admin-api/vector/collection'),
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: count } = useQuery({
|
||||||
|
queryKey: ['vector', 'count'],
|
||||||
|
queryFn: (): Promise<any> => api.get('/admin-api/vector/count'),
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleReindex = async () => {
|
||||||
|
try {
|
||||||
|
await api.post('/admin-api/vector/reindex')
|
||||||
|
message.success('索引重建已提交')
|
||||||
|
qc.invalidateQueries({ queryKey: ['vector'] })
|
||||||
|
} catch {
|
||||||
|
message.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
|
<Title level={5} style={{ margin: 0 }}><DatabaseOutlined /> 向量检索</Title>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={() => qc.invalidateQueries({ queryKey: ['vector'] })}>刷新</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card size="small"><Statistic title="向量总数" value={count?.count ?? 0} loading={collLoading} suffix="条" /></Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card size="small"><Statistic title="向量维度" value={coll?.vectorSize || 1024} /></Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card size="small"><Statistic title="距离度量" value={coll?.distance || 'Cosine'} /></Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card size="small"><Statistic title="状态" value={coll?.status || '-'} valueStyle={{ color: coll?.status === 'green' ? '#52c41a' : '#faad14' }} /></Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col span={16}>
|
||||||
|
<Card size="small" title="Collection 信息">
|
||||||
|
<Descriptions column={2} size="small" bordered>
|
||||||
|
<Descriptions.Item label="名称">{coll?.name || 'zhixi_chunks'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="向量维度">{coll?.vectorSize || 1024}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="距离度量">{coll?.distance || 'Cosine'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="向量总数">{count?.count ?? 0}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="索引算法">HNSW (m=16, ef_construct=100)</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Payload 索引">userId (keyword), knowledgeBaseId (keyword), deleted (bool)</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Card size="small" title="索引操作">
|
||||||
|
<Button icon={<NodeIndexOutlined />} block onClick={handleReindex} style={{ marginBottom: 8 }}>
|
||||||
|
触发索引重建
|
||||||
|
</Button>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
索引重建会通过任务队列异步执行,不影响线上检索。建议在低峰期操作。
|
||||||
|
</Typography.Text>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user