refactor(quality): Q29 前端巨型组件拆分 — AlertCenter 542L→312L (-42%), 提取 4 个受控组件 (AlertsToolbar 89L 筛选工具栏 + AlertsTab 149L 告警列表表格+分页 + ContactsTab 87L 联系人卡片网格 + ContactGroupsTab 88L 联系组卡片网格), 客户端搜索过滤逻辑移入 AlertsTab, 删除确认逻辑下沉到子组件, 零逻辑变更, ESLint errors=0/warnings=65/any=0, tsc -b=0, npm build✓ (18.71s), go build=0, 17/17 测试通过

This commit is contained in:
attacker committed 2026-07-09 12:23:59 +08:00
1 parent 4c44fdde8b
commit 803bffd0e3
7 files changed
+496 -275

No files matched your search

+1 -1
View File
@@ -129,7 +129,7 @@
| ~~`api.ts` 1324 行/352 方法~~ → 已拆分为 12 个域文件 (api/kubernetes.ts, deploy.ts, monitor.ts 等) | ✅ 已解决 | 前端 | ~~P0~~ done | | ~~`api.ts` 1324 行/352 方法~~ → 已拆分为 12 个域文件 (api/kubernetes.ts, deploy.ts, monitor.ts 等) | ✅ 已解决 | 前端 | ~~P0~~ done |
| 前端 **1026 处** `any` 类型 | 类型安全缺失,重构风险高 | 前端全局 | ~~P0~~ **done** (any 1076→40, unused 25→6, console 93→1, tsc --noEmit=0) | | 前端 **1026 处** `any` 类型 | 类型安全缺失,重构风险高 | 前端全局 | ~~P0~~ **done** (any 1076→40, unused 25→6, console 93→1, tsc --noEmit=0) |
| ~~K8s 双路由体系(集群域+Kuboard 域)~~ → code 路由统一为主,ID 路由标记 deprecated 向后兼容 | ✅ 已解决 | 后端 | ~~P1~~ done | | ~~K8s 双路由体系(集群域+Kuboard 域)~~ → code 路由统一为主,ID 路由标记 deprecated 向后兼容 | ✅ 已解决 | 后端 | ~~P1~~ done |
| 25 个 ≥500 行巨型组件 | 拆分困难 | 前端 | P1 → **Q6-Q27 done: 最大组件 ResourceVisualEditor 882L→282L (-68%) + AgentCLI 814L→521L (-36%) + SystemSettingsTab 754L→173L (-77%) + NamespaceResourcePage 719L→300L (-58%) + ApprovalWorkflowPage 705L→167L (-76%) + PipelineRunDetail 678L→273L (-60%) + CloudServerImport 699L→404L (-42%,any[]→具体接口) + UnifiedQueryPanel 682L→529L (-22%) + ServerManager 652L→442L (-32%) + PipelineEditor 676L→406L (-40%) + RdpDialog 642L→442L (-31%) + DeploymentEditService 627L→394L (-37%) + TraceManager 605L→331L (-45%,修复 SpanTreeNode 内联定义反模式) + GrafanaDashboard 624L→78L (-87%,DRY 合并 Zabbix/Prometheus Tab + 移除文件级 eslint-disable + 类型安全升级) + TopologyView 596L→472L (-21%) + DeploymentEditAdvanced 590L→289L (-51%) + WorkloadViewPage 572L→348L (-39%) + CloudAccountManager 572L→308L + ExcelServerImport 560L→385L + PipelineCreateDialog 559L→498L + PodLogsViewer 553L→331L (-40%) + **HostMonitor 547L→224L (-59%,提取 host-monitor/tabs/ 8 个文件 + TABS/PCT_REF_LINES 常量提取为模块级)** + **KubernetesClusterDetail 547L→130L (-76%,提取 cluster-detail/ 8 个文件 + computeClusterMetrics/buildTrendData 纯函数提取)**,累计提取 99 个独立组件文件** | | 25 个 ≥500 行巨型组件 | 拆分困难 | 前端 | P1 → **Q6-Q27 done: 最大组件 ResourceVisualEditor 882L→282L (-68%) + AgentCLI 814L→521L (-36%) + SystemSettingsTab 754L→173L (-77%) + NamespaceResourcePage 719L→300L (-58%) + ApprovalWorkflowPage 705L→167L (-76%) + PipelineRunDetail 678L→273L (-60%) + CloudServerImport 699L→404L (-42%,any[]→具体接口) + UnifiedQueryPanel 682L→529L (-22%) + ServerManager 652L→442L (-32%) + PipelineEditor 676L→406L (-40%) + RdpDialog 642L→442L (-31%) + DeploymentEditService 627L→394L (-37%) + TraceManager 605L→331L (-45%,修复 SpanTreeNode 内联定义反模式) + GrafanaDashboard 624L→78L (-87%,DRY 合并 Zabbix/Prometheus Tab + 移除文件级 eslint-disable + 类型安全升级) + TopologyView 596L→472L (-21%) + DeploymentEditAdvanced 590L→289L (-51%) + WorkloadViewPage 572L→348L (-39%) + CloudAccountManager 572L→308L + ExcelServerImport 560L→385L + PipelineCreateDialog 559L→498L + PodLogsViewer 553L→331L (-40%) + HostMonitor 547L→224L (-59%) + KubernetesClusterDetail 547L→130L (-76%) + **AlertCenter 542L→312L (-42%,提取 4 个受控组件: AlertsToolbar + AlertsTab + ContactsTab + ContactGroupsTab,客户端搜索过滤+删除确认逻辑下沉到子组件)**,累计提取 103 个独立组件文件** |
### 3.2 🟡 中优先级 ### 3.2 🟡 中优先级
+38
View File
@@ -500,6 +500,44 @@ services/api.ts (1324L)
| Q26 | 前端巨型组件拆分(PodLogsViewer 553L→331L,提取 pod-logs-viewer/types(highlightKeyword 纯函数) + LogsHeader + ControlBar + LogContentArea + BottomStatusBar) | P0 | **done** | | Q26 | 前端巨型组件拆分(PodLogsViewer 553L→331L,提取 pod-logs-viewer/types(highlightKeyword 纯函数) + LogsHeader + ControlBar + LogContentArea + BottomStatusBar) | P0 | **done** |
| Q27 | 前端巨型组件拆分(HostMonitor 547L→224L (-59%),提取 host-monitor/tabs/ 8 个文件 (BasicTab + DiskTab + NetworkTab + PortMonitorTab + ProcessTable + FDUsageCard + ContextSwitchCard + ProcessTab),TABS 常量数组提取为模块级,PCT_REF_LINES 常量提升到 types.ts,端口/进程数组类型 JsonData→JsonData[] 类型安全升级) | P0 | **done** | | Q27 | 前端巨型组件拆分(HostMonitor 547L→224L (-59%),提取 host-monitor/tabs/ 8 个文件 (BasicTab + DiskTab + NetworkTab + PortMonitorTab + ProcessTable + FDUsageCard + ContextSwitchCard + ProcessTab),TABS 常量数组提取为模块级,PCT_REF_LINES 常量提升到 types.ts,端口/进程数组类型 JsonData→JsonData[] 类型安全升级) | P0 | **done** |
| Q28 | 前端巨型组件拆分(KubernetesClusterDetail 547L→130L (-76%),提取 cluster-detail/ 8 个文件 (types(computeClusterMetrics/buildTrendData 纯函数) + OverviewTab + NodesTab + PodsTab + DeploymentsTab + ServicesTab + IngressesTab + EventsTab)) | P0 | **done** | | Q28 | 前端巨型组件拆分(KubernetesClusterDetail 547L→130L (-76%),提取 cluster-detail/ 8 个文件 (types(computeClusterMetrics/buildTrendData 纯函数) + OverviewTab + NodesTab + PodsTab + DeploymentsTab + ServicesTab + IngressesTab + EventsTab)) | P0 | **done** |
| Q29 | 前端巨型组件拆分(AlertCenter 542L→312L (-42%),提取 4 个受控组件 (AlertsToolbar 89L 筛选工具栏 + AlertsTab 149L 告警列表表格+分页 + ContactsTab 87L 联系人卡片网格 + ContactGroupsTab 88L 联系组卡片网格),客户端搜索过滤逻辑移入 AlertsTab,删除确认逻辑下沉到子组件) | P0 | **done** |
---
## Q29: 前端巨型组件拆分 — AlertCenter [done]
**问题**: `AlertCenter.tsx`(542 行)是 Q28 拆分后项目最大的核心业务组件之一(排除 UI 库 sidebar/data-table),包含告警中心主页面的全部编排逻辑:6 个 Tab(告警列表/告警规则/指标目录/报警联系人/报警联系组/通知策略)、~20 个 useState 状态管理、TanStack Query hooks(useAlerts/useUnifiedAlertRules/useMetricCatalog/useAlertMutations)、告警操作(确认/解决/静默)、联系人/联系组 CRUD、以及 4 块大段内联 JSX(筛选工具栏 ~56 行 + 告警列表表格+分页 ~110 行 + 联系人卡片网格 ~48 行 + 联系组卡片网格 ~48 行)。单文件过大导致维护困难。
**已完成 (2026-07-09)**:
将筛选工具栏、告警列表表格、联系人 Tab、联系组 Tab 提取为 `alert-center/` 目录下的独立文件:
| 新文件 | 行数 | 职责 |
|--------|------|------|
| `AlertsToolbar.tsx` | 89 | 告警筛选工具栏(搜索框 + 严重级别/状态/来源 3 个 Select 下拉 + 总数 + 刷新按钮),受控展示组件接收 9 个 props(searchQuery/onSearchChange + 3 筛选值 + onFilterChange + total/fetchingAlerts/onRefresh) |
| `AlertsTab.tsx` | 149 | 告警列表 Tab(Card + 表格表头 + loading/空状态 + 告警行渲染(标题/描述/标签徽章/严重级别 Badge/状态 Badge/来源/持续时间/操作按钮)+ 分页控件),受控组件接收 11 个 props(alerts/loading/searchQuery/nowMs/total/totalPages/page + 4 回调) |
| `ContactsTab.tsx` | 87 | 报警联系人 Tab(空状态 + 卡片网格,每张卡片含名称/手机/邮箱/渠道类型徽章/Webhook URL + 编辑/删除按钮 + useConfirm 二次确认),受控展示组件接收 contacts + onEdit + onDelete 回调 |
| `ContactGroupsTab.tsx` | 88 | 报警联系组 Tab(空状态 + 卡片网格,每张卡片含组名/人数徽章/备注/成员名称徽章列表 + 编辑/删除按钮 + useConfirm 二次确认),受控展示组件接收 contactGroups/contacts(用于解析成员名)+ onEdit + onDelete 回调 |
主文件 `AlertCenter.tsx` 从 **542 行降至 312 行**(-42%),仅保留:~20 个 useState 状态声明、TanStack Query hooks 编排、alertParams useMemo、fetchContacts/fetchContactGroups 数据加载、告警操作(handleAcknowledge/handleResolve/handleSilence/handleQuickSetup)、告警规则删除(handleDeleteAgentRule/handleDeleteMonitorRule)、handleFilterChange、pageTabs 配置、nowMs、PageShell 布局(StatStrip + 6 Tab 条件渲染分发 + actions 按钮组)、4 个 Dialog 挂载(AlertDetailDialog/AlertSilenceDialog/ContactDialog/ContactGroupDialog)。
**关键改进**:
- **客户端搜索逻辑下沉**: 原 `alerts.filter(alert => { ... searchQuery ... })` 内联在主组件 JSX 的 `.map()` 前,提取到 `AlertsTab` 组件内部作为 `filteredAlerts` 派生值,主组件不再感知搜索过滤逻辑。
- **删除确认逻辑下沉**: 原 ContactsTab/ContactGroupsTab 的删除按钮内联 `useConfirm` + `alertApi.deleteContact` + `toast` + `fetchContacts` 逻辑,提取到子组件内部,主组件只需传入 `onDelete(id)` async 回调。
- **受控组件架构**: 4 个子组件全部为受控展示组件(接收状态 + 回调 props),无独立状态。所有状态管理保留在主组件,拆分为纯机械操作(移动 JSX + 添加 props 透传),零逻辑变更。
**验收标准**:
- [x] AlertCenter.tsx 从 542 行降至 **312 行**(-42%)
- [x] 4 个提取的文件全部 < 150 行(最大 AlertsTab 149 行)
- [x] `tsc -b` 零错误
- [x] `npm run build` 零错误(✓ built in 18.71s)
- [x] ESLint errors 保持 **0**(5 个文件零 errors/warnings)
- [x] ESLint warnings 保持 **65**(无新增)
- [x] 全局 ESLint any 保持 **0**
- [x] `go build` 零错误
- [x] 17/17 集成回归测试通过(零回归)
- [x] Docker 部署 healthy,前端页面正常加载(HTTP 200)
- [x] 告警中心页功能不变(6 个 Tab:告警列表筛选+表格+分页/告警规则/指标目录/报警联系人/报警联系组/通知策略全部正常)
--- ---
@@ -1,34 +1,25 @@
// 告警中心主组件 — TanStack Query 编排 // Logan.Li // 告警中心主组件 — TanStack Query 编排 // Logan.Li
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { import { AlertTriangle, RefreshCw, Plus } from 'lucide-react';
AlertTriangle, CheckCircle, Search, RefreshCw,
Eye, Check, VolumeX, Plus,
} from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { StatStrip } from '@/components/ui/stat-strip'; import { StatStrip } from '@/components/ui/stat-strip';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { PageShell } from '@/components/layout/PageShell'; import { PageShell } from '@/components/layout/PageShell';
import { PageEmpty } from '@/components/layout/PageEmpty';
import { PageLoading } from '@/components/layout/PageLoading';
import { useConfirm } from '@/components/ui/confirm-dialog'; import { useConfirm } from '@/components/ui/confirm-dialog';
import { alertCenterApi, alertApi } from '@/api'; import { alertCenterApi, alertApi } from '@/api';
import { useAlerts, useUnifiedAlertRules, useMetricCatalog, useAlertMutations } from '@/hooks/api'; import { useAlerts, useUnifiedAlertRules, useMetricCatalog, useAlertMutations } from '@/hooks/api';
import { import { type Alert } from './alert-center-helpers';
type Alert, getSeverityBadge, getStatusBadge, getChannelTypeLabel,
} from './alert-center-helpers';
import { AlertDetailDialog, AlertSilenceDialog } from './AlertCenterDetail'; import { AlertDetailDialog, AlertSilenceDialog } from './AlertCenterDetail';
import { MetricCatalogTab } from './MetricCatalogTab'; import { MetricCatalogTab } from './MetricCatalogTab';
import { AlertRulesTab } from './AlertRulesTab'; import { AlertRulesTab } from './AlertRulesTab';
import { ContactDialog } from './ContactDialog'; import { ContactDialog } from './ContactDialog';
import { ContactGroupDialog } from './ContactGroupDialog'; import { ContactGroupDialog } from './ContactGroupDialog';
import { NotifyPolicyTab } from './NotifyPolicyTab'; import { NotifyPolicyTab } from './NotifyPolicyTab';
import AlertsToolbar from './AlertsToolbar';
import AlertsTab from './AlertsTab';
import ContactsTab from './ContactsTab';
import ContactGroupsTab from './ContactGroupsTab';
import type { UnifiedAlertRule } from '@/api'; import type { UnifiedAlertRule } from '@/api';
import type { Contact, ContactGroup } from './alert-center-helpers'; import type { Contact, ContactGroup } from './alert-center-helpers';
@@ -181,63 +172,6 @@ const AlertCenter: React.FC = () => {
{ id: 'notify', label: t('monitor.alertCenterPanel.notifyPolicy') }, { id: 'notify', label: t('monitor.alertCenterPanel.notifyPolicy') },
]; ];
const alertsToolbar = activeTab === 'alerts' ? (
<div className="flex flex-wrap items-center justify-between gap-2 w-full">
<div className="flex items-center gap-2 flex-1 min-w-[280px]">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
placeholder={t('alert.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 h-8 text-xs"
/>
</div>
<Select value={severityFilter} onValueChange={(v) => handleFilterChange('severity', v)}>
<SelectTrigger className="w-[100px] h-8 text-xs">
<SelectValue placeholder={t('alert.table.severity')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('alert.allSeverity')}</SelectItem>
<SelectItem value="critical">{t('alert.severity.critical')}</SelectItem>
<SelectItem value="warning">{t('alert.severity.warning')}</SelectItem>
<SelectItem value="info">{t('alert.severity.info')}</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={(v) => handleFilterChange('status', v)}>
<SelectTrigger className="w-[100px] h-8 text-xs">
<SelectValue placeholder={t('common.status')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('alert.allStatus')}</SelectItem>
<SelectItem value="firing">{t('alert.status.firing')}</SelectItem>
<SelectItem value="acknowledged">{t('alert.status.acknowledged')}</SelectItem>
<SelectItem value="resolved">{t('alert.status.resolved')}</SelectItem>
</SelectContent>
</Select>
<Select value={sourceFilter} onValueChange={(v) => handleFilterChange('source', v)}>
<SelectTrigger className="w-[120px] h-8 text-xs">
<SelectValue placeholder={t('monitor.alertCenterPanel.sourcePlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('monitor.alertCenterPanel.allSources')}</SelectItem>
<SelectItem value="host">{t('monitor.alertCenterPanel.hostMonitor')}</SelectItem>
<SelectItem value="middleware">{t('monitor.alertCenterPanel.middlewareMonitor')}</SelectItem>
<SelectItem value="snmp">{t('monitor.alertCenterPanel.snmpMonitor')}</SelectItem>
<SelectItem value="site">{t('monitor.alertCenterPanel.siteProbe')}</SelectItem>
<SelectItem value="log">{t('monitor.alertCenterPanel.logAlert')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-1 text-xs dg-text-muted">
<span>{t('alert.totalItems', { count: total })}</span>
<Button variant="outline" size="sm" onClick={() => refetchAlerts()} disabled={fetchingAlerts} className="h-7 text-xs px-2 dg-btn-secondary">
<RefreshCw className={cn('w-3 h-3 mr-1', fetchingAlerts && 'animate-spin')} />{t('common.refresh')}
</Button>
</div>
</div>
) : undefined;
// eslint-disable-next-line react-hooks/purity // eslint-disable-next-line react-hooks/purity
const nowMs = Date.now(); const nowMs = Date.now();
@@ -250,7 +184,19 @@ const AlertCenter: React.FC = () => {
tabs={pageTabs} tabs={pageTabs}
activeTab={activeTab} activeTab={activeTab}
onTabChange={setActiveTab} onTabChange={setActiveTab}
toolbar={alertsToolbar} toolbar={activeTab === 'alerts' ? (
<AlertsToolbar
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
severityFilter={severityFilter}
statusFilter={statusFilter}
sourceFilter={sourceFilter}
onFilterChange={handleFilterChange}
total={total}
fetchingAlerts={fetchingAlerts}
onRefresh={() => refetchAlerts()}
/>
) : undefined}
metrics={( metrics={(
<StatStrip <StatStrip
items={[ items={[
@@ -281,115 +227,20 @@ const AlertCenter: React.FC = () => {
) : undefined} ) : undefined}
> >
{activeTab === 'alerts' && ( {activeTab === 'alerts' && (
<Card> <AlertsTab
<CardContent className="p-0"> alerts={alerts}
<div className="overflow-x-auto"> loading={loading}
<table className="w-full text-sm"> searchQuery={searchQuery}
<thead className="bg-muted/50"> nowMs={nowMs}
<tr> total={total}
<th className="px-3 py-2 text-left text-xs font-medium">{t('alert.table.alertInfo')}</th> totalPages={totalPages}
<th className="px-3 py-2 text-left text-xs font-medium w-16">{t('alert.table.severity')}</th> page={page}
<th className="px-3 py-2 text-left text-xs font-medium w-16">{t('alert.table.status')}</th> onAcknowledge={handleAcknowledge}
<th className="px-3 py-2 text-left text-xs font-medium w-24">{t('alert.table.source')}</th> onResolve={handleResolve}
<th className="px-3 py-2 text-left text-xs font-medium w-20">{t('alert.table.duration')}</th> onOpenDetail={(alert) => { setSelectedAlert(alert); setDetailOpen(true); }}
<th className="px-3 py-2 text-left text-xs font-medium w-28">{t('alert.table.actions')}</th> onOpenSilence={(alert) => { setSelectedAlert(alert); setSilenceOpen(true); }}
</tr> onPageChange={setPage}
</thead> />
<tbody className="divide-y">
{loading ? (
<tr><td colSpan={6} className="px-3 py-2"><PageLoading label={t('alert.loading')} /></td></tr>
) : alerts.length === 0 ? (
<tr><td colSpan={6} className="px-3 py-2"><PageEmpty title={t('alert.noAlerts')} className="py-6" /></td></tr>
) : (
alerts.filter(alert => {
// 客户端也保留关键词搜索过滤
if (!searchQuery) return true;
const q = searchQuery.toLowerCase();
return alert.title.toLowerCase().includes(q) ||
alert.description.toLowerCase().includes(q) ||
alert.source.toLowerCase().includes(q);
}).map((alert) => (
<tr key={alert.id} className="hover:bg-muted/50">
<td className="px-3 py-2">
<div className="font-medium text-xs">{alert.title}</div>
<div className="text-xs text-muted-foreground line-clamp-1">{alert.description}</div>
<div className="flex gap-1 mt-0.5">
{Object.entries(alert.labels).slice(0, 2).map(([k, v]) => (
<Badge key={k} variant="outline" className="text-[10px] px-1 py-0">{k}={v}</Badge>
))}
</div>
</td>
<td className="px-3 py-2">{getSeverityBadge(alert.severity)}</td>
<td className="px-3 py-2">{getStatusBadge(alert.status)}</td>
<td className="px-3 py-2 text-xs">
<div>{alert.source}</div>
<div className="text-muted-foreground text-[10px]">{alert.service}</div>
</td>
<td className="px-3 py-2 text-xs text-muted-foreground">
{alert.status === 'resolved' && alert.resolvedAt
? t('alert.minutesShort', { count: Math.floor((new Date(alert.resolvedAt).getTime() - new Date(alert.startedAt).getTime()) / 60000) })
: t('alert.minutesShort', { count: Math.floor((nowMs - new Date(alert.startedAt).getTime()) / 60000) })}
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-0.5">
<Button variant="ghost" size="sm" className="h-7 w-7 p-0"
onClick={() => { setSelectedAlert(alert); setDetailOpen(true); }}>
<Eye className="w-3.5 h-3.5" />
</Button>
{alert.status === 'firing' && (
<Button variant="ghost" size="sm" className="h-7 w-7 p-0"
onClick={() => handleAcknowledge(alert.id)}>
<Check className="w-3.5 h-3.5 dg-text-blue" />
</Button>
)}
{(alert.status === 'firing' || alert.status === 'acknowledged') && (
<Button variant="ghost" size="sm" className="h-7 w-7 p-0"
onClick={() => handleResolve(alert.id)}>
<CheckCircle className="w-3.5 h-3.5 text-green-600" />
</Button>
)}
<Button variant="ghost" size="sm" className="h-7 w-7 p-0"
onClick={() => { setSelectedAlert(alert); setSilenceOpen(true); }}>
<VolumeX className="w-3.5 h-3.5 text-orange-600" />
</Button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* 分页控件 */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-3 py-2 border-t text-xs">
<span className="text-muted-foreground">{t('alert.pageInfo', { page, totalPages, total })}</span>
<div className="flex items-center gap-1">
<Button variant="outline" size="sm" className="h-7 text-xs px-2"
disabled={page <= 1} onClick={() => setPage(p => p - 1)}>
{t('alert.prevPage')}
</Button>
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
const start = Math.max(1, Math.min(page - 2, totalPages - 4));
const pn = start + i;
if (pn > totalPages) return null;
return (
<Button key={pn} variant={pn === page ? 'default' : 'outline'} size="sm"
className="h-7 min-w-[28px] text-xs px-1"
onClick={() => setPage(pn)}>
{pn}
</Button>
);
})}
<Button variant="outline" size="sm" className="h-7 text-xs px-2"
disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>
{t('alert.nextPage')}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
)} )}
{activeTab === 'rules' && ( {activeTab === 'rules' && (
@@ -411,103 +262,22 @@ const AlertCenter: React.FC = () => {
{activeTab === 'metrics' && <MetricCatalogTab />} {activeTab === 'metrics' && <MetricCatalogTab />}
{activeTab === 'contacts' && ( {activeTab === 'contacts' && (
contacts.length === 0 ? ( <ContactsTab
<PageEmpty title={t('monitor.alertCenterPanel.noContactsTitle')} description={t('monitor.alertCenterPanel.noContactsDesc')} /> contacts={contacts}
) : ( onEdit={(c) => { setEditingContact(c); setContactDialogOpen(true); }}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> onDelete={async (id) => { await alertApi.deleteContact(id); fetchContacts(); }}
{contacts.map((c) => ( />
<Card key={c.id} className="hover:border-primary/50 transition-colors">
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<CardTitle className="text-sm">{c.name}</CardTitle>
</div>
</CardHeader>
<CardContent className="pt-0 space-y-1 text-xs text-muted-foreground">
{c.phone && <div>📱 {c.phone}</div>}
{c.email && <div>📧 {c.email}</div>}
<div className="flex gap-1 flex-wrap">
{c.channel_type && (
<Badge variant="outline" className="text-[10px]">
{getChannelTypeLabel(c.channel_type)}
</Badge>
)}
{c.webhook_url && (
<span className="text-[10px] text-muted-foreground truncate max-w-[200px]" title={c.webhook_url}>
{c.webhook_url}
</span>
)}
</div>
<div className="flex gap-1.5 pt-1.5">
<Button variant="outline" size="sm" className="h-7 text-xs flex-1"
onClick={() => { setEditingContact(c); setContactDialogOpen(true); }}>
{t("monitor.alertCenterPanel.edit")}
</Button>
<Button variant="outline" size="sm" className="h-7 text-xs"
onClick={async () => {
const ok = await _confirm({ title: t('alert.deleteContact'), description: `${c.name}`, confirmText: t('common.delete'), variant: 'destructive' });
if (!ok) return;
try { await alertApi.deleteContact(c.id); toast.success(t('alert.deleted')); fetchContacts(); }
catch { toast.error(t('alert.deleteFailed')); }
}}>
{t("monitor.alertCenterPanel.delete")}
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)
)} )}
{activeTab === 'notify' && <NotifyPolicyTab />} {activeTab === 'notify' && <NotifyPolicyTab />}
{activeTab === 'groups' && ( {activeTab === 'groups' && (
contactGroups.length === 0 ? ( <ContactGroupsTab
<PageEmpty title={t('monitor.alertCenterPanel.noGroupsTitle')} description={t('monitor.alertCenterPanel.noGroupsDesc')} /> contactGroups={contactGroups}
) : ( contacts={contacts}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> onEdit={(g) => { setEditingGroup(g); setGroupDialogOpen(true); }}
{contactGroups.map((g) => { onDelete={async (id) => { await alertApi.deleteContactGroup(id); fetchContactGroups(); }}
let cids: number[] = []; />
try { cids = JSON.parse(g.contact_ids || '[]'); } catch { /* ignore */ }
const memberNames = contacts
.filter(c => cids.includes(c.id))
.map(c => c.name);
return (
<Card key={g.id} className="hover:border-primary/50 transition-colors">
<CardHeader>
<div className="flex items-start justify-between">
<CardTitle className="text-base">{g.group_name}</CardTitle>
<Badge variant="secondary">{t('alert.peopleCount', { count: cids.length })}</Badge>
</div>
</CardHeader>
<CardContent className="pt-0 space-y-2">
{g.notes && <p className="text-sm text-muted-foreground">{g.notes}</p>}
<div className="flex flex-wrap gap-1">
{memberNames.map(name => (
<Badge key={name} variant="outline" className="text-xs">{name}</Badge>
))}
</div>
<div className="flex gap-2 pt-2">
<Button variant="outline" size="sm" className="flex-1"
onClick={() => { setEditingGroup(g); setGroupDialogOpen(true); }}>
{t("monitor.alertCenterPanel.edit")}
</Button>
<Button variant="outline" size="sm"
onClick={async () => {
const ok = await _confirm({ title: t('alert.deleteGroup'), description: `${g.group_name}`, confirmText: t('common.delete'), variant: 'destructive' });
if (!ok) return;
try { await alertApi.deleteContactGroup(g.id); toast.success(t('alert.deleted')); fetchContactGroups(); }
catch { toast.error(t('alert.deleteFailed')); }
}}>
{t("monitor.alertCenterPanel.delete")}
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
)
)} )}
<AlertDetailDialog <AlertDetailDialog
@@ -0,0 +1,149 @@
// 告警中心 — 告警列表 Tab(表格 + 分页)
// Logan.Li — 从 AlertCenter 提取的受控展示组件
import React from 'react';
import { Eye, Check, CheckCircle, VolumeX } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { PageEmpty } from '@/components/layout/PageEmpty';
import { PageLoading } from '@/components/layout/PageLoading';
import { useTranslation } from 'react-i18next';
import { type Alert, getSeverityBadge, getStatusBadge } from './alert-center-helpers';
export interface AlertsTabProps {
alerts: Alert[];
loading: boolean;
searchQuery: string;
nowMs: number;
total: number;
totalPages: number;
page: number;
onAcknowledge: (id: string) => void;
onResolve: (id: string) => void;
onOpenDetail: (alert: Alert) => void;
onOpenSilence: (alert: Alert) => void;
onPageChange: (page: number) => void;
}
const AlertsTab: React.FC<AlertsTabProps> = ({
alerts, loading, searchQuery, nowMs,
total, totalPages, page,
onAcknowledge, onResolve, onOpenDetail, onOpenSilence, onPageChange,
}) => {
const { t } = useTranslation();
const filteredAlerts = alerts.filter(alert => {
if (!searchQuery) return true;
const q = searchQuery.toLowerCase();
return alert.title.toLowerCase().includes(q) ||
alert.description.toLowerCase().includes(q) ||
alert.source.toLowerCase().includes(q);
});
return (
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium">{t('alert.table.alertInfo')}</th>
<th className="px-3 py-2 text-left text-xs font-medium w-16">{t('alert.table.severity')}</th>
<th className="px-3 py-2 text-left text-xs font-medium w-16">{t('alert.table.status')}</th>
<th className="px-3 py-2 text-left text-xs font-medium w-24">{t('alert.table.source')}</th>
<th className="px-3 py-2 text-left text-xs font-medium w-20">{t('alert.table.duration')}</th>
<th className="px-3 py-2 text-left text-xs font-medium w-28">{t('alert.table.actions')}</th>
</tr>
</thead>
<tbody className="divide-y">
{loading ? (
<tr><td colSpan={6} className="px-3 py-2"><PageLoading label={t('alert.loading')} /></td></tr>
) : filteredAlerts.length === 0 ? (
<tr><td colSpan={6} className="px-3 py-2"><PageEmpty title={t('alert.noAlerts')} className="py-6" /></td></tr>
) : (
filteredAlerts.map((alert) => (
<tr key={alert.id} className="hover:bg-muted/50">
<td className="px-3 py-2">
<div className="font-medium text-xs">{alert.title}</div>
<div className="text-xs text-muted-foreground line-clamp-1">{alert.description}</div>
<div className="flex gap-1 mt-0.5">
{Object.entries(alert.labels).slice(0, 2).map(([k, v]) => (
<Badge key={k} variant="outline" className="text-[10px] px-1 py-0">{k}={v}</Badge>
))}
</div>
</td>
<td className="px-3 py-2">{getSeverityBadge(alert.severity)}</td>
<td className="px-3 py-2">{getStatusBadge(alert.status)}</td>
<td className="px-3 py-2 text-xs">
<div>{alert.source}</div>
<div className="text-muted-foreground text-[10px]">{alert.service}</div>
</td>
<td className="px-3 py-2 text-xs text-muted-foreground">
{alert.status === 'resolved' && alert.resolvedAt
? t('alert.minutesShort', { count: Math.floor((new Date(alert.resolvedAt).getTime() - new Date(alert.startedAt).getTime()) / 60000) })
: t('alert.minutesShort', { count: Math.floor((nowMs - new Date(alert.startedAt).getTime()) / 60000) })}
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-0.5">
<Button variant="ghost" size="sm" className="h-7 w-7 p-0"
onClick={() => onOpenDetail(alert)}>
<Eye className="w-3.5 h-3.5" />
</Button>
{alert.status === 'firing' && (
<Button variant="ghost" size="sm" className="h-7 w-7 p-0"
onClick={() => onAcknowledge(alert.id)}>
<Check className="w-3.5 h-3.5 dg-text-blue" />
</Button>
)}
{(alert.status === 'firing' || alert.status === 'acknowledged') && (
<Button variant="ghost" size="sm" className="h-7 w-7 p-0"
onClick={() => onResolve(alert.id)}>
<CheckCircle className="w-3.5 h-3.5 text-green-600" />
</Button>
)}
<Button variant="ghost" size="sm" className="h-7 w-7 p-0"
onClick={() => onOpenSilence(alert)}>
<VolumeX className="w-3.5 h-3.5 text-orange-600" />
</Button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* 分页控件 */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-3 py-2 border-t text-xs">
<span className="text-muted-foreground">{t('alert.pageInfo', { page, totalPages, total })}</span>
<div className="flex items-center gap-1">
<Button variant="outline" size="sm" className="h-7 text-xs px-2"
disabled={page <= 1} onClick={() => onPageChange(page - 1)}>
{t('alert.prevPage')}
</Button>
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
const start = Math.max(1, Math.min(page - 2, totalPages - 4));
const pn = start + i;
if (pn > totalPages) return null;
return (
<Button key={pn} variant={pn === page ? 'default' : 'outline'} size="sm"
className="h-7 min-w-[28px] text-xs px-1"
onClick={() => onPageChange(pn)}>
{pn}
</Button>
);
})}
<Button variant="outline" size="sm" className="h-7 text-xs px-2"
disabled={page >= totalPages} onClick={() => onPageChange(page + 1)}>
{t('alert.nextPage')}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
);
};
export default AlertsTab;
@@ -0,0 +1,89 @@
// 告警中心 — 筛选工具栏(搜索 + 严重级别/状态/来源下拉 + 刷新)
// Logan.Li — 从 AlertCenter 提取的受控展示组件
import React from 'react';
import { Search, RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { useTranslation } from 'react-i18next';
export interface AlertsToolbarProps {
searchQuery: string;
onSearchChange: (value: string) => void;
severityFilter: string;
statusFilter: string;
sourceFilter: string;
onFilterChange: (type: 'severity' | 'status' | 'source', value: string) => void;
total: number;
fetchingAlerts: boolean;
onRefresh: () => void;
}
const AlertsToolbar: React.FC<AlertsToolbarProps> = ({
searchQuery, onSearchChange,
severityFilter, statusFilter, sourceFilter, onFilterChange,
total, fetchingAlerts, onRefresh,
}) => {
const { t } = useTranslation();
return (
<div className="flex flex-wrap items-center justify-between gap-2 w-full">
<div className="flex items-center gap-2 flex-1 min-w-[280px]">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
placeholder={t('alert.searchPlaceholder')}
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-8 h-8 text-xs"
/>
</div>
<Select value={severityFilter} onValueChange={(v) => onFilterChange('severity', v)}>
<SelectTrigger className="w-[100px] h-8 text-xs">
<SelectValue placeholder={t('alert.table.severity')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('alert.allSeverity')}</SelectItem>
<SelectItem value="critical">{t('alert.severity.critical')}</SelectItem>
<SelectItem value="warning">{t('alert.severity.warning')}</SelectItem>
<SelectItem value="info">{t('alert.severity.info')}</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={(v) => onFilterChange('status', v)}>
<SelectTrigger className="w-[100px] h-8 text-xs">
<SelectValue placeholder={t('common.status')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('alert.allStatus')}</SelectItem>
<SelectItem value="firing">{t('alert.status.firing')}</SelectItem>
<SelectItem value="acknowledged">{t('alert.status.acknowledged')}</SelectItem>
<SelectItem value="resolved">{t('alert.status.resolved')}</SelectItem>
</SelectContent>
</Select>
<Select value={sourceFilter} onValueChange={(v) => onFilterChange('source', v)}>
<SelectTrigger className="w-[120px] h-8 text-xs">
<SelectValue placeholder={t('monitor.alertCenterPanel.sourcePlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('monitor.alertCenterPanel.allSources')}</SelectItem>
<SelectItem value="host">{t('monitor.alertCenterPanel.hostMonitor')}</SelectItem>
<SelectItem value="middleware">{t('monitor.alertCenterPanel.middlewareMonitor')}</SelectItem>
<SelectItem value="snmp">{t('monitor.alertCenterPanel.snmpMonitor')}</SelectItem>
<SelectItem value="site">{t('monitor.alertCenterPanel.siteProbe')}</SelectItem>
<SelectItem value="log">{t('monitor.alertCenterPanel.logAlert')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-1 text-xs dg-text-muted">
<span>{t('alert.totalItems', { count: total })}</span>
<Button variant="outline" size="sm" onClick={onRefresh} disabled={fetchingAlerts} className="h-7 text-xs px-2 dg-btn-secondary">
<RefreshCw className={cn('w-3 h-3 mr-1', fetchingAlerts && 'animate-spin')} />{t('common.refresh')}
</Button>
</div>
</div>
);
};
export default AlertsToolbar;
@@ -0,0 +1,88 @@
// 告警中心 — 报警联系组 Tab(卡片网格 + 成员徽章 + 编辑/删除)
// Logan.Li — 从 AlertCenter 提取的受控展示组件
import React from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { PageEmpty } from '@/components/layout/PageEmpty';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { type Contact, type ContactGroup } from './alert-center-helpers';
export interface ContactGroupsTabProps {
contactGroups: ContactGroup[];
contacts: Contact[];
onEdit: (group: ContactGroup) => void;
onDelete: (id: number) => Promise<void>;
}
const ContactGroupsTab: React.FC<ContactGroupsTabProps> = ({ contactGroups, contacts, onEdit, onDelete }) => {
const { t } = useTranslation();
const _confirm = useConfirm();
if (contactGroups.length === 0) {
return (
<PageEmpty
title={t('monitor.alertCenterPanel.noGroupsTitle')}
description={t('monitor.alertCenterPanel.noGroupsDesc')}
/>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{contactGroups.map((g) => {
let cids: number[] = [];
try { cids = JSON.parse(g.contact_ids || '[]'); } catch { /* ignore */ }
const memberNames = contacts
.filter(c => cids.includes(c.id))
.map(c => c.name);
return (
<Card key={g.id} className="hover:border-primary/50 transition-colors">
<CardHeader>
<div className="flex items-start justify-between">
<CardTitle className="text-base">{g.group_name}</CardTitle>
<Badge variant="secondary">{t('alert.peopleCount', { count: cids.length })}</Badge>
</div>
</CardHeader>
<CardContent className="pt-0 space-y-2">
{g.notes && <p className="text-sm text-muted-foreground">{g.notes}</p>}
<div className="flex flex-wrap gap-1">
{memberNames.map(name => (
<Badge key={name} variant="outline" className="text-xs">{name}</Badge>
))}
</div>
<div className="flex gap-2 pt-2">
<Button variant="outline" size="sm" className="flex-1"
onClick={() => onEdit(g)}>
{t('monitor.alertCenterPanel.edit')}
</Button>
<Button variant="outline" size="sm"
onClick={async () => {
const ok = await _confirm({
title: t('alert.deleteGroup'),
description: `${g.group_name}`,
confirmText: t('common.delete'),
variant: 'destructive',
});
if (!ok) return;
try {
await onDelete(g.id);
toast.success(t('alert.deleted'));
} catch {
toast.error(t('alert.deleteFailed'));
}
}}>
{t('monitor.alertCenterPanel.delete')}
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
);
};
export default ContactGroupsTab;
@@ -0,0 +1,87 @@
// 告警中心 — 报警联系人 Tab(卡片网格 + 编辑/删除)
// Logan.Li — 从 AlertCenter 提取的受控展示组件
import React from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { PageEmpty } from '@/components/layout/PageEmpty';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { type Contact, getChannelTypeLabel } from './alert-center-helpers';
export interface ContactsTabProps {
contacts: Contact[];
onEdit: (contact: Contact) => void;
onDelete: (id: number) => Promise<void>;
}
const ContactsTab: React.FC<ContactsTabProps> = ({ contacts, onEdit, onDelete }) => {
const { t } = useTranslation();
const _confirm = useConfirm();
if (contacts.length === 0) {
return (
<PageEmpty
title={t('monitor.alertCenterPanel.noContactsTitle')}
description={t('monitor.alertCenterPanel.noContactsDesc')}
/>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{contacts.map((c) => (
<Card key={c.id} className="hover:border-primary/50 transition-colors">
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<CardTitle className="text-sm">{c.name}</CardTitle>
</div>
</CardHeader>
<CardContent className="pt-0 space-y-1 text-xs text-muted-foreground">
{c.phone && <div>📱 {c.phone}</div>}
{c.email && <div>📧 {c.email}</div>}
<div className="flex gap-1 flex-wrap">
{c.channel_type && (
<Badge variant="outline" className="text-[10px]">
{getChannelTypeLabel(c.channel_type)}
</Badge>
)}
{c.webhook_url && (
<span className="text-[10px] text-muted-foreground truncate max-w-[200px]" title={c.webhook_url}>
{c.webhook_url}
</span>
)}
</div>
<div className="flex gap-1.5 pt-1.5">
<Button variant="outline" size="sm" className="h-7 text-xs flex-1"
onClick={() => onEdit(c)}>
{t('monitor.alertCenterPanel.edit')}
</Button>
<Button variant="outline" size="sm" className="h-7 text-xs"
onClick={async () => {
const ok = await _confirm({
title: t('alert.deleteContact'),
description: `${c.name}`,
confirmText: t('common.delete'),
variant: 'destructive',
});
if (!ok) return;
try {
await onDelete(c.id);
toast.success(t('alert.deleted'));
} catch {
toast.error(t('alert.deleteFailed'));
}
}}>
{t('monitor.alertCenterPanel.delete')}
</Button>
</div>
</CardContent>
</Card>
))}
</div>
);
};
export default ContactsTab;