feat: 品牌统一 LokiStack + 全站中英 i18n + 用户批量删除 + 主题定时切换与浅色侧栏
- 品牌: Go 模块/数据文件/二进制/构建部署资产全部 opsx→lokistack;前端 localStorage 命名空间迁移(读旧写新,含按用户 id 前缀迁移);修复 nginx upstream 与 Makefile 引用已改名服务的断裂 - i18n: react-i18next 落地,common + 11 个页面域命名空间全站中英(菜单/顶栏/登录/列表页/表单/确认弹窗/状态徽章);设置页语言即时切换;修复词典自注册导致的循环导入白屏 - 用户管理: 批量删除(POST /users/batch-delete,不删自己/最后管理员,部分失败不阻断)+ 行复选/全选/批量工具条 - 主题: 浅色侧栏(中性灰激活态)、四态模式(浅色/深色/跟随系统/定时切换,按时区判定)、顶栏太阳月亮二态、设置页紧凑分段选择器 - UI: 登录记住用户名、CardActionButton 统一五页卡片操作钮、shadcn Badge 替换手写徽章、语义色修正 - 清理: 移除主机 Web 终端(前后端)、k8s-helm/Helm 遗留代码与字段、README 重写精简 - 修复: 侧栏与日志告警 order 重复、路由标题 i18n key 未翻译、词典双逗号等
This commit is contained in:
1 parent
02d1bd87a7
commit
f3733f0bfc
201 files changed
+5438
-2388
No files matched your search
@@ -21,6 +21,7 @@ import { alertsApi, type AlertRule, type AlertDelivery, type NotifyChannel } fro
|
||||
import { logsApi, type LogSource } from '@/api/logs'
|
||||
import { toast } from 'sonner'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface RuleForm {
|
||||
name: string
|
||||
@@ -39,43 +40,37 @@ const DEFAULTS: RuleForm = {
|
||||
}
|
||||
|
||||
interface AlertTemplate {
|
||||
label: string
|
||||
desc: string
|
||||
key: 'nsError' | 'allNsError' | 'ingress5xx' | 'ingress4xx' | 'fatal'
|
||||
query: string
|
||||
windowMin: number
|
||||
threshold: number
|
||||
cooldownMin: number
|
||||
}
|
||||
|
||||
/** 常用告警模板 — 只做预填,应用后可改 namespace / 阈值 / 窗口 */
|
||||
/** 常用告警模板 — 只做预填,应用后可改 namespace / 阈值 / 窗口(label/desc 走 i18n) */
|
||||
const TEMPLATES: AlertTemplate[] = [
|
||||
{
|
||||
label: '业务 NS error',
|
||||
desc: '指定命名空间(默认 astralx)内 error 日志',
|
||||
key: 'nsError',
|
||||
query: '{namespace="astralx"} |~ "(?i)error"',
|
||||
windowMin: 5, threshold: 10, cooldownMin: 30,
|
||||
},
|
||||
{
|
||||
label: '全 NS error > 1000',
|
||||
desc: '所有命名空间 error 日志总量超过 1000 条',
|
||||
key: 'allNsError',
|
||||
query: '{namespace=~".+"} |~ "(?i)error"',
|
||||
windowMin: 15, threshold: 1000, cooldownMin: 60,
|
||||
},
|
||||
{
|
||||
label: 'Ingress 5xx',
|
||||
desc: 'ingress-nginx 5xx(需 JSON access log,平台可一键标准化)',
|
||||
key: 'ingress5xx',
|
||||
query: '{namespace="ingress-nginx"} | json | status >= 500',
|
||||
windowMin: 5, threshold: 5, cooldownMin: 15,
|
||||
},
|
||||
{
|
||||
label: 'Ingress 4xx',
|
||||
desc: 'ingress-nginx 4xx(需 JSON access log)',
|
||||
key: 'ingress4xx',
|
||||
query: '{namespace="ingress-nginx"} | json | status >= 400 | status < 500',
|
||||
windowMin: 5, threshold: 50, cooldownMin: 30,
|
||||
},
|
||||
{
|
||||
label: 'FATAL',
|
||||
desc: '所有命名空间出现 FATAL 即告警',
|
||||
key: 'fatal',
|
||||
query: '{namespace=~".+"} |= "FATAL"',
|
||||
windowMin: 5, threshold: 0, cooldownMin: 15,
|
||||
},
|
||||
@@ -83,6 +78,7 @@ const TEMPLATES: AlertTemplate[] = [
|
||||
|
||||
/** 告警规则页:规则 CRUD + 立即检查 + 发送历史 */
|
||||
const AlertsPage = memo(function AlertsPage() {
|
||||
const { t } = useTranslation(['alerts', 'common'])
|
||||
const [rules, setRules] = useState<AlertRule[]>([])
|
||||
const [sources, setSources] = useState<LogSource[]>([])
|
||||
const [channels, setChannels] = useState<NotifyChannel[]>([])
|
||||
@@ -106,9 +102,9 @@ const AlertsPage = memo(function AlertsPage() {
|
||||
setDeliveries(Array.isArray(d) ? d : [])
|
||||
setChannels(Array.isArray(ch) ? ch : [])
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '加载告警数据失败')
|
||||
toast.error(err instanceof Error ? err.message : t('loadFailed'))
|
||||
}
|
||||
}, [])
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
void refresh()
|
||||
@@ -130,12 +126,12 @@ const AlertsPage = memo(function AlertsPage() {
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const applyTemplate = (t: AlertTemplate) => {
|
||||
form.setValue('name', t.label)
|
||||
form.setValue('query', t.query)
|
||||
form.setValue('windowMin', String(t.windowMin))
|
||||
form.setValue('threshold', String(t.threshold))
|
||||
form.setValue('cooldownMin', String(t.cooldownMin))
|
||||
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) => {
|
||||
@@ -153,7 +149,7 @@ const AlertsPage = memo(function AlertsPage() {
|
||||
setOpen(false)
|
||||
void refresh()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '保存失败')
|
||||
toast.error(err instanceof Error ? err.message : t('saveFailed'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -167,7 +163,7 @@ const AlertsPage = memo(function AlertsPage() {
|
||||
setDeleteTarget(null)
|
||||
void refresh()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '删除失败')
|
||||
toast.error(err instanceof Error ? err.message : t('deleteFailed'))
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
@@ -177,10 +173,10 @@ const AlertsPage = memo(function AlertsPage() {
|
||||
setChecking(true)
|
||||
try {
|
||||
await alertsApi.checkNow()
|
||||
toast.info('已执行一次检查,结果见发送历史')
|
||||
toast.info(t('checkDone'))
|
||||
void refresh()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '检查失败')
|
||||
toast.error(err instanceof Error ? err.message : t('checkFailed'))
|
||||
} finally {
|
||||
setChecking(false)
|
||||
}
|
||||
@@ -191,23 +187,23 @@ const AlertsPage = memo(function AlertsPage() {
|
||||
await alertsApi.updateRule(rule.id, { enabled })
|
||||
void refresh()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '切换失败')
|
||||
toast.error(err instanceof Error ? err.message : t('toggleFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="日志告警"
|
||||
description="按 LogQL 计数规则监控日志,超阈值推送飞书卡片(周期 1 分钟)"
|
||||
title={t('title')}
|
||||
description={t('subtitle')}
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleCheckNow} disabled={checking}>
|
||||
<Play className="w-4 h-4 mr-1" />
|
||||
{checking ? '检查中...' : '立即检查'}
|
||||
{checking ? t('checking') : t('checkNow')}
|
||||
</Button>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
新建规则
|
||||
{t('create')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
@@ -215,8 +211,8 @@ const AlertsPage = memo(function AlertsPage() {
|
||||
{rules.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={BellRing}
|
||||
title="暂无告警规则"
|
||||
description={'例如:{namespace=~".+"} |= "FATAL",15 分钟内超过 0 条即推送飞书'}
|
||||
title={t('emptyTitle')}
|
||||
description={t('emptyDesc')}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
@@ -230,16 +226,33 @@ const AlertsPage = memo(function AlertsPage() {
|
||||
<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()}`}
|
||||
{src?.name ?? t('sourceFallback', { id: rule.sourceId })} →{' '}
|
||||
{(rule.channelIds || [])
|
||||
.map((id) => channels.find((c) => c.id === id)?.name || `#${id}`)
|
||||
.join(t('channelSep')) || t('noChannels')}{' '}
|
||||
· {t('ruleMeta', {
|
||||
window: rule.windowMin,
|
||||
threshold: rule.threshold,
|
||||
cooldown: rule.cooldownMin,
|
||||
})}
|
||||
{rule.lastFiredAt && !rule.lastFiredAt.startsWith('0001') && ` · ${t('lastFired', {
|
||||
time: 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)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label={t('editAria', { name: rule.name })}
|
||||
onClick={() => openEdit(rule)}
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline" size="sm" aria-label={`删除 ${rule.name}`}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label={t('deleteAria', { name: rule.name })}
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
onClick={() => setDeleteTarget(rule)}
|
||||
>
|
||||
@@ -257,7 +270,7 @@ const AlertsPage = memo(function AlertsPage() {
|
||||
{deliveries.length > 0 && (
|
||||
<Card className="border-border">
|
||||
<CardContent className="p-4">
|
||||
<h2 className="text-sm font-semibold mb-3">发送历史(最近 50 条)</h2>
|
||||
<h2 className="text-sm font-semibold mb-3">{t('historyTitle')}</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">
|
||||
@@ -268,10 +281,16 @@ const AlertsPage = memo(function AlertsPage() {
|
||||
: 'bg-destructive/10 text-destructive'
|
||||
}`}
|
||||
>
|
||||
{d.status === 'sent' ? '已发送' : d.status === 'cooldown' ? '冷却跳过' : '失败'}
|
||||
{d.status === 'sent'
|
||||
? t('delivery.sent')
|
||||
: d.status === 'cooldown'
|
||||
? t('delivery.cooldown')
|
||||
: t('delivery.failed')}
|
||||
</span>
|
||||
<span className="font-medium">{d.ruleName}</span>
|
||||
<span className="text-muted-foreground text-xs">{d.count} 条命中</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t('delivery.hits', { count: 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>
|
||||
@@ -282,34 +301,36 @@ const AlertsPage = memo(function AlertsPage() {
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
通知未配置?去
|
||||
<Link to="/settings" className="text-primary mx-1">系统设置</Link>
|
||||
配置飞书 Webhook
|
||||
{t('notifyHintPre')}
|
||||
<Link to="/settings" className="text-primary mx-1">{t('common:menu.settings')}</Link>
|
||||
{t('notifyHintPost')}
|
||||
</p>
|
||||
|
||||
{/* 规则弹窗 */}
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? `编辑规则 · ${editing.name}` : '新建告警规则'}</DialogTitle>
|
||||
<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">快捷模板(点击预填,可再修改)</p>
|
||||
<p className="text-xs font-medium text-muted-foreground">{t('templates')}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TEMPLATES.map((t) => (
|
||||
{TEMPLATES.map((tpl) => (
|
||||
<Button
|
||||
key={t.label}
|
||||
key={tpl.key}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
title={`${t.desc}\n${t.query}`}
|
||||
onClick={() => applyTemplate(t)}
|
||||
title={`${t(`tpl.${tpl.key}.desc`)}\n${tpl.query}`}
|
||||
onClick={() => applyTemplate(tpl)}
|
||||
>
|
||||
{t.label}
|
||||
{t(`tpl.${tpl.key}.label`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
@@ -318,16 +339,16 @@ const AlertsPage = memo(function AlertsPage() {
|
||||
<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>
|
||||
<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>数据源 *</FormLabel>
|
||||
<FormLabel>{t('form.source')}</FormLabel>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<FormControl><SelectTrigger><SelectValue placeholder="选择数据源" /></SelectTrigger></FormControl>
|
||||
<FormControl><SelectTrigger><SelectValue placeholder={t('form.sourcePlaceholder')} /></SelectTrigger></FormControl>
|
||||
<SelectContent>
|
||||
{sources.map((s) => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
|
||||
@@ -340,21 +361,21 @@ const AlertsPage = memo(function AlertsPage() {
|
||||
</div>
|
||||
<FormField control={form.control} name="query" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>LogQL 查询 *</FormLabel>
|
||||
<FormLabel>{t('form.query')}</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"'}
|
||||
{t('form.queryHint')}
|
||||
</p>
|
||||
</FormItem>
|
||||
)} />
|
||||
<FormField control={form.control} name="channelIds" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>通知通道(可多选,扇出到多个告警群)</FormLabel>
|
||||
<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">请先在「系统设置 → 告警通知」创建通道</p>}
|
||||
{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 (
|
||||
@@ -369,7 +390,7 @@ const AlertsPage = memo(function AlertsPage() {
|
||||
className="accent-primary"
|
||||
/>
|
||||
{ch.name}
|
||||
{!ch.enabled && <span className="text-xs text-muted-foreground">(禁用)</span>}
|
||||
{!ch.enabled && <span className="text-xs text-muted-foreground">{t('form.channelDisabled')}</span>}
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
@@ -380,22 +401,22 @@ const AlertsPage = memo(function AlertsPage() {
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<FormField control={form.control} name="windowMin" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>统计窗口(分)</FormLabel>
|
||||
<FormLabel>{t('form.window')}</FormLabel>
|
||||
<FormControl><Input type="number" {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
<FormField control={form.control} name="threshold" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>触发阈值(条)</FormLabel>
|
||||
<FormLabel>{t('form.threshold')}</FormLabel>
|
||||
<FormControl><Input type="number" {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
<p className="text-xs text-muted-foreground">0 = 出现即告警</p>
|
||||
<p className="text-xs text-muted-foreground">{t('form.thresholdHint')}</p>
|
||||
</FormItem>
|
||||
)} />
|
||||
<FormField control={form.control} name="cooldownMin" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>冷却(分)</FormLabel>
|
||||
<FormLabel>{t('form.cooldown')}</FormLabel>
|
||||
<FormControl><Input type="number" {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -404,13 +425,13 @@ const AlertsPage = memo(function AlertsPage() {
|
||||
<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>
|
||||
<FormLabel>{t('form.enabled')}</FormLabel>
|
||||
</FormItem>
|
||||
)} />
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>取消</Button>
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>{t('common:dialog.cancel')}</Button>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? '保存中...' : editing ? '保存修改' : '创建规则'}
|
||||
{submitting ? t('saving') : editing ? t('save') : t('submitCreate')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -422,7 +443,7 @@ const AlertsPage = memo(function AlertsPage() {
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(o) => { if (!o) setDeleteTarget(null) }}
|
||||
name={deleteTarget?.name}
|
||||
title="确认删除告警规则"
|
||||
title={t('confirmDeleteTitle')}
|
||||
loading={deleting}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user