Skip to content

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 familyMeaning
surface-1 / surface-2 / surface-3Page / card / hover backgrounds (ascending elevation)
surface-inverse / fg-inverseInverted chip / tooltip background + text
fg / fg-muted / fg-subtlePrimary / secondary / tertiary text
lineDefault border color
brand / brand-hover / brand-fg / brand-soft / brand-soft-fgAccent color + soft/tinted variants
sev-critical / sev-high / sev-medium / sev-low / sev-info (+ -soft)Severity colors and their tinted backgrounds
status-okSuccess/positive color
rounded-v2 / rounded-v2-sm / rounded-v2-lgRadius scale
shadow-v2-xs / shadow-v2-sm / shadow-v2-popElevation 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>.

PropTypeDefaultNotes
variant'primary' | 'secondary' | 'ghost' | 'danger' | 'link' | 'default' | 'destructive' | 'outline''default'defaultprimary, destructivedanger, outlinesecondary
size'sm' | 'md' | 'lg' | 'icon' | 'default''default'
loadingbooleanShows a spinner in place of leftIcon and disables the button
leftIconReact.ReactNodeRendered before children (hidden while loading)
rightIconReact.ReactNodeRendered after children (hidden while loading)
…restnative <button> propsonClick, disabled, type, etc.

Variants

VariantAppearance
primary / defaultSolid brand fill
secondary / outlinesurface-2 fill, line border, hover surface-3
ghostTransparent, hover surface-3
danger / destructiveSolid critical-severity fill, white text
linkText-only brand link, underline on hover (no padding/height)

Sizes

SizeHeight / padding
smh-7, px-2.5, text-xs
mdh-8, px-3, text-sm
lgh-10, px-4, text-sm
iconh-8 w-8, no padding (square icon button)
defaulth-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>.

PropTypeDefaultNotes
tone'neutral' | 'brand' | 'success' | 'warning' | 'danger' | 'info''neutral'Color scheme
dotbooleanRenders 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

ToneUse for
neutralDefault / counts
brandAccent labels
successHealthy / done
warningMedium-severity / caution
dangerCritical / errors
infoInformational

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

PropTypeDefaultNotes
severitySeverity | stringCase-insensitive; unknown strings fall back to info styling
classNamestring
childrenReact.ReactNodeauto labelOverride the displayed text; defaults to the mapped label

The exported Severity type:

export type Severity = 'critical' | 'high' | 'medium' | 'low' | 'info' | 'none' | 'unset';

Severity mapping

severityLabelStyle
criticalCRITICALsev-critical on soft bg
highHIGHsev-high on soft bg
mediumMEDIUMsev-medium on soft bg
lowLOWsev-low on soft bg
infoINFOsev-info on soft bg
noneNonemuted
unsetUnsetmuted, italic

Usage

import { SeverityPill } from '@/components/ui';
<SeverityPill severity="critical" />
<SeverityPill severity={cve.severity} /> // string from the API is fine

Card family

components/ui/Card.tsx — surface container plus header/body/footer subcomponents. All forward refs and spread native <div> props.

The Card root deliberately has no overflow-hidden (it would clip popovers such as Menu and Tooltip). Add overflow-hidden via className only when you explicitly want clipping.

Components

ExportElementPurpose
CarddivContainer: surface-2 bg, line border, shadow-v2-sm
CardHeaderdivOverloaded — see below
CardTitleh3Legacy title text (use inside legacy CardHeader)
CardDescriptionpLegacy subtitle text
CardBodydivPreferred body wrapper (p-4)
CardContentdivLegacy alias of CardBody (identical)
CardFooterdivBottom 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.

PropTypeNotes
titleReact.ReactNodeTriggers the compact bar; truncates
descriptionReact.ReactNodeSubtitle in the compact bar; truncates
actionsReact.ReactNodeRight-aligned action slot in the compact bar
childrenReact.ReactNodeLegacy 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.

ExportElementNotes
Inputinputh-8, surface-2 bg, brand focus ring. Default export of the module.
TextareatextareaAuto-height via rows; same styling as Input
Labellabeltext-xs font-medium text-fg-muted mb-1; not a forwardRef
SelectselectNative <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>

Select is the native styled dropdown used inside forms and the Query Builder. For a floating action/command menu, use Menu instead.


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

PropTypeDefaultNotes
triggerReact.ReactElementSingle element; its onClick is preserved and augmented
itemsMenuItem[]See MenuItem below
align'start' | 'end''end'Horizontal alignment of the popover
widthstring'w-56'Tailwind width class for the popover
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>:

PropTypeDefaultNotes
idstringRequired. Persists column visibility under dt:{id}:visible in localStorage
columnsColumn<T>[]Column definitions (below)
rowsT[]Row data
rowKey(row: T, index: number) => stringStable React key per row
onRowClick(row: T) => voidMakes rows clickable + hover-highlighted
loadingbooleanShows a loading placeholder in the body
emptyReact.ReactNode'No results'Rendered when there are zero rows after filtering
searchablebooleantrueToggles the toolbar search box
searchPlaceholderstring'Search…'
initialSort{ id: string; dir: 'asc' | 'desc' }Starting sort
toolbarReact.ReactNodeExtra controls rendered left of the “Cols” button
classNamestring
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>

