UI primitives (design system)
Every reusable UI primitive lives in
webapp/frontend/src/components/ui/*.tsx and is re-exported from the barrel
webapp/frontend/src/components/ui/index.ts. Import from the barrel:
import { Button, Badge, Card, CardHeader, CardBody, DataTable } from '@/components/ui';All components style themselves with semantic Tailwind tokens (never raw
colors). Class merging goes through the cn() helper in @/lib/utils. The token
families you will see repeatedly:
| Token family | Meaning |
|---|---|
surface-1 / surface-2 / surface-3 | Page / card / hover backgrounds (ascending elevation) |
surface-inverse / fg-inverse | Inverted chip / tooltip background + text |
fg / fg-muted / fg-subtle | Primary / secondary / tertiary text |
line | Default border color |
brand / brand-hover / brand-fg / brand-soft / brand-soft-fg | Accent color + soft/tinted variants |
sev-critical / sev-high / sev-medium / sev-low / sev-info (+ -soft) | Severity colors and their tinted backgrounds |
status-ok | Success/positive color |
rounded-v2 / rounded-v2-sm / rounded-v2-lg | Radius scale |
shadow-v2-xs / shadow-v2-sm / shadow-v2-pop | Elevation shadows |
When you build a new component, reuse these primitives rather than re-styling
raw HTML elements. Prefer passing className to override spacing only — do not
fight the built-in token styling.
Button
components/ui/Button.tsx — the primary action element. Forwards a ref to the
underlying <button> and spreads all native button attributes.
Supports both the v2 variant names and legacy v1 aliases so old pages keep working. Prefer the v2 names in new code.
Props
Extends React.ButtonHTMLAttributes<HTMLButtonElement>.
| Prop | Type | Default | Notes |
|---|---|---|---|
variant | 'primary' | 'secondary' | 'ghost' | 'danger' | 'link' | 'default' | 'destructive' | 'outline' | 'default' | default≙primary, destructive≙danger, outline≙secondary |
size | 'sm' | 'md' | 'lg' | 'icon' | 'default' | 'default' | |
loading | boolean | — | Shows a spinner in place of leftIcon and disables the button |
leftIcon | React.ReactNode | — | Rendered before children (hidden while loading) |
rightIcon | React.ReactNode | — | Rendered after children (hidden while loading) |
| …rest | native <button> props | — | onClick, disabled, type, etc. |
Variants
| Variant | Appearance |
|---|---|
primary / default | Solid brand fill |
secondary / outline | surface-2 fill, line border, hover surface-3 |
ghost | Transparent, hover surface-3 |
danger / destructive | Solid critical-severity fill, white text |
link | Text-only brand link, underline on hover (no padding/height) |
Sizes
| Size | Height / padding |
|---|---|
sm | h-7, px-2.5, text-xs |
md | h-8, px-3, text-sm |
lg | h-10, px-4, text-sm |
icon | h-8 w-8, no padding (square icon button) |
default | h-9, px-3.5, text-sm |
Usage
import { Button } from '@/components/ui';import { Plus } from 'lucide-react';
<Button variant="primary" size="md" leftIcon={<Plus className="h-3.5 w-3.5" />}> Add rule</Button>
<Button variant="danger" loading={isDeleting} onClick={onDelete}> Delete</Button>
<Button variant="ghost" size="icon" aria-label="Options"> <MoreVertical className="h-4 w-4" /></Button>disabled is automatically applied while loading is true.
Badge
components/ui/Badge.tsx — a small pill for status / category labels. Spreads
native <span> attributes.
Props
Extends React.HTMLAttributes<HTMLSpanElement>.
| Prop | Type | Default | Notes |
|---|---|---|---|
tone | 'neutral' | 'brand' | 'success' | 'warning' | 'danger' | 'info' | 'neutral' | Color scheme |
dot | boolean | — | Renders a small leading status dot in the current text color |
size | 'sm' | 'md' | 'sm' | sm = h-5 text-2xs, md = h-6 text-xs |
Tones
| Tone | Use for |
|---|---|
neutral | Default / counts |
brand | Accent labels |
success | Healthy / done |
warning | Medium-severity / caution |
danger | Critical / errors |
info | Informational |
Usage
import { Badge } from '@/components/ui';
<Badge tone="brand" size="sm">{count} rules</Badge><Badge tone="success" dot>Synced</Badge>For vulnerability severity specifically, use SeverityPill
instead of Badge.
SeverityPill
components/ui/SeverityPill.tsx — an uppercase severity chip with the canonical
severity color mapping. Use this everywhere a CVE/finding severity is shown.
Props
| Prop | Type | Default | Notes |
|---|---|---|---|
severity | Severity | string | — | Case-insensitive; unknown strings fall back to info styling |
className | string | — | |
children | React.ReactNode | auto label | Override the displayed text; defaults to the mapped label |
The exported Severity type:
export type Severity = 'critical' | 'high' | 'medium' | 'low' | 'info' | 'none' | 'unset';Severity mapping
severity | Label | Style |
|---|---|---|
critical | CRITICAL | sev-critical on soft bg |
high | HIGH | sev-high on soft bg |
medium | MEDIUM | sev-medium on soft bg |
low | LOW | sev-low on soft bg |
info | INFO | sev-info on soft bg |
none | None | muted |
unset | Unset | muted, italic |
Usage
import { SeverityPill } from '@/components/ui';
<SeverityPill severity="critical" /><SeverityPill severity={cve.severity} /> // string from the API is fineCard family
components/ui/Card.tsx — surface container plus header/body/footer
subcomponents. All forward refs and spread native <div> props.
The
Cardroot deliberately has nooverflow-hidden(it would clip popovers such asMenuandTooltip). Addoverflow-hiddenviaclassNameonly when you explicitly want clipping.
Components
| Export | Element | Purpose |
|---|---|---|
Card | div | Container: surface-2 bg, line border, shadow-v2-sm |
CardHeader | div | Overloaded — see below |
CardTitle | h3 | Legacy title text (use inside legacy CardHeader) |
CardDescription | p | Legacy subtitle text |
CardBody | div | Preferred body wrapper (p-4) |
CardContent | div | Legacy alias of CardBody (identical) |
CardFooter | div | Bottom bar with top border, surface-1 bg |
CardHeader props
CardHeader renders the new compact 48px bar when any of title,
description, or actions is provided; otherwise it falls back to the legacy
flex-col stack that renders children.
| Prop | Type | Notes |
|---|---|---|
title | React.ReactNode | Triggers the compact bar; truncates |
description | React.ReactNode | Subtitle in the compact bar; truncates |
actions | React.ReactNode | Right-aligned action slot in the compact bar |
children | React.ReactNode | Legacy mode only (used when no title/description/actions) |
Usage (preferred / v2)
import { Card, CardHeader, CardBody, CardFooter, Button } from '@/components/ui';
<Card> <CardHeader title="Applications" description="12 discovered" actions={<Button size="sm">Refresh</Button>} /> <CardBody>…</CardBody> <CardFooter>…</CardFooter></Card>Usage (legacy — still supported)
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui';
<Card> <CardHeader> <CardTitle>Applications</CardTitle> <CardDescription>12 discovered</CardDescription> </CardHeader> <CardContent>…</CardContent></Card>Input, Textarea, Label, Select
components/ui/Input.tsx — form controls with enterprise styling. Input,
Textarea, and Select forward refs and spread all native attributes of their
respective elements; there are no custom props beyond className.
| Export | Element | Notes |
|---|---|---|
Input | input | h-8, surface-2 bg, brand focus ring. Default export of the module. |
Textarea | textarea | Auto-height via rows; same styling as Input |
Label | label | text-xs font-medium text-fg-muted mb-1; not a forwardRef |
Select | select | Native <select> with appearance-none and a custom inline SVG chevron. Pass <option> children. |
Usage
import { Input, Textarea, Label, Select } from '@/components/ui';
<div> <Label htmlFor="name">Name</Label> <Input id="name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Search…" /></div>
<Textarea rows={4} value={notes} onChange={(e) => setNotes(e.target.value)} />
<Select value={env} onChange={(e) => setEnv(e.target.value)}> <option value="prod">Production</option> <option value="dev">Development</option></Select>
Selectis the native styled dropdown used inside forms and the Query Builder. For a floating action/command menu, useMenuinstead.
Menu
components/ui/Menu.tsx — a lightweight dropdown menu. Clones the given
trigger element to wire up open/close, closes on outside-click and Escape,
and renders items into an absolutely-positioned popover (z-50).
Props
| Prop | Type | Default | Notes |
|---|---|---|---|
trigger | React.ReactElement | — | Single element; its onClick is preserved and augmented |
items | MenuItem[] | — | See MenuItem below |
align | 'start' | 'end' | 'end' | Horizontal alignment of the popover |
width | string | 'w-56' | Tailwind width class for the popover |
MenuItem
export interface MenuItem { label: React.ReactNode; icon?: React.ReactNode; onSelect?: () => void; href?: string; // navigates via window.location.href after onSelect danger?: boolean; // critical-severity styling disabled?: boolean; separator?: boolean; // renders a divider line instead of a button}Usage
import { Menu } from '@/components/ui';import { Button } from '@/components/ui';import { Pencil, Trash2, ChevronDown } from 'lucide-react';
<Menu align="end" trigger={<Button size="sm" variant="secondary" rightIcon={<ChevronDown className="h-3 w-3" />}>Actions</Button>} items={[ { label: 'Edit', icon: <Pencil className="h-3.5 w-3.5" />, onSelect: onEdit }, { separator: true }, { label: 'Delete', icon: <Trash2 className="h-3.5 w-3.5" />, danger: true, onSelect: onDelete }, ]}/>DataTable
components/ui/DataTable.tsx — the enterprise data grid: sticky header,
client-side search, click-to-sort columns, a persisted column-picker
(localStorage keyed by id), density control, and a results footer. Generic
over the row type T. Uses Menu for the column picker.
Props
DataTableProps<T>:
| Prop | Type | Default | Notes |
|---|---|---|---|
id | string | — | Required. Persists column visibility under dt:{id}:visible in localStorage |
columns | Column<T>[] | — | Column definitions (below) |
rows | T[] | — | Row data |
rowKey | (row: T, index: number) => string | — | Stable React key per row |
onRowClick | (row: T) => void | — | Makes rows clickable + hover-highlighted |
loading | boolean | — | Shows a loading placeholder in the body |
empty | React.ReactNode | 'No results' | Rendered when there are zero rows after filtering |
searchable | boolean | true | Toggles the toolbar search box |
searchPlaceholder | string | 'Search…' | |
initialSort | { id: string; dir: 'asc' | 'desc' } | — | Starting sort |
toolbar | React.ReactNode | — | Extra controls rendered left of the “Cols” button |
className | string | — | |
density | 'compact' | 'comfortable' | 'comfortable' | Row height (h-8 vs h-10) |
resultsLabel | (visible: number, total: number) => React.ReactNode | `${visible} of ${total}` | Footer text |
Column<T>
| Field | Type | Notes |
|---|---|---|
id | string | Unique column id (used for sort + visibility) |
header | React.ReactNode | Header cell content |
accessor | (row: T) => React.ReactNode | Renders the cell |
sortValue | (row: T) => string | number | null | undefined | Value used for sorting and search; falls back to accessor |
width | string | CSS grid track, e.g. '180px' or 'minmax(0,1fr)' (default minmax(120px,1fr)) |
align | 'left' | 'right' | 'center' | Cell + header alignment |
sticky | boolean | Reserved for sticky columns |
mono | boolean | Monospace cell text |
hiddenByDefault | boolean | Column starts hidden (until user enables it) |
Sorting cycles asc → desc → off on repeated header clicks. Search matches
against each column’s sortValue (or accessor output) as a lowercased string.
Usage
import { DataTable, type Column } from '@/components/ui';import { SeverityPill } from '@/components/ui';
type Finding = { id: string; cve: string; severity: string; asset: string };
const columns: Column<Finding>[] = [ { id: 'cve', header: 'CVE', accessor: (r) => r.cve, mono: true, width: '160px' }, { id: 'severity', header: 'Severity', accessor: (r) => <SeverityPill severity={r.severity} />, sortValue: (r) => r.severity }, { id: 'asset', header: 'Asset', accessor: (r) => r.asset },];
<DataTable id="findings" columns={columns} rows={findings} rowKey={(r) => r.id} initialSort={{ id: 'severity', dir: 'desc' }} onRowClick={(r) => openDrawer(r)} density="compact"/>
DataTabledoes not paginate. It filters/sorts therowsyou pass; for server-side paging, slicerowsin the parent and render your own pager (see Pagination).
AttributeTable
components/ui/AttributeTable.tsx — a searchable key/value inspector (runZero
style): muted key column, monospace value column, per-row hover copy button.
Props
| Prop | Type | Default | Notes |
|---|---|---|---|
attributes | Attribute[] | — | Rows to render |
className | string | — | |
searchable | boolean | true | Toggles the top search field |
Attribute
export interface Attribute { key: string; value: React.ReactNode; raw?: string; // plain string used for search + clipboard copy}Provide raw whenever value is non-text (a React node) so search and
copy-to-clipboard behave correctly.
Usage
import { AttributeTable } from '@/components/ui';
<AttributeTable attributes={[ { key: 'hostname', value: 'web-01.prod' }, { key: 'os', value: <Badge tone="info">Ubuntu 22.04</Badge>, raw: 'Ubuntu 22.04' }, ]}/>KeyValueGrid
components/ui/KeyValueGrid.tsx — a compact two-column definition list
(<dl>), for “information” panels. Unlike AttributeTable it has no search or
copy affordances.
Props
| Prop | Type | Default | Notes |
|---|---|---|---|
rows | KV[] | — | Row data |
className | string | — | |
labelWidth | number | 120 | Fixed label-column width in pixels |
KV
export interface KV { label: React.ReactNode; value: React.ReactNode; mono?: boolean; // monospace the value}Usage
import { KeyValueGrid } from '@/components/ui';
<KeyValueGrid labelWidth={140} rows={[ { label: 'IP address', value: '10.0.4.12', mono: true }, { label: 'First seen', value: 'Jul 2, 2026' }, ]}/>Tabs / TabsList / TabsTrigger / TabsContent
components/ui/Tabs.tsx — an underline-style tab set with React-context state.
Works controlled or uncontrolled.
Tabs (root)
| Prop | Type | Notes |
|---|---|---|
defaultValue | string | Uncontrolled starting tab |
value | string | Controlled active tab |
onValueChange | (v: string) => void | Fires on tab change |
children | React.ReactNode | — |
className | string | — |
TabsList
| Prop | Type | Notes |
|---|---|---|
children | React.ReactNode | TabsTriggers |
className | string | — |
TabsTrigger
| Prop | Type | Notes |
|---|---|---|
value | string | Required. Matches a TabsContent value |
icon | React.ReactNode | Optional leading icon |
count | number | Optional trailing count chip |
children | React.ReactNode | Label |
className | string | — |
TabsContent
| Prop | Type | Notes |
|---|---|---|
value | string | Required. Renders (with a fade-in) only when active |
children | React.ReactNode | — |
className | string | — |
Usage
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui';import { List, AlertTriangle } from 'lucide-react';
<Tabs defaultValue="all"> <TabsList> <TabsTrigger value="all" icon={<List className="h-4 w-4" />} count={128}>All</TabsTrigger> <TabsTrigger value="critical" icon={<AlertTriangle className="h-4 w-4" />} count={7}>Critical</TabsTrigger> </TabsList> <TabsContent value="all">…</TabsContent> <TabsContent value="critical">…</TabsContent></Tabs>SourceTabs
components/ui/SourceTabs.tsx — a vertical tab rail (fixed w-48) used for
switching between data sources/integrations in a master-detail layout.
Controlled only.
Props
| Prop | Type | Notes |
|---|---|---|
tabs | SourceTab[] | Tab definitions |
value | string | Active tab id (controlled) |
onChange | (id: string) => void | Fires on selection |
className | string | — |
SourceTab
export interface SourceTab { id: string; label: string; count?: number; // trailing count chip icon?: React.ReactNode; color?: string; // Tailwind text-color class for the icon, e.g. 'text-purple-500'}Usage
import { SourceTabs } from '@/components/ui';import { Shield } from 'lucide-react';
<div className="flex"> <SourceTabs value={source} onChange={setSource} tabs={[ { id: 'intune', label: 'Microsoft Intune', count: 340, icon: <Shield />, color: 'text-blue-500' }, { id: 'sentinelone', label: 'SentinelOne', count: 512, icon: <Shield />, color: 'text-purple-500' }, ]} /> <div className="flex-1">{/* detail panel */}</div></div>Contrast with Tabs: SourceTabs
is vertical + controlled and takes a data array; Tabs is horizontal and
composed from child components.
Drawer
components/ui/Drawer.tsx — a right/left side panel over a dimming backdrop.
Traps body scroll while open, closes on Escape, backdrop click, or the header
X.
Props
DrawerProps:
| Prop | Type | Default | Notes |
|---|---|---|---|
open | boolean | — | Controls visibility; returns null when false |
onClose | () => void | — | Called on ESC / backdrop / X |
title | React.ReactNode | — | Header title; used as aria-label if it is a string |
subtitle | React.ReactNode | — | Secondary header line |
headerActions | React.ReactNode | — | Nodes rendered left of the close button |
footer | React.ReactNode | — | Bottom-anchored footer bar |
children | React.ReactNode | — | Scrollable body |
size | 'sm' | 'md' | 'lg' | 'xl' | 'full' | 'lg' | Panel width |
side | 'right' | 'left' | 'right' | Slide-in edge |
ariaLabel | string | — | Screen-reader label when title is non-text |
className | string | — | Applied to the panel |
Sizes
| Size | Width |
|---|---|
sm | min(360px, 100vw) |
md | min(520px, 100vw) |
lg | min(720px, 100vw) |
xl | min(960px, 100vw) |
full | 100vw |
Usage
import { Drawer, Button } from '@/components/ui';
<Drawer open={open} onClose={() => setOpen(false)} title="Finding detail" subtitle={finding.cve} size="lg" footer={<div className="flex justify-end gap-2"> <Button variant="secondary" onClick={() => setOpen(false)}>Close</Button> <Button variant="primary" onClick={onSave}>Save</Button> </div>}> {/* body content */}</Drawer>For a centered modal (rather than an edge panel), use Dialog.
PageHeader
components/ui/PageHeader.tsx — the standard page title block: optional
breadcrumbs, back button, title/subtitle, and right-aligned meta + actions. The
module also exports Breadcrumbs.
PageHeader props
| Prop | Type | Notes |
|---|---|---|
title | React.ReactNode | Required. h1, truncates |
subtitle | React.ReactNode | Secondary line under the title |
backHref | string | Renders a back chevron linking here |
breadcrumbs | Crumb[] | Rendered above the title |
meta | React.ReactNode | Muted text left of actions |
actions | React.ReactNode | Right-aligned action slot |
className | string | — |
Breadcrumbs
export interface Crumb { label: string; href?: string; }
// <Breadcrumbs items={Crumb[]} />A Crumb with href is a link; without one it renders as the current
(non-link) page. Separators (/) are inserted automatically.
Usage
import { PageHeader, Button } from '@/components/ui';
<PageHeader title="web-01.prod" subtitle="Ubuntu 22.04 · 12 open findings" backHref="/assets" breadcrumbs={[{ label: 'Assets', href: '/assets' }, { label: 'web-01.prod' }]} meta="Last scan 2h ago" actions={<Button variant="primary" size="sm">Rescan</Button>}/>Primitives module
components/ui/Primitives.tsx bundles several smaller components:
Skeleton, EmptyState, Tooltip, Dialog, Copyable, and the toast system
(toast + Toaster). All are re-exported from the barrel.
Skeleton
A shimmering loading placeholder. Give it a size via className.
| Prop | Type | Notes |
|---|---|---|
className | string | Set width/height/shape here |
import { Skeleton } from '@/components/ui';
<Skeleton className="h-4 w-40" /><Skeleton className="h-24 w-full rounded-v2" />EmptyState
Centered empty/zero-data block with optional icon, description, and action.
| Prop | Type | Notes |
|---|---|---|
icon | React.ReactNode | Optional leading icon |
title | React.ReactNode | Required. |
description | React.ReactNode | Optional supporting text |
action | React.ReactNode | Optional CTA (e.g. a Button) |
className | string | — |
import { EmptyState, Button } from '@/components/ui';import { Inbox } from 'lucide-react';
<EmptyState icon={<Inbox className="h-8 w-8" />} title="No findings" description="This asset has no open vulnerabilities." action={<Button size="sm" variant="secondary">Run a scan</Button>}/>Tooltip
Hover/focus tooltip wrapping a single child element.
| Prop | Type | Default | Notes |
|---|---|---|---|
content | React.ReactNode | — | Tooltip body |
children | React.ReactElement | — | Single trigger element |
side | 'top' | 'bottom' | 'left' | 'right' | 'top' | Placement |
import { Tooltip } from '@/components/ui';
<Tooltip content="Copy to clipboard" side="top"> <button aria-label="Copy"><Copy className="h-4 w-4" /></button></Tooltip>Dialog
A centered modal (distinct from the edge-anchored Drawer).
| Prop | Type | Default | Notes |
|---|---|---|---|
open | boolean | — | Visibility; returns null when false |
onOpenChange | (v: boolean) => void | — | Called with false on ESC / backdrop / X |
title | React.ReactNode | — | Header title |
description | React.ReactNode | — | Header subtitle |
children | React.ReactNode | — | Body |
footer | React.ReactNode | — | Right-aligned footer bar |
size | 'sm' | 'md' | 'lg' | 'xl' | 'md' | max-w-sm / max-w-md / max-w-2xl / max-w-4xl |
import { Dialog, Button } from '@/components/ui';
<Dialog open={open} onOpenChange={setOpen} title="Delete rule?" description="This cannot be undone." footer={<> <Button variant="secondary" onClick={() => setOpen(false)}>Cancel</Button> <Button variant="danger" onClick={onConfirm}>Delete</Button> </>}> The rule and its history will be removed.</Dialog>Copyable
Inline monospace text that copies its value to the clipboard on click (shows a brief “Copied!” title).
| Prop | Type | Notes |
|---|---|---|
value | string | Required. Text shown and copied |
className | string | — |
import { Copyable } from '@/components/ui';
<Copyable value="CVE-2024-12345" />toast + Toaster
A minimal global toast system. Mount <Toaster /> once near the app root, then
call toast.* from anywhere. Toasts auto-dismiss after 4 seconds.
export const toast = { success: (message: string) => void, error: (message: string) => void, info: (message: string) => void,};| Tone | Style |
|---|---|
success | status-ok on soft bg |
error | sev-critical on soft bg |
info (default) | neutral surface-2 |
// once, at the app root:import { Toaster } from '@/components/ui';<Toaster />
// anywhere:import { toast } from '@/components/ui';toast.success('Rule saved');toast.error('Failed to save rule');QueryBuilder
components/ui/QueryBuilder.tsx — the enterprise filter builder: infinitely
nestable groups, 4 combinators, 16 operators, per-field type hints, and a
Builder ⇄ raw-JSON mode toggle. Backed by the pure helpers and types in
@/lib/query-builder.
Props
QueryBuilderProps:
| Prop | Type | Default | Notes |
|---|---|---|---|
value | Group | — | Controlled query tree |
onChange | (next: Group) => void | — | Fires on any edit |
fields | FieldDef[] | — | Selectable fields (drives dropdowns) |
maxRules | number | — | Optional cap; disables “Add” when reached |
showModeToggle | boolean | — | Show the Text editor / Query builder switch |
className | string | — |
Supporting types (@/lib/query-builder)
export type Combinator = 'and' | 'or' | 'none' | 'not_all';
export type Operator = | 'contains' | 'equals' | 'not_contains' | 'not_equals' | 'starts_with' | 'not_starts_with' | 'ends_with' | 'not_ends_with' | 'gt' | 'gte' | 'lt' | 'lte' | 'is_empty' | 'is_not_empty' | 'in' | 'not_in';
export interface Rule { id: string; kind: 'rule'; field: string; operator: Operator; value: string; }export interface Group { id: string; kind: 'group'; combinator: Combinator; rules: Array<Rule | Group>; }export type Node = Rule | Group;
export interface FieldDef { id: string; // backend field name (must be allowlisted server-side) label: string; // shown in the dropdown type?: 'string' | 'number' | 'boolean' | 'date'; // defaults to 'string'; drives the operator list options?: Array<{ value: string; label?: string }>; // renders a value <Select> instead of an <Input> group?: string; // renders fields under an <optgroup>}The available operators are derived from FieldDef.type: number fields
get comparison + presence operators, boolean fields get equals/not_equals,
and everything else gets the full string/set operator list. Operators tagged
noValue in OPERATOR_META (is_empty, is_not_empty) disable the value
input.
Helpers
Use these factory / transform helpers rather than hand-building trees:
| Helper | Signature | Purpose |
|---|---|---|
emptyGroup | (combinator?: Combinator) => Group | Fresh group with one blank rule |
newRule | (field?: string, value?: string) => Rule | Blank rule (defaults to contains) |
countRules | (g: Group) => number | Total rules, excluding group nodes |
isQueryNonEmpty | (g: Group) => boolean | True if any rule is meaningful |
summarizeQuery | (g: Group, fields: FieldDef[]) => string | One-line human summary |
toServerPayload | (g: Group) => object | Strips client-only ids before sending |
updateNode / removeNode / appendChild | see source | Immutable tree edits |
Usage
import { QueryBuilder, type QueryBuilderProps } from '@/components/ui';import { emptyGroup, toServerPayload, type Group, type FieldDef } from '@/lib/query-builder';
const fields: FieldDef[] = [ { id: 'hostname', label: 'Hostname' }, { id: 'severity', label: 'Severity', options: [{ value: 'critical' }, { value: 'high' }] }, { id: 'cvss', label: 'CVSS score', type: 'number' },];
const [query, setQuery] = useState<Group>(() => emptyGroup('and'));
<QueryBuilder value={query} onChange={setQuery} fields={fields} showModeToggle />
// when submitting:await api.search(toServerPayload(query));The type shapes match the backend Pydantic schemas in
backend/app/services/query_builder.py 1:1.
Pagination (not a ui/ export)
There is no shared Pagination primitive in components/ui/. Pagination is
a small local function Pagination(...) duplicated inside table pages such as
components/ApplicationsTable.tsx and components/DevicesTable.tsx. Its shape
in both places is:
function Pagination({ page, totalPages, onChange,}: { page: number; totalPages: number; onChange: (p: number) => void;}): JSX.ElementIt renders prev/next chevrons plus first/last and up to a few middle page
buttons with … ellipses (via a buildPageList helper). When you need paging
alongside a DataTable, replicate this local pattern in the parent
page (the DataTable itself does not paginate). If pagination becomes common,
promote it into components/ui/ and export it from the barrel.
Related
- Data model — the entities these components render.
- API reference — endpoints backing the tables and the Query Builder.