Skip to content

Frontend

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.

SettingValue
TechnologyReact 19 + Vite 7 + Redux Toolkit + Tailwind CSS
Port3000 (app.logrite.ai / dev-app.logrite.ai)
RepoDeployd-io/Logrite-UI
Runs asThe frontend compose service on each VM (one instance per estate)
LibraryPurpose
React 19UI framework
Vite 7Build tool + dev server
Redux ToolkitGlobal state management
Tailwind CSSUtility-first styling
echarts-for-reactCharts (via EChartsWrapper.jsx)
react-icons (hi2)Icons — HiOutline* pattern
react-router-domClient-side routing

Redux Toolkit with multiple slices:

  • authSlice — JWT, user info, workspace
  • scanSlice — Compliance scan state
  • uiSlice — Theme (dark/light), sidebar state
  • agentSlice — Agent management (proposed)
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 subdir
  • 16 JS chunks (down from 125 before optimization)
  • CortexPanel lazy-loaded — prevents 1.1MB ECharts on every page
  • Function-based manualChunks in vite.config.js
  • Zero icon micro-chunks
  • Dark/light theme with CSS variables
  • FOUC prevention: inline <script> in index.html reads localStorage['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 of npm run verify) fails on small orange text, on white-on-#E64100 in 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-warden and /settings?tab=connections have 0 violations in both themes.
  • Background: --bg-primary: #0c0e12 (dark mode)
  • 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

Shipped: 2026-05-21 05:48 UTC

The Dashboard pulls two heavy lists on every visit:

EndpointPurpose
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:

ConditionBehavior
First login / empty cacheFetch + populate
Workspace switch (user.currentWorkSpace.id change)Fetch + populate
New scan kicked off (project.scanTrigger bumped)Fetch on next Dashboard mount
Same userId + workspace + scanTriggerCache 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
LogoutSlice 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.

FileRole
src/redux/slices/dashboardSlice.jsCache state + reducers (setDashboardLoading, setDashboardData, setDashboardError, invalidateDashboard, clearDashboard)
src/components/Dashboard/hooks/useDashboardLogic.jsxReads cache, fetches on stale key, exposes actions.refresh + state.refreshing
src/components/Dashboard/components/DashboardAnalytics.jsxRenders 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.

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:

  1. 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.
  2. Keyword fallback — catches custom user-template rules whose name contains phi, gdpr, sox, etc.
  3. 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]).

Shipped: 2026-05-21

The Dashboard’s four “Quick Actions” cards (QuickActions.jsx + QuickActionCard.jsx) follow a strict 2 / 3 / 4-column responsive grid:

BreakpointColumns
mobile (< md)2
md3
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).

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:

