diff --git a/frontend/src/components/SearchableValueSelect.tsx b/frontend/src/components/SearchableValueSelect.tsx new file mode 100644 index 0000000..95df7cc --- /dev/null +++ b/frontend/src/components/SearchableValueSelect.tsx @@ -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(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) { + 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 ( +
+ {!hideLabel && label ? ( + + ) : null} + { setFilter(''); setOpen(true) }} + onChange={event => { setFilter(event.target.value); setActive(0); setOpen(true) }} + onKeyDown={onKeyDown} + /> + {open && ( +
+ {allowEmpty && ( + + )} + {loading ? ( +

{t('select.loading')}

+ ) : choices.length === 0 ? ( +

{t('select.noMatch')}

+ ) : ( + choices.slice(0, 200).map((item, index) => { + const optionIndex = (allowEmpty ? 1 : 0) + index + return ( + + ) + }) + )} +
+ )} +
+ ) +} + +export default SearchableValueSelect diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index db85a09..76c2ac0 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -16,3 +16,4 @@ export { getStatusColorClass } from './status-config'; export { default as ErrorBoundary } from './ErrorBoundary'; export { TimeRangePicker } from './TimeRangePicker'; export { StatCard } from './StatCard'; +export { SearchableValueSelect } from './SearchableValueSelect' diff --git a/frontend/src/pages/analysis/index.tsx b/frontend/src/pages/analysis/index.tsx index 093aa42..9b7c224 100644 --- a/frontend/src/pages/analysis/index.tsx +++ b/frontend/src/pages/analysis/index.tsx @@ -4,6 +4,8 @@ import { useTranslation } from 'react-i18next' import '@/i18n/domains' import { BarChart3, Boxes, FileWarning, Loader2, RefreshCw, ScrollText, TriangleAlert } from 'lucide-react' 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 { PageContainer } from '@/components/PageContainer' import { EmptyState } from '@/components/EmptyState' @@ -35,6 +37,7 @@ const AnalysisPage = memo(function AnalysisPage() { const [sources, setSources] = useState([]) const [sourceId, setSourceId] = useState(0) const [namespaces, setNamespaces] = useState([]) + const [namespacesLoading, setNamespacesLoading] = useState(false) const [namespace, setNamespace] = useState('') const [start, setStart] = useState(() => toLocal(new Date(Date.now() - 3600000))) const [end, setEnd] = useState(() => toLocal(new Date())) @@ -53,11 +56,17 @@ const AnalysisPage = memo(function AnalysisPage() { }, []) // 命名空间候选随数据源变化(切换数据源时在 onChange 里同步清掉已选命名空间) useEffect(() => { - if (!sourceId) return + if (!sourceId) { + setNamespaces([]) + setNamespacesLoading(false) + return + } let active = true + setNamespacesLoading(true) logsApi.labelValues(sourceId, 'namespace') .then(list => { if (active) setNamespaces(Array.isArray(list) ? list : []) }) .catch(() => { if (active) setNamespaces([]) }) + .finally(() => { if (active) setNamespacesLoading(false) }) return () => { active = false } }, [sourceId]) @@ -87,25 +96,32 @@ const AnalysisPage = memo(function AnalysisPage() { > {/* 筛选栏:数据源 / 命名空间 / 时间范围 */}
- { setSourceId(Number(value)); setNamespace('') }} + disabled={sources.length === 0} > - {sources.length === 0 && } - {sources.map(source => )} - - + setNamespace(event.target.value)} + onChange={setNamespace} + allowEmpty + emptyLabel={t('filter.allNamespaces')} + loading={namespacesLoading} disabled={!sourceId} - > - - {namespaces.map(item => )} - + className="min-w-36" + /> { setStart(nextStart); setEnd(nextEnd) }} />
diff --git a/frontend/src/pages/logs/components/SearchableValueSelect.tsx b/frontend/src/pages/logs/components/SearchableValueSelect.tsx index 6caa848..2593411 100644 --- a/frontend/src/pages/logs/components/SearchableValueSelect.tsx +++ b/frontend/src/pages/logs/components/SearchableValueSelect.tsx @@ -1,68 +1 @@ -import { useEffect, useId, useRef, useState } from 'react' -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(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
- - { 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 &&
- {allowEmpty && } - {loading ?

{t('select.loading')}

: choices.length === 0 ?

{t('select.noMatch')}

: choices.slice(0, 200).map((item, index) => - - )} -
} -
-} +export { SearchableValueSelect } from '@/components/SearchableValueSelect' diff --git a/frontend/src/pages/logs/index.tsx b/frontend/src/pages/logs/index.tsx index 84e862b..e2943d5 100644 --- a/frontend/src/pages/logs/index.tsx +++ b/frontend/src/pages/logs/index.tsx @@ -3,6 +3,7 @@ import { toast } from 'sonner' import { useTranslation } from 'react-i18next' import '@/i18n/domains' import { Button } from '@/components/ui/button' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Input } from '@/components/ui/input' import { PageContainer } from '@/components/PageContainer' import { TimeRangePicker } from '@/components/TimeRangePicker' @@ -178,7 +179,14 @@ export default function LogsPage() { )}
- + { setStart(nextStart); setEnd(nextEnd); setTail(false) }} />