feat: 品牌统一 LokiStack + 全站中英 i18n + 用户批量删除 + 主题定时切换与浅色侧栏
- 品牌: Go 模块/数据文件/二进制/构建部署资产全部 opsx→lokistack;前端 localStorage 命名空间迁移(读旧写新,含按用户 id 前缀迁移);修复 nginx upstream 与 Makefile 引用已改名服务的断裂 - i18n: react-i18next 落地,common + 11 个页面域命名空间全站中英(菜单/顶栏/登录/列表页/表单/确认弹窗/状态徽章);设置页语言即时切换;修复词典自注册导致的循环导入白屏 - 用户管理: 批量删除(POST /users/batch-delete,不删自己/最后管理员,部分失败不阻断)+ 行复选/全选/批量工具条 - 主题: 浅色侧栏(中性灰激活态)、四态模式(浅色/深色/跟随系统/定时切换,按时区判定)、顶栏太阳月亮二态、设置页紧凑分段选择器 - UI: 登录记住用户名、CardActionButton 统一五页卡片操作钮、shadcn Badge 替换手写徽章、语义色修正 - 清理: 移除主机 Web 终端(前后端)、k8s-helm/Helm 遗留代码与字段、README 重写精简 - 修复: 侧栏与日志告警 order 重复、路由标题 i18n key 未翻译、词典双逗号等
This commit is contained in:
1 parent
02d1bd87a7
commit
f3733f0bfc
201 files changed
+5438
-2388
No files matched your search
@@ -1,10 +1,14 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { PageContainer } from '@/components/PageContainer'
|
||||
import { ErrorState } from '@/components/ErrorState'
|
||||
import { getUser } from '@/lib/auth-storage'
|
||||
import i18n from '@/i18n'
|
||||
import zhLogs from '@/i18n/locales/zh/logs'
|
||||
import enLogs from '@/i18n/locales/en/logs'
|
||||
import { logsApi, type LogEntry, type LogQuery, type LogSource } from '@/api/logs'
|
||||
import { clusterApi } from '@/api'
|
||||
import type { Cluster } from '@/types'
|
||||
@@ -19,12 +23,17 @@ import { LogResultsPanel } from './components/LogResultsPanel'
|
||||
import { literalFilters } from './query-utils'
|
||||
import { readQueryHistory, rememberQuery } from './query-history'
|
||||
|
||||
// 注册 logs 命名空间(域词典集中在页面入口挂载,域内组件统一 useTranslation('logs'))
|
||||
i18n.addResourceBundle('zh', 'logs', zhLogs)
|
||||
i18n.addResourceBundle('en', 'logs', enLogs)
|
||||
|
||||
const toLocal = (date: Date) => new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 19)
|
||||
const ns = (ms: number) => Math.round(ms).toString() + '000000'
|
||||
|
||||
type Bucket = { timestamp: string; count: number }
|
||||
|
||||
export default function LogsPage() {
|
||||
const { t } = useTranslation('logs')
|
||||
const admin = getUser()?.role === 'admin'
|
||||
const [sources, setSources] = useState<LogSource[]>([])
|
||||
const [sourceId, setSourceId] = useState(0)
|
||||
@@ -55,20 +64,20 @@ export default function LogsPage() {
|
||||
const [clusters, setClusters] = useState<Cluster[]>([])
|
||||
useEffect(() => { if (admin) clusterApi.list({ page: 1, pageSize: 100 }).then(r => setClusters(r.list)).catch(() => setClusters([])) }, [admin])
|
||||
|
||||
async function reloadSources() {
|
||||
const reloadSources = useCallback(async () => {
|
||||
try {
|
||||
const result = await logsApi.sources()
|
||||
setSources(result.list)
|
||||
setSourceId(current => result.list.some(s => s.id === current) ? current : (result.list.find(s => s.isDefault)?.id ?? result.list[0]?.id ?? 0))
|
||||
} catch (e) { setError(e instanceof Error ? e.message : '加载数据源失败') }
|
||||
}
|
||||
useEffect(() => { void reloadSources() }, [])
|
||||
} catch (e) { setError(e instanceof Error ? e.message : t('source.loadFailed')) }
|
||||
}, [t])
|
||||
useEffect(() => { void reloadSources() }, [reloadSources])
|
||||
useEffect(() => { requestVersion.current++; setEntries([]); setBuckets([]); setHistogramLoading(false); setHistogramError(false); setCursor(''); setPageIndex(0); setPageCursors(['']); setSearched(false); setLoading(false) }, [sourceId])
|
||||
|
||||
|
||||
function range(): { start: number; end: number } | null {
|
||||
const a = new Date(start).getTime(), b = new Date(end).getTime()
|
||||
if (!Number.isFinite(a) || !Number.isFinite(b) || a >= b) { setError('请选择有效的起止时间'); return null }
|
||||
if (!Number.isFinite(a) || !Number.isFinite(b) || a >= b) { setError(t('toolbar.rangeInvalid')); return null }
|
||||
return { start: Number(ns(a)), end: Number(ns(b)) }
|
||||
}
|
||||
async function search(nextCursor = '', quiet = false, selectedRange?: { start: number; end: number }, overrideQuery?: string, targetPage?: number) {
|
||||
@@ -102,7 +111,7 @@ export default function LogsPage() {
|
||||
}).catch(() => { if (version === requestVersion.current) setHistogramError(true) })
|
||||
.finally(() => { if (version === requestVersion.current) setHistogramLoading(false) })
|
||||
}
|
||||
} catch (e) { if (!quiet && version === requestVersion.current) setError(e instanceof Error ? e.message : '查询失败') }
|
||||
} catch (e) { if (!quiet && version === requestVersion.current) setError(e instanceof Error ? e.message : t('toolbar.queryFailed')) }
|
||||
finally { if (version === requestVersion.current) { setLoading(false); setLoadingStartedAt(null) } }
|
||||
}
|
||||
useEffect(() => {
|
||||
@@ -130,12 +139,12 @@ export default function LogsPage() {
|
||||
try {
|
||||
const result = await logsApi.context({ sourceId, timestampNs: row.timestampNs, labels: row.labels })
|
||||
setContext({ row, before: result.before.slice().reverse(), after: result.after })
|
||||
} catch (e) { toast.error(e instanceof Error ? e.message : '读取上下文失败') }
|
||||
} catch (e) { toast.error(e instanceof Error ? e.message : t('context.loadFailed')) }
|
||||
}
|
||||
function saveSource() {
|
||||
setSaving(true)
|
||||
const action = sourceForm.id ? logsApi.updateSource(sourceForm.id, sourceForm) : logsApi.createSource(sourceForm)
|
||||
action.then(() => { setSourceForm({ name: '', targetType: 'url', url: '', authType: 'none', access: [] }); void reloadSources() }).catch(e => toast.error(e instanceof Error ? e.message : '保存失败')).finally(() => setSaving(false))
|
||||
action.then(() => { setSourceForm({ name: '', targetType: 'url', url: '', authType: 'none', access: [] }); void reloadSources() }).catch(e => toast.error(e instanceof Error ? e.message : t('source.saveFailed'))).finally(() => setSaving(false))
|
||||
}
|
||||
const highlightTerms = useMemo(() => literalFilters(executedQuery), [executedQuery])
|
||||
const applyDomainFilter = (domain: string) => {
|
||||
@@ -148,15 +157,15 @@ export default function LogsPage() {
|
||||
void search('', false, { start: Number(ns(from)), end: Number(ns(to)) })
|
||||
}
|
||||
|
||||
const pageTitle = tab === 'query' ? 'Loki 查询' : tab === 'access' ? '查询授权' : '数据源管理'
|
||||
const description = tab === 'query' ? '按日志流和时间范围检索;拖动时间线缩小范围,点日志查看前后文。' : tab === 'access' ? '用策略组复用日志范围,并一次关联多个用户。' : '配置 Loki 连接与认证方式。'
|
||||
const pageTitle = tab === 'query' ? t('tabs.query') : tab === 'access' ? t('tabs.access') : t('tabs.sources')
|
||||
const description = tab === 'query' ? t('tabs.descQuery') : tab === 'access' ? t('tabs.descAccess') : t('tabs.descSources')
|
||||
|
||||
return (
|
||||
<div className="-m-4 md:-m-6">
|
||||
<nav aria-label="日志中心导航" className="flex flex-wrap gap-1 border-b bg-card px-4 py-3 md:px-6">
|
||||
<Button variant={tab === 'query' ? 'default' : 'ghost'} onClick={() => setTab('query')}>Loki 查询</Button>
|
||||
{admin && <Button variant={tab === 'access' ? 'default' : 'ghost'} onClick={() => setTab('access')}>查询授权</Button>}
|
||||
{admin && <Button variant={tab === 'sources' ? 'default' : 'ghost'} onClick={() => setTab('sources')}>数据源管理</Button>}
|
||||
<nav aria-label={t('nav.aria')} className="flex flex-wrap gap-1 border-b bg-card px-4 py-3 md:px-6">
|
||||
<Button variant={tab === 'query' ? 'default' : 'ghost'} onClick={() => setTab('query')}>{t('tabs.query')}</Button>
|
||||
{admin && <Button variant={tab === 'access' ? 'default' : 'ghost'} onClick={() => setTab('access')}>{t('tabs.access')}</Button>}
|
||||
{admin && <Button variant={tab === 'sources' ? 'default' : 'ghost'} onClick={() => setTab('sources')}>{t('tabs.sources')}</Button>}
|
||||
</nav>
|
||||
<div className="p-4 md:p-6">
|
||||
<PageContainer title={tab === 'query' ? undefined : pageTitle} description={description}>
|
||||
@@ -165,14 +174,11 @@ export default function LogsPage() {
|
||||
{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>
|
||||
<p className="font-medium text-warning">{t('source.missingTitle')}</p>
|
||||
<p className="mt-1 text-muted-foreground">{t('source.missingBody')}</p>
|
||||
{admin && (
|
||||
<Button size="sm" className="mt-2" variant="outline" onClick={() => setTab('sources')}>
|
||||
去配置数据源
|
||||
{t('source.goConfigure')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -180,24 +186,21 @@ export default function LogsPage() {
|
||||
<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>
|
||||
<Button className="h-10 bg-success text-success-foreground hover:bg-success/90" disabled={!sourceId || !query.trim() || loading} onClick={() => void search()}>{loading ? <Loader2 className="mr-1 size-4 animate-spin" /> : <Play className="mr-1 size-4" />}{loading ? '查询中…' : '查询'}</Button>
|
||||
<Button className="h-10" variant={tail ? 'destructive' : 'outline'} onClick={() => setTail(!tail)} disabled={!sourceId || (!tail && !query.trim())}><Radio className="mr-1 size-4" />{tail ? '停止 Tail' : 'Tail'}</Button>
|
||||
<Button className="h-10 bg-success text-success-foreground hover:bg-success/90" disabled={!sourceId || !query.trim() || loading} onClick={() => void search()}>{loading ? <Loader2 className="mr-1 size-4 animate-spin" /> : <Play className="mr-1 size-4" />}{loading ? t('toolbar.running') : t('toolbar.run')}</Button>
|
||||
<Button className="h-10" variant={tail ? 'destructive' : 'outline'} onClick={() => setTail(!tail)} disabled={!sourceId || (!tail && !query.trim())}><Radio className="mr-1 size-4" />{tail ? t('toolbar.tailStop') : t('toolbar.tail')}</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<TimeRangePicker start={start} end={end} onChange={(nextStart, nextEnd) => { setStart(nextStart); setEnd(nextEnd); setTail(false) }} />
|
||||
<select aria-label="数据源" className="h-9 min-w-40 rounded-md border bg-background px-3 text-sm text-foreground" value={sourceId} onChange={event => setSourceId(Number(event.target.value))}>{sources.length === 0 && <option value={0}>暂无可用数据源</option>}{sources.map(source => <option key={source.id} value={source.id}>{source.name}</option>)}</select>
|
||||
<label className="flex items-center gap-1 text-xs text-muted-foreground">每页条数<Input type="number" min={1} max={1000} value={limit} onChange={event => setLimit(Math.min(1000, Math.max(1, Number(event.target.value))))} className="h-9 w-24" /></label>
|
||||
<span className="text-xs text-muted-foreground">每页最多 1000 条;翻页只保留当前页。大范围先看时间线,再拖选高峰时段。</span>
|
||||
<select aria-label={t('toolbar.sourceAria')} className="h-9 min-w-40 rounded-md border bg-background px-3 text-sm text-foreground" value={sourceId} onChange={event => setSourceId(Number(event.target.value))}>{sources.length === 0 && <option value={0}>{t('toolbar.sourceEmptyOption')}</option>}{sources.map(source => <option key={source.id} value={source.id}>{source.name}</option>)}</select>
|
||||
<label className="flex items-center gap-1 text-xs text-muted-foreground">{t('toolbar.limitLabel')}<Input type="number" min={1} max={1000} value={limit} onChange={event => setLimit(Math.min(1000, Math.max(1, Number(event.target.value))))} className="h-9 w-24" /></label>
|
||||
<span className="text-xs text-muted-foreground">{t('toolbar.limitHint')}</span>
|
||||
</div>
|
||||
<LokiLabelBrowser key={sourceId} sourceId={sourceId} query={query} onQueryChange={setQuery} start={start} end={end} />
|
||||
</section>}
|
||||
{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>
|
||||
{admin && tab === 'sources' && <section className="rounded-xl border bg-card p-4 space-y-4" aria-label={t('source.sectionAria')}>
|
||||
<h2 className="font-semibold">{t('source.heading')}</h2>
|
||||
<p className="text-xs text-muted-foreground">{t('source.hint')}</p>
|
||||
|
||||
{/* 列表:平台部署自动注册的固定展示(🔒 不可编辑);手动添加的可编辑 */}
|
||||
<div className="space-y-2">
|
||||
@@ -209,9 +212,9 @@ export default function LogsPage() {
|
||||
<Lock className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||
<span className="font-medium">{source.name}</span>
|
||||
<span className="text-muted-foreground truncate">
|
||||
{cluster ? `集群 ${cluster.name}` : ''}{source.namespace ? ` · ${source.namespace}` : ''} · {source.service || 'loki'}:{source.port || 3100}
|
||||
{cluster ? t('source.managedCluster', { name: cluster.name }) : ''}{source.namespace ? ` · ${source.namespace}` : ''} · {source.service || 'loki'}:{source.port || 3100}
|
||||
</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground flex-shrink-0">平台部署 · 随部署管理</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground flex-shrink-0">{t('source.managedBadge')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<Button key={source.id} size="sm" variant="outline" onClick={() => setSourceForm({ ...source, headers: Object.fromEntries((source.headerNames ?? []).map(name => [name, ''])) })}>
|
||||
@@ -219,38 +222,38 @@ export default function LogsPage() {
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
<Button size="sm" variant="outline" onClick={() => setSourceForm({ name: '', targetType: 'url', url: '', authType: 'none', access: [] })}>+ 手动添加</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setSourceForm({ name: '', targetType: 'url', url: '', authType: 'none', access: [] })}>{t('source.add')}</Button>
|
||||
</div>
|
||||
|
||||
{/* 表单:URL 直连 或 集群服务(集群名下拉,服务默认 loki) */}
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<label>名称<Input value={sourceForm.name ?? ''} onChange={e => setSourceForm({ ...sourceForm, name: e.target.value })} /></label>
|
||||
<label>{t('source.nameLabel')}<Input value={sourceForm.name ?? ''} onChange={e => setSourceForm({ ...sourceForm, name: e.target.value })} /></label>
|
||||
<div className="flex gap-2 items-end">
|
||||
<button type="button" className={`px-3 py-1.5 rounded-md text-sm border ${sourceForm.targetType === 'url' ? 'border-primary bg-primary/10 text-primary' : 'border-border'}`} onClick={() => setSourceForm({ ...sourceForm, targetType: 'url' })}>URL 直连</button>
|
||||
<button type="button" className={`px-3 py-1.5 rounded-md text-sm border ${sourceForm.targetType === 'cluster-svc' ? 'border-primary bg-primary/10 text-primary' : 'border-border'}`} onClick={() => setSourceForm({ ...sourceForm, targetType: 'cluster-svc', service: sourceForm.service || 'loki', port: sourceForm.port || 3100 })}>集群服务</button>
|
||||
<button type="button" className={`px-3 py-1.5 rounded-md text-sm border ${sourceForm.targetType === 'url' ? 'border-primary bg-primary/10 text-primary' : 'border-border'}`} onClick={() => setSourceForm({ ...sourceForm, targetType: 'url' })}>{t('source.targetUrl')}</button>
|
||||
<button type="button" className={`px-3 py-1.5 rounded-md text-sm border ${sourceForm.targetType === 'cluster-svc' ? 'border-primary bg-primary/10 text-primary' : 'border-border'}`} onClick={() => setSourceForm({ ...sourceForm, targetType: 'cluster-svc', service: sourceForm.service || 'loki', port: sourceForm.port || 3100 })}>{t('source.targetClusterSvc')}</button>
|
||||
</div>
|
||||
{sourceForm.targetType === 'url'
|
||||
? <label className="sm:col-span-2">Loki 地址<Input value={sourceForm.url ?? ''} onChange={e => setSourceForm({ ...sourceForm, url: e.target.value })} placeholder="http://10.0.0.8:3100" /></label>
|
||||
? <label className="sm:col-span-2">{t('source.addressLabel')}<Input value={sourceForm.url ?? ''} onChange={e => setSourceForm({ ...sourceForm, url: e.target.value })} placeholder="http://10.0.0.8:3100" /></label>
|
||||
: <>
|
||||
<label>集群<select className="block h-9 w-full rounded-md border bg-background px-3" value={sourceForm.clusterID ?? ''} onChange={e => setSourceForm({ ...sourceForm, clusterID: Number(e.target.value) })}>
|
||||
<option value="">选择集群…</option>
|
||||
<label>{t('source.clusterLabel')}<select className="block h-9 w-full rounded-md border bg-background px-3" value={sourceForm.clusterID ?? ''} onChange={e => setSourceForm({ ...sourceForm, clusterID: Number(e.target.value) })}>
|
||||
<option value="">{t('source.clusterPlaceholder')}</option>
|
||||
{clusters.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select></label>
|
||||
<label>命名空间<Input value={sourceForm.namespace ?? ''} onChange={e => setSourceForm({ ...sourceForm, namespace: e.target.value })} placeholder="log" /></label>
|
||||
<label>Service<Input value={sourceForm.service ?? ''} onChange={e => setSourceForm({ ...sourceForm, service: e.target.value })} placeholder="loki" /></label>
|
||||
<label>端口<Input type="number" value={sourceForm.port ?? 3100} onChange={e => setSourceForm({ ...sourceForm, port: Number(e.target.value) })} /></label>
|
||||
<label>{t('source.namespaceLabel')}<Input value={sourceForm.namespace ?? ''} onChange={e => setSourceForm({ ...sourceForm, namespace: e.target.value })} placeholder="log" /></label>
|
||||
<label>{t('source.serviceLabel')}<Input value={sourceForm.service ?? ''} onChange={e => setSourceForm({ ...sourceForm, service: e.target.value })} placeholder="loki" /></label>
|
||||
<label>{t('source.portLabel')}<Input type="number" value={sourceForm.port ?? 3100} onChange={e => setSourceForm({ ...sourceForm, port: Number(e.target.value) })} /></label>
|
||||
</>}
|
||||
<label>认证方式<select className="block h-9 w-full rounded-md border bg-background px-3" value={sourceForm.authType ?? 'none'} onChange={e => setSourceForm({ ...sourceForm, authType: e.target.value })}><option value="none">无认证</option><option value="basic">Basic Auth</option><option value="bearer">Bearer Token</option><option value="headers">自定义 Headers</option></select></label>
|
||||
{sourceForm.authType === 'basic' && <><label>用户名<Input value={sourceForm.basicUser ?? ''} onChange={e => setSourceForm({ ...sourceForm, basicUser: e.target.value })} /></label><label>密码<Input type="password" value={sourceForm.basicPass ?? ''} onChange={e => setSourceForm({ ...sourceForm, basicPass: e.target.value })} /></label></>}
|
||||
{sourceForm.authType === 'bearer' && <label>令牌<Input type="password" value={sourceForm.bearerToken ?? ''} onChange={e => setSourceForm({ ...sourceForm, bearerToken: e.target.value })} /></label>}
|
||||
{sourceForm.authType === 'headers' && <label className="sm:col-span-2">自定义 Headers(JSON 对象)<textarea key={sourceForm.id ?? 'new'} className="block w-full rounded-md border bg-background p-2 font-mono text-sm" rows={3} defaultValue={JSON.stringify(sourceForm.headers ?? {}, null, 2)} onBlur={e => { try { const headers = JSON.parse(e.target.value) as Record<string, string>; if (!headers || Array.isArray(headers) || typeof headers !== 'object' || Object.values(headers).some(value => typeof value !== 'string')) throw Error(); setSourceForm(current => ({ ...current, headers })) } catch { toast.error('Headers 必须是字符串键值 JSON 对象') } }} placeholder='{"X-Scope-OrgID":"tenant"}' /></label>}
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={!!sourceForm.skipTLSVerify} onChange={e => setSourceForm({ ...sourceForm, skipTLSVerify: e.target.checked })} />跳过 TLS 校验</label>
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={!!sourceForm.isDefault} onChange={e => setSourceForm({ ...sourceForm, isDefault: e.target.checked })} />设为默认</label>
|
||||
<label>{t('source.authLabel')}<select className="block h-9 w-full rounded-md border bg-background px-3" value={sourceForm.authType ?? 'none'} onChange={e => setSourceForm({ ...sourceForm, authType: e.target.value })}><option value="none">{t('source.authNone')}</option><option value="basic">{t('source.authBasic')}</option><option value="bearer">{t('source.authBearer')}</option><option value="headers">{t('source.authHeaders')}</option></select></label>
|
||||
{sourceForm.authType === 'basic' && <><label>{t('source.usernameLabel')}<Input value={sourceForm.basicUser ?? ''} onChange={e => setSourceForm({ ...sourceForm, basicUser: e.target.value })} /></label><label>{t('source.passwordLabel')}<Input type="password" value={sourceForm.basicPass ?? ''} onChange={e => setSourceForm({ ...sourceForm, basicPass: e.target.value })} /></label></>}
|
||||
{sourceForm.authType === 'bearer' && <label>{t('source.tokenLabel')}<Input type="password" value={sourceForm.bearerToken ?? ''} onChange={e => setSourceForm({ ...sourceForm, bearerToken: e.target.value })} /></label>}
|
||||
{sourceForm.authType === 'headers' && <label className="sm:col-span-2">{t('source.headersLabel')}<textarea key={sourceForm.id ?? 'new'} className="block w-full rounded-md border bg-background p-2 font-mono text-sm" rows={3} defaultValue={JSON.stringify(sourceForm.headers ?? {}, null, 2)} onBlur={e => { try { const headers = JSON.parse(e.target.value) as Record<string, string>; if (!headers || Array.isArray(headers) || typeof headers !== 'object' || Object.values(headers).some(value => typeof value !== 'string')) throw Error(); setSourceForm(current => ({ ...current, headers })) } catch { toast.error(t('source.headersInvalid')) } }} placeholder='{"X-Scope-OrgID":"tenant"}' /></label>}
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={!!sourceForm.skipTLSVerify} onChange={e => setSourceForm({ ...sourceForm, skipTLSVerify: e.target.checked })} />{t('source.skipTLS')}</label>
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={!!sourceForm.isDefault} onChange={e => setSourceForm({ ...sourceForm, isDefault: e.target.checked })} />{t('source.setDefault')}</label>
|
||||
</div>
|
||||
<div className="flex gap-2"><Button variant="outline" onClick={() => logsApi.testSource(sourceForm).then(result => result.success ? toast.info(result.message) : toast.error(result.message)).catch(e => toast.error(e.message))}>测试连接</Button><Button disabled={saving || !sourceForm.name} onClick={saveSource}>保存数据源</Button>{sourceForm.id && <Button variant="outline" onClick={() => logsApi.deleteSource(sourceForm.id!).then(() => { void reloadSources(); setSourceForm({ name: '', targetType: 'url', url: '', authType: 'none', access: [] }) }).catch(e => toast.error(e.message))}>删除</Button>}</div>
|
||||
<div className="flex gap-2"><Button variant="outline" onClick={() => logsApi.testSource(sourceForm).then(result => result.success ? toast.info(result.message) : toast.error(result.message)).catch(e => toast.error(e.message))}>{t('source.test')}</Button><Button disabled={saving || !sourceForm.name} onClick={saveSource}>{t('source.save')}</Button>{sourceForm.id && <Button variant="outline" onClick={() => logsApi.deleteSource(sourceForm.id!).then(() => { void reloadSources(); setSourceForm({ name: '', targetType: 'url', url: '', authType: 'none', access: [] }) }).catch(e => toast.error(e.message))}>{t('source.remove')}</Button>}</div>
|
||||
</section>}
|
||||
{tab === 'query' && histogramLoading && <div role="status" className="flex items-center gap-2 rounded-xl border bg-card p-4 text-sm text-muted-foreground"><Loader2 className="size-4 animate-spin text-info" />正在统计整个时间范围的日志量,日志页可继续浏览。</div>}
|
||||
{tab === 'query' && histogramError && <p className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">日志量统计超时或失败;日志页仍可使用。可缩小时间范围后重新查询。</p>}
|
||||
{tab === 'query' && histogramLoading && <div role="status" className="flex items-center gap-2 rounded-xl border bg-card p-4 text-sm text-muted-foreground"><Loader2 className="size-4 animate-spin text-info" />{t('volume.statsLoading')}</div>}
|
||||
{tab === 'query' && histogramError && <p className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">{t('volume.statsError')}</p>}
|
||||
{tab === 'query' && buckets.length > 0 && <LogVolumeTimeline key={`${buckets[0].timestamp}-${buckets.length}`} buckets={buckets} onSelect={selectTimeRange} />}
|
||||
{tab === 'query' && <LogResultsPanel key={`${sourceId}:${executedQuery}:${executedRange?.start}:${executedRange?.end}`} entries={entries} buckets={buckets} start={executedRange ? toLocal(new Date(executedRange.start / 1e6)) : start} end={executedRange ? toLocal(new Date(executedRange.end / 1e6)) : end} searched={searched} hasSource={sources.length > 0} pageIndex={pageIndex} hasNextPage={Boolean(cursor)} loading={loading} loadingStartedAt={loadingStartedAt} query={query} terms={highlightTerms} onQueryChange={setQuery} onApplyDomain={applyDomainFilter} onShowContext={row => void showContext(row)} onSearch={() => void search()} onNextPage={() => void search(cursor, false, undefined, undefined, pageIndex + 1)} onPreviousPage={() => void search(pageCursors[pageIndex - 1] ?? '', false, undefined, undefined, pageIndex - 1)} />}
|
||||
<LogContextDialog context={context} terms={highlightTerms} onClose={() => setContext(null)} />
|
||||
|
||||
Reference in new issue
Block a user