FieldTypeNotes
idstringUnique column id (used for sort + visibility)
headerReact.ReactNodeHeader cell content
accessor(row: T) => React.ReactNodeRenders the cell
sortValue(row: T) => string | number | null | undefinedValue used for sorting and search; falls back to accessor
widthstringCSS grid track, e.g. '180px' or 'minmax(0,1fr)' (default minmax(120px,1fr))
align'left' | 'right' | 'center'Cell + header alignment
stickybooleanReserved for sticky columns
monobooleanMonospace cell text
hiddenByDefaultbooleanColumn 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"
/>

DataTable does not paginate. It filters/sorts the rows you pass; for server-side paging, slice rows in 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

PropTypeDefaultNotes
attributesAttribute[]Rows to render
classNamestring
searchablebooleantrueToggles 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

PropTypeDefaultNotes
rowsKV[]Row data
classNamestring
labelWidthnumber120Fixed 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)

PropTypeNotes
defaultValuestringUncontrolled starting tab
valuestringControlled active tab
onValueChange(v: string) => voidFires on tab change
childrenReact.ReactNode
classNamestring

TabsList

PropTypeNotes
childrenReact.ReactNodeTabsTriggers
classNamestring

TabsTrigger

PropTypeNotes
valuestringRequired. Matches a TabsContent value
iconReact.ReactNodeOptional leading icon
countnumberOptional trailing count chip
childrenReact.ReactNodeLabel
classNamestring

TabsContent

PropTypeNotes
valuestringRequired. Renders (with a fade-in) only when active
childrenReact.ReactNode
classNamestring

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

PropTypeNotes
tabsSourceTab[]Tab definitions
valuestringActive tab id (controlled)
onChange(id: string) => voidFires on selection
classNamestring

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:

PropTypeDefaultNotes
openbooleanControls visibility; returns null when false
onClose() => voidCalled on ESC / backdrop / X
titleReact.ReactNodeHeader title; used as aria-label if it is a string
subtitleReact.ReactNodeSecondary header line
headerActionsReact.ReactNodeNodes rendered left of the close button
footerReact.ReactNodeBottom-anchored footer bar
childrenReact.ReactNodeScrollable body
size'sm' | 'md' | 'lg' | 'xl' | 'full''lg'Panel width
side'right' | 'left''right'Slide-in edge
ariaLabelstringScreen-reader label when title is non-text
classNamestringApplied to the panel

Sizes

SizeWidth
smmin(360px, 100vw)
mdmin(520px, 100vw)
lgmin(720px, 100vw)
xlmin(960px, 100vw)
full100vw

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.


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

PropTypeNotes
titleReact.ReactNodeRequired. h1, truncates
subtitleReact.ReactNodeSecondary line under the title
backHrefstringRenders a back chevron linking here
breadcrumbsCrumb[]Rendered above the title
metaReact.ReactNodeMuted text left of actions
actionsReact.ReactNodeRight-aligned action slot
classNamestring
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.

PropTypeNotes
classNamestringSet 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.

PropTypeNotes
iconReact.ReactNodeOptional leading icon
titleReact.ReactNodeRequired.
descriptionReact.ReactNodeOptional supporting text
actionReact.ReactNodeOptional CTA (e.g. a Button)
classNamestring
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.

PropTypeDefaultNotes
contentReact.ReactNodeTooltip body
childrenReact.ReactElementSingle 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).

PropTypeDefaultNotes
openbooleanVisibility; returns null when false
onOpenChange(v: boolean) => voidCalled with false on ESC / backdrop / X
titleReact.ReactNodeHeader title
descriptionReact.ReactNodeHeader subtitle
childrenReact.ReactNodeBody
footerReact.ReactNodeRight-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).

PropTypeNotes
valuestringRequired. Text shown and copied
classNamestring
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,
};
ToneStyle
successstatus-ok on soft bg
errorsev-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:

PropTypeDefaultNotes
valueGroupControlled query tree
onChange(next: Group) => voidFires on any edit
fieldsFieldDef[]Selectable fields (drives dropdowns)
maxRulesnumberOptional cap; disables “Add” when reached
showModeTogglebooleanShow the Text editor / Query builder switch
classNamestring

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:

HelperSignaturePurpose
emptyGroup(combinator?: Combinator) => GroupFresh group with one blank rule
newRule(field?: string, value?: string) => RuleBlank rule (defaults to contains)
countRules(g: Group) => numberTotal rules, excluding group nodes
isQueryNonEmpty(g: Group) => booleanTrue if any rule is meaningful
summarizeQuery(g: Group, fields: FieldDef[]) => stringOne-line human summary
toServerPayload(g: Group) => objectStrips client-only ids before sending
updateNode / removeNode / appendChildsee sourceImmutable 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.Element

It 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.


  • Data model — the entities these components render.
  • API reference — endpoints backing the tables and the Query Builder.