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
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user