Skip to main content

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:

  1. Tables need built-in, multi-dimensional filtering and richer data-grid behavior.
  2. Graph Explorer needs subgraph focus controls for large execution graphs.
  3. Users need richer node details in the same Graph Explorer view.
  4. Graph to Entity navigation must preserve context and provide a reliable return path.

2. Current Gaps (Validated in Current UI)

GapCurrent BehaviorImpact
Table filtering and controlsEntity 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 focusGraph 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 detailsGraph detail card shows only summary and a View Details button. Full properties require route change.Repeated context switching slows triage and investigation.
Navigation continuityEntity 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-table for column/filter/sort model
  • @tanstack/react-virtual for 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, Severity where 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 Flow mode: show only path-relevant chain(s) from a selected identity
  • Neighborhood mode: 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 hop and Reset 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 graph when 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 use FindingQuery
  • Add cursor pagination to evaluator findings (request limit+1, reuse buildCursorResponse from src/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 cursor field alongside existing meta

File: src/api/routes/syncs.ts — Add sort query param, pass to adapter.

Sub-phase 1e: Shared DataTable component

New files:

FilePurpose
ui/src/components/table/DataTable.tsxMain reusable table with @tanstack/react-table
ui/src/components/table/DataTableFilters.tsxFilter chip bar + global search input
ui/src/components/table/DataTablePagination.tsxCursor-based "Load more" pagination controls
ui/src/components/table/column-helpers.tsShared column definition helpers (link cell, badge cell, date cell)
ui/src/hooks/use-table-state.tsURL-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:

ColumnCellFilterSort
NameLink to /entities/:idsearch via qdisplay_name
TypeEntityBadgeenum selectentity_type
Sourcetextenum selectsource_system
Statusbadgeenum select
Relationshipscount
Last Synceddatelast_synced_at

ui/src/pages/FindingsList.tsx — Replace hand-built table + client-side useMemo filtering. Columns:

ColumnCellFilterSort
SeveritySeverityBadgeenum selectseverity
Typetextenum select
DescriptionLink to /findings/:id
Entitytext
Sourcetextenum select
Statusbadgeenum select
DetectedtimeAgodetected_at

ui/src/pages/SyncsPage.tsx — Replace hand-built table. Keep expandable rows via renderSubRow. Columns:

ColumnCellFilterSort
Connectortextenum select
Statusbadgeenum select
Starteddatestarted_at
Durationcomputed
Entitiescount
Eventscount

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 ci all 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.tsuseSubgraph(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:

  1. Browse mode (current): useEntities({ limit: 200 }) + type/findings toggles
  2. 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:

  1. Summary — name, type badge, source, status, relationship count. For automation entities: egress badge, risk group badge, ownership status badge.
  2. 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)
  3. Relationships — list with type labels, target names (clickable)
  4. Execution PathsuseBlastRadius(entityId) for identity type; for automation type, show execution chain summary (automation → REST Message → OAuth → SP → resources reached)
  5. FindingsuseFindings({ 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 };
}

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 /entities during graph-driven investigation.

Phase 5 - Hardening and QA ✅ COMPLETE

Tests

FileTests
test/api/entities.test.tsq search, sort params
test/api/findings-merged.test.tsseverity filter, pagination, entity_id
test/api/graph.test.tsFull subgraph endpoint suite
ui/src/__tests__/use-table-state.test.tsURL state sync
ui/src/__tests__/use-back-navigation.test.tsOrigin detection
ui/src/__tests__/DataTable.test.tsxColumn rendering, sort, filters, empty state

Manual QA

  1. Graph → select node → drawer opens → "View Full Details" → entity page shows "Back to graph" → click back → graph restored
  2. Entities page → set 3 filters → copy URL → paste new tab → same filters
  3. Findings page → filter severity=high + type=dormant → paginate → consistent
  4. Graph → Focus from here → depth 3 → responsive rendering
  5. 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:

  1. Entities/Findings/Syncs use shared enterprise table foundation with persistent filter/sort state.
  2. Graph Explorer supports execution-flow and neighborhood focus modes with subgraph rendering.
  3. Node properties are viewable inside Graph Explorer (drawer/sheet) without page navigation.
  4. Graph-origin investigations can return from Entity Detail to preserved graph context.
  5. Automated tests cover the new table, graph focus, and navigation behavior.

6. Risks and Mitigations

RiskImpactMitigation
Subgraph API can become expensive on very dense tenantsSlow graph interactionsEnforce node cap, return truncated, add backend indexes, and support incremental expansion
Table query expansion increases backend complexityInconsistent filters across pagesIntroduce shared query parsing utilities and API contract tests
URL state grows too largeBroken share linksKeep canonical compact keys and move large transient state to route state/session storage

7. File Summary

New Files (10)

FilePhase
ui/src/components/table/DataTable.tsx1
ui/src/components/table/DataTableFilters.tsx1
ui/src/components/table/DataTablePagination.tsx1
ui/src/components/table/column-helpers.ts1
ui/src/hooks/use-table-state.ts1
src/api/routes/graph.ts2
ui/src/hooks/use-subgraph.ts2
ui/src/components/graph/GraphNodeDetailsDrawer.tsx3
ui/src/hooks/use-back-navigation.ts4
test/api/graph.test.ts2/5

Modified Files (16+)

FilePhaseKey Changes
ui/package.json1+react-table, +react-virtual
src/storage/storage-adapter.ts1,2FindingQuery, SubgraphQuery, EntityQuery.q/sort
src/storage/mongo/adapter.ts1,2text search, sort, FindingQuery, getSubgraph BFS
src/api/routes/entities.ts1q, sort params
src/api/routes/findings.ts1severity, entity_id, sort, pagination
src/api/routes/syncs.ts1sort param
src/api/app.ts2Register graph routes
ui/src/hooks/use-entities.ts1q, sort, cursor
ui/src/hooks/use-findings.ts1severity, entity_id, sort, limit, cursor
ui/src/hooks/use-syncs.ts1sort
ui/src/api/api-types.ts1,2cursor on findings, SubgraphResponse
ui/src/pages/EntitiesListPage.tsx1DataTable migration
ui/src/pages/FindingsList.tsx1DataTable migration
ui/src/pages/SyncsPage.tsx1DataTable migration
ui/src/pages/GraphExplorerPage.tsx2,3Focus mode + drawer
ui/src/pages/EntityDetailPage.tsx4Origin-aware back nav
ui/src/components/graph/GraphFilterSidebar.tsx2Focus mode controls
ui/src/components/graph/layout.ts2Accept API-provided edges
test/api/entities.test.ts5q, sort tests
test/api/findings-merged.test.ts5severity, pagination, entity_id tests

8. Pending Decisions

  1. Whether to add saved views server-side in this phase or defer to a follow-up release.
  2. Whether CSV export is enough for v1 or if XLSX export is required by design partners.
  3. Whether Syncs page should include connector-specific advanced filters in first iteration.