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:
opsx committed 2026-10-27 17:11:38 +08:00
1 parent 02d1bd87a7
commit f3733f0bfc
201 files changed
+5438 -2388

No files matched your search

+89 -68
View File
@@ -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}
/>