UI Upgrades Plan (Tables and Graph Explorer)
Date: 2026-02-10 Status: Complete — All 5 phases implemented
1. Objective
Upgrade the SecurityV0 UI from thin exploratory views to an enterprise-ready operator experience by addressing four blocking UX gaps:
- Tables need built-in, multi-dimensional filtering and richer data-grid behavior.
- Graph Explorer needs subgraph focus controls for large execution graphs.
- Users need richer node details in the same Graph Explorer view.
- Graph to Entity navigation must preserve context and provide a reliable return path.
2. Current Gaps (Validated in Current UI)
| Gap | Current Behavior | Impact |
|---|---|---|
| Table filtering and controls | Entity list has only one type dropdown; findings and syncs have limited filter controls and no column-level filtering/sorting state persistence. | Analysts cannot quickly isolate high-risk subsets in large datasets. |
| Graph focus | Graph supports type toggles and has findings only, but no path-focused or neighborhood-focused subgraph mode. | Large graphs become visually noisy; execution-chain analysis is slow. |
| In-graph details | Graph detail card shows only summary and a View Details button. Full properties require route change. | Repeated context switching slows triage and investigation. |
| Navigation continuity | Entity detail page has a fixed Back to entities link, not a contextual return to graph state. | Graph-driven investigations lose position/filter context. |
3. UX Decisions
D1. Table architecture
Adopt a reusable DataTable foundation in UI for Entities, Findings, and Syncs using:
@tanstack/react-tablefor column/filter/sort model@tanstack/react-virtualfor large-list performance- URL-backed table state for shareable filtered views
Planned enterprise behaviors:
- Global search + per-column filters (text, enum, date range, numeric range)
- Multi-column sort and persisted column visibility
- Pinned key columns (
Name,Type,Severitywhere applicable) - Filter chips with one-click clear
- Cursor-based pagination controls and total counts
D2. In-graph detail presentation
Use a right-side details drawer (desktop) and bottom sheet (mobile), not a modal and not only a below-graph panel.
Rationale:
- Preserves graph visibility while inspecting details
- Avoids modal interruption during graph exploration
- Works better for dense property sets than a fixed-height bottom panel
D3. Graph focus model
Add a dedicated Focus Mode with two modes:
Execution Flowmode: show only path-relevant chain(s) from a selected identityNeighborhoodmode: show 1/2/3-hop subgraph around selected node
Additional controls:
- Relationship type include/exclude
- Source-system filter
- Findings-only toggle (within focused subgraph)
Expand 1 hopandReset focus
D4. Navigation continuity model
Pass origin context from Graph to Entity routes:
- Persist graph state in URL/query + route state: selected node, filter state, focus mode, viewport
- Entity page shows
Back to graphwhen origin is graph - Returning restores graph selection, filters, and camera position
4. Implementation Plan
Execution Order
Phase 1 (Tables) ──→ Phase 3 (Drawer — uses entity_id filter from Phase 1)
│ │
└──→ Phase 2 (Graph Focus) ──→ Phase 4 (Navigation — needs graph URL state)
│
└──→ Phase 5 (Testing)
Backend sub-phases first, then frontend. Phases 1 and 2 backend can run in parallel.
Phase 1 - Enterprise Table Foundation ✅ COMPLETE
Completed: 2026-02-10 | Tests: 178 backend (was 173) + 15 UI (was 4) | Build: clean
Sub-phase 1a: Install dependencies
cd ui && npm install @tanstack/react-table @tanstack/react-virtual
Only two new packages for the entire upgrade plan.
Sub-phase 1b: Extend backend query interfaces
File: src/storage/storage-adapter.ts
Add fields to EntityQuery:
q?: string; // text search on display_name + source_id
sort?: string; // field name, "-" prefix for desc
Add new FindingQuery interface (replaces current positional args):
interface FindingQuery {
findingType?: string;
severity?: string;
status?: string;
source?: string; // "evaluator" | "connector_report"
entityId?: string;
sort?: string;
limit?: number;
afterId?: string;
}
Add sort to SyncQuery.
Change queryFindings from (tenantId, findingType?, status?) to (tenantId, query: FindingQuery). Add countFindings(tenantId, query?).
Callers to update: src/api/routes/findings.ts (line 84), test/api/findings-merged.test.ts mock expectations.
Sub-phase 1c: Implement Mongo adapter changes
File: src/storage/mongo/adapter.ts
queryEntities (lines 78-102) — Add $or regex search when q present:
if (query.q) {
const escaped = query.q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
filter.$or = [
{ "properties.display_name": { $regex: escaped, $options: "i" } },
{ source_id: { $regex: escaped, $options: "i" } }
];
}
Add private parseSortField() helper with allowlist (_id, properties.display_name, entity_type, source_system, last_synced_at, created_at). Default: { _id: 1 }.
countEntities (lines 104-110) — Add same q filter for accurate counts.
queryFindings (lines 291-304) — Refactor to FindingQuery object. Add severity, entityId, afterId, limit, sort.
New countFindings method — same filter logic, countDocuments.
queryConnectorSyncs — Add sort parsing (default { _id: -1 }).
Sub-phase 1d: Extend API routes
File: src/api/routes/entities.ts (lines 19-23) — Add q and sort to queryFilters.
File: src/api/routes/findings.ts (lines 50-107) — Major changes:
- Parse new query params:
severity,entity_id,sort,limit,cursor - Refactor
storageAdapter.queryFindings(tenantId, findingType, status)to useFindingQuery - Add cursor pagination to evaluator findings (request limit+1, reuse
buildCursorResponsefromsrc/api/pagination.ts) - When both sources queried and no pagination: merge as today, add severity client-side filter to connector findings
- When
source=evaluator: server-side pagination works directly - Response adds
cursorfield alongside existingmeta
File: src/api/routes/syncs.ts — Add sort query param, pass to adapter.
Sub-phase 1e: Shared DataTable component
New files:
| File | Purpose |
|---|---|
ui/src/components/table/DataTable.tsx | Main reusable table with @tanstack/react-table |
ui/src/components/table/DataTableFilters.tsx | Filter chip bar + global search input |
ui/src/components/table/DataTablePagination.tsx | Cursor-based "Load more" pagination controls |
ui/src/components/table/column-helpers.ts | Shared column definition helpers (link cell, badge cell, date cell) |
ui/src/hooks/use-table-state.ts | URL-backed filter/sort/search state via useSearchParams() |
DataTable props:
interface DataTableProps<T> {
columns: ColumnDef<T>[];
data: T[];
totalCount: number;
isLoading: boolean;
cursor?: { has_more: boolean; next: string | null };
onLoadMore?: () => void;
filters: Record<string, string>;
onFilterChange: (key: string, value: string | null) => void;
sorting: SortingState;
onSortingChange: OnChangeFn<SortingState>;
globalSearch?: string;
onGlobalSearchChange?: (value: string) => void;
onRowClick?: (row: T) => void;
emptyMessage?: string;
renderSubRow?: (row: T) => React.ReactNode;
}
use-table-state hook — maps URL search params to table state. Compact keys: q, sort, cur + filter-specific keys per page. Returns { filters, setFilter, clearFilters, sort, setSort, search, setSearch, cursor, setCursor, pageSize }.
Sub-phase 1f: Migrate pages to DataTable
ui/src/pages/EntitiesListPage.tsx — Replace hand-built table. Columns:
| Column | Cell | Filter | Sort |
|---|---|---|---|
| Name | Link to /entities/:id | search via q | display_name |
| Type | EntityBadge | enum select | entity_type |
| Source | text | enum select | source_system |
| Status | badge | enum select | — |
| Relationships | count | — | — |
| Last Synced | date | — | last_synced_at |
ui/src/pages/FindingsList.tsx — Replace hand-built table + client-side useMemo filtering. Columns:
| Column | Cell | Filter | Sort |
|---|---|---|---|
| Severity | SeverityBadge | enum select | severity |
| Type | text | enum select | — |
| Description | Link to /findings/:id | — | — |
| Entity | text | — | — |
| Source | text | enum select | — |
| Status | badge | enum select | — |
| Detected | timeAgo | — | detected_at |
ui/src/pages/SyncsPage.tsx — Replace hand-built table. Keep expandable rows via renderSubRow. Columns:
| Column | Cell | Filter | Sort |
|---|---|---|---|
| Connector | text | enum select | — |
| Status | badge | enum select | — |
| Started | date | — | started_at |
| Duration | computed | — | — |
| Entities | count | — | — |
| Events | count | — | — |
Sub-phase 1g: Update hooks and types
ui/src/hooks/use-entities.ts — Add q, sort, cursor to filter interface.
ui/src/hooks/use-findings.ts — Add severity, entity_id, sort, limit, cursor.
ui/src/hooks/use-syncs.ts — Add sort.
ui/src/api/api-types.ts — Add cursor to FindingsListResponse.
Sub-phase 1h: Tests
Extend test/api/entities.test.ts: ?q=test passes to adapter, ?sort=-last_synced_at passes sort.
Extend test/api/findings-merged.test.ts: ?severity=high filters, ?limit=10 paginates, ?entity_id=x filters.
New ui/src/__tests__/use-table-state.test.ts: URL state sync, setFilter, clearFilters, setSort.
Acceptance
- Analyst can combine 3+ filters without route refresh.
- Filtered URL can be shared and reproduces same table view.
- Table interactions remain responsive on 1k+ rows (virtualized rendering).
npm test+npm run test:integration+cd ui && npm run ciall pass.
Phase 2 - Graph Focus and Subgraph Exploration ✅ COMPLETE
Sub-phase 2a: Subgraph storage interface
File: src/storage/storage-adapter.ts — Add:
interface SubgraphQuery {
seedId: string;
mode: "neighborhood" | "execution_flow";
depth: number; // 1-3
relationshipTypes?: string[];
sourceSystem?: string;
hasFindingsOnly?: boolean;
limit: number; // default 100, max 500
}
interface SubgraphResult {
nodes: EntityDoc[];
edges: Array<{
source_id: string;
target_id: string;
relationship_type: string;
properties: Record<string, unknown>;
}>;
truncated: boolean;
total_candidates: number;
}
Add getSubgraph(tenantId, query) to StorageAdapter.
Sub-phase 2b: Implement subgraph in Mongo adapter
File: src/storage/mongo/adapter.ts
Neighborhood mode: Iterative BFS from seed entity. At each hop, collect relationships[].target_id from frontier, fetch via getEntitiesByIds. Also find reverse edges via { "relationships.target_id": { $in: visitedIds } } (existing index supports this). Apply relationship type and source_system filters per hop. Stop at depth or node limit.
Execution flow mode: Directed traversal using the canonical execution flow definition (see Phase 1 Deltas Plan). Two patterns depending on seed entity type:
Pattern A — Seed is an automation entity (business_rule, script_include, flow, scheduled_job):
Forward edges (data path):
Automation → RUNS_AS → SP → HAS_ROLE → Role → GRANTS → Permission → APPLIES_TO → Resource
Provenance edges (display overlay):
Automation → EXECUTES_ON → REST Message → AUTHENTICATES_VIA → OAuth Entity
OAuth Entity ←AUTHENTICATES_TO← SP (reverse edge: SP is source, OAuth is target)
Also: Automation → TRIGGERS_ON → Table Resource
Pattern B — Seed is an identity entity (service_principal, oauth_app):
SP → AUTHENTICATES_TO → OAuth Entity (SP is source, OAuth is target)
SP → HAS_ROLE → Role → GRANTS → Permission → APPLIES_TO → Resource
Edge direction note: AUTHENTICATES_TO edge direction is always SP → OAuth (SP is source). Execution flow display that shows OAuth → SP must use a reverse edge lookup. The traversal follows directed edges for data paths; provenance display may require reverse lookups for AUTHENTICATES_TO. Produces a focused DAG, not a full neighborhood.
Both modes return truncated: true when node limit reached.
Sub-phase 2c: Graph API route
New file: src/api/routes/graph.ts
GET /api/v1/graph/subgraph?seed_id=&mode=&depth=&relationship_types=&source_system=&has_findings_only=&limit=- Validates: seed_id required, mode in [neighborhood, execution_flow], depth capped at 3, limit capped at 500
- If
has_findings_only, post-filter nodes against findings - Returns
{ data: SubgraphResult }
File: src/api/app.ts — Register createGraphRoutes(storageAdapter).
Sub-phase 2d: Frontend subgraph hook + types
New: ui/src/hooks/use-subgraph.ts — useSubgraph(filters) returning TanStack Query result.
ui/src/api/api-types.ts — Add SubgraphResponse type.
Sub-phase 2e: Graph Explorer focus mode
ui/src/pages/GraphExplorerPage.tsx — Two modes:
- Browse mode (current):
useEntities({ limit: 200 })+ type/findings toggles - Focus mode: activated by "Focus from here" on selected node. Uses
useSubgraph(). URL state:?focus=1&seed=<id>&mode=neighborhood&depth=2
ui/src/components/graph/GraphFilterSidebar.tsx — Extend with focusMode prop. Focus controls: mode selector (Neighborhood/Execution Flow), depth (1/2/3), relationship type checkboxes, source system filter, "Expand 1 hop", "Reset focus". Execution Flow mode uses Pattern A or B based on seed entity type (see Sub-phase 2b).
ui/src/components/graph/layout.ts — Accept optional edges array from subgraph API (focus mode provides edges directly).
Sub-phase 2f: Tests
New: test/api/graph.test.ts — seed_id required, invalid mode → 400, depth cap, limit cap, truncated flag.
Acceptance
- User can reduce a noisy graph to a relevant execution subgraph in 3 interactions or fewer.
- Focus mode works for both identity-centric and non-identity seed nodes.
Phase 3 - In-Graph Node Details Drawer ✅ COMPLETE
Sub-phase 3a: Drawer component
New: ui/src/components/graph/GraphNodeDetailsDrawer.tsx
Right-side panel (400px), slides in on node selection. Props: entityId, onClose, onNavigateToEntity, onFocusFromHere.
5 tabs:
- Summary — name, type badge, source, status, relationship count. For automation entities: egress badge, risk group badge, ownership status badge.
- Properties — key-value grid from entity.properties (includes egress_category, data_domains, risk_group, ownership_status, execution_count_30d, identity_binding_status for automation entities)
- Relationships — list with type labels, target names (clickable)
- Execution Paths —
useBlastRadius(entityId)for identity type; for automation type, show execution chain summary (automation → REST Message → OAuth → SP → resources reached) - Findings —
useFindings({ entity_id: entityId })(uses Phase 1 entity_id filter)
Action buttons: "View Full Details" (navigates with origin context), "Focus from Here" (triggers focus mode). Close via Escape key or X button. Does not dim the graph.
Sub-phase 3b: Integrate into GraphExplorerPage
ui/src/pages/GraphExplorerPage.tsx — Replace bottom detail panel with drawer:
[sidebar] [graph-canvas] [drawer (conditional)]
Node click → open drawer. Node double-click → navigate to entity.
Acceptance
- User can inspect full node properties from graph without route change.
- Drawer latency remains acceptable (<300ms for cached entity fetch).
Phase 4 - Navigation Continuity Fix ✅ COMPLETE
Sub-phase 4a: Navigation helper hook
New: ui/src/hooks/use-back-navigation.ts
function useBackNavigation(defaultPath: string, defaultLabel: string) {
const { state } = useLocation();
if (state?.from === 'graph' && state.graphUrl)
return { to: state.graphUrl, label: 'Back to graph' };
return { to: defaultPath, label: defaultLabel };
}
Sub-phase 4b: Origin-aware back links
ui/src/pages/EntityDetailPage.tsx — Replace hardcoded Back to entities with useBackNavigation('/entities', 'Back to entities'). Add breadcrumb: Graph Explorer > [Entity Name] when from graph.
ui/src/pages/FindingDetail.tsx — Same pattern.
Sub-phase 4c: Pass origin context from graph
ui/src/pages/GraphExplorerPage.tsx + GraphNodeDetailsDrawer.tsx — When navigating to entity:
navigate(`/entities/${id}`, {
state: { from: 'graph', graphUrl: location.pathname + location.search }
});
Acceptance
- From graph, open entity, return, and land on same graph state (filters, selected node, viewport).
- No forced reset to
/entitiesduring graph-driven investigation.
Phase 5 - Hardening and QA ✅ COMPLETE
Tests
| File | Tests |
|---|---|
test/api/entities.test.ts | q search, sort params |
test/api/findings-merged.test.ts | severity filter, pagination, entity_id |
test/api/graph.test.ts | Full subgraph endpoint suite |
ui/src/__tests__/use-table-state.test.ts | URL state sync |
ui/src/__tests__/use-back-navigation.test.ts | Origin detection |
ui/src/__tests__/DataTable.test.tsx | Column rendering, sort, filters, empty state |
Manual QA
- Graph → select node → drawer opens → "View Full Details" → entity page shows "Back to graph" → click back → graph restored
- Entities page → set 3 filters → copy URL → paste new tab → same filters
- Findings page → filter severity=high + type=dormant → paginate → consistent
- Graph → Focus from here → depth 3 → responsive rendering
npm test+npm run test:integration+cd ui && npm run ci— all pass
5. Definition of Done
The upgrade is complete when all are true:
- Entities/Findings/Syncs use shared enterprise table foundation with persistent filter/sort state.
- Graph Explorer supports execution-flow and neighborhood focus modes with subgraph rendering.
- Node properties are viewable inside Graph Explorer (drawer/sheet) without page navigation.
- Graph-origin investigations can return from Entity Detail to preserved graph context.
- Automated tests cover the new table, graph focus, and navigation behavior.
6. Risks and Mitigations
| Risk | Impact | Mitigation |
|---|---|---|
| Subgraph API can become expensive on very dense tenants | Slow graph interactions | Enforce node cap, return truncated, add backend indexes, and support incremental expansion |
| Table query expansion increases backend complexity | Inconsistent filters across pages | Introduce shared query parsing utilities and API contract tests |
| URL state grows too large | Broken share links | Keep canonical compact keys and move large transient state to route state/session storage |
7. File Summary
New Files (10)
| File | Phase |
|---|---|
ui/src/components/table/DataTable.tsx | 1 |
ui/src/components/table/DataTableFilters.tsx | 1 |
ui/src/components/table/DataTablePagination.tsx | 1 |
ui/src/components/table/column-helpers.ts | 1 |
ui/src/hooks/use-table-state.ts | 1 |
src/api/routes/graph.ts | 2 |
ui/src/hooks/use-subgraph.ts | 2 |
ui/src/components/graph/GraphNodeDetailsDrawer.tsx | 3 |
ui/src/hooks/use-back-navigation.ts | 4 |
test/api/graph.test.ts | 2/5 |
Modified Files (16+)
| File | Phase | Key Changes |
|---|---|---|
ui/package.json | 1 | +react-table, +react-virtual |
src/storage/storage-adapter.ts | 1,2 | FindingQuery, SubgraphQuery, EntityQuery.q/sort |
src/storage/mongo/adapter.ts | 1,2 | text search, sort, FindingQuery, getSubgraph BFS |
src/api/routes/entities.ts | 1 | q, sort params |
src/api/routes/findings.ts | 1 | severity, entity_id, sort, pagination |
src/api/routes/syncs.ts | 1 | sort param |
src/api/app.ts | 2 | Register graph routes |
ui/src/hooks/use-entities.ts | 1 | q, sort, cursor |
ui/src/hooks/use-findings.ts | 1 | severity, entity_id, sort, limit, cursor |
ui/src/hooks/use-syncs.ts | 1 | sort |
ui/src/api/api-types.ts | 1,2 | cursor on findings, SubgraphResponse |
ui/src/pages/EntitiesListPage.tsx | 1 | DataTable migration |
ui/src/pages/FindingsList.tsx | 1 | DataTable migration |
ui/src/pages/SyncsPage.tsx | 1 | DataTable migration |
ui/src/pages/GraphExplorerPage.tsx | 2,3 | Focus mode + drawer |
ui/src/pages/EntityDetailPage.tsx | 4 | Origin-aware back nav |
ui/src/components/graph/GraphFilterSidebar.tsx | 2 | Focus mode controls |
ui/src/components/graph/layout.ts | 2 | Accept API-provided edges |
test/api/entities.test.ts | 5 | q, sort tests |
test/api/findings-merged.test.ts | 5 | severity, pagination, entity_id tests |
8. Pending Decisions
- Whether to add saved views server-side in this phase or defer to a follow-up release.
- Whether CSV export is enough for v1 or if XLSX export is required by design partners.
- Whether Syncs page should include connector-specific advanced filters in first iteration.