Skip to content

SOC Console P3/P4 + gateway query & aggregate layer - #1591

Merged
lavkushry merged 12 commits into
mainfrom
feat/soc-console-design-system
Jun 26, 2026
Merged

SOC Console P3/P4 + gateway query & aggregate layer#1591
lavkushry merged 12 commits into
mainfrom
feat/soc-console-design-system

Conversation

@lavkushry

@lavkushry lavkushry commented Jun 26, 2026

Copy link
Copy Markdown
Owner

Summary

Continues the SOC Console work merged in #1590 (design system + panel framework P0–P2) with P3/P4 console surfaces and the gateway query/aggregate layer that backs them. Merges latest main (incl. #1589 cursor pagination) — clean, no conflicts.

Console (P3–P4)

  • Differentiator panels as registry entries: Approval Card (frozen bytes + role-gated approve/reject/edit), Provable Timeline (one-click chain verify), Receipt Integrity (range verify + evidence-pack export). Live in a new Integrity Console dashboard.
  • Session-first role (useSessionRole) gating + control-bar selector; drilldown router (panel row/hash → filtered Explore).
  • Explore: Kibana-Discover field sidebar (facets), and a typed AQL query bar.
  • Fleet migrated onto DashboardLoader (action-capable AgentTablePanel); bespoke AgentsTab removed.
  • TimeSeries panel (Recharts, themed) — the first server-aggregated chart, on the Overview dashboard.

Gateway (TDD, parameterized, tenant-scoped)

  • /v1/decisions filters: source_trust, tool(skill), time-range (from/to) — each with a new test; fixed a latent bug where the console read tool/tool_call.name while the gateway serializes skill.
  • DecisionListFilters struct refactor — collapses the positional Option<&str> filter args (removes a transposition hazard; behavior-preserving).
  • GET /v1/decisions/timeseriescount_over_time aggregate (TimeBucket minute/hour/day, strftime/date_trunc), feeding the TimeSeries panel.

Test plan

  • cargo check / cargo clippy -p gateway -D warnings / cargo fmt --check clean
  • cargo test -p aegis-storage — 136 pass
  • cd ui && npm run build (Next 16) + tsc + eslint clean
  • CI: full gateway suite + cross-language SDK parity

Notes

  • No emoji anywhere (lucide SVG + text).
  • Pre-existing any in ui/src/app/api.ts left as-is (resolved by the DataFrame layer).
  • Follow-ups: heatmap panel (2-dim aggregate), gateway identity/role model for real RBAC (console already session-first), POST /v1/soc/query AST.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a decision time-series view with selectable minute, hour, or day buckets.
    • Expanded the Explore experience with richer filters, field facets, and drilldown seeding.
    • Introduced new dashboard panels for fleet status, approval review, receipt integrity, and decision timelines.
    • Added an agent table and time-series chart panel.
  • Bug Fixes

    • Improved decision filtering with trust level, skill, and time-range support.
    • Enabled role-aware access and session-based role detection.
    • Added receipt-chain verification and export for audit evidence.

lavkushry and others added 12 commits June 25, 2026 13:21
…eceipt Integrity (P3)

The three surfaces neither Grafana nor Kibana can show, built as registry
panels on the framework.

- ApprovalCard: renders the pending queue from the approval DataFrame —
  frozen canonical parameters (the exact bytes), action_hash via HashChip,
  source trust via TrustBadge, expiry; Approve / Reject (api.ts mutations)
  and Edit (PUT /v1/approvals/:id → re-hash + re-evaluate, with a warning).
  Escalate is a disabled-with-reason stub pending routing config.
- ProvableTimeline: ordered events each carrying a receipt; a Verify-chain
  walk that reports "tamper-free (N/N links)" or "broken at event K",
  using the datasource's verifyReceipt.
- ReceiptIntegrity: hash-chain browse with per-row + range verification,
  a broken link highlighted, and an evidence-pack JSON export (SOC 2 /
  EU AI Act Art. 14).
- Register all three; add an Integrity Console dashboard (approval queue +
  provable decision timeline + receipt chain) and a nav entry rendering it
  through DashboardLoader — a live end-to-end proof.

Panels still never fetch reads (PanelRuntime does); verify/approve are
interactive actions via the datasource and api.ts. No emoji. tsc, eslint,
next build pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- store: add a role (viewer/analyst/approver/admin, persisted) plus a
  canApprove() helper; lift the active view and add a one-time Explore
  seed so drilldowns can navigate. A Role selector joins the control bar.
- ApprovalCard: Approve / Reject / Edit are now gated on the approver or
  admin role — disabled-with-reason for others, with a read-only banner
  (the gateway still enforces server-side; UI gating is UX only).
- useDrilldownRouter: maps a panel DrilldownLink to navigation — explore
  (seeds a filtered Explore query via ${field} templating), verify-receipt
  and incident (view switch), dashboard (by uid). PanelRuntime uses it as
  the default onDrilldown, so every panel gets drilldowns for free.
- page.tsx reads the active view from the store; ExploreTab seeds its
  query from a drilldown at mount. Overview decisions table and the
  Integrity timeline drill into Explore filtered by agent.

No emoji. tsc, eslint, next build pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a faceted field sidebar to the Explore page, computed client-side
from the loaded decisions: decision, source_trust, tool, agent_id, and
event_type, each with value counts. decision/source_trust facets render
with the DecisionBadge/TrustBadge primitives; clicking any value seeds
the search and refetches. Explore is now a two-pane Discover layout
(sidebar + results).

The full AQL parser and the POST /v1/soc/query event-query datasource
remain a follow-up — they depend on the gateway query endpoint that does
not exist yet; this sidebar delivers the Discover faceting value against
the existing substring search now.

No emoji. tsc, eslint, next build pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#1 — Role from the gateway session:
- useSessionRole/useEffectiveRole resolves the operator role session-first
  (GET /v1/session when present) and falls back to the local selector.
  ApprovalCard now gates on the effective role. NOTE: the gateway has no
  user/role model yet (auth is tenant-scoped bearer/mTLS), so this resolves
  to the override today; the wiring is ready for real RBAC.

#2 — Migrate Fleet onto DashboardLoader:
- AgentTablePanel: agent inventory with role-gated freeze/restore Active
  Response (viewer is read-only; gateway enforces server-side).
- fleet dashboard (stats + agent-table); the Agents nav now renders it
  through DashboardLoader and the bespoke AgentsTab is removed.

No emoji. tsc, eslint, next build pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A small, safe AQL syntax for Explore that compiles to the gateway's
existing parameterized, tenant-scoped /v1/decisions filters — no new
endpoint, no string interpolation (the gateway binds every value).

- datasources/aql/parse.ts: parseAql("agent_id:x AND decision:deny term")
  -> { agentId, decision, q }. Known fields map to typed params; unknown
  fields and bare words become FTS5 keyword terms (q), sanitized
  server-side.
