feat: K8s 部署清单、Token 接入加固与日志/设置页优化

- kubernetes/: 30Gi PVC、单副本 Recreate、Secret/Service/NodePort/Ingress 清单与部署文档
- 数据安全: kubeconfig/token 统一加密存储,启动迁移历史明文,DB 0600
- Token 接入: 命名空间级最小权限提示、长期 Token、自签证书 TLS 兼容
- 部署: 命名空间级 Token 下跳过 Namespace patch/枚举,节点信息容错
- 日志页: namespace → app → pod 选择顺序与作用域收窄
- 设置页: 拆分为通用/告警/LDAP/镜像源四个 tab
- 弹框: 统一 max-h 滚动与长命令换行,修复内容溢出
This commit is contained in:
logan committed 2026-10-25 23:44:07 +08:00
1 parent 2d73661dd1
commit 9f7c522157
50 files changed
+1346 -149

No files matched your search

+7 -1
View File
@@ -1,9 +1,15 @@
// 设置域 API — 镜像源等平台级配置
import { http } from '../http'
export interface ComponentImage {
repository: string
tag: string
}
export interface ImageRegistryConfig {
mode: 'overseas' | 'cn' | 'custom'
customHost: string
images: Record<string, ComponentImage>
sampleLoki: string
sampleGrafana: string
samplePromtail: string
@@ -12,6 +18,6 @@ export interface ImageRegistryConfig {
export const settingsApi = {
getImageRegistry: () => http.get<ImageRegistryConfig>('/settings/image-registry'),
saveImageRegistry: (data: { mode: string; customHost?: string }) =>
saveImageRegistry: (data: { mode: string; customHost?: string; images?: Record<string, ComponentImage> }) =>
http.put<ImageRegistryConfig>('/settings/image-registry', data),
}
+1 -1
View File
@@ -4,7 +4,7 @@ import type { PageData, StorageConfig } from '@/types';
export interface StorageCreateInput {
name: string;
storageType: 's3' | 'oss' | 'minio' | 'rustfs';
storageType: 's3' | 'oss' | 'cos' | 'obs' | 'minio' | 'rustfs';
endpoint?: string;
bucket: string;
region?: string;
+1 -1
View File
@@ -58,7 +58,7 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] max-h-[90vh] overflow-y-auto translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className
)}
{...props}
+71
View File
@@ -38,6 +38,49 @@ const DEFAULTS: RuleForm = {
windowMin: '15', threshold: '0', cooldownMin: '60', enabled: true,
}
interface AlertTemplate {
label: string
desc: string
query: string
windowMin: number
threshold: number
cooldownMin: number
}
/** 常用告警模板 — 只做预填,应用后可改 namespace / 阈值 / 窗口 */
const TEMPLATES: AlertTemplate[] = [
{
label: '业务 NS error',
desc: '指定命名空间(默认 astralx)内 error 日志',
query: '{namespace="astralx"} |~ "(?i)error"',
windowMin: 5, threshold: 10, cooldownMin: 30,
},
{
label: '全 NS error > 1000',
desc: '所有命名空间 error 日志总量超过 1000 条',
query: '{namespace=~".+"} |~ "(?i)error"',
windowMin: 15, threshold: 1000, cooldownMin: 60,
},
{
label: 'Ingress 5xx',
desc: 'ingress-nginx 5xx(需 JSON access log,平台可一键标准化)',
query: '{namespace="ingress-nginx"} | json | status >= 500',
windowMin: 5, threshold: 5, cooldownMin: 15,
},
{
label: 'Ingress 4xx',
desc: 'ingress-nginx 4xx(需 JSON access log)',
query: '{namespace="ingress-nginx"} | json | status >= 400 | status < 500',
windowMin: 5, threshold: 50, cooldownMin: 30,
},
{
label: 'FATAL',
desc: '所有命名空间出现 FATAL 即告警',
query: '{namespace=~".+"} |= "FATAL"',
windowMin: 5, threshold: 0, cooldownMin: 15,
},
]
/** 告警规则页:规则 CRUD + 立即检查 + 发送历史 */
const AlertsPage = memo(function AlertsPage() {
const [rules, setRules] = useState<AlertRule[]>([])
@@ -87,6 +130,14 @@ 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 onSubmit = form.handleSubmit(async (v) => {
setSubmitting(true)
try {
@@ -244,6 +295,26 @@ const AlertsPage = memo(function AlertsPage() {
</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>
<div className="flex flex-wrap gap-2">
{TEMPLATES.map((t) => (
<Button
key={t.label}
type="button"
variant="outline"
size="sm"
className="h-8 text-xs"
title={`${t.desc}\n${t.query}`}
onClick={() => applyTemplate(t)}
>
{t.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>
@@ -247,6 +247,89 @@ export const ClusterFormDialog = memo(function ClusterFormDialog({
</FormItem>
)}
/>
<details className="min-w-0 overflow-hidden rounded-md border bg-muted/30 px-3 py-2 text-xs">
<summary className="cursor-pointer select-none font-medium">
最小授权建议(点击展开)
</summary>
<div className="mt-2 min-w-0 space-y-2 break-words text-muted-foreground">
<p>
只授予 <code>log</code> 命名空间权限,Token 泄露也不会影响其他命名空间;
<strong>不要使用 cluster-admin</strong>。
</p>
<p>
一次性创建 <code>log</code> 命名空间、ServiceAccount、命名空间级 Role 和长期 Token
(在集群里执行一次,Token 粘贴到下方输入框):
</p>
<pre className="max-w-full overflow-x-auto whitespace-pre-wrap [overflow-wrap:anywhere] rounded bg-background p-2 font-mono text-[11px] leading-relaxed">{`# 1) 一次创建:命名空间 + SA + 命名空间级 Role + 长期 Token
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Namespace
metadata:
name: log
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: opsx-loki
namespace: log
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: opsx-loki
namespace: log
rules:
- apiGroups: [""]
resources: ["pods", "pods/log", "services", "configmaps", "secrets", "persistentvolumeclaims", "events", "serviceaccounts"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["apps"]
resources: ["statefulsets", "daemonsets", "deployments"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: opsx-loki
namespace: log
subjects:
- kind: ServiceAccount
name: opsx-loki
namespace: log
roleRef:
kind: Role
name: opsx-loki
apiGroup: rbac.authorization.k8s.io
---
apiVersion: v1
kind: Secret
metadata:
name: opsx-loki-token
namespace: log
annotations:
kubernetes.io/service-account.name: opsx-loki
type: kubernetes.io/service-account-token
EOF
# 2) 取出长期 Token(不自动过期),粘贴到 Token 输入框
kubectl -n log get secret opsx-loki-token -o jsonpath='{.data.token}' | base64 -d`}</pre>
<p>
上面的 Namespace 由管理员创建,平台 Token 本身没有任何集群级权限。
只有确实需要平台自建命名空间时,才额外授予
<code>namespaces: create/get</code> 的 ClusterRole;
注意 namespace 的 create 无法按名称限制,所以预建 <code>log</code> 更安全。
</p>
<p>
Token 长期有效,但只绑定 <code>log</code> 命名空间的 Role
(仅能操作该命名空间的 Pod / 工作负载),泄露影响面仅限于 <code>log</code>;
服务端会转成 kubeconfig 并用 <code>ENCRYPTION_KEY</code> 加密存储,
数据库里只有 <code>ENC:</code> 密文,接口不返回明文。
轮换时删除该 Secret 重建即可,旧 Token 立即失效。
</p>
</div>
</details>
</TabsContent>
</Tabs>
</FormControl>
@@ -1,4 +1,5 @@
import { memo, useEffect, useState } from 'react'
import { Link } from 'react-router'
import { Play, Square, RotateCw, Trash2, Terminal, ExternalLink, Pencil, Rocket } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { StatusBadge } from '@/components/StatusBadge'
@@ -91,7 +92,12 @@ export const DeploymentCard = memo(function DeploymentCard({
</a>
) }] : []),
]}
message={d.statusMessage || undefined}
message={
d.statusMessage ||
(d.status === 'running'
? '提示:到「日志查询 → 数据源」关联 Loki 后即可查询;跨集群/公网访问需先为 Loki 配置 Ingress 域名。'
: undefined)
}
actions={
<>
<Button variant="outline" size="sm" className="text-xs" onClick={() => onEditRetention(d)} disabled={isDeploying || isBusy}>
@@ -115,6 +121,16 @@ export const DeploymentCard = memo(function DeploymentCard({
</Button>
</>
)}
{d.status === 'running' && (
<>
<Button variant="outline" size="sm" className="text-xs" asChild>
<Link to="/logs">日志查询</Link>
</Button>
<Button variant="outline" size="sm" className="text-xs" asChild>
<Link to="/collectors">采集器</Link>
</Button>
</>
)}
<Button variant="outline" size="sm" className="text-xs" onClick={() => onViewLogs(d)}>
<Terminal className="size-3 mr-1" />日志
</Button>
@@ -347,7 +347,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
<FormItem>
<FormLabel>存储容量</FormLabel>
<FormControl>
<Input placeholder="10Gi" {...field} />
<Input placeholder="20Gi" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -363,7 +363,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
<FormItem>
<FormLabel>CPU</FormLabel>
<FormControl>
<Input placeholder="500m" {...field} />
<Input placeholder="1" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -376,7 +376,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
<FormItem>
<FormLabel>内存</FormLabel>
<FormControl>
<Input placeholder="512Mi" {...field} />
<Input placeholder="2Gi" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -98,7 +98,7 @@ export const RetentionDialog = memo(function RetentionDialog({ deployment, onClo
{liveConfig && (
<div>
<p className="mb-2 text-xs text-muted-foreground">{liveConfig.location}</p>
<pre className="max-h-[45vh] overflow-auto rounded-md bg-muted/50 p-3 font-mono text-xs whitespace-pre">{liveConfig.yaml}</pre>
<pre className="max-h-[45vh] max-w-full overflow-auto rounded-md bg-muted/50 p-3 font-mono text-xs whitespace-pre">{liveConfig.yaml}</pre>
</div>
)}
</div>
+21 -4
View File
@@ -15,6 +15,17 @@ const isPositiveInt = (v: string, min: number, max?: number) => {
return Number.isInteger(n) && n >= min && (max === undefined || n <= max)
}
// 解析存储容量为 GiB(支持 Gi/Mi/Ti),无法解析返回 null
const parseGiB = (v: string): number | null => {
const m = /^(\d+(?:\.\d+)?)\s*(Gi|Mi|Ti)$/i.exec(v.trim())
if (!m) return null
const n = Number(m[1])
const unit = m[2].toLowerCase()
if (unit === 'ti') return n * 1024
if (unit === 'mi') return n / 1024
return n
}
export const deploymentFormSchema = z
.object({
name: z.string().min(1, '请输入部署名称'),
@@ -36,7 +47,7 @@ export const deploymentFormSchema = z
.refine((v) => isPositiveInt(v, 1, 10), '副本数需为 1-10 之间的整数'),
resourcesCPU: z.string().min(1, '请填写 CPU 配额(如 500m)'),
resourcesMem: z.string().min(1, '请填写内存配额(如 512Mi)'),
storageSize: z.string().min(1, '请填写存储容量(如 10Gi)'),
storageSize: z.string().min(1, '请填写存储容量(如 20Gi)'),
})
.superRefine((data, ctx) => {
if (isK8sMode(data.mode) && !data.clusterID) {
@@ -51,6 +62,12 @@ export const deploymentFormSchema = z
if (isK8sMode(data.mode) && !data.namespace.trim()) {
ctx.addIssue({ code: 'custom', path: ['namespace'], message: '请填写命名空间' })
}
if (isK8sMode(data.mode)) {
const gi = parseGiB(data.storageSize)
if (gi === null || gi < 20) {
ctx.addIssue({ code: 'custom', path: ['storageSize'], message: '阿里云 ESSD 最小 20Gi,请填 20Gi 或以上' })
}
}
})
export type DeploymentFormValues = z.infer<typeof deploymentFormSchema>
@@ -69,7 +86,7 @@ export const DEFAULT_VALUES: DeploymentFormValues = {
authEnabled: false,
retentionDays: '30',
replicas: '1',
resourcesCPU: '500m',
resourcesMem: '512Mi',
storageSize: '10Gi',
resourcesCPU: '1',
resourcesMem: '2Gi',
storageSize: '20Gi',
}
@@ -20,7 +20,7 @@ function ContextRow({ row, current, terms }: { row: LogEntry; current?: boolean;
export function LogContextDialog({ context, terms, onClose }: { context: LogContext | null; terms: string[]; onClose: () => void }) {
return <Dialog open={context !== null} onOpenChange={open => { if (!open) onClose() }}>
<DialogContent className="flex max-h-[85vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl">
<DialogContent className="flex max-h-[85vh] flex-col gap-0 !overflow-hidden p-0 sm:max-w-4xl">
<DialogHeader className="border-b px-5 py-4 pr-12">
<DialogTitle>同一日志流的前后文</DialogTitle>
<DialogDescription>{context?.row.labels.filename ? '同一文件' : '同一日志流'} · 前后各最多 5 条 · 前后 5 分钟</DialogDescription>
@@ -24,7 +24,7 @@ export function LokiLabelBrowser({ sourceId, query, onQueryChange, start, end }:
const [filter, setFilter] = useState('')
const [loadingLabels, setLoadingLabels] = useState(false)
const [loadingValues, setLoadingValues] = useState(false)
const [scoped, setScoped] = useState(false)
const [scoped, setScoped] = useState(true)
const selected = useMemo(() => currentLabels(query), [query])
const startNs = Number.isFinite(new Date(start).getTime()) ? new Date(start).getTime() * 1e6 : undefined
const endNs = Number.isFinite(new Date(end).getTime()) ? new Date(end).getTime() * 1e6 : undefined
@@ -32,7 +32,20 @@ export function LokiLabelBrowser({ sourceId, query, onQueryChange, start, end }:
useEffect(() => {
if (!open || !sourceId) return
let active = true
logsApi.labels(sourceId, startNs, endNs).then(next => { if (active) setLabels(next) }).catch(() => { if (active) setLabels([]) }).finally(() => { if (active) setLoadingLabels(false) })
logsApi.labels(sourceId, startNs, endNs).then(next => {
if (!active) return
// namespace 优先,其次 app/pod/container,其余按字母序
const order = ['namespace', 'app', 'service_name', 'pod', 'container', 'job']
const ordered = [...next].sort((a, b) => {
const ia = order.indexOf(a), ib = order.indexOf(b)
return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib) || a.localeCompare(b)
})
setLabels(ordered)
if (!activeLabel) {
const first = ordered.includes('namespace') ? 'namespace' : ordered[0] || ''
if (first) { setActiveLabel(first); setLoadingValues(true) }
}
}).catch(() => { if (active) setLabels([]) }).finally(() => { if (active) setLoadingLabels(false) })
return () => { active = false }
}, [open, sourceId, startNs, endNs])
@@ -48,6 +61,16 @@ export function LokiLabelBrowser({ sourceId, query, onQueryChange, start, end }:
const shown = values.filter(value => value.toLowerCase().includes(filter.toLowerCase())).slice(0, 200)
// 选 namespace 时重置 selector(清掉旧 app/pod,避免跨 namespace 残留条件),保留后续管道
const choose = (label: string, value: string) => {
if (label === 'namespace') {
const j = query.indexOf('}')
onQueryChange(`{namespace=${JSON.stringify(value)}}${j >= 0 ? query.slice(j + 1) : ''}`)
} else {
onQueryChange(applyLabelFilter(query, label, value))
}
}
return <section className="overflow-hidden rounded-lg border bg-card" aria-label="Label 浏览器">
<button type="button" className="flex w-full items-center gap-2 px-4 py-3 text-left text-sm font-medium hover:bg-muted/50" aria-expanded={open} onClick={() => { setOpen(!open); if (!open) setLoadingLabels(true) }}>
{open ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />}
@@ -61,9 +84,9 @@ export function LokiLabelBrowser({ sourceId, query, onQueryChange, start, end }:
<div className="min-w-0 p-3">
{!activeLabel ? <p className="py-12 text-center text-sm text-muted-foreground">选择左侧标签,再搜索对应的值</p> : <><div className="mb-3 flex flex-wrap items-center gap-2"><strong className="text-sm">{activeLabel} 的值</strong><Input aria-label={`搜索 ${activeLabel} 的值`} className="h-8 max-w-sm" placeholder="搜索或输入标签值" value={filter} onChange={event => setFilter(event.target.value)} /></div>
<label className="mb-2 flex items-center gap-1.5 text-xs text-muted-foreground"><input type="checkbox" checked={scoped} onChange={event => setScoped(event.target.checked)} />按当前查询条件缩小候选</label>
<div className="grid max-h-48 gap-1 overflow-auto sm:grid-cols-2 xl:grid-cols-3">{loadingValues ? <p className="p-2 text-sm text-muted-foreground">加载中…</p> : shown.length === 0 ? <p className="p-2 text-sm text-muted-foreground">没有匹配值</p> : shown.map(value => <button key={value} type="button" title={value} onClick={() => onQueryChange(applyLabelFilter(query, activeLabel, value))} className={`truncate rounded px-2 py-1.5 text-left font-mono text-sm ${selected[activeLabel] === value ? 'bg-success/10 text-success' : 'hover:bg-muted'}`}>{value}</button>)}</div>
{filter.trim() && !values.includes(filter.trim()) && <button type="button" className="mt-2 rounded border px-2 py-1 text-xs text-primary hover:bg-muted" onClick={() => onQueryChange(applyLabelFilter(query, activeLabel, filter.trim()))}>使用“{filter.trim()}”作为值</button>}
<p className="mt-2 text-xs text-muted-foreground">候选来自当前时间范围内已写入 Loki 的日志;Kubernetes 存在的命名空间未必有日志。授权范围仍由服务端限制。</p>
<div className="grid max-h-48 gap-1 overflow-auto sm:grid-cols-2 xl:grid-cols-3">{loadingValues ? <p className="p-2 text-sm text-muted-foreground">加载中…</p> : shown.length === 0 ? <p className="p-2 text-sm text-muted-foreground">没有匹配值</p> : shown.map(value => <button key={value} type="button" title={value} onClick={() => choose(activeLabel, value)} className={`truncate rounded px-2 py-1.5 text-left font-mono text-sm ${selected[activeLabel] === value ? 'bg-success/10 text-success' : 'hover:bg-muted'}`}>{value}</button>)}</div>
{filter.trim() && !values.includes(filter.trim()) && <button type="button" className="mt-2 rounded border px-2 py-1 text-xs text-primary hover:bg-muted" onClick={() => choose(activeLabel, filter.trim())}>使用“{filter.trim()}”作为值</button>}
<p className="mt-2 text-xs text-muted-foreground">先选 namespace 可查该命名空间全部副本;再选 app / service_name 可缩小到某个服务(无 app 标签的工作负载用 namespace 即可)。候选来自当前时间范围内已写入 Loki 的日志;授权范围仍由服务端限制。</p>
</>}
</div>
</div>}
@@ -6,36 +6,69 @@ import { applyLabelFilter, extractSelector } from '../query-utils'
type Template = 'app' | 'error' | 'warn' | 'pod' | 'ingress'
function selectorOf(labels: Record<string, string>) {
const entries = Object.entries(labels).filter(([, value]) => value.trim())
if (entries.length === 0) return ''
return `{${entries.map(([key, value]) => `${key}=${JSON.stringify(value.trim())}`).join(',')}}`
}
export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }: {
sourceId: number
query: string
onQueryChange: (query: string) => void
compact?: boolean
}) {
const [app, setApp] = useState('')
const [namespace, setNamespace] = useState('')
const [app, setApp] = useState('')
const [pod, setPod] = useState('')
const [apps, setApps] = useState<string[]>([])
const [namespaces, setNamespaces] = useState<string[]>([])
const [apps, setApps] = useState<string[]>([])
const [pods, setPods] = useState<string[]>([])
// namespace 列表只加载一次
useEffect(() => {
if (!sourceId) return
let active = true
Promise.allSettled([
logsApi.labelValues(sourceId, 'app'),
logsApi.labelValues(sourceId, 'namespace'),
logsApi.labelValues(sourceId, 'pod'),
]).then(([nextApps, nextNamespaces, nextPods]) => {
if (active) {
setApps(nextApps.status === 'fulfilled' ? nextApps.value : [])
setNamespaces(nextNamespaces.status === 'fulfilled' ? nextNamespaces.value : [])
setPods(nextPods.status === 'fulfilled' ? nextPods.value : [])
}
})
logsApi.labelValues(sourceId, 'namespace')
.then(next => { if (active) setNamespaces(next) })
.catch(() => { if (active) setNamespaces([]) })
return () => { active = false }
}, [sourceId])
// app 候选:选了 namespace 就只在该命名空间内搜索
useEffect(() => {
if (!sourceId) return
let active = true
const selector = selectorOf({ namespace })
logsApi.labelValues(sourceId, 'app', selector)
.then(next => { if (active) setApps(next) })
.catch(() => { if (active) setApps([]) })
return () => { active = false }
}, [sourceId, namespace])
// pod 候选:按 namespace + app 收窄
useEffect(() => {
if (!sourceId) return
let active = true
const selector = selectorOf({ namespace, app })
logsApi.labelValues(sourceId, 'pod', selector)
.then(next => { if (active) setPods(next) })
.catch(() => { if (active) setPods([]) })
return () => { active = false }
}, [sourceId, namespace, app])
function chooseNamespace(value: string) {
setNamespace(value)
// 换命名空间后旧的 app / pod 条件失效
setApp('')
setPod('')
}
function chooseApp(value: string) {
setApp(value)
setPod('')
}
function generate(template: Template) {
let next = extractSelector(query) || '{}'
if (template === 'ingress') next = applyLabelFilter(next, 'namespace', namespace.trim() || 'ingress-nginx')
@@ -54,18 +87,18 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }:
return <section className="space-y-2" aria-label="快捷查询模板">
{!compact && <h2 className="text-sm font-medium">快捷生成 LogQL</h2>}
{!compact && <p className="text-xs text-muted-foreground">选 app、namespace 或副本,再生成可编辑的 LogQL。</p>}
{!compact && <p className="text-xs text-muted-foreground">先选 namespace,再选 app,最后选副本 pod;app 候选只来自所选命名空间。</p>}
<div className="flex flex-wrap items-end gap-2">
<label className="min-w-36 flex-1 text-xs text-muted-foreground">app
<Input className="mt-1 h-8" list="quick-log-apps" value={app} onChange={event => setApp(event.target.value)} placeholder="搜索或输入 app" />
</label>
<datalist id="quick-log-apps">{apps.map(value => <option value={value} key={value} />)}</datalist>
<label className="min-w-36 flex-1 text-xs text-muted-foreground">namespace
<Input className="mt-1 h-8" list="quick-log-namespaces" value={namespace} onChange={event => setNamespace(event.target.value)} placeholder="可选" />
<Input className="mt-1 h-8" list="quick-log-namespaces" value={namespace} onChange={event => chooseNamespace(event.target.value)} placeholder="先选命名空间" />
</label>
<datalist id="quick-log-namespaces">{namespaces.map(value => <option value={value} key={value} />)}</datalist>
<label className="min-w-36 flex-1 text-xs text-muted-foreground">app
<Input className="mt-1 h-8" list="quick-log-apps" value={app} onChange={event => chooseApp(event.target.value)} placeholder={namespace ? '搜索该命名空间下的 app' : '先选 namespace 可缩小范围'} />
</label>
<datalist id="quick-log-apps">{apps.map(value => <option value={value} key={value} />)}</datalist>
<label className="min-w-36 flex-1 text-xs text-muted-foreground">副本 pod
<Input className="mt-1 h-8" list="quick-log-pods" value={pod} onChange={event => setPod(event.target.value)} placeholder="可选" />
<Input className="mt-1 h-8" list="quick-log-pods" value={pod} onChange={event => setPod(event.target.value)} placeholder={app ? '搜索该 app 的副本' : '可选'} />
</label>
<datalist id="quick-log-pods">{pods.map(value => <option value={value} key={value} />)}</datalist>
</div>
@@ -75,7 +108,7 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }:
<Button size="sm" variant="outline" disabled={!hasScope} onClick={() => generate('warn')}>告警 warn</Button>
<Button size="sm" variant="outline" disabled={!pod.trim()} onClick={() => generate('pod')}>指定副本日志</Button>
<Button size="sm" variant="outline" onClick={() => generate('ingress')}>Ingress 访问日志</Button>
{!hasScope && <span className="self-center text-xs text-muted-foreground">先填写 app、namespace 或 pod,也可在 Label 浏览器选值</span>}
{!hasScope && <span className="self-center text-xs text-muted-foreground">先选 namespace,再选 app 或 pod,也可在 Label 浏览器选值</span>}
</div>
</section>
}
+18
View File
@@ -163,6 +163,20 @@ export default function LogsPage() {
<div className="space-y-6">
{error && <ErrorState message={error} onRetry={() => void reloadSources()} />}
{tab === 'query' && <section className="space-y-4">
{sources.length === 0 && (
<div className="rounded-xl border border-warning/40 bg-warning/10 p-4 text-sm">
<p className="font-medium text-warning">还没有可用的 Loki 数据源</p>
<p className="mt-1 text-muted-foreground">
请先到「数据源管理」关联 Loki:集群内可直接选「集群服务」(命名空间 / Service / 端口,无需 Ingress);
跨集群或公网查询,请先给 Loki 配置 Ingress 域名,再用「URL 直连」填写该域名。未关联数据源无法查询。
</p>
{admin && (
<Button size="sm" className="mt-2" variant="outline" onClick={() => setTab('sources')}>
去配置数据源
</Button>
)}
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<span className="flex h-10 items-center gap-2 rounded-md border bg-muted/40 px-3 text-sm font-semibold text-info"><span className="size-2 rounded-full bg-info" />LogQL</span>
<div className="min-w-[260px] flex-1"><QueryInput key={sourceId} value={query} onChange={setQuery} onSubmit={() => void search()} sourceId={sourceId} history={queryHistory} /></div>
@@ -180,6 +194,10 @@ export default function LogsPage() {
{admin && tab === 'access' && <PolicyGroupsPage sources={sources} />}
{admin && tab === 'sources' && <section className="rounded-xl border bg-card p-4 space-y-4" aria-label="数据源管理">
<h2 className="font-semibold">Loki 数据源</h2>
<p className="text-xs text-muted-foreground">
提示:Loki 部署在集群内时,可直接添加「集群服务」数据源(无需 Ingress);跨集群 / 公网查询需先为 Loki 配置
Ingress 域名,再用「URL 直连」关联。未关联数据源时,查询页无法使用。
</p>
{/* 列表:平台部署自动注册的固定展示(🔒 不可编辑);手动添加的可编辑 */}
<div className="space-y-2">
@@ -14,6 +14,13 @@ const MODES = [
{ value: 'custom', label: '私有仓库', desc: '自有 registry 前缀(如华为云 SWR)' },
] as const
const COMPONENTS = [
{ key: 'loki', label: 'Loki', repo: 'grafana/loki', tagHint: '跟随部署版本' },
{ key: 'grafana', label: 'Grafana', repo: 'grafana/grafana', tagHint: '跟随部署版本' },
{ key: 'promtail', label: 'Promtail', repo: 'grafana/promtail', tagHint: '3.4.0' },
{ key: 'fluentbit', label: 'Fluent Bit', repo: 'fluent/fluent-bit', tagHint: '5.1.5' },
] as const
/**
* 镜像源设置 — 影响平台拉取的全部镜像(Loki/Grafana/Promtail/Fluent Bit),
* 参照 kuboard 的国内/海外选择;实时预览四类镜像的生效地址。
@@ -40,6 +47,7 @@ export const ImageRegistrySection = memo(function ImageRegistrySection() {
const saved = await settingsApi.saveImageRegistry({
mode: cfg.mode,
customHost: cfg.mode === 'custom' ? customHost : undefined,
images: cfg.images,
})
setCfg(saved)
toast.success('镜像源已保存,后续部署/采集将使用新镜像地址')
@@ -91,6 +99,43 @@ export const ImageRegistrySection = memo(function ImageRegistrySection() {
</div>
)}
{/* 各服务镜像自定义 */}
<div className="space-y-2">
<Label>各服务镜像(可单独覆盖仓库 / Tag)</Label>
<div className="space-y-2">
{COMPONENTS.map(({ key, label, repo, tagHint }) => {
const img = cfg.images?.[key] || { repository: '', tag: '' }
const update = (patch: Partial<{ repository: string; tag: string }>) =>
setCfg({
...cfg,
images: { ...(cfg.images || {}), [key]: { ...img, ...patch } },
})
return (
<div key={key} className="grid items-center gap-2 sm:grid-cols-[110px_1fr_150px]">
<span className="text-sm font-medium">{label}</span>
<Input
aria-label={`${label} 镜像仓库`}
value={img.repository}
placeholder={repo}
onChange={(e) => update({ repository: e.target.value })}
className="font-mono text-xs"
/>
<Input
aria-label={`${label} 镜像 Tag`}
value={img.tag}
placeholder={tagHint}
onChange={(e) => update({ tag: e.target.value })}
className="font-mono text-xs"
/>
</div>
)
})}
</div>
<p className="text-xs text-muted-foreground">
仓库填镜像路径(如 <code>grafana/loki</code>);Tag 留空时 Loki / Grafana 跟随部署版本,Promtail / Fluent Bit 用内置默认。
</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>
+6 -2
View File
@@ -68,12 +68,13 @@ const SettingsPage = memo(function SettingsPage() {
)
return (
<PageContainer title="系统设置" description="通用配置、告警通知与 LDAP 接入">
<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>
<TabsTrigger value="registry">默认镜像源</TabsTrigger>
</TabsList>
<TabsContent value="general" className="space-y-6">
@@ -175,13 +176,16 @@ const SettingsPage = memo(function SettingsPage() {
</TabsContent>
<TabsContent value="notify" className="space-y-6">
<ImageRegistrySection />
<NotifySection />
</TabsContent>
<TabsContent value="ldap">
<LdapSection />
</TabsContent>
<TabsContent value="registry" className="space-y-6">
<ImageRegistrySection />
</TabsContent>
</Tabs>
</PageContainer>
)
@@ -29,7 +29,7 @@ import { STORAGE_TYPE_ICONS, STORAGE_TYPE_ICON_CLASS, STORAGE_TYPES } from '../c
// 表单 schema:zod 为唯一校验源,消息中文且具体
const storageFormSchema = z.object({
name: z.string().min(1, '请输入存储配置名称'),
storageType: z.enum(['s3', 'oss', 'minio', 'rustfs']),
storageType: z.enum(['s3', 'oss', 'cos', 'obs', 'minio', 'rustfs']),
endpoint: z.string().optional(),
bucket: z.string().min(1, '请填写 Bucket 名称'),
region: z.string().optional(),
@@ -82,7 +82,8 @@ export const StorageFormDialog = memo(function StorageFormDialog({
})
const storageType = form.watch('storageType')
// 区域仅对公有云(s3/oss)有意义;minio/rustfs 为本地部署不显示、不提交
const isCloudStorage = storageType === 's3' || storageType === 'oss'
const isCloudStorage =
storageType === 's3' || storageType === 'oss' || storageType === 'cos' || storageType === 'obs'
// 打开时重置;编辑模式回填(密钥留空=保留旧值)
useEffect(() => {
@@ -218,7 +219,11 @@ export const StorageFormDialog = memo(function StorageFormDialog({
? 's3.amazonaws.com'
: storageType === 'oss'
? 'oss-cn-hangzhou.aliyuncs.com'
: 'http://10.0.0.8:9000'
: storageType === 'cos'
? 'cos.ap-guangzhou.myqcloud.com'
: storageType === 'obs'
? 'obs.cn-north-4.myhuaweicloud.com'
: 'http://10.0.0.8:9000'
}
{...field}
/>
@@ -236,7 +241,12 @@ export const StorageFormDialog = memo(function StorageFormDialog({
<FormItem>
<FormLabel>Bucket *</FormLabel>
<FormControl>
<Input placeholder="loki-logs" {...field} />
<Input
placeholder={
storageType === 'cos' ? 'my-bucket-1250000000' : 'loki-logs'
}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -250,7 +260,20 @@ export const StorageFormDialog = memo(function StorageFormDialog({
<FormItem>
<FormLabel>区域</FormLabel>
<FormControl>
<Input placeholder="us-east-1" {...field} />
<Input
placeholder={
storageType === 's3'
? 'us-east-1'
: storageType === 'oss'
? 'oss-cn-hangzhou'
: storageType === 'cos'
? 'ap-guangzhou'
: storageType === 'obs'
? 'cn-north-4'
: 'us-east-1'
}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
+7 -1
View File
@@ -4,6 +4,8 @@ import { Cloud, Database, Package, type LucideIcon } from 'lucide-react'
export const STORAGE_TYPE_ICONS: Record<string, LucideIcon> = {
s3: Cloud,
oss: Cloud,
cos: Cloud,
obs: Cloud,
minio: Database,
rustfs: Package,
}
@@ -11,6 +13,8 @@ export const STORAGE_TYPE_ICONS: Record<string, LucideIcon> = {
export const STORAGE_TYPE_ICON_CLASS: Record<string, string> = {
s3: 'text-warning',
oss: 'text-destructive',
cos: 'text-info',
obs: 'text-info',
minio: 'text-info',
rustfs: 'text-info',
}
@@ -18,8 +22,10 @@ export const STORAGE_TYPE_ICON_CLASS: Record<string, string> = {
export const STORAGE_TYPE_COLORS: Record<string, string> = {
s3: 'bg-warning/10 text-warning',
oss: 'bg-destructive/10 text-destructive',
cos: 'bg-info/10 text-info',
obs: 'bg-info/10 text-info',
minio: 'bg-info/10 text-info',
rustfs: 'bg-info/10 text-info',
}
export const STORAGE_TYPES = ['s3', 'oss', 'minio', 'rustfs'] as const
export const STORAGE_TYPES = ['s3', 'oss', 'cos', 'obs', 'minio', 'rustfs'] as const
+1 -1
View File
@@ -94,7 +94,7 @@ export interface HostInfo {
export interface StorageConfig {
id: number;
name: string;
storageType: 's3' | 'oss' | 'minio' | 'rustfs';
storageType: 's3' | 'oss' | 'cos' | 'obs' | 'minio' | 'rustfs';
endpoint: string;
bucket: string;
region: string;