StateTriggerCaption
loading/api/v1/agents-flow/picker/{ws} in flight (GitHub /app/installations + OAuth /user/repos fan-out, 2–8 s)“Loading projects and templates…”
comboRunningUser clicked Run scan + Generate logs“Starting scan and log generation… you’ll be redirected to the live progress page in a moment.”
scanningUser clicked Run scan“Running scan… you’ll be redirected to the live progress page in a moment.”
enablingSaving 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.

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:

  1. The scan-results row (/api/v1/scan-results/user/{userId} → allScanResults) carries branchLink that’s the source-branch tree URL (e.g. https://github.com/.../tree/master), not a PR URL.
  2. The PR URL lives on the matching LogGeneratorResult row (/api/v1/log-generator-results/user/{userId} → allGenerateResults) — same data, different table, joined by scanId (the analyzer scan id).
  3. useDashboardLogic builds a memoized scanIdToGeneratorPrUrl map from allGenerateResults, picking only rows whose branchLink contains /pull/.
  4. The map is threaded Dashboard → DashboardAnalytics → DashboardProjectList → ProjectCard.
  5. ProjectCard.getScanResultsRoute() dispatches setGeneratorPrUrl(matchedPrUrl || null) alongside setProjectFromScanResults(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):

TemplateLight bg / borderDark 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 by logType) with one TemplateChip per appliedTemplates entry (deduped via dedupeTemplateNames). Falls back to [logType.name] only when appliedTemplates is empty.
  • Generator/components/BranchesTable.jsx — replaces the old neutral-grey chip on each scan row; now one TemplateChip per 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:

  1. item.appliedTemplates — backend-computed list at the top level of every LogGeneratorResultDTO / 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").
  2. item.runDetails.appliedTemplates — defensive fallback when the top-level field is missing; the project-results endpoint sometimes returns runDetails as a JSON string "{}", the lite payload returns it pre-parsed.
  3. 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.

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:

  1. Initialises pageLoading=true and keeps it true until projects resolve.
  2. If Redux already has projects (the common path through SelectWorkSpace / useDashboardLogic’s background fetch), drops pageLoading immediately.
  3. Otherwise, fires GET /api/v1/project/{workspaceId}/{email} itself (gated on currentWorkSpace.id + a resolvable email) and only drops pageLoading when the request settles.
  4. Renders <LogriteLoader size="xl" /> while loading, then EmptyDashboard only 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.js derives the initial currentFramework from location.state.templateName (or location.state.logTypeId for 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 a templateName via navigation state, the effect either trusts it (built-in name) or looks up its logTypeId from the project’s compliance templates — instead of falling back to setCurrentFrameworkState("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=true on _search and _mapping. resolveTenantIndex expands the selection into a comma-separated list that mixes wildcards (*logrite*) with concrete index names pulled from a platform’s associated_indexes (e.g. payment_queries, ctm_events, external_service_calls). When any of those concrete indexes doesn’t exist, OpenSearch _search/_mapping returns 404 index_not_found_exception for 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 _mapping left primaryTimestampField stuck on its @timestamp default, so the date_histogram aggregated on a field the log index (time) doesn’t have.
  • Dropped the unused highlight block from buildDiscoverQuery. It requested highlight.fields: {"*": {}} with fragment_size: 2147483647 (Integer.MAX) across every field of a multi-index expansion. The Discover table/expanded-row render _source directly and never read the highlight fragments, so it was dead weight that inflated each request’s memory estimate — a contributor to OpenSearch’s 429 circuit_breaking_exception on 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.jsx builds the bar tooltip with date (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 a CustomSelect (Auto / Minute / Hour / Day) bound to a new intervalOverride state in useDiscoverLogic.js. Precedence in fetchData: explicit override → custom-range span heuristic → calculateInterval(...). intervalOverride is a dependency of both fetchData (the useCallback) and the data-fetch effect, so changing it re-buckets the date_histogram (fixed_interval 1m/1h/1d) and refetches.
  • Removed the api.ipify.org client-IP fetch. ExpandedRowView.jsx and TraceDetailsModal.jsx fetched the viewer’s public IP from https://api.ipify.org. This is blocked by the app CSP connect-src allowlist (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 derive clientIp from span fields only and fall back to null instead of the browser IP.
  • “View in Discover” now loads OTEL-agent data (2026-05-29). OTEL-agent log docs have no message field (the text is in body) and a real time field (traces use startTime). The Discover query builder fell back to a __ts_from_message painless runtime field that reads message → null → empty result. Fix: discoverUtils.js and TableBlock.jsx now include observedTime/startTime in the timestamp-field detection, time filter, and docvalue_fields; the __ts_from_message fallback also reads body. The real time/startTime field 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 in App.jsx and a Browse-dropdown entry in SidebarPage/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 the OpenSearch_Analytics/styles/theme.js helpers (getThemeClasses, getCardStyles, getTableStyles, getLogLevelBadgeStyles).
  • Static MVP → live swap. usePlatformMetrics is 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 (a terms agg on platform with percentiles on query_exec_ms, a filter for level:ERROR, max(@timestamp), and a date_histogram for the sparkline, plus a per-platform recent-logs _search). The returned shape is identical either way, so PlatformsView / PlatformCard never 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, &#123; Authorization: Bearer &#125;) with the user’s token from state.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)”
  • projectId threaded into the chat request. ProjectConfirmContent → useChatLogic → useSSEChat → aiServiceApi.sendChatMessageV2 now forward the selected project’s numeric id as payload.projectId, so the AI service can resolve the authoritative serviceName binding instead of guessing lower(projectName). Additive — null when no project is selected.
  • Deterministic-error-sweep rendering. ChatMessageBubble.jsx halves 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 sets selectedAgentKey + a scoped savedContext so every send is scoped.
  • Answer-style dropdown — PersonaPicker.jsx rewritten 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 by aiServiceApi at send time.
  • Empty-state agent cards now start a conversation scoped to the agent (persona + index) instead of a blank one that did nothing (handleAgentCardStart in Assistant.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 the index → owner map, 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 indexName string 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 — joinScope returns null rather 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 indexes list 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. retainVisibleScope filters 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.js asserts 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.

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.

ControlWhat it says
ScopePickerToggle — the pillthe current state: “All logs”, or the project name, or “N sources”
ScopePickerOpener — beside itthe action: “Add project or index”, or “Edit project or index” once something is scoped
@ in the composernames 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.

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:

HostPreview features
dev-* (e.g. dev-app.logrite.ai), localhost, 127.0.0.1shown
app.logrite.ai, and any host not listed abovehidden — 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
integrationsThe Integrations tab button in Settings (settings-tab-integrations).
customRulesThe 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.

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:

Argdevprod
VITE_WS_API_URLws://dev-leapfrogapi.logrite.ai/wsws://leapfrogapi.deployd.io/ws
VITE_WSS_API_URLwss://dev-leapfrogapi.logrite.ai/wswss://leapfrogapi.deployd.io/ws
VITE_AI_SERVICE_WSwss://dev-aiservice.logrite.ai/wswss://aiservice.logrite.ai/ws

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.

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 logging
window.__cortexDebug = false // silence

Each 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:

  1. URL→conversation effect re-fires because conversations changed. urlChatId is still null (the Conversation→URL effect hasn’t navigated yet, and effects run in declaration order). The old !urlChatId && currentConversation?.id branch called clearChat() → wiped messages and currentConversation.
  2. Conversation→URL effect then navigated to /logrite-cortex/{newId}.
  3. The follow-up URL→conversation run matched lastPushedChatIdRef.current === urlChatId and 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:

WhereHoldsReadable by page script?
src/utils/sessionVault.js memorythe live tokens, for the life of the pageyes, while the page is open
HttpOnly cookies lgv_a/t/e/r/g/b on the app’s own originthe same, so a reload or a new tab can get them backno

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):

  1. takeLegacyCredentials() runs before persistStore() 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.
  2. persist:user rehydrates with the credential fields blanked (a redux-persist transform in src/redux/store.js, keyed on STORE_SECRET_FIELDS).
  3. bootSession() runs as PersistGate’s onBeforeLift: if the user is signed in, it reads the vault and dispatches restoreCredentials. Nothing renders until it finishes, or the route guard would bounce a signed-in user to /login on 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).

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.

Flips a localStorage-persisted flag (logriteDebug=true) that does two things on every subsequent request:

  1. The shared aiServiceApi.js axios interceptor attaches X-Debug: 1 to every outbound call.
  2. parseSSEStream handles incoming event: debug lines from logrite-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: github

X-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.

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):

Terminal window
C=$(docker ps -q --filter label=com.docker.compose.project=logrite --filter label=com.docker.compose.service=frontend)
docker logs -f "$C" # logs
docker inspect -f '{{.State.Status}}' "$C" # status

Deploy 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:

Terminal window
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.

Terminal window
cd services/frontend/dev/Logrite-UI # in a MasterControl checkout
npm ci
npm run build

This is for tests and inspection only; the served bundle is built by the deploy pipeline.

FilePurpose
src/index.cssGlobal CSS, imports responsive.css
src/App.cssApp-wide CSS (loads after index.css)
src/components/Login/css/Login-specific styles
src/utils/themeClasses.jsTheme utilities

Key animation classes:

  • .animate-fadeIn — pure opacity (global)
  • .animate-fadeInUp — slide + opacity (chat messages only)
  • .animate-border-flow — border animation on KeyFindingsPopover

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).

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.

