feat: complete Loki query, insights, settings and deployment workflows
Add the Loki query UI, access policies, ingress insights, alert and registry settings, and their backend APIs. Include the related Kubernetes deployment and collector updates required by the integrated application. Validated with go test ./..., npm run build, and npm run lint.
This commit is contained in:
1 parent
f9c6c1d826
commit
2d73661dd1
127 files changed
+8117
-2876
No files matched your search
@@ -0,0 +1,362 @@
|
||||
import { memo, useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
import { BellRing, Plus, Play, Pencil, Trash2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
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 { PageContainer } from '@/components/PageContainer'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { ConfirmDialog } from '@/components/ConfirmDialog'
|
||||
import { alertsApi, type AlertRule, type AlertDelivery, type NotifyChannel } from '@/api/alerts'
|
||||
import { logsApi, type LogSource } from '@/api/logs'
|
||||
import { toast } from 'sonner'
|
||||
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,
|
||||
}
|
||||
|
||||
/** 告警规则页:规则 CRUD + 立即检查 + 发送历史 */
|
||||
const AlertsPage = memo(function AlertsPage() {
|
||||
const [rules, setRules] = useState<AlertRule[]>([])
|
||||
const [sources, setSources] = useState<LogSource[]>([])
|
||||
const [channels, setChannels] = useState<NotifyChannel[]>([])
|
||||
const [deliveries, setDeliveries] = useState<AlertDelivery[]>([])
|
||||
const [open, setOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<AlertRule | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<AlertRule | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const form = useForm<RuleForm>({ defaultValues: DEFAULTS })
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const [r, s, d, ch] = await Promise.all([
|
||||
alertsApi.listRules(), logsApi.sources(), alertsApi.deliveries(), alertsApi.listChannels(),
|
||||
])
|
||||
setRules(Array.isArray(r) ? r : (r as unknown as { list: AlertRule[] }).list ?? [])
|
||||
setSources(s.list)
|
||||
setDeliveries(Array.isArray(d) ? d : [])
|
||||
setChannels(Array.isArray(ch) ? ch : [])
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '加载告警数据失败')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void refresh()
|
||||
}, [refresh])
|
||||
|
||||
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 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 : '保存失败')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
})
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await alertsApi.deleteRule(deleteTarget.id)
|
||||
setDeleteTarget(null)
|
||||
void refresh()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '删除失败')
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCheckNow = async () => {
|
||||
setChecking(true)
|
||||
try {
|
||||
await alertsApi.checkNow()
|
||||
toast.info('已执行一次检查,结果见发送历史')
|
||||
void refresh()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '检查失败')
|
||||
} finally {
|
||||
setChecking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleRule = async (rule: AlertRule, enabled: boolean) => {
|
||||
try {
|
||||
await alertsApi.updateRule(rule.id, { enabled })
|
||||
void refresh()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '切换失败')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="日志告警"
|
||||
description="按 LogQL 计数规则监控日志,超阈值推送飞书卡片(周期 1 分钟)"
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleCheckNow} disabled={checking}>
|
||||
<Play className="w-4 h-4 mr-1" />
|
||||
{checking ? '检查中...' : '立即检查'}
|
||||
</Button>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
新建规则
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{rules.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={BellRing}
|
||||
title="暂无告警规则"
|
||||
description={'例如:{namespace=~".+"} |= "FATAL",15 分钟内超过 0 条即推送飞书'}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{rules.map((rule) => {
|
||||
const src = sources.find((s) => s.id === rule.sourceId)
|
||||
return (
|
||||
<Card key={rule.id} className="border-border">
|
||||
<CardContent className="p-4 flex items-center gap-4 flex-wrap">
|
||||
<Switch checked={rule.enabled} onCheckedChange={(v) => void toggleRule(rule, v)} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">{rule.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate font-mono mt-0.5">{rule.query}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{src?.name ?? `数据源 ${rule.sourceId}`} → {(rule.channelIds || []).map((id) => channels.find((c) => c.id === id)?.name || `#${id}`).join('、') || '未关联通道'} · {rule.windowMin} 分钟超过 {rule.threshold} 条触发 · 冷却 {rule.cooldownMin} 分钟
|
||||
{rule.lastFiredAt && !rule.lastFiredAt.startsWith('0001') && ` · 上次触发 ${new Date(rule.lastFiredAt).toLocaleString()}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" aria-label={`编辑 ${rule.name}`} onClick={() => openEdit(rule)}>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline" size="sm" aria-label={`删除 ${rule.name}`}
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
onClick={() => setDeleteTarget(rule)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 发送历史 */}
|
||||
{deliveries.length > 0 && (
|
||||
<Card className="border-border">
|
||||
<CardContent className="p-4">
|
||||
<h2 className="text-sm font-semibold mb-3">发送历史(最近 50 条)</h2>
|
||||
<div className="divide-y divide-border text-sm">
|
||||
{deliveries.map((d) => (
|
||||
<div key={d.id} className="py-2 flex items-center gap-3 flex-wrap">
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
d.status === 'sent' ? 'bg-success/10 text-success'
|
||||
: d.status === 'cooldown' ? 'bg-muted text-muted-foreground'
|
||||
: 'bg-destructive/10 text-destructive'
|
||||
}`}
|
||||
>
|
||||
{d.status === 'sent' ? '已发送' : d.status === 'cooldown' ? '冷却跳过' : '失败'}
|
||||
</span>
|
||||
<span className="font-medium">{d.ruleName}</span>
|
||||
<span className="text-muted-foreground text-xs">{d.count} 条命中</span>
|
||||
{d.reason && <span className="text-destructive text-xs truncate flex-1">{d.reason}</span>}
|
||||
<span className="text-muted-foreground text-xs ml-auto">{new Date(d.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
通知未配置?去
|
||||
<Link to="/settings" className="text-primary mx-1">系统设置</Link>
|
||||
配置飞书 Webhook
|
||||
</p>
|
||||
|
||||
{/* 规则弹窗 */}
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? `编辑规则 · ${editing.name}` : '新建告警规则'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<FormField control={form.control} name="name" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>规则名称 *</FormLabel>
|
||||
<FormControl><Input placeholder="应用致命异常" {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
<FormField control={form.control} name="sourceId" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>数据源 *</FormLabel>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<FormControl><SelectTrigger><SelectValue placeholder="选择数据源" /></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>LogQL 查询 *</FormLabel>
|
||||
<FormControl>
|
||||
<Input className="font-mono" placeholder='{namespace="log"} |= "FATAL"' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{'常用:{app="xxx"} |= "FATAL" / {namespace=~".+"} |~ "(?i)error" / {app="x"} |= "OutOfMemoryError"'}
|
||||
</p>
|
||||
</FormItem>
|
||||
)} />
|
||||
<FormField control={form.control} name="channelIds" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>通知通道(可多选,扇出到多个告警群)</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">请先在「系统设置 → 告警通知」创建通道</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'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) field.onChange([...field.value, String(ch.id)])
|
||||
else field.onChange(field.value.filter((x) => x !== String(ch.id)))
|
||||
}}
|
||||
className="accent-primary"
|
||||
/>
|
||||
{ch.name}
|
||||
{!ch.enabled && <span className="text-xs text-muted-foreground">(禁用)</span>}
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<FormField control={form.control} name="windowMin" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>统计窗口(分)</FormLabel>
|
||||
<FormControl><Input type="number" {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
<FormField control={form.control} name="threshold" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>触发阈值(条)</FormLabel>
|
||||
<FormControl><Input type="number" {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
<p className="text-xs text-muted-foreground">0 = 出现即告警</p>
|
||||
</FormItem>
|
||||
)} />
|
||||
<FormField control={form.control} name="cooldownMin" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>冷却(分)</FormLabel>
|
||||
<FormControl><Input type="number" {...field} /></FormControl>
|
||||
<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>启用规则</FormLabel>
|
||||
</FormItem>
|
||||
)} />
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>取消</Button>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? '保存中...' : editing ? '保存修改' : '创建规则'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(o) => { if (!o) setDeleteTarget(null) }}
|
||||
name={deleteTarget?.name}
|
||||
title="确认删除告警规则"
|
||||
loading={deleting}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</PageContainer>
|
||||
)
|
||||
})
|
||||
|
||||
export default AlertsPage
|
||||
Reference in new issue
Block a user