Frontend
Overview
Section titled “Overview”The Logrite Frontend is a React 19 single-page application that provides the user interface for log analysis, compliance scanning, AI chat, and agentic AI management.
| Setting | Value |
|---|---|
| Technology | React 19 + Vite 7 + Redux Toolkit + Tailwind CSS |
| Port | 3000 (app.logrite.ai / dev-app.logrite.ai) |
| Repo | Deployd-io/Logrite-UI |
| Runs as | The frontend compose service on each VM (one instance per estate) |
Key Libraries
Section titled “Key Libraries”| Library | Purpose |
|---|---|
| React 19 | UI framework |
| Vite 7 | Build tool + dev server |
| Redux Toolkit | Global state management |
| Tailwind CSS | Utility-first styling |
| echarts-for-react | Charts (via EChartsWrapper.jsx) |
| react-icons (hi2) | Icons — HiOutline* pattern |
| react-router-dom | Client-side routing |
Architecture
Section titled “Architecture”State Management
Section titled “State Management”Redux Toolkit with multiple slices:
authSlice— JWT, user info, workspacescanSlice— Compliance scan stateuiSlice— Theme (dark/light), sidebar stateagentSlice— Agent management (proposed)
Component Structure
Section titled “Component Structure”src/├── components/│ ├── common/ # Shared: hooks, UI primitives│ │ └── hooks/ # useScan*.js, custom hooks│ ├── Dashboard/ # Main analytics views│ ├── Login/ # Auth flows (login, register, forgot)│ ├── Scan/ # Compliance scan UI│ ├── Chat/ # AI chat interface│ ├── Agents/ # Agent management UI│ └── Settings/ # Settings subdir (split for chunk size)├── contexts/ # React contexts│ └── ScanProgressContext.jsx # Global WebSocket broker├── redux/slices/ # Redux state slices├── utils/│ ├── themeClasses.js # Theme utilities (extracted to avoid chunk pollution)│ └── useGeneratorWebSocket.js└── chartConfigs/ # ECharts config subdirPerformance Optimizations
Section titled “Performance Optimizations”- 16 JS chunks (down from 125 before optimization)
CortexPanellazy-loaded — prevents 1.1MB ECharts on every page- Function-based
manualChunksinvite.config.js - Zero icon micro-chunks
Theme System
Section titled “Theme System”- Dark/light theme with CSS variables
- FOUC prevention: inline
<script>inindex.htmlreadslocalStorage['persist:root']and applies CSS vars before React loads - Primary color:
--accent-orange: #E64100— a brand/decoration colour only. It fails WCAG AA as text on white (4.1:1) and under white text (4.1:1), so:- orange text uses
--accent-orange-text(light#BF3D00, dark#FF8B5B); - orange behind white text (active tabs, filter pills, the pager’s active page) uses
--accent-orange-fill(#D43C00, 4.72:1); - framework chip fills pass through
readableBackground()(src/utils/contrast.js), which darkens a fill below 4.5:1 in hue-preserving steps — the backend still serves the original catalogue colours. npm run check:orange-text(part ofnpm run verify) fails on small orange text, on white-on-#E64100in the fixed surfaces, and on any token or chip fill below 4.5:1. Measured 2026-10-10 with axe 4.10.2:/sandbox,/ai-wardenand/settings?tab=connectionshave 0 violations in both themes.
- orange text uses
- Background:
--bg-primary: #0c0e12(dark mode)
WebSocket Scan Progress
Section titled “WebSocket Scan Progress”ScanProgressContext.jsx— global broker that owns socket refs surviving navigation- Attach/detach pattern: scan page “attaches” for full UI, “detaches” on unmount
ScanProgressPanel.jsx— draggable floating panel at bottom-right
Dashboard Cache + Refresh
Section titled “Dashboard Cache + Refresh”Shipped: 2026-05-21 05:48 UTC
The Dashboard pulls two heavy lists on every visit:
| Endpoint | Purpose |
|---|---|
GET /api/v1/scan-results/user/{userId} | Compliance + analyzer roll-up |
GET /api/v1/log-generator-results/user/{userId} | Generator roll-up |
Both responses are cached in a dedicated Redux slice (src/redux/slices/dashboardSlice.js, persisted via redux-persist). useDashboardLogic only fires the GETs when the cache key is stale or missing:
| Condition | Behavior |
|---|---|
| First login / empty cache | Fetch + populate |
Workspace switch (user.currentWorkSpace.id change) | Fetch + populate |
New scan kicked off (project.scanTrigger bumped) | Fetch on next Dashboard mount |
| Same userId + workspace + scanTrigger | Cache hit — no API call |
| Manual refresh (header button) | Forced fetch, bypasses cache |
| Scan completes (analyzer END Scan ID or generator END) | Background refresh via refreshDashboardForCurrentUser thunk in src/redux/thunks/dashboardThunks.js, dispatched from ScanProgressContext — no loader, no user interaction |
| Logout | Slice cleared by clearDashboard |
The refresh control is a square icon-only button (HiOutlineArrowPath) rendered inside DashboardAnalytics.jsx immediately after the “All Projects” CustomSelect. While the API is in flight the icon spins and clicks are disabled. The button is visible at every breakpoint (it lives in the analytics-header row, not the page header).
When the refresh is in flight the metrics grid and project list are replaced by a centered LogriteLoader (size="md") with caption “Refreshing dashboard…”. The page header (greeting + Quick Actions) and the analytics-header (title + dropdown + refresh button) stay mounted underneath so the spinning refresh icon remains visible and the page doesn’t reflow. Once setDashboardData lands, the metrics and project list re-render with fresh values.
The metrics grid uses a 4-col layout on lg (and 2-col on sm/md). The Active Alerts card spans 2/4 columns because it carries four severity chips (Critical, High, Medium, Low) that wrap onto a second line in a narrow 1/3 cell; Log Coverage and Total Generated Logs each take 1/4. At sm, the first card also col-span-2 to own its row.
| File | Role |
|---|---|
src/redux/slices/dashboardSlice.js | Cache state + reducers (setDashboardLoading, setDashboardData, setDashboardError, invalidateDashboard, clearDashboard) |
src/components/Dashboard/hooks/useDashboardLogic.jsx | Reads cache, fetches on stale key, exposes actions.refresh + state.refreshing |
src/components/Dashboard/components/DashboardAnalytics.jsx | Renders the icon-only Refresh button next to the All Projects dropdown |
Multi-template chips on the Dashboard project card
Section titled “Multi-template chips on the Dashboard project card”Shipped: 2026-05-21
The project card’s chip row (ProjectCard.jsx) keeps the existing single Generator (dark grey) or Compliance (orange) pill driven by logType.id, then renders one extra small uppercase pill per non-primary template when the scan was a multi-template merged run (e.g. agents-flow with SOC 2 + HIPAA selected).
The non-primary pills are sourced from scanResult.appliedTemplates: string[] returned by /api/v1/scan-results/user/{userId}. Backend populates it from LogScanResult.run_details.appliedTemplates (written only by multi-template scans — ScanOrchestratorService stamps it when matchedIds.size() > 1). Single-template scans get the fallback [logType.name], which makes the filter (name !== logType.name) emit zero extra pills — so the UI is byte-for-byte unchanged for single-template flows.
/api/v1/log-generator-results/project/{projectId} and /api/v1/scan-results/{id} carry the same appliedTemplates field (LogGeneratorResultDTO, LogScanResultDTO), zero-cost passthrough from the same run_details JSON column.
Type Dropdown Template Tags
Section titled “Type Dropdown Template Tags”Shipped: 2026-05-21 05:48 UTC
The Type filter dropdown on the analyzer success screen (AnalyzerSuccessScreen.jsx) and the generator success screen (GeneratingLogsSuccess.jsx) labels every option with its source template — HIPAA / SOC2 / GDPR / GEN — as a small bordered pill rendered by CustomSelect.jsx.
Mapping lives in src/utils/frameworkTag.js:
RULE_TO_TEMPLATE— explicit rule-name → tag map covering every rule the backend’s seeded templates ship today. Authoritative; no guessing. Sync this when new rules are added to General Logging / SOC 2 / HIPAA templates.- Keyword fallback — catches custom user-template rules whose name contains
phi,gdpr,sox, etc. GEN— last-resort tag (never silently mis-attribute).
The dropdown option list comes from getAllLogTypesFromScanResults(scanResults) (src/utils/scanResultsUtils.js), which walks all four priority buckets (criticalLvl1Details, highLvl1Details, mediumLvl1Details, lowLvl1Details), dedupes by loggingRule, and sorts — so multi-template scans (e.g. SOC 2 + HIPAA merged) surface every template’s rules.
The selected option’s tag is also mirrored onto the closed dropdown button so the filter chip stays template-attributed at a glance. Tag styling matches the StatusBadge family (rounded-md, border, px-1.5 py-0.5, text-[10px]).
Quick Actions card grid
Section titled “Quick Actions card grid”Shipped: 2026-05-21
The Dashboard’s four “Quick Actions” cards (QuickActions.jsx + QuickActionCard.jsx) follow a strict 2 / 3 / 4-column responsive grid:
| Breakpoint | Columns |
|---|---|
mobile (< md) | 2 |
md | 3 |
lg+ | 4 |
Card layout is a single row (icon-pill | title + description) at every breakpoint. Title and description both use truncate so neither ever wraps onto a second line — important at the 4-col layout where each card is narrow. The full text remains accessible via the card’s title tooltip on hover. The icon column is flex-shrink-0 and the text column is flex-1 min-w-0 (the min-w-0 is what lets truncate actually clip — without it, flex children refuse to shrink past content width).
Agents-flow wizard loaders
Section titled “Agents-flow wizard loaders”Shipped: 2026-05-21
StepMonitorAndScan (in src/components/Onboarding/AgentsFlowOnboarding.jsx) has one full-section LogriteLoader that covers every long-running state the user might be staring at. Whenever loading || enabling || scanning || comboRunning is true, the entire wizard body (Project field, Compliance templates, monitoring health, Run scan panel) is swapped for a centered LogriteLoader (size="xl") with a state-specific caption:
| State | Trigger | Caption |
|---|---|---|
loading | /api/v1/agents-flow/picker/{ws} in flight (GitHub /app/installations + OAuth /user/repos fan-out, 2–8 s) | “Loading projects and templates…” |
comboRunning | User clicked Run scan + Generate logs | “Starting scan and log generation… you’ll be redirected to the live progress page in a moment.” |
scanning | User clicked Run scan | “Running scan… you’ll be redirected to the live progress page in a moment.” |
enabling | Saving monitoring contract (template chip toggled) | “Saving monitoring contract… scan controls will re-enable in a moment.” |
The page header (Monitor & scan h2 + description) and the workspace-warning Notice stay mounted above the loader so the user keeps context. Once the state clears (response lands or navigation kicks in), the wizard body re-renders.
Buttons (PrimaryButton / SecondaryButton) still accept a busy prop that renders an inline InlineSpinner next to the text — useful for short transient states, but the full-section loader is the primary “something is happening” surface.
GDPR is parked. In the Compliance templates picker, the GDPR chip renders disabled with an Upcoming pill (purple, matches the framework-tag style) and the description swaps to “Future enhancement — GDPR monitoring isn’t enabled yet…”. It stays in COMPLIANCE_TEMPLATES so the layout doesn’t shift when we flip the switch later. Hover tooltip: “GDPR monitoring is coming soon”.
Auto-flow skips the Approved-all toast. When the user enters the analyzer success screen via the agents-flow “Run scan + Generate logs” combo (i.e. runAutoApproveAndGenerate in useScanApi.js), the post-approval SuccessNotification toast (“Changes Approved Successfully!”) is suppressed — the user didn’t manually click Approve All, so the confirmation is redundant noise. The toast still fires for the manual submitApprovedChanges path when the user clicks Approve All from the table.
Per-scan PR URL on the success screen
Section titled “Per-scan PR URL on the success screen”Shipped: 2026-05-21
The PR URL the View Branch button uses on the analyzer/generator success screen is state.project.generatorPrUrl. Before this fix, it was a single global slot only ever written by the generator websocket — so opening a past scan via Dashboard’s View Results button left the button pointing at a stale URL (or disabled because the slot was null after a page reload).
The fix is a scanId → PR URL join on the Dashboard side:
- The scan-results row (
/api/v1/scan-results/user/{userId}→allScanResults) carriesbranchLinkthat’s the source-branch tree URL (e.g.https://github.com/.../tree/master), not a PR URL. - The PR URL lives on the matching
LogGeneratorResultrow (/api/v1/log-generator-results/user/{userId}→allGenerateResults) — same data, different table, joined byscanId(the analyzer scan id). useDashboardLogicbuilds a memoizedscanIdToGeneratorPrUrlmap fromallGenerateResults, picking only rows whosebranchLinkcontains/pull/.- The map is threaded
Dashboard → DashboardAnalytics → DashboardProjectList → ProjectCard. ProjectCard.getScanResultsRoute()dispatchessetGeneratorPrUrl(matchedPrUrl || null)alongsidesetProjectFromScanResults(scanResult)on every View Results click, so the success screen reads the correct URL no matter how the user got there.
setProjectFromScanResults deliberately leaves generatorPrUrl untouched — overwriting from scanResult.branchLink would clobber the correct value with the source-branch tree URL.
The Dashboard’s own View Branch button on each ProjectCard was also reading branchLink directly (the tree URL). The first attempt looked the PR URL up by scanId against dashboard.generatorResults.allGenerateResults — but that endpoint returns LogGenerateResultLiteDTO which only carries projectId + logsGeneratorCount, no scanId and no branchLink — so the map was always empty and the button silently fell through to the tree URL.
The fix is server-side: LogScanResultLiteDTO now carries a generatorBranchLink field (src/main/java/com/logwrite/dto/LogScanResultLiteDTO.java:40), populated in LogScanResultService.getLogScanResultSummaryByCreatedBy from the matching LogGeneratorResult.branchLink via a new LogGeneratorResultService.getBranchLinkById(...) helper. The frontend now reads result.generatorBranchLink straight off each scan-results entry and passes it as the per-card generatorPrUrl prop. The old scanId-map lookup is kept as a fallback for the brief window before the backend redeploys.
Unified TemplateChip — Dashboard, Project Details, Type dropdown
Section titled “Unified TemplateChip — Dashboard, Project Details, Type dropdown”Shipped: 2026-05-21
A single <TemplateChip> component (src/components/common/TemplateChip.jsx) is the source of truth for “applied template” pills across the app. Style matches the framework pills rendered inside CustomSelect’s Type dropdown on the analyzer + generator success screens — text-[10px] font-semibold tracking-wide uppercase px-1.5 py-0.5 rounded-md border shadow-sm, themed via the high-contrast TAG_STYLES palette in frameworkTag.js. Each chip is solid-saturated background + white text so it reads as a label (distinct from the pastel StatusBadge family used for success/failure/warning statuses):
| Template | Light bg / border | Dark bg / border |
|---|---|---|
| HIPAA | #DC2626 / #B91C1C | #B42318 / #F04438 |
| SOC 2 | #2E90FA / #1570CD | #1849A9 / #2E90FA |
| GDPR | #7F56D9 / #6927DA | #5925DC / #9E77ED |
| General Logging / unknown | #E64100 / #B33300 (Logrite brand orange) | #B33300 / #FF6B2B |
Used in two surfaces:
Dashboard/components/ProjectCard.jsx— replaces the old single “Generator” / “Compliance” pill (driven bylogType) with oneTemplateChipperappliedTemplatesentry (deduped viadedupeTemplateNames). Falls back to[logType.name]only whenappliedTemplatesis empty.Generator/components/BranchesTable.jsx— replaces the old neutral-grey chip on each scan row; now oneTemplateChipper applied template with the matching framework color.
The CustomSelect Type dropdown already sources its tag styling from the same TAG_STYLES table, so all three surfaces stay visually identical without further plumbing.
Project Details — multi-template row chips
Section titled “Project Details — multi-template row chips”Shipped: 2026-05-21
BranchesTable.jsx (/generator/project/{id} / /compliance/project/{id}) renders one template chip per entry, sourced in this priority order:
item.appliedTemplates— backend-computed list at the top level of everyLogGeneratorResultDTO/LogScanResultLiteDTO. Multi-template scans get the display names directly (e.g.["General Logging", "SOC 2"]). Single-template scans get[logType.name], which can be the ugly internal form ("soc2_compliance").item.runDetails.appliedTemplates— defensive fallback when the top-level field is missing; the project-results endpoint sometimes returnsrunDetailsas a JSON string"{}", the lite payload returns it pre-parsed.item.logType.name— last-resort single chip for very old rows.
A prettifyTemplateName helper rewrites known internal names to canonical UI labels — general → “General Logging”, soc2_compliance → “SOC 2”, hipaa_compliance → “HIPAA”, gdpr_compliance → “GDPR”. Display-friendly names already in appliedTemplates survive untouched.
normalizeTemplateKey is the dedupe key — collapses underscores/hyphens to spaces, lowercases, and folds general/general logging/general_logging, soc 2/soc-2/soc2/soc2_compliance, hipaa/hipaa_compliance, gdpr/gdpr_compliance all to one canonical key each. Only one source renders at a time — never both.
Breadcrumb project crumb redirect
Section titled “Breadcrumb project crumb redirect”Shipped: 2026-05-21
NavigationBreadcrumb’s “project” crumb (the one labelled with the project name, e.g. JobSearch) navigates to ${routePrefix}/project/${projectId} only when projectId is truthy. ScanResults.jsx was previously passing projectId={params.id}, but the route is /scan-results/:scanId so params.id is always undefined — and the click silently did nothing. The breadcrumb now receives scanResults?.projectId || projectToUse?.id || projectToUse?.projectId, populated from the scan-results fetch / Redux project state.
Generator / Compliance: loader instead of EmptyDashboard while projects load
Section titled “Generator / Compliance: loader instead of EmptyDashboard while projects load”Shipped: 2026-05-23
Generator.jsx is used by both /generator and /compliance. It used to bail out to EmptyDashboard whenever state.project.projects was empty, even when the page had just mounted and the project list was still being loaded. After a hard reload (or any path that doesn’t go through SelectWorkSpace) the user saw a one-frame flash of “you have no projects” before the real list arrived.
The page now:
- Initialises
pageLoading=trueand keeps it true until projects resolve. - If Redux already has projects (the common path through
SelectWorkSpace/useDashboardLogic’s background fetch), dropspageLoadingimmediately. - Otherwise, fires
GET /api/v1/project/{workspaceId}/{email}itself (gated oncurrentWorkSpace.id+ a resolvable email) and only dropspageLoadingwhen the request settles. - Renders
<LogriteLoader size="xl" />while loading, thenEmptyDashboardonly after the fetch attempt finished and the list is still empty.
Compliance “Configure Rules” — HIPAA description on first load
Section titled “Compliance “Configure Rules” — HIPAA description on first load”Shipped: 2026-05-23
useRulesState.js used to hard-code currentFramework = "SOC 2" regardless of location.state.templateName. The fetchRules effect in useRulesEffects.js reads currentFramework to compute logTypeId (SOC 2 → 2, HIPAA → 3, GDPR → 4), so navigations from ScanResults’ Configure Rules button with { templateName: "HIPAA" } produced a first get-analyzerrules?logTypeId=2 call. Symptom: HIPAA descriptions were blank until the framework dropdown was toggled, which forced a refetch with the right logTypeId.
Two fixes:
useRulesState.jsderives the initialcurrentFrameworkfromlocation.state.templateName(orlocation.state.logTypeIdfor custom-template callers that pass that explicitly).useRulesEffects.js’s “init compliance template on first load” effect no longer overrides the explicit pick. When the caller handed us atemplateNamevia navigation state, the effect either trusts it (built-in name) or looks up itslogTypeIdfrom the project’s compliance templates — instead of falling back tosetCurrentFrameworkState("SOC 2")when the project lacks custom compliance templates.
OpenSearch Analytics — Discover histogram, interval & resilient index queries
Section titled “OpenSearch Analytics — Discover histogram, interval & resilient index queries”Shipped: 2026-05-28
The Discover view (src/components/OpenSearch_Analytics/) gained these fixes:
ignore_unavailable=true&allow_no_indices=trueon_searchand_mapping.resolveTenantIndexexpands the selection into a comma-separated list that mixes wildcards (*logrite*) with concrete index names pulled from a platform’sassociated_indexes(e.g.payment_queries,ctm_events,external_service_calls). When any of those concrete indexes doesn’t exist, OpenSearch_search/_mappingreturns 404index_not_found_exceptionfor the whole request. The two flags make OpenSearch skip the missing concrete indexes instead. This was the root cause of “Discover shows no data” and “No aggregation data available”: the 404 on_mappingleftprimaryTimestampFieldstuck on its@timestampdefault, so thedate_histogramaggregated on a field the log index (time) doesn’t have.- Dropped the unused
highlightblock frombuildDiscoverQuery. It requestedhighlight.fields: {"*": {}}withfragment_size: 2147483647(Integer.MAX) across every field of a multi-index expansion. The Discover table/expanded-row render_sourcedirectly and never read the highlight fragments, so it was dead weight that inflated each request’s memory estimate — a contributor to OpenSearch’s 429circuit_breaking_exceptionon the small single-node cluster (2 GB heap / 1.8 GB parent breaker). NOTE: the 429 is fundamentally a server-side heap issue; the FE change only lowers trip frequency. Bumping OpenSearch heap is the real fix. - Histogram bar tooltip now shows time.
DiscoverResults.jsxbuilds the bar tooltip withdate(toLocaleDateString),time(toLocaleTimeString), and the bucket’s log count — previously only the date and count were shown, which was ambiguous for minute/hour buckets. - Histogram interval selector is functional. The old “Auto” pill in the chart header was a static
<button>with no handler. It’s now aCustomSelect(Auto/Minute/Hour/Day) bound to a newintervalOverridestate inuseDiscoverLogic.js. Precedence infetchData: explicit override → custom-range span heuristic →calculateInterval(...).intervalOverrideis a dependency of bothfetchData(theuseCallback) and the data-fetch effect, so changing it re-buckets thedate_histogram(fixed_interval1m/1h/1d) and refetches. - Removed the
api.ipify.orgclient-IP fetch.ExpandedRowView.jsxandTraceDetailsModal.jsxfetched the viewer’s public IP fromhttps://api.ipify.org. This is blocked by the app CSPconnect-srcallowlist (it only permits*.logrite.ai/*.deployd.io), leaked the viewer’s IP to a third party, and was semantically wrong (it labelled the viewer’s IP as the span/log’s client IP). Trace spans now deriveclientIpfrom span fields only and fall back tonullinstead of the browser IP. - “View in Discover” now loads OTEL-agent data (2026-05-29). OTEL-agent log docs have no
messagefield (the text is inbody) and a realtimefield (traces usestartTime). The Discover query builder fell back to a__ts_from_messagepainless runtime field that readsmessage→ null → empty result. Fix:discoverUtils.jsandTableBlock.jsxnow includeobservedTime/startTimein the timestamp-field detection, time filter, anddocvalue_fields; the__ts_from_messagefallback also readsbody. The realtime/startTimefield is preferred, so the runtime field is only a last resort.
Platforms analytics view (AWS / Netlify / Vercel)
Section titled “Platforms analytics view (AWS / Netlify / Vercel)”Shipped: 2026-06-03
A new tab at /analytics/platforms visualizes the cloud-platform logs ingested by the Cloud Platform Log Pull cron (index log-cloud-platform-logrite). One card per platform shows log volume, p50/p95 latency, error rate, a 24h volume sparkline, and a recent-logs table.
- Files (under
src/components/OpenSearch_Analytics/):PlatformsView.jsx,components/platforms/PlatformCard.jsx,components/platforms/PlatformSparkline.jsx,hooks/usePlatformMetrics.js,utils/mockPlatformData.js. Wired via a lazy route inApp.jsxand a Browse-dropdown entry inSidebarPage/Analytics.jsx. - Reuse, not reinvent. The sparkline wraps the existing
components/charts/EChartsWrapper(echarts is already a dependency — no new package), and all styling uses theOpenSearch_Analytics/styles/theme.jshelpers (getThemeClasses,getCardStyles,getTableStyles,getLogLevelBadgeStyles). - Static MVP → live swap.
usePlatformMetricsis the single data source. It returns a mock fixture today (USE_MOCK = true) so the view renders before any cloud data exists; flipping the flag runs the real OpenSearch query (atermsagg onplatformwithpercentilesonquery_exec_ms, afilterforlevel:ERROR,max(@timestamp), and adate_histogramfor the sparkline, plus a per-platform recent-logs_search). The returned shape is identical either way, soPlatformsView/PlatformCardnever change. - Transport is the same browser→backend proxy used by Discover:
axios.post(`${OPENSEARCH_API}/log-cloud-platform-logrite/_search?ignore_unavailable=true&allow_no_indices=true`, body, { Authorization: Bearer })with the user’s token fromstate.user.
Cortex chat: project binding, tighter spacing, Quick Stats (2026-05-29)
Section titled “Cortex chat: project binding, tighter spacing, Quick Stats (2026-05-29)”projectIdthreaded into the chat request.ProjectConfirmContent→useChatLogic→useSSEChat→aiServiceApi.sendChatMessageV2now forward the selected project’s numericidaspayload.projectId, so the AI service can resolve the authoritative serviceName binding instead of guessinglower(projectName). Additive —nullwhen no project is selected.- Deterministic-error-sweep rendering.
ChatMessageBubble.jsxhalves the markdown header margins and collapses stacked/adjacent<br/>around block elements (the sweep response had excessive blank lines between### Quick Stats/### Affected code/### Sample errors); the Quick Stats heading renders as a left-bordered titled label.
Cortex composer: agent picker, Answer-style dropdown, scoped cards, grid chips (2026-07-14)
Section titled “Cortex composer: agent picker, Answer-style dropdown, scoped cards, grid chips (2026-07-14)”- Pinned-agent picker (
AgentPicker.jsx) — a pill in the composer bottom bar lets the user scope the chat to a specific collector/log agent (its persona +targetIndex) at any time, or “All logs” to clear the pin. Selecting one setsselectedAgentKey+ a scopedsavedContextso every send is scoped. - Answer-style dropdown —
PersonaPicker.jsxrewritten from native<select>s into a modern upward popover (role + experience), styled like the mode/domain dropdowns. Still presentation-only (tone/depth), read from localStorage byaiServiceApiat send time. - Empty-state agent cards now start a conversation scoped to the agent
(persona + index) instead of a blank one that did nothing
(
handleAgentCardStartinAssistant.jsx). - Suggestion chips render in a responsive grid (
grid-cols-1 sm:2 lg:3) so long-sentence prompts no longer stack one-per-line on tablet/desktop. - Tighter message spacing — heading/list/hr block margins reduced and the
streaming renderer (
ChatMessages.jsx) now collapses<br/>runs, so sections no longer show 3–4 blank lines.
Cortex composer: scope picker + smarter modal-skip (2026-07-29)
Section titled “Cortex composer: scope picker + smarter modal-skip (2026-07-29)”- Scope picker labeled by project name (
ScopePicker.jsx) — an always-visible pill in the composer that replaces the generic “All logs” control. It labels the current scope by its human project/agent name (from theindex → ownermap,GET /api/v1/index-owners) instead of the raw index id. Rendered in both composers (CortexPanel.jsx,ChatView.jsx); hidden in collector log-scoped panels, where the scope is already pinned. - The picker is MULTI-SELECT (2026-08). It emits the same comma-joined
indexNamestring the “Send with Context” modal has always sent, so nothing changed on the wire and no backend change was needed — the composer simply reached a contract that already existed. Options are grouped under Projects / Agents / AI Warden / Other indexes, filterable by project name or raw index id, and the current selection shows as chips with an “N sources” summary. Picking nothing still means “all logs”, which is the absence of an index filter —joinScopereturnsnullrather than'', because an empty string would be sent as a real, unmatchable index name. Picking exactly one still emits a single plain index name, byte-for-byte what the single-select version emitted. The modal remains for method targeting. - A multi-index scope survives an index-list refresh. The old rule was
all-or-nothing: a scope that was not an exact member of the visible
indexeslist was cleared — and a comma-joined scope is never an exact member of that list, so every multi-index selection was silently reset to “all logs” the next time the list changed.retainVisibleScopefilters per name, keeps the survivors, and clears only when nothing survives. - Smarter modal-skip (
chatScopePlan.js) — the two composers’ duplicated “does the prompt already name a scope?” logic is unified into one pure helper (resolveScopePlan). It skips the modal when the prompt names a project (exact whole-word match), an index, or — new — a collector agent/“model” (detectMentionedAgent, longest-match wins). Naming an agent pins it (setSelectedAgentKey) and sends; naming an index scopes to it. Both composers now behave identically, so a mentioned project/index/agent never interrupts the user with the dialog. - Table cells (
TableBlock.jsx) no longer dump raw object/array JSON — arrays render as “N items”, objects to a label/keys, and cell text is length-capped.
Cortex composer: pressing Enter mid-upload is remembered, not dropped (2026-09-25)
Section titled “Cortex composer: pressing Enter mid-upload is remembered, not dropped (2026-09-25)”Reported as two symptoms that sounded like separate bugs and were one: “not able to give me resposne when I uploads multiple images” and “the input space [is not] emtpy after clikcin enter … the images are still not removed”.
Sending while an attachment is still uploading would genuinely drop the file from the
turn, so handleSend refused — correctly. But ChatInputPart1 disables Send on
disabled || sending || !value.trim() and has never consulted
attachmentsUploading. So the button looked ready, the click reached the guard, and
nothing happened: no send, no error, the text still in the box and the chips still in the
tray. Indistinguishable from a broken button.
The press is now deferred rather than discarded — useDeferredSend flushes it the
moment attachmentsUploading goes false. Pressing Enter is an instruction; the only
thing standing in its way is a request already in flight.
Three things had to be right about that clearing, and each is a separate way to get it wrong:
- Wrapped once, not appended to six call sites. Six branches are six chances to miss one, and the branch most likely to be missed is the rarest.
- Not inside
useAnalysisSend, which looks like the real choke point. It is also the choke point for edit / regenerate / follow-up, so clearing there empties a tray you have just filled because you pressed “regenerate” on an older answer. - Order is load-bearing: clear after. Reversed, the turn goes out with no attachment ids
while the bubble still renders the files — the screen looks right and the answer is wrong.
tests/unit/composerClearsOnSend.test.jsasserts the call order specifically.
The sidebar assistant’s dialog path does not clear. setPendingMessage parks the prompt
and opens the project dialog; nothing has been sent yet, so clearing there and then cancelling
would lose the files with no turn to show for them. handleConfirmSend and handleSkipProject
clear for themselves, where the turn actually leaves.
The queued press is now visible
Section titled “The queued press is now visible”A deferral you cannot see is indistinguishable from the dropped press it replaced — the fix
and the bug looked identical from the chair. useDeferredSend therefore exposes sendPending
as state, not the ref: a ref cannot re-render.
The Send button renders a spinner, keeps its title/aria-label explaining that it will send
when the upload finishes, and stays enabled — clicking it cancels. A deferral is a promise
to act later, and one you cannot withdraw is worse than one never made.
Scoping a chat: the pill, the opener, and @ (2026-09-25)
Section titled “Scoping a chat: the pill, the opener, and @ (2026-09-25)”Reported as “we don’t need to display the mode[l] for index and project selection by default
… sometimes user don’t want to merge any index/project with the prompt, in that we cannot
block the user”, and separately as a request for @-mentions “same functionality like Claude
Code, ChatGPT and Gemini provides”.
The blocking modal was already gone, and ScopePicker already offered projects and indexes
inline. What was missing was not the capability — it was any way to tell that it existed.
| Control | What it says |
|---|---|
ScopePickerToggle — the pill | the current state: “All logs”, or the project name, or “N sources” |
ScopePickerOpener — beside it | the action: “Add project or index”, or “Edit project or index” once something is scoped |
@ in the composer | names one inline, in the flow of typing |
The opener is a second control and deliberately not a second menu: it sets the same
open state and renders nothing of its own, per this repo’s standing rule that a second copy
of a renderer will drift from the first. Closing hands focus back to whichever button was
pressed — returning it to the other one moves the caret somewhere nobody clicked.
@ binds structurally, never by prose
Section titled “@ binds structurally, never by prose”useMentionPicker emits the same onSelectIndex(joined, names) the picker emits, so the scope
travels as data. The @JobSearch left in the text is a receipt for the human and is never
read back. That matters because the prose route already exists and is fuzzy —
resolveScopePlan guesses a project from the wording, and a guess is precisely what an
explicit pick replaces. Delete the mention text afterwards and the scope survives, because the
scope was never in the text.
The candidate list is the picker’s own buildScopeOptions / filterScopeOptions /
groupScopeOptions, and the rows are the picker’s own ScopePickerOptionRow — two lists of
“things you can scope to” would answer differently the first time an index is classified, and
the menu the user did not open would be the one that stayed right.
Picking adds, never toggles. Reading a second @JobSearch as “remove JobSearch” would
silently undo a scope from a gesture that, in the text, reads like asking for it again.
Selecting nothing remains a real answer — scoping is optional, and it is never a step skipped.
Preview features: on dev, hidden on production (mechanism since 2026-09-28)
Section titled “Preview features: on dev, hidden on production (mechanism since 2026-09-28)”main deploys to both estates, so a feature still being tested on dev cannot be held back on a
branch. It is held back by the host the bundle is served from, in
src/utils/previewFeatures.js:
| Host | Preview features |
|---|---|
dev-* (e.g. dev-app.logrite.ai), localhost, 127.0.0.1 | shown |
app.logrite.ai, and any host not listed above | hidden — it fails closed |
| no browser (unit tests run in node) | shown — that is never production |
Only the entry-point button is hidden. The page behind it still opens from its URL, by design.
Currently gated: nothing. The registry has been empty since 2026-10-01, when both
features gated on 2026-09-28 were released to production (Logrite-UI 3f42aae2). They appear on
app.logrite.ai from the first prod frontend build that contains that commit — prod moves only
when a human promotes.
| Key (removed 2026-10-01) | What production did not show until then |
|---|---|
integrations | The Integrations tab button in Settings (settings-tab-integrations). |
customRules | The Your own rules button on Configure Rules (custom-rules-open) — Generator and Compliance → Rules. |
Why not a VITE_* build arg: prod’s frontend is built from the overlay in
ops/deploy/build-args.prod-frontend.json, which refuses any declared arg it does not answer.
A new arg would need edits on two hosts, and a missed one stops every prod build. The hostname
needs neither and cannot be baked wrong.
This hides the UI only. The backend endpoints behind both features are unchanged, and a custom rule saved on dev is not visible to prod, which has its own database.
To release a feature to prod: delete its key from PREVIEW_FEATURES and its
isPreviewFeatureEnabled(...) call. tests/unit/previewFeatures.test.js pins the hostname rule and that the released buttons render unconditionally.
WebSocket URLs must end in /ws
Section titled “WebSocket URLs must end in /ws”src/utils/webSocket/connection.js opens ${VITE_WSS_API_URL}/stream (or VITE_WS_API_URL on
plain http). The backend registers its progress socket at /ws/stream, and the AI service
listens on /ws, so all three socket args carry the path:
| Arg | dev | prod |
|---|---|---|
VITE_WS_API_URL | ws://dev-leapfrogapi.logrite.ai/ws | ws://leapfrogapi.deployd.io/ws |
VITE_WSS_API_URL | wss://dev-leapfrogapi.logrite.ai/ws | wss://leapfrogapi.deployd.io/ws |
VITE_AI_SERVICE_WS | wss://dev-aiservice.logrite.ai/ws | wss://aiservice.logrite.ai/ws |
Debugging from the browser console
Section titled “Debugging from the browser console”Two helpers are mounted on window at app start; they exist purely for ad-hoc diagnostics and have no effect on the running app unless explicitly invoked.
window.__cortexDebug
Section titled “window.__cortexDebug”Flag (not a function) that gates verbose [Cortex] console logs in Assistant.jsx. Set it from devtools when investigating chat-state issues:
window.__cortexDebug = true // enable loggingwindow.__cortexDebug = false // silenceEach render of the URL ⇄ conversation effects logs its inputs (urlChatId, prevUrlChatId, currentConversationId, lastPushed, conversationsCount, loading) and the branch it took (clearChat, no-op, selectConversation, direct fetch, push to URL, etc.). Used during the 2026-05-23 fix for the “messages disappear from the chat view but show up in History” bug.
/logrite-cortex URL update wiping the just-sent prompt + response
Section titled “/logrite-cortex URL update wiping the just-sent prompt + response”Shipped: 2026-05-23
Assistant.jsx’s URL→conversation effect depends on [urlChatId, conversations, loading]. When sendMessage finishes the first turn of a fresh chat, the create flow batches setMessages([userMsg, aiMsg]), setCurrentConversation(newConv), and setConversations([newConv, ...]) into one render. After commit:
- URL→conversation effect re-fires because
conversationschanged.urlChatIdis stillnull(the Conversation→URL effect hasn’t navigated yet, and effects run in declaration order). The old!urlChatId && currentConversation?.idbranch calledclearChat()→ wipedmessagesandcurrentConversation. - Conversation→URL effect then navigated to
/logrite-cortex/{newId}. - The follow-up URL→conversation run matched
lastPushedChatIdRef.current === urlChatIdand returned without re-fetching the (now empty) messages.
The conversation row stayed in conversations (so it appeared in the History sidebar), but the active chat view was blank.
Fix: a prevUrlChatIdRef tracks the last value across renders. The !urlChatId branch now only calls clearChat() when urlChatId actually transitioned from a populated value back to no id (i.e., the user clicked New Chat / navigated to the landing). When urlChatId was never set in this render cycle, the effect skips clearChat and lets the Conversation→URL effect push the new id so the follow-up run can match on currentConversation?.id === urlChatId and no-op.
Where the session lives — memory and the session vault (F-13, F-35)
Section titled “Where the session lives — memory and the session vault (F-13, F-35)”No credential is stored in localStorage or sessionStorage any more. The Logrite JWT,
its 30-day refresh token, the OAuth handoff token and the GitHub/Bitbucket provider tokens
(gitToken / bitToken — a non-expiring gho_…) used to be persisted by redux-persist in
persist:user, where any script that ran on the page could copy all of them in one call.
They now live in exactly two places:
| Where | Holds | Readable by page script? |
|---|---|---|
src/utils/sessionVault.js memory | the live tokens, for the life of the page | yes, while the page is open |
HttpOnly cookies lgv_a/t/e/r/g/b on the app’s own origin | the same, so a reload or a new tab can get them back | no |
The cookies are set and read by the frontend’s own nginx at GET|PUT /session/vault
(docker/nginx.conf.template) — no backend involvement, no proxy, no env var. They are
HttpOnly; Secure; SameSite=Strict; Path=/session; Max-Age=30d, so they travel only to that
one endpoint. A PUT replaces the whole vault (a slot not sent is cleared); sign-out sends an
empty PUT.
Boot order (src/main.jsx → src/utils/sessionBoot.js):
takeLegacyCredentials()runs beforepersistStore()and reads any pre-vault copy out of storage — otherwise the first deploy of this change would have signed every user out. The copy is moved into the vault once, then removed from storage.persist:userrehydrates with the credential fields blanked (a redux-persist transform insrc/redux/store.js, keyed onSTORE_SECRET_FIELDS).bootSession()runs as PersistGate’sonBeforeLift: if the user is signed in, it reads the vault and dispatchesrestoreCredentials. Nothing renders until it finishes, or the route guard would bounce a signed-in user to/loginon every reload. A vault that cannot be read (5 s timeout) behaves exactly like an expired session: the first API call 401s and the user signs in again.
CSRF. The vault refuses (403) any request without X-Logrite-Session: 1 — a header a
cross-origin page cannot send without a CORS preflight, which this server never grants — and
any request whose Sec-Fetch-Site is not same-origin, which also refuses sibling
*.logrite.ai subdomains. Values are restricted to [A-Za-z0-9%._~-] (the client
percent-encodes), so a value can neither add a cookie attribute nor break out of the JSON a
GET returns; an invalid value is cleared, never stored.
Unchanged: every caller still gets the token from getBearerToken() (now memory-first),
so the Bearer header still reaches Analytics, Cortex, Warden, MCP and semantic search. In
vite dev and under vitest there is no vault, and the old storage behaviour is kept.
Covered by tests/unit/sessionVault.test.js (16 tests, including the real store’s persisted
blob and the template/client slot parity).
window.__logriteAuthDiagnostic()
Section titled “window.__logriteAuthDiagnostic()”Dumps every auth-related storage value so you can see why a JWT-gated request is failing. Since the session vault, the storage entries read (absent) for a signed-in user on a deployed build — chosenByGetBearerToken is the line that answers whether a token is held (in memory). Output structure:
{ sessionStorage: { accessToken: '(absent)' | '<N chars> abc123…', 'persist:user': { isLoggedIn, userId, token, gitToken, bitToken, emailLoginToken, ... } }, localStorage: { accessToken: '(absent)' | '<N chars> abc123…' }, chosenByGetBearerToken: '<N chars> eyJ…' | '(none — request will short-circuit with NO_BEARER_TOKEN)'}Token values are masked (length + first 6 chars). The chosenByGetBearerToken line answers the practical question: “will the next chatv2 call even leave the browser?”. Implementation lives in src/utils/bearerToken.js.
window.__logriteDebugToggle(true | false)
Section titled “window.__logriteDebugToggle(true | false)”Flips a localStorage-persisted flag (logriteDebug=true) that does two things on every subsequent request:
- The shared
aiServiceApi.jsaxios interceptor attachesX-Debug: 1to every outbound call. parseSSEStreamhandles incomingevent: debuglines fromlogrite-ai-service’s chatv2 and pipes them to a single helper,logServiceDebug(serviceName, payload), which renders a collapsed console group:
▸ [logrite:cortex] request_entry user: 477 clientId: logrite indexName: log-analytics-logrite ...▸ [logrite:cortex] auth_echo sub: 477 loginType: githubX-Debug-Auth (from ai-service) and X-Debug-OsProxy (from the backend OS proxy) response headers are surfaced the same way. semantic-search’s _debug field on error responses is logged automatically.
Flag is persisted in localStorage.logriteDebug so it survives reloads. Turn off with window.__logriteDebugToggle(false). Implementation lives in src/utils/debugConsole.js.
Operating it
Section titled “Operating it”The cli/*-cli.cjs scripts and logrite-manager.js drove PM2 on the host destroyed on
2026-08-03; they manage nothing now. The service is the frontend compose service on each VM
(ssh dev / ssh prod):
C=$(docker ps -q --filter label=com.docker.compose.project=logrite --filter label=com.docker.compose.service=frontend)docker logs -f "$C" # logsdocker inspect -f '{{.State.Status}}' "$C" # statusDeploy by pushing to main (the deploy watcher builds and deploys it on dev), or redeploy
from the console at https://management.logrite.ai — or, from the dev VM:
curl -sX POST http://127.0.0.1:8090/api/deploy -H 'content-type: application/json' \ -d '{"service":"frontend","host":"dev","ref":"main"}'Prod moves only when promoted from the console. See Deployment platform.
The VITE_* values are baked into the bundle at build time, so a prod frontend is built
for prod rather than promoted from a dev image — see the platform page.
Build locally
Section titled “Build locally”cd services/frontend/dev/Logrite-UI # in a MasterControl checkoutnpm cinpm run buildThis is for tests and inspection only; the served bundle is built by the deploy pipeline.
CSS Architecture
Section titled “CSS Architecture”| File | Purpose |
|---|---|
src/index.css | Global CSS, imports responsive.css |
src/App.css | App-wide CSS (loads after index.css) |
src/components/Login/css/ | Login-specific styles |
src/utils/themeClasses.js | Theme utilities |
Key animation classes:
.animate-fadeIn— pure opacity (global).animate-fadeInUp— slide + opacity (chat messages only).animate-border-flow— border animation on KeyFindingsPopover
Icon sizing convention — flex-shrink-0
Section titled “Icon sizing convention — flex-shrink-0”Every square-sized icon (a react-icons component or inline <svg> with a square
w-N h-N / size-N class) carries flex-shrink-0 so it never collapses when its flex
row runs out of space — the classic failure is an icon squished to a sliver next to a long
label in a tight button. A 2026-06-30 sweep applied this to 609 sites across 191 files (the
trigger was the Authenticator button icon). When adding new icons, include flex-shrink-0
on the icon and pair it with min-w-0 on the adjacent flexible text column if that text uses
truncate (see the Quick Actions card grid note above).
Deployment Lock Overlay
Section titled “Deployment Lock Overlay”A global, non-dismissable full-screen overlay mounted in App.jsx that blocks all interaction while a Coolify deployment is running for any user-facing service (frontend-main, backend-main, analyzer-main, analyzer-dev, ai-service). Infrastructure and pipeline deploys (OpenSearch, Data Prepper, etc.) do NOT trigger the lock.
| File | Purpose |
|---|---|
src/components/DeploymentLock/DeploymentLockOverlay.jsx | The overlay UI — gradient card, per-service progress bar, stage text, commit SHA, 2-min cooldown countdown |
src/components/DeploymentLock/useDeploymentLock.js | Hook that drives the phase machine (idle → active → cooldown → idle) |
Polling strategy
Section titled “Polling strategy”Two-tier to keep idle cost near zero:
| Phase | Endpoint | Cadence | Upstream cost |
|---|---|---|---|
idle | GET /api/v1/deployments/signal | 5 min | 1 Coolify call (server cached 5s) |
active | GET /api/v1/deployments + signal | 2s (detail), 5 min (signal) | 26 Coolify calls per detail fetch |
cooldown | none (1-min timer only) | — | 0 |
The signal endpoint is a lightweight { active, count, activeServices } probe so the overlay only mounts when a gated deploy is actually running. Full enrichment (percent, stage, commit) only happens during the active phase.
Behavior
Section titled “Behavior”- Overlay mounts the moment signal reports a gated service is in
queued/in_progress. - Body scroll locked (
overflow: hidden). - Tab / Escape / Enter keys are
preventDefault-ed. - Percent and stage update every 2s from the detail endpoint.
- On signal going idle, the overlay transitions to
cooldown: shows a 1:00 MM:SS countdown and unlocks automatically at zero. - If a new deploy starts during cooldown, state returns to
activeand the cooldown is cancelled.
Service-to-UUID mapping lives in logrite-ai-service/src/config/coolify-apps.ts. The set of gated services is defined at the top of useDeploymentLock.js — edit there to expand or shrink the lock scope.
Authentication flows
Section titled “Authentication flows”Email verification
Section titled “Email verification”The sign-up form posts POST /api/v1/auth/pre-register (useSignupLogic.js), which stores an
Inactive account and mails a 4-digit code (10-minute expiry) and a Verify Email link.
The user lands on /verifyEmail:
- typing the code posts
POST /api/v1/auth/verify-otp{email, otp}; - the emailed link carries
?token=…, exchanged viaPOST /api/v1/auth/verify-magic; - either one verifies the account and returns a session — no separate login.
Logging in before verifying is refused with EMAIL_NOT_VERIFIED, and the page offers a resend
(POST /api/v1/auth/resend-otp). The create-workspace step then finishes through
POST /api/v1/auth/signup (useCreateWorkspace.js). Called directly, /auth/signup creates the
account and workspace and returns a session immediately, with no email — see
Backend → Email verification.
The email uses otp-email-template.ftl and is sent by the backend’s notification service
(mail settings are env on the backend compose service).
Invite Team Member
Section titled “Invite Team Member”There are two separate invite surfaces:
1. Signup flow — InviteTeam.jsx (/invite-team route)
After creating a workspace, new users are taken to an optional “Invite your team” step before being dropped into the app. The form accepts multiple email + role pairs:
| Role | Permissions |
|---|---|
| Maintainer | Full workspace access, manage members |
| Developer | Create/run scans, view results |
| Viewer | View results only |
Submitting calls POST /api/v1/invite with body { workSpaceName, userEmail: [{ userEmail, role }] }. The backend sends invitation emails. The step can be skipped via Skip this step.
2. Generator flow — InviteTeamMembers.jsx (/generator/invite-team-members)
After creating a new project via the generator flow, users can invite team members to collaborate on it. The form renders a table of invited members with email, role, and individual Invite buttons. Mode-aware: the route prefix is /generator or /compliance depending on which flow the user is in.
3. Settings → Workspace → Team Members management (Settings.jsx + TeamMembersList.jsx)
The standing management surface for an existing workspace. GET /api/v1/workspace/{id}/members
returns both real members (status: "ACTIVE") and pending invitations (status: "INVITED",
carrying invitationId, no userId); mapMember maps both into the list. Pending rows get an
Invited badge. Edit/Remove are gated to Maintainers via canManage={isMaintainer}, and
Edit is hidden for pending invitations (you can revoke but not edit them). The trash action
calls DELETE …/members/{userId} for real members but DELETE …/invitations/{invitationId}
for pending invites. EditMemberModal role options are Maintainer/Developer/Viewer (previously
the non-existent Admin/Moderator); default new-invite role is Developer.
Backend endpoint: POST /api/v1/invite (JWT-gated, Maintainer-only) — validates emails +
roles, adds existing users immediately, creates PENDING user_invitations rows for unregistered
emails, sends invitation emails, returns 200 OK. See backend RBAC.
Settings page (2026-06-25 revamp)
Section titled “Settings page (2026-06-25 revamp)”/settings uses the AI-Warden card/pill-tab design language across three tabs: General · Workspace · Projects. A global search box (⌘K) spans all three tabs and jumps to the matching section.
Workspace description (full-stack)
Section titled “Workspace description (full-stack)”Workspaces carry an optional description.
- Backend:
Workspace.descriptionis exposed viaWorkspaceInfoResponse(so it flows to the frontend on login /userProfile);POST /api/v1/auth/workspaceaccepts an optionaldescriptionon create;PUT /api/v1/workspace/{id}(Maintainer-gated) updates it. No migration — the column existed andddl-auto: updateis on. - Frontend: editable on the Workspace tab (Save gated on change + 500-char counter; Maintainer-only — read-only for others), shown on the Create-Workspace page (optional field), on the Select-Workspace cards, and in “Your Workspaces” (hidden when empty).
Multi-device sessions
Section titled “Multi-device sessions”The old static “This device” card is now a live Devices & sessions list.
- Backend:
UserSessionentity/repo (table auto-created),UserSessionService. Email login/signup and OAuth login mint ajticlaim and record a session row (device label parsed from user-agent, IP, login type, timestamps);/auth/refreshpreserves thejti.GET /api/v1/user/sessionslists the user’s devices (current flagged);DELETE /api/v1/user/sessions/{id}revokes one (ownership-checked).JwtAuthenticationFilterrejects a token whose session was revoked — fail-open, so any error falls back to normal auth and can never lock a user out. - Frontend: lists each device with “This device” badge + per-device Sign out; falls back to local device detection when no server sessions exist (e.g. tokens minted before the feature).
- Location (since 2026-09-28): each device shows where its IP address is, next to the IP:
49.206.134.106 – Bengaluru, Karnataka, India. It works for IPv4 and IPv6.- Where the data comes from: the backend looks the IP up against the DB-IP City Lite database baked into the backend image at
/app/geo/dbip-city-lite.mmdb(GeoIpLocator). The lookup is local, so no user’s IP is sent to a third party. - When it is looked up:
UserSession.locationis filled once, when the session row is written (@PrePersist). Sessions written before this change are located when the list is read. The API field islocationonGET /api/v1/user/sessions. - When there is no location: a private or loopback address, a hostname, an address the database cannot place, or an image built without the database. The row then shows the IP alone, as before. The lookup never does a DNS query and never throws on the login path.
- Refreshing the database:
Dockerfile.cidownloads it in a layer before the jar.ops/notify/prebuild/backend.shpassesGEOIP_MONTH=$(date -u +%Y-%m), so the database refreshes once a month and is a cache hit in between. If DB-IP is unreachable the build tries the previous month, then builds without the database. It never fails the deploy. - Attribution: DB-IP Lite is CC BY 4.0. The page therefore shows “Locations are approximate. IP geolocation by DB-IP” (linking to db-ip.com) whenever at least one location is displayed. Keep that link if you restyle the card.
- Accuracy: it is the IP’s registered location, usually the ISP’s city, not the user’s.
- Where the data comes from: the backend looks the IP up against the DB-IP City Lite database baked into the backend image at
Connected GitHub organizations
Section titled “Connected GitHub organizations”The Workspace tab shows a Connected GitHub organizations card reusing the GitHub App APIs (GET /api/v1/github/app/install/installations, POST /api/v1/github/app/install/start): each installation’s org/account, type, repo-selection, Linked status and a Configure link. Maintainers get Add repositories (redirects to the GitHub App install URL); the empty state offers Connect GitHub / Add a project. A note discloses the access granted (scopes repo, read:org). No new GitHub scopes.
Projects tab
Section titled “Projects tab”Project rows are clickable and open the project in its owning flow — Generator (logType.id === 1) or Compliance (>= 2) via setCurrentProject/setSelectedProject → /generator/project/{id} or /compliance/project/{id}. Per-flow badges open that specific flow; the project description + a chevron are shown; delete uses stopPropagation.
Profile uses an initials avatar (no image upload — images aren’t stored server-side). OAuth accounts can Set a password (POST /api/v1/user/set-password) to also sign in by email. Team Members loads real workspace members (GET /api/v1/workspace/{id}/members). Email validation is shared via utils/validateEmail.js. GitHub noreply addresses are hidden from the Email card.
AI Warden UI changes (2026-06-17–18)
Section titled “AI Warden UI changes (2026-06-17–18)”Several UI changes were shipped to the /ai-warden page:
Overview tab (absorbed the former Violations tab)
Section titled “Overview tab (absorbed the former Violations tab)”The standalone Violations tab was removed — the Overview tab is now the single traffic + compliance log:
- Columns: Date · Time · Provider · Model · Verdict · Frameworks · Developer · Latency. The Verdict badge is ALLOW (green) / NOTIFY (amber) / BLOCK (red);
RequestsTable.effVerdict()derives NOTIFY fromcompliance.notify/ a response-scope violation for back-compat with rows captured before the relay tagged NOTIFY directly. Frameworks render asTemplateChips (first 3 + overflow count); Developer is theX-Warden-Usercaller. - Verdict filter pills: All / Allowed / Notify / Blocked quick pills above the table (next to provider / model / date filters). Filter to Blocked or Notify for exactly what the Violations tab used to show. Block/flag deep-links from the Step-3 console and Business Rules now land on Overview filtered to Blocked.
- Detail modal: clicking a row opens
RequestDetailDrawer(centered modal) — the single comprehensive view: verdict + decision layer + developer, a Decision review breakdown (business / compliance / final badges fromreview.*), the AI judgement (judge verdict, model, escalation, plain-Englishjudge.reasoning), matched rules, frameworks, redacted previews, the full raw request/response, and — for flagged (block/notify) requests — a “Mark as false positive” suppress button (wardenApi.suppressViolation, keyed byrequestId; refreshes on success). The per-violation suppress that lived in the old Violations tab now lives here. ViolationsView.jsx/ViolationDetailDrawer.jsxare no longer mounted. The per-violation suppress moved into the Overview detail modal (above); the standalone replay-cURL drawer was retired (the Step-3 console’s live cURL preview covers replay).
Settings & Keys tab (renamed from “Connect & Keys”, now the last tab)
Section titled “Settings & Keys tab (renamed from “Connect & Keys”, now the last tab)”- Delete credential button: each stored credential now has a trash icon delete button with inline confirmation.
- Credential decryption error state: if the backend cannot decrypt a credential (e.g.
WARDEN_KMS_MASTER_KEYwas rotated after the credential was stored), the UI shows “Key could not be decrypted — re-store the credential” in place of the snippets. - Live relay test console moved: the Step-3 test panel was extracted to its own component (
RelayTestConsole.jsx) and relocated to the bottom of the Business Rules tab (below the rule-sets table) — Settings & Keys now ends after the keys table.
Business Rules tab
Section titled “Business Rules tab”- Live relay test console (below the business-rules policy table):
RelayTestConsole.jsx— a dev-friendly request console so you can test a policy right where you build it. Pick a relay key from a dropdown (it reveals the plaintext as the Bearer token; a paste-a-key fallback remains), the provider auto-derives from the key’s credential, and you set an editable Model and Message (with preset scenario chips). A live cURL preview updates to reflect the selected key/provider/model/message (copy button). Send test request fires a real request throughwarden.logrite.aiand shows the ALLOW/BLOCK/ERROR verdict + HTTP status + the full (scrollable) response inline, with “View in Overview” deep-links. Self-contained (own state + key-reveal logic). - Since 2026-10-15 the tab body is
PolicyTableEmbed(business-rules policies on AI traffic) and the sharedPolicyEditorDrawer; the sampled-mode slider and thepolicyTexttextarea are gone. The workspace-wide editor is the Rules & Policies page (/policies, sidebar after Compliance,src/components/RulesPolicies/, API clientservices/policiesApi.js) — see Rules & Policies. Configure Rules, Your own rules (useCustomRules→policiesApi), the collector Business Rules tab and AI Coding → Create rule all edit the same policies.
LogriteLoader fix
Section titled “LogriteLoader fix”The LogriteLoader component’s pulse animation was replaced with a CSS border-t spinner (same Logrite orange #E64100) after the pulse animation caused a full-page layout flash on every tab switch within the AI Warden page.
Settings page (2026-06-25)
Section titled “Settings page (2026-06-25)”The user Settings page (src/components/SidebarPage/Settings.jsx) is split into three tabs — General, Workspace Settings, Projects — each its own component under src/components/SidebarPage/settings/ (GeneralSettings.jsx, WorkspaceSettings.jsx, ProjectsSettings.jsx).
Global settings search
Section titled “Global settings search”Previously the header search box only filtered the Projects tab and did nothing on General/Workspace. It is now a global search across all three tabs:
- Index:
src/components/SidebarPage/settings/settingsSearchIndex.jscatalogs every setting (Appearance, Sidebar, Profile, Email, Password, Default Branch/Template, AI Warden guide, Workspace Logo/Name/Description, Your Workspaces, Team Members, Projects). Each entry has atab, ananchorId, andkeywords.searchSettings(query, projects)matches the query against each entry’s label/description/keywords and also surfaces individual matching projects by name/platform. - Anchors: each tab component carries
id="setting-*"attributes (e.g.setting-appearance,setting-password,setting-team,setting-projects) on its section wrappers so results can scroll to them. - Dropdown: typing opens a results dropdown (each row shows the setting + a badge for its owning tab). Empty result set renders “No matches found”. Selecting a result switches to the owning tab (short 300 ms load vs. the 1.5 s manual tab switch), then scrolls the section into view and briefly outlines it with the Logrite orange (
#E64100).⌘K/Ctrl+Kfocuses the box;Escapeand outside-click close the dropdown.
Email display — hide GitHub privacy addresses
Section titled “Email display — hide GitHub privacy addresses”General > Profile > Email lists the account’s verified addresses (read-only, synced from the sign-in provider via getUserEmails() → GET /user/emails). GitHub privacy/noreply addresses (*@users.noreply.github.com, [email protected]) are now filtered out of the display and the active-email selection, so only the user’s real verified inboxes appear. If filtering leaves no visible addresses the Email section is hidden.
Redesign + Username + password-reset indicator (2026-06-25)
Section titled “Redesign + Username + password-reset indicator (2026-06-25)”The Settings page was rebuilt to match the AI Warden design language:
- Pill tabs. The underline-text tabs were replaced with AI-Warden pill tabs (
bg-[#E64100]active, hover-bg inactive,rounded-lg). Tab content is constrained tomax-w-4xlso forms no longer sprawl across the full width. - Card sections. Every section across all three tabs is now a
rounded-xl bordercard (bg-[#13161B] border-[#22262F]dark /bg-white border-[#E5E7EB]light). General’s Appearance + Sidebar toggles collapse into one compact “Preferences” card (left label / right switch rows). - grid-3 profile row. First name · Last name · Save profile sit on a single
grid-cols-3row on desktop (stacks on mobile). Password and Defaults use 2-column grids. - Username (read-only). A new disabled Username card sits between Profile and Email, showing the provider handle (Redux
gitUsername/bitUsername, falling back to the email local-part). Added tosettingsSearchIndex.js(anchorId: setting-username). - Password-reset email indicator. The active address carries a
🔑 RESETtag whose hover tooltip explains it’s where Forgot Password emails go (no separate banner). It’s gated onhasPassword— only email/password (signup) accounts have a password to reset, so OAuth (GitHub/Bitbucket) users don’t see it. The reset address is the server-selectedone, falling back to primary, then first. Single-email accounts show the tag next to the field; multi-email accounts show it on the active row of the list.
The change-password flow (POST /api/v1/user/change-password) attaches the Bearer token via the axios interceptor; client guards mirror the backend, which rejects OAuth-only accounts, enforces the shared password policy, BCrypt-verifies the current password, blocks reuse of the last two passwords, and returns 200 SUCCESS. The section only renders when hasPassword === true. (Note: the local SQLite perf-test profile can’t boot far enough to live-test it — post-startup Postgres-only migrators using GREATEST/setval/TIMESTAMPTZ/now() abort on SQLite; this is a harness limitation, not a backend bug.)
Shared password policy on the client (2026-07-01)
Section titled “Shared password policy on the client (2026-07-01)”Password rules are now a single source of truth in src/utils/validatePassword.js (validatePassword → first violation message, getPasswordChecks → per-rule booleans, PASSWORD_REQUIREMENTS → the checklist items), mirroring the backend PasswordPolicy. Every rule requires 8+ chars, one uppercase letter, one number, and one special character. It is wired into all four password entry points:
- Signup (
SignupForm.jsx+useSignupLogic.js) and Reset password (ResetPassword.jsx) render the live green/grey checklist offgetPasswordChecks, now with four items (added “one uppercase letter” and “one number” alongside the existing length + special-character rows). - Settings → Password (
SecuritySettings.jsx, change and set-password) validates withvalidatePasswordbefore calling the API and shows the same four-item checklist under the new-password field.
The “must differ from your last two passwords” rule is backend-only (the client has no access to previous hashes) and surfaces via the API error message.
Full-width responsive grid + new cards; image uploads removed
Section titled “Full-width responsive grid + new cards; image uploads removed”- Layout. The
max-w-4xlcap was dropped — each tab now lays its cards out in a responsive grid (grid-cols-1 lg:grid-cols-2) that fills laptop width, with wide cards (Profile, Email, AI Warden guide) spanning both columns. The Projects list is a 2-column grid. - Image uploads removed. The Workspace logo uploader was deleted — images aren’t stored server-side, so it was dead UI. Workspace and Profile now use an initials avatar (no upload). Where the logo was, a read-only Workspace info block shows your role, member count, and workspace ID (
anchorId: setting-workspace-info). - New General cards (frontend-only, real data):
- Account & sign-in (
setting-account): sign-in method (GitHub/Bitbucket/email), active email, workspace role, user id, and a working Sign out button (dispatch(logout())→/login). - This device (
setting-device): current session — browser + OS parsed fromnavigator.userAgent, and sign-in time from the ReduxloginTimestamp. (A full multi-device list with revoke would need server-side session storage; JWT auth is stateless today.)
- Account & sign-in (
- All new sections are in
settingsSearchIndex.js, so global search reaches them.
Set a password (OAuth accounts) — 2026-06-25
Section titled “Set a password (OAuth accounts) — 2026-06-25”OAuth (GitHub/Bitbucket) accounts can now add a password so they can also sign in with email (the modern “login methods” pattern). The General > Password card branches on hasPassword:
hasPassword === true→ Change password (current · new · confirm), as before.hasPassword === false→ Set a password (new · confirm, no current — the user is already authenticated via JWT and has no existing password). Copy explains the OAuth login keeps working.
New backend endpoint POST /api/v1/user/set-password (UserController.setPassword → UserService.setPassword, DTO SetPasswordRequest{ newPassword }): JWT-gated, rejects accounts that already have a password (400 → use change-password), enforces ≥8 chars, BCrypt-encodes and saves. No loginType change is needed — login goes through Spring’s authenticationManager on email+password (it doesn’t gate on loginType), so a freshly-set password immediately enables email sign-in and the Forgot Password flow. FE wrapper setPassword() in constant.js; on success the card flips to change-mode and the RESET email tag appears (the reset indicator is gated on hasPassword).
Deferred (need backend, intentionally not shipped as fake UI): notification preferences (new user-settings columns), multi-device session management (session table + revoke endpoint), and a destructive Danger Zone (delete account / leave workspace endpoints).
Sidebar & email-validation fixes (2026-06-25)
Section titled “Sidebar & email-validation fixes (2026-06-25)”Sidebar — Agents active state on the detail route
Section titled “Sidebar — Agents active state on the detail route”The Sidebar.jsx isPathActive() helper used an exact pathname === path match for most items, so the Agents nav item highlighted on /agents but went inactive on the detail route /agents/:agentId. It now matches pathname === "/agents" || pathname.startsWith("/agents/") — covering the list and any agent detail, while deliberately not matching the separate /agents-flow item (a bare startsWith("/agents") would have).
Email validation — shared, trimming, less-strict regex
Section titled “Email validation — shared, trimming, less-strict regex”All four auth/invite flows (useLoginLogic.js, useSignupLogic.js, ForgotPassword.jsx, InviteTeam.jsx) previously each carried the same regex ^([a-zA-Z0-9_\-\.]+)@([a-zA-Z0-9_\-]+)(\.[a-zA-Z]{2,5}){1,2}$. It capped the TLD at 5 letters (.online/.software failed), rejected +tag local parts, and — because forgot-password and invite tested the raw, un-trimmed input — flagged a valid address as “Invalid email” when it carried stray leading/trailing whitespace from copy-paste/autofill (this is what rejected [email protected]).
Replaced by a single source of truth: src/utils/validateEmail.js exporting EMAIL_REGEX (/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/) and isValidEmail(email), which trims before testing. All four flows now call isValidEmail().
AI features — copilot, BYO-LLM, AI Agents (2026-07-19)
Section titled “AI features — copilot, BYO-LLM, AI Agents (2026-07-19)”- Global Cortex copilot. The
CortexPanelslide-over was already mounted app-wide inDashboardLayout, but the “Ask” trigger only rendered on/dashboard,/generator,/compliance.routeAgent(DashboardLayout.jsx) andmobileRouteAgent(Sidebar.jsx) now always return an agent — a context-matched one where it fits, else the universal “Ask Cortex” fallback (dashboard-agent) — so the copilot is reachable on every page, including AI Warden, Platforms, M-26-14, Settings, and Agents. - Settings → AI Model (BYO-LLM). New tab (
settings/AIModelSettings.jsx+aiModelApi.js, hitting the backend/api/v1/ai-model/*). Store your own provider key once (OpenAI / Anthropic / Groq / Azure / Mistral / OpenAI-compatible), make it Cortex’s default, or switch back to Logrite’s built-in model. A status banner states which model + provider is answering (“your key ••last4” vs “Logrite built-in”). Mirrors the AI Warden Connect wizard. - AI Agents page (
/ai-agents). NewAIAgents/AIAgents.jsx— list, a two-step NL AgentBuilder (describe → parse → review editable config + read-only tool checkboxes → create), run-on-demand, and an execution-history timeline (the agent’s answer + the exact tool calls it made, with success-rate stats). Sidebar nav entry “AI Agents” (distinct from the collector Agents page). See AI Agents Overview.
Recent changes (2026-07)
Section titled “Recent changes (2026-07)”- WS scan-error screen (2026-07-23) — any analyzer WebSocket frame whose text starts
with
ERROR:(or arrives stampedmessageType: "error") now terminates the analyzing loader and renders the error screen with the backend’s raw message (“Scan Failed”, retryable — Try Again stays available). Previously such frames (e.g.ERROR: checkout: Failed to clone …) arrived asmessageType: "success"and the UI spun forever. Handled inuseAnalyzerLogic(attached view) andScanProgressContext(detached/floating panel). - Real invite flow (2026-07-23) —
InviteTeamMembers(Project Details → + chip) now lists real workspace members fromGET /workspace/{id}/members(pending rows get an INVITED badge) and sends invites viaPOST /api/v1/invite({workSpaceName, userEmail:[{userEmail, role}]}). Full client-side validation: format (sharedvalidateEmail.js, now rejects consecutive/leading/trailing dots), duplicates in the form, already-member / already-invited. “Proceed” sends any filled rows first, then navigates. Hardcoded placeholder names removed here and in the Project Details avatar chips (useProjectDetailsLogicfetches real member initials, +N overflow). X-Vcs-Tokenforward (2026-07-23) —usePushStatusand the pre-scan safety gate inuseInitiateScanLogicsend the browser-heldgitTokenasX-Vcs-Tokento/push-status, so email-login users whose PAT has write access aren’t blocked from scanning (see backend page for the server-side branch).- Attach-key dropdown portaled (2026-07-23) — the AI Warden rule-set attach-key menu
renders via
createPortalto<body>with fixed positioning: opens below, flips above when it doesn’t fit below but fits above, clamps to the viewport, closes on outside scroll/resize. Was clipped inside the table’soverflow-x-autocontainer. - Sidebar width 280px (2026-07-23) — expanded width 260→280px and the search-row
inbox tile is
flex-shrink-0(was clipped on some screens). The tile is now a live link to/notificationswith an unread-count badge. - Generator early-block + connect — the “Initiate Log Scan” button (Project Details,
Generator flow) disables when the repo has no push-capable credential, with a themed
custom tooltip (reason + Connect CTA).
ConnectRepoAccessModaloffers both OAuth (personal token) and GitHub App install, auto-highlighting the one that fits the repo owner. Backed byGET /api/v1/project/{id}/push-status(fail-open — a probe failure never blocks scanning). - Component rename —
src/components/OpenSearch_Analytics→src/components/Analytics(the “OpenSearch → Analytics” naming migration; env reads now preferVITE_ANALYTICS_*with a legacyVITE_OPENSEARCH_*fallback). - Stale lazy-chunk recovery —
lazyWithRetryreloads once on a post-redeploy chunk load failure (“Failed to fetch dynamically imported module”), guarded against loops. - Cortex right-panel polish — professional response spacing (one blank line between paragraphs, block margins govern the rest), a near-max-context “start a new chat” toast, and a persisted index/project “skip” sentinel so follow-ups don’t re-open the dialog.
- Cortex empty-state — the “Start with an agent” cards were removed; agent selection now lives solely in the input-field dropdown.
- Project Details breadcrumb — the “Projects” crumb is now a searchable project-switcher
dropdown showing the current project name; the equivalent dropdown was removed from the
page body (
NavigationBreadcrumbgained a custom-nodecrumb).
Analytics plugin sections enabled, Notifications activity bridge, AI Model tab restyle (2026-07-24)
Section titled “Analytics plugin sections enabled, Notifications activity bridge, AI Model tab restyle (2026-07-24)”Committed to main (pending Coolify frontend redeploy).
All Analytics Browse-dropdown sections enabled. The 14 previously-commented
browseOptions entries in SidebarPage/Analytics.jsx are now live — the plugin group
(AI Search Flows, Forecasting, Machine Learning ml-commons, Search Relevance,
Observability, Security, Reporting, k-NN) and the Management group (Dashboards
Management, Index Patterns, Index Management, Cluster Management, Query Workbench, Dev
Tools). Each already had a lazy route + native React view + logic hook + API service, so
uncommenting the dropdown entry was the only change. Verified against the live cluster:
the backing plugins (opensearch-ml, -knn, -alerting, -anomaly-detection,
-index-management, -observability, -security, -reports-scheduler, -sql,
-neural-search, -flow-framework) are all installed on opensearch:2.19.3.
Two former static shells wired to real endpoints:
- Search Relevance (
SearchRelevanceView.jsx+ newhooks/useSearchRelevanceLogic.jsservices/searchRelevanceApi.js) — runs both query bodies against the selected index via the sharedosHttpproxy client (POST {index}/_search) and renders real hit counts,max_score, per-hit_score,took, and_sourcepreviews. The index picker is populated from the user’s visible indexes (getVisibleIndexNames). JSON is validated client-side before any request.
- AI Search Flows (
SearchFlowsView.jsx+hooks/useSearchFlowsLogic.js+services/searchFlowsApi.js) — lists the cluster’s real_search/pipeline+_ingest/pipelineregistries as flow cards (processor types become the flow nodes), replacing the hard-coded sample list. Refresh re-fetches; empty/error/loading handled.
Notifications — live activity bridge. The feed (already its own route at
/notifications) previously had only two client-side producers (analysis-complete,
PR-created). Added:
upsertNotificationreducer innotificationsSlice.js— insert-or-update keyed on a caller-supplied stable id, preserving the user’s read state so re-polling never duplicates or re-marks-unread.src/hooks/useNotificationSync.js— mounted once inDashboardLayout, a best-effort 5-minute poll (each source independentlytry/catched) that bridges AI Warden violations (GET /api/v1/warden/requests,response.violationCount), OpenSearch alerting alerts (fetchAllAlerts, active/error only), and anomaly-detection results (fetchDetectorsWithStats,anomalyCount24h) into the feed with deterministic ids (warden_<requestId>/alert_<id>/anomaly_<id>). ExportssyncNotificationsOncewhich the page’s Refresh button also calls.- New feed types
analytics_alert+anomaly— icon/badge/left-border, filter tab, stat card, legend entry. The list now sorts newest-first (synced items can arrive out of insertion order). - Redirects fixed (the “notifications don’t redirect” root cause — no producer ever set
a target):
analyzer_completenow setsmetadata.redirectTo = /scan-results/<scanId>; synced items deep-link to/ai-warden//analytics/alerting//analytics/anomaly-detection. A row with no target stays intentionally non-clickable. - The header/mobile bells remain hidden by design; the sidebar inbox tile stays the sole entry point (with unread badge).
Sidebar search input (SidebarPage/components/SidebarSearch.jsx) — added the missing
border width utility (it declared a border colour but no width, so the border was
invisible next to the inbox tile) plus the same orange hover/focus accent.
Settings → AI Model tab (SidebarPage/settings/AIModelSettings.jsx) — restyled onto
the shared settingsTokens design system (rounded-2xl gradient cards with hover-lift,
text-base card titles, text-sm focus-ring inputs, primaryBtn/subtleBtn, section
icon chips) and the app CustomSelect for the provider picker, so it matches
General / Workspace / Projects instead of its former ad-hoc local palette.
Analytics: 429 rate-limit fix, unified error screens, sidebar & Dashboards sort (2026-07-17)
Section titled “Analytics: 429 rate-limit fix, unified error screens, sidebar & Dashboards sort (2026-07-17)”Root cause of Error fetching indices: AxiosError … 429. The Analytics views issued
every OpenSearch call on the bare global axios, with nine independent copies of the
same _cat/indices request — no cache, no dedup, no backoff. One view load fired the
listing several times; the create-index-pattern modal fired a full cluster listing per
keystroke (its 300 ms debounce only collapses fast typing, and the listing is
pattern-independent — matching was always client-side). Cloudflare answered that volume
with 1015.
It was also self-sustaining: OpenSearchContext.checkConnection read any failed
health check as an outage → isConnected=false → the health poll dropped from 10 min to
60 s, aimed at the zone that had just throttled us, while hooks keyed on the resulting
flip re-fetched their indices. The rate limit fed itself.
Analytics/services/osClient.js(new) — the shared transport.getCatIndices()gives a 30 s TTL cache + single-flight: nine concurrent callers now produce one HTTP request (verified).osHttpretries only what a retry can fix (429 + 502/503/504 — a 403/404 is deterministic and retrying it just burns quota), honorsRetry-After, and uses jittered exponential backoff. A 429 arms a process-wide cooldown: every subsequent request through the client waits it out, because backing off on one request while fifty others sail past the same edge is not backing off.invalidateIndicesCache()is called by the index create/delete/open/close mutations so the list is never stale after a write.mapWithConcurrency()caps per-item fan-out (OS_FANOUT_LIMIT = 4) — anomaly-detection profiles previously fired one unbounded request per detector.contexts/OpenSearchContext.jsx— a 429 now means reachable but throttled: it no longer flipsisConnected, the poll skips ticks while throttled,handleApiErrornever schedules its 5 s recheck for a 429, and the context value isuseMemo’d (it was re-rendering the whole Analytics subtree on every health tick).knnApi.js/CreateIndexPatternModal.jsxadditionally gained theAuthorizationheader they never sent — both used a barefetch()and silently depended on the proxy not enforcing JWT (OPENSEARCH_PROXY_ENFORCE_JWT).
Unified error screens. Views used to catch { console.error(e); setRows([]) }, so a
429/500/503 rendered as an empty dashboard — “the backend is down” and “you have no
data yet” looked identical, and users hunted for data that was never missing.
Analytics/utils/analyticsError.js(new) —describeAnalyticsError()maps the full 4xx/5xx space (plus network/timeout) to{status, type, title, message, suggestion, retryable}. Keyed offresponse.status, not string-matching the serialized error as before —errorStr.includes('404')misfires on an index namedlogs-404or a 4045 ms timing.components/common/OpenSearchConnectionError.jsx— already existed but was a dead import, never rendered;handleApiErrorhad zero call sites. Now rewired onto the normalizer, withfullHeight={false}for in-view use, Retry hidden whenretryableis false, and the “Analytics ●” status dots shown only for reachability failures (a red dot on a 403 sends users chasing an outage that isn’t happening).- Wired into Discover, Visualize, Dashboards, Index Management, Security and
Observability. The takeover is gated on
error && !loading && list.length === 0, and the error state is owned by the primary list loader only — secondary loaders hit different backends and can fail while the list is legitimately empty, which would show “Couldn’t Load Dashboards” to a new user whose list loaded fine. Tabbed views key the error per tab so one failing tab can’t blank a sibling.
Dashboards table — “Last updated” never sorted to the top. lastUpdated was stored as
toLocaleString() display text, and the comparator parsed it back with new Date().
That only works in en-US: new Date("17/07/2026, 15:04:21") is Invalid Date in every
day-first locale, so every row scored 0, the comparator returned 0 for every pair, and
the table silently fell back to API insertion order. Now sorts on a raw ISO updatedAtRaw
kept alongside the display string — sort the machine value, show the human one.
AI Warden — the requests loader started loading: false while its mount effect was
already fetching, so the first frame rendered requests=[] and flashed “No relay traffic
captured yet” at users who had traffic. Starts true now, and the auto-broaden path holds
the loader across the hand-off to the widened re-fetch. A failed load no longer claims the
relay is idle.
Sidebar — isExpanded = pinned && open || isHovered meant clicking sidebar-pin-toggle
to collapse did nothing visible: the cursor is still on the sidebar, so hover instantly
re-expanded it. Collapsing now suppresses hover-expand until the pointer leaves; hover-to-peek
re-arms from the next hover on.
Configured Rules — the rules a finished scan ran with
Section titled “Configured Rules — the rules a finished scan ran with”Shipped: 2026-09-17
The Configured Rules button on the scan results page (formerly “Configure Rules”; the past tense is deliberate — the rules it opens have already been used) had three separate defects.
It was dimmed on the analyzer-success page. A generator-mode run does not mark itself complete
when the analyzer half finishes: useScanMessageHandlers’s END Scan ID: handler sets
analyzerComplete and gates isComplete on mode === 'compliance', because a generator run still
has its generator half to do. The button read isScanRunning
(scanProgress.isActive && !scanProgress.isComplete), which is exactly that state — so the page
that is parked, waiting for the user to press Generate Logs, disabled the one button that only
navigates. ScanResultsHeader.jsx now uses isConfiguredRulesDisabled, which names the two states
that really are in flight (isAnalyzing, isGeneratorInProgress) and lets every parked one
through. The neighbouring Re-Initiate predicate (effectivelyRunning) would not have fixed it:
it excludes generator success, not the parked analyzer-success state.
It could show the wrong rules. The navigation carries the scan’s template id
(scanResults.templateId — the scan-profile policy the scan is foreign-keyed to; before 2026-10-15 a project_analyzer_rules_config row),
but useRulesHelpers.selectedTemplate and the compliance sync effect both resolved the template by
name. A name lookup misses when the navigation fell back to "Default" before the project’s
template list had loaded, and a miss renders the framework’s catalogue under the template’s name
rather than raising anything. src/utils/templateResolution.js now resolves id first, name
second; the id hint is dropped as soon as the user picks a different template, because a stale id
beats the name. logTypeId and scanId are carried through the navigation too.
The read-only rows swallowed the click. Every control carried disabled, and a disabled
control produces no click event at all — so the gesture that means “I want to change this” reached
nothing, and the user got a dimmed row with no explanation. The controls are aria-disabled now
(same semantics for assistive tech, same treatment) and the click is answered:
- Notice — “These rules have already been used”, naming the template and the scan, with Save as a new template.
- Save as Template — the existing naming modal. In this flow
handleConfirmTemplateSavetakes anonSavedcallback that replaces the usual redirect to the project page. - Follow-up — Run a new scan (navigates to the project page with
state.openScanModal, which opens the Initiate Scan modal) or Back to the last scan (returns to that scan’s results, unchanged).
The edit itself is still refused. tests/unit/configuredRulesReadOnly.test.js asserts both halves,
because a regression that also applied the edit would otherwise pass.
Rule suggestions: Apply all, and where the line is shown
Section titled “Rule suggestions: Apply all, and where the line is shown”Shipped: 2026-09-17
The suggested severity and log level for a compliance rule are the analyzer’s
(src/core/compliance/ruleSuggestionTable.ts), served at GET /compliance/rule-suggestions and
proxied by the backend at GET /api/v1/warden/library/rule-suggestions. The frontend keeps no copy.
- Apply all. After the user applies suggestions on two distinct rules, the page offers the
rest — once per visit. It counts rules, not clicks (applying the same row twice is one decision),
and the number it states is the number of rows that would actually change, not the number
that have a suggestion. Rows that are switched off are skipped rather than silently enabled.
See
useSuggestionApplyAll.js. - Where the line appears. Only on a user’s own template. On a built-in framework the
catalogue’s stored level already is the suggested level (the backend seeds it, and
ComplianceRuleLevelBackfillmoves the rows that predate that seeding), so repeating it under 200 descriptions is noise. It is also hidden on the read-only view, where it could not be acted on.
Initiate Scan: the framework and the template now agree
Section titled “Initiate Scan: the framework and the template now agree”Shipped: 2026-09-17
Opening the scan modal makes two choices: which framework (pickDefaultFramework) and then
which template within it (pickTemplate). The template half has always preferred the template
this project was last scanned with, falling back to the newest by id. The framework half fell
through to the first framework in served order with any template — an ordering of the
catalogue, related to nothing about this project — so on a project with a SOC 2 template and a
newer HIPAA one, the modal opened on SOC 2 and then correctly selected the newest SOC 2 template.
That is the reported “it picks the latest created template but not that compliance rule”.
The arbitrary step is what changed. Order now:
- the user’s Settings default, if this project has a template for it — an explicit preference is never overruled by where they happened to be last, and it could not have caused the symptom anyway (it constrains the template half to the same framework);
- the framework of the template
pickTemplateis about to select; - the first framework, in served order, with any template;
- otherwise nothing, and the modal’s empty state says so.
The chosen log level, end to end
Section titled “The chosen log level, end to end”Shipped: 2026-09-17
A level is written to loggingLevel — the field that means it — and never to categoryName.
AnalyzerConfigResponseDTO carries loggingLevel, so a save transports the user’s choice instead
of overwriting the library category (phi, audit_coverage) with whatever the picker showed.
RuleLogLevel.resolve still reads categoryName as a fallback, so rows saved the old way keep
their level. From there the scan path reads RuleLogLevel.of(templateRule) into
AnalysisRule.loggingLevel, and the analyzer emits at that level.
The measurement that made this worth re-testing. Queried against the dev database on
2026-09-17: of 715 saved templates in project_analyzer_rules_config, zero carried a
loggingLevel on any rule, and every stored rule looked like this:
{ "enable": true, "priority": "Medium", "categoryName": "INFO", "strategyName": "Log User Authentication Events", "canonicalName": "soc2_authentication" }categoryName: "INFO" is a level in the column that means category, and it was INFO on every
row of every template — so the analyzer was handed INFO for everything, correctly, for what was
actually stored.
tests/unit/chosenLevelReachesTheWire.test.js mounts useRulesHandlers and useTemplateSaving
the way the page mounts them, over the real Redux slices, and reads the level off the actual
axios POST body — rather than reproducing the merge and the payload row inside the test, which is
how a test stays green while the hooks it was copied from drift.
Production
Section titled “Production”- URL: https://logrite.deployd.io
- Served by nginx as a static build
- Frontend proxy routes
/apirequests to Backend
Suggested level and priority on Configure Rules (2026-09-17)
Section titled “Suggested level and priority on Configure Rules (2026-09-17)”Each SOC2 / HIPAA / GDPR / PCI rule shows a recommendation under its description:
Log Leak: PHIHIPAA §164.312(b) — Do not write PHI to logs.[Suggested] level WARN · priority High Apply- The line’s tooltip gives the reason — an override’s own regulatory citation, else a sentence naming the derivation.
(matches yours)is appended when the suggestion already equals what is recorded.Applyappears only when applying would change something, and calls the same handlers a manual click does. That is the only path from a suggestion to the server: what gets stored is always the user’s choice.- In the level buttons, a suggested level is a dashed outline and a chosen one is filled, so the two can never be mistaken for each other.
- Nothing renders for a rule with no compliance-library entry (core, hand-written and custom rules).
- The FE holds no copy of the suggestion data — it fetches it, so changing a suggestion needs no frontend release.
loggingLevel is now the field that carries a rule’s level. It used to ride in categoryName,
which is why withResolvedLevels mapped any unrecognised value to INFO and persisted it,
silently converting a library category (phi, audit_coverage) into a level on the next save — 498
stored rules were in that state. Its replacement withCarriedLevels moves a level into
loggingLevel and invents nothing; savedRuleLogLevel() returns null when nobody recorded one.
The categoryName fallback is retained, in the same order the backend’s RuleLogLevel.resolve
uses, so no existing row loses its level.
Two consequences worth expecting on first load: 17 catalogue rules will display a different level
(10 WARN, 6 ERROR, 1 DEBUG) because the page previously ignored the stored value and drew INFO; and
on prod, only PCI is visible — HIDDEN_STRATEGY_PREFIXES hides SOC2:, HIPAA: and GDPR:
there by product decision, so the other three frameworks’ suggestions are not a failed feature.
Settings → Integrations: every card explains its own setup (2026-09-30)
Section titled “Settings → Integrations: every card explains its own setup (2026-09-30)”All three integrations now carry a collapsible setup guide on their own card. Gatekeeper had one; Salesforce and Discord had a credential field and no statement of where that credential comes from inside the other product — so the page explained the integration a customer was least likely to reach first, and left the other two to be guessed at or supported by hand.
| Piece | Path |
|---|---|
| The disclosure shell | settings/IntegrationSetupGuide.jsx |
| Discord + Salesforce step copy | settings/integrationSetup.js |
| Gatekeeper step copy + workflow YAML | settings/gatekeeper/gatekeeperWorkflow.js |
| Tests | tests/unit/integrationSetupGuides.test.js (26) |
One shell, three sets of words. The chevron, the collapsed-by-default behaviour and the step
list live in one component; each card supplies only its own copy. Copying the panel twice would
have put three <ol>-and-chevron implementations on one screen, which is how this tab drifted
once before — its single card carried its own input class and its own muted colour and read as
though it came from a different product. A test asserts each card imports the shared shell and
contains no second chevron, because three correct-looking disclosures is exactly what a
hand-copied one also renders, and the drift only becomes visible much later.
Every guide ends on evidence observed outside LogRite — a post appearing in the Discord channel, an approved Salesforce OAuth prompt, a check reporting on a pull request. A guide that stops at Save has explained how to reach the state where somebody believes they are covered.
Two real-world traps are stated because neither is discoverable from the form, and both make a correct setup look like a wrong credential:
- Salesforce takes two to ten minutes to propagate a new connected app. Until it has, a perfectly correct key and secret fail with an invalid-client error. That reads as a typo, so the natural response is to re-copy the credentials — which changes nothing and spends the ten minutes again.
- The callback URL must be pasted, not typed. It is rendered from the server’s own configured origin; a mismatch is the usual reason a first attempt fails, and Salesforce reports it on a page the user cannot act on.
A guide is not a permission. The Salesforce guide renders for a non-manager and while already connected. The person reading the card is often the one who has to ask a Salesforce admin for the credentials, and reconnecting after a revoked app is exactly when those screens are hardest to find again. Discord’s renders while the connection is still being fetched, for the same reason: how to create a webhook does not depend on what we are still loading.
Salesforce: raising a Case from a finding (2026-10-01)
Section titled “Salesforce: raising a Case from a finding (2026-10-01)”The Salesforce card had always advertised “Raise a Salesforce Case from a finding and follow its
status”, the server had always implemented it, and the API client had always exported the three
functions for it. Nothing in src/ called them. The only importer of salesforceApi.js was
SalesforceSettings.jsx, and it imported three of the six exports — so connect/disconnect worked
and the half the card advertised was unreachable from the product.
Found the same way as the Gatekeeper gap, by asking who calls the client rather than whether the endpoint exists. Both are worth remembering as a class: a complete server feature with no call site looks identical, from the server, to one nobody uses.
| Piece | Path |
|---|---|
| Finding identity + Case payload (pure) | utils/salesforceFinding.js |
| Connection + case state, one fetch per table | common/hooks/useSalesforceCases.js |
| The per-finding control | common/SalesforceCaseButton.jsx |
| Raised cases, with status and sync | settings/SalesforceCasesList.jsx |
| Which workspace the next request is in | utils/sessionWorkspace.js |
The finding id is a dedupe key, and two things are deliberately not in it
Section titled “The finding id is a dedupe key, and two things are deliberately not in it”The server has a unique index on (workspace, finding_id) and createCase returns the existing
link rather than erroring, so this one string decides whether a customer’s support queue gets one
ticket for a problem or fifty.
The control renders nothing in five cases, each for its own reason
Section titled “The control renders nothing in five cases, each for its own reason”- While the connection is unknown.
connectedstarts asnull, notfalse. A boolean would render the “no org is connected” state on the first paint of every page load — telling a connected customer their integration had broken. - When no org is connected. An issues table is not where somebody configures an integration.
- Below Developer.
POST /casesisrequireDeveloper;GET /casesisrequireWorkspaceMember. So a Viewer sees the raised-cases list on the card and gets no button on a row — matching the server rather than handing them a 403. - When the finding cannot be identified. See above.
- When the caller passes no
salesforceprop at all, so every existingIssuesTablecaller renders exactly as before.
Where the state lives, and why not in the button
Section titled “Where the state lives, and why not in the button”An issues table renders up to fifty rows. A hook inside the button would issue fifty connection
reads and fifty case-list reads to paint one screen, and the fiftieth would answer after the user had
clicked. ScanTables calls useSalesforceCases() once and the rows read a Map.
The org’s host comes from the same connection read that decided connected, never from a second
prop or a hardcoded salesforce.com: a sandbox and a production org have different hosts, and a Case
link pointing at the wrong one 404s in a way that reads as “the case was never created”.
Workspace identity comes from the bearer token, not from redux — sessionWorkspace.js, for the
reason sessionRole.js records. switchWorkspaceToken mints a new token on a workspace switch, so
for one render after a switch a redux read would ask about workspace A with a token the server reads
as workspace B, and the answer comes back correct-looking and wrong.
Billing shows this workspace, or says billing is off (2026-10-01)
Section titled “Billing shows this workspace, or says billing is off (2026-10-01)”Settings → Billing used to show every user the same page:
- Billing disabled, or the endpoints missing: the page silently showed a demo fixture (Pro, 22 engineers, $968, Visa 4242).
- Billing live: the page read fixture-shaped fields the API never sends.
Now:
- Billing not enabled: the page says so (“Nothing is being charged”). The example is one deliberate click away, Show an example of this page.
- Field shapes are mapped once, in
billingApi.js:- plan prices from
priceAnnualCents/priceMonthlyCents; - the allowance from
includedUsageMicroUsd; - token counts from raw
cachedTokens/inputTokens/outputTokensinto millions; - every plan always has a
featureslist, so Change plan no longer crashes.
- plan prices from
- Invoices show their own status, not a hard-coded “Paid”.
- The backend creates the workspace’s trial subscription the first time Billing is opened, but only when billing is enabled. The trial uses observe enforcement, so opening the page never blocks anyone.
Cortex sends at once — no “Send with Context” popup (2026-10-01)
Section titled “Cortex sends at once — no “Send with Context” popup (2026-10-01)”An unscoped prompt is sent immediately, with no scope. Cortex answers across everything the workspace can read, which is what the popup’s “skip” did, without the extra step.
- To scope a question: use Add project or index in the composer, or name a project or index in the prompt.
- Unchanged: the pencil Update context dialog.
- Still true: nothing is chosen for the user.
This applies to both Cortex pages, for typed prompts and suggestion chips.
Platforms is out of the sidebar for now. It opens at /platforms and from
Settings → Add-ons → Your platforms, which lists the workspace’s platforms beside the
Cortex add-ons. See Platforms.
Discord: choosing which notifications a channel carries (2026-10-01)
Section titled “Discord: choosing which notifications a channel carries (2026-10-01)”The Discord card used to offer three toggles — Blocked, Flagged, Allowed — which are AI Warden verdicts. The backend now sends five more kinds of notification to the same webhook (the catalogue), so the card gained an Also send section.
| Card | SidebarPage/settings/DiscordCard.jsx (markup), DiscordSettings.jsx (behaviour) |
| Client | utils/constant/discordApi.js |
| API | PUT /api/v1/integrations/discord/{workspaceId}/connection, field categories |
| Tests | discordSettingsCard.test.js (rendering), discordCategoryPicking.test.js (jsdom, the toggle) |
The list comes from the server, and that is the point
Section titled “The list comes from the server, and that is the point”The card renders availableCategories from /connection — [{key, heading}] — never a list
defined in the frontend. The failure a hardcoded list causes is not “a new category is missing
from the page”:
A workspace with no catalogue in its response renders no section rather than an empty one: “the server did not tell us what exists” and “there is nothing to choose” are indistinguishable once an empty list is on screen, and only one of them is true.
Null, empty, and the click that bridges them
Section titled “Null, empty, and the click that bridges them”categories is three-state end to end — the database column has no default for the same reason:
| State | Means | What the card does |
|---|---|---|
null | never configured | every box reads ticked, because that is what the server does with a null column |
[] | the customer unticked everything | every box reads unticked, and the save stores “none” |
['analyzer', …] | an explicit choice | exactly those ticked |
There is no Warden checkbox, deliberately
Section titled “There is no Warden checkbox, deliberately”warden is filtered out of the catalogue. The relay reads the three verdict booleans off the
binding — not this list — so a Warden checkbox would be a second control for one thing, and
unticking it would change nothing at all. A control that does nothing is worse than an absent
one. The three verdict toggles stay exactly where they were.
Gatekeeper setup is one secret now (2026-10-01)
Section titled “Gatekeeper setup is one secret now (2026-10-01)”Settings → Integrations → Gatekeeper serves the CI workflow text, and that panel is the only channel we have for it: the plugin is vendored, so every customer holds their own copy in their own repository and there is no LogRite-owned action repo to publish a fix through. That is why the 2026-09-08 breakage stayed broken — the server was corrected, the half running in their pipeline could not be.
Setting it up used to need a Go toolchain, a vendored gatekeeper.go, two secrets and a
repository variable. Every one of them failed in a way that looked like a LogRite problem. All of
it is now either computed by the workflow or answered by the server:
| Was | Now |
|---|---|
logrite-plugin/gatekeeper.go + setup-go + go build | git diff --name-only, jq and curl — already on ubuntu-latest |
secrets.API_URL | baked into the served text; an existing API_URL secret still wins |
vars.LOG_COVERAGE_THRESHOLD | omitted, so the server supplies and reports it |
secrets.GITHUB_TOKEN | unchanged — GitHub provides it to every run; nobody creates it |
secrets.LOGRITE_API_KEY | the one thing the customer still creates |
The || defaults work for the same reason the backend had to accept an empty threshold: an unset
secret is the empty string in a GitHub expression, not absent. So secrets.API_URL || '<baked>'
gives a zero-configuration default and lets an existing setting win.
Three outcomes, and the one that must never be green
Section titled “Three outcomes, and the one that must never be green”The client reads the HTTP status before the body. The old plugin decoded the body first and
tested status != "allow", so a 401 — which carries no status field at all — decoded to "",
failed that test, and announced a coverage failure next to a link to an auto-fix pull request that
had never been created.
| Verdict | Exit | Means |
|---|---|---|
pass | 0 | checked, and coverage is at or above the threshold |
below | 1 | checked, and it is genuinely short |
nocheck | 2 | no verdict — auth, transport, a 5xx, or a 200 we cannot read |
A 200 whose status we do not recognise is nocheck, not below: inferring a coverage failure
from an absent field is the original defect, and it would survive a rewrite that only fixed the 401.
The step reports its verdict through outputs rather than its own exit status, so the PR-comment
step can still run — which means a script that dies early would leave the verdict empty and the job
would go green on a check that never ran. A final step fires on verdict == '' to close that,
because a false green is the one outcome worse than a false red.
Two ways this script could have gone green while doing nothing
Section titled “Two ways this script could have gone green while doing nothing”Both were found by re-reading the finished script, and both are the same shape: a failure that produces the same observable as a success.
- A failed
git diffpiped intojqemits no lines, sojqreturns[], the count is zero, and the check reports “nothing changed” and passes. “git broke” and “this pull request changed nothing” are different facts and only one of them is a pass, so the diff is captured and its exit status checked beforejqever sees it. - GitHub runs
run:steps underbash -e, so the first non-zero command kills the step and skips every step below it — including the ones that report the reason.set +eis therefore deliberate, every failure path is handled by hand, and acurlthat never completed is read off its exit status rather than off an HTTP status it never received.
The suite runs the artefact, it does not grep it
Section titled “The suite runs the artefact, it does not grep it”tests/unit/gatekeeperWorkflow.test.js parses the YAML with js-yaml, hands every run: block to
bash -n, and executes the jq filter against real inputs to prove an unset threshold is
omitted rather than sent as "". Nothing between this file and a customer’s runner would
otherwise catch an indentation slip or an unbalanced case — the whole estate’s checks would just
start failing, and it would look like LogRite being down. Proven in both directions: six planted
defects, including a mis-indented step and a removed esac, each turn the suite red.
Close-out fix wave 2 (2026-10-09)
Section titled “Close-out fix wave 2 (2026-10-09)”- Traces list one row per trace. The Traces page (Analytics →
/analytics/observability) groups spans bytraceId: each row shows the trace’s duration, status and span count (prod ws 303, 24 h: 5,906 spans → 457 traces). Paging, status and minimum-duration filters work on traces (frontend 5534ba66). - Warden detail. The
rawCapturenote renders alongside tool steps; the decision panel showsdecisionReasonand the deciding layer.