From 8f35b8e5fa973c608d4b16b4957d48ae82b4bbac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Mon, 3 Aug 2026 12:37:18 -0600 Subject: [PATCH 1/7] fix[frontend](user-auditor): fixed user auditor tabs --- .../user-auditor/pages/UserAuditorPage.tsx | 125 ++++++++++++------ .../services/ad-audit-http.service.ts | 1 + .../user-auditor/types/ad-user.types.ts | 25 +++- 3 files changed, 107 insertions(+), 44 deletions(-) diff --git a/frontend/src/features/user-auditor/pages/UserAuditorPage.tsx b/frontend/src/features/user-auditor/pages/UserAuditorPage.tsx index 5eefb29b2..fc9f851a3 100644 --- a/frontend/src/features/user-auditor/pages/UserAuditorPage.tsx +++ b/frontend/src/features/user-auditor/pages/UserAuditorPage.tsx @@ -29,13 +29,13 @@ import { Button } from '@/shared/components/ui/button' import { Input } from '@/shared/components/ui/input' import { InfiniteScrollSentinel } from '@/shared/components/ui/infinite-scroll' import { adAuditHttpService } from '../services/ad-audit-http.service' -import type { ADUser, ADUserStats, ADUserStatus } from '../types/ad-user.types' +import type { ADUser, ADUserSource, ADUserStats } from '../types/ad-user.types' const SIZE = 50 const STALE_MS = 30 * 86_400_000 -type ViewId = 'all' | ADUserStatus -const VIEW_IDS: ViewId[] = ['all', 'active', 'disabled', 'deleted', 'service', 'stale'] +type ViewId = 'all' | ADUserSource +const VIEW_IDS: ViewId[] = ['all', 'windows', 'linux'] /* ─── Derived (per-row, display-only) ──────────────────────────────────── */ @@ -50,7 +50,23 @@ function isStale(u: ADUser): boolean { return statusOf(u) === 'active' && !!u.lastLogon && Date.now() - new Date(u.lastLogon).getTime() > STALE_MS } function isService(u: ADUser): boolean { - return /^svc/i.test(u.samAccountName) + return u.source === 'windows' && /^svc/i.test(u.samAccountName ?? '') +} + +/* Windows accounts key off SID + samAccountName; Linux accounts off username + uid@host. */ +function accountName(u: ADUser): string { + return (u.source === 'linux' ? u.username : u.samAccountName) ?? '—' +} +function accountIdentity(u: ADUser): string { + if (u.source === 'linux') { + const uid = u.uidNumber ? `uid=${u.uidNumber}` : '' + const host = u.hostname ?? '' + return [uid, host].filter(Boolean).join(' · ') || '—' + } + return u.sid ?? '—' +} +function accountScope(u: ADUser): string { + return (u.source === 'linux' ? u.hostname : u.domain) ?? '—' } const STATUS_DOT: Record = { @@ -108,7 +124,7 @@ export function UserAuditorPage() { const res = await adAuditHttpService.list({ search: search || undefined, tenantId: tenant || undefined, - status: view === 'all' ? undefined : view, + source: view === 'all' ? undefined : view, sort: 'recent', page, size: SIZE, @@ -138,11 +154,8 @@ export function UserAuditorPage() { const counts: Record = { all: stats?.total ?? 0, - active: stats?.active ?? 0, - disabled: stats?.disabled ?? 0, - deleted: stats?.deleted ?? 0, - service: stats?.service ?? 0, - stale: stats?.stale ?? 0, + windows: stats?.by_source?.windows ?? 0, + linux: stats?.by_source?.linux ?? 0, } return ( @@ -440,7 +453,7 @@ function ListHeader({ t }: { t: TFunction }) { >
{t('userAuditor.list.account')}
-
{t('userAuditor.list.sid')}
+
{t('userAuditor.list.identity')}
{t('userAuditor.list.status')}
{t('userAuditor.list.lastLogon')}
{t('userAuditor.list.lastSeen')}
@@ -460,6 +473,7 @@ function LoadingRows() { function UserListRow({ user, onOpen, t }: { user: ADUser; onOpen: () => void; t: TFunction }) { const status = statusOf(user) + const identity = accountIdentity(user) return (
void; t:
- {user.samAccountName} + {accountName(user)} + + {user.source} + {isService(user) && ( {t('userAuditor.badge.service')} @@ -481,10 +498,10 @@ function UserListRow({ user, onOpen, t }: { user: ADUser; onOpen: () => void; t: )}
-
{user.domain}
+
{accountScope(user)}
-
- {user.sid} +
+ {identity}
@@ -506,6 +523,7 @@ function UserListRow({ user, onOpen, t }: { user: ADUser; onOpen: () => void; t: function UserCard({ user, onOpen, t }: { user: ADUser; onOpen: () => void; t: TFunction }) { const status = statusOf(user) + const identity = accountIdentity(user) return ( -
@@ -636,14 +670,25 @@ function UserDrawer({ user, onClose, t }: { user: ADUser; onClose: () => void; t
- {user.samAccountName} - - - {user.domain} + {accountName(user)} - - {user.sid} + + {accountScope(user)} + {isLinux ? ( + <> + + {user.uidNumber ?? '—'} + + + {user.machineId ?? '—'} + + + ) : ( + + {user.sid ?? '—'} + + )} {user.tenantId} diff --git a/frontend/src/features/user-auditor/services/ad-audit-http.service.ts b/frontend/src/features/user-auditor/services/ad-audit-http.service.ts index 61633266b..cc976a5c3 100644 --- a/frontend/src/features/user-auditor/services/ad-audit-http.service.ts +++ b/frontend/src/features/user-auditor/services/ad-audit-http.service.ts @@ -9,6 +9,7 @@ function listQuery(q: ADUserListQuery): string { const p = new URLSearchParams() if (q.search) p.set('search', q.search) if (q.tenantId) p.set('tenantId', q.tenantId) + if (q.source) p.set('source', q.source) if (q.status) p.set('status', q.status) if (q.sort) p.set('sort', q.sort) p.set('page', String(q.page ?? 0)) diff --git a/frontend/src/features/user-auditor/types/ad-user.types.ts b/frontend/src/features/user-auditor/types/ad-user.types.ts index 3797307d0..5648f544a 100644 --- a/frontend/src/features/user-auditor/types/ad-user.types.ts +++ b/frontend/src/features/user-auditor/types/ad-user.types.ts @@ -1,12 +1,22 @@ /* Types mirror the backend adaudit DTOs (modules/adaudit/dto, domain.ADUser). */ -/** One Active Directory account observed in ingested Windows security logs. */ +export type ADUserSource = 'windows' | 'linux' + +/** One user account observed in ingested Windows or Linux logs. */ export interface ADUser { id: number tenantId: string - sid: string - samAccountName: string - domain: string + source: ADUserSource + /* Windows-only fields */ + sid?: string + samAccountName?: string + domain?: string + /* Linux-only fields */ + machineId?: string + uidNumber?: string + hostname?: string + username?: string + /* Shared lifecycle */ active: boolean accountCreatedAt?: string lastLogon?: string @@ -22,6 +32,7 @@ export type ADUserSort = 'recent' | 'name' export interface ADUserListQuery { search?: string tenantId?: string + source?: ADUserSource status?: ADUserStatus sort?: ADUserSort page?: number // zero-based @@ -33,6 +44,11 @@ export interface DomainCount { count: number } +export interface SourceCount { + windows: number + linux: number +} + /** GET /ad-audit/stats — inventory roll-up for the overview. */ export interface ADUserStats { total: number @@ -42,6 +58,7 @@ export interface ADUserStats { stale: number service: number seen_24h: number + by_source: SourceCount by_domain: DomainCount[] tenants: string[] } From 3b5a30e8e12744de449575c46aa9376d5799e27a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Mon, 3 Aug 2026 12:45:06 -0600 Subject: [PATCH 2/7] feat[frontend](shared): added shared filters component --- .../filters/AddCustomFilterButton.tsx | 168 ++++++++++++++++++ .../components/filters/CustomFilterBar.tsx | 62 +++++++ .../components/filters/custom-filter.types.ts | 34 ++++ 3 files changed, 264 insertions(+) create mode 100644 frontend/src/shared/components/filters/AddCustomFilterButton.tsx create mode 100644 frontend/src/shared/components/filters/CustomFilterBar.tsx create mode 100644 frontend/src/shared/components/filters/custom-filter.types.ts diff --git a/frontend/src/shared/components/filters/AddCustomFilterButton.tsx b/frontend/src/shared/components/filters/AddCustomFilterButton.tsx new file mode 100644 index 000000000..ae649c8d3 --- /dev/null +++ b/frontend/src/shared/components/filters/AddCustomFilterButton.tsx @@ -0,0 +1,168 @@ +import { useEffect, useRef, useState } from 'react' +import { ListFilter, Loader2, Search } from 'lucide-react' +import { cn } from '@/shared/lib/utils' +import { Button } from '@/shared/components/ui/button' +import { Input } from '@/shared/components/ui/input' +import type { + CustomFilter, + FilterBarLabels, + FilterFieldDef, + FilterOpDef, + FilterValue, +} from './custom-filter.types' + +const SELECT_CLS = + 'h-9 cursor-pointer rounded-md border border-input bg-popover px-2 text-sm text-popover-foreground transition-colors focus-visible:border-ring focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring' + +export function AddCustomFilterButton({ + onAdd, + fields, + operators, + fetchValues, + labels, +}: { + onAdd: (f: CustomFilter) => void + fields: FilterFieldDef[] + operators: FilterOpDef[] + fetchValues?: (field: string) => Promise + labels: FilterBarLabels +}) { + const [open, setOpen] = useState(false) + const [field, setField] = useState(fields[0]?.field ?? '') + const [operator, setOperator] = useState(operators[0]?.id ?? 'IS') + const [freeValue, setFreeValue] = useState('') + const [values, setValues] = useState([]) + const [loadingValues, setLoadingValues] = useState(false) + const [vq, setVq] = useState('') + const ref = useRef(null) + + const op = operators.find((o) => o.id === operator) + const needsValue = op?.needsValue ?? true + + useEffect(() => { + if (!open) return + const onDoc = (e: MouseEvent) => ref.current && !ref.current.contains(e.target as Node) && setOpen(false) + document.addEventListener('mousedown', onDoc) + return () => document.removeEventListener('mousedown', onDoc) + }, [open]) + + useEffect(() => { + if (!open || !needsValue || !fetchValues) return + setLoadingValues(true) + setValues([]) + fetchValues(field) + .then(setValues) + .catch(() => setValues([])) + .finally(() => setLoadingValues(false)) + }, [open, field, needsValue, fetchValues]) + + const add = (value: string) => { + const fdef = fields.find((f) => f.field === field) + if (!fdef) return + onAdd({ field, label: fdef.label, operator, value }) + setOpen(false) + setVq('') + setFreeValue('') + } + + const filtered = values.filter((v) => (vq ? v.value.toLowerCase().includes(vq.toLowerCase()) : true)) + + return ( +
+ + {open && ( +
+
+ + + + {needsValue ? ( + fetchValues ? ( + <> +
+ + setVq(e.target.value)} + placeholder={labels.filterValues} + className="h-8 pl-8 text-xs" + autoFocus + /> +
+
+ {loadingValues ? ( +
+ {labels.loadingValues} +
+ ) : filtered.length === 0 ? ( +
{labels.noValues}
+ ) : ( + filtered.map((v) => ( + + )) + )} +
+

{labels.pickValue}

+ + ) : ( + <> + setFreeValue(e.target.value)} + placeholder={labels.filterValues} + className="h-8 text-xs" + autoFocus + onKeyDown={(e) => e.key === 'Enter' && freeValue && add(freeValue)} + /> +
+ + +
+ + ) + ) : ( +
+ + +
+ )} +
+
+ )} +
+ ) +} diff --git a/frontend/src/shared/components/filters/CustomFilterBar.tsx b/frontend/src/shared/components/filters/CustomFilterBar.tsx new file mode 100644 index 000000000..0513e4814 --- /dev/null +++ b/frontend/src/shared/components/filters/CustomFilterBar.tsx @@ -0,0 +1,62 @@ +import { X } from 'lucide-react' +import { AddCustomFilterButton } from './AddCustomFilterButton' +import type { + CustomFilter, + FilterBarLabels, + FilterFieldDef, + FilterOpDef, + FilterValue, +} from './custom-filter.types' + +export function CustomFilterBar({ + filters, + onAdd, + onRemove, + onClear, + fields, + operators, + fetchValues, + labels, +}: { + filters: CustomFilter[] + onAdd: (f: CustomFilter) => void + onRemove: (i: number) => void + onClear: () => void + fields: FilterFieldDef[] + operators: FilterOpDef[] + fetchValues?: (field: string) => Promise + labels: FilterBarLabels +}) { + return ( +
+ {filters.map((f, i) => { + const op = operators.find((o) => o.id === f.operator) + return ( + + {f.label} + {op?.label ?? f.operator} + {op?.needsValue && {f.value}} + + + ) + })} + + {filters.length > 1 && ( + + )} +
+ ) +} diff --git a/frontend/src/shared/components/filters/custom-filter.types.ts b/frontend/src/shared/components/filters/custom-filter.types.ts new file mode 100644 index 000000000..b0a25c89a --- /dev/null +++ b/frontend/src/shared/components/filters/custom-filter.types.ts @@ -0,0 +1,34 @@ +export interface CustomFilter { + field: string + label: string + operator: string + value: string +} + +export interface FilterFieldDef { + field: string + label: string +} + +export interface FilterOpDef { + id: string + label: string + needsValue: boolean +} + +export interface FilterValue { + value: string + count: number +} + +export interface FilterBarLabels { + add: string + clearAll: string + filterValues: string + loadingValues: string + noValues: string + pickValue: string + empty: string + cancel: string + addBtn: string +} From a160dd7e8c5b3c797567fad0ebe21df7d607dd69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Mon, 3 Aug 2026 13:00:22 -0600 Subject: [PATCH 3/7] feat[frontend](shared): added update to the selected filters --- .../filters/AddCustomFilterButton.tsx | 136 ++------------- .../components/filters/CustomFilterBar.tsx | 67 ++++++-- .../components/filters/FilterEditorPanel.tsx | 156 ++++++++++++++++++ 3 files changed, 224 insertions(+), 135 deletions(-) create mode 100644 frontend/src/shared/components/filters/FilterEditorPanel.tsx diff --git a/frontend/src/shared/components/filters/AddCustomFilterButton.tsx b/frontend/src/shared/components/filters/AddCustomFilterButton.tsx index ae649c8d3..4b892723a 100644 --- a/frontend/src/shared/components/filters/AddCustomFilterButton.tsx +++ b/frontend/src/shared/components/filters/AddCustomFilterButton.tsx @@ -1,8 +1,6 @@ import { useEffect, useRef, useState } from 'react' -import { ListFilter, Loader2, Search } from 'lucide-react' -import { cn } from '@/shared/lib/utils' -import { Button } from '@/shared/components/ui/button' -import { Input } from '@/shared/components/ui/input' +import { ListFilter } from 'lucide-react' +import { FilterEditorPanel } from './FilterEditorPanel' import type { CustomFilter, FilterBarLabels, @@ -11,9 +9,6 @@ import type { FilterValue, } from './custom-filter.types' -const SELECT_CLS = - 'h-9 cursor-pointer rounded-md border border-input bg-popover px-2 text-sm text-popover-foreground transition-colors focus-visible:border-ring focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring' - export function AddCustomFilterButton({ onAdd, fields, @@ -28,17 +23,8 @@ export function AddCustomFilterButton({ labels: FilterBarLabels }) { const [open, setOpen] = useState(false) - const [field, setField] = useState(fields[0]?.field ?? '') - const [operator, setOperator] = useState(operators[0]?.id ?? 'IS') - const [freeValue, setFreeValue] = useState('') - const [values, setValues] = useState([]) - const [loadingValues, setLoadingValues] = useState(false) - const [vq, setVq] = useState('') const ref = useRef(null) - const op = operators.find((o) => o.id === operator) - const needsValue = op?.needsValue ?? true - useEffect(() => { if (!open) return const onDoc = (e: MouseEvent) => ref.current && !ref.current.contains(e.target as Node) && setOpen(false) @@ -46,27 +32,6 @@ export function AddCustomFilterButton({ return () => document.removeEventListener('mousedown', onDoc) }, [open]) - useEffect(() => { - if (!open || !needsValue || !fetchValues) return - setLoadingValues(true) - setValues([]) - fetchValues(field) - .then(setValues) - .catch(() => setValues([])) - .finally(() => setLoadingValues(false)) - }, [open, field, needsValue, fetchValues]) - - const add = (value: string) => { - const fdef = fields.find((f) => f.field === field) - if (!fdef) return - onAdd({ field, label: fdef.label, operator, value }) - setOpen(false) - setVq('') - setFreeValue('') - } - - const filtered = values.filter((v) => (vq ? v.value.toLowerCase().includes(vq.toLowerCase()) : true)) - return (
{open && ( -
-
- - - - {needsValue ? ( - fetchValues ? ( - <> -
- - setVq(e.target.value)} - placeholder={labels.filterValues} - className="h-8 pl-8 text-xs" - autoFocus - /> -
-
- {loadingValues ? ( -
- {labels.loadingValues} -
- ) : filtered.length === 0 ? ( -
{labels.noValues}
- ) : ( - filtered.map((v) => ( - - )) - )} -
-

{labels.pickValue}

- - ) : ( - <> - setFreeValue(e.target.value)} - placeholder={labels.filterValues} - className="h-8 text-xs" - autoFocus - onKeyDown={(e) => e.key === 'Enter' && freeValue && add(freeValue)} - /> -
- - -
- - ) - ) : ( -
- - -
- )} -
+
+ setOpen(false)} + onSubmit={(f) => { + onAdd(f) + setOpen(false) + }} + />
)}
diff --git a/frontend/src/shared/components/filters/CustomFilterBar.tsx b/frontend/src/shared/components/filters/CustomFilterBar.tsx index 0513e4814..a1ee9cf3e 100644 --- a/frontend/src/shared/components/filters/CustomFilterBar.tsx +++ b/frontend/src/shared/components/filters/CustomFilterBar.tsx @@ -1,5 +1,7 @@ +import { useEffect, useRef, useState } from 'react' import { X } from 'lucide-react' import { AddCustomFilterButton } from './AddCustomFilterButton' +import { FilterEditorPanel } from './FilterEditorPanel' import type { CustomFilter, FilterBarLabels, @@ -11,6 +13,7 @@ import type { export function CustomFilterBar({ filters, onAdd, + onUpdate, onRemove, onClear, fields, @@ -20,6 +23,7 @@ export function CustomFilterBar({ }: { filters: CustomFilter[] onAdd: (f: CustomFilter) => void + onUpdate?: (i: number, f: CustomFilter) => void onRemove: (i: number) => void onClear: () => void fields: FilterFieldDef[] @@ -27,25 +31,62 @@ export function CustomFilterBar({ fetchValues?: (field: string) => Promise labels: FilterBarLabels }) { + const [editing, setEditing] = useState(null) + const editRef = useRef(null) + + useEffect(() => { + if (editing === null) return + const onDoc = (e: MouseEvent) => editRef.current && !editRef.current.contains(e.target as Node) && setEditing(null) + document.addEventListener('mousedown', onDoc) + return () => document.removeEventListener('mousedown', onDoc) + }, [editing]) + return (
{filters.map((f, i) => { const op = operators.find((o) => o.id === f.operator) + const isEditing = editing === i return ( - - {f.label} - {op?.label ?? f.operator} - {op?.needsValue && {f.value}} - - + + + + {isEditing && onUpdate && ( +
+ setEditing(null)} + onSubmit={(next) => { + onUpdate(i, next) + setEditing(null) + }} + /> +
+ )} +
) })} diff --git a/frontend/src/shared/components/filters/FilterEditorPanel.tsx b/frontend/src/shared/components/filters/FilterEditorPanel.tsx new file mode 100644 index 000000000..ffabd18a8 --- /dev/null +++ b/frontend/src/shared/components/filters/FilterEditorPanel.tsx @@ -0,0 +1,156 @@ +import { useEffect, useState } from 'react' +import { Loader2, Search } from 'lucide-react' +import { cn } from '@/shared/lib/utils' +import { Button } from '@/shared/components/ui/button' +import { Input } from '@/shared/components/ui/input' +import type { + CustomFilter, + FilterBarLabels, + FilterFieldDef, + FilterOpDef, + FilterValue, +} from './custom-filter.types' + +const SELECT_CLS = + 'h-9 cursor-pointer rounded-md border border-input bg-popover px-2 text-sm text-popover-foreground transition-colors focus-visible:border-ring focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring' + +export function FilterEditorPanel({ + initial, + fields, + operators, + fetchValues, + labels, + onSubmit, + onCancel, +}: { + initial?: CustomFilter + fields: FilterFieldDef[] + operators: FilterOpDef[] + fetchValues?: (field: string) => Promise + labels: FilterBarLabels + onSubmit: (f: CustomFilter) => void + onCancel: () => void +}) { + const [field, setField] = useState(initial?.field ?? fields[0]?.field ?? '') + const [operator, setOperator] = useState(initial?.operator ?? operators[0]?.id ?? 'IS') + const [freeValue, setFreeValue] = useState(initial?.value ?? '') + const [values, setValues] = useState([]) + const [loadingValues, setLoadingValues] = useState(false) + const [vq, setVq] = useState('') + + const op = operators.find((o) => o.id === operator) + const needsValue = op?.needsValue ?? true + + useEffect(() => { + if (!needsValue || !fetchValues) return + setLoadingValues(true) + setValues([]) + fetchValues(field) + .then(setValues) + .catch(() => setValues([])) + .finally(() => setLoadingValues(false)) + }, [field, needsValue, fetchValues]) + + const submit = (value: string) => { + const fdef = fields.find((f) => f.field === field) + if (!fdef) return + onSubmit({ field, label: fdef.label, operator, value }) + } + + const filtered = values.filter((v) => (vq ? v.value.toLowerCase().includes(vq.toLowerCase()) : true)) + + return ( +
+
+ + + + {needsValue ? ( + fetchValues ? ( + <> +
+ + setVq(e.target.value)} + placeholder={labels.filterValues} + className="h-8 pl-8 text-xs" + autoFocus + /> +
+
+ {loadingValues ? ( +
+ {labels.loadingValues} +
+ ) : filtered.length === 0 ? ( +
{labels.noValues}
+ ) : ( + filtered.map((v) => { + const selected = initial?.value === v.value + return ( + + ) + }) + )} +
+

{labels.pickValue}

+ + ) : ( + <> + setFreeValue(e.target.value)} + placeholder={labels.filterValues} + className="h-8 text-xs" + autoFocus + onKeyDown={(e) => e.key === 'Enter' && freeValue && submit(freeValue)} + /> +
+ + +
+ + ) + ) : ( +
+ + +
+ )} +
+
+ ) +} From 34cf1c8cb18051fa0bc4f1c16bcb4478825acc6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Mon, 3 Aug 2026 13:06:39 -0600 Subject: [PATCH 4/7] feat[frontend](user-auditor): added linux users and replaced filters --- .../user-auditor/pages/UserAuditorPage.tsx | 56 ++++++++++++++++--- frontend/src/shared/i18n/locales/de.json | 32 ++++++++--- frontend/src/shared/i18n/locales/en.json | 32 ++++++++--- frontend/src/shared/i18n/locales/es.json | 32 ++++++++--- frontend/src/shared/i18n/locales/fr.json | 32 ++++++++--- frontend/src/shared/i18n/locales/it.json | 32 ++++++++--- frontend/src/shared/i18n/locales/pt.json | 32 ++++++++--- frontend/src/shared/i18n/locales/ru.json | 32 ++++++++--- 8 files changed, 223 insertions(+), 57 deletions(-) diff --git a/frontend/src/features/user-auditor/pages/UserAuditorPage.tsx b/frontend/src/features/user-auditor/pages/UserAuditorPage.tsx index fc9f851a3..881c05d4b 100644 --- a/frontend/src/features/user-auditor/pages/UserAuditorPage.tsx +++ b/frontend/src/features/user-auditor/pages/UserAuditorPage.tsx @@ -11,7 +11,6 @@ import { ExternalLink, Fingerprint, LayoutGrid, - ListFilter, ListIcon, Loader2, RefreshCw, @@ -28,8 +27,14 @@ import { cn } from '@/shared/lib/utils' import { Button } from '@/shared/components/ui/button' import { Input } from '@/shared/components/ui/input' import { InfiniteScrollSentinel } from '@/shared/components/ui/infinite-scroll' +import { CustomFilterBar } from '@/shared/components/filters/CustomFilterBar' +import type { + CustomFilter, + FilterFieldDef, + FilterOpDef, +} from '@/shared/components/filters/custom-filter.types' import { adAuditHttpService } from '../services/ad-audit-http.service' -import type { ADUser, ADUserSource, ADUserStats } from '../types/ad-user.types' +import type { ADUser, ADUserSource, ADUserStats, ADUserStatus } from '../types/ad-user.types' const SIZE = 50 const STALE_MS = 30 * 86_400_000 @@ -82,6 +87,8 @@ const STATUS_TEXT: Record = { /* ─── Page ─────────────────────────────────────────────────────────────── */ +const STATUS_VALUES: ADUserStatus[] = ['active', 'disabled', 'deleted', 'stale', 'service'] + export function UserAuditorPage() { const { t } = useTranslation() const [view, setView] = useState('all') @@ -90,6 +97,7 @@ export function UserAuditorPage() { const [tenant, setTenant] = useState('') // '' = all const [layout, setLayout] = useState<'list' | 'cards'>('list') const [page, setPage] = useState(0) + const [customFilters, setCustomFilters] = useState([]) const [users, setUsers] = useState([]) const [total, setTotal] = useState(0) @@ -98,6 +106,26 @@ export function UserAuditorPage() { const [stats, setStats] = useState(null) const [openUser, setOpenUser] = useState(null) + const filterFields: FilterFieldDef[] = [ + { field: 'status', label: t('userAuditor.filterFields.status') }, + ] + const filterOps: FilterOpDef[] = [{ id: 'IS', label: t('userAuditor.ops.is'), needsValue: true }] + const filterLabels = { + add: t('userAuditor.filters.add'), + clearAll: t('userAuditor.filters.clearAll'), + filterValues: t('userAuditor.filters.filterValues'), + loadingValues: t('userAuditor.filters.loadingValues'), + noValues: t('userAuditor.filters.noValues'), + pickValue: t('userAuditor.filters.pickValue'), + empty: t('userAuditor.filters.empty'), + cancel: t('userAuditor.filters.cancel'), + addBtn: t('userAuditor.filters.addBtn'), + } + const fetchFilterValues = async (field: string) => { + if (field === 'status') return STATUS_VALUES.map((v) => ({ value: v, count: 0 })) + return [] + } + // Debounce the search box. useEffect(() => { const id = setTimeout(() => setSearch(searchInput.trim()), 300) @@ -107,7 +135,7 @@ export function UserAuditorPage() { // Any filter change resets to the first page. useEffect(() => { setPage(0) - }, [view, search, tenant]) + }, [view, search, tenant, customFilters]) const loadStats = useCallback(async () => { try { @@ -121,10 +149,12 @@ export function UserAuditorPage() { setLoading(true) setError(false) try { + const statusFilter = customFilters.find((f) => f.field === 'status' && f.operator === 'IS')?.value const res = await adAuditHttpService.list({ search: search || undefined, tenantId: tenant || undefined, source: view === 'all' ? undefined : view, + status: statusFilter ? (statusFilter as ADUserStatus) : undefined, sort: 'recent', page, size: SIZE, @@ -138,7 +168,7 @@ export function UserAuditorPage() { } finally { setLoading(false) } - }, [search, tenant, view, page]) + }, [search, tenant, view, page, customFilters]) useEffect(() => { void load() @@ -183,6 +213,20 @@ export function UserAuditorPage() { t={t} /> +
+ setCustomFilters((c) => [...c, f])} + onUpdate={(i, f) => setCustomFilters((c) => c.map((x, idx) => (idx === i ? f : x)))} + onRemove={(i) => setCustomFilters((c) => c.filter((_, idx) => idx !== i))} + onClear={() => setCustomFilters([])} + fields={filterFields} + operators={filterOps} + fetchValues={fetchFilterValues} + labels={filterLabels} + /> +
+ {error ? (
@@ -404,10 +448,6 @@ function Toolbar({
)} -
- {open && ( -
-
- - - - {needsValue ? ( - <> -
- - setVq(e.target.value)} - placeholder={t('alerts.filters.filterValues')} - className="h-8 pl-8 text-xs" - autoFocus - /> -
-
- {loadingValues ? ( -
- {t('alerts.filters.loadingValues')} -
- ) : filtered.length === 0 ? ( -
{t('alerts.filters.noValues')}
- ) : ( - filtered.map((v) => ( - - )) - )} -
-

{t('alerts.filters.pickValue')}

- - ) : ( -
- - -
- )} -
-
- )} -
- ) -} diff --git a/frontend/src/features/alerts/components/alerts-filter-bar.tsx b/frontend/src/features/alerts/components/alerts-filter-bar.tsx index 26e27a111..40f5b1182 100644 --- a/frontend/src/features/alerts/components/alerts-filter-bar.tsx +++ b/frontend/src/features/alerts/components/alerts-filter-bar.tsx @@ -1,51 +1,59 @@ -import { X } from 'lucide-react' +import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { FILTER_OPS, fieldKey } from '../lib/alert-meta' -import type { CustomFilter } from '../types/alert.types' -import { AddFilterButton } from './add-filter-button' +import { CustomFilterBar } from '@/shared/components/filters/CustomFilterBar' +import type { CustomFilter, FilterFieldDef, FilterOpDef } from '@/shared/components/filters/custom-filter.types' +import { alertsHttpService } from '../services/alerts-http.service' +import { FILTER_FIELDS, FILTER_OPS, fieldKey } from '../lib/alert-meta' export function AlertsFilterBar({ filters, onAdd, + onUpdate, onRemove, onClear, }: { filters: CustomFilter[] onAdd: (f: CustomFilter) => void + onUpdate?: (i: number, f: CustomFilter) => void onRemove: (i: number) => void onClear: () => void }) { const { t } = useTranslation() + + const fields = useMemo( + () => FILTER_FIELDS.map((f) => ({ field: f.field, label: t(`alerts.fields.${fieldKey(f.field)}`) })), + [t], + ) + const operators = useMemo( + () => FILTER_OPS.map((o) => ({ id: o.id, label: t(`alerts.ops.${o.id}`), needsValue: o.needsValue })), + [t], + ) + const labels = useMemo( + () => ({ + add: t('alerts.filters.add'), + clearAll: t('alerts.filters.clearAll'), + filterValues: t('alerts.filters.filterValues'), + loadingValues: t('alerts.filters.loadingValues'), + noValues: t('alerts.filters.noValues'), + pickValue: t('alerts.filters.pickValue'), + empty: t('alerts.filters.empty'), + cancel: t('alerts.filters.cancel'), + addBtn: t('alerts.filters.addBtn'), + }), + [t], + ) + return ( -
- {filters.map((f, i) => { - const op = FILTER_OPS.find((o) => o.id === f.operator) - return ( - - {t(`alerts.fields.${fieldKey(f.field)}`)} - {op ? t(`alerts.ops.${op.id}`) : f.operator} - {op?.needsValue && {f.value}} - - - ) - })} - - {filters.length > 1 && ( - - )} -
+ alertsHttpService.fieldValues(field)} + labels={labels} + /> ) } diff --git a/frontend/src/features/alerts/pages/AlertsPage.tsx b/frontend/src/features/alerts/pages/AlertsPage.tsx index 1a853f731..c415f8ca1 100644 --- a/frontend/src/features/alerts/pages/AlertsPage.tsx +++ b/frontend/src/features/alerts/pages/AlertsPage.tsx @@ -248,6 +248,7 @@ export function AlertsPage() { }) const addFilter = (cf: CustomFilter) => { setCustomFilters((c) => [...c, cf]); setPage(0) } + const updateFilter = (i: number, cf: CustomFilter) => { setCustomFilters((c) => c.map((f, idx) => (idx === i ? cf : f))); setPage(0) } const removeFilter = (i: number) => { setCustomFilters((c) => c.filter((_, idx) => idx !== i)); setPage(0) } // Register a new tag in the catalog, then apply it to the given alerts. @@ -286,6 +287,7 @@ export function AlertsPage() { { setCustomFilters([]); setPage(0) }} /> diff --git a/frontend/src/features/alerts/types/alert.types.ts b/frontend/src/features/alerts/types/alert.types.ts index 57555a649..327607751 100644 --- a/frontend/src/features/alerts/types/alert.types.ts +++ b/frontend/src/features/alerts/types/alert.types.ts @@ -128,9 +128,4 @@ export const STATUS_TABS = ['all', 'open', 'in_review', 'completed', 'auto'] as export type StatusTab = (typeof STATUS_TABS)[number] /** A user-defined filter row in the page filter bar. */ -export interface CustomFilter { - field: string - label: string - operator: string - value: string -} +export type { CustomFilter } from '@/shared/components/filters/custom-filter.types' diff --git a/frontend/src/features/log-explorer/components/AddFilterButton.tsx b/frontend/src/features/log-explorer/components/AddFilterButton.tsx deleted file mode 100644 index c9227424a..000000000 --- a/frontend/src/features/log-explorer/components/AddFilterButton.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from 'react' -import { Filter, Loader2, Search } from 'lucide-react' -import { useTranslation } from 'react-i18next' -import { cn } from '@/shared/lib/utils' -import { Button } from '@/shared/components/ui/button' -import { Input } from '@/shared/components/ui/input' -import { logExplorerHttpService as svc } from '../services/log-explorer-http.service' -import type { - FilterOperator, - FilterType, - IndexField, - IndexPattern, - TopValues, -} from '../types/log-explorer.types' -import { OP_KEY, SELECT_CLS } from './log-explorer.constants' - -/* Explicit field/operator/value filter builder — pick a field, an operator, and - * a value from the field's real existing values (fetched via top-x-values). */ -const BUILDER_OPS: { id: FilterOperator; label: string; needsValue: boolean }[] = [ - { id: 'IS', label: 'is', needsValue: true }, - { id: 'IS_NOT', label: 'is not', needsValue: true }, - { id: 'CONTAIN', label: 'contains', needsValue: true }, - { id: 'EXIST', label: 'exists', needsValue: false }, -] - -export function AddFilterButton({ - pattern, - fields, - filters, - onAdd, -}: { - pattern: IndexPattern - fields: IndexField[] - filters: FilterType[] - onAdd: (f: FilterType) => void -}) { - const { t } = useTranslation() - const selectable = useMemo( - () => fields.filter((f) => !f.name.endsWith('.keyword')).sort((a, b) => a.name.localeCompare(b.name)), - [fields] - ) - const [open, setOpen] = useState(false) - const [field, setField] = useState('') - const [operator, setOperator] = useState('IS') - const [values, setValues] = useState([]) - const [loadingValues, setLoadingValues] = useState(false) - const [vq, setVq] = useState('') - const ref = useRef(null) - - useEffect(() => { - if (open && !field && selectable.length) setField(selectable[0].name) - }, [open, field, selectable]) - - useEffect(() => { - if (!open) return - const onDoc = (e: MouseEvent) => ref.current && !ref.current.contains(e.target as Node) && setOpen(false) - document.addEventListener('mousedown', onDoc) - return () => document.removeEventListener('mousedown', onDoc) - }, [open]) - - const op = BUILDER_OPS.find((o) => o.id === operator) - const needsValue = op?.needsValue ?? true - const fieldDef = selectable.find((f) => f.name === field) - const aggField = fieldDef?.type === 'text' && !field.endsWith('.keyword') ? `${field}.keyword` : field - - useEffect(() => { - if (!open || !needsValue || !field) return - setLoadingValues(true) - setValues([]) - svc - .topValues(pattern.pattern, aggField, filters, 100) - .then((r) => setValues(r.top ?? [])) - .catch(() => setValues([])) - .finally(() => setLoadingValues(false)) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, field, needsValue]) - - const add = (value: string) => { - onAdd({ field, operator, value: needsValue ? value : undefined }) - setOpen(false) - setVq('') - } - const filtered = values.filter((v) => (vq ? String(v.value).toLowerCase().includes(vq.toLowerCase()) : true)) - - return ( -
- - {open && ( -
-
- - - {needsValue ? ( - <> -
- - setVq(e.target.value)} placeholder={t('logExplorer.builder.filterValues')} className="h-8 pl-8 text-xs" autoFocus /> -
-
- {loadingValues ? ( -
{t('logExplorer.builder.loadingValues')}
- ) : filtered.length === 0 ? ( -
{t('logExplorer.builder.noValues')}
- ) : ( - filtered.map((v) => ( - - )) - )} -
- - ) : ( -
- - -
- )} -
-
- )} -
- ) -} diff --git a/frontend/src/features/log-explorer/components/FilterChips.tsx b/frontend/src/features/log-explorer/components/FilterChips.tsx deleted file mode 100644 index 983761966..000000000 --- a/frontend/src/features/log-explorer/components/FilterChips.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { Filter, X } from 'lucide-react' -import { useTranslation } from 'react-i18next' -import { cn } from '@/shared/lib/utils' -import type { FilterType } from '../types/log-explorer.types' -import { OP_KEY } from './log-explorer.constants' - -export function FilterChips({ - filters, - onRemove, - onClear, -}: { - filters: FilterType[] - onRemove: (i: number) => void - onClear: () => void -}) { - const { t } = useTranslation() - return ( -
- - {t('logExplorer.filters.label')} - - {filters.map((f, i) => { - const neg = f.operator === 'IS_NOT' - return ( - - {f.field} - - {OP_KEY[f.operator] ? t(`logExplorer.ops.${OP_KEY[f.operator]}`) : f.operator} - - {f.value != null && f.operator !== 'EXIST' && ( - - {Array.isArray(f.value) ? t('logExplorer.related.nLogs', { count: f.value.length }) : String(f.value)} - - )} - - - ) - })} - {filters.length > 1 && ( - - )} -
- ) -} diff --git a/frontend/src/features/log-explorer/components/LogExplorerView.tsx b/frontend/src/features/log-explorer/components/LogExplorerView.tsx index 007527a82..8ffc3788f 100644 --- a/frontend/src/features/log-explorer/components/LogExplorerView.tsx +++ b/frontend/src/features/log-explorer/components/LogExplorerView.tsx @@ -1,26 +1,27 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { AlertTriangle, Loader2 } from 'lucide-react' +import { AlertTriangle, Loader2, X } from 'lucide-react' import { toast } from 'sonner' import { useTranslation } from 'react-i18next' import { useLocation, useNavigate } from 'react-router-dom' import { Button } from '@/shared/components/ui/button' import { presetRange, type TimeRange } from '@/shared/components/ui/time-range-picker' import { ResultsHeader, ResultRow, flattenDoc } from './log-results' +import { CustomFilterBar } from '@/shared/components/filters/CustomFilterBar' +import type { CustomFilter, FilterOpDef } from '@/shared/components/filters/custom-filter.types' import { QueryBar } from './QueryBar' -import { AddFilterButton } from './AddFilterButton' -import { FilterChips } from './FilterChips' import { FieldSidebar } from './FieldSidebar' import { RowMessage } from './RowMessage' import { ViewToggle } from './ViewToggle' import { SavedSearches, type SavedSearchState } from './SavedSearches' import { HistogramStrip } from './HistogramStrip' import { ChartPanel } from './ChartPanel' -import { TS } from './log-explorer.constants' +import { OP_KEY, TS } from './log-explorer.constants' import { logExplorerHttpService as svc, LogExplorerHttpError, } from '../services/log-explorer-http.service' import type { + FilterOperator, FilterType, IndexField, IndexPattern, @@ -81,6 +82,22 @@ interface RelatedLogsSeed { truncated?: boolean } +const BUILDER_OPS: FilterOpDef[] = [ + { id: 'IS', label: 'is', needsValue: true }, + { id: 'IS_NOT', label: 'is not', needsValue: true }, + { id: 'CONTAIN', label: 'contains', needsValue: true }, + { id: 'EXIST', label: 'exists', needsValue: false }, +] + +function toCustom(f: FilterType): CustomFilter { + return { field: f.field, label: f.field, operator: f.operator, value: typeof f.value === 'string' ? f.value : '' } +} + +function toFilter(cf: CustomFilter): FilterType { + const op = BUILDER_OPS.find((o) => o.id === cf.operator) + return { field: cf.field, operator: cf.operator as FilterOperator, value: op?.needsValue ? cf.value : undefined } +} + interface LogExplorerViewProps { initial: LogExplorerTabConfig onConfigChange: (patch: Partial) => void @@ -400,11 +417,80 @@ export function LogExplorerView({ initial, onConfigChange }: LogExplorerViewProp const removeFilter = useCallback((i: number) => { setFilters((cur) => cur.filter((_, idx) => idx !== i)) }, []) - const clearFilters = useCallback(() => setFilters([]), []) const toggleExpanded = useCallback((i: number) => { setExpanded((prev) => (prev === i ? null : i)) }, []) + // Adapters: CustomFilterBar works with CustomFilter; FilterType is the internal type. + // IS_ONE_OF_TERMS (array value) is excluded — rendered separately as terms chips. + const simpleFilters = useMemo(() => filters.filter((f) => f.operator !== 'IS_ONE_OF_TERMS'), [filters]) + const termsFilters = useMemo(() => filters.filter((f) => f.operator === 'IS_ONE_OF_TERMS'), [filters]) + + const customFilters = useMemo(() => simpleFilters.map(toCustom), [simpleFilters]) + + const barFields = useMemo( + () => + fields + .filter((f) => !f.name.endsWith('.keyword')) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((f) => ({ field: f.name, label: f.name })), + [fields] + ) + + const barOperators = useMemo( + () => BUILDER_OPS.map((o) => ({ ...o, label: t(`logExplorer.ops.${OP_KEY[o.id] ?? o.id}`) })), + [t] + ) + + const fetchValues = useCallback( + (field: string) => { + if (!pattern) return Promise.resolve([]) + const fieldDef = fields.find((f) => f.name === field) + const aggField = fieldDef?.type === 'text' && !field.endsWith('.keyword') ? `${field}.keyword` : field + return svc.topValues(pattern.pattern, aggField, activeFilterList, 100).then((r) => r.top ?? []) + }, + [fields, pattern, activeFilterList] + ) + + const barLabels = useMemo( + () => ({ + add: t('logExplorer.builder.add'), + clearAll: t('logExplorer.filters.clearAll'), + filterValues: t('logExplorer.builder.filterValues'), + loadingValues: t('logExplorer.builder.loadingValues'), + noValues: t('logExplorer.builder.noValues'), + pickValue: t('logExplorer.builder.pickValue'), + empty: t('logExplorer.fields.empty'), + cancel: t('logExplorer.builder.cancel'), + addBtn: t('logExplorer.builder.confirm'), + }), + [t] + ) + + const onBarAdd = useCallback( + (cf: CustomFilter) => addFilter(toFilter(cf)), + [addFilter] + ) + + const onBarUpdate = useCallback( + (i: number, cf: CustomFilter) => { + // i is the index within simpleFilters; map back to the full filters array + const target = simpleFilters[i] + setFilters((cur) => cur.map((f) => (f === target ? toFilter(cf) : f))) + }, + [simpleFilters] + ) + + const onBarRemove = useCallback( + (i: number) => { + const target = simpleFilters[i] + setFilters((cur) => cur.filter((f) => f !== target)) + }, + [simpleFilters] + ) + + const onBarClear = useCallback(() => setFilters([]), []) + return (
@@ -447,10 +533,37 @@ export function LogExplorerView({ initial, onConfigChange }: LogExplorerViewProp
{!sqlMode && pattern && ( - + )} + {termsFilters.map((f, idx) => ( + + {f.field} + {t('logExplorer.ops.isOneOf')} + + {Array.isArray(f.value) ? t('logExplorer.related.nLogs', { count: f.value.length }) : String(f.value)} + + + + ))} {!sqlMode && } - {filters.length > 0 && }
diff --git a/frontend/src/shared/i18n/locales/en.json b/frontend/src/shared/i18n/locales/en.json index d19d57b10..77593cdf1 100644 --- a/frontend/src/shared/i18n/locales/en.json +++ b/frontend/src/shared/i18n/locales/en.json @@ -1797,6 +1797,7 @@ "filterValues": "Filter values…", "loadingValues": "Loading values…", "noValues": "No values found.", + "pickValue": "Pick a value to add the filter.", "cancel": "Cancel", "confirm": "Add" }, From fda7ff6a3118a7f8a717ad9a290e55f1ae9035b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Mon, 3 Aug 2026 13:26:12 -0600 Subject: [PATCH 6/7] feat[frontend](adversary-views): make filters editable --- frontend/src/features/adversaries/pages/AdversariesPage.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/features/adversaries/pages/AdversariesPage.tsx b/frontend/src/features/adversaries/pages/AdversariesPage.tsx index ff3166026..b21f46bfd 100644 --- a/frontend/src/features/adversaries/pages/AdversariesPage.tsx +++ b/frontend/src/features/adversaries/pages/AdversariesPage.tsx @@ -53,6 +53,7 @@ export function AdversariesPage() { setCustomFilters((c) => [...c, f])} + onUpdate={(i, f) => setCustomFilters((c) => c.map((x, idx) => (idx === i ? f : x)))} onRemove={(i) => setCustomFilters((c) => c.filter((_, idx) => idx !== i))} onClear={() => setCustomFilters([])} /> From 408c77d6394863ae283b1678c3371032c3180c4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Mon, 3 Aug 2026 13:27:36 -0600 Subject: [PATCH 7/7] feat[frontend](adversary-views): remove adversary view margins --- frontend/src/features/adversaries/pages/AdversariesPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/features/adversaries/pages/AdversariesPage.tsx b/frontend/src/features/adversaries/pages/AdversariesPage.tsx index b21f46bfd..c2fea60f1 100644 --- a/frontend/src/features/adversaries/pages/AdversariesPage.tsx +++ b/frontend/src/features/adversaries/pages/AdversariesPage.tsx @@ -28,7 +28,7 @@ export function AdversariesPage() { const { data, loading, error, refresh } = useAdversaries(filters) return ( -
+