# Data Explorer

Ask arbitrary questions of your fleet. Build a condition tree, run it, save it
as a template, share it with your organisation.

It is **read-only**, and it is scoped to what you can already see — a query
cannot return a device your role or tenant would hide from you elsewhere.

Not to be confused with **[Fleet Query](fleet-query.md)**, which is the quick
one-line device filter in the toolbar. Data Explorer is the one with nested
AND/OR conditions across ten entities.

## The ten entities

| Entity | One row per | Use it for |
|---|---|---|
| `devices` | host | posture, telemetry, inventory |
| `cves` | (host, finding) | "which hosts still have a critical CVE in openssl" |
| `drift` | (host, watched file) | "which hosts have drifted from baseline on /etc/ssh/sshd_config" |
| `packages` | (host, installed package) | "which hosts still run openssl 3.0.x" |
| `ports` | (host, listening socket) | "who listens on 3306, and is it reachable from the world" |
| `services` | (host, watched unit) | "which units are failed, and which are quietly restarting" |
| `containers` | (host, container) | "what is stopped, and what keeps restarting" |
| `alerts` | alert | "every open critical on hosts in the prod group" |
| `attackers` | (source address, attacked host) | "which known-abusive addresses are hitting the web tier, and are they blocked" |
| `gateway_sessions` | finished SSH-gateway session | "who reached the database hosts through the gateway this week" |

## Conditions

A condition is a field, an operator and a value:

```json
{"field": "disk_pct", "op": "gt", "value": 90}
```

Combine them with `and`, `or` and `not`, nested up to six deep:

```json
{"and": [{"field": "tags", "op": "contains", "value": "prod"},
         {"field": "disk_encrypted", "op": "eq", "value": false}]}
```

Operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `contains`
(case-insensitive substring), `in` (value is a list), `exists`.

`exists` means *present and not empty*, which is worth knowing for the
tri-state fields below.

## Device fields

### Identity and inventory

| Field | Notes |
|---|---|
| `device_id`, `name`, `group`, `site`, `tags` | `tags` is comma-joined, so use `contains` |
| `hostname` | the host's own current hostname, not the name it enrolled under |
| `os`, `kernel`, `chassis` | `chassis` distinguishes a laptop from a server |
| `agent_version`, `agentless`, `monitored` | |
| `online`, `last_seen` | `last_seen` is a unix timestamp, so it sorts and compares |

### Load and capacity

`cpu_pct`, `mem_pct`, `disk_pct`, `swap_pct`, `loadavg_1m`, `cpu_count`,
`mem_total_mb`, `disk_total_gb`, `fd_pct`, `conntrack_pct`, `uptime_seconds`.

`uptime_seconds` is the numeric one — the human "up 3 weeks" string is not
sortable, which is why nothing could rank hosts by uptime before.

### Health

| Field | Notes |
|---|---|
| `reboot_required` | an update is installed and waiting on a reboot |
| `failed_units`, `mount_issues` | counts, so `gt 0` finds any |
| `quarantined_files` | files the integrity guard has quarantined |
| `listening_ports` | how many, not which — use the Exposure page for which |
| `metrics_limited` | the agent said its own metrics are limited. Distinguishes a host with no CPU figure from an idle one |

### Security posture

| Field | Notes |
|---|---|
| `disk_encrypted` | LUKS / BitLocker / FileVault |
| `firewall_active` | any host firewall backend with an active ruleset |
| `secure_boot` | |
| `autoupdate_enabled`, `autoupdate_mechanism` | |
| `ssh_root_login`, `ssh_password_auth`, `ssh_empty_passwords`, `ssh_x11_forwarding` | the resolved sshd values, as strings (`"no"`, `"yes"`, `"prohibit-password"`) |
| `clock_synced`, `clock_offset_ms` | a skewed clock breaks log correlation and certificate validation |
| `battery_pct`, `battery_health_pct` | |
| `audit_mode` | the agent is in read-only mode and refuses every command |
| `usb_devices` | how many USB devices the host reports attached; empty when it never reported |
| `logged_in_users` | how many interactive sessions are open on the host right now |
| `sshgw_enabled` | the host is opted in to the [SSH gateway](sshgw.md) |

