feat(frontend): analysis/logs 筛选下拉改可搜索 Select
数据源换 ui/select;命名空间复用 SearchableValueSelect 工具条态(可搜、全部置顶、loading);与 ahead 功能 tip 分开。
This commit is contained in:
1 parent
11d0fff764
commit
016d3ecdfd
5 files changed
+197
-86
No files matched your search
@@ -0,0 +1,153 @@
|
|||||||
|
import { useEffect, useId, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface SearchableValueSelectProps {
|
||||||
|
/** Visible label above the field; omit or set hideLabel for toolbar chips. */
|
||||||
|
label?: string
|
||||||
|
hideLabel?: boolean
|
||||||
|
ariaLabel?: string
|
||||||
|
values: string[]
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
/** Show a leading empty option (e.g. all namespaces). */
|
||||||
|
allowEmpty?: boolean
|
||||||
|
/** Label for the empty option and closed-state display when value is empty. */
|
||||||
|
emptyLabel?: string
|
||||||
|
loading?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
className?: string
|
||||||
|
triggerClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Searchable single-value combobox for long option lists (namespaces, labels, …). */
|
||||||
|
export function SearchableValueSelect({
|
||||||
|
label,
|
||||||
|
hideLabel = false,
|
||||||
|
ariaLabel,
|
||||||
|
values,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
allowEmpty = false,
|
||||||
|
emptyLabel,
|
||||||
|
loading = false,
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
triggerClassName,
|
||||||
|
}: SearchableValueSelectProps) {
|
||||||
|
const { t } = useTranslation('logs')
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [filter, setFilter] = useState('')
|
||||||
|
const [active, setActive] = useState(0)
|
||||||
|
const root = useRef<HTMLDivElement>(null)
|
||||||
|
const id = useId()
|
||||||
|
const emptyText = emptyLabel ?? t('select.any')
|
||||||
|
const choices = values.filter(item => item.toLowerCase().includes(filter.toLowerCase()))
|
||||||
|
// active index accounts for optional empty row at top
|
||||||
|
const optionCount = (allowEmpty ? 1 : 0) + choices.length
|
||||||
|
const closedDisplay = value || (allowEmpty ? emptyText : '')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const close = (event: MouseEvent) => {
|
||||||
|
if (!root.current?.contains(event.target as Node)) setOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', close)
|
||||||
|
return () => document.removeEventListener('mousedown', close)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function choose(next: string) {
|
||||||
|
onChange(next)
|
||||||
|
setFilter('')
|
||||||
|
setActive(0)
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
|
||||||
|
if (event.key === 'Escape') setOpen(false)
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault()
|
||||||
|
setActive(current => Math.max(0, Math.min(current + 1, optionCount - 1)))
|
||||||
|
}
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault()
|
||||||
|
setActive(current => Math.max(0, current - 1))
|
||||||
|
}
|
||||||
|
if (event.key === 'Enter' && open) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (allowEmpty && active === 0) choose('')
|
||||||
|
else {
|
||||||
|
const item = choices[allowEmpty ? active - 1 : active]
|
||||||
|
if (item) choose(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={root} className={cn('relative min-w-48', className)}>
|
||||||
|
{!hideLabel && label ? (
|
||||||
|
<label className="mb-1 block text-sm font-medium" htmlFor={id}>{label}</label>
|
||||||
|
) : null}
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
role="combobox"
|
||||||
|
aria-label={hideLabel ? (ariaLabel ?? label) : undefined}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={`${id}-options`}
|
||||||
|
disabled={disabled}
|
||||||
|
value={open ? filter : closedDisplay}
|
||||||
|
placeholder={placeholder ?? t('select.placeholder')}
|
||||||
|
className={cn('h-9', triggerClassName)}
|
||||||
|
onFocus={() => { setFilter(''); setOpen(true) }}
|
||||||
|
onChange={event => { setFilter(event.target.value); setActive(0); setOpen(true) }}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
/>
|
||||||
|
{open && (
|
||||||
|
<div id={`${id}-options`} role="listbox" className="absolute z-30 mt-1 max-h-56 w-full overflow-auto rounded-md border bg-popover p-1 shadow-lg">
|
||||||
|
{allowEmpty && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={!value}
|
||||||
|
className={cn('block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-muted', active === 0 && 'bg-primary/10 text-primary')}
|
||||||
|
onMouseDown={event => event.preventDefault()}
|
||||||
|
onClick={() => choose('')}
|
||||||
|
>
|
||||||
|
{emptyText}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{loading ? (
|
||||||
|
<p className="px-2 py-1.5 text-sm text-muted-foreground">{t('select.loading')}</p>
|
||||||
|
) : choices.length === 0 ? (
|
||||||
|
<p className="px-2 py-1.5 text-sm text-muted-foreground">{t('select.noMatch')}</p>
|
||||||
|
) : (
|
||||||
|
choices.slice(0, 200).map((item, index) => {
|
||||||
|
const optionIndex = (allowEmpty ? 1 : 0) + index
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={item === value}
|
||||||
|
className={cn(
|
||||||
|
'block w-full rounded px-2 py-1.5 text-left text-sm',
|
||||||
|
optionIndex === active ? 'bg-primary/10 text-primary' : 'hover:bg-muted',
|
||||||
|
)}
|
||||||
|
onMouseDown={event => event.preventDefault()}
|
||||||
|
onClick={() => choose(item)}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SearchableValueSelect
|
||||||
@@ -16,3 +16,4 @@ export { getStatusColorClass } from './status-config';
|
|||||||
export { default as ErrorBoundary } from './ErrorBoundary';
|
export { default as ErrorBoundary } from './ErrorBoundary';
|
||||||
export { TimeRangePicker } from './TimeRangePicker';
|
export { TimeRangePicker } from './TimeRangePicker';
|
||||||
export { StatCard } from './StatCard';
|
export { StatCard } from './StatCard';
|
||||||
|
export { SearchableValueSelect } from './SearchableValueSelect'
|
||||||
@@ -4,6 +4,8 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import '@/i18n/domains'
|
import '@/i18n/domains'
|
||||||
import { BarChart3, Boxes, FileWarning, Loader2, RefreshCw, ScrollText, TriangleAlert } from 'lucide-react'
|
import { BarChart3, Boxes, FileWarning, Loader2, RefreshCw, ScrollText, TriangleAlert } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import { SearchableValueSelect } from '@/components/SearchableValueSelect'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { PageContainer } from '@/components/PageContainer'
|
import { PageContainer } from '@/components/PageContainer'
|
||||||
import { EmptyState } from '@/components/EmptyState'
|
import { EmptyState } from '@/components/EmptyState'
|
||||||
@@ -35,6 +37,7 @@ const AnalysisPage = memo(function AnalysisPage() {
|
|||||||
const [sources, setSources] = useState<LogSource[]>([])
|
const [sources, setSources] = useState<LogSource[]>([])
|
||||||
const [sourceId, setSourceId] = useState(0)
|
const [sourceId, setSourceId] = useState(0)
|
||||||
const [namespaces, setNamespaces] = useState<string[]>([])
|
const [namespaces, setNamespaces] = useState<string[]>([])
|
||||||
|
const [namespacesLoading, setNamespacesLoading] = useState(false)
|
||||||
const [namespace, setNamespace] = useState('')
|
const [namespace, setNamespace] = useState('')
|
||||||
const [start, setStart] = useState(() => toLocal(new Date(Date.now() - 3600000)))
|
const [start, setStart] = useState(() => toLocal(new Date(Date.now() - 3600000)))
|
||||||
const [end, setEnd] = useState(() => toLocal(new Date()))
|
const [end, setEnd] = useState(() => toLocal(new Date()))
|
||||||
@@ -53,11 +56,17 @@ const AnalysisPage = memo(function AnalysisPage() {
|
|||||||
}, [])
|
}, [])
|
||||||
// 命名空间候选随数据源变化(切换数据源时在 onChange 里同步清掉已选命名空间)
|
// 命名空间候选随数据源变化(切换数据源时在 onChange 里同步清掉已选命名空间)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sourceId) return
|
if (!sourceId) {
|
||||||
|
setNamespaces([])
|
||||||
|
setNamespacesLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
let active = true
|
let active = true
|
||||||
|
setNamespacesLoading(true)
|
||||||
logsApi.labelValues(sourceId, 'namespace')
|
logsApi.labelValues(sourceId, 'namespace')
|
||||||
.then(list => { if (active) setNamespaces(Array.isArray(list) ? list : []) })
|
.then(list => { if (active) setNamespaces(Array.isArray(list) ? list : []) })
|
||||||
.catch(() => { if (active) setNamespaces([]) })
|
.catch(() => { if (active) setNamespaces([]) })
|
||||||
|
.finally(() => { if (active) setNamespacesLoading(false) })
|
||||||
return () => { active = false }
|
return () => { active = false }
|
||||||
}, [sourceId])
|
}, [sourceId])
|
||||||
|
|
||||||
@@ -87,25 +96,32 @@ const AnalysisPage = memo(function AnalysisPage() {
|
|||||||
>
|
>
|
||||||
{/* 筛选栏:数据源 / 命名空间 / 时间范围 */}
|
{/* 筛选栏:数据源 / 命名空间 / 时间范围 */}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<select
|
<Select
|
||||||
aria-label={t('filter.source')}
|
value={sourceId ? String(sourceId) : undefined}
|
||||||
className="h-9 min-w-40 rounded-md border bg-background px-3 text-sm text-foreground"
|
onValueChange={value => { setSourceId(Number(value)); setNamespace('') }}
|
||||||
value={sourceId}
|
disabled={sources.length === 0}
|
||||||
onChange={event => { setSourceId(Number(event.target.value)); setNamespace('') }}
|
|
||||||
>
|
>
|
||||||
{sources.length === 0 && <option value={0}>{t('filter.sourceEmpty')}</option>}
|
<SelectTrigger className="h-9 min-w-40" aria-label={t('filter.source')}>
|
||||||
{sources.map(source => <option key={source.id} value={source.id}>{source.name}</option>)}
|
<SelectValue placeholder={t('filter.sourceEmpty')} />
|
||||||
</select>
|
</SelectTrigger>
|
||||||
<select
|
<SelectContent>
|
||||||
aria-label={t('filter.namespace')}
|
{sources.map(source => (
|
||||||
className="h-9 min-w-36 rounded-md border bg-background px-3 text-sm text-foreground"
|
<SelectItem key={source.id} value={String(source.id)}>{source.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<SearchableValueSelect
|
||||||
|
hideLabel
|
||||||
|
ariaLabel={t('filter.namespace')}
|
||||||
|
values={namespaces}
|
||||||
value={namespace}
|
value={namespace}
|
||||||
onChange={event => setNamespace(event.target.value)}
|
onChange={setNamespace}
|
||||||
|
allowEmpty
|
||||||
|
emptyLabel={t('filter.allNamespaces')}
|
||||||
|
loading={namespacesLoading}
|
||||||
disabled={!sourceId}
|
disabled={!sourceId}
|
||||||
>
|
className="min-w-36"
|
||||||
<option value="">{t('filter.allNamespaces')}</option>
|
/>
|
||||||
{namespaces.map(item => <option key={item} value={item}>{item}</option>)}
|
|
||||||
</select>
|
|
||||||
<TimeRangePicker start={start} end={end} onChange={(nextStart, nextEnd) => { setStart(nextStart); setEnd(nextEnd) }} />
|
<TimeRangePicker start={start} end={end} onChange={(nextStart, nextEnd) => { setStart(nextStart); setEnd(nextEnd) }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,68 +1 @@
|
|||||||
import { useEffect, useId, useRef, useState } from 'react'
|
export { SearchableValueSelect } from '@/components/SearchableValueSelect'
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
|
|
||||||
interface SearchableValueSelectProps {
|
|
||||||
label: string
|
|
||||||
values: string[]
|
|
||||||
value: string
|
|
||||||
onChange: (value: string) => void
|
|
||||||
placeholder?: string
|
|
||||||
allowEmpty?: boolean
|
|
||||||
loading?: boolean
|
|
||||||
disabled?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A searchable single-value combobox for Loki label values. */
|
|
||||||
export function SearchableValueSelect({ label, values, value, onChange, placeholder, allowEmpty = false, loading = false, disabled = false }: SearchableValueSelectProps) {
|
|
||||||
const { t } = useTranslation('logs')
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const [filter, setFilter] = useState('')
|
|
||||||
const [active, setActive] = useState(0)
|
|
||||||
const root = useRef<HTMLDivElement>(null)
|
|
||||||
const id = useId()
|
|
||||||
const choices = values.filter(item => item.toLowerCase().includes(filter.toLowerCase()))
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const close = (event: MouseEvent) => {
|
|
||||||
if (!root.current?.contains(event.target as Node)) setOpen(false)
|
|
||||||
}
|
|
||||||
document.addEventListener('mousedown', close)
|
|
||||||
return () => document.removeEventListener('mousedown', close)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
function choose(next: string) {
|
|
||||||
onChange(next)
|
|
||||||
setFilter('')
|
|
||||||
setActive(0)
|
|
||||||
setOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div ref={root} className="relative min-w-48">
|
|
||||||
<label className="block text-sm font-medium mb-1" htmlFor={id}>{label}</label>
|
|
||||||
<Input
|
|
||||||
id={id}
|
|
||||||
role="combobox"
|
|
||||||
aria-autocomplete="list"
|
|
||||||
aria-expanded={open}
|
|
||||||
aria-controls={`${id}-options`}
|
|
||||||
disabled={disabled}
|
|
||||||
value={open ? filter : value}
|
|
||||||
placeholder={placeholder ?? t('select.placeholder')}
|
|
||||||
onFocus={() => { setFilter(''); setOpen(true) }}
|
|
||||||
onChange={event => { setFilter(event.target.value); setActive(0); setOpen(true) }}
|
|
||||||
onKeyDown={event => {
|
|
||||||
if (event.key === 'Escape') setOpen(false)
|
|
||||||
if (event.key === 'ArrowDown') { event.preventDefault(); setActive(current => Math.max(0, Math.min(current + 1, choices.length - 1))) }
|
|
||||||
if (event.key === 'ArrowUp') { event.preventDefault(); setActive(current => Math.max(0, current - 1)) }
|
|
||||||
if (event.key === 'Enter' && open && choices[active]) { event.preventDefault(); choose(choices[active]) }
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{open && <div id={`${id}-options`} role="listbox" className="absolute z-30 mt-1 max-h-56 w-full overflow-auto rounded-md border bg-popover p-1 shadow-lg">
|
|
||||||
{allowEmpty && <button type="button" role="option" aria-selected={!value} className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-muted" onMouseDown={event => event.preventDefault()} onClick={() => choose('')}>{t('select.any')}</button>}
|
|
||||||
{loading ? <p className="px-2 py-1.5 text-sm text-muted-foreground">{t('select.loading')}</p> : choices.length === 0 ? <p className="px-2 py-1.5 text-sm text-muted-foreground">{t('select.noMatch')}</p> : choices.slice(0, 200).map((item, index) =>
|
|
||||||
<button key={item} type="button" role="option" aria-selected={item === value} className={`block w-full rounded px-2 py-1.5 text-left text-sm ${index === active ? 'bg-primary/10 text-primary' : 'hover:bg-muted'}`} onMouseDown={event => event.preventDefault()} onClick={() => choose(item)}>{item}</button>
|
|
||||||
)}
|
|
||||||
</div>}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,7 @@ import { toast } from 'sonner'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import '@/i18n/domains'
|
import '@/i18n/domains'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { PageContainer } from '@/components/PageContainer'
|
import { PageContainer } from '@/components/PageContainer'
|
||||||
import { TimeRangePicker } from '@/components/TimeRangePicker'
|
import { TimeRangePicker } from '@/components/TimeRangePicker'
|
||||||
@@ -178,7 +179,14 @@ export default function LogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<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>
|
<Select value={sourceId ? String(sourceId) : undefined} onValueChange={value => setSourceId(Number(value))} disabled={sources.length === 0}>
|
||||||
|
<SelectTrigger className="h-9 min-w-40" aria-label={t('toolbar.sourceAria')}>
|
||||||
|
<SelectValue placeholder={t('toolbar.sourceEmptyOption')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sources.map(source => <SelectItem key={source.id} value={String(source.id)}>{source.name}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<TimeRangePicker start={start} end={end} onChange={(nextStart, nextEnd) => { setStart(nextStart); setEnd(nextEnd); setTail(false) }} />
|
<TimeRangePicker start={start} end={end} onChange={(nextStart, nextEnd) => { setStart(nextStart); setEnd(nextEnd); setTail(false) }} />
|
||||||
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user