Skip to content

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 a Layout, then AppLayout (the sidebar + top-bar chrome), an AuthCheck guard, and the React feature component — hydrated with client:load. Route params flow in as props. For example src/pages/device/[deviceName].astro renders <AssetDetail deviceId={...} />.
  • Feature components are React (.tsx) in src/components. The large ones (DevicesTable, AssetDetail, ApplicationsTable, UsersPage, …) each instantiate their own TanStack Query QueryClient and wrap their content in a QueryClientProvider, 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 are lucide-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.

src/components/Sidebar.tsx defines a fixed left rail (Lockstep VM brand at top, Profile + Usage & Billing at the bottom). The nav is grouped:

GroupItems (→ route)
OverviewDashboard (/)
InventoryAssets (/devices), Software (/applications), Users (/users), Vulnerabilities (/vulnerabilities), Version Tracking (/versions)
IntelligenceSecurity News, Advisories, Threat Intel, Weaknesses, Vendors & Products
SourcesSentinelOne, Rapid7, Intune, Entra ID, ConnectWise, CW Automate (/connectwise-automate), Upload Data (/inventory)
WorkProjects, Reports, Statistics, Subscriptions
AdministrationOrganizations, 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 localStorage under dt:<id>:visible.
  • Click-to-sort headers cycling asc → desc → none (numeric vs. locale-string aware via sortValue).
  • Client-side search box (searchable), density compact / comfortable, optional toolbar slot and resultsLabel footer.
  • Responsive width: the grid is min-w-full inside an overflow-x-auto wrapper, 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.

  • SourceTabs is a vertical tab rail (w-48). Each tab is a source with a label, a colored icon, and a count badge. The active tab is highlighted in the brand color.
  • AttributeTable renders 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, and break-all wrapping 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)

ComponentNotes
Card (Card, CardHeader, CardBody, CardTitle, …)Surface container used to frame every panel.
Buttonvariant (primary/secondary/ghost/icon), size, leftIcon/rightIcon, loading.
Badgetone = neutral | brand | success | warning | danger | info; size sm/md; optional dot.
MenuClick-triggered dropdown; items support icon, onSelect, href, danger, disabled, separator. Closes on outside-click / Escape.
SeverityPillColored severity chip (critical/high/medium/low/info/none/unset).
KeyValueGridTwo-column label/value grid used by “Asset information” and per-source detail panels.
Tabs, Drawer, PageHeader, Input/Select/TextareaStandard primitives; Drawer hosts the query builder.
PrimitivesEmptyState, 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 all
interface 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 with options render a value picker; fields carry a group label 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.

backend/app/services/query_builder.py
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 joins

Operator → 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 httpOnly cookie on the Astro origin; withCredentials: true (axios) / credentials: 'include' (raw fetch) forwards it to FastAPI, which validates it against the shared Mongo session collection and resolves the tenant + role. (Legacy JWT Bearer tokens are still accepted for a grace period; any stale access_token in localStorage is cleared on boot.)
  • A response interceptor redirects to /login on a 401 — 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 raw fetch rather than the axios wrapper, but follow the same convention: request http://localhost:8000/api/v1/... with credentials: 'include'.

See also