FilePurpose
src/components/DeploymentLock/DeploymentLockOverlay.jsxThe overlay UI — gradient card, per-service progress bar, stage text, commit SHA, 2-min cooldown countdown
src/components/DeploymentLock/useDeploymentLock.jsHook that drives the phase machine (idle → active → cooldown → idle)

Two-tier to keep idle cost near zero:

PhaseEndpointCadenceUpstream cost
idleGET /api/v1/deployments/signal5 min1 Coolify call (server cached 5s)
activeGET /api/v1/deployments + signal2s (detail), 5 min (signal)26 Coolify calls per detail fetch
cooldownnone (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.

  • 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 active and 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.

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 via POST /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).

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:

RolePermissions
MaintainerFull workspace access, manage members
DeveloperCreate/run scans, view results
ViewerView 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 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.

Workspaces carry an optional description.

  • Backend: Workspace.description is exposed via WorkspaceInfoResponse (so it flows to the frontend on login / userProfile); POST /api/v1/auth/workspace accepts an optional description on create; PUT /api/v1/workspace/{id} (Maintainer-gated) updates it. No migration — the column existed and ddl-auto: update is 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).

The old static “This device” card is now a live Devices & sessions list.

  • Backend: UserSession entity/repo (table auto-created), UserSessionService. Email login/signup and OAuth login mint a jti claim and record a session row (device label parsed from user-agent, IP, login type, timestamps); /auth/refresh preserves the jti. GET /api/v1/user/sessions lists the user’s devices (current flagged); DELETE /api/v1/user/sessions/{id} revokes one (ownership-checked). JwtAuthenticationFilter rejects 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.location is filled once, when the session row is written (@PrePersist). Sessions written before this change are located when the list is read. The API field is location on GET /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.ci downloads it in a layer before the jar. ops/notify/prebuild/backend.sh passes GEOIP_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.

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.

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.

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 from compliance.notify / a response-scope violation for back-compat with rows captured before the relay tagged NOTIFY directly. Frameworks render as TemplateChips (first 3 + overflow count); Developer is the X-Warden-User caller.
  • 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 from review.*), the AI judgement (judge verdict, model, escalation, plain-English judge.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 by requestId; refreshes on success). The per-violation suppress that lived in the old Violations tab now lives here.
  • ViolationsView.jsx / ViolationDetailDrawer.jsx are 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_KEY was 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.
  • 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 through warden.logrite.ai and 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 shared PolicyEditorDrawer; the sampled-mode slider and the policyText textarea are gone. The workspace-wide editor is the Rules & Policies page (/policies, sidebar after Compliance, src/components/RulesPolicies/, API client services/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.

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.

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).

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.js catalogs 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 a tab, an anchorId, and keywords. 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+K focuses the box; Escape and 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 to max-w-4xl so forms no longer sprawl across the full width.
  • Card sections. Every section across all three tabs is now a rounded-xl border card (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-3 row 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 to settingsSearchIndex.js (anchorId: setting-username).
  • Password-reset email indicator. The active address carries a 🔑 RESET tag whose hover tooltip explains it’s where Forgot Password emails go (no separate banner). It’s gated on hasPassword — 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-selected one, 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 off getPasswordChecks, 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 with validatePassword before 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-4xl cap 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 from navigator.userAgent, and sign-in time from the Redux loginTimestamp. (A full multi-device list with revoke would need server-side session storage; JWT auth is stateless today.)
  • 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).

