refactor(frontend): Live Tail 重构 + alerts 拆分 + NotifySection 去重
- Live Tail:输入 500ms 防抖后才切换轮询查询;轮询 AbortController 取消 + 版本号丢弃乱序响应 - alerts/index 508→286 行:表单与模板拆出 RuleFormDialog(浏览器实测模板/通道/提交可用) - NotifySection 两份逐字节副本合一为共享组件;settings 词典注册进 domains(alerts 直访不再裸键)
This commit is contained in:
1 parent
572efc89a4
commit
a69142fbfe
7 files changed
+283
-489
No files matched your search
File renamed without changes.
@@ -16,4 +16,5 @@ export { getStatusColorClass } from './status-config';
|
|||||||
export { default as ErrorBoundary } from './ErrorBoundary';
|
export { default as ErrorBoundary } from './ErrorBoundary';
|
||||||
export { TimeRangePicker } from './TimeRangePicker';
|
export { TimeRangePicker } from './TimeRangePicker';
|
||||||
export { StatCard } from './StatCard';
|
export { StatCard } from './StatCard';
|
||||||
|
export { NotifySection } from './NotifySection';
|
||||||
export { SearchableValueSelect } from './SearchableValueSelect'
|
export { SearchableValueSelect } from './SearchableValueSelect'
|
||||||
@@ -15,6 +15,8 @@ import zhAnalysis from './locales/zh/analysis'
|
|||||||
import enAnalysis from './locales/en/analysis'
|
import enAnalysis from './locales/en/analysis'
|
||||||
import zhLogs from './locales/zh/logs'
|
import zhLogs from './locales/zh/logs'
|
||||||
import enLogs from './locales/en/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 zhAudit from './locales/zh/audit'
|
||||||
import enAudit from './locales/en/audit'
|
import enAudit from './locales/en/audit'
|
||||||
|
|
||||||
@@ -29,6 +31,8 @@ const DOMAIN_BUNDLES: ReadonlyArray<[string, string, object]> = [
|
|||||||
['en', 'analysis', enAnalysis],
|
['en', 'analysis', enAnalysis],
|
||||||
['zh', 'logs', zhLogs],
|
['zh', 'logs', zhLogs],
|
||||||
['en', 'logs', enLogs],
|
['en', 'logs', enLogs],
|
||||||
|
['zh', 'settings', zhSettings],
|
||||||
|
['en', 'settings', enSettings],
|
||||||
['zh', 'audit', zhAudit],
|
['zh', 'audit', zhAudit],
|
||||||
['en', 'audit', enAudit],
|
['en', 'audit', enAudit],
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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<RuleForm>({ 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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{editing ? t('editTitle', { name: editing.name }) : t('createTitle')}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={onSubmit} className="space-y-4">
|
||||||
|
{!editing && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">{t('templates')}</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{TEMPLATES.map((tpl) => (
|
||||||
|
<Button
|
||||||
|
key={tpl.key}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
title={`${t(`tpl.${tpl.key}.desc`)}\n${tpl.query}`}
|
||||||
|
onClick={() => applyTemplate(tpl)}
|
||||||
|
>
|
||||||
|
{t(`tpl.${tpl.key}.label`)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<FormField control={form.control} name="name" render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('form.name')}</FormLabel>
|
||||||
|
<FormControl><Input placeholder={t('form.namePlaceholder')} {...field} /></FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="sourceId" render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('form.source')}</FormLabel>
|
||||||
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
|
<FormControl><SelectTrigger><SelectValue placeholder={t('form.sourcePlaceholder')} /></SelectTrigger></FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{sources.map((s) => (
|
||||||
|
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
<FormField control={form.control} name="query" render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('form.query')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input className="font-mono" placeholder='{namespace="log"} |= "FATAL"' {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('form.queryHint')}
|
||||||
|
</p>
|
||||||
|
</FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="channelIds" render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('form.channels')}</FormLabel>
|
||||||
|
<div className="grid grid-cols-2 gap-2 rounded-md border bg-background p-3 max-h-40 overflow-auto">
|
||||||
|
{channels.length === 0 && <p className="text-xs text-muted-foreground col-span-2">{t('form.noChannels')}</p>}
|
||||||
|
{channels.map((ch) => {
|
||||||
|
const checked = field.value.includes(String(ch.id))
|
||||||
|
return (
|
||||||
|
<label key={ch.id} className={`flex items-center gap-2 text-sm cursor-pointer rounded-md border px-2.5 py-1.5 ${checked ? 'border-primary bg-primary/5 text-primary' : 'border-border'}`}>
|
||||||
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(v) => {
|
||||||
|
if (v) field.onChange([...field.value, String(ch.id)])
|
||||||
|
else field.onChange(field.value.filter((x) => x !== String(ch.id)))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{ch.name}
|
||||||
|
{!ch.enabled && <span className="text-xs text-muted-foreground">{t('form.channelDisabled')}</span>}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)} />
|
||||||
|
{/* 三个字段必须等高:之前只有「触发阈值」带一行提示,
|
||||||
|
于是中间那格比其他两格高,提示文字悬在底部,看起来像没对齐。
|
||||||
|
现在每格都给一行同结构提示(min-h 固定高度),底边自然齐平。 */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<FormField control={form.control} name="windowMin" render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-col">
|
||||||
|
<FormLabel>{t('form.window')}</FormLabel>
|
||||||
|
<FormControl><Input type="number" min={1} {...field} /></FormControl>
|
||||||
|
<p className="mt-1 min-h-4 text-xs text-muted-foreground">{t('form.windowHint')}</p>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="threshold" render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-col">
|
||||||
|
<FormLabel>{t('form.threshold')}</FormLabel>
|
||||||
|
<FormControl><Input type="number" min={0} {...field} /></FormControl>
|
||||||
|
<p className="mt-1 min-h-4 text-xs text-muted-foreground">{t('form.thresholdHint')}</p>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="cooldownMin" render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-col">
|
||||||
|
<FormLabel>{t('form.cooldown')}</FormLabel>
|
||||||
|
<FormControl><Input type="number" min={1} {...field} /></FormControl>
|
||||||
|
<p className="mt-1 min-h-4 text-xs text-muted-foreground">{t('form.cooldownHint')}</p>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
<FormField control={form.control} name="enabled" render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center gap-2 space-y-0">
|
||||||
|
<FormControl><Switch checked={field.value} onCheckedChange={field.onChange} /></FormControl>
|
||||||
|
<FormLabel>{t('form.enabled')}</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)} />
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>{t('common:dialog.cancel')}</Button>
|
||||||
|
<Button type="submit" disabled={submitting}>
|
||||||
|
{submitting ? t('saving') : editing ? t('save') : t('submitCreate')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -1,21 +1,11 @@
|
|||||||
import { memo, useCallback, useEffect, useState } from 'react'
|
import { memo, useCallback, useEffect, useState } from 'react'
|
||||||
import { BellRing, Plus, Play, Pencil, Trash2 } from 'lucide-react'
|
import { BellRing, Plus, Play, Pencil, Trash2 } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
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 { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { NotifySection } from './components/NotifySection'
|
import { NotifySection } from '@/components/NotifySection'
|
||||||
import {
|
import { RuleFormDialog } from './components/RuleFormDialog'
|
||||||
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 { PageContainer } from '@/components/PageContainer'
|
import { PageContainer } from '@/components/PageContainer'
|
||||||
import { EmptyState } from '@/components/EmptyState'
|
import { EmptyState } from '@/components/EmptyState'
|
||||||
import { ConfirmDialog } from '@/components/ConfirmDialog'
|
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 { logsApi, type LogSource } from '@/api/logs'
|
||||||
import { systemApi } from '@/api/system'
|
import { systemApi } from '@/api/system'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useForm } from 'react-hook-form'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
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 + 立即检查 + 发送历史 */
|
/** 告警规则页:规则 CRUD + 立即检查 + 发送历史 */
|
||||||
const AlertsPage = memo(function AlertsPage() {
|
const AlertsPage = memo(function AlertsPage() {
|
||||||
const { t } = useTranslation(['alerts', 'common'])
|
const { t } = useTranslation(['alerts', 'common'])
|
||||||
@@ -96,9 +32,7 @@ const AlertsPage = memo(function AlertsPage() {
|
|||||||
const [tab, setTab] = useState<'rules' | 'channels' | 'history'>('rules')
|
const [tab, setTab] = useState<'rules' | 'channels' | 'history'>('rules')
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
const [checking, setChecking] = useState(false)
|
const [checking, setChecking] = useState(false)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
|
||||||
|
|
||||||
const form = useForm<RuleForm>({ defaultValues: DEFAULTS })
|
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -122,49 +56,14 @@ const AlertsPage = memo(function AlertsPage() {
|
|||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setEditing(null)
|
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)
|
setOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const openEdit = (rule: AlertRule) => {
|
const openEdit = (rule: AlertRule) => {
|
||||||
setEditing(rule)
|
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)
|
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 () => {
|
const handleDelete = async () => {
|
||||||
if (!deleteTarget) return
|
if (!deleteTarget) return
|
||||||
setDeleting(true)
|
setDeleting(true)
|
||||||
@@ -358,140 +257,7 @@ const AlertsPage = memo(function AlertsPage() {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{/* 规则弹窗 */}
|
{/* 规则弹窗 */}
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<RuleFormDialog open={open} onOpenChange={setOpen} editing={editing} sources={sources} channels={channels} onSaved={() => void refresh()} />
|
||||||
<DialogContent className="max-w-2xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
{editing ? t('editTitle', { name: editing.name }) : t('createTitle')}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={onSubmit} className="space-y-4">
|
|
||||||
{!editing && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground">{t('templates')}</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{TEMPLATES.map((tpl) => (
|
|
||||||
<Button
|
|
||||||
key={tpl.key}
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 text-xs"
|
|
||||||
title={`${t(`tpl.${tpl.key}.desc`)}\n${tpl.query}`}
|
|
||||||
onClick={() => applyTemplate(tpl)}
|
|
||||||
>
|
|
||||||
{t(`tpl.${tpl.key}.label`)}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<FormField control={form.control} name="name" render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>{t('form.name')}</FormLabel>
|
|
||||||
<FormControl><Input placeholder={t('form.namePlaceholder')} {...field} /></FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)} />
|
|
||||||
<FormField control={form.control} name="sourceId" render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>{t('form.source')}</FormLabel>
|
|
||||||
<Select value={field.value} onValueChange={field.onChange}>
|
|
||||||
<FormControl><SelectTrigger><SelectValue placeholder={t('form.sourcePlaceholder')} /></SelectTrigger></FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
{sources.map((s) => (
|
|
||||||
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)} />
|
|
||||||
</div>
|
|
||||||
<FormField control={form.control} name="query" render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>{t('form.query')}</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input className="font-mono" placeholder='{namespace="log"} |= "FATAL"' {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t('form.queryHint')}
|
|
||||||
</p>
|
|
||||||
</FormItem>
|
|
||||||
)} />
|
|
||||||
<FormField control={form.control} name="channelIds" render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>{t('form.channels')}</FormLabel>
|
|
||||||
<div className="grid grid-cols-2 gap-2 rounded-md border bg-background p-3 max-h-40 overflow-auto">
|
|
||||||
{channels.length === 0 && <p className="text-xs text-muted-foreground col-span-2">{t('form.noChannels')}</p>}
|
|
||||||
{channels.map((ch) => {
|
|
||||||
const checked = field.value.includes(String(ch.id))
|
|
||||||
return (
|
|
||||||
<label key={ch.id} className={`flex items-center gap-2 text-sm cursor-pointer rounded-md border px-2.5 py-1.5 ${checked ? 'border-primary bg-primary/5 text-primary' : 'border-border'}`}>
|
|
||||||
<Checkbox
|
|
||||||
checked={checked}
|
|
||||||
onCheckedChange={(v) => {
|
|
||||||
if (v) field.onChange([...field.value, String(ch.id)])
|
|
||||||
else field.onChange(field.value.filter((x) => x !== String(ch.id)))
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{ch.name}
|
|
||||||
{!ch.enabled && <span className="text-xs text-muted-foreground">{t('form.channelDisabled')}</span>}
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)} />
|
|
||||||
{/* 三个字段必须等高:之前只有「触发阈值」带一行提示,
|
|
||||||
于是中间那格比其他两格高,提示文字悬在底部,看起来像没对齐。
|
|
||||||
现在每格都给一行同结构提示(min-h 固定高度),底边自然齐平。 */}
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
|
||||||
<FormField control={form.control} name="windowMin" render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-col">
|
|
||||||
<FormLabel>{t('form.window')}</FormLabel>
|
|
||||||
<FormControl><Input type="number" min={1} {...field} /></FormControl>
|
|
||||||
<p className="mt-1 min-h-4 text-xs text-muted-foreground">{t('form.windowHint')}</p>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)} />
|
|
||||||
<FormField control={form.control} name="threshold" render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-col">
|
|
||||||
<FormLabel>{t('form.threshold')}</FormLabel>
|
|
||||||
<FormControl><Input type="number" min={0} {...field} /></FormControl>
|
|
||||||
<p className="mt-1 min-h-4 text-xs text-muted-foreground">{t('form.thresholdHint')}</p>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)} />
|
|
||||||
<FormField control={form.control} name="cooldownMin" render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-col">
|
|
||||||
<FormLabel>{t('form.cooldown')}</FormLabel>
|
|
||||||
<FormControl><Input type="number" min={1} {...field} /></FormControl>
|
|
||||||
<p className="mt-1 min-h-4 text-xs text-muted-foreground">{t('form.cooldownHint')}</p>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)} />
|
|
||||||
</div>
|
|
||||||
<FormField control={form.control} name="enabled" render={({ field }) => (
|
|
||||||
<FormItem className="flex items-center gap-2 space-y-0">
|
|
||||||
<FormControl><Switch checked={field.value} onCheckedChange={field.onChange} /></FormControl>
|
|
||||||
<FormLabel>{t('form.enabled')}</FormLabel>
|
|
||||||
</FormItem>
|
|
||||||
)} />
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>{t('common:dialog.cancel')}</Button>
|
|
||||||
<Button type="submit" disabled={submitting}>
|
|
||||||
{submitting ? t('saving') : editing ? t('save') : t('submitCreate')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={deleteTarget !== null}
|
open={deleteTarget !== null}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { LogContextDialog, type LogContext } from './components/LogContextDialog
|
|||||||
import { LogResultsPanel } from './components/LogResultsPanel'
|
import { LogResultsPanel } from './components/LogResultsPanel'
|
||||||
import { literalFilters } from './query-utils'
|
import { literalFilters } from './query-utils'
|
||||||
import { readQueryHistory, rememberQuery } from './query-history'
|
import { readQueryHistory, rememberQuery } from './query-history'
|
||||||
|
import { useDebouncedValue } from '@/hooks/useDebouncedValue'
|
||||||
|
|
||||||
const toLocal = (date: Date) => new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 19)
|
const toLocal = (date: Date) => new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 19)
|
||||||
const ns = (ms: number) => Math.round(ms).toString() + '000000'
|
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')) }
|
} 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) } }
|
finally { if (version === requestVersion.current) { setLoading(false); setLoadingStartedAt(null) } }
|
||||||
}
|
}
|
||||||
|
// tail 期间编辑 LogQL 不逐键触发:防抖 500ms 后才让轮询换查询
|
||||||
|
const tailQuery = useDebouncedValue(query, 500)
|
||||||
useEffect(() => {
|
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 poll = async () => {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
|
const current = ++version
|
||||||
setEnd(toLocal(new Date(now)))
|
setEnd(toLocal(new Date(now)))
|
||||||
setStart(toLocal(new Date(now - 300000)))
|
setStart(toLocal(new Date(now - 300000)))
|
||||||
try {
|
try {
|
||||||
const result = await logsApi.query({ sourceId, query, start: Number(ns(now - 300000)), end: Number(ns(now)), limit, direction: 'backward' })
|
const result = await logsApi.query({ sourceId, query: tailQuery, start: Number(ns(now - 300000)), end: Number(ns(now)), limit, direction: 'backward' }, { signal: controller.signal })
|
||||||
setExecutedQuery(query)
|
if (current !== version) return
|
||||||
|
setExecutedQuery(tailQuery)
|
||||||
setSearched(true)
|
setSearched(true)
|
||||||
setEntries(result.entries)
|
setEntries(result.entries)
|
||||||
setCursor(result.hasMore ? result.nextCursor : '')
|
setCursor(result.hasMore ? result.nextCursor : '')
|
||||||
@@ -237,8 +245,8 @@ export default function LogsPage() {
|
|||||||
}
|
}
|
||||||
void poll()
|
void poll()
|
||||||
const timer = window.setInterval(() => void poll(), 3000)
|
const timer = window.setInterval(() => void poll(), 3000)
|
||||||
return () => window.clearInterval(timer)
|
return () => { controller.abort(); window.clearInterval(timer) }
|
||||||
}, [tail, sourceId, query, limit])
|
}, [tail, sourceId, tailQuery, limit])
|
||||||
|
|
||||||
/** 上下文前后条数:默认 5 条(Java 一个异常常拆成 4 条日志,2 条拼不完整),选择后记住 */
|
/** 上下文前后条数:默认 5 条(Java 一个异常常拆成 4 条日志,2 条拼不完整),选择后记住 */
|
||||||
const [contextLines, setContextLines] = useState<number>(() => {
|
const [contextLines, setContextLines] = useState<number>(() => {
|
||||||
|
|||||||
@@ -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<NotifyChannel[]>([])
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const [editing, setEditing] = useState<NotifyChannel | null>(null)
|
|
||||||
const [form, setForm] = useState<ChannelForm>({ name: '', webhook: '', signingSecret: '', enabled: true })
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [testing, setTesting] = useState(false)
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState<NotifyChannel | null>(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 (
|
|
||||||
<Card className="border-border">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-base flex items-center justify-between">
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<BellRing className="w-4 h-4" />
|
|
||||||
{t('notify.cardTitle')}
|
|
||||||
</span>
|
|
||||||
<Button size="sm" variant="outline" onClick={openCreate}>
|
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
|
||||||
{t('notify.create')}
|
|
||||||
</Button>
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{channels.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground py-2 text-center">
|
|
||||||
{t('notify.emptyBefore')}
|
|
||||||
<Link to="/alerts" className="text-primary mx-1">{t('notify.emptyLink')}</Link>
|
|
||||||
{t('notify.emptyAfter')}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
channels.map((ch) => (
|
|
||||||
<div key={ch.id} className="flex items-center gap-3 rounded-lg border bg-card px-3 py-2.5">
|
|
||||||
<Switch checked={ch.enabled} onCheckedChange={(v) => void handleToggle(ch, v)} />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="font-medium text-sm">{ch.name}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{ch.hasWebhook ? t('notify.webhookConfigured') : t('notify.webhookMissing')}
|
|
||||||
{ch.hasSecret ? t('notify.secretConfigured') : ''}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<Button variant="outline" size="sm" aria-label={t('notify.testAria', { name: ch.name })} disabled={testing} onClick={() => void handleTest(ch)}>
|
|
||||||
<Send className="w-3.5 h-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" aria-label={t('notify.editAria', { name: ch.name })} onClick={() => openEdit(ch)}>
|
|
||||||
<Pencil className="w-3.5 h-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline" size="sm" aria-label={t('notify.deleteAria', { name: ch.name })}
|
|
||||||
className="text-destructive hover:bg-destructive/10"
|
|
||||||
onClick={() => setDeleteTarget(ch)}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogContent className="max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
{editing ? t('notify.editTitle', { name: editing.name }) : t('notify.createTitle')}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>{t('notify.nameLabel')}</Label>
|
|
||||||
<Input
|
|
||||||
placeholder={t('notify.namePlaceholder')}
|
|
||||||
value={form.name}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>
|
|
||||||
{t('notify.webhookLabel')}
|
|
||||||
{editing ? t('notify.webhookKeepHint') : ' *'}
|
|
||||||
</Label>
|
|
||||||
<PasswordInput
|
|
||||||
placeholder={editing?.hasWebhook ? t('notify.webhookSavedPlaceholder') : 'https://open.feishu.cn/open-apis/bot/v2/hook/xxx'}
|
|
||||||
value={form.webhook}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, webhook: e.target.value }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>{t('notify.signingLabel')}</Label>
|
|
||||||
<PasswordInput
|
|
||||||
placeholder={editing?.hasSecret ? t('notify.signingSavedPlaceholder') : t('notify.signingPlaceholder')}
|
|
||||||
value={form.signingSecret}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, signingSecret: e.target.value }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Switch checked={form.enabled} onCheckedChange={(v) => setForm((f) => ({ ...f, enabled: v }))} />
|
|
||||||
<Label>{t('notify.enable')}</Label>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button variant="outline" onClick={() => setOpen(false)}>{t('actions.cancel')}</Button>
|
|
||||||
<Button onClick={handleSave} disabled={saving}>
|
|
||||||
{saving ? t('actions.saving') : editing ? t('notify.saveChanges') : t('notify.createChannel')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={deleteTarget !== null}
|
|
||||||
onOpenChange={(o) => { if (!o) setDeleteTarget(null) }}
|
|
||||||
name={deleteTarget?.name}
|
|
||||||
title={t('notify.deleteTitle')}
|
|
||||||
loading={deleting}
|
|
||||||
onConfirm={handleDelete}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
Reference in new issue
Block a user