Data display
How Zenox shows tables, identifiers, times, empty values and severity, the content analysts read all day.
Most Zenox screens are lists of alerts, events, assets and findings. Analysts scan them for hours, compare them with other tools and cite them in reports, so these rules favor values that are exact, aligned and comparable over values that are short or friendly.
Tables
| ID | Alert | Asset | Severity | Events |
|---|---|---|---|---|
| ALR-4821 | Impossible travel sign-in | m.rocha@acme.com | Critical | 3 |
| ALR-4820 | Outbound traffic to a known C2 domain | srv-web-02 | High | 1,284 |
| ALR-4817 | RDP exposed to the internet | vm-jump-01 | High | 57 |
| ALR-4815 | TLS certificate expires in 5 days | portal.acme.com | Medium | 1 |
| ALR-4809 | Unsigned PowerShell script ran | wks-0193 | Low | 12 |
function TableDemo() {
const alerts = [
{
id: "ALR-4821",
title: "Impossible travel sign-in",
asset: "m.rocha@acme.com",
severity: "critical",
events: 3,
},
{
id: "ALR-4820",
title: "Outbound traffic to a known C2 domain",
asset: "srv-web-02",
severity: "high",
events: 1284,
},
{
id: "ALR-4817",
title: "RDP exposed to the internet",
asset: "vm-jump-01",
severity: "high",
events: 57,
},
{
id: "ALR-4815",
title: "TLS certificate expires in 5 days",
asset: "portal.acme.com",
severity: "medium",
events: 1,
},
{
id: "ALR-4809",
title: "Unsigned PowerShell script ran",
asset: "wks-0193",
severity: "low",
events: 12,
},
] as const
return (
<Table containerLabel="Open alerts">
<TableCaption>Open alerts, most recent first.</TableCaption>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Alert</TableHead>
<TableHead>Asset</TableHead>
<TableHead>Severity</TableHead>
<TableHead className="text-right">Events</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{alerts.map((alert) => (
<TableRow key={alert.id}>
<TableCell className="font-mono text-text-muted">
{alert.id}
</TableCell>
<TableCell className="font-medium">{alert.title}</TableCell>
<TableCell className="font-mono">{alert.asset}</TableCell>
<TableCell>
<SeverityBadge level={alert.severity} />
</TableCell>
<TableCell className="text-right tabular-nums">
{alert.events.toLocaleString("en-US")}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}Alignment
| Column holds | Align | Notes |
|---|---|---|
| Text, names, titles | Left | |
| Identifiers: IDs, hashes, IPs, hosts, e-mails | Left | font-mono |
| Numbers you compare: counts, sizes, scores, prices | Right | tabular-nums, so digits line up |
| Dates and times | Left | One format per column, so the values line up by length |
| Severity, status | Left | A badge with its label |
| Row actions | Right, in the last column | See Action placement |
The header of a column takes the alignment of its values.
Rows
- One line per row. Cells do not wrap. Truncate long text at the end (
max-w-*on the cell,truncateandtitleon the text), and show the full value in the item’s detail view. - Default density. Use the Table’s own row height and cell padding. Do not pad cells to make a table look airier: it costs the rows the user came to see.
- Sort by the question the page answers. Alerts by severity, then newest first; events and audit entries newest first. Show the sort on the column header.
- Keep the header in view. A long table scrolls inside its own container with a sticky
header:
containerClassName="max-h-96"on Table andstickyon TableHeader. - Selection is a checkbox in the first column; bulk actions appear in the toolbar (see Actions on several items).
Paging
Paginate the lists users work through (alerts, assets, findings, audit entries), and show the total: “1 to 50 of 1,204”. A page number can be cited, and a position survives a reload. Infinite scroll is for feeds read once, never for lists that are exported, audited or compared.
Values
Empty values
Show an empty value with Empty Value: a muted em dash, with words for screen readers. A blank cell is ambiguous (no value, still loading, or a rendering error), and “N/A”, “null” or “-” read as data.
<TableCell>{asset.owner ?? <EmptyValue />}</TableCell>
This placeholder is the one use of the em dash in the interface; in sentences, the writing rules apply. When the absence itself means something, say it instead: “Never scanned”, “No owner”, “Not reported by the agent”.
Identifiers
- Hashes, IPs, hosts, domains, keys and IDs use
font-monoand offer Copy through Value Actions where the user may paste them elsewhere. - In tables, truncate at the end and give the full value in the
titleand through Copy. In detail views, show the whole value; a value longer than the line wraps at any character. - Inside a sentence, follow
zx/inline-codein Layout and craft.
Numbers
- Format through the app’s formatters, in the user’s locale: “1,204” in English, “1.204” in Portuguese and Spanish. Never format by hand.
- Units take a space (“16 GB”, “250 ms”); percentages do not (“98%”).
- Compact notation (“1.2K”) only in Stat Tile figures (with the exact
value in
exactValue) and chart axes. Tables show the exact number. - A KPI figure is never colored by its state; a Stat Tile
tonemarks the line that says why it needs attention. A change against a previous period is adelta, colored by whether it is good news, not by its direction. - Booleans are words or a status: “Enabled”, “Disabled”. Never “true” and “false”, and never a check mark alone.
Dates and times
- Absolute by default, in the user’s time zone, through the app’s date formatters. Event, alert and audit lists show the date and the time to the second: analysts correlate events across tools by timestamp.
- Relative times only for freshness, where the age matters more than the moment: “Synced 5 min ago”, “Last seen 2 h ago” in a summary. Put the absolute value in a tooltip.
- Say which time zone wherever times are compared: in the column header’s tooltip, or as the UTC offset in a detail view. Exports and API values use ISO 8601 with the offset.
- Follow the locale’s clock and order: the formatter writes 24-hour time for Portuguese and Spanish. Never hard-code a date format.
Severity and status
<>
<SeverityBadge level="critical" />
<SeverityBadge level="high" />
<SeverityBadge level="medium" />
<SeverityBadge level="low" />
<SeverityBadge level="unknown" />
</>- Severity says how bad something is: Critical, High, Medium, Low, Info. Keep that order everywhere: sorting, filters, legends, charts and counts. Show it with Severity Badge, which pairs the color with the label.
- Status says where an item is in its workflow: Open, Acknowledged, Resolved. Show it with Status Badge, never in severity colors: a resolved critical alert is still critical.
- In a fixed layout, such as a row of stat tiles, keep the levels with a count of zero so the positions do not move. In an inline summary, leave them out.