diff --git a/frontend/src/pages/alerts/components/NotifySection.tsx b/frontend/src/components/NotifySection.tsx similarity index 100% rename from frontend/src/pages/alerts/components/NotifySection.tsx rename to frontend/src/components/NotifySection.tsx diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 76c2ac0..29bfeed 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -16,4 +16,5 @@ export { getStatusColorClass } from './status-config'; export { default as ErrorBoundary } from './ErrorBoundary'; export { TimeRangePicker } from './TimeRangePicker'; export { StatCard } from './StatCard'; +export { NotifySection } from './NotifySection'; export { SearchableValueSelect } from './SearchableValueSelect' diff --git a/frontend/src/i18n/domains.ts b/frontend/src/i18n/domains.ts index 34c73f3..e5c2ca9 100644 --- a/frontend/src/i18n/domains.ts +++ b/frontend/src/i18n/domains.ts @@ -15,6 +15,8 @@ import zhAnalysis from './locales/zh/analysis' import enAnalysis from './locales/en/analysis' import zhLogs from './locales/zh/logs' import enLogs from './locales/en/logs' +import zhSettings from './locales/zh/settings' +import enSettings from './locales/en/settings' import zhAudit from './locales/zh/audit' import enAudit from './locales/en/audit' @@ -29,6 +31,8 @@ const DOMAIN_BUNDLES: ReadonlyArray<[string, string, object]> = [ ['en', 'analysis', enAnalysis], ['zh', 'logs', zhLogs], ['en', 'logs', enLogs], + ['zh', 'settings', zhSettings], + ['en', 'settings', enSettings], ['zh', 'audit', zhAudit], ['en', 'audit', enAudit], ] diff --git a/frontend/src/pages/alerts/components/RuleFormDialog.tsx b/frontend/src/pages/alerts/components/RuleFormDialog.tsx new file mode 100644 index 0000000..3891748 --- /dev/null +++ b/frontend/src/pages/alerts/components/RuleFormDialog.tsx @@ -0,0 +1,261 @@ +import { memo, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { Dialog, DialogContent, DialogHeader,DialogTitle } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Switch } from '@/components/ui/switch' +import { alertsApi, type AlertRule, type NotifyChannel } from '@/api/alerts' +import type { LogSource } from '@/api/logs' +import { useForm } from 'react-hook-form' + +interface RuleForm { + name: string + sourceId: string + channelIds: string[] + query: string + windowMin: string + threshold: string + cooldownMin: string + enabled: boolean +} + +const DEFAULTS: RuleForm = { + name: '', sourceId: '', channelIds: [], query: '{namespace=~".+"} |= "error"', + windowMin: '15', threshold: '0', cooldownMin: '60', enabled: true, +} + +interface AlertTemplate { + key: 'nsError' | 'allNsError' | 'ingress5xx' | 'ingress4xx' | 'fatal' + query: string + windowMin: number + threshold: number + cooldownMin: number +} + +/** 常用告警模板 — 只做预填,应用后可改 namespace / 阈值 / 窗口(label/desc 走 i18n) */ +const TEMPLATES: AlertTemplate[] = [ + { + key: 'nsError', + query: '{namespace="astralx"} |~ "(?i)error"', + windowMin: 5, threshold: 10, cooldownMin: 30, + }, + { + key: 'allNsError', + query: '{namespace=~".+"} |~ "(?i)error"', + windowMin: 15, threshold: 1000, cooldownMin: 60, + }, + { + key: 'ingress5xx', + query: '{namespace="ingress-nginx"} | json | status >= 500', + windowMin: 5, threshold: 5, cooldownMin: 15, + }, + { + key: 'ingress4xx', + query: '{namespace="ingress-nginx"} | json | status >= 400 | status < 500', + windowMin: 5, threshold: 50, cooldownMin: 30, + }, + { + key: 'fatal', + query: '{namespace=~".+"} |= "FATAL"', + windowMin: 5, threshold: 0, cooldownMin: 15, + }, +] + + +/** 规则新建/编辑弹窗:表单、模板与提交逻辑从 alerts/index.tsx 拆出 */ +export const RuleFormDialog = memo(function RuleFormDialog({ open, onOpenChange, editing, sources, channels, onSaved }: { + open: boolean + onOpenChange: (open: boolean) => void + editing: AlertRule | null + sources: LogSource[] + channels: NotifyChannel[] + onSaved: () => void +}) { + const { t } = useTranslation('alerts') + const [submitting, setSubmitting] = useState(false) + const form = useForm({ defaultValues: DEFAULTS }) + + useEffect(() => { + if (!open) return + if (editing) { + form.reset({ + name: editing.name, sourceId: String(editing.sourceId), channelIds: (editing.channelIds || []).map(String), query: editing.query, + windowMin: String(editing.windowMin), threshold: String(editing.threshold), + cooldownMin: String(editing.cooldownMin), enabled: editing.enabled, + }) + } else { + form.reset({ ...DEFAULTS, sourceId: sources[0] ? String(sources[0].id) : '', channelIds: channels.filter(c => c.enabled).slice(0, 1).map(c => String(c.id)) }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, editing]) + + const applyTemplate = (tpl: AlertTemplate) => { + form.setValue('name', t(`tpl.${tpl.key}.label`)) + form.setValue('query', tpl.query) + form.setValue('windowMin', String(tpl.windowMin)) + form.setValue('threshold', String(tpl.threshold)) + form.setValue('cooldownMin', String(tpl.cooldownMin)) + } + + const onSubmit = form.handleSubmit(async (v) => { + setSubmitting(true) + try { + const payload = { + name: v.name, sourceId: Number(v.sourceId), channelIds: v.channelIds.map(Number).filter(Boolean), query: v.query, + windowMin: Number(v.windowMin) || 15, + threshold: Number(v.threshold) || 0, + cooldownMin: Number(v.cooldownMin) || 60, + enabled: v.enabled, + } + if (editing) await alertsApi.updateRule(editing.id, payload) + else await alertsApi.createRule(payload) + onOpenChange(false) + onSaved() + } catch (err) { + toast.error(err instanceof Error ? err.message : t('saveFailed')) + } finally { + setSubmitting(false) + } + }) + + return ( + + + + + {editing ? t('editTitle', { name: editing.name }) : t('createTitle')} + + +
+ + {!editing && ( +
+

{t('templates')}

+
+ {TEMPLATES.map((tpl) => ( + + ))} +
+
+ )} +
+ ( + + {t('form.name')} + + + + )} /> + ( + + {t('form.source')} + + + + )} /> +
+ ( + + {t('form.query')} + + + + +

+ {t('form.queryHint')} +

+
+ )} /> + ( + + {t('form.channels')} +
+ {channels.length === 0 &&

{t('form.noChannels')}

} + {channels.map((ch) => { + const checked = field.value.includes(String(ch.id)) + return ( + + ) + })} +
+ +
+ )} /> + {/* 三个字段必须等高:之前只有「触发阈值」带一行提示, + 于是中间那格比其他两格高,提示文字悬在底部,看起来像没对齐。 + 现在每格都给一行同结构提示(min-h 固定高度),底边自然齐平。 */} +
+ ( + + {t('form.window')} + +

{t('form.windowHint')}

+ +
+ )} /> + ( + + {t('form.threshold')} + +

{t('form.thresholdHint')}

+ +
+ )} /> + ( + + {t('form.cooldown')} + +

{t('form.cooldownHint')}

+ +
+ )} /> +
+ ( + + + {t('form.enabled')} + + )} /> +
+ + +
+ + +
+
+ ) +}) diff --git a/frontend/src/pages/alerts/index.tsx b/frontend/src/pages/alerts/index.tsx index a8ce3ca..5c664a4 100644 --- a/frontend/src/pages/alerts/index.tsx +++ b/frontend/src/pages/alerts/index.tsx @@ -1,21 +1,11 @@ import { memo, useCallback, useEffect, useState } from 'react' import { BellRing, Plus, Play, Pencil, Trash2 } from 'lucide-react' import { Button } from '@/components/ui/button' -import { Checkbox } from '@/components/ui/checkbox' -import { Input } from '@/components/ui/input' -import { Switch } from '@/components/ui/switch' import { Card, CardContent } from '@/components/ui/card' +import { Switch } from '@/components/ui/switch' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { NotifySection } from './components/NotifySection' -import { - Dialog, DialogContent, DialogHeader, DialogTitle, -} from '@/components/ui/dialog' -import { - Form, FormControl, FormField, FormItem, FormLabel, FormMessage, -} from '@/components/ui/form' -import { - Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from '@/components/ui/select' +import { NotifySection } from '@/components/NotifySection' +import { RuleFormDialog } from './components/RuleFormDialog' import { PageContainer } from '@/components/PageContainer' import { EmptyState } from '@/components/EmptyState' import { ConfirmDialog } from '@/components/ConfirmDialog' @@ -23,62 +13,8 @@ import { alertsApi, type AlertRule, type AlertDelivery, type NotifyChannel } fro import { logsApi, type LogSource } from '@/api/logs' import { systemApi } from '@/api/system' import { toast } from 'sonner' -import { useForm } from 'react-hook-form' import { useTranslation } from 'react-i18next' -interface RuleForm { - name: string - sourceId: string - channelIds: string[] - query: string - windowMin: string - threshold: string - cooldownMin: string - enabled: boolean -} - -const DEFAULTS: RuleForm = { - name: '', sourceId: '', channelIds: [], query: '{namespace=~".+"} |= "error"', - windowMin: '15', threshold: '0', cooldownMin: '60', enabled: true, -} - -interface AlertTemplate { - key: 'nsError' | 'allNsError' | 'ingress5xx' | 'ingress4xx' | 'fatal' - query: string - windowMin: number - threshold: number - cooldownMin: number -} - -/** 常用告警模板 — 只做预填,应用后可改 namespace / 阈值 / 窗口(label/desc 走 i18n) */ -const TEMPLATES: AlertTemplate[] = [ - { - key: 'nsError', - query: '{namespace="astralx"} |~ "(?i)error"', - windowMin: 5, threshold: 10, cooldownMin: 30, - }, - { - key: 'allNsError', - query: '{namespace=~".+"} |~ "(?i)error"', - windowMin: 15, threshold: 1000, cooldownMin: 60, - }, - { - key: 'ingress5xx', - query: '{namespace="ingress-nginx"} | json | status >= 500', - windowMin: 5, threshold: 5, cooldownMin: 15, - }, - { - key: 'ingress4xx', - query: '{namespace="ingress-nginx"} | json | status >= 400 | status < 500', - windowMin: 5, threshold: 50, cooldownMin: 30, - }, - { - key: 'fatal', - query: '{namespace=~".+"} |= "FATAL"', - windowMin: 5, threshold: 0, cooldownMin: 15, - }, -] - /** 告警规则页:规则 CRUD + 立即检查 + 发送历史 */ const AlertsPage = memo(function AlertsPage() { const { t } = useTranslation(['alerts', 'common']) @@ -96,9 +32,7 @@ const AlertsPage = memo(function AlertsPage() { const [tab, setTab] = useState<'rules' | 'channels' | 'history'>('rules') const [deleting, setDeleting] = useState(false) const [checking, setChecking] = useState(false) - const [submitting, setSubmitting] = useState(false) - const form = useForm({ defaultValues: DEFAULTS }) const refresh = useCallback(async () => { try { @@ -122,49 +56,14 @@ const AlertsPage = memo(function AlertsPage() { const openCreate = () => { setEditing(null) - form.reset({ ...DEFAULTS, sourceId: sources[0] ? String(sources[0].id) : '', channelIds: channels.filter(c => c.enabled).slice(0, 1).map(c => String(c.id)) }) setOpen(true) } const openEdit = (rule: AlertRule) => { setEditing(rule) - form.reset({ - name: rule.name, sourceId: String(rule.sourceId), channelIds: (rule.channelIds || []).map(String), query: rule.query, - windowMin: String(rule.windowMin), threshold: String(rule.threshold), - cooldownMin: String(rule.cooldownMin), enabled: rule.enabled, - }) setOpen(true) } - const applyTemplate = (tpl: AlertTemplate) => { - form.setValue('name', t(`tpl.${tpl.key}.label`)) - form.setValue('query', tpl.query) - form.setValue('windowMin', String(tpl.windowMin)) - form.setValue('threshold', String(tpl.threshold)) - form.setValue('cooldownMin', String(tpl.cooldownMin)) - } - - const onSubmit = form.handleSubmit(async (v) => { - setSubmitting(true) - try { - const payload = { - name: v.name, sourceId: Number(v.sourceId), channelIds: v.channelIds.map(Number).filter(Boolean), query: v.query, - windowMin: Number(v.windowMin) || 15, - threshold: Number(v.threshold) || 0, - cooldownMin: Number(v.cooldownMin) || 60, - enabled: v.enabled, - } - if (editing) await alertsApi.updateRule(editing.id, payload) - else await alertsApi.createRule(payload) - setOpen(false) - void refresh() - } catch (err) { - toast.error(err instanceof Error ? err.message : t('saveFailed')) - } finally { - setSubmitting(false) - } - }) - const handleDelete = async () => { if (!deleteTarget) return setDeleting(true) @@ -358,140 +257,7 @@ const AlertsPage = memo(function AlertsPage() { {/* 规则弹窗 */} - - - - - {editing ? t('editTitle', { name: editing.name }) : t('createTitle')} - - -
- - {!editing && ( -
-

{t('templates')}

-
- {TEMPLATES.map((tpl) => ( - - ))} -
-
- )} -
- ( - - {t('form.name')} - - - - )} /> - ( - - {t('form.source')} - - - - )} /> -
- ( - - {t('form.query')} - - - - -

- {t('form.queryHint')} -

-
- )} /> - ( - - {t('form.channels')} -
- {channels.length === 0 &&

{t('form.noChannels')}

} - {channels.map((ch) => { - const checked = field.value.includes(String(ch.id)) - return ( - - ) - })} -
- -
- )} /> - {/* 三个字段必须等高:之前只有「触发阈值」带一行提示, - 于是中间那格比其他两格高,提示文字悬在底部,看起来像没对齐。 - 现在每格都给一行同结构提示(min-h 固定高度),底边自然齐平。 */} -
- ( - - {t('form.window')} - -

{t('form.windowHint')}

- -
- )} /> - ( - - {t('form.threshold')} - -

{t('form.thresholdHint')}

- -
- )} /> - ( - - {t('form.cooldown')} - -

{t('form.cooldownHint')}

- -
- )} /> -
- ( - - - {t('form.enabled')} - - )} /> -
- - -
- - -
-
+ void refresh()} /> new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 19) const ns = (ms: number) => Math.round(ms).toString() + '000000' @@ -220,15 +221,22 @@ export default function LogsPage() { } catch (e) { if (!quiet && version === requestVersion.current) setError(e instanceof Error ? e.message : t('toolbar.queryFailed')) } finally { if (version === requestVersion.current) { setLoading(false); setLoadingStartedAt(null) } } } + // tail 期间编辑 LogQL 不逐键触发:防抖 500ms 后才让轮询换查询 + const tailQuery = useDebouncedValue(query, 500) useEffect(() => { - if (!tail || !sourceId || !query.trim()) return + if (!tail || !sourceId || !tailQuery.trim()) return + // 取消 + 版本号双防护:停止 tail/换查询时在途请求作废;慢响应不覆盖新一轮 + const controller = new AbortController() + let version = 0 const poll = async () => { const now = Date.now() + const current = ++version setEnd(toLocal(new Date(now))) setStart(toLocal(new Date(now - 300000))) try { - const result = await logsApi.query({ sourceId, query, start: Number(ns(now - 300000)), end: Number(ns(now)), limit, direction: 'backward' }) - setExecutedQuery(query) + const result = await logsApi.query({ sourceId, query: tailQuery, start: Number(ns(now - 300000)), end: Number(ns(now)), limit, direction: 'backward' }, { signal: controller.signal }) + if (current !== version) return + setExecutedQuery(tailQuery) setSearched(true) setEntries(result.entries) setCursor(result.hasMore ? result.nextCursor : '') @@ -237,8 +245,8 @@ export default function LogsPage() { } void poll() const timer = window.setInterval(() => void poll(), 3000) - return () => window.clearInterval(timer) - }, [tail, sourceId, query, limit]) + return () => { controller.abort(); window.clearInterval(timer) } + }, [tail, sourceId, tailQuery, limit]) /** 上下文前后条数:默认 5 条(Java 一个异常常拆成 4 条日志,2 条拼不完整),选择后记住 */ const [contextLines, setContextLines] = useState(() => { diff --git a/frontend/src/pages/settings/components/NotifySection.tsx b/frontend/src/pages/settings/components/NotifySection.tsx deleted file mode 100644 index 9e9ecf5..0000000 --- a/frontend/src/pages/settings/components/NotifySection.tsx +++ /dev/null @@ -1,246 +0,0 @@ -import { memo, useCallback, useEffect, useState } from 'react' -import { Link } from 'react-router' -import { useTranslation } from 'react-i18next' -import { BellRing, Plus, Pencil, Trash2, Send } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Switch } from '@/components/ui/switch' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { - Dialog, DialogContent, DialogHeader, DialogTitle, -} from '@/components/ui/dialog' -import { PasswordInput } from '@/components/PasswordInput' -import { ConfirmDialog } from '@/components/ConfirmDialog' -import { alertsApi, type NotifyChannel } from '@/api/alerts' -import { toast } from 'sonner' - -interface ChannelForm { - name: string - webhook: string - signingSecret: string - enabled: boolean -} - -/** - * 通知通道(飞书/Lark)— 多 webhook 对应不同告警群。 - * webhook 加密存储(留空=保留旧值),每通道可独立测试/禁用/删除(有关联规则时禁删)。 - */ -export const NotifySection = memo(function NotifySection() { - const { t } = useTranslation('settings') - const [channels, setChannels] = useState([]) - const [open, setOpen] = useState(false) - const [editing, setEditing] = useState(null) - const [form, setForm] = useState({ name: '', webhook: '', signingSecret: '', enabled: true }) - const [saving, setSaving] = useState(false) - const [testing, setTesting] = useState(false) - const [deleteTarget, setDeleteTarget] = useState(null) - const [deleting, setDeleting] = useState(false) - - const refresh = useCallback(async () => { - try { - const list = await alertsApi.listChannels() - setChannels(Array.isArray(list) ? list : []) - } catch (err) { - toast.error(err instanceof Error ? err.message : t('notify.errLoad')) - } - }, [t]) - - useEffect(() => { - void refresh() - }, [refresh]) - - const openCreate = () => { - setEditing(null) - setForm({ name: '', webhook: '', signingSecret: '', enabled: true }) - setOpen(true) - } - - const openEdit = (ch: NotifyChannel) => { - setEditing(ch) - setForm({ name: ch.name, webhook: '', signingSecret: '', enabled: ch.enabled }) - setOpen(true) - } - - const handleSave = async () => { - if (!form.name.trim()) { - toast.error(t('notify.errNameRequired')) - return - } - if (!editing && !form.webhook) { - toast.error(t('notify.errWebhookRequired')) - return - } - setSaving(true) - try { - if (editing) { - await alertsApi.updateChannel(editing.id, { - name: form.name, - webhook: form.webhook || undefined, - signingSecret: form.signingSecret || undefined, - enabled: form.enabled, - }) - } else { - await alertsApi.saveChannel({ - name: form.name, - webhook: form.webhook, - signingSecret: form.signingSecret || undefined, - enabled: form.enabled, - }) - } - setOpen(false) - void refresh() - } catch (err) { - toast.error(err instanceof Error ? err.message : t('notify.errSave')) - } finally { - setSaving(false) - } - } - - const handleTest = async (ch: NotifyChannel) => { - setTesting(true) - try { - await alertsApi.testChannel({ id: ch.id }) - toast.success(t('notify.testSent', { name: ch.name })) - } catch (err) { - toast.error(err instanceof Error ? err.message : t('notify.errSend')) - } finally { - setTesting(false) - } - } - - const handleToggle = async (ch: NotifyChannel, enabled: boolean) => { - try { - await alertsApi.updateChannel(ch.id, { name: ch.name, enabled }) - void refresh() - } catch (err) { - toast.error(err instanceof Error ? err.message : t('notify.errToggle')) - } - } - - const handleDelete = async () => { - if (!deleteTarget) return - setDeleting(true) - try { - await alertsApi.deleteChannel(deleteTarget.id) - setDeleteTarget(null) - void refresh() - } catch (err) { - toast.error(err instanceof Error ? err.message : t('notify.errDelete')) - } finally { - setDeleting(false) - } - } - - return ( - - - - - - {t('notify.cardTitle')} - - - - - - {channels.length === 0 ? ( -

- {t('notify.emptyBefore')} - {t('notify.emptyLink')} - {t('notify.emptyAfter')} -

- ) : ( - channels.map((ch) => ( -
- void handleToggle(ch, v)} /> -
-

{ch.name}

-

- {ch.hasWebhook ? t('notify.webhookConfigured') : t('notify.webhookMissing')} - {ch.hasSecret ? t('notify.secretConfigured') : ''} -

-
-
- - - -
-
- )) - )} -
- - - - - - {editing ? t('notify.editTitle', { name: editing.name }) : t('notify.createTitle')} - - -
-
- - setForm((f) => ({ ...f, name: e.target.value }))} - /> -
-
- - setForm((f) => ({ ...f, webhook: e.target.value }))} - /> -
-
- - setForm((f) => ({ ...f, signingSecret: e.target.value }))} - /> -
-
- setForm((f) => ({ ...f, enabled: v }))} /> - -
-
- - -
-
-
-
- - { if (!o) setDeleteTarget(null) }} - name={deleteTarget?.name} - title={t('notify.deleteTitle')} - loading={deleting} - onConfirm={handleDelete} - /> -
- ) -})