- api.ts: searchDecisions() builds the parameterized URL.
- ExploreTab: the query bar is now AQL; field-sidebar facet clicks emit
  field:value and route through the same parser, so decision/agent_id
  facets become real server-side filters.

The richer typed AST pipeline (POST /v1/soc/query) remains a backend
follow-up; this delivers structured filtering against what exists today.
No emoji. tsc and next build pass (api.ts pre-existing any debt unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds an exact, parameterized, tenant-scoped filter on root_trust_level —
the provenance differentiator — to the decisions listing, end to end.

Gateway:
- list_decisions_cursor gains a source_trust param -> an
  `AND (? IS NULL OR root_trust_level = ?)` clause, bound (sqlite +
  postgres). Threaded through the StorageBackend trait, the sqlite impl,
  and GET /v1/decisions (parse_filter "source_trust"). All other callers
  pass None.
- New test: list_decisions_cursor_filters_by_source_trust_and_is_tenant_scoped
  (exact match + tenant isolation). 127 storage tests pass; cargo
  check/clippy clean.

Console:
- AQL parser maps source_trust:/root_trust_level: to a typed param;
  searchDecisions sends &source_trust=. So `source_trust:untrusted_external`
  and the trust facet in the Explore sidebar are now real server-side
  filters instead of keyword search.

No emoji. cargo (check/clippy/test), tsc, and next build pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Whole-workspace cargo fmt (run while adding the source_trust filter)
wrapped several long lines in files unrelated to that change. Formatting
only; no behavior change. Mirrors 719d196.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The decisions table stores the tool/integration name in the `skill`
column (authorize.rs: skill = tool_call.tool). Adds an exact,
parameterized, tenant-scoped filter on it, end to end.

Gateway:
- list_decisions_cursor gains a `skill` param -> `AND (? IS NULL OR
  skill = ?)`, bound (sqlite + postgres); threaded through the trait,
  the sqlite impl, and GET /v1/decisions (parse_filter "skill"). Other
  callers pass None.
- New test list_decisions_cursor_filters_by_skill_tool. cargo
  check/clippy clean, fmt --check clean, storage tests pass.

Console:
- AQL maps tool:/skill: -> skill param; searchDecisions sends &skill=.
- Fixes a latent bug: the Explore "tool" facet and the decision row
  label read tool/tool_call.name, but the gateway serializes `skill` —
  both now read `skill`, so the tool facet/label populate correctly and
  the facet filter works server-side.

No emoji. cargo (check/clippy/test), tsc, and next build pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wires the console's global time-range picker to Explore.

Gateway:
- list_decisions_cursor gains from/to params -> inclusive
  `AND (? IS NULL OR created_at >= ?)` / `<= ?` clauses, bound (sqlite
  + postgres) and threaded through the trait, the sqlite impl, and GET
  /v1/decisions. The route parses RFC3339 from/to and reformats to the
  DB timestamp format (%F %T%.6f) so string comparisons sort correctly
  (same approach as list_decisions_in_range #1283); invalid input is
  dropped (time range is a UI convenience, not a security control).
- New test list_decisions_cursor_filters_by_time_range (past/future
  from, past to). 129 storage tests pass; cargo check/clippy/fmt clean.

Console:
- relativeRangeToFrom() maps the picker token (1h/24h/7d/30d) to an
  RFC3339 lower bound; ExploreTab sends &from= and keys the query on the
  range, so changing the picker now refetches a bounded window.

Follow-up noted: list_decisions' filter params are getting numerous
(already #[allow(too_many_arguments)]); a DecisionListFilters struct
would remove the positional-arg transposition risk.

No emoji. cargo (check/clippy/test), tsc, and next build pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ilters

Behavior-preserving. list_decisions / list_decisions_cursor had grown to
many positional Option<&str> filter args (already under
#[allow(too_many_arguments)]) — a real transposition hazard in
security-relevant query code (swapping e.g. source_trust and skill, or
from and to, compiles and silently returns wrong rows).

Introduce a named DecisionListFilters struct (agent_id, decision, q,
source_trust, skill, from, to) and thread it through the StorageBackend
trait, the sqlite impl, and list_decisions_cursor. Call sites are now
self-documenting:
  list_decisions(&tenant, limit, cursor,
                 DecisionListFilters { source_trust: Some(t), ..Default::default() })
The SQL, binds, and behavior are unchanged; the per-fn
#[allow(too_many_arguments)] is removed.

129 storage tests pass unchanged; cargo check/clippy/fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Unblocks real Grafana-style timeseries panels with a server-side
aggregate, end to end.

Gateway:
- TimeBucket (minute|hour|day, allowlisted; never raw user input) +
  count_decisions_over_time on StorageBackend / sqlite, bucketing
  created_at via strftime (sqlite) / date_trunc (postgres), reusing
  DecisionListFilters (tenant-scoped, parameterized; q/FTS unsupported).
- GET /v1/decisions/timeseries?interval=&from=&to=&<filters> -> [{ bucket,
  count }]. Wired in main.rs before /decisions/:id.
- New test count_decisions_over_time_buckets_and_filters (aggregate +
  decision filter + tenant isolation). 130 storage tests pass; cargo
  check/clippy/fmt clean.

Console:
- QueryRequest/PanelDefinition gain aggregate ("count_over_time") +
  interval; PanelRuntime threads them through.
- GatewayEntityDatasource.countOverTime() resolves the panel's time
  tokens (now-24h) to RFC3339 and returns a [time, number] DataFrame.
- New TimeSeriesPanel (Recharts, themed via useChartColors), registered
  as "timeseries". The Overview dashboard now renders a real hourly
  "Decisions over time" chart from the aggregate endpoint.

No emoji. cargo (check/clippy/test), tsc, eslint, next build pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@ecc-tools

ecc-tools Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

ECC bundle files are already tracked in this repository. Skipping generation of another bundle PR.

@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds structured decision filters and time-bucket aggregation in storage and routes, then wires client-side AQL search, drilldown navigation, dashboard, and panel paths to the new endpoints. It also introduces role state, session role handling, and new approval, integrity, fleet, and time-series UI modules.

Changes

Decision query and console flow

Layer / File(s) Summary
Decision query contract
lib/storage/src/traits.rs
Adds bundled decision filters and time bucket helpers, and updates the storage backend trait signatures to accept them.
Decision SQL and adapter
lib/storage/src/db/decisions.rs, lib/storage/src/sqlite.rs
Refactors decision listing to use structured filters, extends the SQL predicates and bindings for trust, skill, and time bounds, adds the time-series aggregation query, updates the SQLite adapter, and refreshes the storage tests.
Decision route wiring
src/src/routes/soc.rs, src/src/main.rs, src/src/routes/graph.rs, src/src/routes/authorize.rs
Adds the /decisions/timeseries route, parses extra decision filters and timestamp bounds in the SOC routes, and updates other route callers to the refactored decision list API.
Decision query client plumbing
ui/src/app/api.ts, ui/src/lib/format.ts, ui/src/datasources/aql/parse.ts, ui/src/datasources/types.ts, ui/src/datasources/gatewayEntity.ts
Adds AQL parsing, relative time helpers, the decision search request builder, and gateway datasource support for count-over-time aggregation.
Console state and explore UI
ui/src/app/store.ts, ui/src/hooks/*, ui/src/components/ConfigBar.tsx, ui/src/components/ExploreTab.tsx, ui/src/components/filters/FieldSidebar.tsx
Adds role and drilldown state, session role resolution, the role selector, the explore search and facet UI, and the sidebar/tab wiring for the console.
Panel runtime and standard panels
ui/src/panels/types.ts, ui/src/panels/registry.ts, ui/src/panels/PanelRuntime.tsx, ui/src/panels/standard/*
Extends panel typing and registry wiring, updates panel runtime drilldown and query keys, and adds the time series and agent table panel renderers.
Specialized panels and dashboards
ui/src/panels/differentiators/*, ui/src/dashboards/system/*, ui/src/app/page.tsx
Adds the approval, receipt, and provable timeline panels, registers the new system dashboards, and updates the app page to switch between the new dashboard views.

Sequence Diagram(s)

sequenceDiagram
  participant PanelRuntime
  participant GatewayEntityDatasource
  participant decision_timeseries
  participant storage_count_decisions_over_time
  participant db_count_decisions_over_time
  PanelRuntime->>GatewayEntityDatasource: query({ aggregate: "count_over_time", interval, timeRange })
  GatewayEntityDatasource->>decision_timeseries: GET /v1/decisions/timeseries
  decision_timeseries->>storage_count_decisions_over_time: storage.count_decisions_over_time(...)
  storage_count_decisions_over_time->>db_count_decisions_over_time: SQL bucket aggregation
  db_count_decisions_over_time-->>storage_count_decisions_over_time: bucket rows
  storage_count_decisions_over_time-->>decision_timeseries: JSON points
  decision_timeseries-->>GatewayEntityDatasource: buckets
  GatewayEntityDatasource-->>PanelRuntime: DataFrame
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~90+ minutes

Possibly related PRs

  • lavkushry/AegisAgent#1456: This overlaps with the decision trust/filter handling that maps source_trust and root_trust_level across the query path.

Poem

🐰 I hopped through filters, neat and true,
then counted time in buckets, too.
Approval cards began to glow,
and dashboards learned to bloom and flow.
My carrot says: “Let graphs now show!”

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly reflects the two main changes: SOC Console P3/P4 surfaces and the gateway query/aggregate layer.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/soc-console-design-system

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a robust dashboard and panel framework, adding new views such as the Integrity Console, Agents Fleet, and a decision volume timeseries. It refactors the backend storage query interface to use a unified DecisionListFilters struct and adds support for counting decisions over time. Feedback on these changes highlights a potential Postgres type mismatch in the backend query when comparing timestamps to string parameters. On the frontend, recommendations include using the centralized fetchFromGateway helper instead of direct fetch in ApprovalCard, making the shortBucket parser in TimeSeriesPanel more robust against ISO 8601 separators, and adding defensive checks in FieldSidebar to prevent potential runtime crashes.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +787 to +788
AND (? IS NULL OR created_at >= ?)
AND (? IS NULL OR created_at <= ?)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In Postgres, created_at is a TIMESTAMP or TIMESTAMPTZ column. Comparing it directly to a string parameter (bound as Option<&str>) without an explicit cast will result in a type mismatch error (e.g., operator does not exist: timestamp with time zone >= character varying). To ensure compatibility and prevent runtime errors on Postgres, explicitly cast the placeholders to timestamptz or timestamp.

Suggested change
AND (? IS NULL OR created_at >= ?)
AND (? IS NULL OR created_at <= ?)
AND (? IS NULL OR created_at >= ?::timestamptz)
AND (? IS NULL OR created_at <= ?::timestamptz)

Comment on lines +80 to +87
const res = await fetch(
`${gatewayUrl.replace(/\/+$/, "")}/v1/approvals/${approvalId(a)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${bearerToken}` },
body: JSON.stringify({ parameters: parsed, reason: "Edited via SOC console; re-hash and re-evaluate." }),
},
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using direct fetch here bypasses the centralized fetchFromGateway helper, which handles base URL resolution, authentication headers, and error handling consistently. To maintain clean architecture and consistency, consider defining an API helper function (e.g., updateApproval) in ui/src/app/api.ts and calling it here.

Comment on lines +14 to +17
function shortBucket(raw: string): string {
const parts = raw.split(" ");
return parts.length === 2 ? parts[1].slice(0, 5) : raw;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The shortBucket function assumes that the date and time are separated by a space. However, if the database or API returns standard ISO 8601 strings (e.g., from Postgres) where the separator is T (e.g., 2026-06-25T13:00:00Z), raw.split(" ") will not split the string and the label won't be shortened. Splitting on either space or T makes this helper much more robust.

Suggested change
function shortBucket(raw: string): string {
const parts = raw.split(" ");
return parts.length === 2 ? parts[1].slice(0, 5) : raw;
}
function shortBucket(raw: string): string {
const parts = raw.split(/[ T]/);
return parts.length >= 2 ? parts[1].slice(0, 5) : raw;
}

Comment on lines +30 to +35
function readValue(row: Row, facet: FacetField): string {
const keys = [facet.key, ...(facet.altKeys ?? [])];
for (const k of keys) {
const v = row[k];
if (v !== null && v !== undefined && v !== "") return String(v);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If row is null or undefined, accessing row[k] will throw a TypeError. Adding a defensive check at the beginning of readValue ensures the sidebar won't crash the UI if the API returns unexpected null or undefined elements in the dataset.

Suggested change
function readValue(row: Row, facet: FacetField): string {
const keys = [facet.key, ...(facet.altKeys ?? [])];
for (const k of keys) {
const v = row[k];
if (v !== null && v !== undefined && v !== "") return String(v);
}
function readValue(row: Row, facet: FacetField): string {
if (!row) return "";
const keys = [facet.key, ...(facet.altKeys ?? [])];
for (const k of keys) {
const v = row[k];
if (v !== null && v !== undefined && v !== "") return String(v);
}

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

🧹 Nitpick comments (3)
ui/src/panels/differentiators/ApprovalCard.tsx (1)

79-94: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider routing the edit through an api.ts helper.

Approve/Reject use approveApproval/rejectApproval, but the edit path hand-rolls a fetch with header/URL normalization duplicated here. Extracting an editApproval(apiOpts, id, params, reason) helper keeps auth/URL handling consistent and centralized.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/src/panels/differentiators/ApprovalCard.tsx` around lines 79 - 94, The
edit flow in ApprovalCard currently hand-rolls a fetch request, duplicating URL
trimming and auth header setup that should be centralized. Move this logic into
a new editApproval helper in api.ts, similar to approveApproval and
rejectApproval, and have ApprovalCard call that helper instead. Keep the helper
responsible for building the /v1/approvals request, applying api options/auth,
and sending the edited parameters and reason so the component only handles UI
state and errors.
ui/src/datasources/types.ts (1)

56-57: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider a literal union for interval.

The doc comment constrains interval to "minute" | "hour" | "day", but the type is string. Narrowing the type matches the backend TimeBucket contract and catches typos at compile time.

♻️ Proposed change
-  /** Bucket granularity for count_over_time ("minute" | "hour" | "day"). */
-  readonly interval?: string;
+  /** Bucket granularity for count_over_time. */
+  readonly interval?: "minute" | "hour" | "day";
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/src/datasources/types.ts` around lines 56 - 57, The interval field is
typed too broadly compared with its documented values. Update the datasource
type definition for interval in types.ts from string to a literal union matching
the TimeBucket contract ("minute" | "hour" | "day"), so callers of the relevant
datasource shape get compile-time validation and typo protection.
src/src/routes/soc.rs (1)

195-217: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Timeseries query is unbounded when from/to are absent.

from/to are optional and silently dropped on invalid input, so a request with interval=minute and no valid time bounds groups the tenant's entire decision history by minute — a full-table scan returning a potentially huge result set. Although the current UI datasource always sends bounds, the endpoint itself doesn't enforce them. Consider defaulting to (or capping) a sane time window server-side, or bounding the maximum number of buckets returned.

Also confirm there is an index on decisions(tenant_id, created_at) to keep the bucketed aggregate efficient.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/src/routes/soc.rs` around lines 195 - 217, The timeseries route in the
SOC handler can run unbounded when `from`/`to` are missing or invalid, since
`parse_filter` silently drops them before `count_decisions_over_time` is called.
Update the route logic to enforce a server-side time window or cap the maximum
number of buckets before invoking `state.storage.count_decisions_over_time`,
using the existing `TimeBucket::parse` and `DecisionListFilters` flow. Also
verify the database has an index on `decisions(tenant_id, created_at)` so the
bucketed aggregate stays efficient.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/storage/src/db/decisions.rs`:
- Around line 725-740: The count_decisions_over_time aggregate currently ignores
the shared DecisionListFilters q field, which can return unfiltered counts when
keyword filtering is requested. Update count_decisions_over_time to either apply
the same FTS predicate used by list_decisions or explicitly reject
filters.q.is_some() with a domain error, and make the function return Result<_,
AegisError> to match the lib error-boundary rule instead of exposing sqlx::Error
directly.

In `@lib/storage/src/traits.rs`:
- Around line 21-22: `DecisionListFilters` is using raw string bounds for `from`
and `to`, which can be compared inconsistently against `created_at` across
backends. Update the shared filter contract in `traits.rs` to use typed or
normalized timestamps (for example `Option<DateTime<Utc>>` or a canonical
timestamp newtype), then adjust all `DecisionListFilters` call sites and backend
queries to parse/format these bounds consistently before comparing against
`created_at`.
- Around line 37-42: The Bucket parse logic currently coerces unknown values to
Hour, which hides typos and makes invalid requests look valid. Update
Bucket::parse in traits.rs to return Option<Self> or Result<Self, _> instead of
defaulting on the fallback branch, and only let the route/controller choose Hour
when the bucket parameter is actually missing. Make sure the handler that
consumes parse handles invalid values as an error rather than silently mapping
them to Hour.

In `@src/src/routes/soc.rs`:
- Around line 186-232: The new decision_timeseries REST handler in soc.rs has no
matching gRPC exposure, so add the corresponding RPC to SocService and implement
it in SocGrpcServiceImpl in grpc.rs. Wire the new method to the existing storage
call count_decisions_over_time, using the same DecisionListFilters and
TimeBucket parsing logic as decision_timeseries so both REST and gRPC share the
same lib/ behavior. Register the RPC in the gRPC service implementation
alongside the existing list_alerts, list_incidents, and semantic_search methods.

In `@ui/src/app/store.ts`:
- Around line 52-55: The fallback role in getInitialRole() is too privileged
because it defaults to admin; change the default to the least-privilege role,
viewer, and ensure VALID_ROLES validation falls back to that same value. Update
any initial state consumers that rely on role: getInitialRole() in store.ts so
the UI posture stays fail-safe and canApprove does not become true by default.

In `@ui/src/components/filters/FieldSidebar.tsx`:
- Line 59: The facet selection flow in FieldSidebar.onSelect emits raw
field:value tokens that parseAql later splits on whitespace, so values with
spaces break into the wrong AQL filter. Update the FieldSidebar selection path
(especially the onSelect call sites around the facet click handlers) to preserve
full facet values by either encoding/quoting the value before emitting it or by
teaching parseAql to understand quoted values. Make sure tool and event_type
selections round-trip correctly even when the value contains spaces, and keep
the field mapping logic intact.

In `@ui/src/datasources/gatewayEntity.ts`:
- Around line 68-77: The countOverTime method in gatewayEntity.ts only forwards
interval/from/to to /v1/decisions/timeseries, so active facet filters are being
dropped. Update countOverTime to read req.filters and append the supported typed
filters (agent_id, decision, source_trust, skill) to the URLSearchParams before
calling fetchFromGateway, keeping the existing time-range handling intact.

In `@ui/src/panels/differentiators/ApprovalCard.tsx`:
- Around line 71-94: The saveEdit flow in ApprovalCard allows duplicate PUT
requests because it has no in-flight guard like the Approve/Reject actions. Add
a pending state around saveEdit, set it before the fetch starts and clear it in
a finally block, and use that flag to disable the Save button while the request
is running. Update the handler and button wiring in ApprovalCard so rapid clicks
cannot submit the re-hash/re-evaluate request twice.

In `@ui/src/panels/differentiators/ReceiptIntegrity.tsx`:
- Around line 56-74: The per-row running state in ReceiptIntegrity’s verifyRange
flow is never set, so the active row cannot render the in-progress spinner.
Update the loop in verifyRange to mark next[i] as status "running" before
awaiting datasource.verifyReceipt, then overwrite it with "ok" or "failed" after
the result/error. Keep the existing setRowStates and setRange updates in sync
with RowState so the render branch checking state?.status === "running" can be
reached.

In `@ui/src/panels/standard/TimeSeriesPanel.tsx`:
- Around line 13-17: `shortBucket` in `TimeSeriesPanel` is dropping the date for
Postgres day buckets, which makes every `00:00` label collide on the axis.
Update `shortBucket(raw)` to inspect the split date/time parts and, when the
time portion starts with `00:00` (day granularity), return the date part instead
of the time; otherwise keep the existing time-shortening behavior. Keep the
change localized to the `shortBucket` helper so all callers benefit.

---

Nitpick comments:
In `@src/src/routes/soc.rs`:
- Around line 195-217: The timeseries route in the SOC handler can run unbounded
when `from`/`to` are missing or invalid, since `parse_filter` silently drops
them before `count_decisions_over_time` is called. Update the route logic to
enforce a server-side time window or cap the maximum number of buckets before
invoking `state.storage.count_decisions_over_time`, using the existing
`TimeBucket::parse` and `DecisionListFilters` flow. Also verify the database has
an index on `decisions(tenant_id, created_at)` so the bucketed aggregate stays
efficient.

In `@ui/src/datasources/types.ts`:
- Around line 56-57: The interval field is typed too broadly compared with its
documented values. Update the datasource type definition for interval in
types.ts from string to a literal union matching the TimeBucket contract
("minute" | "hour" | "day"), so callers of the relevant datasource shape get
compile-time validation and typo protection.

In `@ui/src/panels/differentiators/ApprovalCard.tsx`:
- Around line 79-94: The edit flow in ApprovalCard currently hand-rolls a fetch
request, duplicating URL trimming and auth header setup that should be
centralized. Move this logic into a new editApproval helper in api.ts, similar
to approveApproval and rejectApproval, and have ApprovalCard call that helper
instead. Keep the helper responsible for building the /v1/approvals request,
applying api options/auth, and sending the edited parameters and reason so the
component only handles UI state and errors.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: eb310ba9-f604-4902-ad13-ff8b9eddce52

📥 Commits

Reviewing files that changed from the base of the PR and between 6a1970d and 99b25e7.

📒 Files selected for processing (33)
  • lib/storage/src/db/decisions.rs
  • lib/storage/src/sqlite.rs
  • lib/storage/src/traits.rs
  • src/src/main.rs
  • src/src/routes/authorize.rs
  • src/src/routes/dashboard.rs
  • src/src/routes/graph.rs
  • src/src/routes/policy.rs
  • src/src/routes/soc.rs
  • ui/src/app/api.ts
  • ui/src/app/page.tsx
  • ui/src/app/store.ts
  • ui/src/components/AgentsTab.tsx
  • ui/src/components/ConfigBar.tsx
  • ui/src/components/ExploreTab.tsx
  • ui/src/components/filters/FieldSidebar.tsx
  • ui/src/dashboards/system/fleet.ts
  • ui/src/dashboards/system/integrity.ts
  • ui/src/dashboards/system/overview.ts
  • ui/src/datasources/aql/parse.ts
  • ui/src/datasources/gatewayEntity.ts
  • ui/src/datasources/types.ts
  • ui/src/hooks/useDrilldownRouter.ts
  • ui/src/hooks/useSessionRole.ts
  • ui/src/lib/format.ts
  • ui/src/panels/PanelRuntime.tsx
  • ui/src/panels/differentiators/ApprovalCard.tsx
  • ui/src/panels/differentiators/ProvableTimeline.tsx
  • ui/src/panels/differentiators/ReceiptIntegrity.tsx
  • ui/src/panels/registry.ts
  • ui/src/panels/standard/AgentTablePanel.tsx
  • ui/src/panels/standard/TimeSeriesPanel.tsx
  • ui/src/panels/types.ts
💤 Files with no reviewable changes (1)
  • ui/src/components/AgentsTab.tsx

Comment on lines +725 to +740
pub async fn count_decisions_over_time(
pool: &DbPool,
tenant_id: &str,
bucket: crate::traits::TimeBucket,
filters: crate::traits::DecisionListFilters<'_>,
) -> Result<Vec<(String, i64)>, sqlx::Error> {
use sqlx::Row;
let crate::traits::DecisionListFilters {
agent_id,
decision,
source_trust,
skill,
from,
to,
..
} = filters;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Fail fast instead of accepting a q filter that the aggregate ignores.

DecisionListFilters is shared with list_decisions, but this aggregate drops q; callers can request a keyword-filtered time series and receive unfiltered counts. Either add the same FTS predicate or reject q.is_some() with a domain error; returning AegisError here would also align the new production lib function with the lib error-boundary rule. As per coding guidelines, lib/**/*.rs: All Rust lib/ crate files must return Result<T, AegisError> for all functions in production paths and never use .unwrap() or .expect().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/storage/src/db/decisions.rs` around lines 725 - 740, The
count_decisions_over_time aggregate currently ignores the shared
DecisionListFilters q field, which can return unfiltered counts when keyword
filtering is requested. Update count_decisions_over_time to either apply the
same FTS predicate used by list_decisions or explicitly reject
filters.q.is_some() with a domain error, and make the function return Result<_,
AegisError> to match the lib error-boundary rule instead of exposing sqlx::Error
directly.

Source: Coding guidelines

Comment thread lib/storage/src/traits.rs
Comment on lines +21 to +22
pub from: Option<&'a str>,
pub to: Option<&'a str>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Use typed or normalized time bounds in the shared filter contract.

from/to are raw strings, but storage compares them directly against created_at; RFC3339 values like 2026-06-26T12:00:00Z can sort differently from SQLite CURRENT_TIMESTAMP strings like 2026-06-26 12:00:00, silently dropping rows. Prefer Option<DateTime<Utc>> or a canonical timestamp newtype and format/cast per backend.

#!/bin/bash
set -euo pipefail

# Verify all callers normalize range params before constructing DecisionListFilters.
# Expected: HTTP/UI inputs are parsed to a canonical DB/backend timestamp shape before `from`/`to` are set.
fd -e rs -e ts -e tsx | xargs rg -n "DecisionListFilters\\s*\\{|from:\\s*Some|to:\\s*Some|relativeRangeToFrom|created_at\\s*[<>]="
fd -e sql -e rs | xargs rg -n "CREATE TABLE decisions|created_at"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/storage/src/traits.rs` around lines 21 - 22, `DecisionListFilters` is
using raw string bounds for `from` and `to`, which can be compared
inconsistently against `created_at` across backends. Update the shared filter
contract in `traits.rs` to use typed or normalized timestamps (for example
`Option<DateTime<Utc>>` or a canonical timestamp newtype), then adjust all
`DecisionListFilters` call sites and backend queries to parse/format these
bounds consistently before comparing against `created_at`.

Comment thread lib/storage/src/traits.rs
Comment on lines +37 to +42
pub fn parse(raw: &str) -> Self {
match raw {
"minute" => Self::Minute,
"day" => Self::Day,
_ => Self::Hour,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject unknown bucket values instead of coercing them to Hour.

A typo such as bucket=minutes currently returns valid-looking hourly data. Return Option<Self>/Result<Self, _> and let the route use Hour only when the parameter is absent.

Proposed direction
-    pub fn parse(raw: &str) -> Self {
+    pub fn parse(raw: &str) -> Option<Self> {
         match raw {
-            "minute" => Self::Minute,
-            "day" => Self::Day,
-            _ => Self::Hour,
+            "minute" => Some(Self::Minute),
+            "hour" => Some(Self::Hour),
+            "day" => Some(Self::Day),
+            _ => None,
         }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pub fn parse(raw: &str) -> Self {
match raw {
"minute" => Self::Minute,
"day" => Self::Day,
_ => Self::Hour,
}
pub fn parse(raw: &str) -> Option<Self> {
match raw {
"minute" => Some(Self::Minute),
"hour" => Some(Self::Hour),
"day" => Some(Self::Day),
_ => None,
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/storage/src/traits.rs` around lines 37 - 42, The Bucket parse logic
currently coerces unknown values to Hour, which hides typos and makes invalid
requests look valid. Update Bucket::parse in traits.rs to return Option<Self> or
Result<Self, _> instead of defaulting on the fallback branch, and only let the
route/controller choose Hour when the bucket parameter is actually missing. Make
sure the handler that consumes parse handles invalid values as an error rather
than silently mapping them to Hour.

Comment thread src/src/routes/soc.rs
Comment on lines +186 to +232
pub async fn decision_timeseries(
State(state): State<Arc<AppState>>,
TenantId(tenant_id): TenantId,
axum::extract::RawQuery(raw_query): axum::extract::RawQuery,
) -> impl IntoResponse {
let agent_id = parse_filter(raw_query.as_deref(), "agent_id");
let decision = parse_filter(raw_query.as_deref(), "decision");
let source_trust = parse_filter(raw_query.as_deref(), "source_trust");
let skill = parse_filter(raw_query.as_deref(), "skill");
let from = parse_filter(raw_query.as_deref(), "from").and_then(|raw| to_db_timestamp(&raw));
let to = parse_filter(raw_query.as_deref(), "to").and_then(|raw| to_db_timestamp(&raw));
let bucket = TimeBucket::parse(
parse_filter(raw_query.as_deref(), "interval")
.as_deref()
.unwrap_or("hour"),
);

match state
.storage
.count_decisions_over_time(
&tenant_id,
bucket,
DecisionListFilters {
agent_id: agent_id.as_deref(),
decision: decision.as_deref(),
source_trust: source_trust.as_deref(),
skill: skill.as_deref(),
from: from.as_deref(),
to: to.as_deref(),
..Default::default()
},
)
.await
{
Ok(buckets) => {
let points: Vec<_> = buckets
.into_iter()
.map(|(bucket, count)| serde_json::json!({ "bucket": bucket, "count": count }))
.collect();
(StatusCode::OK, Json(points)).into_response()
}
Err(e) => {
error!("Failed to count decisions over time: {:?}", e);
StatusError::internal("Database error").into_response()
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Look for a gRPC counterpart for the timeseries/count-over-time query.
rg -nP -C3 '(decision_timeseries|count_decisions_over_time|timeseries|TimeSeries)' --type=rust -g '*grpc*' -g '*proto*'
fd -e proto | xargs -r rg -nC2 -i 'timeseries|count_over_time|decision'

Repository: lavkushry/AegisAgent

Length of output: 232


🏁 Script executed:

#!/bin/bash
# List rust files in grpc directory
fd -e rs "src/grpc"
# Search for count_decisions_over_time in the whole codebase
rg -n "count_decisions_over_time" --type=rust
# Search for proto files
fd -e proto

Repository: lavkushry/AegisAgent

Length of output: 158


🏁 Script executed:

#!/bin/bash
# List all files to find grpc directory structure
find src -type d -name "*grpc*" 2>/dev/null || echo "No grpc dir found"
# List all rust files in src
find src -name "*.rs" | head -20
# Search for count_decisions_over_time anywhere
grep -r "count_decisions_over_time" src/ 2>/dev/null | head -20
# List all files in src
ls -la src/

Repository: lavkushry/AegisAgent

Length of output: 1606


🏁 Script executed:

#!/bin/bash
# Check the structure of src/src directory where grpc.rs is located
ls -la src/src/
# Check if there is a grpc directory inside src/src
find src/src -type d -name "*grpc*" 2>/dev/null
# List files in src/src
ls -la src/src/ | grep -E "^d"
# Search for grpc implementation details in grpc.rs
grep -n "struct\|impl\|Service" src/src/grpc.rs
# Search for count_decisions_over_time definition
grep -rn "fn count_decisions_over_time" src/

Repository: lavkushry/AegisAgent

Length of output: 2380


🏁 Script executed:

#!/bin/bash
# Search for count_decisions_over_time definition in the codebase
rg -n "fn count_decisions_over_time" --type=rust
# Search in src/src/grpc.rs for any timeseries related methods
rg -n "timeseries|TimeSeries|count.*over.*time" src/src/grpc.rs
# List soc_service methods in grpc.rs
sed -n '503,600p' src/src/grpc.rs

Repository: lavkushry/AegisAgent

Length of output: 158


🏁 Script executed:

#!/bin/bash
# Search for count_decisions_over_time definition in the codebase
grep -rn "fn count_decisions_over_time" --include="*.rs"
# Search in src/src/grpc.rs for any timeseries related methods
grep -n -i "timeseries\|time_series\|count.*over.*time" src/src/grpc.rs
# List soc_service methods in grpc.rs starting from line 503
sed -n '503,650p' src/src/grpc.rs

Repository: lavkushry/AegisAgent

Length of output: 5534


🏁 Script executed:

#!/bin/bash
# Search for SocService trait definition to see all methods
rg -A20 "trait SocService" --type=rust
# Check if there is a timeseries method in SocService trait
rg -B5 -A10 "timeseries|TimeSeries\|count.*time" --type=rust -p -i
# List all methods in SocService trait
grep -n "async fn" src/src/grpc.rs | grep -A1 -B1 "SocService"
# Check the full SocService implementation in grpc.rs
sed -n '503,800p' src/src/grpc.rs

Repository: lavkushry/AegisAgent

Length of output: 158


🏁 Script executed:

#!/bin/bash
# Find all async fn in the SocService impl block (lines 503+)
awk '/impl SocService for SocGrpcServiceImpl/,/^}/' src/src/grpc.rs | grep -n "async fn"
# Search for any method related to timeseries in the entire grpc.rs file
grep -n "time" src/src/grpc.rs | head -30
# Check the full SocService implementation to see all endpoints
sed -n '503,900p' src/src/grpc.rs | head -100

Repository: lavkushry/AegisAgent

Length of output: 3958


Missing gRPC implementation for decision_timeseries endpoint.

The new REST endpoint decision_timeseries in src/src/routes/soc.rs lacks a corresponding gRPC service definition and implementation. Per project guidelines, every new endpoint must be exposed on both REST and gRPC, delegating to the same lib/ service method.

The count_decisions_over_time method exists in the storage trait (lib/storage/src/traits.rs), but it is not wired to the SvcService gRPC service in src/src/grpc.rs. Implement the missing get_decision_timeseries (or named equivalent) RPC method in SocService and register it in the SocGrpcServiceImpl to ensure API parity.

Current gRPC SocService methods without timeseries support
impl SocService for SocGrpcServiceImpl {
    async fn list_alerts(...) { ... }
    async fn list_incidents(...) { ... }
    async fn close_incident(...) { ... }
    async fn create_playbook(...) { ... }
    async fn list_playbooks(...) { ... }
    async fn delete_playbook(...) { ... }
    async fn semantic_search(...) { ... }
    // Missing: async fn get_decision_timeseries(...) { ... }
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/src/routes/soc.rs` around lines 186 - 232, The new decision_timeseries
REST handler in soc.rs has no matching gRPC exposure, so add the corresponding
RPC to SocService and implement it in SocGrpcServiceImpl in grpc.rs. Wire the
new method to the existing storage call count_decisions_over_time, using the
same DecisionListFilters and TimeBucket parsing logic as decision_timeseries so
both REST and gRPC share the same lib/ behavior. Register the RPC in the gRPC
service implementation alongside the existing list_alerts, list_incidents, and
semantic_search methods.

Source: Coding guidelines

Comment thread ui/src/app/store.ts
Comment on lines +52 to 55
const getInitialRole = (): Role => {
const stored = getInitialValue("aegis_role", "admin");
return VALID_ROLES.includes(stored as Role) ? (stored as Role) : "admin";
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Default role to least privilege (viewer) rather than admin.

getInitialRole() falls back to "admin" when no/invalid role is persisted, which makes canApprove true and enables Active Response by default. This contradicts the separation-of-duties intent of canApprove. The gateway still enforces authorization server-side, so this is UI posture only, but a fail-safe default is preferable.

🛡️ Proposed change
 const getInitialRole = (): Role => {
-  const stored = getInitialValue("aegis_role", "admin");
-  return VALID_ROLES.includes(stored as Role) ? (stored as Role) : "admin";
+  const stored = getInitialValue("aegis_role", "viewer");
+  return VALID_ROLES.includes(stored as Role) ? (stored as Role) : "viewer";
 };

Also update the initial role: getInitialRole() consumers accordingly if a different default is intended.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const getInitialRole = (): Role => {
const stored = getInitialValue("aegis_role", "admin");
return VALID_ROLES.includes(stored as Role) ? (stored as Role) : "admin";
};
const getInitialRole = (): Role => {
const stored = getInitialValue("aegis_role", "viewer");
return VALID_ROLES.includes(stored as Role) ? (stored as Role) : "viewer";
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/src/app/store.ts` around lines 52 - 55, The fallback role in
getInitialRole() is too privileged because it defaults to admin; change the
default to the least-privilege role, viewer, and ensure VALID_ROLES validation
falls back to that same value. Update any initial state consumers that rely on
role: getInitialRole() in store.ts so the UI posture stays fail-safe and
canApprove does not become true by default.


type Props = {
rows: ReadonlyArray<Row>;
onSelect: (field: string, value: string) => void;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor

Facet values containing whitespace produce malformed AQL filters.

onSelect in FieldSidebar.tsx (lines 59, 96–98) emits a ${field}:${value} token that parseAql splits on whitespace before parsing field:value pairs. A facet value with spaces (e.g., tool:code review tool or event_type:user login attempt) breaks the filter: only the first word maps to the field (tool:code), and the remainder becomes free-text q terms (review tool), so the resulting filter does not match the selected value. This affects tool and event_type facets specifically; event_type additionally has no typed AQL field and defaults to q even for single-word values.

A fix requires either quoting support in the AQL parser or URL-encoding facet values before emitting the ${field}:${value} token.

Example breakdown

Input from facet click: tool:code review tool

  • Parsed tokens: ["tool:code", "review", "tool"]
  • tool:code → maps to skill field
  • review, tool → added to free-text q
  • Result: skill="code" AND q="review tool" (wrong)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/src/components/filters/FieldSidebar.tsx` at line 59, The facet selection
flow in FieldSidebar.onSelect emits raw field:value tokens that parseAql later
splits on whitespace, so values with spaces break into the wrong AQL filter.
Update the FieldSidebar selection path (especially the onSelect call sites
around the facet click handlers) to preserve full facet values by either
encoding/quoting the value before emitting it or by teaching parseAql to
understand quoted values. Make sure tool and event_type selections round-trip
correctly even when the value contains spaces, and keep the field mapping logic
intact.

Comment on lines +68 to +77
private async countOverTime(req: QueryRequest): Promise<DataFrame> {
const params = new URLSearchParams({ interval: req.interval ?? "hour" });
const from = resolveTimeToken(req.timeRange.from);
const to = resolveTimeToken(req.timeRange.to);
if (from) params.set("from", from);
if (to) params.set("to", to);
const points = await fetchFromGateway<Array<{ bucket: string; count: number }>>(
this.opts,
`/v1/decisions/timeseries?${params.toString()}`,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm the gateway timeseries handler parses these query params.
fd -t f 'soc.rs' --exec rg -nC3 'timeseries|count_decisions_over_time|source_trust|skill|q' {}

Repository: lavkushry/AegisAgent

Length of output: 50376


Propagate typed filters to timeseries endpoint.

The /v1/decisions/timeseries backend endpoint supports agent_id, decision, source_trust, and skill query parameters, but countOverTime (lines 68–77) only forwards interval, from, and to. Consequently, timeseries panels ignore active facet filters.

Update countOverTime to forward supported filters from req.filters:

  private async countOverTime(req: QueryRequest): Promise<DataFrame> {
    const params = new URLSearchParams({ interval: req.interval ?? "hour" });
    const from = resolveTimeToken(req.timeRange.from);
    const to = resolveTimeToken(req.timeRange.to);
    if (from) params.set("from", from);
    if (to) params.set("to", to);
+   if (req.filters?.agent_id) params.set("agent_id", req.filters.agent_id);
+   if (req.filters?.decision) params.set("decision", req.filters.decision);
+   if (req.filters?.source_trust) params.set("source_trust", req.filters.source_trust);
+   if (req.filters?.skill) params.set("skill", req.filters.skill);
    const points = await fetchFromGateway<Array<{ bucket: string; count: number }>>(
      this.opts,
      `/v1/decisions/timeseries?${params.toString()}`,
    );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private async countOverTime(req: QueryRequest): Promise<DataFrame> {
const params = new URLSearchParams({ interval: req.interval ?? "hour" });
const from = resolveTimeToken(req.timeRange.from);
const to = resolveTimeToken(req.timeRange.to);
if (from) params.set("from", from);
if (to) params.set("to", to);
const points = await fetchFromGateway<Array<{ bucket: string; count: number }>>(
this.opts,
`/v1/decisions/timeseries?${params.toString()}`,
);
private async countOverTime(req: QueryRequest): Promise<DataFrame> {
const params = new URLSearchParams({ interval: req.interval ?? "hour" });
const from = resolveTimeToken(req.timeRange.from);
const to = resolveTimeToken(req.timeRange.to);
if (from) params.set("from", from);
if (to) params.set("to", to);
if (req.filters?.agent_id) params.set("agent_id", req.filters.agent_id);
if (req.filters?.decision) params.set("decision", req.filters.decision);
if (req.filters?.source_trust) params.set("source_trust", req.filters.source_trust);
if (req.filters?.skill) params.set("skill", req.filters.skill);
const points = await fetchFromGateway<Array<{ bucket: string; count: number }>>(
this.opts,
`/v1/decisions/timeseries?${params.toString()}`,
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/src/datasources/gatewayEntity.ts` around lines 68 - 77, The countOverTime
method in gatewayEntity.ts only forwards interval/from/to to
/v1/decisions/timeseries, so active facet filters are being dropped. Update
countOverTime to read req.filters and append the supported typed filters
(agent_id, decision, source_trust, skill) to the URLSearchParams before calling
fetchFromGateway, keeping the existing time-range handling intact.

Comment on lines +71 to +94
const saveEdit = async (a: ApprovalRow) => {
let parsed: unknown;
try {
parsed = JSON.parse(editParamsJson);
} catch {
setEditError("Parameters must be valid JSON.");
return;
}
try {
const res = await fetch(
`${gatewayUrl.replace(/\/+$/, "")}/v1/approvals/${approvalId(a)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${bearerToken}` },
body: JSON.stringify({ parameters: parsed, reason: "Edited via SOC console; re-hash and re-evaluate." }),
},
);
if (!res.ok) throw new Error(await res.text());
setEditingId(null);
invalidate();
} catch (err: unknown) {
setEditError(`Edit failed: ${errorMessage(err)}`);
}
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard saveEdit against double-submission.

Unlike Approve/Reject (gated by busy), the Save button has no in-flight state, so a rapid double-click fires the re-hash/re-evaluate PUT twice. Track a pending flag and disable the button while the request is in flight.

Also applies to: 183-190

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/src/panels/differentiators/ApprovalCard.tsx` around lines 71 - 94, The
saveEdit flow in ApprovalCard allows duplicate PUT requests because it has no
in-flight guard like the Approve/Reject actions. Add a pending state around
saveEdit, set it before the fetch starts and clear it in a finally block, and
use that flag to disable the Save button while the request is running. Update
the handler and button wiring in ApprovalCard so rapid clicks cannot submit the
re-hash/re-evaluate request twice.

Comment on lines +56 to +74
const next: Record<number, RowState> = {};
for (let i = 0; i < rows.length; i++) {
const id = pick(rows[i], opts.receiptIdField);
try {
const result = await datasource.verifyReceipt(id);
next[i] = { status: result.ok ? "ok" : "failed", message: result.message };
setRowStates({ ...next });
if (!result.ok) {
setRange({ status: "failed", brokenAt: i + 1, message: result.message });
return;
}
} catch (err: unknown) {
next[i] = { status: "failed", message: errorMessage(err) };
setRowStates({ ...next });
setRange({ status: "failed", brokenAt: i + 1, message: errorMessage(err) });
return;
}
setRange({ status: "running", checked: i + 1, total: rows.length });
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Per-row "running" indicator is unreachable.

The render branch at Line 131-132 expects state?.status === "running", but verifyRange only ever writes "ok"/"failed" to next[i]. The in-progress spinner for the active row never shows. Set the row to running before awaiting verifyReceipt.

🔧 Proposed fix
     for (let i = 0; i < rows.length; i++) {
       const id = pick(rows[i], opts.receiptIdField);
+      next[i] = { status: "running" };
+      setRowStates({ ...next });
       try {
         const result = await datasource.verifyReceipt(id);

Also applies to: 131-137

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/src/panels/differentiators/ReceiptIntegrity.tsx` around lines 56 - 74, The
per-row running state in ReceiptIntegrity’s verifyRange flow is never set, so
the active row cannot render the in-progress spinner. Update the loop in
verifyRange to mark next[i] as status "running" before awaiting
datasource.verifyReceipt, then overwrite it with "ok" or "failed" after the
result/error. Keep the existing setRowStates and setRange updates in sync with
RowState so the render branch checking state?.status === "running" can be
reached.

Comment on lines +13 to +17
/** Shorten a DB bucket label for the axis: "2026-06-25 13:00:00" -> "13:00". */
function shortBucket(raw: string): string {
const parts = raw.split(" ");
return parts.length === 2 ? parts[1].slice(0, 5) : raw;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm the SQLite vs Postgres bucket formats to validate the label assumption.
fd -t f 'decisions.rs' -p 'lib/storage' --exec rg -nC2 'sqlite_fmt|pg_unit|strftime|to_char|date_trunc' {}

Repository: lavkushry/AegisAgent

Length of output: 1037


shortBucket renders duplicate 00:00 labels for all day buckets on Postgres.

The Postgres query in lib/storage/decisions.rs:780 forces every bucket to the format YYYY-MM-DD HH24:MI:SS. For day granularity, date_trunc results in timestamps ending in 00:00:00. The current shortBucket implementation extracts only the time portion, causing every day bucket to render as 00:00, which creates duplicate X-axis labels and hides the date.

SQLite avoids this because its day format omits the time component.

Update shortBucket to detect when the time is 00:00 (indicating a day bucket) and return the date part instead:

Code snippet
/** Shorten a DB bucket label for the axis: "2026-06-25 13:00:00" -> "13:00". */
function shortBucket(raw: string): string {
  const parts = raw.split(" ");
  if (parts.length === 2) {
    // If time is 00:00:00 (typical for day buckets in Postgres), return the date
    if (parts[1].startsWith("00:00")) return parts[0];
    return parts[1].slice(0, 5);
  }
  return raw;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/src/panels/standard/TimeSeriesPanel.tsx` around lines 13 - 17,
`shortBucket` in `TimeSeriesPanel` is dropping the date for Postgres day
buckets, which makes every `00:00` label collide on the axis. Update
`shortBucket(raw)` to inspect the split date/time parts and, when the time
portion starts with `00:00` (day granularity), return the date part instead of
the time; otherwise keep the existing time-shortening behavior. Keep the change
localized to the `shortBucket` helper so all callers benefit.

@lavkushry
lavkushry merged commit 57cb5a0 into main Jun 26, 2026
35 of 42 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant