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:
logan committed 2026-10-25 21:28:32 +08:00
1 parent f9c6c1d826
commit 2d73661dd1
127 files changed
+8117 -2876

No files matched your search

@@ -0,0 +1,119 @@
import { memo, useCallback, useEffect, useState } from 'react'
import { Globe, Save } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { cn } from '@/lib/utils'
import { settingsApi, type ImageRegistryConfig } from '@/api/settings'
import { toast } from 'sonner'
const MODES = [
{ value: 'overseas', label: '海外官方', desc: 'docker.io,海外网络环境' },
{ value: 'cn', label: '国内加速', desc: 'docker.m.daocloud.io 镜像,国内环境防被墙' },
{ value: 'custom', label: '私有仓库', desc: '自有 registry 前缀(如华为云 SWR)' },
] as const
/**
* 镜像源设置 — 影响平台拉取的全部镜像(Loki/Grafana/Promtail/Fluent Bit),
* 参照 kuboard 的国内/海外选择;实时预览四类镜像的生效地址。
*/
export const ImageRegistrySection = memo(function ImageRegistrySection() {
const [cfg, setCfg] = useState<ImageRegistryConfig | null>(null)
const [customHost, setCustomHost] = useState('')
const [saving, setSaving] = useState(false)
useEffect(() => {
settingsApi
.getImageRegistry()
.then((c) => {
setCfg(c)
setCustomHost(c.customHost || '')
})
.catch(() => toast.error('加载镜像源配置失败'))
}, [])
const handleSave = useCallback(async () => {
if (!cfg) return
setSaving(true)
try {
const saved = await settingsApi.saveImageRegistry({
mode: cfg.mode,
customHost: cfg.mode === 'custom' ? customHost : undefined,
})
setCfg(saved)
toast.success('镜像源已保存,后续部署/采集将使用新镜像地址')
} catch (err) {
toast.error(err instanceof Error ? err.message : '保存失败')
} finally {
setSaving(false)
}
}, [cfg, customHost])
if (!cfg) return null
return (
<Card className="border-border">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Globe className="w-4 h-4" />
镜像仓库源
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{MODES.map((m) => (
<button
key={m.value}
type="button"
aria-pressed={cfg.mode === m.value}
onClick={() => setCfg({ ...cfg, mode: m.value })}
className={cn(
'text-left rounded-xl border-2 p-3 transition-all',
cfg.mode === m.value ? 'border-primary bg-primary/5' : 'border-border hover:border-input'
)}
>
<p className={cn('text-sm font-medium', cfg.mode === m.value && 'text-primary')}>{m.label}</p>
<p className="text-xs text-muted-foreground mt-1">{m.desc}</p>
</button>
))}
</div>
{cfg.mode === 'custom' && (
<div className="space-y-2">
<Label>私有仓库前缀 *</Label>
<Input
placeholder="swr.cn-east-2.myhuaweicloud.com/your-namespace"
value={customHost}
onChange={(e) => setCustomHost(e.target.value)}
/>
<p className="text-xs text-muted-foreground">平台会在镜像名前拼接此前缀,如 {customHost || 'your-registry'}/grafana/loki:3.4.0</p>
</div>
)}
{/* 生效镜像预览 */}
<div className="rounded-lg bg-muted/50 p-3 space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">生效镜像预览</p>
{([
['Loki', cfg.sampleLoki],
['Grafana', cfg.sampleGrafana],
['Promtail', cfg.samplePromtail],
['Fluent Bit', cfg.sampleFluentBit],
] as const).map(([name, img]) => (
<p key={name} className="text-xs font-mono flex gap-2">
<span className="text-muted-foreground w-20 shrink-0">{name}</span>
<span className="truncate" title={img}>{img}</span>
</p>
))}
</div>
<div className="flex justify-end">
<Button onClick={handleSave} disabled={saving}>
<Save className="w-4 h-4 mr-1" />
{saving ? '保存中...' : '保存镜像源'}
</Button>
</div>
</CardContent>
</Card>
)
})
@@ -193,7 +193,7 @@ export const LdapSection = memo(function LdapSection() {
<Button variant="outline" onClick={handleTest} disabled={testing || !cfg.enabled}>
{testing ? '测试中...' : '测试连接'}
</Button>
<Button onClick={handleSave} disabled={saving || !cfg.enabled}>
<Button onClick={handleSave} disabled={saving}>
{saving ? '保存中...' : '保存配置'}
</Button>
</div>
@@ -0,0 +1,240 @@
import { memo, useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router'
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 [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 : '加载通知通道失败')
}
}, [])
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('请填写通道名称')
return
}
if (!editing && !form.webhook) {
toast.error('新建通道需要填写 Webhook 地址')
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 : '保存失败')
} finally {
setSaving(false)
}
}
const handleTest = async (ch: NotifyChannel) => {
setTesting(true)
try {
await alertsApi.testChannel({ id: ch.id })
toast.success(`测试卡片已发送到「${ch.name}」`)
} catch (err) {
toast.error(err instanceof Error ? err.message : '发送失败')
} 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 : '切换失败')
}
}
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 : '删除失败')
} 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" />
告警通知通道(飞书/Lark)
</span>
<Button size="sm" variant="outline" onClick={openCreate}>
<Plus className="w-4 h-4 mr-1" />
新建通道
</Button>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{channels.length === 0 ? (
<p className="text-sm text-muted-foreground py-2 text-center">
尚无通知通道 —— 每个告警群创建一个通道(如「应用告警群」「Nginx 告警群」),
在
<Link to="/alerts" className="text-primary mx-1">日志告警</Link>
页把规则关联到对应通道
</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 ? 'Webhook 已配置' : '未配置'}
{ch.hasSecret ? ' · 签名已配置' : ''}
</p>
</div>
<div className="flex items-center gap-1.5">
<Button variant="outline" size="sm" aria-label={`测试 ${ch.name}`} disabled={testing} onClick={() => void handleTest(ch)}>
<Send className="w-3.5 h-3.5" />
</Button>
<Button variant="outline" size="sm" aria-label={`编辑 ${ch.name}`} onClick={() => openEdit(ch)}>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
variant="outline" size="sm" aria-label={`删除 ${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 ? `编辑通道 · ${editing.name}` : '新建通知通道'}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>通道名称 *</Label>
<Input
placeholder="如:应用告警群 / Nginx 告警群"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>Webhook 地址{editing ? '(留空保持不变)' : ' *'}</Label>
<PasswordInput
placeholder={editing?.hasWebhook ? '已保存,输入新地址以修改' : '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>签名校验密钥(可选)</Label>
<PasswordInput
placeholder={editing?.hasSecret ? '已保存,留空保持不变' : '飞书机器人「签名校验」密钥'}
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>启用通道</Label>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setOpen(false)}>取消</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? '保存中...' : editing ? '保存修改' : '创建通道'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(o) => { if (!o) setDeleteTarget(null) }}
name={deleteTarget?.name}
title="确认删除通知通道"
loading={deleting}
onConfirm={handleDelete}
/>
</Card>
)
})
@@ -0,0 +1,41 @@
import { memo } from 'react'
import { useTheme } from 'next-themes'
import { Sun, Moon, Monitor } from 'lucide-react'
import { cn } from '@/lib/utils'
const OPTIONS = [
{ value: 'light', label: '浅色', icon: Sun },
{ value: 'dark', label: '深色', icon: Moon },
{ value: 'system', label: '跟随系统', icon: Monitor },
] as const
/**
* 主题设置 — 三态单选卡片(浅色/深色/跟随系统),next-themes 驱动,
* key `opsxloki.theme.mode`。
*/
export const ThemeSection = memo(function ThemeSection() {
const { theme, setTheme } = useTheme()
return (
<div className="grid grid-cols-3 gap-3">
{OPTIONS.map((opt) => {
const Icon = opt.icon
const active = theme === opt.value
return (
<button
key={opt.value}
type="button"
aria-pressed={active}
onClick={() => setTheme(opt.value)}
className={cn(
'flex flex-col items-center gap-2 rounded-xl border-2 p-4 transition-all',
active ? 'border-primary bg-primary/5 text-primary' : 'border-border bg-card text-muted-foreground hover:border-input'
)}
>
<Icon className="w-5 h-5" />
<span className="text-sm font-medium">{opt.label}</span>
</button>
)
})}
</div>
)
})
+40 -6
View File
@@ -10,6 +10,11 @@ import { clearAuth } from '@/lib/auth-storage'
import { toast } from 'sonner'
import { PageContainer } from '@/components/PageContainer'
import { LdapSection } from './components/LdapSection'
import { ThemeSection } from './components/ThemeSection'
import { Palette } from 'lucide-react'
import { NotifySection } from './components/NotifySection'
import { ImageRegistrySection } from './components/ImageRegistrySection'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
// 构建指纹由 vite define 注入;本地 tsc/预览时回退
const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'
@@ -25,7 +30,7 @@ const SYSTEM_INFO = [
]
/**
* 系统设置 — 修改密码 + 关于。
* 系统设置 — 通用配置(主题/密码/关于)/ 告警通知 / LDAP 接入。
* 表单保持手写(字段少、校验已达标准);成功后清空表单即反馈(成功静默)。
*/
const SettingsPage = memo(function SettingsPage() {
@@ -63,8 +68,27 @@ const SettingsPage = memo(function SettingsPage() {
)
return (
<PageContainer title="系统设置" description="修改登录密码、LDAP 接入与系统信息">
<LdapSection />
<PageContainer title="系统设置" description="通用配置、告警通知与 LDAP 接入">
<Tabs defaultValue="general" className="space-y-6">
<TabsList>
<TabsTrigger value="general">通用配置</TabsTrigger>
<TabsTrigger value="notify">告警通知</TabsTrigger>
<TabsTrigger value="ldap">LDAP 接入</TabsTrigger>
</TabsList>
<TabsContent value="general" className="space-y-6">
{/* 主题 */}
<Card className="border-border">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Palette className="w-4 h-4" />
主题
</CardTitle>
</CardHeader>
<CardContent>
<ThemeSection />
</CardContent>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 修改密码 */}
@@ -136,9 +160,7 @@ const SettingsPage = memo(function SettingsPage() {
<div
key={item.label}
className={`flex justify-between py-2.5 ${
index < SYSTEM_INFO.length - 1
? 'border-b border-border'
: ''
index < SYSTEM_INFO.length - 1 ? 'border-b border-border' : ''
}`}
>
<span className="text-sm text-muted-foreground">{item.label}</span>
@@ -148,7 +170,19 @@ const SettingsPage = memo(function SettingsPage() {
</div>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="notify" className="space-y-6">
<ImageRegistrySection />
<NotifySection />
</TabsContent>
<TabsContent value="ldap">
<LdapSection />
</TabsContent>
</Tabs>
</PageContainer>
)
})