Section titled “Sidebar & email-validation fixes (2026-06-25)”
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 CortexPanel slide-over was already mounted app-wide in DashboardLayout, but the “Ask” trigger only rendered on /dashboard, /generator, /compliance. routeAgent (DashboardLayout.jsx) and mobileRouteAgent (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). New AIAgents/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.
  • WS scan-error screen (2026-07-23) — any analyzer WebSocket frame whose text starts with ERROR: (or arrives stamped messageType: "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 as messageType: "success" and the UI spun forever. Handled in useAnalyzerLogic (attached view) and ScanProgressContext (detached/floating panel).
  • Real invite flow (2026-07-23) — InviteTeamMembers (Project Details → + chip) now lists real workspace members from GET /workspace/{id}/members (pending rows get an INVITED badge) and sends invites via POST /api/v1/invite ({workSpaceName, userEmail:[{userEmail, role}]}). Full client-side validation: format (shared validateEmail.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 (useProjectDetailsLogic fetches real member initials, +N overflow).
  • X-Vcs-Token forward (2026-07-23) — usePushStatus and the pre-scan safety gate in useInitiateScanLogic send the browser-held gitToken as X-Vcs-Token to /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 createPortal to <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’s overflow-x-auto container.
  • 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 /notifications with 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). ConnectRepoAccessModal offers both OAuth (personal token) and GitHub App install, auto-highlighting the one that fits the repo owner. Backed by GET /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 prefer VITE_ANALYTICS_* with a legacy VITE_OPENSEARCH_* fallback).
  • Stale lazy-chunk recovery — lazyWithRetry reloads 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 (NavigationBreadcrumb gained a custom-node crumb).

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 + new hooks/useSearchRelevanceLogic.js
    • services/searchRelevanceApi.js) — runs both query bodies against the selected index via the shared osHttp proxy client (POST {index}/_search) and renders real hit counts, max_score, per-hit _score, took, and _source previews. 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/pipeline registries 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:

  • upsertNotification reducer in notificationsSlice.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 in DashboardLayout, a best-effort 5-minute poll (each source independently try/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>). Exports syncNotificationsOnce which 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_complete now sets metadata.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). osHttp retries only what a retry can fix (429 + 502/503/504 — a 403/404 is deterministic and retrying it just burns quota), honors Retry-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 flips isConnected, the poll skips ticks while throttled, handleApiError never schedules its 5 s recheck for a 429, and the context value is useMemo’d (it was re-rendering the whole Analytics subtree on every health tick).
  • knnApi.js / CreateIndexPatternModal.jsx additionally gained the Authorization header they never sent — both used a bare fetch() 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&#125;. Keyed off response.status, not string-matching the serialized error as before — errorStr.includes('404') misfires on an index named logs-404 or a 4045 ms timing.
  • components/common/OpenSearchConnectionError.jsx — already existed but was a dead import, never rendered; handleApiError had zero call sites. Now rewired onto the normalizer, with fullHeight={false} for in-view use, Retry hidden when retryable is 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:

  1. Notice — “These rules have already been used”, naming the template and the scan, with Save as a new template.
  2. Save as Template — the existing naming modal. In this flow handleConfirmTemplateSave takes an onSaved callback that replaces the usual redirect to the project page.
  3. 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 ComplianceRuleLevelBackfill moves 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:

  1. 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);
  2. the framework of the template pickTemplate is about to select;
  3. the first framework, in served order, with any template;
  4. otherwise nothing, and the modal’s empty state says so.

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.

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: PHI
HIPAA §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.
  • Apply appears 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.

