fix(logs): 域名筛选与残余原生 select 统一为可搜索/Radix Select
通病:/logs 域名筛选仍用 Input+datalist(与已修的快捷模板同类);策略组数据源、数据源弹窗集群/鉴权仍为原生 select。改为 SearchableValueSelect / ui/select,并顺带理顺策略组列表空标签与用户勾选交互。采集器编辑里 app 预设 datalist 仍保留(自由输入场景)。
This commit is contained in:
1 parent
016d3ecdfd
commit
8c9f5a95f8
5 files changed
+77
-18
No files matched your search
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { SearchableValueSelect } from '@/components/SearchableValueSelect'
|
||||
import type { LogEntry } from '@/api/logs'
|
||||
import { parseIngressAccessLog } from '@/lib/ingress-access-log'
|
||||
import { applyLabelFilter } from '../query-utils'
|
||||
@@ -50,9 +50,19 @@ export function LogResultsPanel({ entries, searched, hasSource, pageIndex, hasNe
|
||||
</div>}
|
||||
{pageIndex > 0 && <p className="mb-2 text-xs text-muted-foreground">{t('results.pagingHint')}</p>}
|
||||
{domains.length > 0 && <div className="mb-3 flex flex-wrap items-center gap-2 text-xs">
|
||||
<label htmlFor="log-domain-filter" className="shrink-0 text-muted-foreground">{t('results.domainFilter')}</label>
|
||||
<Input id="log-domain-filter" list="log-domain-options" type="search" className="h-8 w-60" value={domainFilter} onChange={event => setDomainFilter(event.target.value)} placeholder={t('results.domainPlaceholder')} />
|
||||
<datalist id="log-domain-options">{domains.map(domain => <option key={domain} value={domain} />)}</datalist>
|
||||
<span className="shrink-0 text-muted-foreground">{t('results.domainFilter')}</span>
|
||||
<SearchableValueSelect
|
||||
hideLabel
|
||||
ariaLabel={t('results.domainFilter')}
|
||||
values={domains}
|
||||
value={domainFilter}
|
||||
onChange={setDomainFilter}
|
||||
allowEmpty
|
||||
emptyLabel={t('results.domainAll')}
|
||||
placeholder={t('results.domainPlaceholder')}
|
||||
className="min-w-60"
|
||||
triggerClassName="h-8"
|
||||
/>
|
||||
<Button type="button" size="sm" variant="outline" disabled={!domainFilter.trim() || loading} onClick={() => onApplyDomain(domainFilter.trim())}>{t('results.domainQuery')}</Button>
|
||||
<span className="text-muted-foreground">{t('results.domainHint', { shown: visibleEntries.length, total: entries.length })}</span>
|
||||
</div>}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Plus, UsersRound } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ConfirmDialog } from '@/components/ConfirmDialog'
|
||||
import { ErrorState } from '@/components/ErrorState'
|
||||
@@ -84,20 +87,49 @@ export function PolicyGroupsPage({ sources }: { sources: LogSource[] }) {
|
||||
|
||||
return <div className="grid gap-6 xl:grid-cols-[minmax(240px,0.85fr)_minmax(0,2fr)]">
|
||||
<aside className="rounded-xl border bg-card p-5 space-y-4">
|
||||
<div className="flex items-center justify-between gap-2"><h2 className="font-semibold">{t('access.listTitle')}</h2><Button size="sm" onClick={() => setForm(emptyForm(sources[0]?.id ?? 0))}>{t('access.create')}</Button></div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="font-semibold">{t('access.listTitle')} <span className="text-sm font-normal text-muted-foreground">({groups.length})</span></h2>
|
||||
<Button size="sm" onClick={() => setForm(emptyForm(sources[0]?.id ?? 0))}><Plus className="mr-1 size-4" />{t('access.create')}</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{t('access.listHint')}</p>
|
||||
{loading && <p className="text-sm text-muted-foreground">{t('access.loading')}</p>}
|
||||
{error && <ErrorState message={error} onRetry={() => void reload()} />}
|
||||
{!loading && !error && groups.length === 0 && <p className="text-sm text-muted-foreground">{t('access.empty')}</p>}
|
||||
<div className="space-y-2">{groups.map(group => <button key={group.id} type="button" onClick={() => setForm({ ...group, labels: { ...group.labels }, userIds: [...group.userIds] })} className={`w-full rounded-lg border p-3 text-left transition-colors ${form.id === group.id ? 'border-primary bg-primary/5' : 'hover:bg-muted'}`}>
|
||||
<strong className="block text-sm">{group.name}</strong><span className="block mt-1 text-xs text-muted-foreground">{sourceName(group.sourceId)} · {group.userIds.includes(ANYONE) ? t('access.anyone') : t('access.userCount', { count: group.userIds.length })}</span><span className="block mt-1 truncate font-mono text-xs text-muted-foreground">{JSON.stringify(group.labels)}</span>
|
||||
<strong className="block text-sm">{group.name}</strong><span className="block mt-1 text-xs text-muted-foreground">{sourceName(group.sourceId)} · {group.userIds.includes(ANYONE) ? t('access.anyone') : t('access.userCount', { count: group.userIds.length })}</span>{Object.keys(group.labels).length > 0 ? (
|
||||
<span className="mt-1 flex flex-wrap gap-1">
|
||||
{Object.entries(group.labels).map(([key, value]) => <span key={key} className="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground">{key}={value}</span>)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="mt-1 block text-xs text-muted-foreground">{t('access.labelsUnlimited')}</span>
|
||||
)}
|
||||
</button>)}</div>
|
||||
</aside>
|
||||
<section className="rounded-xl border bg-card p-5 space-y-6">
|
||||
<div className="flex items-start justify-between gap-3"><div><h2 className="font-semibold">{form.id ? t('access.editTitle') : t('access.createTitle')}</h2><p className="mt-1 text-sm text-muted-foreground">{t('access.formHint')}</p></div>{form.id && <Button variant="outline" size="sm" className="text-destructive" onClick={() => setDeleteTarget(groups.find(group => group.id === form.id) ?? null)}>{t('access.remove')}</Button>}</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2"><label className="text-sm font-medium">{t('access.nameLabel')}<Input className="mt-1" value={form.name} onChange={event => setForm(current => ({ ...current, name: event.target.value }))} placeholder={t('access.namePlaceholder')} /></label><label className="text-sm font-medium">{t('access.sourceLabel')}<select className="mt-1 block h-9 w-full rounded-md border bg-background px-3" value={form.sourceId} onChange={event => setForm(current => ({ ...current, sourceId: Number(event.target.value), labels: {}, userIds: [] }))}>{sources.length === 0 && <option value={0}>{t('access.sourceEmpty')}</option>}{sources.map(source => <option key={source.id} value={source.id}>{source.name}</option>)}</select></label></div>
|
||||
<div className="grid gap-4 sm:grid-cols-2"><label className="text-sm font-medium">{t('access.nameLabel')}<Input className="mt-1" value={form.name} onChange={event => setForm(current => ({ ...current, name: event.target.value }))} placeholder={t('access.namePlaceholder')} /></label><label className="text-sm font-medium">{t('access.sourceLabel')}
|
||||
<Select
|
||||
value={form.sourceId ? String(form.sourceId) : undefined}
|
||||
onValueChange={value => setForm(current => ({ ...current, sourceId: Number(value), labels: {}, userIds: [] }))}
|
||||
disabled={sources.length === 0}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-9 w-full">
|
||||
<SelectValue placeholder={t('access.sourceEmpty')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sources.map(source => <SelectItem key={source.id} value={String(source.id)}>{source.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label></div>
|
||||
<div><h3 className="mb-2 text-sm font-medium">{t('access.scopeTitle')}</h3><AccessRuleEditor sourceId={form.sourceId} username={form.name || t('access.ownerFallback')} labels={form.labels} onChange={labels => setForm(current => ({ ...current, labels }))} /><p className="mt-2 text-xs text-muted-foreground">{t('access.scopeHint')}</p></div>
|
||||
<div className="space-y-3"><div className="flex items-center justify-between gap-2"><h3 className="text-sm font-medium">{t('access.usersTitle')} <span className="text-muted-foreground">{form.userIds.includes(ANYONE) ? t('access.selectedAnyone') : t('access.selectedCount', { count: form.userIds.length })}</span></h3><div className="flex gap-2"><Button size="sm" variant={form.userIds.includes(ANYONE) ? 'default' : 'outline'} onClick={() => setForm(current => current.userIds.includes(ANYONE) ? { ...current, userIds: [] } : { ...current, userIds: [ANYONE] })}>{form.userIds.includes(ANYONE) ? t('access.anyoneActive') : t('access.anyoneToggle')}</Button><Button size="sm" variant="outline" disabled={form.userIds.includes(ANYONE)} onClick={() => setForm(current => ({ ...current, userIds: Array.from(new Set([...current.userIds, ...visibleUsers.filter(user => !assignedElsewhere.has(user.id)).map(user => user.id)])) }))}>{t('access.selectFiltered')}</Button><Button size="sm" variant="ghost" onClick={() => setForm(current => ({ ...current, userIds: [] }))}>{t('access.clear')}</Button></div></div><p className="text-xs text-muted-foreground">{t('access.usersHint')}</p><Input aria-label={t('access.searchAria')} value={filter} onChange={event => setFilter(event.target.value)} placeholder={t('access.searchPlaceholder')} /><div className="max-h-60 overflow-auto rounded-md border p-2 grid gap-1 sm:grid-cols-2 lg:grid-cols-3">{visibleUsers.map(user => <label key={user.id} className={`flex items-center gap-2 rounded px-2 py-1.5 text-sm ${assignedElsewhere.has(user.id) ? 'text-muted-foreground' : 'hover:bg-muted'}`}><input type="checkbox" disabled={assignedElsewhere.has(user.id) || form.userIds.includes(ANYONE)} checked={form.userIds.includes(user.id)} onChange={event => setForm(current => ({ ...current, userIds: event.target.checked ? [...current.userIds, user.id] : current.userIds.filter(id => id !== user.id) }))} /><span className="truncate">{user.username}</span>{assignedElsewhere.has(user.id) && <span className="text-xs">{t('access.grouped')}</span>}</label>)}{visibleUsers.length === 0 && <p className="p-2 text-sm text-muted-foreground">{t('access.noMatch')}</p>}</div></div>
|
||||
<div className="space-y-3"><div className="flex items-center justify-between gap-2"><h3 className="text-sm font-medium">{t('access.usersTitle')} <span className="text-muted-foreground">{form.userIds.includes(ANYONE) ? t('access.selectedAnyone') : t('access.selectedCount', { count: form.userIds.length })}</span></h3><div className="flex gap-2"><Button size="sm" variant={form.userIds.includes(ANYONE) ? 'default' : 'outline'} onClick={() => setForm(current => current.userIds.includes(ANYONE) ? { ...current, userIds: [] } : { ...current, userIds: [ANYONE] })}><UsersRound className="mr-1 size-3.5" />{form.userIds.includes(ANYONE) ? t('access.anyoneActive') : t('access.anyoneToggle')}</Button><Button size="sm" variant="outline" disabled={form.userIds.includes(ANYONE)} onClick={() => setForm(current => ({ ...current, userIds: Array.from(new Set([...current.userIds, ...visibleUsers.filter(user => !assignedElsewhere.has(user.id)).map(user => user.id)])) }))}>{t('access.selectFiltered')}</Button><Button size="sm" variant="ghost" onClick={() => setForm(current => ({ ...current, userIds: [] }))}>{t('access.clear')}</Button></div></div><p className="text-xs text-muted-foreground">{t('access.usersHint')}</p><Input aria-label={t('access.searchAria')} value={filter} onChange={event => setFilter(event.target.value)} placeholder={t('access.searchPlaceholder')} /><div className={`grid max-h-60 grid-cols-1 gap-1 overflow-auto rounded-md border p-2 sm:grid-cols-2 lg:grid-cols-3 ${form.userIds.includes(ANYONE) ? 'pointer-events-none opacity-50' : ''}`}>{visibleUsers.map(user => (
|
||||
<label key={user.id} htmlFor={`policy-user-${user.id}`} className={`flex items-center gap-2 rounded px-2 py-1.5 text-sm ${assignedElsewhere.has(user.id) ? 'text-muted-foreground' : 'hover:bg-muted'}`}>
|
||||
<Checkbox id={`policy-user-${user.id}`} disabled={assignedElsewhere.has(user.id)} checked={form.userIds.includes(user.id)} onCheckedChange={checked => setForm(current => ({ ...current, userIds: checked ? [...current.userIds, user.id] : current.userIds.filter(id => id !== user.id) }))} />
|
||||
<span className="truncate">{user.username}</span>
|
||||
{user.nickname && <span className="truncate text-xs text-muted-foreground">{user.nickname}</span>}
|
||||
{assignedElsewhere.has(user.id) && <span className="ml-auto shrink-0 text-xs">{t('access.grouped')}</span>}
|
||||
</label>
|
||||
))}{visibleUsers.length === 0 && <p className="p-2 text-sm text-muted-foreground">{t('access.noMatch')}</p>}</div></div>
|
||||
<div className="flex justify-end"><Button disabled={saving || !sources.length} onClick={() => void save()}>{saving ? t('access.saving') : t('access.save')}</Button></div>
|
||||
</section>
|
||||
<ConfirmDialog open={deleteTarget !== null} onOpenChange={open => { if (!open) setDeleteTarget(null) }} name={deleteTarget?.name} title={t('access.deleteTitle')} description={t('access.deleteDesc', { name: deleteTarget?.name ?? '' })} loading={saving} onConfirm={() => void remove()} />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { toast } from 'sonner'
|
||||
import { Database, Globe, Loader2, Lock, Pencil, Plus, Server, Trash2, Zap } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
@@ -180,10 +181,17 @@ export const SourcesManager = memo(function SourcesManager({ sources, onSaved }:
|
||||
<>
|
||||
<label className={labelClass}>
|
||||
{t('source.clusterLabel')}
|
||||
<select className="mt-1 block h-9 w-full rounded-md border bg-background px-3 text-sm" value={form.clusterID ?? ''} onChange={event => setForm({ ...form, clusterID: Number(event.target.value) })}>
|
||||
<option value="">{t('source.clusterPlaceholder')}</option>
|
||||
{clusters.map(cluster => <option key={cluster.id} value={cluster.id}>{cluster.name}</option>)}
|
||||
</select>
|
||||
<Select
|
||||
value={form.clusterID ? String(form.clusterID) : undefined}
|
||||
onValueChange={value => setForm({ ...form, clusterID: Number(value) })}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-9 w-full">
|
||||
<SelectValue placeholder={t('source.clusterPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clusters.map(cluster => <SelectItem key={cluster.id} value={String(cluster.id)}>{cluster.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
<label className={labelClass}>
|
||||
{t('source.namespaceLabel')}
|
||||
@@ -201,12 +209,17 @@ export const SourcesManager = memo(function SourcesManager({ sources, onSaved }:
|
||||
)}
|
||||
<label className={labelClass}>
|
||||
{t('source.authLabel')}
|
||||
<select className="mt-1 block h-9 w-full rounded-md border bg-background px-3 text-sm" value={form.authType ?? 'none'} onChange={event => setForm({ ...form, authType: event.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>
|
||||
<Select value={form.authType ?? 'none'} onValueChange={value => setForm({ ...form, authType: value })}>
|
||||
<SelectTrigger className="mt-1 h-9 w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t('source.authNone')}</SelectItem>
|
||||
<SelectItem value="basic">{t('source.authBasic')}</SelectItem>
|
||||
<SelectItem value="bearer">{t('source.authBearer')}</SelectItem>
|
||||
<SelectItem value="headers">{t('source.authHeaders')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
{form.authType === 'basic' && (
|
||||
<>
|
||||
|
||||
Reference in new issue
Block a user