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

@@ -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">