PiecePath
The disclosure shellsettings/IntegrationSetupGuide.jsx
Discord + Salesforce step copysettings/integrationSetup.js
Gatekeeper step copy + workflow YAMLsettings/gatekeeper/gatekeeperWorkflow.js
Teststests/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.

PiecePath
Finding identity + Case payload (pure)utils/salesforceFinding.js
Connection + case state, one fetch per tablecommon/hooks/useSalesforceCases.js
The per-finding controlcommon/SalesforceCaseButton.jsx
Raised cases, with status and syncsettings/SalesforceCasesList.jsx
Which workspace the next request is inutils/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. connected starts as null, not false. 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 /cases is requireDeveloper; GET /cases is requireWorkspaceMember. 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 salesforce prop at all, so every existing IssuesTable caller 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 / outputTokens into millions;
    • every plan always has a features list, so Change plan no longer crashes.
  • 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.

CardSidebarPage/settings/DiscordCard.jsx (markup), DiscordSettings.jsx (behaviour)
Clientutils/constant/discordApi.js
APIPUT /api/v1/integrations/discord/{workspaceId}/connection, field categories
TestsdiscordSettingsCard.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:

StateMeansWhat the card does
nullnever configuredevery box reads ticked, because that is what the server does with a null column
[]the customer unticked everythingevery box reads unticked, and the save stores “none”
['analyzer', …]an explicit choiceexactly those ticked

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:

WasNow
logrite-plugin/gatekeeper.go + setup-go + go buildgit diff --name-only, jq and curl — already on ubuntu-latest
secrets.API_URLbaked into the served text; an existing API_URL secret still wins
vars.LOG_COVERAGE_THRESHOLDomitted, so the server supplies and reports it
secrets.GITHUB_TOKENunchanged — GitHub provides it to every run; nobody creates it
secrets.LOGRITE_API_KEYthe 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.

VerdictExitMeans
pass0checked, and coverage is at or above the threshold
below1checked, and it is genuinely short
nocheck2no 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 diff piped into jq emits no lines, so jq returns [], 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 before jq ever sees it.
  • GitHub runs run: steps under bash -e, so the first non-zero command kills the step and skips every step below it — including the ones that report the reason. set +e is therefore deliberate, every failure path is handled by hand, and a curl that 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.

  • Traces list one row per trace. The Traces page (Analytics → /analytics/observability) groups spans by traceId: 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 rawCapture note renders alongside tool steps; the decision panel shows decisionReason and the deciding layer.