Frontend & design system
The Lockstep VM web app lives in webapp/frontend. It is an Astro app that
renders server-side and hydrates interactive React islands. This page covers
how the app is built and deployed, the navigation, the shared design-system
components in src/components/ui, and the conventions the feature screens follow.
Architecture: Astro SSR + React islands
astro.config.mjs configures the app as a server-rendered Node application:
export default defineConfig({ integrations: [react(), tailwind({ applyBaseStyles: false })], output: 'server', adapter: node({ mode: 'standalone' }), server: { port: 4321, host: true },});- Pages live in
src/pages/*.astro. Each page is a thin Astro shell that imports aLayout, thenAppLayout(the sidebar + top-bar chrome), anAuthCheckguard, and the React feature component — hydrated withclient:load. Route params flow in as props. For examplesrc/pages/device/[deviceName].astrorenders<AssetDetail deviceId={...} />. - Feature components are React (
.tsx) insrc/components. The large ones (DevicesTable,AssetDetail,ApplicationsTable,UsersPage, …) each instantiate their own TanStack QueryQueryClientand wrap their content in aQueryClientProvider, so data-fetching state is local to the island. - Styling is Tailwind (base styles disabled; a custom token layer supplies
surface-*,fg-*,brand,sev-*,line,rounded-v2*, etc.). Icons arelucide-react.
Because each screen is an island, there is no client-side router — navigation is
plain <a href> / window.location between Astro pages, and each page does a
fresh server render.
Build & deploy model — baked image, no hot reload
The frontend ships as a baked production Docker image built from the Astro
output: 'server' bundle. There is no dev server / hot-module reload in the
running stack: the compiled bundle is what serves.
Sidebar navigation
src/components/Sidebar.tsx defines a fixed left rail (Lockstep VM brand at
top, Profile + Usage & Billing at the bottom). The nav is grouped:
| Group | Items (→ route) |
|---|---|
| Overview | Dashboard (/) |
| Inventory | Assets (/devices), Software (/applications), Users (/users), Vulnerabilities (/vulnerabilities), Version Tracking (/versions) |
| Intelligence | Security News, Advisories, Threat Intel, Weaknesses, Vendors & Products |
| Sources | SentinelOne, Rapid7, Intune, Entra ID, ConnectWise, CW Automate (/connectwise-automate), Upload Data (/inventory) |
| Work | Projects, Reports, Statistics, Subscriptions |
| Administration | Organizations, Tags |
The active item is resolved by prefix match on the current path (/devices stays
active on /device/<id> detail pages).
Design-system components (src/components/ui)
Everything is re-exported from src/components/ui/index.ts and imported as a
group, e.g. import { DataTable, Card, Button, Badge } from '@/components/ui'.
DataTable
DataTable.tsx is the enterprise grid used by nearly every list and detail
table. It is a CSS-grid (not <table>) driven by a typed Column<T>[]:
interface Column<T> { id: string; header: React.ReactNode; accessor: (row: T) => React.ReactNode; sortValue?: (row: T) => string | number | null | undefined; width?: string; // e.g. '180px' or 'minmax(120px,1fr)' align?: 'left' | 'right' | 'center'; mono?: boolean; // monospace cell hiddenByDefault?: boolean;}Features:
- Sticky header, column picker (“Cols” menu) with per-table visibility
persisted to
localStorageunderdt:<id>:visible. - Click-to-sort headers cycling asc → desc → none (numeric vs. locale-string
aware via
sortValue). - Client-side search box (
searchable), densitycompact/comfortable, optionaltoolbarslot andresultsLabelfooter. - Responsive width: the grid is
min-w-fullinside anoverflow-x-autowrapper, so it fills the pane and only scrolls horizontally when the columns’ minimum widths exceed the available space.
Pagination is left to the parent (list screens fetch a page from the server and
render their own pager), so DataTable renders exactly the rows it is given.
AttributeTable + SourceTabs — the source-grouped Attributes pattern
These two components together build the runZero-style attribute panel seen on both the asset and user detail pages.
SourceTabsis a vertical tab rail (w-48). Each tab is a source with alabel, a colored icon, and a count badge. The active tab is highlighted in the brand color.AttributeTablerenders the selected source’s attributes as a key/value list: a muted key column (with an inspect icon) on the left, a monospace value column on the right with copy-on-hover, its own search box, andbreak-allwrapping for long values.
Attributes are produced by a flattenAttributes(obj) helper that walks a raw
backend object and flattens nested dictionaries into dotted paths
(intune.compliance_state), joins scalar arrays, formats ISO timestamps, drops
empties, and sorts by key. The result is that each source contributes one tab,
and switching tabs swaps the attribute set — exactly the runZero “attributes by
source” experience.
Card, Button, Badge, Menu (and friends)
| Component | Notes |
|---|---|
Card (Card, CardHeader, CardBody, CardTitle, …) | Surface container used to frame every panel. |
| Button | variant (primary/secondary/ghost/icon), size, leftIcon/rightIcon, loading. |
| Badge | tone = neutral | brand | success | warning | danger | info; size sm/md; optional dot. |
| Menu | Click-triggered dropdown; items support icon, onSelect, href, danger, disabled, separator. Closes on outside-click / Escape. |
| SeverityPill | Colored severity chip (critical/high/medium/low/info/none/unset). |
| KeyValueGrid | Two-column label/value grid used by “Asset information” and per-source detail panels. |
| Tabs, Drawer, PageHeader, Input/Select/Textarea | Standard primitives; Drawer hosts the query builder. |
| Primitives | EmptyState, Skeleton, Copyable, and the toast helper. |
Structured query builder
The Assets and Software lists
share a structured, allow-listed query builder. The client half lives in
src/lib/query-builder and src/components/ui/QueryBuilder.tsx; the server half
is backend/app/services/query_builder.py. The shapes match 1:1.
Client model
type Combinator = 'and' | 'or' | 'none' | 'not_all'; // All / Any / None / Not allinterface Rule { kind: 'rule'; field: string; operator: Operator; value: string }interface Group { kind: 'group'; combinator: Combinator; rules: Array<Rule | Group> }- 16 operators across four families: string (
contains,equals,starts_with,ends_with, and their negations), numeric (gt/gte/lt/lte), presence (is_empty/is_not_empty, no value), and set (in/not_in). - The operator dropdown is filtered by each field’s type (
string/number/boolean/date), so a numeric field only offers comparison operators. Fields withoptionsrender a value picker; fields carry agrouplabel that becomes an<optgroup>in the field selector. - Groups nest infinitely, with Builder and raw-JSON Text editor modes
that round-trip.
toServerPayload()strips client-only ids before sending.
Server validation (allow-listed fields)
The backend never trusts a field name from the client. Each search endpoint
passes a FieldSpec allowlist mapping a logical field to a db_field +
type; build_mongo_filter() translates the tree to a MongoDB filter and
silently skips any rule whose field is not in the allowlist (and any unknown
operator), so arbitrary Mongo paths can never be referenced.
APPLICATION_FIELDS = { "name", "vendor", "product", "version", "device_count"(number), "site", "publisher", "platform", "size_bytes"(number), "category", "install_status" }
DEVICE_FIELDS = { device_name, device_id, os_platform, os_version, user_email, user_name, application_name, application_count, source, intune.*, s1.*, r7.* } # source-prefixed fields come from $lookup joinsOperator → Mongo mapping highlights: string contains/equals become
case-insensitive escaped $regex; presence checks expand to
$exists/null/"" clauses; combinators map and→$and, or→$or,
none→$nor, not_all→$nor:[$and].
The POST body is a SearchRequest:
{ "query": { "combinator": "and", "rules": [ /* Rule|Group */ ] }, "search": "free text", "skip": 0, "limit": 50, "sort": "-device_count" }posted to POST /devices/devices/search and POST /inventory/applications/search.
API client conventions
src/lib/api.ts exports an axios instance plus per-domain wrappers
(devicesApi, inventoryApi, vulnerabilityApi, versionApi, …):
const API_URL = import.meta.env.PUBLIC_API_URL || 'http://localhost:8000';export const api = axios.create({ baseURL: `${API_URL}/api/v1`, headers: { 'Content-Type': 'application/json' }, withCredentials: true, // send the Better Auth session cookie});- Auth is cookie-based. Better Auth owns the session and sets an
httpOnlycookie on the Astro origin;withCredentials: true(axios) /credentials: 'include'(rawfetch) forwards it to FastAPI, which validates it against the shared Mongosessioncollection and resolves the tenant + role. (Legacy JWT Bearer tokens are still accepted for a grace period; any staleaccess_tokeninlocalStorageis cleared on boot.) - A response interceptor redirects to
/loginon a401— unless the user is already on an auth page (/login,/signup,/forgot-password,/reset-password), to avoid redirect loops. - Several detail components (e.g.
AssetDetail,UserDetail,ApplicationDetail) call the API with rawfetchrather than the axios wrapper, but follow the same convention: requesthttp://localhost:8000/api/v1/...withcredentials: 'include'.
See also
- User guides: Assets, Software inventory, Users, Vulnerabilities.
- API reference · Data model.