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:
1 parent
2d73661dd1
commit
9f7c522157
50 files changed
+1346
-149
No files matched your search
@@ -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),
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user