**The posture booleans are tri-state, and this matters when you write a
query.** `false` means the host reported, and the answer is no. **Absent** means
the host never told us — a Windows box does not report sshd, and a container
host may not be able to see device-mapper.

So `{"field": "disk_encrypted", "op": "eq", "value": false}` finds hosts that
told you their disks are not encrypted. It does **not** include hosts that said
nothing, which is what you want: a list of findings should not be padded with
hosts that were never asked. To find the silent ones, use `not` + `exists`.

## The other entities' fields

### `packages`

`device_id`, `device_name`, `package`, `version`, `ecosystem`.

Versions are strings, so compare them with `contains` (`"3.0."`) rather than
`gt` — a string comparison would put `3.10` before `3.9`.

The scan stops at 100,000 rows, because a large fleet reports more installed
packages than any single answer needs. When it stops, the response says so in
`meta.truncated`, so a partial answer is never presented as a complete one.

### `ports`

`device_id`, `device_name`, `proto`, `port`, `process`, `addr`, `scope`.

`scope` is the one to reach for: `world` means the socket is bound somewhere
reachable off the host, `lan` means the local network, `local` means loopback.
"Which of my hosts expose a database to the world" is `port` plus
`scope eq world`.

### `services`

`device_id`, `device_name`, `unit`, `canonical`, `active`, `sub`, `since`,
`restarts`, `flapping`, `running`.

`canonical` is what systemd actually matched, which differs from `unit` when
you watch an alias (`mysql.service` resolving to `mariadb.service`).

`flapping` is worth knowing about: a unit crash-looping under `Restart=always`
reads `active` every time it is sampled, because it comes back before the next
heartbeat. Only the restart count reveals it, so an `active`/`failed` query
will never find one.

### `containers`

`device_id`, `device_name`, `name`, `image`, `tag`, `status`, `runtime`,
`health`, `restart_count`, `running`.

`status` is the runtime's own wording, which differs between Docker, Podman and
Kubernetes (`Up 3 days`, `running`, `Ready`). `running` is the normalised
boolean, and it is the same test the Devices page counts with, so the two
cannot disagree.

### `alerts`

`alertid`, `event`, `severity`, `title`, `device_id`, `device_name`, `status`,
`source`, `ts`, `first_seen`, `acknowledged_by`, `resolved_by`.

`status` is `open`, `ack` or `resolved`. `first_seen` is when the condition
first fired and `ts` is the most recent occurrence — they differ for a repeating
alert, and `first_seen` is the one to measure how long something has been
broken.

Alerts are the one entity where a row need not belong to a device: a fleet-level
condition such as a failed backup has no `device_id`, and those rows stay
visible to you.

### `attackers`

`ip`, `device_id`, `device_name`, `unit`, `count`, `score`, `reports`,
`country`, `isp`, `usage`, `blocked`, `reported`, `first_seen`, `last_seen`.

One row for each brute-force source on each host it attacked, as
[IP intel](ip-intel.md) recorded it. `score` is the merged reputation (0–100)
and is empty until a lookup has run; `blocked` is whether the address is blocked
on that host right now; `reported` is whether it has been reported to a
reputation service.

### `gateway_sessions`

`username`, `device_id`, `device_name`, `client_ip`, `fingerprint`, `started`,
`duration_s`, `bytes_in`, `bytes_out`, `reason`.

Finished [SSH gateway](sshgw.md) sessions. Visible to admins and auditors, the
same people who see the session list on the gateway page; for every other role
the entity returns no rows.

## Saved queries

Saved on the server, not in your browser. Private to your account by default,
tenant-scoped, shareable with your organisation when you choose. Data Explorer,
Fleet Query and Trends share the same store.

## Limits

A predicate is capped at 100 nodes and six levels of nesting. Both are cost
guards — a wide shallow tree with thousands of leaves is as expensive as a deep
one.

There is no raw SQL. Every entity's rows are fetched through the
same path every other page uses, so the tenant and role scoping that applies to
your data elsewhere applies here without a second implementation to keep in
step. See [scaling.md](scaling.md) for how that scoping works.
