Release/0.5.0 (#35)

* feat: Introduce function-scoped variable interning for state analysis with new tests and fixtures

* feat: Add Phase 26 symbolic execution enhancements with bitwise operator support, abstract interpretation refinements, and new taint analysis tests

* feat: Refine state analysis to handle factory-pattern resource returns with mixed-path tests and leak detection enhancements

* feat: Add Phase 27 debug views with symbolic execution, abstract interpretation, SSA, and call graph viewers; integrate with debug layout and styles

* feat: Add Phase 31 type-qualified symbolic resolution with receiver-based callee disambiguation and testing

* feat: Extend symbolic execution with state iteration, enhanced debug views, and debounced input handling

* feat: Add Phase 13 resource and auth pattern extensions with new tests and fixtures

* feat: Introduce CFG debug graph renderer with compact mode, toolbar, and DAG layout integration

* feat: Add Phase 28 encoding and decoding transform modeling with structural symex enhancements and new taint analysis tests

* feat: Extend abstract interpretation with type facts and constant value tracking in debug views and server logic

* feat: Add linear path handling and witness extraction to symbolic execution with Phase 28 transform mismatch detection

* feat: Refine Go auth and sanitizer handling with enhanced rules, state updates, and benchmark improvements

* feat: Enable auth-state analysis by default and update relevant tests in benchmark config

* test: Update state_tests to reflect default enablement of auth-state analysis and add auth suppression test

* docs: update CHANGELOG.md

* feat: Introduce per-index taint tracking in `HeapState` with `HeapSlot`, overflow handling, and revised SSA transfers

* feat: Introduce C/C++ language labels and refine heap state tracking in SSA transfers

* feat: Implement per-index array slot tracking in symbolic heap with overflow collapse

* feat: Add implicit definition handling for uninitialized declarations in SSA value allocation

* feat: Refactor function parameters and constants for improved clarity and maintainability

* refactor: Reorder module imports and improve formatting for consistency

* refactor: Fix formatting erorrs

* refactor: Fix clippy warnings

* refactor: Fix fmt warnings (again)

* chore: Update dependencies and improve feature configuration

* Add comprehensive tests for undertested modules (#36) (COPILOT)

* Add comprehensive tests for undertested modules

Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com>
Agent-Logs-Url: https://github.com/elicpeter/nyx/sessions/f3fc877e-f386-49ba-9793-fc93d3805083

* Add comprehensive tests for ext, project, walk, and errors modules

Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com>
Agent-Logs-Url: https://github.com/elicpeter/nyx/sessions/f3fc877e-f386-49ba-9793-fc93d3805083

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com>

* chore: Update dependencies and improve feature configuration

* fix: formatting errors in new tests

* chore: Update license list in about.toml

* chore: made functions input inline

* chore: updated cfg graph to take up the full page

* chore: add Prettier configuration and update code formatting

* Add frontend test suite with Vitest (111 tests) (#37)

* Add Vitest test suite for frontend - 111 tests across utils, components, hooks, and graph utilities

Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com>
Agent-Logs-Url: https://github.com/elicpeter/nyx/sessions/7cf0dba2-ecff-4740-ba4d-92717e74a0b7

* ci: add frontend test step to CI workflow

Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com>
Agent-Logs-Url: https://github.com/elicpeter/nyx/sessions/5bc0ac9f-0a32-4d03-9cb7-7a15aea53fca

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com>

* chore: simplify array initialization in test files for consistency

* ran typecheck

* feat: add AnalysisWorkspace component and integrate it into CfgViewerPage

* feat: update routing in AppLayout and improve empty state message in ExplorerPage

* feat: enhance scan progress tracking with additional metrics and stages

* feat: update license information and add license check script

* feat: implement cross-file symbolic execution with callee body persistence

* feat: replace dagre graphs with Graphology + ELK + Sigma for more advanced call stack and cfg rendering

* feat: ensure CFG function view is scoped to the selected function, preventing bleed into sibling functions

* feat: enhance resource tracking with proxy method summaries and improve finding extraction

* feat: add terminal function exit detection for accurate resource leak analysis

* feat: add warnings for loops and functions without bodies to improve error recovery

* feat: update lambda expression handling to ensure proper function classification and control flow

* feat: remove bounded formatting/string ops and add JSON.parse sanitizer for improved data handling

* feat: add inline return taint analysis and regression tests for improved security checks

* feat: add engine version management and migration handling for database schema updates

* feat: enhance first_call_ident to skip nested function bodies and add regression tests

* feat: enhance callee name resolution with two-segment normalization and disambiguation

* feat: add cross-file context flags and debug assertions for taint analysis

* feat: refactor taint analysis structure to unify context handling and improve clarity

* feat: enhance dead code elimination to preserve Sink, Source, and Sanitizer labels with new tests

* docs: updated CHANGELOG.md

* fmt: formatting fixes

* fix: fixed frontend formatting and lint warnings

* fix: optimized ci

* fix: optimized ci

* Add comprehensive multi-file test coverage to Nyx (#38)

* Initial checklist for multi-file test suite expansion

Agent-Logs-Url: https://github.com/elicpeter/nyx/sessions/e550cb88-9767-4442-94d4-101bf5bb0e23

Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com>

* Add 12 new multi-file test fixtures with TP/TN/near-miss coverage

Agent-Logs-Url: https://github.com/elicpeter/nyx/sessions/e550cb88-9767-4442-94d4-101bf5bb0e23

Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com>

* deleted root repo

* rebuilt to test for regressions

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com>
Co-authored-by: elipeter <elicpeter@gmail.com>

* feat: enhance import alias resolution and taint tracking

* feat: implement security hardening with CSRF protection and path validation

* feat: add support for import alias bindings in Python, PHP, and Rust

* feat: enhance CFG analysis modes and improve code readability

* feat: add detection for parameterized SQL queries to enhance security

* feat: add safe internal redirect handling and enhance session destroy validation

* feat: implement security improvements by addressing vulnerabilities in execAsync, session management, and file downloads

* feat: enhance taint detection by adding support for inline source member expressions in call arguments

* feat: implement pre-emission of Source nodes for inline source member expressions in call arguments

* feat: add support for Throw statement in control flow and error handling

* feat: add debug and echo endpoints with potential information leakage

* feat: implement internal redirect suppression and enhance taint detection

* feat: implement module alias tracking for dynamic dispatch in JS/TS

* feat: add authorization analysis module with Express support

* feat: add authorization analysis module with Express support

* feat: add tests for admin guard requirements and clean checks in authorization analysis

* feat: integrate Koa and Fastify frameworks into authorization analysis

* feat: add Flask and Django support to authorization analysis module

* feat: add support for Rails and Sinatra frameworks in authorization analysis

* feat: add support for Axum, ActixWeb, and Rocket frameworks in authorization analysis

* feat: add support for ActixWeb, Axum, and Rocket frameworks in authorization analysis

* feat: add support for Rails and Sinatra in authorization analysis

* chore: add .DS_Store to .gitignore

* refactor: simplify conditional checks and improve readability in multiple files

* refactor: update usage of Option methods for improved clarity and consistency

* refactor: improve code readability by simplifying conditional checks and formatting

* refactor: improve code formatting and readability by simplifying conditional checks

* refactor: simplify conditional checks and improve readability in multiple files

* refactor: simplify conditional checks in axum.rs for improved readability

* feat: add CodeQL analysis configuration for enhanced security scanning

* test: add comprehensive tests for `src/output.rs` SARIF builder (#39)

* chore: start test coverage improvement work

Agent-Logs-Url: https://github.com/elicpeter/nyx/sessions/cd7ff398-134e-4728-a5e7-0353a0744423

Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com>

* test: add comprehensive tests for src/output.rs SARIF builder

Agent-Logs-Url: https://github.com/elicpeter/nyx/sessions/cd7ff398-134e-4728-a5e7-0353a0744423

Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com>

* refactor: improve code formatting and readability in output.rs

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com>
Co-authored-by: elipeter <elicpeter@gmail.com>

* refactor: improve code formatting and readability in output.rs

* Potential fix for code scanning alert no. 210: Uncontrolled data used in path expression

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Potential fix for code scanning alert no. 211: Uncontrolled data used in path expression

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* refactor: enhance triage file path handling with improved error management and validation

* refactor: updated func summaries for richer detail

* refactor: update SSA summary extraction to use canonical FuncKey for distinct entries

* refactor: enhance callee metadata structure to support arity, receiver, and qualifier for better overload resolution

* refactor: add support for keyword arguments in function calls and enhance receiver extraction for method-style calls

* refactor: implement new Flask routes for safe and unsafe shell command execution

* refactor: separate receiver handling in SSA operations and enhance taint propagation

* refactor: improve arity handling by using arg_uses for positional argument count and enhance witness scoring for tainted arguments

* refactor: implement auth decorator extraction and classification for multiple languages

* refactor: enhance Rust module path resolution and use map handling for cross-file disambiguation

* refactor: introduce CalleeQuery struct for structured callee resolution and enhance resolver logic

* refactor: implement same-file identity collision handling for `runTask` to ensure correct resolver behavior

* refactor: standardize default struct initialization across multiple files

* feat: add scripts for formatting checks and auto-fixes with test summaries

* refactor: simplify character splitting and enhance namespace qualifier handling

* refactor: improve documentation clarity and enhance code readability in resolver logic

* refactor: replace default struct initialization with explicit field assignments for clarity

* feat: enhance anonymous function naming by deriving context-based bindings

* refactor: streamline match expressions for improved readability and performance

* refactor: streamline match expressions for improved readability and performance

* refactor: replace loop with while let for improved clarity and performance

* feat: add SSA constant propagation support to analysis context for improved accuracy

* feat: add SSA constant propagation support to analysis context for improved accuracy

* feat: implement shell metacharacter validation and bounded-length checks in Rust analysis

* feat: add static map analysis for command injection suppression and type safety

* refactor: simplify match statements and reduce line breaks for improved readability

* feat(summary): phase 1/5 SinkSite data model for primary sink-location attribution

Introduce SinkSite (file_rel, line, col, snippet, cap) carrying the
primary sink source-location through function summaries. Swap
SsaFuncSummary.param_to_sink and FuncSummary.param_to_sink from a coarse
Cap map to a deduped SmallVec<[SinkSite; 1]> per parameter, with a
backward-compatible cap_sites() helper and serde defaults so pre-phase-1
on-disk rows continue to deserialise cleanly.

Extraction: SinkSiteLocator bundles the tree/bytes/file_rel needed by
extract_ssa_func_summary; ParsedFile::extract_ssa_artifacts wires the
locator in for the persisted pass-1 path, while pass-2 intra-file
transient summaries fall back to cap-only sites (behavior unchanged).
Merge: GlobalSummaries::insert now unions sink sites with
(file_rel, line, col, cap) dedup via shared union_param_sink_sites
helper.

Database: JSON-serialised summary columns carry the new shape
automatically; no schema change needed.

Phase 2 will consume SinkSite in build_taint_diag() to overwrite the
caller-site Finding.line with the callee's sink line when resolved via
summary. Phase 1 keeps behavior unchanged: scanning
tests/benchmark/corpus/rust/cmdi/cmdi_indirect.rs still produces the
same (wrong) line 10 finding.

Adds round-trip tests covering SinkSite solo, SsaFuncSummary with sink
sites, legacy-JSON default handling for both summary types, and merge
dedup.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(taint): phase 2/5 thread SinkSite into SsaTaintEvent and Finding

Plumb Phase 1's SinkSite through the event pipeline into Findings,
no output change yet.  SsaTaintEvent gains `primary_sink_site:
Option<SinkSite>`; when the main or callback sink-emission path has
non-empty `param_to_sink_sites`, filter to sites whose
`(line != 0) && (cap ∩ sink_caps != ∅)` and emit one event per
distinct site — the multi-primary collapse keeps each downstream
Finding single-primary.

Resolution: ResolvedSummary and SinkInfo gain mirror
`param_to_sink_sites` fields, populated from `SsaFuncSummary.param_to_sink`
(SSA + callback paths) and `FuncSummary.param_to_sink` (global paths).
Label, local-summary, and interop resolution paths leave the field
empty — they only ever had cap-level info to begin with.

Finding: new `primary_location: Option<SinkLocation>` with
`file_rel/line/col`.  `ssa_events_to_findings` maps
`event.primary_sink_site` → `Finding.primary_location`, filtering
cap-only sites (`line == 0`) to `None` so the (0,0) sentinel never
leaks to formatters.  Dedup key extended with the primary location
so multi-site events aren't collapsed back together.

Invariants (debug_assert!):
* every SinkSite reaching emission has `line != 0 && cap ∩ sink_caps
  != ∅` — enforced by the pick_primary_sink_sites* filters;
* every populated Finding.primary_location has `line != 0` AND
  non-empty `file_rel` — the cap-only → None translation upstream
  guarantees this.

Deliberately independent of `uses_summary`: that flag tracks whether
the *taint chain* used a summary, whereas primary attribution
requires only that the *sink* itself was summary-resolved.  A local
source reaching a cross-file sink produces `uses_summary=false`
alongside a populated primary_location — documented on
Finding.primary_location, covered by
`cross_file_sink_finding_carries_primary_location`.

build_taint_diag, SARIF/JSON/explanation formatters, and the
benchmark scorer remain untouched: finding.line still comes from
`cfg_graph[finding.sink]`, so cmdi_indirect.rs still reports line 10
and the benchmark's rs-cmdi-003 row still shows FN in the LOC column.

Tests: `cross_file_sink_finding_carries_primary_location` (proves
plumbing via a synthetic FuncSummary carrying a SinkSite at 42:5) and
`cross_file_sink_cap_only_site_leaves_primary_location_none`
(regression guard against cap-only sites surfacing).  All 1566 lib
tests + integration tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(output): phase 3/5 consume primary sink location in diag + SARIF

When a finding's primary_location (populated in phase 2 from a callee
summary's SinkSite) names the dangerous instruction inside a callee
body, attribute the diagnostic line to that location instead of the
caller's call site. The call site is demoted to a Call step in
flow_steps, and a synthetic Sink step at the primary location is
appended so analysts still see the full trace.

Changes:
- Add scan_root parameter to build_taint_diag so file_rel can be
  resolved back to an absolute path via a shared resolve_file_rel
  helper. Empty file_rel (single-file scans where namespace == "")
  resolves to the file under analysis.
- Extend SinkLocation with snippet, carried from the upstream
  SinkSite so the formatter needs no second file read.
- Relax the ssa_events_to_findings debug_assert to allow empty
  file_rel, which is valid when scan root equals the file itself.
- SARIF: emit data-flow as codeFlows[0].threadFlows[0].locations[];
  locations[0] already reflects the primary sink position via the
  updated diag line/col.

Acceptance: scan on tests/benchmark/corpus/rust/cmdi/cmdi_indirect.rs
now reports line 5 (Command::new) as the primary sink, with the call
site at line 10 visible in flow_steps.

Two expect.json fixtures updated (must_match line_range widened):
- javascript/taint/context_sensitive_call: 12-14 -> 7-14 (line 8 is
  the real sink inside run()).
- rust/cfg/closure_async: 10-10 -> 10-11 (line 11 is Command::new
  inside the closure).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(bench): phase 4/5 validate primary sink attribution across corpus

Extend the benchmark scorer and ground truth to lock in phase 3's
primary-location behavior, and add fixtures that exercise the new
capability end-to-end.

Scorer (tests/benchmark_test.rs):
- Add optional `expected_call_site_lines: Option<Vec<[usize; 2]>>` on
  Case. When present, score_location_level additionally requires at
  least one flow_step in the finding's evidence trace to fall within
  ±2 of the call-site range. When absent, the check is skipped —
  fully forward-compatible with existing fixtures.
- Retain ±2 tolerance on expected_sink_lines (compared against the
  now-primary Diag.line post-phase-3).

Ground truth edits:
- rs-cmdi-cross-001: expected_sink_lines [8,8] -> [9,9]. Line 8 is the
  transform::wrap call site (a cross-file propagator, not a sink);
  line 9 is Command::new, the real sink. The ±2 tolerance happened to
  mask this stale attribution but it was semantically wrong — phase 4
  is the right time to correct it. Also adds expected_call_site_lines
  [8,8] so the new field is exercised on an existing cross-file case.
- rs-cmdi-003: adds expected_call_site_lines [10,10] (run_cmd call).
  This fixture's sink (Command::new inside run_cmd at line 5) was the
  motivating case for phases 1-3; adding the call-site assertion
  guards against regression to caller-line attribution.

New fixtures:
- rust/cmdi/cmdi_indirect_multisink.rs (rs-cmdi-009): helper run_both
  takes two tainted params and invokes two Command sinks on
  consecutive lines. Locks in that primary line lands inside the
  helper (lines 5-6), not at the caller (line 12). Notes document
  that SinkSite is currently one-per-callee so both findings today
  collapse onto the first sink; expected_sink_lines=[5,6] and
  expected_call_site_lines=[12,12] stay valid either way.
- python/cmdi/cross_indirect_sink/{app.py,helper.py} (py-cmdi-cross-
  004): sink os.system lives in helper.py (cross-file), caller in
  app.py reads env source and calls run_cmd. Verifies phase 3's
  cross-file primary attribution: Diag.path = helper.py, Diag.line =
  5, with app.py:7 recorded in flow_steps as a Call step.

Acceptance:
- `cargo test --test benchmark_test -- --ignored --nocapture` passes.
- rs-cmdi-003 is TP/TP/TP (the target flip FN->TP at LOC). All
  pre-existing TP/TP/TP fixtures remain TP/TP/TP; 2 new fixtures are
  TP/TP/TP.
- Aggregate rule-level: TP=158 FP=10 FN=1 TN=97, P=0.940 R=0.994
  F1=0.966 on the 266-case corpus (was TP=156 FP=10 FN=1 TN=97 on
  264 pre-phase-4, delta is the +2 new cases both resolving TP).
- Full `cargo test` green (1566 lib tests + all integration tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(taint): phase 5/5 lock Finding.primary_location contract via regression test

Add a regression test in src/taint/ssa_transfer.rs that wires up a synthetic
SsaFuncSummary with a SinkSite at other.rs:42:10 and drives the three
emission stages (pick_primary_sink_sites → emit_ssa_taint_events →
ssa_events_to_findings) against a minimal caller SSA body.  Asserts the
resulting Finding.primary_location is exactly that triple.

The existing integration tests in src/taint/tests.rs cover the coarse
FuncSummary path end-to-end through analyse_file.  This test locks in the
lower-level SSA-side plumbing so a future refactor that silently drops the
site between pick → emit → findings fails here rather than only at the
benchmark layer.

Also refreshes tests/benchmark/results/latest.json (timestamp only; rs-cmdi-003
remains TP/TP/TP and the aggregate P/R/F1 are unchanged from phase 4).

Closes the primary sink-location attribution feature (phases 1-5/5):
* Phase 1 — SinkSite data model on summaries.
* Phase 2 — SinkSite threaded into SsaTaintEvent and Finding.
* Phase 3 — diag + SARIF consume primary_location.
* Phase 4 — benchmark validates primary_call_site_lines across corpus.
* Phase 5 — regression test locks the event→finding contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: clean up formatting and improve readability in multiple files

* refactor: simplify type definition for deduplication key in findings

* test(harness): add must_not_match expectation for FP regression guards

Extends ExpectedFinding with must_not_match field that asserts a
diagnostic must NOT fire — presence is a hard failure. Non-consuming
scan so it coexists with must_match entries on the same rule_id.
Adds forbidden_violations accumulator and updates summary line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(regression): update expectations to ensure must_not_match for various taint and resource leak rules

* feat: implement auto-seeding for JS/TS handler parameters to enhance taint tracking

* feat: update switch statement handling to improve control flow analysis

* feat: implement promisify alias handling for JS/TS to enhance taint tracking

* feat: enhance taint tracking by refining expectation handling and adding mode filtering

* feat: refine SQL handling in stream processing and enhance auto-seeding for handler parameters

* feat: update taint tracking rules to enforce full mode matching and improve flow analysis

* feat: enhance Ruby subshell handling to improve taint tracking and flow analysis

* feat: update xss_response expectations to refine taint flow analysis and enhance regression guarding

* feat: refine framework detection and update expectation handling for Echo and Sinatra

* feat: implement max_count for taint tracking expectations and deduplicate findings

* feat: add strict_unexpected handling for taint-unsanitised-flow in expectation files

* feat: enhance deduplication of taint-unsanitised-flow findings by collapsing based on line and severity

* feat: add strict_unexpected handling for taint-unsanitised-flow in multiple expectation files

* feat: add structural invariant checks for SSA bodies

* feat: ensure deterministic phi emission order using BTreeSet

* feat: enhance handling of terminators to ensure authoritative flow through successor edges

* feat: enhance Goto terminator handling to ensure all successors are marked executable

* feat: refactor code for improved readability and organization

* feat: simplify predicate checks and enhance readability in SSA handling

* feat: implement per-file parse timeout and enhance file size handling

* feat: migrate analysis engine toggles from environment variables to configuration file

* feat: remove unnecessary whitespace in hostile_input_tests.rs

* feat: remove unnecessary whitespace in hostile_input_tests.rs

* feat: update dependencies and enhance documentation on language maturity

* feat: enhance security headers and improve request body limits

* feat: implement sink capability bits for deduplication and enhance evidence tagging

* feat: implement dynamic activation handling for gated sinks and enhance validation logic

* feat: enhance configuration documentation and clarify inline analysis cache behavior

* feat: implement panic recovery during analysis to continue scans past errors

* feat: add expectations configuration for taint analysis and performance metrics

* feat: enhance error handling and logging during file reading and mutex locking

* feat: add cross-file body loading tests and plumbing for CF-1 phase

* feat: implement cross-file k=1 context-sensitive inline taint analysis with new tests and fixtures

* feat: implement indexed-scan parity in cross-file inline analysis with new dropdown and copy functionality

* feat: enhance classification span handling in CFG and AST for improved source attribution

* feat: add new Express routes for handling user input and telemetry data

* feat: implement ternary expression handling in CFG with diamond structure for JS/TS

* feat: implement Phase CF-3 abstract-domain transfer channels in summaries

* feat: add support for string-prefix transfer in cross-file calls and update tests

* docs: reduce RESULTS.md doc size

* feat: implement Phase CF-4 per-return-path summary decomposition with tests

* feat: update parameter handling in pass1 and refactor SsaFuncSummary initialization

* feat: implement Phase CF-5 for cross-file SCC joint fixed-point convergence with new flags and tests

* feat: implement Phase CF-6 with parameter-granularity points-to summaries and associated tests

* refactor: update comments and documentation for clarity and consistency

* style: format code for consistency and readability

* refactor: simplify verdict handling and improve edge checking logic

* refactor: optimize path and identifier collection by avoiding unnecessary cloning

* chore: update Cargo.toml for Rust version 1.85 and add ignored files; modify CHANGELOG and README for clarity on state analysis defaults

* refactor: update documentation and improve clarity in configuration files

* refactor: update documentation and improve clarity in configuration files

* feat: add JS/TS pass-2 convergence tests and expectations configuration

* feat: add Phase 5 regression tests for inline cache origin attribution and update related logic

* feat: implement Phase 7 deduplication and alternative path linking for taint findings

* feat: implement structural DFS index for anonymous functions and update naming conventions

* feat: add Phase 8 regression tests for container-element taint in JS and Python

* feat: add engine-depth profiles and explain-engine option for CLI

* feat: update expectations and add new README fixtures for multi-file scan regression

* feat: implement Phase 11 callback-alias and factory patterns with regression tests

* feat: implement Terminator::Switch for multi-way dispatch and add regression tests

* feat: add real-CVE benchmark fixtures for CVE-2023-48022, CVE-2019-14939, and CVE-2023-26159 with corresponding patched variants

* refactor: extract cfg and ssa_transfer to submodules

* refactor: cargo fmt

* refactor: remove unnecessary blank line in cfg_tests.rs

* refactor: remove unnecessary planning file

* chore: update Rust version to 1.88 and bump dependencies in Cargo files

* feat: enhance triage UI with new layout and controls, update README for clarity

* feat: enhance triage UI with new layout and controls, update README for clarity

* chore: remove outdated section from README for version 0.5.0

* docs: improve clarity and consistency in README content

* chore: add "GPL-3.0-or-later" to license options in about.toml

* chore: update license handling in about.toml and check-licenses.mjs

* style: format code for improved readability in TriagePage component

* style: format code for improved readability in TriagePage component

* chore: enhance license handling and improve body_id scoping in seed lookup

* feat: introduce owner and parent body IDs for enhanced seed scoping

* feat: implement direction-aware engine provenance with new CLI flag for strict CI gating

* feat: add Undef SSA operation for improved control-flow handling

* style: improve code formatting for consistency and readability in multiple files

* feat: add 16-function chain SCC across multiple files for enhanced analysis

* style: simplify code formatting for improved readability in multiple files

* fix: update CapHitReason default implementation and improve README clarity

* docs: enhance README with detailed explanations of taint analysis and limitations

* docs: refine README for clarity and consistency in taint analysis section

* style: improve code formatting for better readability in NewScanModal and scans

* fix: update cargo-about command to use --offline for deterministic license generation

* fix: update cargo-about command to use --offline for deterministic license generation

* ci: add step to prime cargo registry cache for deterministic license generation

* feat: add support for non-sink collections in authorization analysis

* feat: enhance authorization checks with row-level ownership equality and binding tracking

* feat: implement self-scoped user handling and enhance ownership checks

* refactor: simplify assertions and formatting in authorization analysis tests

* fix: normalize line endings in THIRDPARTY-LICENSES.html generation and update README with AI disclosure

* docs: update AI disclosure section for clarity and conciseness

* feat: add AI Contribution Policy and update contributing guidelines for AI assistance disclosure

* feat: enhance authorization analysis with SSA-derived variable type classification

* feat: implement auth_finding_to_diag function for enhanced security diagnostics

* feat: add args_value_refs to CallSite struct for enhanced argument tracking

* feat: add args_value_refs to CallSite struct for enhanced argument tracking

* feat: add direction-aware engine provenance with LossDirection classification and new CLI flag

* feat: simplify strip_cap_from_call_args call by removing unnecessary line breaks

* feat: enhance error message handling in cli_validation_tests for better Windows compatibility

* feat: optimize release profile settings in Cargo.toml and update CodeQL configuration

* feat: enhance release build process with SBOM generation and SLSA provenance

* feat: update actions/checkout and actions/setup-node to v6, enhance CLI options, and improve auth-check summaries

* feat: introduce PathFact handling for path safety checks and rejection logic

* feat: introduce PathFact handling for path safety checks and rejection logic

* feat: update benchmark data and enhance path sanitization logic with new safety checks

* feat: document AI assistance in frontend UI development and human review process

* feat: add return path facts for enhanced path safety checks and update documentation

* chore: update release date for version 0.5.0 in CHANGELOG.md

* chore: clean up ci.yml by removing outdated comments and clarifying steps

* feat: implement cross-language path sanitizers and validators for enhanced security

* feat: enhance SSA value usage tracking by including block terminators and improve path safety checks

* feat: enhance switch statement handling by adding per-case path constraints and support for exclusive cases

* refactor: simplify conditional formatting and improve code readability in executor and lower modules

* feat: add vulnerable examples for various languages demonstrating authentication and sanitization issues

* feat: enhance actor context recognition for self-actor identifiers and add support for global non-sink receivers

* feat: enhance actor context recognition for self-actor identifiers and add support for global non-sink receivers

* feat: add transform classifiers for Java, Go, and Ruby with corresponding tests

* refactor: clarify comments on reassign-to-constant idiom and sink behavior in guards.rs

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Eli Peter 2026-04-25 17:59:11 -04:00 committed by GitHub
parent c4ce08b452
commit 41128177d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2144 changed files with 201812 additions and 8927 deletions

17
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,17 @@
import { QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
import { queryClient } from './api/queryClient';
import { SSEProvider } from './contexts/SSEContext';
import { AppLayout } from './components/layout/AppLayout';
export function App() {
return (
<QueryClientProvider client={queryClient}>
<SSEProvider>
<BrowserRouter>
<AppLayout />
</BrowserRouter>
</SSEProvider>
</QueryClientProvider>
);
}

104
frontend/src/api/client.ts Normal file
View file

@ -0,0 +1,104 @@
const BASE = '/api';
const CSRF_HEADER = 'X-Nyx-CSRF';
let csrfTokenPromise: Promise<string> | null = null;
export class ApiError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
this.name = 'ApiError';
}
}
async function getCsrfToken(): Promise<string> {
if (!csrfTokenPromise) {
csrfTokenPromise = fetch(`${BASE}/session`)
.then(async (res) => {
if (!res.ok) {
throw new ApiError(
res.status,
await res.text().catch(() => res.statusText),
);
}
const text = await res.text();
const payload = text
? (JSON.parse(text) as { csrf_token?: unknown })
: {};
if (
typeof payload.csrf_token !== 'string' ||
payload.csrf_token.length === 0
) {
throw new ApiError(500, 'Missing CSRF token');
}
return payload.csrf_token;
})
.catch((error) => {
csrfTokenPromise = null;
throw error;
});
}
return csrfTokenPromise;
}
function isMutatingMethod(method?: string): boolean {
const upper = (method || 'GET').toUpperCase();
return (
upper === 'POST' ||
upper === 'PUT' ||
upper === 'PATCH' ||
upper === 'DELETE'
);
}
async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
const { headers: rawHeaders, ...rest } = opts;
const url = `${BASE}${path}`;
const headers: Record<string, string> = {
...(rawHeaders as Record<string, string>),
};
if (isMutatingMethod(rest.method)) {
headers[CSRF_HEADER] = await getCsrfToken();
}
if (opts.body) {
headers['Content-Type'] = 'application/json';
}
const res = await fetch(url, {
...rest,
headers,
});
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
throw new ApiError(res.status, text);
}
// Handle empty responses
const text = await res.text();
if (!text) return undefined as T;
return JSON.parse(text) as T;
}
export function apiGet<T>(path: string, signal?: AbortSignal): Promise<T> {
return request<T>(path, { signal });
}
export function apiPost<T>(
path: string,
body?: unknown,
signal?: AbortSignal,
): Promise<T> {
return request<T>(path, {
method: 'POST',
body: body != null ? JSON.stringify(body) : undefined,
signal,
});
}
export function apiDelete<T>(path: string, signal?: AbortSignal): Promise<T> {
return request<T>(path, { method: 'DELETE', signal });
}

View file

@ -0,0 +1,157 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { apiPost, apiDelete } from '../client';
import type { LabelEntryView, TerminatorView, ProfileView } from '../types';
// --- Sources ---
export interface AddLabelBody {
lang: string;
matchers: string[];
cap: string;
case_sensitive?: boolean;
}
export function useAddSource() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: AddLabelBody) =>
apiPost<LabelEntryView>('/config/sources', body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['config', 'sources'] });
},
});
}
export function useDeleteSource() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: AddLabelBody) => apiDelete<void>('/config/sources'),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['config', 'sources'] });
},
});
}
// --- Sinks ---
export function useAddSink() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: AddLabelBody) =>
apiPost<LabelEntryView>('/config/sinks', body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['config', 'sinks'] });
},
});
}
export function useDeleteSink() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: AddLabelBody) => apiDelete<void>('/config/sinks'),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['config', 'sinks'] });
},
});
}
// --- Sanitizers ---
export function useAddSanitizer() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: AddLabelBody) =>
apiPost<LabelEntryView>('/config/sanitizers', body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['config', 'sanitizers'] });
},
});
}
export function useDeleteSanitizer() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: AddLabelBody) => apiDelete<void>('/config/sanitizers'),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['config', 'sanitizers'] });
},
});
}
// --- Terminators ---
export interface AddTerminatorBody {
lang: string;
name: string;
}
export function useAddTerminator() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: AddTerminatorBody) =>
apiPost<TerminatorView>('/config/terminators', body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['config', 'terminators'] });
},
});
}
export function useDeleteTerminator() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: AddTerminatorBody) =>
apiDelete<void>('/config/terminators'),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['config', 'terminators'] });
},
});
}
// --- Profiles ---
export function useAddProfile() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: { name: string; settings: Record<string, unknown> }) =>
apiPost<ProfileView>('/config/profiles', body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['config', 'profiles'] });
},
});
}
export function useDeleteProfile() {
const qc = useQueryClient();
return useMutation({
mutationFn: (name: string) =>
apiDelete<void>(`/config/profiles/${encodeURIComponent(name)}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['config', 'profiles'] });
},
});
}
export function useActivateProfile() {
const qc = useQueryClient();
return useMutation({
mutationFn: (name: string) =>
apiPost<void>(`/config/profiles/${encodeURIComponent(name)}/activate`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['config'] });
qc.invalidateQueries({ queryKey: ['config', 'profiles'] });
},
});
}
// --- Triage Sync ---
export function useToggleTriageSync() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: { enabled: boolean }) =>
apiPost<void>('/config/triage-sync', body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['triage', 'sync-status'] });
},
});
}

View file

@ -0,0 +1,24 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { apiPost } from '../client';
export function useToggleRule() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
apiPost<void>(`/rules/${encodeURIComponent(id)}/toggle`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['rules'] });
},
});
}
export function useCloneRule() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: { rule_id: string }) =>
apiPost<void>('/rules/clone', body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['rules'] });
},
});
}

View file

@ -0,0 +1,34 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { apiPost, apiDelete } from '../client';
import type { ScanView } from '../types';
export type ScanMode = 'full' | 'ast' | 'cfg' | 'taint';
export type EngineProfile = 'fast' | 'balanced' | 'deep';
export interface StartScanBody {
scan_root?: string;
mode?: ScanMode;
engine_profile?: EngineProfile;
}
export function useStartScan() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body?: StartScanBody) => apiPost<ScanView>('/scans', body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['scans'] });
},
});
}
export function useDeleteScan() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
apiDelete<void>(`/scans/${encodeURIComponent(id)}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['scans'] });
qc.invalidateQueries({ queryKey: ['overview'] });
},
});
}

View file

@ -0,0 +1,86 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { apiPost, apiDelete } from '../client';
export interface BulkTriageBody {
fingerprints: string[];
state: string;
note?: string;
}
export interface UpdateFindingTriageBody {
state: string;
note?: string;
}
export interface AddSuppressionBody {
by: string;
value: string;
note?: string;
}
export function useBulkTriage() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: BulkTriageBody) => apiPost<void>('/triage', body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['findings'] });
qc.invalidateQueries({ queryKey: ['triage'] });
qc.invalidateQueries({ queryKey: ['overview'] });
},
});
}
export function useUpdateFindingTriage() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
index,
...body
}: UpdateFindingTriageBody & { index: number | string }) =>
apiPost<void>(`/findings/${index}/triage`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['findings'] });
qc.invalidateQueries({ queryKey: ['triage'] });
},
});
}
export function useAddSuppression() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: AddSuppressionBody) =>
apiPost<void>('/triage/suppress', body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['triage'] });
qc.invalidateQueries({ queryKey: ['findings'] });
qc.invalidateQueries({ queryKey: ['triage', 'suppress'] });
},
});
}
export function useDeleteSuppression() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => apiDelete<void>(`/triage/suppress?id=${id}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['triage', 'suppress'] });
},
});
}
export function useTriageExport() {
return useMutation({
mutationFn: () => apiPost<unknown>('/triage/export'),
});
}
export function useTriageImport() {
const qc = useQueryClient();
return useMutation({
mutationFn: () => apiPost<unknown>('/triage/import'),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['triage'] });
qc.invalidateQueries({ queryKey: ['findings'] });
},
});
}

View file

@ -0,0 +1,48 @@
import { useQuery } from '@tanstack/react-query';
import { apiGet } from '../client';
import type { LabelEntryView, TerminatorView, ProfileView } from '../types';
export function useConfig() {
return useQuery({
queryKey: ['config'],
queryFn: ({ signal }) => apiGet<unknown>('/config', signal),
});
}
export function useSources() {
return useQuery({
queryKey: ['config', 'sources'],
queryFn: ({ signal }) =>
apiGet<LabelEntryView[]>('/config/sources', signal),
});
}
export function useSinks() {
return useQuery({
queryKey: ['config', 'sinks'],
queryFn: ({ signal }) => apiGet<LabelEntryView[]>('/config/sinks', signal),
});
}
export function useSanitizers() {
return useQuery({
queryKey: ['config', 'sanitizers'],
queryFn: ({ signal }) =>
apiGet<LabelEntryView[]>('/config/sanitizers', signal),
});
}
export function useTerminators() {
return useQuery({
queryKey: ['config', 'terminators'],
queryFn: ({ signal }) =>
apiGet<TerminatorView[]>('/config/terminators', signal),
});
}
export function useProfiles() {
return useQuery({
queryKey: ['config', 'profiles'],
queryFn: ({ signal }) => apiGet<ProfileView[]>('/config/profiles', signal),
});
}

View file

@ -0,0 +1,111 @@
import { useQuery } from '@tanstack/react-query';
import { apiGet } from '../client';
import type {
FunctionInfo,
CfgGraphView,
SsaBodyView,
TaintAnalysisView,
AbstractInterpView,
SymexView,
CallGraphView,
FuncSummaryView,
} from '../types';
export function useDebugFunctions(file: string | null) {
return useQuery({
queryKey: ['debug', 'functions', file],
queryFn: ({ signal }) =>
apiGet<FunctionInfo[]>(
`/debug/functions?file=${encodeURIComponent(file!)}`,
signal,
),
enabled: !!file,
});
}
export function useDebugCfg(file: string | null, fn_name: string | null) {
return useQuery({
queryKey: ['debug', 'cfg', file, fn_name],
queryFn: ({ signal }) =>
apiGet<CfgGraphView>(
`/debug/cfg?file=${encodeURIComponent(file!)}&function=${encodeURIComponent(fn_name!)}`,
signal,
),
enabled: !!file && !!fn_name,
});
}
export function useDebugSsa(file: string | null, fn_name: string | null) {
return useQuery({
queryKey: ['debug', 'ssa', file, fn_name],
queryFn: ({ signal }) =>
apiGet<SsaBodyView>(
`/debug/ssa?file=${encodeURIComponent(file!)}&function=${encodeURIComponent(fn_name!)}`,
signal,
),
enabled: !!file && !!fn_name,
});
}
export function useDebugTaint(file: string | null, fn_name: string | null) {
return useQuery({
queryKey: ['debug', 'taint', file, fn_name],
queryFn: ({ signal }) =>
apiGet<TaintAnalysisView>(
`/debug/taint?file=${encodeURIComponent(file!)}&function=${encodeURIComponent(fn_name!)}`,
signal,
),
enabled: !!file && !!fn_name,
});
}
export function useDebugAbstractInterp(
file: string | null,
fn_name: string | null,
) {
return useQuery({
queryKey: ['debug', 'abstract-interp', file, fn_name],
queryFn: ({ signal }) =>
apiGet<AbstractInterpView>(
`/debug/abstract-interp?file=${encodeURIComponent(file!)}&function=${encodeURIComponent(fn_name!)}`,
signal,
),
enabled: !!file && !!fn_name,
});
}
export function useDebugSymex(file: string | null, fn_name: string | null) {
return useQuery({
queryKey: ['debug', 'symex', file, fn_name],
queryFn: ({ signal }) =>
apiGet<SymexView>(
`/debug/symex?file=${encodeURIComponent(file!)}&function=${encodeURIComponent(fn_name!)}`,
signal,
),
enabled: !!file && !!fn_name,
});
}
export function useDebugCallGraph(scope: string, file?: string | null) {
const params = new URLSearchParams({ scope });
if (file) params.set('file', file);
return useQuery({
queryKey: ['debug', 'call-graph', scope, file],
queryFn: ({ signal }) =>
apiGet<CallGraphView>(`/debug/call-graph?${params}`, signal),
});
}
export function useDebugSummaries(
file?: string | null,
fn_name?: string | null,
) {
const params = new URLSearchParams();
if (file) params.set('file', file);
if (fn_name) params.set('function', fn_name);
return useQuery({
queryKey: ['debug', 'summaries', file, fn_name],
queryFn: ({ signal }) =>
apiGet<FuncSummaryView[]>(`/debug/summaries?${params}`, signal),
});
}

View file

@ -0,0 +1,37 @@
import { useQuery } from '@tanstack/react-query';
import { apiGet } from '../client';
import type { TreeEntry, SymbolEntry, ExplorerFinding } from '../types';
export function useExplorerTree(path?: string) {
return useQuery({
queryKey: ['explorer', 'tree', path ?? ''],
queryFn: ({ signal }) => {
const qs = path ? `?path=${encodeURIComponent(path)}` : '';
return apiGet<TreeEntry[]>(`/explorer/tree${qs}`, signal);
},
});
}
export function useExplorerSymbols(path: string | null) {
return useQuery({
queryKey: ['explorer', 'symbols', path],
queryFn: ({ signal }) =>
apiGet<SymbolEntry[]>(
`/explorer/symbols?path=${encodeURIComponent(path!)}`,
signal,
),
enabled: !!path,
});
}
export function useExplorerFindings(path: string | null) {
return useQuery({
queryKey: ['explorer', 'findings', path],
queryFn: ({ signal }) =>
apiGet<ExplorerFinding[]>(
`/explorer/findings?path=${encodeURIComponent(path!)}`,
signal,
),
enabled: !!path,
});
}

View file

@ -0,0 +1,63 @@
import { useQuery, type QueryClient } from '@tanstack/react-query';
import { apiGet } from '../client';
import type { PaginatedFindings, FindingView, FilterValues } from '../types';
export interface FindingsParams {
page?: number;
per_page?: number;
severity?: string;
category?: string;
confidence?: string;
language?: string;
rule_id?: string;
status?: string;
search?: string;
sort_by?: string;
sort_dir?: string;
}
function buildQuery(params: FindingsParams): string {
const entries = Object.entries(params).filter(
([, v]) => v !== undefined && v !== null && v !== '',
);
if (entries.length === 0) return '';
const qs = new URLSearchParams(
entries.map(([k, v]) => [k, String(v)]),
).toString();
return `?${qs}`;
}
export function useFindings(params: FindingsParams = {}) {
return useQuery({
queryKey: ['findings', params],
queryFn: ({ signal }) =>
apiGet<PaginatedFindings>(`/findings${buildQuery(params)}`, signal),
});
}
export function useFinding(id: number | string) {
return useQuery({
queryKey: ['findings', id],
queryFn: ({ signal }) => apiGet<FindingView>(`/findings/${id}`, signal),
enabled: id !== undefined && id !== null && id !== '',
});
}
export function fetchFindingDetail(
qc: QueryClient,
index: number,
signal?: AbortSignal,
): Promise<FindingView> {
return qc.fetchQuery({
queryKey: ['findings', String(index)],
queryFn: ({ signal: s }) =>
apiGet<FindingView>(`/findings/${index}`, s ?? signal),
});
}
export function useFindingFilters() {
return useQuery({
queryKey: ['findings', 'filters'],
queryFn: ({ signal }) => apiGet<FilterValues>('/findings/filters', signal),
});
}

View file

@ -0,0 +1,11 @@
import { useQuery } from '@tanstack/react-query';
import { apiGet } from '../client';
import type { HealthResponse } from '../types';
export function useHealth() {
return useQuery({
queryKey: ['health'],
queryFn: ({ signal }) => apiGet<HealthResponse>('/health', signal),
staleTime: 60_000,
});
}

View file

@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/react-query';
import { apiGet } from '../client';
import type { OverviewResponse, TrendPoint } from '../types';
export function useOverview() {
return useQuery({
queryKey: ['overview'],
queryFn: ({ signal }) => apiGet<OverviewResponse>('/overview', signal),
});
}
export function useOverviewTrends() {
return useQuery({
queryKey: ['overview', 'trends'],
queryFn: ({ signal }) => apiGet<TrendPoint[]>('/overview/trends', signal),
});
}

View file

@ -0,0 +1,18 @@
import { useQuery } from '@tanstack/react-query';
import { apiGet } from '../client';
import type { RuleListItem, RuleDetailView } from '../types';
export function useRules() {
return useQuery({
queryKey: ['rules'],
queryFn: ({ signal }) => apiGet<RuleListItem[]>('/rules', signal),
});
}
export function useRuleDetail(id: string) {
return useQuery({
queryKey: ['rules', id],
queryFn: ({ signal }) => apiGet<RuleDetailView>(`/rules/${id}`, signal),
enabled: !!id,
});
}

View file

@ -0,0 +1,89 @@
import { useQuery } from '@tanstack/react-query';
import { apiGet } from '../client';
import type {
ScanView,
PaginatedFindings,
ScanLogEntry,
ScanMetricsSnapshot,
CompareResponse,
} from '../types';
export function useScans() {
return useQuery({
queryKey: ['scans'],
queryFn: ({ signal }) => apiGet<ScanView[]>('/scans', signal),
});
}
export function useScan(id: string) {
return useQuery({
queryKey: ['scans', id],
queryFn: ({ signal }) => apiGet<ScanView>(`/scans/${id}`, signal),
enabled: !!id,
});
}
export interface ScanFindingsParams {
page?: number;
per_page?: number;
severity?: string;
category?: string;
search?: string;
}
function buildQuery(
params: Record<string, string | number | boolean | undefined | null>,
): string {
const entries = Object.entries(params).filter(
([, v]) => v !== undefined && v !== null && v !== '',
);
if (entries.length === 0) return '';
const qs = new URLSearchParams(
entries.map(([k, v]) => [k, String(v)]),
).toString();
return `?${qs}`;
}
export function useScanFindings(id: string, params: ScanFindingsParams = {}) {
return useQuery({
queryKey: ['scans', id, 'findings', params],
queryFn: ({ signal }) =>
apiGet<PaginatedFindings>(
`/scans/${id}/findings${buildQuery({ ...params })}`,
signal,
),
enabled: !!id,
});
}
export function useScanLogs(id: string, level?: string) {
return useQuery({
queryKey: ['scans', id, 'logs', level],
queryFn: ({ signal }) => {
const qs = level ? `?level=${encodeURIComponent(level)}` : '';
return apiGet<ScanLogEntry[]>(`/scans/${id}/logs${qs}`, signal);
},
enabled: !!id,
});
}
export function useScanMetrics(id: string) {
return useQuery({
queryKey: ['scans', id, 'metrics'],
queryFn: ({ signal }) =>
apiGet<ScanMetricsSnapshot>(`/scans/${id}/metrics`, signal),
enabled: !!id,
});
}
export function useScanCompare(left: string, right: string) {
return useQuery({
queryKey: ['scans', 'compare', left, right],
queryFn: ({ signal }) =>
apiGet<CompareResponse>(
`/scans/compare?left=${encodeURIComponent(left)}&right=${encodeURIComponent(right)}`,
signal,
),
enabled: !!left && !!right,
});
}

View file

@ -0,0 +1,67 @@
import { useQuery } from '@tanstack/react-query';
import { apiGet } from '../client';
import type {
PaginatedTriage,
PaginatedAudit,
SuppressionRule,
SyncStatus,
} from '../types';
export interface TriageParams {
state?: string;
page?: number;
per_page?: number;
}
export interface TriageAuditParams {
fingerprint?: string;
page?: number;
per_page?: number;
}
function buildQuery(
params: Record<string, string | number | boolean | undefined | null>,
): string {
const entries = Object.entries(params).filter(
([, v]) => v !== undefined && v !== null && v !== '',
);
if (entries.length === 0) return '';
const qs = new URLSearchParams(
entries.map(([k, v]) => [k, String(v)]),
).toString();
return `?${qs}`;
}
export function useTriage(params: TriageParams = {}) {
return useQuery({
queryKey: ['triage', params],
queryFn: ({ signal }) =>
apiGet<PaginatedTriage>(`/triage${buildQuery({ ...params })}`, signal),
});
}
export function useTriageAudit(params: TriageAuditParams = {}) {
return useQuery({
queryKey: ['triage', 'audit', params],
queryFn: ({ signal }) =>
apiGet<PaginatedAudit>(
`/triage/audit${buildQuery({ ...params })}`,
signal,
),
});
}
export function useSuppressions() {
return useQuery({
queryKey: ['triage', 'suppress'],
queryFn: ({ signal }) =>
apiGet<{ rules: SuppressionRule[] }>('/triage/suppress', signal),
});
}
export function useSyncStatus() {
return useQuery({
queryKey: ['triage', 'sync-status'],
queryFn: ({ signal }) => apiGet<SyncStatus>('/triage/sync-status', signal),
});
}

View file

@ -0,0 +1,11 @@
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
refetchOnWindowFocus: true,
retry: 1,
},
},
});

593
frontend/src/api/types.ts Normal file
View file

@ -0,0 +1,593 @@
// Evidence types (from src/evidence.rs)
export type Confidence = 'Low' | 'Medium' | 'High';
export type FlowStepKind = 'source' | 'assignment' | 'call' | 'phi' | 'sink';
export interface FlowStep {
step: number;
kind: FlowStepKind;
file: string;
line: number;
col: number;
snippet?: string;
variable?: string;
callee?: string;
function?: string;
is_cross_file?: boolean;
}
export interface SpanEvidence {
path: string;
line: number;
col: number;
kind: string;
snippet?: string;
}
export interface StateEvidence {
machine: string;
subject?: string;
from_state: string;
to_state: string;
}
export interface Evidence {
source?: SpanEvidence;
sink?: SpanEvidence;
guards: SpanEvidence[];
sanitizers: SpanEvidence[];
state?: StateEvidence;
notes: string[];
flow_steps: FlowStep[];
explanation?: string;
confidence_limiters: string[];
}
// Finding types
export interface CodeContextView {
start_line: number;
lines: string[];
highlight_line: number;
}
export interface RelatedFindingView {
index: number;
rule_id: string;
path: string;
line: number;
severity: string;
}
export interface FindingView {
index: number;
fingerprint: string;
portable_fingerprint?: string;
path: string;
line: number;
col: number;
severity: string;
rule_id: string;
category: string;
confidence?: Confidence;
rank_score?: number;
message?: string;
labels: [string, string][];
path_validated: boolean;
suppressed: boolean;
language?: string;
status: string;
triage_state: string;
triage_note?: string;
code_context?: CodeContextView;
evidence?: Evidence;
guard_kind?: string;
rank_reason?: [string, string][];
sanitizer_status?: string;
related_findings: RelatedFindingView[];
}
export interface FindingSummary {
total: number;
by_severity: Record<string, number>;
by_category: Record<string, number>;
by_rule: Record<string, number>;
by_file: Record<string, number>;
}
export interface FilterValues {
severities: string[];
categories: string[];
confidences: string[];
languages: string[];
rules: string[];
statuses: string[];
}
// Scan types
export interface TimingBreakdown {
walk_ms: number;
pass1_ms: number;
call_graph_ms: number;
pass2_ms: number;
post_process_ms: number;
}
export interface ScanMetricsSnapshot {
cfg_nodes: number;
call_edges: number;
functions_analyzed: number;
summaries_reused: number;
unresolved_calls: number;
}
export interface ScanView {
id: string;
status: string;
scan_root: string;
started_at?: string;
finished_at?: string;
duration_secs?: number;
finding_count?: number;
error?: string;
engine_version?: string;
languages?: string[];
files_scanned?: number;
timing?: TimingBreakdown;
metrics?: ScanMetricsSnapshot;
}
// Scan Comparison types
export interface CompareScanInfo {
id: string;
started_at?: string;
finding_count: number;
}
export interface CompareSummary {
new_count: number;
fixed_count: number;
changed_count: number;
unchanged_count: number;
severity_delta: Record<string, number>;
}
export interface ComparedFinding extends FindingView {
fingerprint: string;
}
export interface FieldChange {
field: string;
old_value: string;
new_value: string;
}
export interface ChangedFinding extends FindingView {
fingerprint: string;
changes: FieldChange[];
}
export interface CompareResponse {
left_scan: CompareScanInfo;
right_scan: CompareScanInfo;
summary: CompareSummary;
new_findings: ComparedFinding[];
fixed_findings: ComparedFinding[];
changed_findings: ChangedFinding[];
unchanged_findings: ComparedFinding[];
}
// Overview types
export interface OverviewCount {
name: string;
count: number;
}
export interface NoisyRule {
rule_id: string;
finding_count: number;
suppression_rate: number;
}
export interface ScanSummary {
id: string;
status: string;
started_at?: string;
duration_secs?: number;
finding_count?: number;
}
export interface Insight {
kind: string;
message: string;
severity: string;
action_url?: string;
}
export interface TrendPoint {
scan_id: string;
timestamp: string;
total: number;
by_severity: Record<string, number>;
}
export interface OverviewResponse {
state: string;
total_findings: number;
new_since_last: number;
fixed_since_last: number;
high_confidence_rate: number;
triage_coverage: number;
latest_scan_duration_secs?: number;
latest_scan_id?: string;
latest_scan_at?: string;
by_severity: Record<string, number>;
by_category: Record<string, number>;
by_language: Record<string, number>;
top_files: OverviewCount[];
top_directories: OverviewCount[];
top_rules: OverviewCount[];
noisy_rules: NoisyRule[];
recent_scans: ScanSummary[];
insights: Insight[];
}
// Rules types
export interface RuleListItem {
id: string;
title: string;
language: string;
kind: string;
cap: string;
matchers: string[];
enabled: boolean;
is_custom: boolean;
is_gated: boolean;
case_sensitive: boolean;
finding_count: number;
suppression_rate: number;
}
export interface RuleDetailView extends RuleListItem {
example_findings: RelatedFindingView[];
}
// Config types
export interface RuleView {
lang: string;
matchers: string[];
kind: string;
cap: string;
}
export interface TerminatorView {
lang: string;
name: string;
}
export interface LabelEntryView {
lang: string;
matchers: string[];
cap: string;
case_sensitive: boolean;
is_builtin: boolean;
}
export interface ProfileView {
name: string;
is_builtin: boolean;
settings: Record<string, unknown>;
}
// Health
export interface HealthResponse {
status: string;
version: string;
scan_root: string;
}
// Paginated response wrappers
export interface PaginatedFindings {
findings: FindingView[];
total: number;
page: number;
per_page: number;
}
// Triage types
export interface TriageEntry {
fingerprint: string;
state: string;
note: string;
updated_at: string;
finding?: FindingView;
}
export interface PaginatedTriage {
entries: TriageEntry[];
total: number;
page: number;
per_page: number;
}
export interface AuditEntry {
id: number;
fingerprint: string;
action: string;
previous_state: string;
new_state: string;
note: string;
timestamp: string;
}
export interface PaginatedAudit {
entries: AuditEntry[];
total: number;
page: number;
per_page: number;
}
export interface SuppressionRule {
id: number;
suppress_by: string;
match_value: string;
state: string;
note: string;
created_at: string;
}
export interface SyncStatus {
file_path: string;
file_exists: boolean;
sync_enabled: boolean;
decisions: number;
suppression_rules: number;
}
// File viewer
export interface FileResponse {
path: string;
lines: { number: number; content: string }[];
total_lines: number;
}
// Explorer types
export interface TreeEntry {
name: string;
entry_type: 'file' | 'dir';
path: string;
language?: string;
finding_count: number;
severity_max?: string;
}
export interface SymbolEntry {
name: string;
kind: string;
line?: number;
finding_count: number;
namespace?: string;
arity?: number;
}
export interface ExplorerFinding {
index: number;
line: number;
col: number;
severity: string;
rule_id: string;
category: string;
message?: string;
confidence?: string;
}
// Scan log entry
export interface ScanLogEntry {
timestamp: string;
level: string;
message: string;
file_path?: string;
detail?: string;
}
// ── Debug view types ─────────────────────────────────────────────────────────
export interface FunctionInfo {
name: string;
namespace: string;
param_count: number;
line: number;
source_caps: string[];
sanitizer_caps: string[];
sink_caps: string[];
}
// CFG
export interface CfgNodeView {
id: number;
kind: string;
span: [number, number];
line: number;
defines?: string;
uses: string[];
callee?: string;
labels: string[];
condition_text?: string;
enclosing_func?: string;
}
export interface CfgEdgeView {
source: number;
target: number;
kind: string;
}
export interface CfgGraphView {
nodes: CfgNodeView[];
edges: CfgEdgeView[];
entry: number;
}
// SSA
export interface SsaInstView {
value: number;
op: string;
operands: string[];
var_name?: string;
span: [number, number];
line: number;
}
export interface SsaBlockView {
id: number;
phis: SsaInstView[];
body: SsaInstView[];
terminator: string;
preds: number[];
succs: number[];
}
export interface SsaBodyView {
blocks: SsaBlockView[];
entry: number;
num_values: number;
}
// Taint
export interface TaintValueView {
ssa_value: number;
var_name?: string;
caps: string[];
uses_summary: boolean;
}
export interface TaintBlockStateView {
block_id: number;
values: TaintValueView[];
validated_must: number;
validated_may: number;
}
export interface TaintEventView {
sink_node: number;
sink_caps: string[];
tainted_values: TaintValueView[];
all_validated: boolean;
uses_summary: boolean;
}
export interface TaintAnalysisView {
block_states: TaintBlockStateView[];
events: TaintEventView[];
}
// Abstract Interpretation
export interface AbstractValueView {
ssa_value: number;
var_name?: string;
interval_lo?: number;
interval_hi?: number;
string_prefix?: string;
string_suffix?: string;
known_zero: number;
known_one: number;
}
export interface AbstractBlockView {
block_id: number;
values: AbstractValueView[];
}
export interface TypeFactView {
ssa_value: number;
var_name?: string;
type_kind: string;
nullable: boolean;
}
export interface ConstValueViewEntry {
ssa_value: number;
var_name?: string;
value: string;
}
export interface AbstractInterpView {
blocks: AbstractBlockView[];
type_facts: TypeFactView[];
const_values: ConstValueViewEntry[];
}
// Symbolic Execution
export interface SymexValueView {
ssa_value: number;
var_name?: string;
expression: string;
}
export interface PathConstraintView {
block: number;
condition: string;
polarity: boolean;
}
export interface SymexView {
values: SymexValueView[];
path_constraints: PathConstraintView[];
tainted_roots: number[];
}
// Call Graph
export interface CallGraphNodeView {
id: number;
name: string;
file: string;
lang: string;
namespace: string;
arity?: number;
}
export interface CallGraphEdgeView {
source: number;
target: number;
call_site: string;
}
export interface CallGraphView {
nodes: CallGraphNodeView[];
edges: CallGraphEdgeView[];
sccs: number[][];
unresolved_count: number;
ambiguous_count: number;
}
// Summaries
export interface ParamReturnView {
param_index: number;
transform: string;
}
export interface ParamSinkView {
param_index: number;
sink_caps: string[];
}
export interface SsaSummaryView {
param_to_return: ParamReturnView[];
param_to_sink: ParamSinkView[];
source_caps: string[];
}
export interface FuncSummaryView {
name: string;
file_path: string;
lang: string;
namespace: string;
arity?: number;
param_count: number;
source_caps: string[];
sanitizer_caps: string[];
sink_caps: string[];
propagates_taint: boolean;
propagating_params: number[];
tainted_sink_params: number[];
callees: string[];
ssa_summary?: SsaSummaryView;
}

View file

@ -0,0 +1,171 @@
import { useCallback, useEffect, useRef, useState } from 'react';
type Status = 'idle' | 'working' | 'copied' | 'failed';
interface CopyMarkdownButtonProps {
getMarkdown: () => string | Promise<string>;
label?: string;
className?: string;
title?: string;
stopPropagation?: boolean;
iconOnly?: boolean;
}
const COPIED_MS = 1500;
const FAILED_MS = 2000;
const ICON_SIZE = 14;
function CopyIcon() {
return (
<svg
width={ICON_SIZE}
height={ICON_SIZE}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="5" y="5" width="9" height="9" rx="1.5" />
<path d="M11 5V3.5A1.5 1.5 0 0 0 9.5 2h-6A1.5 1.5 0 0 0 2 3.5v6A1.5 1.5 0 0 0 3.5 11H5" />
</svg>
);
}
function CheckIcon() {
return (
<svg
width={ICON_SIZE}
height={ICON_SIZE}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M3 8.5l3 3 7-7" />
</svg>
);
}
function FailIcon() {
return (
<svg
width={ICON_SIZE}
height={ICON_SIZE}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
);
}
export function CopyMarkdownButton({
getMarkdown,
label = 'Copy',
className,
title,
stopPropagation,
iconOnly,
}: CopyMarkdownButtonProps) {
const [status, setStatus] = useState<Status>('idle');
const timeoutRef = useRef<number | null>(null);
useEffect(() => {
return () => {
if (timeoutRef.current != null) {
window.clearTimeout(timeoutRef.current);
}
};
}, []);
const scheduleReset = useCallback((ms: number) => {
if (timeoutRef.current != null) window.clearTimeout(timeoutRef.current);
timeoutRef.current = window.setTimeout(() => {
setStatus('idle');
timeoutRef.current = null;
}, ms);
}, []);
const handleClick = useCallback(
async (e: React.MouseEvent<HTMLButtonElement>) => {
if (stopPropagation) e.stopPropagation();
if (status === 'working') return;
if (
typeof navigator === 'undefined' ||
!navigator.clipboard ||
typeof navigator.clipboard.writeText !== 'function'
) {
setStatus('failed');
scheduleReset(FAILED_MS);
return;
}
setStatus('working');
try {
const text = await getMarkdown();
await navigator.clipboard.writeText(text);
setStatus('copied');
scheduleReset(COPIED_MS);
} catch (err) {
console.error('CopyMarkdownButton: failed to copy', err);
setStatus('failed');
scheduleReset(FAILED_MS);
}
},
[getMarkdown, scheduleReset, status, stopPropagation],
);
const displayLabel =
status === 'working'
? 'Copying…'
: status === 'copied'
? 'Copied!'
: status === 'failed'
? 'Failed'
: label;
const classes = [
'btn',
'btn-sm',
'copy-btn',
iconOnly ? 'copy-btn--icon' : '',
status === 'copied' ? 'copy-btn--copied' : '',
status === 'failed' ? 'copy-btn--failed' : '',
className || '',
]
.filter(Boolean)
.join(' ');
const icon =
status === 'copied' ? (
<CheckIcon />
) : status === 'failed' ? (
<FailIcon />
) : (
<CopyIcon />
);
return (
<button
type="button"
className={classes}
title={title ?? (iconOnly ? displayLabel : undefined)}
aria-label={iconOnly ? displayLabel : undefined}
disabled={status === 'working'}
onClick={handleClick}
>
{iconOnly ? icon : displayLabel}
</button>
);
}

View file

@ -0,0 +1,84 @@
export interface BarItem {
label: string;
value: number;
color?: string;
}
interface HorizontalBarChartProps {
items: BarItem[];
maxValue?: number;
width?: number;
}
export function HorizontalBarChart({
items,
maxValue,
width = 400,
}: HorizontalBarChartProps) {
if (!items || items.length === 0) {
return (
<div className="empty-state" style={{ padding: 20 }}>
<p>No data</p>
</div>
);
}
const barH = 22;
const gap = 4;
const labelW = 110;
const valueW = 45;
const barAreaW = width - labelW - valueW - 16;
const totalH = items.length * (barH + gap);
const maxVal = maxValue ?? Math.max(...items.map((i) => i.value), 1);
return (
<div className="chart-container">
<svg
viewBox={`0 0 ${width} ${totalH}`}
width="100%"
preserveAspectRatio="xMinYMin meet"
xmlns="http://www.w3.org/2000/svg"
>
{items.map((item, i) => {
const y = i * (barH + gap);
const w = Math.max((item.value / maxVal) * barAreaW, 2);
const color = item.color || 'var(--accent)';
return (
<g key={item.label}>
<text
x={labelW - 8}
y={y + barH / 2 + 4}
textAnchor="end"
fontSize={11}
fontFamily="var(--font)"
fill="var(--text-secondary)"
>
{item.label}
</text>
<rect
x={labelW}
y={y + 2}
width={w}
height={barH - 4}
rx={3}
fill={color}
opacity={0.85}
/>
<text
x={labelW + barAreaW + 8}
y={y + barH / 2 + 4}
textAnchor="start"
fontSize={11}
fontFamily="var(--font-mono)"
fontWeight={600}
fill="var(--text)"
>
{item.value}
</text>
</g>
);
})}
</svg>
</div>
);
}

View file

@ -0,0 +1,139 @@
import { formatShortDate } from '../../utils/formatDate';
export interface LinePoint {
label: string;
value: number;
}
interface LineChartProps {
points: LinePoint[];
color?: string;
width?: number;
height?: number;
}
export function LineChart({
points,
color = 'var(--accent)',
width = 400,
height = 160,
}: LineChartProps) {
if (!points || points.length < 2) {
return (
<div className="empty-state" style={{ padding: 20 }}>
<p>Need multiple scans for trends</p>
</div>
);
}
const pad = { top: 15, right: 15, bottom: 30, left: 40 };
const plotW = width - pad.left - pad.right;
const plotH = height - pad.top - pad.bottom;
const maxVal = Math.max(...points.map((p) => p.value), 1);
const minVal = 0;
const yRange = maxVal - minVal || 1;
const xStep = plotW / Math.max(points.length - 1, 1);
const coords = points.map((p, i) => ({
x: pad.left + i * xStep,
y: pad.top + plotH - ((p.value - minVal) / yRange) * plotH,
label: p.label,
value: p.value,
}));
const polyPoints = coords.map((c) => `${c.x},${c.y}`).join(' ');
const areaPoints = `${coords[0].x},${pad.top + plotH} ${polyPoints} ${coords[coords.length - 1].x},${pad.top + plotH}`;
// Y-axis grid lines
const yTicks = 4;
const gridLines = [];
for (let i = 0; i <= yTicks; i++) {
const y = pad.top + (i / yTicks) * plotH;
const val = Math.round(maxVal - (i / yTicks) * yRange);
gridLines.push({ y, val });
}
// X-axis label sampling
const maxLabels = 6;
const step = Math.max(1, Math.ceil(coords.length / maxLabels));
return (
<div className="chart-container">
<svg
viewBox={`0 0 ${width} ${height}`}
width="100%"
preserveAspectRatio="xMinYMin meet"
xmlns="http://www.w3.org/2000/svg"
>
{/* Grid lines */}
{gridLines.map((g, i) => (
<g key={i}>
<line
x1={pad.left}
y1={g.y}
x2={pad.left + plotW}
y2={g.y}
stroke="var(--border-light)"
strokeWidth={1}
/>
<text
x={pad.left - 6}
y={g.y + 3}
textAnchor="end"
fontSize={9}
fontFamily="var(--font-mono)"
fill="var(--text-tertiary)"
>
{g.val}
</text>
</g>
))}
{/* Area fill */}
<polygon points={areaPoints} fill={color} opacity={0.08} />
{/* Line */}
<polyline
points={polyPoints}
fill="none"
stroke={color}
strokeWidth={2}
strokeLinejoin="round"
strokeLinecap="round"
/>
{/* Dots */}
{coords.map((c, i) => (
<circle
key={i}
cx={c.x}
cy={c.y}
r={3}
fill={color}
stroke="var(--bg)"
strokeWidth={2}
/>
))}
{/* X-axis labels */}
{coords.map((c, i) => {
if (i % step !== 0 && i !== coords.length - 1) return null;
return (
<text
key={i}
x={c.x}
y={height - 4}
textAnchor="middle"
fontSize={9}
fontFamily="var(--font)"
fill="var(--text-tertiary)"
>
{formatShortDate(c.label)}
</text>
);
})}
</svg>
</div>
);
}

View file

@ -0,0 +1,185 @@
import { useEffect, useRef } from 'react';
import { useQuery } from '@tanstack/react-query';
import { apiGet } from '../../api/client';
import { highlightSyntax, escapeHtml } from '../../utils/syntaxHighlight';
import type { FileResponse, ExplorerFinding } from '../../api/types';
interface LineHighlights {
sourceLine?: number;
sinkLine?: number;
findingLine?: number;
}
interface CodeViewerProps {
filePath: string;
findings?: ExplorerFinding[];
highlights?: LineHighlights;
highlightLine?: number;
flowLines?: Set<number>;
language?: string;
className?: string;
initialScrollTop?: number;
onScrollPositionChange?: (scrollTop: number) => void;
}
export function CodeViewer({
filePath,
findings,
highlights,
highlightLine,
flowLines,
language,
className,
initialScrollTop,
onScrollPositionChange,
}: CodeViewerProps) {
const bodyRef = useRef<HTMLDivElement>(null);
const {
data: fileData,
isLoading,
error,
} = useQuery({
queryKey: ['files', filePath],
queryFn: ({ signal }) =>
apiGet<FileResponse>(
`/files?path=${encodeURIComponent(filePath)}`,
signal,
),
enabled: !!filePath,
staleTime: 5 * 60_000,
});
const scrollTarget = highlightLine ?? highlights?.findingLine;
useEffect(() => {
if (!fileData || !scrollTarget || !bodyRef.current) return;
const timer = requestAnimationFrame(() => {
const target = bodyRef.current?.querySelector(
`[data-line="${scrollTarget}"]`,
);
if (target)
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
});
return () => cancelAnimationFrame(timer);
}, [fileData, scrollTarget]);
useEffect(() => {
if (
!fileData ||
scrollTarget ||
initialScrollTop == null ||
!bodyRef.current
) {
return;
}
const timer = requestAnimationFrame(() => {
if (bodyRef.current) {
bodyRef.current.scrollTop = initialScrollTop;
}
});
return () => cancelAnimationFrame(timer);
}, [fileData, initialScrollTop, scrollTarget]);
// Build a set of finding lines for gutter markers
const findingsByLine = new Map<number, ExplorerFinding>();
if (findings) {
for (const f of findings) {
// Keep the highest severity per line
const existing = findingsByLine.get(f.line);
if (
!existing ||
severityRank(f.severity) > severityRank(existing.severity)
) {
findingsByLine.set(f.line, f);
}
}
}
const lang = (language || '').toLowerCase();
if (isLoading) {
return (
<div className={className} style={{ padding: 40, textAlign: 'center' }}>
Loading file...
</div>
);
}
if (error) {
return (
<div className={className}>
<div className="error-state" style={{ padding: 40 }}>
<p>
Could not load file:{' '}
{error instanceof Error ? error.message : 'Unknown error'}
</p>
</div>
</div>
);
}
if (!fileData) return null;
return (
<div
className={`code-viewer-body ${className || ''}`}
ref={bodyRef}
onScroll={(event) =>
onScrollPositionChange?.(event.currentTarget.scrollTop)
}
>
{fileData.lines.map((l) => {
let cls = 'code-line';
if (highlights) {
if (l.number === highlights.sourceLine) cls += ' highlight-source';
else if (l.number === highlights.sinkLine) cls += ' highlight-sink';
else if (l.number === highlights.findingLine)
cls += ' highlight-finding';
else if (flowLines?.has(l.number)) cls += ' highlight-flow';
} else if (highlightLine && l.number === highlightLine) {
cls += ' highlight-finding';
}
const gutterFinding = findingsByLine.get(l.number);
return (
<div key={l.number} className={cls} data-line={l.number}>
<span className="line-gutter">
{gutterFinding ? (
<span
className={`gutter-marker sev-${gutterFinding.severity.toLowerCase()}`}
title={`${gutterFinding.rule_id}: ${gutterFinding.message || gutterFinding.category}`}
/>
) : (
<span className="gutter-marker-spacer" />
)}
</span>
<span className="line-number">{l.number}</span>
<span
className="line-content"
dangerouslySetInnerHTML={{
__html: highlightSyntax(escapeHtml(l.content), lang),
}}
/>
</div>
);
})}
</div>
);
}
function severityRank(s: string): number {
switch (s.toUpperCase()) {
case 'HIGH':
return 3;
case 'MEDIUM':
return 2;
case 'LOW':
return 1;
default:
return 0;
}
}

View file

@ -0,0 +1,155 @@
import { FolderIcon } from '../icons/Icons';
import type { TreeEntry } from '../../api/types';
interface FileTreeProps {
entries: TreeEntry[];
expandedPaths: Set<string>;
selectedPath: string | null;
onToggleExpand: (path: string) => void;
onSelectFile: (path: string) => void;
loadedChildren: Map<string, TreeEntry[]>;
}
export function FileTree({
entries,
expandedPaths,
selectedPath,
onToggleExpand,
onSelectFile,
loadedChildren,
}: FileTreeProps) {
return (
<div className="file-tree">
{entries.map((entry) => (
<FileTreeNode
key={entry.path}
entry={entry}
depth={0}
expandedPaths={expandedPaths}
selectedPath={selectedPath}
onToggleExpand={onToggleExpand}
onSelectFile={onSelectFile}
loadedChildren={loadedChildren}
/>
))}
</div>
);
}
interface FileTreeNodeProps {
entry: TreeEntry;
depth: number;
expandedPaths: Set<string>;
selectedPath: string | null;
onToggleExpand: (path: string) => void;
onSelectFile: (path: string) => void;
loadedChildren: Map<string, TreeEntry[]>;
}
function FileTreeNode({
entry,
depth,
expandedPaths,
selectedPath,
onToggleExpand,
onSelectFile,
loadedChildren,
}: FileTreeNodeProps) {
const isDir = entry.entry_type === 'dir';
const isExpanded = expandedPaths.has(entry.path);
const isSelected = selectedPath === entry.path;
const children = loadedChildren.get(entry.path);
const sevClass =
entry.finding_count > 0 && entry.severity_max
? ` sev-${entry.severity_max.toLowerCase()}`
: '';
const handleClick = () => {
if (isDir) {
onToggleExpand(entry.path);
} else {
onSelectFile(entry.path);
}
};
return (
<>
<div
className={`tree-node${isSelected ? ' selected' : ''}${sevClass}`}
style={{ paddingLeft: 8 + depth * 16 }}
onClick={handleClick}
>
<span className={`tree-chevron${isDir ? '' : ' invisible'}`}>
{isDir ? (isExpanded ? '▾' : '▸') : ''}
</span>
<span className="tree-node-icon">
{isDir ? (
<FolderIcon size={14} />
) : (
<FileIcon language={entry.language} />
)}
</span>
<span className="tree-node-name" title={entry.path}>
{entry.name}
</span>
{entry.finding_count > 0 && (
<span className="tree-node-badge">{entry.finding_count}</span>
)}
</div>
{isDir && isExpanded && children && (
<div className="tree-children">
{children.map((child) => (
<FileTreeNode
key={child.path}
entry={child}
depth={depth + 1}
expandedPaths={expandedPaths}
selectedPath={selectedPath}
onToggleExpand={onToggleExpand}
onSelectFile={onSelectFile}
loadedChildren={loadedChildren}
/>
))}
</div>
)}
</>
);
}
function FileIcon({ language }: { language?: string }) {
const label = (language || '').charAt(0).toUpperCase() || '·';
const color = langColor(language);
return (
<span className="file-icon" style={{ color }} title={language || 'file'}>
{label}
</span>
);
}
function langColor(lang?: string): string {
switch (lang?.toLowerCase()) {
case 'javascript':
return '#f0db4f';
case 'typescript':
return '#3178c6';
case 'python':
return '#3572a5';
case 'rust':
return '#dea584';
case 'go':
return '#00add8';
case 'java':
return '#b07219';
case 'ruby':
return '#cc342d';
case 'php':
return '#4f5d95';
case 'c':
return '#555555';
case 'c++':
return '#f34b7d';
default:
return 'var(--text-tertiary)';
}
}

View file

@ -0,0 +1,35 @@
import type { ReactNode } from 'react';
interface AnalysisWorkspaceProps {
canvas: ReactNode;
inspector?: ReactNode;
inspectorTitle?: string;
inspectorSide?: 'left' | 'right';
}
export function AnalysisWorkspace({
canvas,
inspector,
inspectorTitle,
inspectorSide = 'right',
}: AnalysisWorkspaceProps) {
const hasInspector = Boolean(inspector);
const inspectorPanel = hasInspector ? (
<aside className="analysis-inspector">
{inspectorTitle && <h3>{inspectorTitle}</h3>}
{inspector}
</aside>
) : null;
return (
<div
className={`analysis-workspace${hasInspector ? ' analysis-workspace-with-inspector' : ''}${
hasInspector ? ` analysis-workspace-inspector-${inspectorSide}` : ''
}`}
>
{inspectorSide === 'left' && inspectorPanel}
<div className="analysis-canvas">{canvas}</div>
{inspectorSide === 'right' && inspectorPanel}
</div>
);
}

View file

@ -0,0 +1,170 @@
import type { FC, SVGProps } from 'react';
export interface IconProps {
className?: string;
size?: number;
}
type SvgBaseProps = SVGProps<SVGSVGElement> & IconProps;
function svgProps({ className, size = 18 }: IconProps): SvgBaseProps {
return {
className,
width: size,
height: size,
fill: 'none',
stroke: 'currentColor',
strokeWidth: 1.5,
strokeLinecap: 'round',
strokeLinejoin: 'round',
};
}
export function OverviewIcon({ className, size = 18 }: IconProps) {
return (
<svg {...svgProps({ className, size })} viewBox="0 0 18 18">
<rect x="2" y="2" width="5.5" height="5.5" rx="1" />
<rect x="10.5" y="2" width="5.5" height="5.5" rx="1" />
<rect x="2" y="10.5" width="5.5" height="5.5" rx="1" />
<rect x="10.5" y="10.5" width="5.5" height="5.5" rx="1" />
</svg>
);
}
export function FindingsIcon({ className, size = 18 }: IconProps) {
return (
<svg {...svgProps({ className, size })} viewBox="0 0 18 18">
<path d="M9 2L2 6v5c0 3.5 3 6 7 7 4-1 7-3.5 7-7V6L9 2z" />
<path d="M9 6v4" />
<circle cx="9" cy="12.5" r="0.5" fill="currentColor" stroke="none" />
</svg>
);
}
export function ScansIcon({ className, size = 18 }: IconProps) {
return (
<svg {...svgProps({ className, size })} viewBox="0 0 18 18">
<path d="M14.5 9A5.5 5.5 0 1 1 9 3.5" />
<polyline points="9 5 9 9 12 11" />
</svg>
);
}
export function RulesIcon({ className, size = 18 }: IconProps) {
return (
<svg {...svgProps({ className, size })} viewBox="0 0 18 18">
<path d="M4 5h10" />
<path d="M4 9h10" />
<path d="M4 13h10" />
<polyline points="2 4.5 2.8 5.5 4 4" />
<polyline points="2 8.5 2.8 9.5 4 8" />
<polyline points="2 12.5 2.8 13.5 4 12" />
</svg>
);
}
export function TriageIcon({ className, size = 18 }: IconProps) {
return (
<svg {...svgProps({ className, size })} viewBox="0 0 18 18">
<path d="M10 2L4 3v9l6 4 6-4V3l-6-1z" />
<path d="M10 6v4" />
<circle cx="10" cy="12.5" r="0.5" fill="currentColor" stroke="none" />
</svg>
);
}
export function ConfigIcon({ className, size = 18 }: IconProps) {
return (
<svg {...svgProps({ className, size })} viewBox="0 0 18 18">
<line x1="3" y1="5" x2="15" y2="5" />
<line x1="3" y1="9" x2="15" y2="9" />
<line x1="3" y1="13" x2="15" y2="13" />
<circle cx="6" cy="5" r="1.5" fill="var(--bg-secondary)" />
<circle cx="11" cy="9" r="1.5" fill="var(--bg-secondary)" />
<circle cx="7" cy="13" r="1.5" fill="var(--bg-secondary)" />
</svg>
);
}
export function ExplorerIcon({ className, size = 18 }: IconProps) {
return (
<svg {...svgProps({ className, size })} viewBox="0 0 18 18">
<path d="M3 3v12h12" />
<path d="M7 3v4h4V3" />
<path d="M7 11v4h4v-4" />
<path d="M11 7h4v4h-4" />
</svg>
);
}
export function DebugIcon({ className, size = 18 }: IconProps) {
return (
<svg {...svgProps({ className, size })} viewBox="0 0 18 18">
<polyline points="4 5 2 5 2 16 13 16 13 14" />
<polyline points="6 2 16 2 16 12 6 12 6 2" />
<path d="M9 5.5h4" />
<path d="M9 8h4" />
</svg>
);
}
export function SettingsIcon({ className, size = 18 }: IconProps) {
return (
<svg {...svgProps({ className, size })} viewBox="0 0 18 18">
<circle cx="9" cy="9" r="2.5" />
<path d="M9 1.5v2M9 14.5v2M1.5 9h2M14.5 9h2M3.7 3.7l1.4 1.4M12.9 12.9l1.4 1.4M14.3 3.7l-1.4 1.4M5.1 12.9l-1.4 1.4" />
</svg>
);
}
export function FolderIcon({ className, size = 14 }: IconProps) {
return (
<svg
className={className}
width={size}
height={size}
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M2 3.5C2 2.95 2.45 2.5 3 2.5h2.5l1.5 1.5H11c.55 0 1 .45 1 1v5.5c0 .55-.45 1-1 1H3c-.55 0-1-.45-1-1V3.5z" />
</svg>
);
}
export function TagIcon({ className, size = 14 }: IconProps) {
return (
<svg
className={className}
width={size}
height={size}
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M1.5 7.8V2.5c0-.6.4-1 1-1h5.3L13 6.7l-5.3 5.3L1.5 7.8z" />
<circle cx="5" cy="5" r="0.8" fill="currentColor" stroke="none" />
</svg>
);
}
/** Map of icon name to component, for dynamic lookup */
export const ICONS: Record<string, FC<IconProps>> = {
overview: OverviewIcon,
findings: FindingsIcon,
scans: ScansIcon,
rules: RulesIcon,
triage: TriageIcon,
config: ConfigIcon,
explorer: ExplorerIcon,
debug: DebugIcon,
settings: SettingsIcon,
folder: FolderIcon,
tag: TagIcon,
};

View file

@ -0,0 +1,67 @@
import { useState, useCallback } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { Sidebar } from './Sidebar';
import { HeaderBar } from './HeaderBar';
import { NewScanModal } from '../../modals/NewScanModal';
import { OverviewPage } from '../../pages/OverviewPage';
import { FindingsPage } from '../../pages/FindingsPage';
import { FindingDetailPage } from '../../pages/FindingDetailPage';
import { ScansPage } from '../../pages/ScansPage';
import { ScanDetailPage } from '../../pages/ScanDetailPage';
import { ScanComparePage } from '../../pages/ScanComparePage';
import { RulesPage } from '../../pages/RulesPage';
import { TriagePage } from '../../pages/TriagePage';
import { ConfigPage } from '../../pages/ConfigPage';
import { StubPage } from '../../pages/StubPage';
import { ExplorerPage } from '../../pages/ExplorerPage';
import { DebugLayout } from '../../pages/debug/DebugLayout';
import { CallGraphPage } from '../../pages/debug/CallGraphPage';
import { SummaryExplorerPage } from '../../pages/debug/SummaryExplorerPage';
export function AppLayout() {
const [scanModalOpen, setScanModalOpen] = useState(false);
const handleStartScan = useCallback(() => {
setScanModalOpen(true);
}, []);
return (
<div id="app">
<Sidebar />
<div className="main-panel">
<HeaderBar onStartScan={handleStartScan} />
<main className="content">
<Routes>
<Route path="/" element={<OverviewPage />} />
<Route path="/findings" element={<FindingsPage />} />
<Route path="/findings/:id" element={<FindingDetailPage />} />
<Route path="/scans" element={<ScansPage />} />
<Route
path="/scans/compare/:left/:right"
element={<ScanComparePage />}
/>
<Route path="/scans/:id" element={<ScanDetailPage />} />
<Route path="/rules" element={<RulesPage />} />
<Route path="/rules/:id" element={<RulesPage />} />
<Route path="/triage" element={<TriagePage />} />
<Route path="/config" element={<ConfigPage />} />
<Route path="/explorer" element={<ExplorerPage />} />
<Route path="/debug" element={<DebugLayout />}>
<Route
index
element={<Navigate to="/debug/call-graph" replace />}
/>
<Route path="call-graph" element={<CallGraphPage />} />
<Route path="summaries" element={<SummaryExplorerPage />} />
</Route>
<Route path="/settings" element={<StubPage />} />
</Routes>
</main>
</div>
<NewScanModal
open={scanModalOpen}
onClose={() => setScanModalOpen(false)}
/>
</div>
);
}

View file

@ -0,0 +1,90 @@
import { Link, useLocation } from 'react-router-dom';
const SECTION_TITLES: Record<string, string> = {
overview: 'Overview',
findings: 'Findings',
scans: 'Scans',
rules: 'Rules',
triage: 'Triage',
config: 'Config',
explorer: 'Explorer',
debug: 'Debug',
settings: 'Settings',
};
const ROUTE_TITLES: Record<string, string> = {
'/debug/cfg': 'CFG Viewer',
'/debug/ssa': 'SSA Viewer',
'/debug/call-graph': 'Call Graph',
'/debug/taint': 'Taint Debugger',
};
function pathToSection(pathname: string): string {
if (pathname === '/') return 'overview';
const first = pathname.split('/')[1];
return first || 'overview';
}
function buildBreadcrumbs(pathname: string) {
const section = pathToSection(pathname);
const sectionTitle = SECTION_TITLES[section] ?? section;
const crumbs: Array<{ label: string; path?: string }> = [];
// Always show section as root breadcrumb
const sectionPath = section === 'overview' ? '/' : `/${section}`;
crumbs.push({ label: sectionTitle, path: sectionPath });
// If we have a sub-route, show it
if (ROUTE_TITLES[pathname]) {
crumbs.push({ label: ROUTE_TITLES[pathname] });
} else {
const parts = pathname.split('/').filter(Boolean);
if (parts.length > 1) {
// e.g. /findings/123 or /scans/compare/1/2
const sub = parts.slice(1).join('/');
crumbs.push({ label: sub });
}
}
return crumbs;
}
interface HeaderBarProps {
onStartScan?: () => void;
}
export function HeaderBar({ onStartScan }: HeaderBarProps) {
const { pathname } = useLocation();
const crumbs = buildBreadcrumbs(pathname);
return (
<header className="header-bar">
<div className="header-left">
<nav className="breadcrumbs">
{crumbs.map((crumb, i) => {
const isLast = i === crumbs.length - 1;
return (
<span key={i}>
{i > 0 && <span className="breadcrumb-sep">/</span>}
{isLast || !crumb.path ? (
<span className="breadcrumb-current">{crumb.label}</span>
) : (
<Link to={crumb.path} className="breadcrumb-link">
{crumb.label}
</Link>
)}
</span>
);
})}
</nav>
</div>
<div className="header-right">
{onStartScan && (
<button className="btn btn-primary btn-sm" onClick={onStartScan}>
Start Scan
</button>
)}
</div>
</header>
);
}

View file

@ -0,0 +1,178 @@
import { NavLink } from 'react-router-dom';
import {
OverviewIcon,
FindingsIcon,
ScansIcon,
RulesIcon,
TriageIcon,
ConfigIcon,
ExplorerIcon,
DebugIcon,
SettingsIcon,
FolderIcon,
TagIcon,
} from '../icons/Icons';
import type { FC } from 'react';
import type { IconProps } from '../icons/Icons';
import { useHealth } from '../../api/queries/health';
import { useSSE } from '../../contexts/SSEContext';
interface NavItem {
id: string;
label: string;
path: string;
Icon: FC<IconProps>;
group: 'primary' | 'secondary' | 'footer';
}
const NAV_SECTIONS: NavItem[] = [
{
id: 'overview',
label: 'Overview',
path: '/',
Icon: OverviewIcon,
group: 'primary',
},
{
id: 'findings',
label: 'Findings',
path: '/findings',
Icon: FindingsIcon,
group: 'primary',
},
{
id: 'scans',
label: 'Scans',
path: '/scans',
Icon: ScansIcon,
group: 'primary',
},
{
id: 'rules',
label: 'Rules',
path: '/rules',
Icon: RulesIcon,
group: 'primary',
},
{
id: 'triage',
label: 'Triage',
path: '/triage',
Icon: TriageIcon,
group: 'primary',
},
{
id: 'config',
label: 'Config',
path: '/config',
Icon: ConfigIcon,
group: 'secondary',
},
{
id: 'explorer',
label: 'Explorer',
path: '/explorer',
Icon: ExplorerIcon,
group: 'secondary',
},
{
id: 'debug',
label: 'Debug',
path: '/debug',
Icon: DebugIcon,
group: 'secondary',
},
{
id: 'settings',
label: 'Settings',
path: '/settings',
Icon: SettingsIcon,
group: 'footer',
},
];
function navLinkClass({ isActive }: { isActive: boolean }) {
return `nav-link${isActive ? ' active' : ''}`;
}
export function Sidebar() {
const { data: health } = useHealth();
const { isScanRunning } = useSSE();
const primary = NAV_SECTIONS.filter((n) => n.group === 'primary');
const secondary = NAV_SECTIONS.filter((n) => n.group === 'secondary');
const footer = NAV_SECTIONS.filter((n) => n.group === 'footer');
return (
<aside className="sidebar">
<div className="sidebar-header">
<span className="logo">nyx</span>
{health?.version && <span className="version">v{health.version}</span>}
</div>
<ul className="nav-list">
{primary.map((item) => (
<li key={item.id}>
<NavLink
to={item.path}
end={item.path === '/'}
className={navLinkClass}
>
<span className="nav-icon">
<item.Icon />
</span>
<span>{item.label}</span>
</NavLink>
</li>
))}
<li className="nav-separator" />
{secondary.map((item) => (
<li key={item.id}>
<NavLink to={item.path} className={navLinkClass}>
<span className="nav-icon">
<item.Icon />
</span>
<span>{item.label}</span>
</NavLink>
</li>
))}
</ul>
<div className="sidebar-footer">
<ul className="nav-list" style={{ flex: 'none' }}>
{footer.map((item) => (
<li key={item.id}>
<NavLink to={item.path} className={navLinkClass}>
<span className="nav-icon">
<item.Icon />
</span>
<span>{item.label}</span>
</NavLink>
</li>
))}
</ul>
</div>
<div className="sidebar-meta">
{health?.scan_root && (
<div className="sidebar-meta-item" title={health.scan_root}>
<FolderIcon />
<span>{health.scan_root}</span>
</div>
)}
{health?.version && (
<div className="sidebar-meta-item">
<TagIcon />
<span>v{health.version}</span>
</div>
)}
<div className={`scan-indicator${isScanRunning ? ' visible' : ''}`}>
<span className="status-dot running" />
Scanning...
</div>
</div>
</aside>
);
}

View file

@ -0,0 +1,103 @@
import {
useCallback,
useEffect,
useRef,
useState,
type ReactNode,
} from 'react';
interface DropdownProps {
trigger: (opts: { open: boolean }) => ReactNode;
children: (opts: { close: () => void }) => ReactNode;
align?: 'left' | 'right';
className?: string;
}
export function Dropdown({
trigger,
children,
align = 'left',
className,
}: DropdownProps) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const close = useCallback(() => setOpen(false), []);
useEffect(() => {
if (!open) return;
const handlePointer = (e: MouseEvent) => {
if (!rootRef.current) return;
if (!rootRef.current.contains(e.target as Node)) setOpen(false);
};
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', handlePointer);
document.addEventListener('keydown', handleKey);
return () => {
document.removeEventListener('mousedown', handlePointer);
document.removeEventListener('keydown', handleKey);
};
}, [open]);
return (
<div
ref={rootRef}
className={`dropdown${open ? ' dropdown--open' : ''}${className ? ` ${className}` : ''}`}
>
<div
className="dropdown-trigger"
onClick={() => setOpen((v) => !v)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setOpen((v) => !v);
}
}}
>
{trigger({ open })}
</div>
{open && (
<div className={`dropdown-menu dropdown-menu--${align}`} role="menu">
{children({ close })}
</div>
)}
</div>
);
}
interface DropdownItemProps {
onClick: () => void;
children: ReactNode;
checked?: boolean;
hint?: string;
tone?: 'default' | 'warning';
}
export function DropdownItem({
onClick,
children,
checked,
hint,
tone = 'default',
}: DropdownItemProps) {
return (
<button
type="button"
role="menuitem"
className={`dropdown-item dropdown-item--${tone}`}
onClick={onClick}
>
<span className="dropdown-item-check" aria-hidden>
{checked ? '✓' : ''}
</span>
<span className="dropdown-item-label">{children}</span>
{hint && <span className="dropdown-item-hint">{hint}</span>}
</button>
);
}

View file

@ -0,0 +1,17 @@
import type { ReactNode } from 'react';
interface EmptyStateProps {
message?: string;
children?: ReactNode;
icon?: ReactNode;
}
export function EmptyState({ message, children, icon }: EmptyStateProps) {
return (
<div className="empty-state">
{icon && <div className="empty-state-icon">{icon}</div>}
{message && <p>{message}</p>}
{children}
</div>
);
}

View file

@ -0,0 +1,13 @@
interface ErrorStateProps {
title?: string;
message: string;
}
export function ErrorState({ title = 'Error', message }: ErrorStateProps) {
return (
<div className="error-state">
<h3>{title}</h3>
<p>{message}</p>
</div>
);
}

View file

@ -0,0 +1,7 @@
interface LoadingStateProps {
message?: string;
}
export function LoadingState({ message = 'Loading...' }: LoadingStateProps) {
return <div className="loading">{message}</div>;
}

View file

@ -0,0 +1,38 @@
import { useEffect, useCallback, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
interface ModalProps {
open: boolean;
onClose: () => void;
className?: string;
children: ReactNode;
}
export function Modal({ open, onClose, className, children }: ModalProps) {
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
},
[onClose],
);
useEffect(() => {
if (!open) return;
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [open, handleKeyDown]);
if (!open) return null;
return createPortal(
<div
className={className || 'code-modal-overlay'}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
{children}
</div>,
document.body,
);
}

View file

@ -0,0 +1,75 @@
interface PaginationProps {
page: number;
perPage: number;
total: number;
onPageChange: (page: number) => void;
onPerPageChange?: (perPage: number) => void;
}
const PER_PAGE_OPTIONS = [25, 50, 100];
export function Pagination({
page,
perPage,
total,
onPageChange,
onPerPageChange,
}: PaginationProps) {
const totalPages = Math.ceil(total / perPage) || 1;
return (
<div className="pagination">
<div className="pagination-left">
<span>Per page:</span>
<select
value={perPage}
onChange={(e) => onPerPageChange?.(Number(e.target.value))}
>
{PER_PAGE_OPTIONS.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</div>
<div className="pagination-center">
<button
className="btn btn-sm"
disabled={page <= 1}
onClick={() => onPageChange(1)}
>
First
</button>
<button
className="btn btn-sm"
disabled={page <= 1}
onClick={() => onPageChange(Math.max(1, page - 1))}
>
Prev
</button>
<span>
Page {page} of {totalPages}
</span>
<button
className="btn btn-sm"
disabled={page >= totalPages}
onClick={() => onPageChange(Math.min(totalPages, page + 1))}
>
Next
</button>
<button
className="btn btn-sm"
disabled={page >= totalPages}
onClick={() => onPageChange(totalPages)}
>
Last
</button>
</div>
<div className="pagination-right">
<span>{total} total</span>
</div>
</div>
);
}

View file

@ -0,0 +1,32 @@
interface StatCardProps {
label: string;
value: string | number;
delta?: number | null;
color?: string;
subtitle?: string;
}
export function StatCard({
label,
value,
delta,
color,
subtitle,
}: StatCardProps) {
const colorStyle = color ? { color } : undefined;
return (
<div className="overview-stat-card">
<div className="stat-label">{label}</div>
<div className="stat-value" style={colorStyle}>
{value}
{delta != null && delta !== 0 && (
<span className={`stat-delta delta-${delta > 0 ? 'up' : 'down'}`}>
{delta > 0 ? '\u25B2' : '\u25BC'}&nbsp;{Math.abs(delta)}
</span>
)}
</div>
{subtitle && <div className="stat-subtitle">{subtitle}</div>}
</div>
);
}

View file

@ -0,0 +1,109 @@
import {
createContext,
useContext,
useEffect,
useState,
useRef,
useCallback,
type ReactNode,
} from 'react';
import { useQueryClient } from '@tanstack/react-query';
import type { TimingBreakdown } from '../api/types';
export interface ScanProgress {
job_id: string;
stage: string;
files_discovered: number;
files_parsed: number;
files_analyzed: number;
files_skipped: number;
batches_total: number;
batches_completed: number;
current_file: string;
elapsed_ms: number;
timing: TimingBreakdown;
}
interface SSEState {
scanProgress: ScanProgress | null;
isScanRunning: boolean;
}
const SSEContext = createContext<SSEState>({
scanProgress: null,
isScanRunning: false,
});
export function useSSE() {
return useContext(SSEContext);
}
export function SSEProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
const [scanProgress, setScanProgress] = useState<ScanProgress | null>(null);
const [isScanRunning, setIsScanRunning] = useState(false);
const esRef = useRef<EventSource | null>(null);
const reconnectTimer = useRef<ReturnType<typeof setTimeout>>();
const connect = useCallback(() => {
if (esRef.current) {
esRef.current.close();
}
const es = new EventSource('/api/events');
esRef.current = es;
es.addEventListener('scan_started', () => {
setIsScanRunning(true);
queryClient.invalidateQueries({ queryKey: ['scans'] });
});
es.addEventListener('scan_progress', (e) => {
try {
const data = JSON.parse(e.data);
setScanProgress(data.data ?? data);
} catch {
/* ignore parse errors */
}
});
es.addEventListener('scan_completed', () => {
setScanProgress(null);
setIsScanRunning(false);
queryClient.invalidateQueries({ queryKey: ['scans'] });
queryClient.invalidateQueries({ queryKey: ['overview'] });
queryClient.invalidateQueries({ queryKey: ['findings'] });
});
es.addEventListener('scan_failed', () => {
setScanProgress(null);
setIsScanRunning(false);
queryClient.invalidateQueries({ queryKey: ['scans'] });
});
es.addEventListener('config_changed', () => {
queryClient.invalidateQueries({ queryKey: ['config'] });
queryClient.invalidateQueries({ queryKey: ['rules'] });
});
es.onerror = () => {
es.close();
esRef.current = null;
reconnectTimer.current = setTimeout(connect, 3000);
};
}, [queryClient]);
useEffect(() => {
connect();
return () => {
if (esRef.current) esRef.current.close();
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
};
}, [connect]);
return (
<SSEContext.Provider value={{ scanProgress, isScanRunning }}>
{children}
</SSEContext.Provider>
);
}

View file

@ -0,0 +1,57 @@
import type { CallGraphNodeView, CallGraphView } from '@/api/types';
import type { GraphModel } from '../types';
const MAX_LABEL = 44;
const MAX_DETAIL = 48;
function truncate(value: string, max: number): string {
return value.length > max ? `${value.slice(0, max - 1)}` : value;
}
function summarizeNode(node: CallGraphNodeView): string {
if (node.namespace) return truncate(node.namespace, MAX_DETAIL);
const segments = node.file.split(/[\\/]/);
return truncate(segments[segments.length - 1] ?? node.file, MAX_DETAIL);
}
export function adaptCallGraph(data: CallGraphView): GraphModel {
const recursiveNodes = new Set<number>();
for (const scc of data.sccs) {
for (const id of scc) recursiveNodes.add(id);
}
return {
kind: 'callgraph',
nodes: data.nodes.map((node) => ({
key: String(node.id),
rawId: node.id,
label: truncate(node.name, MAX_LABEL),
kind: 'Call',
detail: summarizeNode(node),
metadata: {
...node,
isRecursive: recursiveNodes.has(node.id),
searchText: [
node.name,
node.namespace,
node.file,
node.lang,
node.arity == null ? '' : String(node.arity),
]
.filter(Boolean)
.join(' ')
.toLowerCase(),
},
})),
edges: data.edges.map((edge, index) => ({
key: `call:${edge.source}:${edge.target}:${index}`,
source: String(edge.source),
target: String(edge.target),
kind: 'Call',
metadata: {
...edge,
},
})),
};
}

View file

@ -0,0 +1,85 @@
import type { CfgEdgeView, CfgGraphView, CfgNodeView } from '@/api/types';
import type { GraphModel } from '../types';
function truncate(value: string, max: number): string {
return value.length > max ? `${value.slice(0, max - 1)}` : value;
}
function normalizeText(value: string): string {
return value.replace(/\s+/g, ' ').trim();
}
const CFG_EDGE_PRIORITY: Record<string, number> = {
True: 4,
False: 4,
Exception: 3,
Back: 2,
Seq: 1,
};
function getCfgEdgePriority(kind: string): number {
return CFG_EDGE_PRIORITY[kind] ?? 2;
}
export function formatCfgNodeLabel(node: CfgNodeView): string {
const summary =
node.kind === 'Call'
? (node.callee ?? node.defines)
: (node.defines ?? node.callee);
if (summary) return `${node.kind}: ${truncate(normalizeText(summary), 56)}`;
return node.kind;
}
export function normalizeCfgEdges(edges: CfgEdgeView[]): CfgEdgeView[] {
const deduped = new Map<string, CfgEdgeView>();
for (const edge of edges) {
const key = `${edge.source}:${edge.target}`;
const current = deduped.get(key);
if (
!current ||
getCfgEdgePriority(edge.kind) > getCfgEdgePriority(current.kind)
) {
deduped.set(key, edge);
}
}
return [...deduped.values()];
}
export function adaptCfgGraph(data: CfgGraphView): GraphModel {
const edges = normalizeCfgEdges(data.edges);
return {
kind: 'cfg',
nodes: data.nodes.map((node) => ({
key: String(node.id),
rawId: node.id,
label: formatCfgNodeLabel(node),
kind: node.kind,
detail: `Line ${node.line}`,
sublabel: node.condition_text
? truncate(node.condition_text, 40)
: undefined,
badges: node.labels.length > 0 ? node.labels.slice(0, 4) : undefined,
line: node.line,
metadata: {
...node,
isEntry: node.id === data.entry,
isExit: node.kind === 'Exit' || node.kind === 'Return',
},
})),
edges: edges.map((edge, index) => ({
key: `cfg:${edge.source}:${edge.target}:${edge.kind}:${index}`,
source: String(edge.source),
target: String(edge.target),
kind: edge.kind,
label: edge.kind !== 'Seq' ? edge.kind : undefined,
metadata: {
...edge,
},
})),
};
}

View file

@ -0,0 +1,125 @@
import { useMemo, useState } from 'react';
import type { CallGraphView } from '@/api/types';
import { adaptCallGraph } from '../adapters/callgraph';
import { useElkLayout } from '../hooks/useElkLayout';
import {
collectSearchMatches,
extractNeighborhoodSubgraph,
} from '../reduction/neighborhood';
import { SigmaGraph } from '../rendering/sigma/SigmaGraph';
interface CallGraphCanvasProps {
data: CallGraphView;
selectedNodeId: number | null;
onSelectNode: (id: number) => void;
}
export function CallGraphCanvas({
data,
selectedNodeId,
onSelectNode,
}: CallGraphCanvasProps) {
const [searchQuery, setSearchQuery] = useState('');
const [neighborhoodOnly, setNeighborhoodOnly] = useState(false);
const [radius, setRadius] = useState(1);
const fullGraph = useMemo(() => adaptCallGraph(data), [data]);
const selectedNodeKey =
selectedNodeId == null ? null : String(selectedNodeId);
const matches = useMemo(
() => collectSearchMatches(fullGraph, searchQuery, 60),
[fullGraph, searchQuery],
);
const matchKeys = useMemo(
() => new Set(matches.map((node) => node.key)),
[matches],
);
const visibleGraph = useMemo(() => {
if (!neighborhoodOnly || !selectedNodeKey) return fullGraph;
return extractNeighborhoodSubgraph(fullGraph, selectedNodeKey, radius);
}, [fullGraph, neighborhoodOnly, radius, selectedNodeKey]);
const { graph, isLoading, error } = useElkLayout(visibleGraph);
if (error) {
return (
<div className="error-state">
Failed to compute the call graph layout.
</div>
);
}
if (!graph) {
return <div className="loading">Preparing call graph</div>;
}
const extras = (
<>
<label className="graph-toolbar-field">
<span>Search</span>
<input
className="graph-toolbar-input"
type="search"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Function name"
/>
</label>
<label className="graph-toolbar-field">
<span>Match</span>
<select
className="graph-toolbar-select"
value={selectedNodeKey ?? ''}
onChange={(event) => {
const next = event.target.value;
if (!next) return;
onSelectNode(Number(next));
}}
>
<option value="">Select</option>
{matches.map((match) => (
<option key={match.key} value={match.key}>
{match.label}
</option>
))}
</select>
</label>
<label className="graph-toolbar-check">
<input
type="checkbox"
checked={neighborhoodOnly}
onChange={(event) => setNeighborhoodOnly(event.target.checked)}
/>
<span>Neighbors only</span>
</label>
<label className="graph-toolbar-field graph-toolbar-field-compact">
<span>Radius</span>
<input
className="graph-toolbar-range"
type="range"
min="1"
max="4"
step="1"
value={radius}
disabled={!neighborhoodOnly}
onChange={(event) => setRadius(Number(event.target.value))}
/>
<strong>{radius}</strong>
</label>
</>
);
return (
<SigmaGraph
graph={graph}
viewKind="callgraph"
selectedNodeKey={selectedNodeKey}
onNodeClick={(key) => onSelectNode(Number(key))}
searchMatchKeys={matchKeys}
toolbarExtras={extras}
loading={isLoading}
/>
);
}

View file

@ -0,0 +1,204 @@
import { useEffect, useMemo, useState } from 'react';
import type { CfgGraphView, CfgNodeView } from '@/api/types';
import { AnalysisWorkspace } from '@/components/explorer/AnalysisWorkspace';
import {
adaptCfgGraph,
formatCfgNodeLabel,
normalizeCfgEdges,
} from '../adapters/cfg';
import { useElkLayout } from '../hooks/useElkLayout';
import { SigmaGraph } from '../rendering/sigma/SigmaGraph';
interface CfgGraphCanvasProps {
data: CfgGraphView;
}
function formatNodeList(
ids: number[],
nodeMap: Map<number, CfgNodeView>,
): string {
if (ids.length === 0) return 'None';
return ids
.map((id) => {
const node = nodeMap.get(id);
return node ? `${id} (${node.kind})` : `${id}`;
})
.join(', ');
}
function NodeDetail({
node,
label,
predecessorIds,
successorIds,
nodeMap,
}: {
node: CfgNodeView;
label: string;
predecessorIds: number[];
successorIds: number[];
nodeMap: Map<number, CfgNodeView>;
}) {
return (
<div className="analysis-node-detail">
<div className="debug-detail-row">
<span className="debug-detail-label">Kind</span>
<span className="debug-detail-value">{node.kind}</span>
</div>
<div className="debug-detail-row">
<span className="debug-detail-label">Label</span>
<span className="debug-detail-value mono">{label}</span>
</div>
<div className="debug-detail-row">
<span className="debug-detail-label">Source</span>
<span className="debug-detail-value">
L{node.line} span {node.span[0]}-{node.span[1]}
</span>
</div>
{node.defines && (
<div className="debug-detail-row">
<span className="debug-detail-label">Defines</span>
<span className="debug-detail-value mono">{node.defines}</span>
</div>
)}
{node.uses.length > 0 && (
<div className="debug-detail-row">
<span className="debug-detail-label">Uses</span>
<span className="debug-detail-value mono">
{node.uses.join(', ')}
</span>
</div>
)}
{node.callee && (
<div className="debug-detail-row">
<span className="debug-detail-label">Callee</span>
<span className="debug-detail-value mono">{node.callee}</span>
</div>
)}
{node.labels.length > 0 && (
<div className="debug-detail-row">
<span className="debug-detail-label">Labels</span>
<div>
{node.labels.map((labelValue, index) => (
<span key={index} className="cap-badge">
{labelValue}
</span>
))}
</div>
</div>
)}
{node.condition_text && (
<div className="debug-detail-row">
<span className="debug-detail-label">Condition</span>
<span className="debug-detail-value mono">{node.condition_text}</span>
</div>
)}
{node.enclosing_func && (
<div className="debug-detail-row">
<span className="debug-detail-label">Function</span>
<span className="debug-detail-value mono">{node.enclosing_func}</span>
</div>
)}
<div className="debug-detail-row">
<span className="debug-detail-label">Predecessors</span>
<span className="debug-detail-value mono">
{formatNodeList(predecessorIds, nodeMap)}
</span>
</div>
<div className="debug-detail-row">
<span className="debug-detail-label">Successors</span>
<span className="debug-detail-value mono">
{formatNodeList(successorIds, nodeMap)}
</span>
</div>
</div>
);
}
export function CfgGraphCanvas({ data }: CfgGraphCanvasProps) {
const [selectedNodeKey, setSelectedNodeKey] = useState<string | null>(null);
const normalizedEdges = useMemo(
() => normalizeCfgEdges(data.edges),
[data.edges],
);
const fullGraph = useMemo(() => adaptCfgGraph(data), [data]);
const nodeMap = useMemo(
() => new Map(data.nodes.map((node) => [node.id, node])),
[data.nodes],
);
const { graph, isLoading, error } = useElkLayout(fullGraph);
useEffect(() => {
if (!selectedNodeKey) return;
if (fullGraph.nodes.some((node) => node.key === selectedNodeKey)) return;
setSelectedNodeKey(null);
}, [fullGraph.nodes, selectedNodeKey]);
if (error) {
return <div className="error-state">Failed to compute the CFG layout.</div>;
}
if (!graph) {
return <div className="loading">Preparing CFG</div>;
}
const selectedVisibleNode =
selectedNodeKey == null
? undefined
: fullGraph.nodes.find((node) => node.key === selectedNodeKey);
const selectedRawNode =
selectedVisibleNode && selectedVisibleNode.rawId >= 0
? nodeMap.get(selectedVisibleNode.rawId)
: undefined;
const predecessorIds =
selectedRawNode == null
? []
: normalizedEdges
.filter((edge) => edge.target === selectedRawNode.id)
.map((edge) => edge.source);
const successorIds =
selectedRawNode == null
? []
: normalizedEdges
.filter((edge) => edge.source === selectedRawNode.id)
.map((edge) => edge.target);
const inspector =
selectedRawNode != null ? (
<NodeDetail
node={selectedRawNode}
label={formatCfgNodeLabel(selectedRawNode)}
predecessorIds={predecessorIds}
successorIds={successorIds}
nodeMap={nodeMap}
/>
) : undefined;
const inspectorTitle = selectedRawNode
? `Node ${selectedRawNode.id}`
: undefined;
return (
<AnalysisWorkspace
inspector={inspector}
inspectorTitle={inspectorTitle}
canvas={
<div className="analysis-graph-frame">
<SigmaGraph
graph={graph}
viewKind="cfg"
selectedNodeKey={selectedNodeKey}
onNodeClick={(key) =>
setSelectedNodeKey((current) => (current === key ? null : key))
}
loading={isLoading}
/>
</div>
}
/>
);
}

View file

@ -0,0 +1,88 @@
import type { ReactNode } from 'react';
interface GraphToolbarProps {
zoomPercentage: number;
onZoomIn: () => void;
onZoomOut: () => void;
onFitGraph: () => void;
onFocusSelection?: () => void;
focusDisabled?: boolean;
extras?: ReactNode;
status?: ReactNode;
}
export function GraphToolbar({
zoomPercentage,
onZoomIn,
onZoomOut,
onFitGraph,
onFocusSelection,
focusDisabled,
extras,
status,
}: GraphToolbarProps) {
return (
<div className="graph-toolbar">
<div className="graph-toolbar-group">
<button
className="graph-toolbar-btn"
onClick={onZoomOut}
title="Zoom out"
type="button"
>
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<line x1="3" y1="7" x2="11" y2="7" />
</svg>
</button>
<span className="graph-toolbar-zoom">{zoomPercentage}%</span>
<button
className="graph-toolbar-btn"
onClick={onZoomIn}
title="Zoom in"
type="button"
>
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<line x1="3" y1="7" x2="11" y2="7" />
<line x1="7" y1="3" x2="7" y2="11" />
</svg>
</button>
<div className="graph-toolbar-sep" />
<button
className="graph-toolbar-btn"
onClick={onFitGraph}
title="Fit graph"
type="button"
>
Fit
</button>
{onFocusSelection && (
<button
className="graph-toolbar-btn"
onClick={onFocusSelection}
disabled={focusDisabled}
title="Focus selection"
type="button"
>
Focus
</button>
)}
</div>
{extras ? <div className="graph-toolbar-extras">{extras}</div> : null}
{status ? <div className="graph-toolbar-status">{status}</div> : null}
</div>
);
}

View file

@ -0,0 +1,99 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { layoutGraphWithElk } from '../layout/elk';
import type { ElkLayoutPreset, GraphModel, LayoutGraphModel } from '../types';
interface LayoutState {
graph: LayoutGraphModel | null;
isLoading: boolean;
error: Error | null;
}
function createLayoutKey(
graph: GraphModel,
overrides?: Partial<ElkLayoutPreset>,
): string {
const nodeKey = graph.nodes
.map(
(node) => `${node.key}:${node.label}:${node.kind}:${node.detail ?? ''}`,
)
.join('|');
const edgeKey = graph.edges
.map((edge) => `${edge.key}:${edge.source}:${edge.target}:${edge.kind}`)
.join('|');
return JSON.stringify({
kind: graph.kind,
nodeKey,
edgeKey,
overrides,
});
}
const layoutCache = new Map<string, LayoutGraphModel>();
// The hook stays async even on the main thread so moving ELK into a worker later
// does not require rewriting the React call sites.
export function useElkLayout(
graph: GraphModel,
overrides?: Partial<ElkLayoutPreset>,
): LayoutState {
const layoutKey = useMemo(
() => createLayoutKey(graph, overrides),
[graph, overrides],
);
const [state, setState] = useState<LayoutState>(() => {
const cached = layoutCache.get(layoutKey) ?? null;
return {
graph: cached,
isLoading: cached == null,
error: null,
};
});
const requestRef = useRef(0);
useEffect(() => {
const cached = layoutCache.get(layoutKey);
if (cached) {
setState({
graph: cached,
isLoading: false,
error: null,
});
return;
}
const requestId = requestRef.current + 1;
requestRef.current = requestId;
let cancelled = false;
setState((current) => ({
graph: current.graph,
isLoading: true,
error: null,
}));
void layoutGraphWithElk(graph, overrides)
.then((layout) => {
if (cancelled || requestRef.current !== requestId) return;
layoutCache.set(layoutKey, layout);
setState({
graph: layout,
isLoading: false,
error: null,
});
})
.catch((error: unknown) => {
if (cancelled || requestRef.current !== requestId) return;
setState({
graph: null,
isLoading: false,
error: error instanceof Error ? error : new Error('Layout failed'),
});
});
return () => {
cancelled = true;
};
}, [graph, layoutKey, overrides]);
return state;
}

View file

@ -0,0 +1,288 @@
import ELK from 'elkjs/lib/elk.bundled.js';
import type { ElkEdgeSection, ElkNode } from 'elkjs/lib/elk-api';
import { getNodeTextLayout } from './text';
import type {
ElkLayoutPreset,
GraphModel,
GraphNodeModel,
GraphPoint,
GraphViewKind,
LayoutGraphEdge,
LayoutGraphModel,
LayoutGraphNode,
} from '../types';
const elk = new ELK();
const CHAR_WIDTH = 7.1;
const LINE_HEIGHT = 16;
const HORIZONTAL_PADDING = 30;
const VERTICAL_PADDING = 18;
const MIN_WIDTH = 112;
const BADGE_HEIGHT = 16;
const MAX_WIDTH = 360;
const PRESETS: Record<GraphViewKind, ElkLayoutPreset> = {
callgraph: {
direction: 'DOWN',
nodeSpacing: 42,
layerSpacing: 148,
edgeNodeSpacing: 24,
padding: 36,
edgeRouting: 'POLYLINE',
},
cfg: {
direction: 'DOWN',
nodeSpacing: 36,
layerSpacing: 128,
edgeNodeSpacing: 24,
padding: 32,
edgeRouting: 'ORTHOGONAL',
},
};
function measureNode(
node: GraphNodeModel,
viewKind: GraphViewKind,
): {
width: number;
height: number;
text: ReturnType<typeof getNodeTextLayout>;
} {
const text = getNodeTextLayout(node, viewKind);
const width = Math.max(
MIN_WIDTH,
Math.min(MAX_WIDTH, text.maxChars * CHAR_WIDTH + HORIZONTAL_PADDING),
);
const height =
Math.max(1, text.lineCount) * LINE_HEIGHT +
VERTICAL_PADDING +
(node.badges?.length ? BADGE_HEIGHT : 0);
return { width, height, text };
}
function estimateSigmaNodeSize(
node: GraphNodeModel,
width: number,
height: number,
): number {
const base = Math.max(6, Math.min(18, Math.sqrt(width * height) / 8));
if (node.kind === 'Entry' || node.kind === 'Exit') return base + 1.5;
if (node.kind === 'If' || node.kind === 'Loop') return base + 0.75;
return base;
}
function buildLayoutOptions(
graph: GraphModel,
overrides?: Partial<ElkLayoutPreset>,
): ElkNode['layoutOptions'] {
const preset = { ...PRESETS[graph.kind], ...overrides };
return {
'elk.algorithm': 'layered',
'elk.direction': preset.direction,
'elk.spacing.nodeNode': String(preset.nodeSpacing),
'elk.layered.spacing.nodeNodeBetweenLayers': String(preset.layerSpacing),
'elk.spacing.edgeNode': String(preset.edgeNodeSpacing),
'elk.edgeRouting': preset.edgeRouting,
'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP',
'elk.layered.unnecessaryBendpoints': 'true',
'elk.layered.thoroughness': graph.kind === 'callgraph' ? '6' : '8',
};
}
function sortSections(
sections: ElkEdgeSection[] | undefined,
): ElkEdgeSection[] {
if (!sections || sections.length <= 1) return sections ?? [];
const sectionById = new Map(sections.map((section) => [section.id, section]));
const head =
sections.find(
(section) =>
!section.incomingSections || section.incomingSections.length === 0,
) ?? sections[0];
const ordered: ElkEdgeSection[] = [];
const seen = new Set<string>();
let cursor: ElkEdgeSection | undefined = head;
while (cursor && !seen.has(cursor.id)) {
ordered.push(cursor);
seen.add(cursor.id);
const nextId: string | undefined = cursor.outgoingSections?.[0];
cursor = nextId ? sectionById.get(nextId) : undefined;
}
if (ordered.length === sections.length) return ordered;
return sections;
}
function dedupePoints(points: GraphPoint[]): GraphPoint[] {
const deduped: GraphPoint[] = [];
for (const point of points) {
const previous = deduped[deduped.length - 1];
if (previous && previous.x === point.x && previous.y === point.y) continue;
deduped.push(point);
}
return deduped;
}
function extractRoute(sections: ElkEdgeSection[] | undefined): GraphPoint[] {
const points: GraphPoint[] = [];
for (const section of sortSections(sections)) {
points.push(section.startPoint);
if (section.bendPoints) points.push(...section.bendPoints);
points.push(section.endPoint);
}
return dedupePoints(points);
}
function collectBounds(
nodes: LayoutGraphNode[],
edges: LayoutGraphEdge[],
padding: number,
) {
let minX = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
const includePoint = (x: number, y: number) => {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
};
for (const node of nodes) {
includePoint(node.x - node.width / 2, node.y - node.height / 2);
includePoint(node.x + node.width / 2, node.y + node.height / 2);
}
for (const edge of edges) {
for (const point of edge.route) {
includePoint(point.x, point.y);
}
}
if (minX === Number.POSITIVE_INFINITY) minX = 0;
if (maxX === Number.NEGATIVE_INFINITY) maxX = 0;
if (minY === Number.POSITIVE_INFINITY) minY = 0;
if (maxY === Number.NEGATIVE_INFINITY) maxY = 0;
const offsetX = padding - minX;
const offsetY = padding - minY;
return {
offsetX,
offsetY,
width: maxX - minX + padding * 2,
height: maxY - minY + padding * 2,
};
}
export async function layoutGraphWithElk(
graph: GraphModel,
overrides?: Partial<ElkLayoutPreset>,
): Promise<LayoutGraphModel> {
if (graph.nodes.length === 0) {
return {
kind: graph.kind,
nodes: [],
edges: [],
bounds: { width: 0, height: 0 },
};
}
const preset = { ...PRESETS[graph.kind], ...overrides };
const dimensions = new Map<
string,
{
width: number;
height: number;
text: ReturnType<typeof getNodeTextLayout>;
}
>();
const elkGraph: ElkNode = {
id: 'root',
layoutOptions: buildLayoutOptions(graph, overrides),
children: graph.nodes.map((node) => {
const size = measureNode(node, graph.kind);
dimensions.set(node.key, size);
return {
id: node.key,
width: size.width,
height: size.height,
};
}),
edges: graph.edges.map((edge) => ({
id: edge.key,
sources: [edge.source],
targets: [edge.target],
})),
};
const layout = await elk.layout(elkGraph);
const edgeById = new Map(
layout.edges?.map((edge) => [edge.id ?? '', edge]) ?? [],
);
const layoutNodesById = new Map(
layout.children?.map((node) => [node.id, node]) ?? [],
);
const nodes: LayoutGraphNode[] = graph.nodes.map((node) => {
const layoutNode = layoutNodesById.get(node.key);
const size = dimensions.get(node.key) ?? measureNode(node, graph.kind);
const x = (layoutNode?.x ?? 0) + size.width / 2;
const y = (layoutNode?.y ?? 0) + size.height / 2;
return {
...node,
x,
y,
width: size.width,
height: size.height,
sigmaSize: estimateSigmaNodeSize(node, size.width, size.height),
labelLines: size.text.labelLines,
detailLines: size.text.detailLines,
sublabelLines: size.text.sublabelLines,
};
});
const edges: LayoutGraphEdge[] = graph.edges.map((edge) => {
const layoutEdge = edgeById.get(edge.key);
const route = extractRoute(layoutEdge?.sections);
return {
...edge,
route,
};
});
const bounds = collectBounds(nodes, edges, preset.padding);
return {
kind: graph.kind,
nodes: nodes.map((node) => ({
...node,
x: node.x + bounds.offsetX,
y: node.y + bounds.offsetY,
})),
edges: edges.map((edge) => ({
...edge,
route: edge.route.map((point) => ({
x: point.x + bounds.offsetX,
y: point.y + bounds.offsetY,
})),
})),
bounds: {
width: bounds.width,
height: bounds.height,
},
};
}

View file

@ -0,0 +1,119 @@
import type { GraphNodeModel, GraphViewKind } from '../types';
interface TextLayoutConfig {
primaryChars: number;
secondaryChars: number;
maxPrimaryLines: number;
maxSecondaryLines: number;
maxSublabelLines: number;
}
export interface NodeTextLayout {
labelLines: string[];
detailLines: string[];
sublabelLines: string[];
lineCount: number;
maxChars: number;
}
const CONFIG: Record<GraphViewKind, TextLayoutConfig> = {
callgraph: {
primaryChars: 28,
secondaryChars: 30,
maxPrimaryLines: 2,
maxSecondaryLines: 1,
maxSublabelLines: 1,
},
cfg: {
primaryChars: 30,
secondaryChars: 34,
maxPrimaryLines: 3,
maxSecondaryLines: 2,
maxSublabelLines: 1,
},
};
function normalizeWhitespace(value: string): string {
return value.replace(/\s+/g, ' ').trim();
}
function chooseBreakIndex(value: string, maxChars: number): number {
const probe = value.slice(0, maxChars + 1);
const preferred = Math.max(
probe.lastIndexOf(' '),
probe.lastIndexOf('.'),
probe.lastIndexOf(':'),
probe.lastIndexOf('/'),
probe.lastIndexOf('_'),
probe.lastIndexOf('('),
probe.lastIndexOf(')'),
probe.lastIndexOf(','),
);
if (preferred >= Math.floor(maxChars * 0.55)) {
return preferred + 1;
}
return maxChars;
}
export function wrapGraphText(
value: string | undefined,
maxChars: number,
): string[] {
if (!value) return [];
const normalized = normalizeWhitespace(value);
if (!normalized) return [];
const lines: string[] = [];
let remaining = normalized;
while (remaining.length > maxChars) {
const breakIndex = chooseBreakIndex(remaining, maxChars);
lines.push(remaining.slice(0, breakIndex).trim());
remaining = remaining.slice(breakIndex).trim();
}
if (remaining) lines.push(remaining);
return lines;
}
function clampLines(lines: string[], maxLines: number): string[] {
if (lines.length <= maxLines) return lines;
const visible = lines.slice(0, maxLines);
const last = visible[maxLines - 1];
if (!last) return visible;
visible[maxLines - 1] = last.endsWith('…') ? last : `${last.slice(0, -1)}`;
return visible;
}
export function getNodeTextLayout(
node: GraphNodeModel,
viewKind: GraphViewKind,
): NodeTextLayout {
const config = CONFIG[viewKind];
const labelLines = clampLines(
wrapGraphText(node.label, config.primaryChars),
config.maxPrimaryLines,
);
const detailLines = clampLines(
wrapGraphText(node.detail, config.secondaryChars),
config.maxSecondaryLines,
);
const sublabelLines = clampLines(
wrapGraphText(node.sublabel, config.secondaryChars),
config.maxSublabelLines,
);
const allLines = labelLines.concat(detailLines, sublabelLines);
return {
labelLines,
detailLines,
sublabelLines,
lineCount: allLines.length,
maxChars: Math.max(...allLines.map((line) => line.length), 8),
};
}

View file

@ -0,0 +1,165 @@
import type {
GraphCompactionResult,
GraphEdgeModel,
GraphModel,
GraphNodeModel,
} from '../types';
const CONTROL_KINDS = new Set([
'Entry',
'Exit',
'If',
'Loop',
'Return',
'Break',
'Continue',
]);
function buildLineRange(nodes: GraphNodeModel[]): string | undefined {
const lines = nodes
.map((node) => node.line)
.filter((line): line is number => typeof line === 'number' && line > 0);
if (lines.length === 0) return undefined;
const minLine = Math.min(...lines);
const maxLine = Math.max(...lines);
return minLine === maxLine ? `L${minLine}` : `L${minLine}-L${maxLine}`;
}
export function compactGraph(graph: GraphModel): GraphCompactionResult {
if (graph.kind !== 'cfg' || graph.nodes.length <= 3) {
return { graph, compounds: new Map() };
}
const seqOut = new Map<string, string>();
const seqIn = new Map<string, string>();
const seqOutCount = new Map<string, number>();
const seqInCount = new Map<string, number>();
const totalOutCount = new Map<string, number>();
const totalInCount = new Map<string, number>();
for (const node of graph.nodes) {
seqOutCount.set(node.key, 0);
seqInCount.set(node.key, 0);
totalOutCount.set(node.key, 0);
totalInCount.set(node.key, 0);
}
for (const edge of graph.edges) {
totalOutCount.set(edge.source, (totalOutCount.get(edge.source) ?? 0) + 1);
totalInCount.set(edge.target, (totalInCount.get(edge.target) ?? 0) + 1);
if (edge.kind !== 'Seq') continue;
seqOutCount.set(edge.source, (seqOutCount.get(edge.source) ?? 0) + 1);
seqInCount.set(edge.target, (seqInCount.get(edge.target) ?? 0) + 1);
seqOut.set(edge.source, edge.target);
seqIn.set(edge.target, edge.source);
}
const nodeMap = new Map(graph.nodes.map((node) => [node.key, node]));
const chainable = new Set<string>();
for (const node of graph.nodes) {
if (CONTROL_KINDS.has(node.kind)) continue;
if (
totalInCount.get(node.key) === 1 &&
totalOutCount.get(node.key) === 1 &&
seqInCount.get(node.key) === 1 &&
seqOutCount.get(node.key) === 1
) {
chainable.add(node.key);
}
}
const consumed = new Set<string>();
const chains: string[][] = [];
for (const node of graph.nodes) {
if (consumed.has(node.key) || chainable.has(node.key)) continue;
if (seqOutCount.get(node.key) !== 1) continue;
const next = seqOut.get(node.key);
if (!next || !chainable.has(next)) continue;
const chain: string[] = [];
let cursor: string | undefined = next;
while (cursor && chainable.has(cursor) && !consumed.has(cursor)) {
chain.push(cursor);
consumed.add(cursor);
cursor = seqOut.get(cursor);
}
if (chain.length >= 2) chains.push(chain);
}
if (chains.length === 0) return { graph, compounds: new Map() };
const removedKeys = new Set<string>();
const compounds = new Map<string, string[]>();
const compoundNodes: GraphNodeModel[] = [];
const replacement = new Map<string, string>();
let nextCompoundIndex = 0;
for (const chain of chains) {
const members = chain
.map((key) => nodeMap.get(key))
.filter((member): member is GraphNodeModel => member != null);
if (members.length !== chain.length) continue;
for (const key of chain) removedKeys.add(key);
const compoundKey = `compound:${nextCompoundIndex}`;
nextCompoundIndex += 1;
compounds.set(compoundKey, chain);
for (const key of chain) replacement.set(key, compoundKey);
compoundNodes.push({
key: compoundKey,
rawId: -1,
label: `${chain.length} statements`,
kind: 'Compound',
detail: buildLineRange(members),
line: members[0].line,
metadata: {
isCompound: true,
memberKeys: chain,
memberRawIds: members.map((member) => member.rawId),
},
});
}
const nodes = graph.nodes
.filter((node) => !removedKeys.has(node.key))
.concat(compoundNodes);
const dedupe = new Set<string>();
const edges: GraphEdgeModel[] = [];
for (const edge of graph.edges) {
const source = replacement.get(edge.source) ?? edge.source;
const target = replacement.get(edge.target) ?? edge.target;
if (source === target) continue;
const dedupeKey = `${source}:${target}:${edge.kind}`;
if (dedupe.has(dedupeKey)) continue;
dedupe.add(dedupeKey);
edges.push({
...edge,
key: `${edge.key}:compact:${source}:${target}`,
source,
target,
});
}
return {
graph: {
kind: graph.kind,
nodes,
edges,
},
compounds,
};
}

View file

@ -0,0 +1,66 @@
import type { GraphModel, GraphNodeModel } from '../types';
export function collectSearchMatches(
graph: GraphModel,
query: string,
limit = 200,
): GraphNodeModel[] {
const normalized = query.trim().toLowerCase();
if (!normalized) return [];
const matches: GraphNodeModel[] = [];
for (const node of graph.nodes) {
const haystack = String(
node.metadata?.searchText ?? node.label,
).toLowerCase();
if (!haystack.includes(normalized)) continue;
matches.push(node);
if (matches.length >= limit) break;
}
return matches;
}
export function extractNeighborhoodSubgraph(
graph: GraphModel,
centerKey: string | null,
radius: number,
): GraphModel {
if (!centerKey || radius < 1) return graph;
const nodeKeys = new Set(graph.nodes.map((node) => node.key));
if (!nodeKeys.has(centerKey)) return graph;
const adjacency = new Map<string, Set<string>>();
for (const node of graph.nodes) adjacency.set(node.key, new Set());
for (const edge of graph.edges) {
adjacency.get(edge.source)?.add(edge.target);
adjacency.get(edge.target)?.add(edge.source);
}
const visible = new Set<string>([centerKey]);
let frontier = new Set<string>([centerKey]);
for (let depth = 0; depth < radius; depth += 1) {
const next = new Set<string>();
for (const key of frontier) {
const neighbors = adjacency.get(key);
if (!neighbors) continue;
for (const neighbor of neighbors) {
if (visible.has(neighbor)) continue;
visible.add(neighbor);
next.add(neighbor);
}
}
if (next.size === 0) break;
frontier = next;
}
return {
kind: graph.kind,
nodes: graph.nodes.filter((node) => visible.has(node.key)),
edges: graph.edges.filter(
(edge) => visible.has(edge.source) && visible.has(edge.target),
),
};
}

View file

@ -0,0 +1,332 @@
import type { MutableRefObject, ReactNode } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import Sigma from 'sigma';
import { GraphToolbar } from '../../components/GraphToolbar';
import { readGraphPalette } from '../../styles';
import type {
GraphThemePalette,
GraphViewKind,
SigmaEdgeAttributes,
SigmaNodeAttributes,
} from '../../types';
import { buildSigmaGraph } from './buildGraph';
import { buildInteractionState, drawGraphOverlay } from './edgeOverlay';
import type { LayoutGraphModel } from '../../types';
interface SigmaGraphProps {
graph: LayoutGraphModel;
viewKind: GraphViewKind;
selectedNodeKey: string | null;
onNodeClick?: (key: string) => void;
searchMatchKeys?: Set<string>;
toolbarExtras?: ReactNode;
loading?: boolean;
}
const EMPTY_MATCHES = new Set<string>();
const MIN_CAMERA_RATIO = 0.001;
const NOOP_NODE_HOVER = () => {};
function zoomPercentage(
renderer: Sigma<SigmaNodeAttributes, SigmaEdgeAttributes> | null,
): number {
if (!renderer) return 100;
const ratio = renderer.getCamera().getState().ratio;
return Math.max(10, Math.round(100 / ratio));
}
function clampCameraRatio(
renderer: Sigma<SigmaNodeAttributes, SigmaEdgeAttributes>,
ratio: number,
): number {
const minCameraRatio = renderer.getSetting('minCameraRatio') ?? 0;
const maxCameraRatio =
renderer.getSetting('maxCameraRatio') ?? Number.POSITIVE_INFINITY;
return Math.min(maxCameraRatio, Math.max(minCameraRatio, ratio));
}
function getReadableFocusRatio(
renderer: Sigma<SigmaNodeAttributes, SigmaEdgeAttributes>,
graph: LayoutGraphModel,
nodeKey: string,
): number {
const currentRatio = renderer.getCamera().getState().ratio;
const node = graph.nodes.find((entry) => entry.key === nodeKey);
if (!node) return currentRatio;
const center = renderer.graphToViewport({ x: node.x, y: node.y });
const rightEdge = renderer.graphToViewport({
x: node.x + node.width / 2,
y: node.y,
});
const bottomEdge = renderer.graphToViewport({
x: node.x,
y: node.y + node.height / 2,
});
const renderedWidth = Math.max(1, Math.abs(rightEdge.x - center.x) * 2);
const renderedHeight = Math.max(1, Math.abs(bottomEdge.y - center.y) * 2);
const totalLines =
node.labelLines.length +
node.detailLines.length +
node.sublabelLines.length;
const maxLineChars = Math.max(
1,
...node.labelLines.map((line) => line.length),
...node.detailLines.map((line) => line.length),
...node.sublabelLines.map((line) => line.length),
);
const { width, height } = renderer.getDimensions();
const desiredWidth = Math.min(
width * 0.4,
Math.max(170, maxLineChars * 9.5 + 40),
);
const desiredHeight = Math.min(
height * 0.28,
Math.max(72, totalLines * 16 + (node.badges?.length ? 18 : 12)),
);
const widthRatio = currentRatio * (renderedWidth / desiredWidth);
const heightRatio = currentRatio * (renderedHeight / desiredHeight);
const targetRatio = Math.min(widthRatio, heightRatio, currentRatio);
return clampCameraRatio(renderer, Math.max(MIN_CAMERA_RATIO, targetRatio));
}
function createNodeReducer(
interactionRef: MutableRefObject<ReturnType<typeof buildInteractionState>>,
) {
return (nodeKey: string, data: SigmaNodeAttributes) => {
const interaction = interactionRef.current;
const isFocused =
interaction.selectedNodeKey === nodeKey ||
interaction.hoveredNodeKey === nodeKey ||
interaction.highlightedNodeKeys.has(nodeKey) ||
interaction.searchMatchKeys.has(nodeKey);
return {
...data,
color: 'rgba(0, 0, 0, 0)',
size: data.size,
highlighted: false,
forceLabel: false,
zIndex: isFocused ? 2 : 1,
};
};
}
export function SigmaGraph({
graph,
viewKind,
selectedNodeKey,
onNodeClick,
searchMatchKeys = EMPTY_MATCHES,
toolbarExtras,
loading = false,
}: SigmaGraphProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const rendererRef = useRef<Sigma<
SigmaNodeAttributes,
SigmaEdgeAttributes
> | null>(null);
const overlayCanvasRef = useRef<HTMLCanvasElement | null>(null);
const [hoveredNodeKey, setHoveredNodeKey] = useState<string | null>(null);
const [zoom, setZoom] = useState(100);
const palette = useMemo(() => readGraphPalette(), []);
const renderGraph = useMemo(
() => buildSigmaGraph(graph, palette, false),
[graph, palette],
);
const overlayGraph = useMemo(
() => buildSigmaGraph(graph, palette, true),
[graph, palette],
);
const interactionRef = useRef(
buildInteractionState(
overlayGraph,
selectedNodeKey,
hoveredNodeKey,
searchMatchKeys,
),
);
interactionRef.current = buildInteractionState(
overlayGraph,
selectedNodeKey,
hoveredNodeKey,
searchMatchKeys,
);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const renderer = new Sigma<SigmaNodeAttributes, SigmaEdgeAttributes>(
renderGraph,
container,
{
allowInvalidContainer: true,
autoCenter: true,
autoRescale: true,
defaultEdgeType: 'arrow',
defaultDrawNodeHover: NOOP_NODE_HOVER,
enableEdgeEvents: false,
renderEdgeLabels: false,
renderLabels: false,
hideLabelsOnMove: true,
labelDensity: viewKind === 'callgraph' ? 0.85 : 0.95,
labelRenderedSizeThreshold: viewKind === 'callgraph' ? 10 : 8,
minCameraRatio: MIN_CAMERA_RATIO,
maxCameraRatio: 4,
nodeReducer: createNodeReducer(interactionRef),
edgeReducer: () => ({
hidden: true,
}),
stagePadding: 24,
zIndex: true,
},
);
rendererRef.current = renderer;
setZoom(zoomPercentage(renderer));
const overlayCanvas = renderer.createCanvas('graphOverlay', {
afterLayer: 'edges',
style: {
pointerEvents: 'none',
},
});
overlayCanvasRef.current = overlayCanvas;
const redraw = () => {
if (!overlayCanvasRef.current || !rendererRef.current) return;
drawGraphOverlay(
overlayCanvasRef.current,
rendererRef.current,
overlayGraph,
viewKind,
palette,
interactionRef.current,
);
};
const handleClickNode = ({ node }: { node: string }) => {
onNodeClick?.(node);
const nodeDisplay = renderer.getNodeDisplayData(node);
if (!nodeDisplay) return;
const camera = renderer.getCamera();
const targetRatio = getReadableFocusRatio(renderer, graph, node);
void camera.animate(
{
x: nodeDisplay.x,
y: nodeDisplay.y,
ratio: targetRatio,
},
{ duration: 240 },
);
};
const handleEnterNode = ({ node }: { node: string }) => {
setHoveredNodeKey(node);
};
const handleLeaveNode = () => {
setHoveredNodeKey(null);
};
const handleAfterRender = () => {
setZoom(zoomPercentage(renderer));
redraw();
};
renderer.on('clickNode', handleClickNode);
renderer.on('enterNode', handleEnterNode);
renderer.on('leaveNode', handleLeaveNode);
renderer.on('afterRender', handleAfterRender);
const resizeObserver =
typeof ResizeObserver === 'undefined'
? null
: new ResizeObserver(() => {
renderer.resize();
renderer.refresh({ schedule: true });
});
resizeObserver?.observe(container);
redraw();
return () => {
resizeObserver?.disconnect();
renderer.off('clickNode', handleClickNode);
renderer.off('enterNode', handleEnterNode);
renderer.off('leaveNode', handleLeaveNode);
renderer.off('afterRender', handleAfterRender);
if (overlayCanvasRef.current) {
renderer.killLayer('graphOverlay');
overlayCanvasRef.current = null;
}
renderer.kill();
rendererRef.current = null;
};
}, [graph, onNodeClick, overlayGraph, palette, renderGraph, viewKind]);
useEffect(() => {
const renderer = rendererRef.current;
if (!renderer) return;
renderer.refresh({ schedule: true, skipIndexation: true });
}, [hoveredNodeKey, overlayGraph, searchMatchKeys, selectedNodeKey]);
const handleZoomIn = () => {
void rendererRef.current?.getCamera().animatedZoom();
};
const handleZoomOut = () => {
void rendererRef.current?.getCamera().animatedUnzoom();
};
const handleFitGraph = () => {
void rendererRef.current?.getCamera().animatedReset();
};
const handleFocusSelection = () => {
if (!selectedNodeKey) return;
const renderer = rendererRef.current;
if (!renderer) return;
const nodeDisplay = renderer.getNodeDisplayData(selectedNodeKey);
if (!nodeDisplay) return;
const camera = renderer.getCamera();
const targetRatio = getReadableFocusRatio(renderer, graph, selectedNodeKey);
void camera.animate(
{ x: nodeDisplay.x, y: nodeDisplay.y, ratio: targetRatio },
{ duration: 240 },
);
};
return (
<div className="graph-renderer-container">
<GraphToolbar
zoomPercentage={zoom}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
onFitGraph={handleFitGraph}
onFocusSelection={handleFocusSelection}
focusDisabled={!selectedNodeKey}
extras={toolbarExtras}
status={
loading ? (
<span className="graph-toolbar-pill">Layouting</span>
) : (
<span className="graph-toolbar-pill">
{graph.nodes.length} nodes
</span>
)
}
/>
<div className="graph-surface" ref={containerRef}>
{loading ? (
<div className="graph-loading-overlay">Computing ELK layout</div>
) : null}
</div>
</div>
);
}

View file

@ -0,0 +1,53 @@
import { MultiDirectedGraph } from 'graphology';
import { getEdgeStyle, getNodeStyle } from '../../styles';
import type {
GraphThemePalette,
LayoutGraphModel,
SigmaEdgeAttributes,
SigmaNodeAttributes,
} from '../../types';
function addNodes(
sigmaGraph: MultiDirectedGraph<SigmaNodeAttributes, SigmaEdgeAttributes>,
graph: LayoutGraphModel,
palette: GraphThemePalette,
) {
for (const node of graph.nodes) {
const style = getNodeStyle(node.kind, graph.kind, node.metadata, palette);
sigmaGraph.addNode(node.key, {
...node,
x: node.x,
y: node.y,
size: node.sigmaSize,
color: style.fill,
hidden: false,
});
}
}
export function buildSigmaGraph(
graph: LayoutGraphModel,
palette: GraphThemePalette,
includeEdges = true,
): MultiDirectedGraph<SigmaNodeAttributes, SigmaEdgeAttributes> {
const sigmaGraph = new MultiDirectedGraph<
SigmaNodeAttributes,
SigmaEdgeAttributes
>();
addNodes(sigmaGraph, graph, palette);
if (includeEdges) {
for (const edge of graph.edges) {
const style = getEdgeStyle(edge.kind, graph.kind, palette);
sigmaGraph.addDirectedEdgeWithKey(edge.key, edge.source, edge.target, {
...edge,
color: style.color,
size: style.width,
hidden: false,
});
}
}
return sigmaGraph;
}

View file

@ -0,0 +1,656 @@
import type Sigma from 'sigma';
import type { MultiDirectedGraph } from 'graphology';
import { getEdgeStyle, getNodeStyle, withAlpha } from '../../styles';
import type {
GraphThemePalette,
GraphViewKind,
SigmaEdgeAttributes,
SigmaNodeAttributes,
} from '../../types';
export interface GraphInteractionState {
activeNodeKey: string | null;
hoveredNodeKey: string | null;
selectedNodeKey: string | null;
highlightedNodeKeys: Set<string>;
highlightedEdgeKeys: Set<string>;
searchMatchKeys: Set<string>;
}
const MIN_NODE_TEXT_WIDTH = 58;
const MIN_NODE_TEXT_HEIGHT = 18;
const DETAIL_EDGE_LABEL_KINDS = new Set(['True', 'False', 'Back', 'Exception']);
export function buildInteractionState(
graph: MultiDirectedGraph<SigmaNodeAttributes, SigmaEdgeAttributes>,
selectedNodeKey: string | null,
hoveredNodeKey: string | null,
searchMatchKeys: Set<string>,
): GraphInteractionState {
const activeNodeKey = hoveredNodeKey ?? selectedNodeKey;
const highlightedNodeKeys = new Set<string>(searchMatchKeys);
const highlightedEdgeKeys = new Set<string>();
if (selectedNodeKey) highlightedNodeKeys.add(selectedNodeKey);
if (hoveredNodeKey) highlightedNodeKeys.add(hoveredNodeKey);
if (activeNodeKey && graph.hasNode(activeNodeKey)) {
highlightedNodeKeys.add(activeNodeKey);
for (const neighbor of graph.neighbors(activeNodeKey)) {
highlightedNodeKeys.add(neighbor);
}
for (const edge of graph.edges(activeNodeKey)) {
highlightedEdgeKeys.add(edge);
}
}
return {
activeNodeKey,
hoveredNodeKey,
selectedNodeKey,
highlightedNodeKeys,
highlightedEdgeKeys,
searchMatchKeys,
};
}
function setCanvasSize(
canvas: HTMLCanvasElement,
renderer: Sigma<SigmaNodeAttributes, SigmaEdgeAttributes>,
) {
const { width, height } = renderer.getDimensions();
const pixelRatio = window.devicePixelRatio || 1;
const nextWidth = Math.max(1, Math.floor(width * pixelRatio));
const nextHeight = Math.max(1, Math.floor(height * pixelRatio));
if (canvas.width !== nextWidth) canvas.width = nextWidth;
if (canvas.height !== nextHeight) canvas.height = nextHeight;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
const context = canvas.getContext('2d');
if (!context) return null;
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
return context;
}
function parseColor(color: string): [number, number, number] | null {
if (color.startsWith('#')) {
const normalized = color.slice(1);
const expanded =
normalized.length === 3
? normalized
.split('')
.map((segment) => segment + segment)
.join('')
: normalized;
const value = Number.parseInt(expanded, 16);
if (Number.isNaN(value)) return null;
return [(value >> 16) & 255, (value >> 8) & 255, value & 255];
}
const rgbaMatch = color.match(/rgba?\(([^)]+)\)/);
if (!rgbaMatch) return null;
const parts = rgbaMatch[1]
.split(',')
.slice(0, 3)
.map((part) => part.trim());
if (parts.length !== 3) return null;
const rgb = parts.map((part) => Number.parseFloat(part));
if (rgb.some((part) => Number.isNaN(part))) return null;
return [rgb[0], rgb[1], rgb[2]];
}
function isLightColor(color: string): boolean {
const rgb = parseColor(color);
if (!rgb) return false;
const [red, green, blue] = rgb.map((channel) => channel / 255);
const luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue;
return luminance > 0.68;
}
function drawRoundedRect(
context: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number,
) {
drawLabelBackdrop(context, x, y, width, height, radius);
}
function drawDoubleRect(
context: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number,
) {
drawRoundedRect(context, x, y, width, height, radius);
drawRoundedRect(context, x + 4, y + 4, width - 8, height - 8, radius - 2);
}
function drawTerminalRect(
context: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
) {
drawRoundedRect(context, x, y, width, height, height / 2);
}
function getViewportRect(
renderer: Sigma<SigmaNodeAttributes, SigmaEdgeAttributes>,
node: SigmaNodeAttributes,
) {
const center = renderer.graphToViewport({ x: node.x, y: node.y });
const xExtent = renderer.graphToViewport({
x: node.x + node.width / 2,
y: node.y,
});
const yExtent = renderer.graphToViewport({
x: node.x,
y: node.y + node.height / 2,
});
const width = Math.max(8, Math.abs(xExtent.x - center.x) * 2);
const height = Math.max(8, Math.abs(yExtent.y - center.y) * 2);
return {
x: center.x - width / 2,
y: center.y - height / 2,
width,
height,
centerX: center.x,
centerY: center.y,
};
}
function drawNodeBadges(
context: CanvasRenderingContext2D,
node: SigmaNodeAttributes,
rect: { x: number; y: number; width: number; height: number },
palette: GraphThemePalette,
fill: string,
) {
if (!node.badges?.length || rect.width < 90 || rect.height < 34) return;
const badges = node.badges.slice(0, 3);
const badgeHeight = 12;
const gap = 4;
const totalWidth = badges.reduce((sum, badge) => {
const badgeWidth = Math.min(52, Math.max(22, badge.length * 5.2 + 10));
return sum + badgeWidth;
}, 0);
const fullWidth = totalWidth + gap * (badges.length - 1);
let cursor = rect.x + (rect.width - fullWidth) / 2;
const y = rect.y + rect.height - badgeHeight - 4;
const textColor = isLightColor(fill) ? palette.text : '#ffffff';
context.save();
context.font = '600 8px var(--font-mono, "SF Mono", monospace)';
context.textAlign = 'center';
context.textBaseline = 'middle';
for (const badge of badges) {
const badgeWidth = Math.min(52, Math.max(22, badge.length * 5.2 + 10));
context.fillStyle = withAlpha(palette.background, 0.24);
context.strokeStyle = withAlpha(textColor, 0.18);
context.lineWidth = 0.8;
drawRoundedRect(context, cursor, y, badgeWidth, badgeHeight, 4);
context.fill();
context.stroke();
context.fillStyle = textColor;
context.fillText(badge, cursor + badgeWidth / 2, y + badgeHeight / 2 + 0.5);
cursor += badgeWidth + gap;
}
context.restore();
}
function drawNodeText(
context: CanvasRenderingContext2D,
node: SigmaNodeAttributes,
rect: { x: number; y: number; width: number; height: number },
palette: GraphThemePalette,
fill: string,
) {
const textLines = node.labelLines
.map((text) => ({ text, secondary: false }))
.concat(node.detailLines.map((text) => ({ text, secondary: true })))
.concat(node.sublabelLines.map((text) => ({ text, secondary: true })));
if (textLines.length === 0) return;
const availableHeight = rect.height - (node.badges?.length ? 18 : 10);
const lineBudget = Math.max(1, Math.floor(availableHeight / 11));
const visibleLines = textLines.slice(0, lineBudget);
if (
rect.width < MIN_NODE_TEXT_WIDTH ||
rect.height < MIN_NODE_TEXT_HEIGHT ||
visibleLines.length === 0
) {
return;
}
const primaryFont = Math.max(
8,
Math.min(12.5, rect.height / (visibleLines.length + 1.6)),
);
const secondaryFont = Math.max(7, primaryFont - 1.5);
const lineHeight = primaryFont + 2;
const blockHeight = visibleLines.reduce(
(sum, line) => sum + (line.secondary ? secondaryFont + 2 : lineHeight),
0,
);
const textColor = isLightColor(fill) ? palette.text : '#ffffff';
const secondaryColor = isLightColor(fill)
? palette.textSecondary
: withAlpha(textColor, 0.76);
let cursorY = rect.y + (availableHeight - blockHeight) / 2 + primaryFont;
context.save();
context.beginPath();
drawRoundedRect(context, rect.x, rect.y, rect.width, rect.height, 8);
context.clip();
context.textAlign = 'center';
context.textBaseline = 'alphabetic';
for (const line of visibleLines) {
const fontSize = line.secondary ? secondaryFont : primaryFont;
context.font = `${line.secondary ? '500' : '600'} ${fontSize}px var(--font-mono, "SF Mono", monospace)`;
context.fillStyle = line.secondary ? secondaryColor : textColor;
context.fillText(line.text, rect.x + rect.width / 2, cursorY);
cursorY += line.secondary ? secondaryFont + 2 : lineHeight;
}
context.restore();
}
function drawNodes(
context: CanvasRenderingContext2D,
renderer: Sigma<SigmaNodeAttributes, SigmaEdgeAttributes>,
graph: MultiDirectedGraph<SigmaNodeAttributes, SigmaEdgeAttributes>,
viewKind: GraphViewKind,
palette: GraphThemePalette,
interaction: GraphInteractionState,
) {
const nodes = graph
.mapNodes((key, attributes) => ({
key,
attributes,
}))
.sort((left, right) => {
const leftPriority =
interaction.selectedNodeKey === left.key
? 3
: interaction.hoveredNodeKey === left.key
? 2
: interaction.highlightedNodeKeys.has(left.key)
? 1
: 0;
const rightPriority =
interaction.selectedNodeKey === right.key
? 3
: interaction.hoveredNodeKey === right.key
? 2
: interaction.highlightedNodeKeys.has(right.key)
? 1
: 0;
return leftPriority - rightPriority;
});
for (const { key, attributes } of nodes) {
const style = getNodeStyle(
attributes.kind,
viewKind,
attributes.metadata,
palette,
);
const rect = getViewportRect(renderer, attributes);
const isSelected = interaction.selectedNodeKey === key;
const isHovered = interaction.hoveredNodeKey === key;
const isHighlighted = interaction.highlightedNodeKeys.has(key);
const isSearchMatch = interaction.searchMatchKeys.has(key);
const shouldDim =
Boolean(interaction.activeNodeKey) &&
!isSelected &&
!isHighlighted &&
!isSearchMatch;
let fill = style.fill;
let stroke = style.stroke;
const opacity = shouldDim ? 0.14 : 1;
if (isSelected) {
fill = style.accentFill;
stroke = withAlpha(palette.accent, 0.96);
} else if (isHovered || isHighlighted || isSearchMatch) {
fill = style.neighborFill;
stroke = withAlpha(style.accentFill, 0.85);
}
context.save();
context.globalAlpha = opacity;
if (isSelected) {
context.strokeStyle = withAlpha(palette.accent, 0.32);
context.lineWidth = 6;
drawRoundedRect(
context,
rect.x - 4,
rect.y - 4,
rect.width + 8,
rect.height + 8,
12,
);
context.stroke();
}
context.fillStyle = fill;
context.strokeStyle = stroke;
context.lineWidth = isSelected
? style.strokeWidth + 0.8
: style.strokeWidth;
if (style.shape === 'double') {
drawDoubleRect(context, rect.x, rect.y, rect.width, rect.height, 8);
} else if (style.shape === 'terminal') {
drawTerminalRect(context, rect.x, rect.y, rect.width, rect.height);
} else {
drawRoundedRect(context, rect.x, rect.y, rect.width, rect.height, 8);
}
context.fill();
context.stroke();
drawNodeText(context, attributes, rect, palette, fill);
drawNodeBadges(context, attributes, rect, palette, fill);
context.restore();
}
}
function drawArrow(
context: CanvasRenderingContext2D,
from: { x: number; y: number },
to: { x: number; y: number },
color: string,
size: number,
) {
const angle = Math.atan2(to.y - from.y, to.x - from.x);
const length = Math.max(5, size * 2.6);
context.save();
context.translate(to.x, to.y);
context.rotate(angle);
context.fillStyle = color;
context.beginPath();
context.moveTo(0, 0);
context.lineTo(-length, length * 0.45);
context.lineTo(-length, -length * 0.45);
context.closePath();
context.fill();
context.restore();
}
function drawLabelBackdrop(
context: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number,
) {
const clampedRadius = Math.min(radius, width / 2, height / 2);
context.beginPath();
context.moveTo(x + clampedRadius, y);
context.lineTo(x + width - clampedRadius, y);
context.quadraticCurveTo(x + width, y, x + width, y + clampedRadius);
context.lineTo(x + width, y + height - clampedRadius);
context.quadraticCurveTo(
x + width,
y + height,
x + width - clampedRadius,
y + height,
);
context.lineTo(x + clampedRadius, y + height);
context.quadraticCurveTo(x, y + height, x, y + height - clampedRadius);
context.lineTo(x, y + clampedRadius);
context.quadraticCurveTo(x, y, x + clampedRadius, y);
context.closePath();
}
function resolveOpacity(
interaction: GraphInteractionState,
edgeKey: string,
source: string,
target: string,
): number {
if (!interaction.activeNodeKey) return 0.8;
if (interaction.highlightedEdgeKeys.has(edgeKey)) return 0.96;
if (
interaction.highlightedNodeKeys.has(source) &&
interaction.highlightedNodeKeys.has(target)
) {
return 0.7;
}
return 0.14;
}
function resolveLineWidth(
baseWidth: number,
interaction: GraphInteractionState,
edgeKey: string,
): number {
if (interaction.highlightedEdgeKeys.has(edgeKey)) return baseWidth + 0.8;
return baseWidth;
}
function shouldDrawLabel(
renderer: Sigma<SigmaNodeAttributes, SigmaEdgeAttributes>,
graph: MultiDirectedGraph<SigmaNodeAttributes, SigmaEdgeAttributes>,
edge: SigmaEdgeAttributes,
interaction: GraphInteractionState,
graphOrder: number,
source: string,
target: string,
): boolean {
if (!edge.label) return false;
if (interaction.highlightedEdgeKeys.has(edge.key)) return true;
if (DETAIL_EDGE_LABEL_KINDS.has(edge.kind)) {
const sourceNode = graph.getNodeAttributes(source);
const targetNode = graph.getNodeAttributes(target);
const sourceRect = sourceNode
? getViewportRect(renderer, sourceNode)
: undefined;
const targetRect = targetNode
? getViewportRect(renderer, targetNode)
: undefined;
const nearReadableNode = [sourceRect, targetRect].some(
(rect) =>
rect != null &&
rect.width >= MIN_NODE_TEXT_WIDTH &&
rect.height >= MIN_NODE_TEXT_HEIGHT,
);
return nearReadableNode;
}
if (graphOrder <= 80) return true;
return renderer.getCamera().getState().ratio < 0.42;
}
function measureSegmentLength(
start: { x: number; y: number },
end: { x: number; y: number },
): number {
return Math.hypot(end.x - start.x, end.y - start.y);
}
function getLabelPlacement(
points: Array<{ x: number; y: number }>,
edgeKind: string,
) {
if (points.length < 2) return null;
const totalLength = points.reduce((sum, point, index) => {
if (index === 0) return sum;
return sum + measureSegmentLength(points[index - 1]!, point);
}, 0);
if (totalLength <= 0) return points[0] ?? null;
const alongPathRatio =
edgeKind === 'True' || edgeKind === 'False' ? 0.24 : 0.5;
const targetDistance = totalLength * alongPathRatio;
let traversed = 0;
for (let index = 1; index < points.length; index += 1) {
const start = points[index - 1]!;
const end = points[index]!;
const segmentLength = measureSegmentLength(start, end);
if (segmentLength <= 0) continue;
if (
traversed + segmentLength >= targetDistance ||
index === points.length - 1
) {
const distanceOnSegment = Math.max(0, targetDistance - traversed);
const t = Math.min(1, distanceOnSegment / segmentLength);
const directionX = (end.x - start.x) / segmentLength;
const directionY = (end.y - start.y) / segmentLength;
const normalX = -directionY;
const normalY = directionX;
const offset = edgeKind === 'False' ? -10 : edgeKind === 'True' ? 10 : 8;
return {
x: start.x + (end.x - start.x) * t + normalX * offset,
y: start.y + (end.y - start.y) * t + normalY * offset,
};
}
traversed += segmentLength;
}
return points[Math.floor(points.length / 2)] ?? null;
}
interface EdgeLabelInstruction {
color: string;
strokeColor: string;
text: string;
x: number;
y: number;
}
function drawEdgeLabels(
context: CanvasRenderingContext2D,
palette: GraphThemePalette,
labels: EdgeLabelInstruction[],
) {
for (const label of labels) {
const textWidth = Math.max(18, label.text.length * 6.4);
const rectX = label.x - textWidth / 2 - 5;
const rectY = label.y - 10;
context.fillStyle = withAlpha(palette.background, 0.92);
context.strokeStyle = label.strokeColor;
context.lineWidth = 1;
drawLabelBackdrop(context, rectX, rectY, textWidth + 10, 18, 4);
context.fill();
context.stroke();
context.fillStyle = label.color;
context.font = `600 10px var(--font-mono, "SF Mono", monospace)`;
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillText(label.text, label.x, label.y - 0.5);
}
}
export function drawGraphOverlay(
canvas: HTMLCanvasElement,
renderer: Sigma<SigmaNodeAttributes, SigmaEdgeAttributes>,
graph: MultiDirectedGraph<SigmaNodeAttributes, SigmaEdgeAttributes>,
viewKind: GraphViewKind,
palette: GraphThemePalette,
interaction: GraphInteractionState,
) {
const context = setCanvasSize(canvas, renderer);
if (!context) return;
const { width, height } = renderer.getDimensions();
context.clearRect(0, 0, width, height);
context.lineCap = 'round';
context.lineJoin = 'round';
const edgeLabels: EdgeLabelInstruction[] = [];
graph.forEachEdge((edgeKey, edge, source, target) => {
const style = getEdgeStyle(edge.kind, viewKind, palette);
const points =
edge.route.length > 1
? edge.route.map((point) => renderer.graphToViewport(point))
: [
renderer.graphToViewport(graph.getNodeAttributes(source)),
renderer.graphToViewport(graph.getNodeAttributes(target)),
];
if (points.length < 2) return;
const opacity = resolveOpacity(interaction, edgeKey, source, target);
const lineWidth = resolveLineWidth(style.width, interaction, edgeKey);
const color = withAlpha(style.color, opacity);
context.save();
context.strokeStyle = color;
context.lineWidth = lineWidth;
context.setLineDash(style.dash);
context.beginPath();
context.moveTo(points[0].x, points[0].y);
for (let index = 1; index < points.length; index += 1) {
context.lineTo(points[index].x, points[index].y);
}
context.stroke();
const from = points[points.length - 2];
const to = points[points.length - 1];
drawArrow(context, from, to, color, lineWidth + 0.5);
if (
shouldDrawLabel(
renderer,
graph,
edge,
interaction,
graph.order,
source,
target,
)
) {
const labelPoint = getLabelPlacement(points, edge.kind);
if (labelPoint) {
const labelColor = withAlpha(
interaction.highlightedEdgeKeys.has(edgeKey)
? palette.text
: style.color,
interaction.highlightedEdgeKeys.has(edgeKey) ? 0.96 : 0.8,
);
edgeLabels.push({
color: labelColor,
strokeColor: withAlpha(labelColor, 0.25),
text: edge.label!,
x: labelPoint.x,
y: labelPoint.y,
});
}
}
context.restore();
});
drawNodes(context, renderer, graph, viewKind, palette, interaction);
drawEdgeLabels(context, palette, edgeLabels);
}

View file

@ -0,0 +1,258 @@
import type { GraphMetadata, GraphThemePalette, GraphViewKind } from './types';
export interface NodeStyle {
fill: string;
stroke: string;
textFill: string;
secondaryFill: string;
shape: 'rect' | 'terminal' | 'double';
strokeWidth: number;
accentFill: string;
neighborFill: string;
}
export interface EdgeStyle {
color: string;
width: number;
dash: number[];
}
const FALLBACK_PALETTE: GraphThemePalette = {
background: '#ffffff',
backgroundSecondary: '#f7f7f8',
text: '#1a1a1a',
textSecondary: '#6b6b76',
textTertiary: '#9b9ba7',
border: '#e5e5ea',
borderLight: '#f0f0f4',
accent: '#5856d6',
accentSoft: '#ededfc',
success: '#2ecc71',
warning: '#e67e22',
danger: '#e74c3c',
neutral: '#607187',
neutralSoft: '#8c99ab',
};
function readVar(name: string, fallback: string): string {
if (typeof window === 'undefined') return fallback;
const value = getComputedStyle(document.documentElement)
.getPropertyValue(name)
.trim();
return value || fallback;
}
function hexToRgb(value: string): [number, number, number] | null {
const normalized = value.replace('#', '').trim();
if (normalized.length !== 3 && normalized.length !== 6) return null;
const expanded =
normalized.length === 3
? normalized
.split('')
.map((part) => part + part)
.join('')
: normalized;
const intValue = Number.parseInt(expanded, 16);
if (Number.isNaN(intValue)) return null;
return [(intValue >> 16) & 255, (intValue >> 8) & 255, intValue & 255];
}
export function withAlpha(color: string, alpha: number): string {
if (color.startsWith('rgba(')) {
return color.replace(/rgba\(([^)]+),[^)]+\)/, `rgba($1, ${alpha})`);
}
if (color.startsWith('rgb(')) {
const inner = color.slice(4, -1);
return `rgba(${inner}, ${alpha})`;
}
const rgb = hexToRgb(color);
if (!rgb) return color;
return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${alpha})`;
}
export function readGraphPalette(): GraphThemePalette {
return {
background: readVar('--bg', FALLBACK_PALETTE.background),
backgroundSecondary: readVar(
'--bg-secondary',
FALLBACK_PALETTE.backgroundSecondary,
),
text: readVar('--text', FALLBACK_PALETTE.text),
textSecondary: readVar('--text-secondary', FALLBACK_PALETTE.textSecondary),
textTertiary: readVar('--text-tertiary', FALLBACK_PALETTE.textTertiary),
border: readVar('--border', FALLBACK_PALETTE.border),
borderLight: readVar('--border-light', FALLBACK_PALETTE.borderLight),
accent: readVar('--accent', FALLBACK_PALETTE.accent),
accentSoft: readVar('--accent-light', FALLBACK_PALETTE.accentSoft),
success: readVar('--success', FALLBACK_PALETTE.success),
warning: readVar('--sev-medium', FALLBACK_PALETTE.warning),
danger: readVar('--sev-high', FALLBACK_PALETTE.danger),
neutral: FALLBACK_PALETTE.neutral,
neutralSoft: FALLBACK_PALETTE.neutralSoft,
};
}
function cfgNodeStyle(
type: string,
palette: GraphThemePalette,
metadata?: GraphMetadata,
): NodeStyle {
if (metadata?.isCompound) {
return {
fill: withAlpha(palette.borderLight, 0.9),
stroke: palette.border,
textFill: palette.text,
secondaryFill: palette.textSecondary,
shape: 'rect',
strokeWidth: 1.25,
accentFill: palette.accent,
neighborFill: palette.accentSoft,
};
}
switch (type) {
case 'Entry':
return {
fill: palette.success,
stroke: withAlpha(palette.success, 0.85),
textFill: '#ffffff',
secondaryFill: withAlpha('#ffffff', 0.78),
shape: 'double',
strokeWidth: 1.8,
accentFill: palette.accent,
neighborFill: withAlpha(palette.success, 0.75),
};
case 'Exit':
return {
fill: palette.textSecondary,
stroke: withAlpha(palette.textSecondary, 0.85),
textFill: '#ffffff',
secondaryFill: withAlpha('#ffffff', 0.78),
shape: 'double',
strokeWidth: 1.6,
accentFill: palette.accent,
neighborFill: withAlpha(palette.textSecondary, 0.76),
};
case 'If':
return {
fill: palette.accent,
stroke: withAlpha(palette.accent, 0.82),
textFill: '#ffffff',
secondaryFill: withAlpha('#ffffff', 0.8),
shape: 'rect',
strokeWidth: 2,
accentFill: palette.accent,
neighborFill: palette.accentSoft,
};
case 'Loop':
return {
fill: '#4f78c2',
stroke: '#3c5f9a',
textFill: '#ffffff',
secondaryFill: withAlpha('#ffffff', 0.8),
shape: 'rect',
strokeWidth: 2.1,
accentFill: palette.accent,
neighborFill: withAlpha('#4f78c2', 0.74),
};
case 'Call':
return {
fill: palette.warning,
stroke: withAlpha(palette.warning, 0.85),
textFill: '#ffffff',
secondaryFill: withAlpha('#ffffff', 0.8),
shape: 'rect',
strokeWidth: 1.5,
accentFill: palette.accent,
neighborFill: withAlpha(palette.warning, 0.76),
};
case 'Return':
return {
fill: palette.danger,
stroke: withAlpha(palette.danger, 0.86),
textFill: '#ffffff',
secondaryFill: withAlpha('#ffffff', 0.8),
shape: 'terminal',
strokeWidth: 1.7,
accentFill: palette.accent,
neighborFill: withAlpha(palette.danger, 0.75),
};
default:
return {
fill: withAlpha(palette.neutral, 0.92),
stroke: withAlpha(palette.neutral, 0.8),
textFill: '#ffffff',
secondaryFill: withAlpha('#ffffff', 0.78),
shape: 'rect',
strokeWidth: 1.2,
accentFill: palette.accent,
neighborFill: withAlpha(palette.neutralSoft, 0.88),
};
}
}
function callGraphNodeStyle(
palette: GraphThemePalette,
metadata?: GraphMetadata,
): NodeStyle {
const isRecursive = metadata?.isRecursive === true;
const fill = isRecursive ? '#7d6450' : palette.neutral;
const stroke = isRecursive ? '#6a5444' : withAlpha(palette.neutral, 0.84);
return {
fill,
stroke,
textFill: '#ffffff',
secondaryFill: withAlpha('#ffffff', 0.74),
shape: 'rect',
strokeWidth: isRecursive ? 1.8 : 1.3,
accentFill: palette.accent,
neighborFill: isRecursive ? withAlpha(fill, 0.76) : palette.accentSoft,
};
}
export function getNodeStyle(
type: string,
graphKind: GraphViewKind = 'cfg',
metadata?: GraphMetadata,
palette = FALLBACK_PALETTE,
): NodeStyle {
return graphKind === 'callgraph'
? callGraphNodeStyle(palette, metadata)
: cfgNodeStyle(type, palette, metadata);
}
export function getEdgeStyle(
type: string,
graphKind: GraphViewKind = 'cfg',
palette = FALLBACK_PALETTE,
): EdgeStyle {
if (graphKind === 'callgraph') {
return {
color: withAlpha(palette.neutralSoft, 0.72),
width: 1.2,
dash: [],
};
}
switch (type) {
case 'True':
return { color: palette.success, width: 1.8, dash: [] };
case 'False':
return { color: palette.danger, width: 1.8, dash: [] };
case 'Back':
return { color: '#4f78c2', width: 1.6, dash: [7, 4] };
case 'Exception':
return { color: palette.warning, width: 1.6, dash: [3, 3] };
default:
return {
color: withAlpha(palette.textTertiary, 0.78),
width: 1.3,
dash: [],
};
}
}

111
frontend/src/graph/types.ts Normal file
View file

@ -0,0 +1,111 @@
export type GraphViewKind = 'callgraph' | 'cfg';
export interface GraphPoint {
x: number;
y: number;
}
export interface GraphMetadata {
[key: string]: unknown;
}
export interface GraphNodeModel {
key: string;
rawId: number;
label: string;
kind: string;
detail?: string;
sublabel?: string;
badges?: string[];
line?: number;
metadata?: GraphMetadata;
}
export type GraphNode = GraphNodeModel;
export interface GraphEdgeModel {
key: string;
source: string;
target: string;
kind: string;
label?: string;
metadata?: GraphMetadata;
}
export type GraphEdge = GraphEdgeModel;
export interface GraphModel {
kind: GraphViewKind;
nodes: GraphNodeModel[];
edges: GraphEdgeModel[];
}
export interface GraphCompactionResult {
graph: GraphModel;
compounds: Map<string, string[]>;
}
export interface LayoutBounds {
width: number;
height: number;
}
export interface LayoutGraphNode extends GraphNodeModel {
x: number;
y: number;
width: number;
height: number;
sigmaSize: number;
labelLines: string[];
detailLines: string[];
sublabelLines: string[];
}
export interface LayoutGraphEdge extends GraphEdgeModel {
route: GraphPoint[];
}
export interface LayoutGraphModel {
kind: GraphViewKind;
nodes: LayoutGraphNode[];
edges: LayoutGraphEdge[];
bounds: LayoutBounds;
}
export interface ElkLayoutPreset {
direction: 'DOWN' | 'RIGHT';
nodeSpacing: number;
layerSpacing: number;
edgeNodeSpacing: number;
padding: number;
edgeRouting: 'POLYLINE' | 'ORTHOGONAL';
}
export interface GraphThemePalette {
background: string;
backgroundSecondary: string;
text: string;
textSecondary: string;
textTertiary: string;
border: string;
borderLight: string;
accent: string;
accentSoft: string;
success: string;
warning: string;
danger: string;
neutral: string;
neutralSoft: string;
}
export interface SigmaNodeAttributes extends LayoutGraphNode {
size: number;
color: string;
hidden: boolean;
}
export interface SigmaEdgeAttributes extends LayoutGraphEdge {
color: string;
size: number;
hidden: boolean;
}

View file

@ -0,0 +1,16 @@
import { useState, useEffect } from 'react';
/**
* Returns a debounced version of the given value.
* The returned value only updates after `delay` ms of inactivity.
*/
export function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}

View file

@ -0,0 +1,129 @@
import { useState, useEffect, useCallback } from 'react';
import { useExplorerTree } from '../api/queries/explorer';
import type { TreeEntry } from '../api/types';
export interface UseFileTreeReturn {
rootEntries: TreeEntry[] | undefined;
isLoading: boolean;
expandedPaths: Set<string>;
loadedChildren: Map<string, TreeEntry[]>;
selectedPath: string | null;
handleToggleExpand: (path: string) => void;
handleSelectFile: (path: string) => void;
setSelectedPath: (path: string | null) => void;
}
export function useFileTree(
initialPath?: string | null,
onSelectFile?: (path: string) => void,
): UseFileTreeReturn {
const [selectedPath, setSelectedPath] = useState<string | null>(
initialPath ?? null,
);
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
const [loadedChildren, setLoadedChildren] = useState<
Map<string, TreeEntry[]>
>(new Map());
const [expandQueue, setExpandQueue] = useState<string | null>(null);
const { data: rootEntries, isLoading } = useExplorerTree();
const { data: childEntries } = useExplorerTree(expandQueue || undefined);
// Sync external path changes (e.g. back/forward navigation).
useEffect(() => {
const normalized = initialPath ?? null;
setSelectedPath((prev) => (prev !== normalized ? normalized : prev));
}, [initialPath]);
// Auto-expand ancestor directories for deep-linked files so the selected
// file is visible in the tree once its parent directories load.
useEffect(() => {
if (!initialPath) {
return;
}
const ancestors = getAncestorPaths(initialPath);
if (ancestors.length === 0) {
return;
}
setExpandedPaths((prev) => {
const next = new Set(prev);
let changed = false;
for (const ancestor of ancestors) {
if (!next.has(ancestor)) {
next.add(ancestor);
changed = true;
}
}
return changed ? next : prev;
});
const nextToLoad = ancestors.find(
(ancestor) => !loadedChildren.has(ancestor),
);
if (nextToLoad && expandQueue !== nextToLoad) {
setExpandQueue(nextToLoad);
}
}, [expandQueue, initialPath, loadedChildren]);
// Store child entries when they arrive for an expanded directory.
useEffect(() => {
if (expandQueue && childEntries) {
setLoadedChildren((prev) => {
const next = new Map(prev);
next.set(expandQueue, childEntries);
return next;
});
setExpandQueue(null);
}
}, [expandQueue, childEntries]);
const handleToggleExpand = useCallback(
(path: string) => {
setExpandedPaths((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
if (!loadedChildren.has(path)) {
setExpandQueue(path);
}
}
return next;
});
},
[loadedChildren],
);
const handleSelectFile = useCallback(
(path: string) => {
setSelectedPath(path);
onSelectFile?.(path);
},
[onSelectFile],
);
return {
rootEntries,
isLoading,
expandedPaths,
loadedChildren,
selectedPath,
handleToggleExpand,
handleSelectFile,
setSelectedPath,
};
}
function getAncestorPaths(path: string): string[] {
const parts = path.split('/').filter(Boolean);
const ancestors: string[] = [];
for (let i = 1; i < parts.length; i += 1) {
ancestors.push(parts.slice(0, i).join('/'));
}
return ancestors;
}

View file

@ -0,0 +1,117 @@
import { useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
export interface FindingsURLState {
page: string;
per_page: string;
sort_by: string;
sort_dir: string;
severity: string;
category: string;
confidence: string;
language: string;
rule_id: string;
status: string;
search: string;
}
const FINDINGS_DEFAULTS: FindingsURLState = {
page: '1',
per_page: '50',
sort_by: '',
sort_dir: 'asc',
severity: '',
category: '',
confidence: '',
language: '',
rule_id: '',
status: '',
search: '',
};
const FILTER_KEYS: ReadonlySet<string> = new Set([
'severity',
'category',
'confidence',
'language',
'rule_id',
'status',
'search',
]);
/** Keys that do NOT trigger a page reset when changed. */
const NON_RESET_KEYS: ReadonlySet<string> = new Set([
'page',
'sort_by',
'sort_dir',
'per_page',
]);
export function useFindingsURLState() {
const [searchParams, setSearchParams] = useSearchParams();
const state: FindingsURLState = useMemo(() => {
const s = {} as FindingsURLState;
for (const key of Object.keys(
FINDINGS_DEFAULTS,
) as (keyof FindingsURLState)[]) {
s[key] = searchParams.get(key) || FINDINGS_DEFAULTS[key];
}
return s;
}, [searchParams]);
const updateState = useCallback(
(updates: Partial<FindingsURLState>) => {
setSearchParams((prev) => {
const current = {} as FindingsURLState;
for (const key of Object.keys(
FINDINGS_DEFAULTS,
) as (keyof FindingsURLState)[]) {
current[key] = prev.get(key) || FINDINGS_DEFAULTS[key];
}
const merged = { ...current, ...updates };
// Reset page to 1 when any filter/non-pagination field changes
const hasFilterChange = Object.keys(updates).some(
(k) => !NON_RESET_KEYS.has(k),
);
if (hasFilterChange) {
merged.page = '1';
}
// Build new search params, omitting defaults
const next = new URLSearchParams();
for (const [k, v] of Object.entries(merged)) {
if (v && v !== FINDINGS_DEFAULTS[k as keyof FindingsURLState]) {
next.set(k, v);
}
}
return next;
});
},
[setSearchParams],
);
const resetFilters = useCallback(() => {
setSearchParams((prev) => {
const next = new URLSearchParams();
// Preserve per_page but reset everything else
const perPage = prev.get('per_page');
if (perPage && perPage !== FINDINGS_DEFAULTS.per_page) {
next.set('per_page', perPage);
}
return next;
});
}, [setSearchParams]);
const hasActiveFilters = useMemo(
() =>
Array.from(FILTER_KEYS).some(
(k) => state[k as keyof FindingsURLState] !== '',
),
[state],
);
return { state, updateState, resetFilters, hasActiveFilters };
}

10
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { App } from './App';
import './styles/global.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View file

@ -0,0 +1,42 @@
import { Modal } from '../components/ui/Modal';
import { CodeViewer } from '../components/data-display/CodeViewer';
import type { FindingView } from '../api/types';
interface CodeViewerModalProps {
open: boolean;
onClose: () => void;
finding: FindingView | null;
}
export function CodeViewerModal({
open,
onClose,
finding,
}: CodeViewerModalProps) {
if (!open || !finding) return null;
return (
<Modal open={open} onClose={onClose} className="code-modal-overlay">
<div className="code-modal">
<div className="code-modal-header">
<span className="code-modal-title">{finding.path}</span>
<button className="btn btn-sm code-modal-close" onClick={onClose}>
Close
</button>
</div>
<div className="code-modal-body">
<CodeViewer
filePath={finding.path}
language={finding.language || ''}
highlights={{
sourceLine: finding.evidence?.source?.line,
sinkLine: finding.evidence?.sink?.line,
findingLine: finding.line,
}}
highlightLine={finding.line}
/>
</div>
</div>
</Modal>
);
}

View file

@ -0,0 +1,114 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Modal } from '../components/ui/Modal';
import { useHealth } from '../api/queries/health';
import {
useStartScan,
type ScanMode,
type EngineProfile,
type StartScanBody,
} from '../api/mutations/scans';
interface NewScanModalProps {
open: boolean;
onClose: () => void;
}
const MODE_HINTS: Record<ScanMode, string> = {
full: 'AST + CFG + taint (default)',
ast: 'AST patterns only — fastest',
cfg: 'CFG structural + taint',
taint: 'Taint flows only',
};
const PROFILE_HINTS: Record<EngineProfile, string> = {
fast: 'Basic taint. No abstract-interp / context-sensitive / symex / backwards.',
balanced: 'Default. Adds abstract-interp + context-sensitive inlining.',
deep: 'Adds symex (cross-file + interproc) and demand-driven backwards taint. ~23× slower.',
};
export function NewScanModal({ open, onClose }: NewScanModalProps) {
const { data: health } = useHealth();
const startScan = useStartScan();
const navigate = useNavigate();
const defaultRoot = health?.scan_root || '';
const [scanRoot, setScanRoot] = useState('');
const [mode, setMode] = useState<ScanMode>('full');
const [engineProfile, setEngineProfile] = useState<EngineProfile>('balanced');
const handleStart = async () => {
const root = scanRoot.trim();
const body: StartScanBody = {};
if (root && root !== defaultRoot) body.scan_root = root;
if (mode !== 'full') body.mode = mode;
body.engine_profile = engineProfile;
const payload = Object.keys(body).length ? body : undefined;
try {
await startScan.mutateAsync(payload);
onClose();
navigate('/scans');
} catch (e) {
alert(e instanceof Error ? e.message : 'Failed to start scan');
}
};
if (!open) return null;
return (
<Modal open={open} onClose={onClose} className="scan-modal-overlay">
<div className="scan-modal">
<h3>Start New Scan</h3>
<div className="scan-modal-form">
<div className="form-group">
<label>Scan Root</label>
<input
type="text"
value={scanRoot || defaultRoot}
onChange={(e) => setScanRoot(e.target.value)}
placeholder="/path/to/project"
/>
</div>
<div className="form-group">
<label>Analysis Mode</label>
<select
value={mode}
onChange={(e) => setMode(e.target.value as ScanMode)}
>
<option value="full">Full</option>
<option value="ast">AST only</option>
<option value="cfg">CFG + taint</option>
<option value="taint">Taint only</option>
</select>
<span className="form-hint">{MODE_HINTS[mode]}</span>
</div>
<div className="form-group">
<label>Engine Profile</label>
<select
value={engineProfile}
onChange={(e) =>
setEngineProfile(e.target.value as EngineProfile)
}
>
<option value="fast">Fast</option>
<option value="balanced">Balanced (default)</option>
<option value="deep">Deep</option>
</select>
<span className="form-hint">{PROFILE_HINTS[engineProfile]}</span>
</div>
<div className="scan-modal-actions">
<button className="btn btn-sm" onClick={onClose}>
Cancel
</button>
<button
className="btn btn-primary btn-sm"
onClick={handleStart}
disabled={startScan.isPending}
>
{startScan.isPending ? 'Starting...' : 'Start Scan'}
</button>
</div>
</div>
</div>
</Modal>
);
}

View file

@ -0,0 +1,519 @@
import { useState, useCallback } from 'react';
import {
useConfig,
useSources,
useSinks,
useSanitizers,
useTerminators,
useProfiles,
} from '../api/queries/config';
import {
useAddSource,
useDeleteSource,
useAddSink,
useDeleteSink,
useAddSanitizer,
useDeleteSanitizer,
useAddTerminator,
useDeleteTerminator,
useAddProfile,
useDeleteProfile,
useActivateProfile,
useToggleTriageSync,
} from '../api/mutations/config';
import { LoadingState } from '../components/ui/LoadingState';
import { ErrorState } from '../components/ui/ErrorState';
import type { LabelEntryView, TerminatorView, ProfileView } from '../api/types';
const LANG_OPTIONS = [
'javascript',
'typescript',
'python',
'go',
'java',
'c',
'cpp',
'php',
'ruby',
'rust',
];
const CAP_OPTIONS = [
'all',
'env_var',
'html_escape',
'shell_escape',
'url_encode',
'json_parse',
'file_io',
'sql_query',
'deserialize',
'ssrf',
'code_exec',
'crypto',
];
// ── Collapsible Config Section ───────────────────────────────────────────────
function ConfigSection({
title,
id,
children,
}: {
title: string;
id: string;
children: React.ReactNode;
}) {
const [collapsed, setCollapsed] = useState(false);
return (
<div className="config-section" id={id}>
<div
className={`config-section-header${collapsed ? ' collapsed' : ''}`}
onClick={() => setCollapsed(!collapsed)}
>
<span
className={`config-collapse-arrow${collapsed ? ' collapsed' : ''}`}
>
&#9660;
</span>{' '}
<strong>{title}</strong>
</div>
<div className={`config-section-body${collapsed ? ' collapsed' : ''}`}>
{children}
</div>
</div>
);
}
// ── Label Table (Source/Sink/Sanitizer) ──────────────────────────────────────
function LabelSection({
title,
id,
kind,
entries,
onAdd,
onDelete,
}: {
title: string;
id: string;
kind: string;
entries: LabelEntryView[];
onAdd: (body: { lang: string; matchers: string[]; cap: string }) => void;
onDelete: (entry: LabelEntryView) => void;
}) {
const [lang, setLang] = useState('');
const [matcher, setMatcher] = useState('');
const [cap, setCap] = useState('all');
const builtins = entries.filter((e) => e.is_builtin);
const custom = entries.filter((e) => !e.is_builtin);
const handleAdd = useCallback(() => {
if (!lang || !matcher) return;
onAdd({ lang, matchers: [matcher], cap });
setMatcher('');
}, [lang, matcher, cap, onAdd]);
return (
<ConfigSection title={title} id={id}>
<div className="inline-form add-label-form">
<div className="form-group">
<label>Language</label>
<select
style={{ width: 140 }}
value={lang}
onChange={(e) => setLang(e.target.value)}
>
<option value="">Select...</option>
{LANG_OPTIONS.map((l) => (
<option key={l} value={l}>
{l}
</option>
))}
</select>
</div>
<div className="form-group">
<label>Matcher</label>
<input
type="text"
placeholder="functionName"
value={matcher}
onChange={(e) => setMatcher(e.target.value)}
/>
</div>
<div className="form-group">
<label>Capability</label>
<select value={cap} onChange={(e) => setCap(e.target.value)}>
{CAP_OPTIONS.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
<button className="btn btn-primary btn-sm" onClick={handleAdd}>
Add {kind}
</button>
</div>
<div className="table-wrap" style={{ marginTop: 8 }}>
{entries.length === 0 ? (
<div className="empty-state" style={{ padding: 12 }}>
<p>No {kind} rules</p>
</div>
) : (
<table className="label-table">
<thead>
<tr>
<th>Language</th>
<th>Matchers</th>
<th>Cap</th>
<th></th>
</tr>
</thead>
<tbody>
{builtins.map((e, i) => (
<tr key={`b-${i}`} className="label-builtin">
<td>{e.lang}</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>
{e.matchers.join(', ')}
</td>
<td>{e.cap}</td>
<td>
<span className="badge-builtin">built-in</span>
</td>
</tr>
))}
{custom.map((e, i) => (
<tr key={`c-${i}`}>
<td>{e.lang}</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>
{e.matchers.join(', ')}
</td>
<td>{e.cap}</td>
<td>
<button
className="btn btn-danger btn-sm"
onClick={() => onDelete(e)}
>
Remove
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</ConfigSection>
);
}
// ── Config Page ──────────────────────────────────────────────────────────────
export function ConfigPage() {
const {
data: config,
isLoading: configLoading,
error: configError,
} = useConfig();
const { data: sources } = useSources();
const { data: sinks } = useSinks();
const { data: sanitizers } = useSanitizers();
const { data: terminators } = useTerminators();
const { data: profiles } = useProfiles();
const addSource = useAddSource();
const deleteSource = useDeleteSource();
const addSink = useAddSink();
const deleteSink = useDeleteSink();
const addSanitizer = useAddSanitizer();
const deleteSanitizer = useDeleteSanitizer();
const addTerminator = useAddTerminator();
const deleteTerminator = useDeleteTerminator();
const addProfile = useAddProfile();
const deleteProfile = useDeleteProfile();
const activateProfile = useActivateProfile();
const toggleTriageSync = useToggleTriageSync();
const [termLang, setTermLang] = useState('');
const [termName, setTermName] = useState('');
const [profileName, setProfileName] = useState('');
const handleAddTerminator = useCallback(() => {
if (!termLang || !termName) return;
addTerminator.mutate({ lang: termLang, name: termName });
setTermName('');
}, [termLang, termName, addTerminator]);
const handleSaveProfile = useCallback(() => {
if (!profileName) return;
addProfile.mutate({ name: profileName, settings: {} });
setProfileName('');
}, [profileName, addProfile]);
if (configLoading) return <LoadingState message="Loading configuration..." />;
if (configError) return <ErrorState message={configError.message} />;
// Extract config fields (config is typed as unknown since it's the raw NyxConfig)
const cfg = config as Record<string, Record<string, unknown>> | undefined;
const scanner = cfg?.scanner as Record<string, unknown> | undefined;
const output = cfg?.output as Record<string, unknown> | undefined;
const server = cfg?.server as Record<string, unknown> | undefined;
return (
<>
<div className="page-header">
<h2>Config</h2>
</div>
{/* General Section */}
<ConfigSection title="General" id="config-general">
<div className="detail-meta">
<div>
<strong>Analysis Mode:</strong> {String(scanner?.mode || 'full')}
</div>
<div>
<strong>Min Severity:</strong>{' '}
{String(scanner?.min_severity || 'Low')}
</div>
<div>
<strong>Max File Size:</strong>{' '}
{scanner?.max_file_size_mb
? String(scanner.max_file_size_mb) + ' MB'
: 'unlimited'}
</div>
<div>
<strong>Excluded Dirs:</strong>{' '}
{((scanner?.excluded_directories as string[]) || []).join(', ')}
</div>
<div>
<strong>Excluded Exts:</strong>{' '}
{((scanner?.excluded_extensions as string[]) || []).join(', ')}
</div>
<div>
<strong>Attack Surface Ranking:</strong>{' '}
{output?.attack_surface_ranking ? 'Enabled' : 'Disabled'}
</div>
</div>
<div
style={{
marginTop: 'var(--space-4)',
paddingTop: 'var(--space-3)',
borderTop: '1px solid var(--border)',
}}
>
<div className="toggle-inline">
<input
type="checkbox"
id="triage-sync-toggle"
checked={!!server?.triage_sync}
onChange={(e) =>
toggleTriageSync.mutate({ enabled: e.target.checked })
}
/>
<label htmlFor="triage-sync-toggle">
<strong>Triage Sync</strong> &mdash; Auto-sync triage decisions to{' '}
<code>.nyx/triage.json</code> for git-based team sharing
</label>
</div>
</div>
</ConfigSection>
{/* Sources */}
<LabelSection
title="Sources"
id="config-sources"
kind="source"
entries={sources || []}
onAdd={(body) => addSource.mutate(body)}
onDelete={(e) =>
deleteSource.mutate({
lang: e.lang,
matchers: e.matchers,
cap: e.cap,
})
}
/>
{/* Sinks */}
<LabelSection
title="Sinks"
id="config-sinks"
kind="sink"
entries={sinks || []}
onAdd={(body) => addSink.mutate(body)}
onDelete={(e) =>
deleteSink.mutate({ lang: e.lang, matchers: e.matchers, cap: e.cap })
}
/>
{/* Sanitizers */}
<LabelSection
title="Sanitizers"
id="config-sanitizers"
kind="sanitizer"
entries={sanitizers || []}
onAdd={(body) => addSanitizer.mutate(body)}
onDelete={(e) =>
deleteSanitizer.mutate({
lang: e.lang,
matchers: e.matchers,
cap: e.cap,
})
}
/>
{/* Terminators */}
<ConfigSection title="Terminators" id="config-terminators">
<div className="inline-form" id="add-term-form">
<div className="form-group">
<label>Language</label>
<select
style={{ width: 140 }}
value={termLang}
onChange={(e) => setTermLang(e.target.value)}
>
<option value="">Select...</option>
{LANG_OPTIONS.map((l) => (
<option key={l} value={l}>
{l}
</option>
))}
</select>
</div>
<div className="form-group">
<label>Function Name</label>
<input
type="text"
placeholder="process.exit"
value={termName}
onChange={(e) => setTermName(e.target.value)}
/>
</div>
<button
className="btn btn-primary btn-sm"
onClick={handleAddTerminator}
>
Add Terminator
</button>
</div>
<div className="table-wrap">
{!terminators || terminators.length === 0 ? (
<div className="empty-state" style={{ padding: 12 }}>
<p>No terminators configured</p>
</div>
) : (
<table>
<thead>
<tr>
<th>Language</th>
<th>Name</th>
<th></th>
</tr>
</thead>
<tbody>
{(terminators as TerminatorView[]).map((t, i) => (
<tr key={i}>
<td>{t.lang}</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>{t.name}</td>
<td>
<button
className="btn btn-danger btn-sm"
onClick={() => deleteTerminator.mutate(t)}
>
Remove
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</ConfigSection>
{/* Profiles */}
<ConfigSection title="Profiles" id="config-profiles">
<div className="table-wrap">
{!profiles || profiles.length === 0 ? (
<div className="empty-state" style={{ padding: 12 }}>
<p>No profiles configured</p>
</div>
) : (
<table>
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Settings</th>
<th></th>
</tr>
</thead>
<tbody>
{(profiles as ProfileView[]).map((p) => (
<tr key={p.name}>
<td>
<strong>{p.name}</strong>
</td>
<td>
{p.is_builtin ? (
<span className="badge-builtin">built-in</span>
) : (
<span className="badge-custom">custom</span>
)}
</td>
<td
style={{
fontSize: 'var(--text-xs)',
maxWidth: 300,
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{JSON.stringify(p.settings)}
</td>
<td>
<button
className="btn btn-sm"
onClick={() => activateProfile.mutate(p.name)}
>
Activate
</button>
{!p.is_builtin && (
<button
className="btn btn-danger btn-sm"
onClick={() => deleteProfile.mutate(p.name)}
>
Delete
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="inline-form" style={{ marginTop: 12 }}>
<div className="form-group">
<label>Profile Name</label>
<input
type="text"
placeholder="my_profile"
value={profileName}
onChange={(e) => setProfileName(e.target.value)}
/>
</div>
<button
className="btn btn-primary btn-sm"
onClick={handleSaveProfile}
>
Save Current as Profile
</button>
</div>
</ConfigSection>
</>
);
}

View file

@ -0,0 +1,878 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
useExplorerSymbols,
useExplorerFindings,
} from '../api/queries/explorer';
import { useFinding } from '../api/queries/findings';
import { useDebugFunctions } from '../api/queries/debug';
import { ApiError } from '../api/client';
import { FileTree } from '../components/data-display/FileTree';
import { CodeViewer } from '../components/data-display/CodeViewer';
import { LoadingState } from '../components/ui/LoadingState';
import { EmptyState } from '../components/ui/EmptyState';
import { ExplorerIcon } from '../components/icons/Icons';
import { useFileTree } from '../hooks/useFileTree';
import { FunctionSelector } from './debug/FunctionSelector';
import { CfgAnalysisPanel } from './debug/CfgViewerPage';
import { SsaAnalysisPanel } from './debug/SsaViewerPage';
import { TaintAnalysisPanel } from './debug/TaintViewerPage';
import { SummaryAnalysisPanel } from './debug/SummaryExplorerPage';
import { AbstractInterpAnalysisPanel } from './debug/AbstractInterpPage';
import { SymexAnalysisPanel } from './debug/SymexPage';
import type { TreeEntry, FlowStep, FindingView } from '../api/types';
type ExplorerMode = 'tree' | 'symbols' | 'hotspots';
type ExplorerView =
| 'code'
| 'cfg'
| 'ssa'
| 'taint'
| 'summaries'
| 'abstract-interp'
| 'symex';
const FLOW_KIND_COLORS: Record<string, string> = {
source: 'var(--success)',
assignment: 'var(--accent)',
call: 'var(--sev-medium)',
phi: 'var(--text-tertiary)',
sink: 'var(--sev-high)',
};
const FLOW_KIND_LABELS: Record<string, string> = {
source: 'Source',
assignment: 'Assign',
call: 'Call',
phi: 'Phi',
sink: 'Sink',
};
const VIEW_CONFIG: Array<{
id: ExplorerView;
label: string;
requiresFunction?: boolean;
supportsFunction?: boolean;
}> = [
{ id: 'code', label: 'Code' },
{ id: 'cfg', label: 'CFG', requiresFunction: true, supportsFunction: true },
{ id: 'ssa', label: 'SSA', requiresFunction: true, supportsFunction: true },
{
id: 'taint',
label: 'Taint',
requiresFunction: true,
supportsFunction: true,
},
{ id: 'summaries', label: 'Summaries', supportsFunction: true },
{
id: 'abstract-interp',
label: 'Abstract Interp',
requiresFunction: true,
supportsFunction: true,
},
{
id: 'symex',
label: 'Symex',
requiresFunction: true,
supportsFunction: true,
},
];
const VIEW_CONFIG_BY_ID = new Map(VIEW_CONFIG.map((view) => [view.id, view]));
export function ExplorerPage() {
const [params, setParams] = useSearchParams();
const [explorerMode, setExplorerMode] = useState<ExplorerMode>('tree');
const [highlightLine, setHighlightLine] = useState<number | undefined>();
const [selectedFindingIndex, setSelectedFindingIndex] = useState<
number | null
>(null);
const [invalidFunctionNotice, setInvalidFunctionNotice] = useState<
string | null
>(null);
const codeScrollPositionsRef = useRef<Record<string, number>>({});
const rawView = params.get('view');
const rawFile = params.get('file') || null;
const rawFunction = params.get('function') || null;
const currentView: ExplorerView = isExplorerView(rawView) ? rawView : 'code';
const currentViewConfig = VIEW_CONFIG_BY_ID.get(currentView)!;
const isCodeView = currentView === 'code';
const updateExplorerParams = useCallback(
(
updates: Partial<Record<'file' | 'view' | 'function', string | null>>,
replace = false,
) => {
setParams(
(prev) => {
const next = new URLSearchParams(prev);
for (const [key, value] of Object.entries(updates)) {
if (value) {
next.set(key, value);
} else {
next.delete(key);
}
}
return next;
},
{ replace },
);
},
[setParams],
);
useEffect(() => {
if (rawView !== currentView) {
updateExplorerParams({ view: currentView }, true);
}
}, [currentView, rawView, updateExplorerParams]);
const { data: symbolEntries, error: symbolsError } =
useExplorerSymbols(rawFile);
const hasInvalidFile = Boolean(
rawFile && isPathResolutionError(symbolsError),
);
const hasFileLookupError = Boolean(
rawFile && symbolsError && !hasInvalidFile,
);
const selectedFile = rawFile && !hasInvalidFile ? rawFile : null;
const handleFileSelect = useCallback(
(path: string) => {
setHighlightLine(undefined);
setSelectedFindingIndex(null);
setInvalidFunctionNotice(null);
updateExplorerParams({ file: path, function: null });
},
[updateExplorerParams],
);
const {
rootEntries,
isLoading: treeLoading,
expandedPaths,
loadedChildren,
selectedPath,
handleToggleExpand,
handleSelectFile,
} = useFileTree(selectedFile, handleFileSelect);
const { data: functions, isLoading: functionsLoading } =
useDebugFunctions(selectedFile);
const selectedFunction =
rawFunction && functions?.some((fn) => fn.name === rawFunction)
? rawFunction
: null;
const hasFunctionOptions = (functions?.length ?? 0) > 0;
useEffect(() => {
if (!rawFunction) {
return;
}
if (!selectedFile) {
setInvalidFunctionNotice(
`Function "${rawFunction}" was cleared because no valid file is selected.`,
);
updateExplorerParams({ function: null }, true);
return;
}
if (!functions) {
return;
}
if (!functions.some((fn) => fn.name === rawFunction)) {
setInvalidFunctionNotice(
`Function "${rawFunction}" was not found in ${selectedFile}.`,
);
updateExplorerParams({ function: null }, true);
}
}, [functions, rawFunction, selectedFile, updateExplorerParams]);
const { data: findings } = useExplorerFindings(selectedFile);
const { data: fullFinding } = useFinding(selectedFindingIndex ?? '');
const handleSelectFinding = useCallback((index: number, line: number) => {
setSelectedFindingIndex(index);
setHighlightLine(line);
}, []);
const handleViewSelect = useCallback(
(view: ExplorerView) => {
updateExplorerParams({ view });
},
[updateExplorerParams],
);
const handleFunctionChange = useCallback(
(fnName: string | null) => {
setInvalidFunctionNotice(null);
updateExplorerParams({ function: fnName });
},
[updateExplorerParams],
);
const selectedEntry = findEntry(rootEntries, loadedChildren, selectedFile);
const language = selectedEntry?.language || '';
const hotspotFiles = useMemo(
() => buildHotspotList(rootEntries, loadedChildren),
[loadedChildren, rootEntries],
);
const sevBreakdown = findings
? findings.reduce(
(acc, finding) => {
const key = finding.severity.toUpperCase();
acc[key] = (acc[key] || 0) + 1;
return acc;
},
{} as Record<string, number>,
)
: {};
const evidence = fullFinding?.evidence;
const flowSteps = evidence?.flow_steps;
const hasFlow = flowSteps && flowSteps.length > 0;
const hasStateEvidence =
fullFinding?.rule_id.startsWith('state-') && evidence?.state;
const codeHighlights =
selectedFindingIndex != null && evidence
? {
sourceLine: evidence.source?.line,
sinkLine: evidence.sink?.line,
findingLine: fullFinding?.line,
}
: undefined;
const flowLineSet = new Set<number>();
if (hasFlow) {
for (const step of flowSteps) {
if (step.line) {
flowLineSet.add(step.line);
}
}
}
const analysisContent = renderAnalysisContent({
currentView,
currentViewLabel: currentViewConfig.label,
selectedFile,
selectedFunction,
functions,
functionsLoading,
onBrowseFiles: () => handleViewSelect('code'),
});
return (
<div
className={`explorer-page ${isCodeView ? 'explorer-page-code' : 'explorer-page-analysis'}`}
>
<div className="explorer-left">
<div className="explorer-left-header">
<div className="explorer-mode-toggle">
{(['tree', 'symbols', 'hotspots'] as ExplorerMode[]).map((mode) => (
<button
key={mode}
className={`mode-btn${explorerMode === mode ? ' active' : ''}`}
onClick={() => setExplorerMode(mode)}
>
{mode === 'tree'
? 'Files'
: mode === 'symbols'
? 'Symbols'
: 'Hotspots'}
</button>
))}
</div>
</div>
<div className="explorer-left-body">
{explorerMode === 'tree' && (
<>
{treeLoading && <LoadingState message="Loading files..." />}
{rootEntries && (
<FileTree
entries={rootEntries}
expandedPaths={expandedPaths}
selectedPath={selectedPath}
onToggleExpand={handleToggleExpand}
onSelectFile={handleSelectFile}
loadedChildren={loadedChildren}
/>
)}
</>
)}
{explorerMode === 'symbols' && (
<div className="explorer-symbol-list">
{!selectedFile && (
<div className="explorer-hint">
Select a file to view symbols
</div>
)}
{selectedFile && symbolEntries && symbolEntries.length === 0 && (
<div className="explorer-hint">No symbols found</div>
)}
{selectedFile &&
symbolEntries?.map((sym, index) => (
<div
key={`${sym.name}-${index}`}
className="explorer-symbol-item"
>
<span className={`symbol-kind symbol-kind-${sym.kind}`}>
{sym.kind === 'function' ? 'ƒ' : 'm'}
</span>
<span className="symbol-name">{sym.name}</span>
{sym.arity !== undefined && sym.arity !== null && (
<span className="symbol-arity">({sym.arity})</span>
)}
{sym.finding_count > 0 && (
<span className="tree-node-badge">
{sym.finding_count}
</span>
)}
</div>
))}
</div>
)}
{explorerMode === 'hotspots' && (
<div className="explorer-hotspot-list">
{hotspotFiles.length === 0 && (
<div className="explorer-hint">
No findings in scanned files
</div>
)}
{hotspotFiles.map((entry) => (
<div
key={entry.path}
className={`hotspot-item${selectedFile === entry.path ? ' selected' : ''}`}
onClick={() => handleSelectFile(entry.path)}
>
<span className="hotspot-name" title={entry.path}>
{entry.name}
</span>
<span className="hotspot-count">
<span
className={`badge badge-sev badge-sev-${(entry.severity_max || 'low').toLowerCase()}`}
>
{entry.finding_count}
</span>
</span>
</div>
))}
</div>
)}
</div>
</div>
<div className="explorer-main-shell">
<div className="explorer-file-header">
<div className="explorer-file-header-top">
<div className="explorer-file-header-copy">
<span className="explorer-file-label">File</span>
<span className="explorer-file-path">
{selectedFile || 'Select a file in Explorer'}
</span>
</div>
{selectedFile && currentViewConfig.supportsFunction && (
<div className="explorer-function-picker">
<FunctionSelector
file={selectedFile}
selectedFunction={selectedFunction}
onFunctionChange={handleFunctionChange}
showFilePath={false}
/>
</div>
)}
</div>
<div
className="explorer-view-tabs"
role="tablist"
aria-label="File views"
>
{VIEW_CONFIG.map((view) => (
<button
key={view.id}
className={`explorer-view-tab${currentView === view.id ? ' active' : ''}`}
onClick={() => handleViewSelect(view.id)}
type="button"
>
{view.label}
</button>
))}
</div>
{hasInvalidFile && rawFile && (
<div className="explorer-inline-notice">
The requested file <code>{rawFile}</code> could not be found.
Choose another file in Explorer.
</div>
)}
{hasFileLookupError && (
<div className="explorer-inline-notice explorer-inline-notice-warning">
Explorer could not validate the selected file right now.
</div>
)}
{invalidFunctionNotice && (
<div className="explorer-inline-notice">
{invalidFunctionNotice}
</div>
)}
</div>
<div className="explorer-main-body">
{isCodeView ? (
<>
{!selectedFile && (
<EmptyState
icon={<ExplorerIcon size={48} />}
message={
hasInvalidFile
? 'Choose a file from the Explorer to continue.'
: 'Select a file from the tree to view its contents.'
}
/>
)}
{selectedFile && (
<CodeViewer
filePath={selectedFile}
findings={findings || undefined}
highlights={codeHighlights}
highlightLine={highlightLine}
flowLines={flowLineSet.size > 0 ? flowLineSet : undefined}
language={language}
initialScrollTop={
codeScrollPositionsRef.current[selectedFile]
}
onScrollPositionChange={(scrollTop) => {
codeScrollPositionsRef.current[selectedFile] = scrollTop;
}}
/>
)}
</>
) : (
analysisContent
)}
</div>
</div>
{isCodeView && (
<div className="explorer-right">
{!selectedFile && (
<div className="explorer-right-section">
<div className="explorer-hint">
Select a file to view analysis details
</div>
</div>
)}
{selectedFile && (
<>
<div className="explorer-right-section">
<h3>File Summary</h3>
<div className="explorer-file-meta">
{language && <span className="badge">{language}</span>}
<span className="meta-text">
{findings ? findings.length : 0} finding
{findings?.length !== 1 ? 's' : ''}
</span>
</div>
{findings && findings.length > 0 && (
<div className="explorer-sev-breakdown">
{Object.entries(sevBreakdown)
.sort(([a], [b]) => sevOrder(a) - sevOrder(b))
.map(([sev, count]) => (
<span
key={sev}
className={`badge badge-sev badge-sev-${sev.toLowerCase()}`}
>
{sev}: {count}
</span>
))}
</div>
)}
</div>
<div className="explorer-right-section">
<h3>Symbols</h3>
{symbolEntries && symbolEntries.length === 0 && (
<div className="explorer-hint">No symbols found</div>
)}
{symbolEntries?.map((sym, index) => (
<div
key={`${sym.name}-${index}`}
className="explorer-symbol-item compact"
>
<span className={`symbol-kind symbol-kind-${sym.kind}`}>
{sym.kind === 'function' ? 'ƒ' : 'm'}
</span>
<span className="symbol-name">{sym.name}</span>
</div>
))}
</div>
<div className="explorer-right-section">
<h3>Findings</h3>
{findings && findings.length === 0 && (
<div className="explorer-hint">No findings in this file</div>
)}
<div className="explorer-findings-list">
{findings?.map((finding) => (
<div
key={`${finding.line}-${finding.rule_id}`}
className={`explorer-finding-item${selectedFindingIndex === finding.index ? ' active' : ''}`}
onClick={() =>
handleSelectFinding(finding.index, finding.line)
}
>
<span
className={`finding-sev-dot sev-${finding.severity.toLowerCase()}`}
/>
<span className="finding-line">L{finding.line}</span>
<span className="finding-rule">{finding.rule_id}</span>
{finding.message && (
<span className="finding-msg" title={finding.message}>
{finding.message}
</span>
)}
</div>
))}
</div>
</div>
{hasFlow && (
<div className="explorer-right-section">
<h3>Taint Flow</h3>
<ExplorerFlowTimeline
steps={flowSteps}
onStepClick={(line) => setHighlightLine(line)}
/>
</div>
)}
{hasStateEvidence && fullFinding && (
<ExplorerStateDetail finding={fullFinding} />
)}
</>
)}
</div>
)}
</div>
);
}
function renderAnalysisContent({
currentView,
currentViewLabel,
selectedFile,
selectedFunction,
functions,
functionsLoading,
onBrowseFiles,
}: {
currentView: ExplorerView;
currentViewLabel: string;
selectedFile: string | null;
selectedFunction: string | null;
functions: Array<{ name: string }> | undefined;
functionsLoading: boolean;
onBrowseFiles: () => void;
}) {
if (!selectedFile) {
return (
<EmptyState
icon={<ExplorerIcon size={48} />}
message="Select a file from the tree to view its contents."
/>
);
}
if (currentView === 'summaries') {
return (
<div className="explorer-analysis-content">
<SummaryAnalysisPanel
file={selectedFile}
functionName={selectedFunction}
scope="file"
/>
</div>
);
}
if (functionsLoading) {
return <LoadingState message="Loading functions..." />;
}
if ((functions?.length ?? 0) === 0) {
return (
<AnalysisEmptyState
title="No functions found"
message="This file does not expose any functions for function-scoped analysis."
/>
);
}
if (!selectedFunction) {
return (
<AnalysisEmptyState
title={`Select a function to inspect ${currentViewLabel}`}
message={`Choose a function in the header to view ${currentViewLabel.toLowerCase()} for this file.`}
/>
);
}
switch (currentView) {
case 'cfg':
return (
<CfgAnalysisPanel file={selectedFile} functionName={selectedFunction} />
);
case 'ssa':
return (
<div className="explorer-analysis-content">
<SsaAnalysisPanel
file={selectedFile}
functionName={selectedFunction}
/>
</div>
);
case 'taint':
return (
<div className="explorer-analysis-content">
<TaintAnalysisPanel
file={selectedFile}
functionName={selectedFunction}
/>
</div>
);
case 'abstract-interp':
return (
<div className="explorer-analysis-content">
<AbstractInterpAnalysisPanel
file={selectedFile}
functionName={selectedFunction}
/>
</div>
);
case 'symex':
return (
<div className="explorer-analysis-content">
<SymexAnalysisPanel
file={selectedFile}
functionName={selectedFunction}
/>
</div>
);
case 'code':
return null;
}
}
function AnalysisEmptyState({
title,
message,
onBrowseFiles,
}: {
title: string;
message: string;
onBrowseFiles?: () => void;
}) {
return (
<EmptyState>
<h3>{title}</h3>
<p>{message}</p>
{onBrowseFiles && (
<button className="btn btn-primary btn-sm" onClick={onBrowseFiles}>
Browse Files
</button>
)}
</EmptyState>
);
}
function ExplorerFlowTimeline({
steps,
onStepClick,
}: {
steps: FlowStep[];
onStepClick: (line: number) => void;
}) {
return (
<div className="flow-timeline explorer-flow">
{steps.map((step, index) => {
const color = FLOW_KIND_COLORS[step.kind] || 'var(--text-secondary)';
const label = FLOW_KIND_LABELS[step.kind] || step.kind;
const isLast = index === steps.length - 1;
return (
<div
key={index}
className={`flow-step${step.is_cross_file ? ' flow-step-cross-file' : ''}`}
onClick={() => step.line && onStepClick(step.line)}
>
<div className="flow-step-connector">
<div className="flow-step-dot" style={{ background: color }} />
{!isLast && <div className="flow-step-line" />}
</div>
<div className="flow-step-card">
<div className="flow-step-header">
<span className="flow-step-badge" style={{ color }}>
{label}
</span>
{step.variable && (
<span className="flow-step-var">{step.variable}</span>
)}
{step.callee && (
<span className="flow-step-callee">{step.callee}</span>
)}
</div>
<div className="flow-step-loc">
L{step.line}:{step.col}
{step.function ? ` in ${step.function}` : ''}
</div>
{step.snippet && (
<div className="flow-step-snippet">{step.snippet}</div>
)}
</div>
</div>
);
})}
</div>
);
}
const STATE_REMEDIATION_HINTS: Record<string, string> = {
'state-use-after-close':
'Ensure the resource is not accessed after calling close/free.',
'state-double-close':
'Remove the duplicate close call, or guard with a null/closed check.',
'state-resource-leak':
'Add a close/free call before the function exits, or use defer/with/try-with-resources/RAII.',
'state-resource-leak-possible':
'Ensure the resource is closed on all code paths, including error/early-return paths.',
'state-unauthed-access':
'Add an authentication check before this operation, or move it behind auth middleware.',
};
function ExplorerStateDetail({ finding }: { finding: FindingView }) {
const state = finding.evidence?.state;
if (!state) {
return null;
}
const isAuth = state.machine === 'auth';
const machineLabel = isAuth ? 'Authentication State' : 'Resource Lifecycle';
const hint = STATE_REMEDIATION_HINTS[finding.rule_id];
const acquireLocation =
finding.rule_id.includes('leak') && finding.evidence?.sink
? `L${finding.evidence.sink.line}:${finding.evidence.sink.col}`
: null;
return (
<div className="explorer-right-section">
<h3>State Analysis</h3>
<div className="state-transition-card">
<div className="state-machine-label">{machineLabel}</div>
{state.subject && (
<div className="state-subject">
<span className="state-subject-label">Variable:</span>
<code className="state-subject-name">{state.subject}</code>
</div>
)}
<div className="state-transition-visual">
<span className="state-from">{state.from_state}</span>
<span className="state-arrow">&rarr;</span>
<span className="state-to">{state.to_state}</span>
</div>
{acquireLocation && (
<div className="state-acquire-location">
Acquired at: {acquireLocation}
</div>
)}
</div>
{hint && (
<div className="state-remediation">
<div className="state-remediation-label">Remediation</div>
{hint}
</div>
)}
</div>
);
}
function findEntry(
rootEntries: TreeEntry[] | undefined,
loadedChildren: Map<string, TreeEntry[]>,
path: string | null,
): TreeEntry | undefined {
if (!path) {
return undefined;
}
if (rootEntries) {
const found = rootEntries.find((entry) => entry.path === path);
if (found) {
return found;
}
}
for (const children of loadedChildren.values()) {
const found = children.find((entry) => entry.path === path);
if (found) {
return found;
}
}
return undefined;
}
function buildHotspotList(
rootEntries: TreeEntry[] | undefined,
loadedChildren: Map<string, TreeEntry[]>,
): TreeEntry[] {
const files: TreeEntry[] = [];
function collect(entries: TreeEntry[]) {
for (const entry of entries) {
if (entry.entry_type === 'file' && entry.finding_count > 0) {
files.push(entry);
}
if (entry.entry_type === 'dir') {
const children = loadedChildren.get(entry.path);
if (children) {
collect(children);
}
}
}
}
if (rootEntries) {
collect(rootEntries);
}
files.sort((a, b) => b.finding_count - a.finding_count);
return files;
}
function sevOrder(sev: string): number {
switch (sev) {
case 'HIGH':
return 0;
case 'MEDIUM':
return 1;
case 'LOW':
return 2;
default:
return 3;
}
}
function isExplorerView(value: string | null): value is ExplorerView {
return VIEW_CONFIG_BY_ID.has(value as ExplorerView);
}
function isPathResolutionError(error: unknown): boolean {
return (
error instanceof ApiError && (error.status === 403 || error.status === 404)
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,729 @@
import { useState, useCallback, useMemo, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { useFindingsURLState } from '../hooks/useFindingsURLState';
import { useDebounce } from '../hooks/useDebounce';
import {
useFindings,
useFindingFilters,
fetchFindingDetail,
} from '../api/queries/findings';
import { useBulkTriage, useAddSuppression } from '../api/mutations/triage';
import { Pagination } from '../components/ui/Pagination';
import { Dropdown, DropdownItem } from '../components/ui/Dropdown';
import { CopyMarkdownButton } from '../components/CopyMarkdownButton';
import { truncPath } from '../utils/truncPath';
import { findingsToMarkdown } from '../utils/findingMarkdown';
import type { FindingView, FilterValues } from '../api/types';
// ── Helpers ─────────────────────────────────────────────────────────────────
function formatTriageState(state: string): string {
return (state || 'open').replace(/_/g, ' ');
}
// ── Filter Bar ──────────────────────────────────────────────────────────────
interface FilterSelectProps {
id: string;
label: string;
values: string[] | undefined;
current: string;
onChange: (value: string) => void;
}
function FilterSelect({
id,
label,
values,
current,
onChange,
}: FilterSelectProps) {
if (!values || values.length === 0) return null;
return (
<select id={id} value={current} onChange={(e) => onChange(e.target.value)}>
<option value="">All {label}</option>
{values.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
);
}
// ── Bulk Action Bar ─────────────────────────────────────────────────────────
interface BulkBarProps {
selectedCount: number;
sharedStatus: string | null;
onBulkTriage: (state: string) => void;
onSuppressByPattern: () => void;
onBulkCopy: () => Promise<string>;
}
const STATUS_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [
{ value: 'investigating', label: 'Investigating' },
{ value: 'false_positive', label: 'Mark as False Positive' },
{ value: 'accepted_risk', label: 'Accept Risk' },
];
function BulkActionBar({
selectedCount,
sharedStatus,
onBulkTriage,
onSuppressByPattern,
onBulkCopy,
}: BulkBarProps) {
const disabled = selectedCount === 0;
return (
<div
className={`bulk-action-bar${selectedCount > 0 ? ' visible' : ''}`}
aria-hidden={disabled}
>
<span className="bulk-count">{selectedCount} selected</span>
<div className="bulk-actions">
<Dropdown
align="right"
trigger={({ open }) => (
<button
type="button"
className="btn btn-sm bulk-menu-btn"
disabled={disabled}
>
Status
<span className={`bulk-caret${open ? ' bulk-caret--open' : ''}`}>
</span>
</button>
)}
>
{({ close }) =>
STATUS_OPTIONS.map((opt) => (
<DropdownItem
key={opt.value}
checked={sharedStatus === opt.value}
onClick={() => {
onBulkTriage(opt.value);
close();
}}
>
{opt.label}
</DropdownItem>
))
}
</Dropdown>
<Dropdown
align="right"
trigger={({ open }) => (
<button
type="button"
className="btn btn-sm bulk-menu-btn bulk-menu-btn--warning"
disabled={disabled}
>
Suppress
<span className={`bulk-caret${open ? ' bulk-caret--open' : ''}`}>
</span>
</button>
)}
>
{({ close }) => (
<>
<DropdownItem
tone="warning"
onClick={() => {
onBulkTriage('suppressed');
close();
}}
>
Suppress this finding
</DropdownItem>
<DropdownItem
tone="warning"
hint="advanced"
onClick={() => {
onSuppressByPattern();
close();
}}
>
Suppress by pattern
</DropdownItem>
</>
)}
</Dropdown>
<div className="bulk-divider" aria-hidden />
<CopyMarkdownButton
className="bulk-copy-btn"
iconOnly
label="Copy selected as markdown"
title="Copy selected as markdown"
getMarkdown={onBulkCopy}
/>
</div>
</div>
);
}
// ── Suppress Modal ──────────────────────────────────────────────────────────
interface SuppressModalProps {
rules: string[];
files: string[];
onSuppress: (by: string, value: string, note: string) => void;
onClose: () => void;
}
function SuppressModal({
rules,
files,
onSuppress,
onClose,
}: SuppressModalProps) {
const [note, setNote] = useState('');
return (
<div
className="suppress-modal-overlay"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="suppress-modal">
<h3>Suppress by Pattern</h3>
<div className="suppress-options">
{rules.map((r) => (
<button
key={`rule-${r}`}
className="btn btn-sm suppress-opt"
onClick={() => onSuppress('rule', r, note)}
>
By rule: {r}
</button>
))}
{files.map((f) => (
<button
key={`file-${f}`}
className="btn btn-sm suppress-opt"
onClick={() => onSuppress('file', f, note)}
>
By file: {truncPath(f, 40)}
</button>
))}
</div>
<textarea
placeholder="Note (optional)..."
rows={2}
style={{ width: '100%', marginTop: 'var(--space-3)' }}
value={note}
onChange={(e) => setNote(e.target.value)}
/>
<div
style={{
display: 'flex',
gap: 'var(--space-2)',
marginTop: 'var(--space-3)',
}}
>
<button className="btn btn-sm" onClick={onClose}>
Cancel
</button>
</div>
</div>
</div>
);
}
// ── Sortable Header ─────────────────────────────────────────────────────────
interface SortableThProps {
column: string;
label: string;
currentSort: string;
currentDir: string;
onSort: (col: string, dir: string) => void;
}
function SortableTh({
column,
label,
currentSort,
currentDir,
onSort,
}: SortableThProps) {
const isActive = currentSort === column;
const arrow = isActive ? (currentDir === 'desc' ? '\u2193' : '\u2191') : '';
const handleClick = () => {
const newDir =
currentSort === column && currentDir === 'asc' ? 'desc' : 'asc';
onSort(column, newDir);
};
return (
<th
className={`sortable${isActive ? ' active' : ''}`}
onClick={handleClick}
>
{label}
{arrow && <span className="sort-arrow">{arrow}</span>}
</th>
);
}
// ── Main Component ──────────────────────────────────────────────────────────
export function FindingsPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { state, updateState, resetFilters, hasActiveFilters } =
useFindingsURLState();
// Local search input state (debounced before pushing to URL)
const [searchInput, setSearchInput] = useState(state.search);
const debouncedSearch = useDebounce(searchInput, 300);
// Sync debounced search to URL state
useEffect(() => {
if (debouncedSearch !== state.search) {
updateState({ search: debouncedSearch });
}
}, [debouncedSearch]); // eslint-disable-line react-hooks/exhaustive-deps
// Sync URL search back to local input when navigating
useEffect(() => {
setSearchInput(state.search);
}, [state.search]);
// Build query params for the API
const queryParams = useMemo(
() => ({
page: Number(state.page) || 1,
per_page: Number(state.per_page) || 50,
sort_by: state.sort_by || undefined,
sort_dir: state.sort_dir !== 'asc' ? state.sort_dir : undefined,
severity: state.severity || undefined,
category: state.category || undefined,
confidence: state.confidence || undefined,
language: state.language || undefined,
rule_id: state.rule_id || undefined,
status: state.status || undefined,
search: state.search || undefined,
}),
[state],
);
const { data, isLoading, isError, error } = useFindings(queryParams);
const { data: filters } = useFindingFilters();
// Selection state
const [selected, setSelected] = useState<Set<number>>(new Set());
// Clear selection when data changes
useEffect(() => {
setSelected(new Set());
}, [data]);
const bulkTriage = useBulkTriage();
const addSuppression = useAddSuppression();
// Suppress modal
const [suppressModalOpen, setSuppressModalOpen] = useState(false);
// ── Selection handlers ──
const toggleRow = useCallback((index: number) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(index)) next.delete(index);
else next.add(index);
return next;
});
}, []);
const toggleSelectAll = useCallback(
(checked: boolean) => {
if (!data) return;
if (checked) {
setSelected(new Set(data.findings.map((f) => f.index)));
} else {
setSelected(new Set());
}
},
[data],
);
const allSelected =
data != null &&
data.findings.length > 0 &&
data.findings.every((f) => selected.has(f.index));
const sharedStatus = useMemo<string | null>(() => {
if (!data || selected.size === 0) return null;
const states = new Set(
data.findings
.filter((f) => selected.has(f.index))
.map((f) => f.triage_state || f.status),
);
return states.size === 1 ? [...states][0] : null;
}, [data, selected]);
// ── Bulk action handlers ──
const getSelectedFingerprints = useCallback((): string[] => {
if (!data) return [];
return data.findings
.filter((f) => selected.has(f.index))
.map((f) => f.fingerprint);
}, [data, selected]);
const handleBulkTriage = useCallback(
(triageState: string) => {
const fingerprints = getSelectedFingerprints();
if (fingerprints.length === 0) return;
bulkTriage.mutate(
{ fingerprints, state: triageState, note: '' },
{ onSuccess: () => setSelected(new Set()) },
);
},
[getSelectedFingerprints, bulkTriage],
);
const handleSuppressByPattern = useCallback(() => {
if (selected.size === 0 || !data) return;
setSuppressModalOpen(true);
}, [selected.size, data]);
const handleBulkCopy = useCallback(async (): Promise<string> => {
const indices =
data?.findings.filter((f) => selected.has(f.index)).map((f) => f.index) ??
[];
const results = await Promise.allSettled(
indices.map((i) => fetchFindingDetail(queryClient, i)),
);
const views = results
.filter(
(r): r is PromiseFulfilledResult<FindingView> =>
r.status === 'fulfilled',
)
.map((r) => r.value);
return findingsToMarkdown(views);
}, [data, selected, queryClient]);
const suppressPatternRules = useMemo(() => {
if (!data) return [];
const selectedFindings = data.findings.filter((f) => selected.has(f.index));
return [...new Set(selectedFindings.map((f) => f.rule_id))];
}, [data, selected]);
const suppressPatternFiles = useMemo(() => {
if (!data) return [];
const selectedFindings = data.findings.filter((f) => selected.has(f.index));
return [...new Set(selectedFindings.map((f) => f.path))];
}, [data, selected]);
const handleSuppress = useCallback(
(by: string, value: string, note: string) => {
addSuppression.mutate(
{ by, value, note },
{
onSuccess: () => {
setSuppressModalOpen(false);
setSelected(new Set());
},
},
);
},
[addSuppression],
);
// ── Sort handler ──
const handleSort = useCallback(
(col: string, dir: string) => {
updateState({ sort_by: col, sort_dir: dir });
},
[updateState],
);
// ── Filter handler ──
const handleFilterChange = useCallback(
(key: string, value: string) => {
updateState({ [key]: value });
},
[updateState],
);
// ── Row click ──
const handleRowClick = useCallback(
(e: React.MouseEvent, finding: FindingView) => {
if ((e.target as HTMLElement).tagName === 'INPUT') return;
navigate(`/findings/${finding.index}`);
},
[navigate],
);
// ── Render ──
if (isLoading) {
return <div className="loading">Loading findings...</div>;
}
if (isError) {
const msg = error instanceof Error ? error.message : 'Unknown error';
if (msg.includes('404')) {
return (
<div className="empty-state">
<h3>No scan results yet</h3>
<p>Run a scan first to see findings.</p>
</div>
);
}
return (
<div className="error-state">
<h3>Error</h3>
<p>{msg}</p>
</div>
);
}
if (!data) return null;
const page = data.page;
const totalPages = Math.ceil(data.total / data.per_page) || 1;
return (
<>
<div className="page-header">
<h2>Findings</h2>
<span className="filter-count">
{data.total} finding{data.total !== 1 ? 's' : ''}
{hasActiveFilters ? ' (filtered)' : ''}
</span>
</div>
{/* Filter bar */}
<div className="filter-bar">
<input
type="text"
placeholder="Search findings... (/)"
className="search-input"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
/>
<FilterSelect
id="filter-severity"
label="Severities"
values={filters?.severities}
current={state.severity}
onChange={(v) => handleFilterChange('severity', v)}
/>
<FilterSelect
id="filter-confidence"
label="Confidences"
values={filters?.confidences}
current={state.confidence}
onChange={(v) => handleFilterChange('confidence', v)}
/>
<FilterSelect
id="filter-category"
label="Categories"
values={filters?.categories}
current={state.category}
onChange={(v) => handleFilterChange('category', v)}
/>
<FilterSelect
id="filter-language"
label="Languages"
values={filters?.languages}
current={state.language}
onChange={(v) => handleFilterChange('language', v)}
/>
<FilterSelect
id="filter-rule"
label="Rules"
values={filters?.rules}
current={state.rule_id}
onChange={(v) => handleFilterChange('rule_id', v)}
/>
<FilterSelect
id="filter-status"
label="Statuses"
values={filters?.statuses}
current={state.status}
onChange={(v) => handleFilterChange('status', v)}
/>
{hasActiveFilters && (
<button className="btn btn-sm btn-clear" onClick={resetFilters}>
Clear All
</button>
)}
</div>
{/* Bulk action bar */}
<BulkActionBar
selectedCount={selected.size}
sharedStatus={sharedStatus}
onBulkTriage={handleBulkTriage}
onSuppressByPattern={handleSuppressByPattern}
onBulkCopy={handleBulkCopy}
/>
{/* Findings table */}
{data.findings.length === 0 ? (
<div className="empty-state">
<h3>No findings</h3>
<p>Run a scan to see results, or adjust your filters.</p>
</div>
) : (
<>
<div className="table-wrap">
<table>
<thead>
<tr>
<th className="col-checkbox">
<input
type="checkbox"
checked={allSelected}
onChange={(e) => toggleSelectAll(e.target.checked)}
/>
</th>
<SortableTh
column="severity"
label="Severity"
currentSort={state.sort_by}
currentDir={state.sort_dir}
onSort={handleSort}
/>
<SortableTh
column="confidence"
label="Confidence"
currentSort={state.sort_by}
currentDir={state.sort_dir}
onSort={handleSort}
/>
<SortableTh
column="rule_id"
label="Rule"
currentSort={state.sort_by}
currentDir={state.sort_dir}
onSort={handleSort}
/>
<SortableTh
column="category"
label="Category"
currentSort={state.sort_by}
currentDir={state.sort_dir}
onSort={handleSort}
/>
<SortableTh
column="file"
label="File"
currentSort={state.sort_by}
currentDir={state.sort_dir}
onSort={handleSort}
/>
<SortableTh
column="line"
label="Line"
currentSort={state.sort_by}
currentDir={state.sort_dir}
onSort={handleSort}
/>
<SortableTh
column="language"
label="Language"
currentSort={state.sort_by}
currentDir={state.sort_dir}
onSort={handleSort}
/>
<SortableTh
column="status"
label="Status"
currentSort={state.sort_by}
currentDir={state.sort_dir}
onSort={handleSort}
/>
</tr>
</thead>
<tbody>
{data.findings.map((f) => (
<tr
key={f.index}
className={`clickable${selected.has(f.index) ? ' selected' : ''}`}
onClick={(e) => handleRowClick(e, f)}
>
<td className="col-checkbox">
<input
type="checkbox"
checked={selected.has(f.index)}
onChange={() => toggleRow(f.index)}
/>
</td>
<td>
<span
className={`badge badge-${f.severity.toLowerCase()}`}
>
{f.severity}
</span>
</td>
<td>
{f.confidence ? (
<span
className={`badge badge-conf-${f.confidence.toLowerCase()}`}
>
{f.confidence}
</span>
) : (
'-'
)}
</td>
<td title={f.message || ''}>{f.rule_id}</td>
<td>{f.category}</td>
<td className="cell-path" title={f.path}>
{truncPath(f.path)}
</td>
<td>{f.line}</td>
<td>{f.language || '-'}</td>
<td>
<span
className={`badge badge-triage-${f.triage_state || f.status}`}
>
{formatTriageState(f.triage_state || f.status)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination
page={page}
perPage={data.per_page}
total={data.total}
onPageChange={(p) => updateState({ page: String(p) })}
onPerPageChange={(pp) => updateState({ per_page: String(pp) })}
/>
</>
)}
{/* Suppress by pattern modal */}
{suppressModalOpen && (
<SuppressModal
rules={suppressPatternRules}
files={suppressPatternFiles}
onSuppress={handleSuppress}
onClose={() => setSuppressModalOpen(false)}
/>
)}
</>
);
}

View file

@ -0,0 +1,341 @@
import { useNavigate } from 'react-router-dom';
import { useOverview, useOverviewTrends } from '../api/queries/overview';
import { StatCard } from '../components/ui/StatCard';
import { LoadingState } from '../components/ui/LoadingState';
import { ErrorState } from '../components/ui/ErrorState';
import { HorizontalBarChart } from '../components/charts/HorizontalBarChart';
import { LineChart } from '../components/charts/LineChart';
import { OverviewIcon } from '../components/icons/Icons';
import { truncPath } from '../utils/truncPath';
import type { OverviewCount, ScanSummary, Insight } from '../api/types';
export function OverviewPage() {
const navigate = useNavigate();
const { data: overview, isLoading, error } = useOverview();
const { data: trends } = useOverviewTrends();
if (isLoading) {
return <LoadingState message="Loading overview..." />;
}
if (error) {
return (
<ErrorState
title="Error loading overview"
message={(error as Error).message}
/>
);
}
if (!overview) {
return <LoadingState message="Loading overview..." />;
}
// Empty state
if (overview.state === 'empty') {
return (
<div className="overview-empty">
<OverviewIcon size={48} />
<h2>Welcome to Nyx</h2>
<p>Start your first scan to see security findings and analytics.</p>
</div>
);
}
// Data preparation
const netDelta = overview.new_since_last - overview.fixed_since_last;
const sevItems = (['HIGH', 'MEDIUM', 'LOW'] as const).map((s) => ({
label: s.charAt(0) + s.slice(1).toLowerCase(),
value: overview.by_severity[s] || 0,
color: s === 'HIGH' ? '#e74c3c' : s === 'MEDIUM' ? '#e67e22' : '#3498db',
}));
const catItems = Object.entries(overview.by_category || {})
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
.map(([k, v]) => ({ label: k, value: v, color: '#5856d6' }));
const langItems = Object.entries(overview.by_language || {})
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
.map(([k, v]) => ({ label: k, value: v, color: '#5856d6' }));
const trendData = (trends || []).map((t) => ({
label: t.timestamp,
value: t.total,
}));
return (
<>
<div className="page-header">
<h2>Overview</h2>
</div>
{/* Fresh banner */}
{overview.state === 'fresh' && (
<div className="overview-fresh-banner">
<strong>Scan completed</strong>
<span>
{overview.total_findings} finding
{overview.total_findings === 1 ? '' : 's'} detected
{overview.latest_scan_duration_secs != null
? ` in ${overview.latest_scan_duration_secs.toFixed(1)}s`
: ''}
.
</span>
<a
href="/findings"
className="nav-link-internal"
onClick={(e) => {
e.preventDefault();
navigate('/findings');
}}
>
View all findings &rarr;
</a>
</div>
)}
{/* Stat cards */}
<div className="overview-stat-grid">
<StatCard
label="Total Findings"
value={overview.total_findings}
delta={netDelta || null}
/>
<StatCard
label="New"
value={overview.new_since_last}
color={overview.new_since_last > 0 ? 'var(--sev-high)' : undefined}
/>
<StatCard
label="Fixed"
value={overview.fixed_since_last}
color={overview.fixed_since_last > 0 ? 'var(--success)' : undefined}
/>
<StatCard
label="High Confidence"
value={`${(overview.high_confidence_rate * 100).toFixed(0)}%`}
/>
<StatCard
label="Triage Coverage"
value={`${(overview.triage_coverage * 100).toFixed(0)}%`}
/>
<StatCard
label="Scan Duration"
value={
overview.latest_scan_duration_secs != null
? `${overview.latest_scan_duration_secs.toFixed(1)}s`
: '-'
}
/>
</div>
{/* Charts */}
<div className="overview-chart-grid">
<div className="card">
<div className="card-header">Findings Over Time</div>
<LineChart points={trendData} />
</div>
<div className="card">
<div className="card-header">By Severity</div>
<HorizontalBarChart items={sevItems} />
</div>
<div className="card">
<div className="card-header">By Category</div>
<HorizontalBarChart items={catItems} />
</div>
<div className="card">
<div className="card-header">By Language</div>
<HorizontalBarChart items={langItems} />
</div>
</div>
{/* Tables */}
<div className="overview-table-grid">
<div className="card">
<div className="card-header">Top Affected Files</div>
<CompactTable
items={overview.top_files}
nameLabel="File"
countLabel="Findings"
truncate
onRowClick={(item) =>
navigate(`/findings?search=${encodeURIComponent(item.name)}`)
}
/>
</div>
<div className="card">
<div className="card-header">Top Directories</div>
<CompactTable
items={overview.top_directories}
nameLabel="Directory"
countLabel="Findings"
truncate
/>
</div>
<div className="card">
<div className="card-header">Top Rules Triggered</div>
<CompactTable
items={overview.top_rules}
nameLabel="Rule"
countLabel="Findings"
/>
</div>
<div className="card">
<div className="card-header">Recent Scans</div>
<RecentScansTable
scans={overview.recent_scans}
onRowClick={(scan) => navigate(`/scans/${scan.id}`)}
/>
</div>
</div>
{/* Insights */}
{overview.insights.length > 0 && (
<div className="overview-insights">
<div className="card">
<div className="card-header">Insights</div>
<div className="insight-list">
{overview.insights.map((insight, i) => (
<InsightCard key={i} insight={insight} />
))}
</div>
</div>
</div>
)}
</>
);
}
// ── Sub-components ──────────────────────────────────────────────────────────
interface CompactTableProps {
items: OverviewCount[];
nameLabel: string;
countLabel: string;
truncate?: boolean;
onRowClick?: (item: OverviewCount) => void;
}
function CompactTable({
items,
nameLabel,
countLabel,
truncate,
onRowClick,
}: CompactTableProps) {
if (!items || items.length === 0) {
return (
<div className="empty-state" style={{ padding: 16 }}>
<p>No data</p>
</div>
);
}
return (
<table>
<thead>
<tr>
<th>{nameLabel}</th>
<th>{countLabel}</th>
</tr>
</thead>
<tbody>
{items.map((item) => {
const displayName = truncate ? truncPath(item.name, 45) : item.name;
return (
<tr
key={item.name}
className={onRowClick ? 'clickable' : undefined}
onClick={onRowClick ? () => onRowClick(item) : undefined}
title={item.name}
>
<td>{displayName}</td>
<td>{item.count}</td>
</tr>
);
})}
</tbody>
</table>
);
}
interface RecentScansTableProps {
scans: ScanSummary[];
onRowClick: (scan: ScanSummary) => void;
}
function RecentScansTable({ scans, onRowClick }: RecentScansTableProps) {
if (!scans || scans.length === 0) {
return (
<div className="empty-state" style={{ padding: 16 }}>
<p>No scans yet</p>
</div>
);
}
return (
<table>
<thead>
<tr>
<th>Status</th>
<th>Duration</th>
<th>Findings</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{scans.slice(0, 5).map((scan) => (
<tr
key={scan.id}
className="clickable"
onClick={() => onRowClick(scan)}
>
<td>
<span className={`status-dot ${scan.status}`} /> {scan.status}
</td>
<td>
{scan.duration_secs != null
? `${scan.duration_secs.toFixed(1)}s`
: '-'}
</td>
<td>{scan.finding_count ?? '-'}</td>
<td>
{scan.started_at
? new Date(scan.started_at).toLocaleString()
: '-'}
</td>
</tr>
))}
</tbody>
</table>
);
}
interface InsightCardProps {
insight: Insight;
}
function InsightCard({ insight }: InsightCardProps) {
const navigate = useNavigate();
return (
<div className={`insight-card insight-${insight.severity}`}>
<span>{insight.message}</span>
{insight.action_url && (
<a
href={insight.action_url}
className="nav-link-internal"
onClick={(e) => {
e.preventDefault();
navigate(insight.action_url!);
}}
>
View &rarr;
</a>
)}
</div>
);
}

View file

@ -0,0 +1,360 @@
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { useRules } from '../api/queries/rules';
import { useToggleRule, useCloneRule } from '../api/mutations/rules';
import { LoadingState } from '../components/ui/LoadingState';
import { ErrorState } from '../components/ui/ErrorState';
import type { RuleListItem } from '../api/types';
function useDebounce(value: string, delay: number): string {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
// ── Rule Detail Panel ────────────────────────────────────────────────────────
function RuleDetail({
rule,
onToggle,
onClone,
}: {
rule: RuleListItem;
onToggle: () => void;
onClone: () => void;
}) {
return (
<div className="rule-detail-card">
<h3>{rule.title}</h3>
<div className="rule-detail-grid">
<div className="rule-detail-label">ID</div>
<div>
<code style={{ fontSize: 'var(--text-xs)', wordBreak: 'break-all' }}>
{rule.id}
</code>
</div>
<div className="rule-detail-label">Language</div>
<div>{rule.language}</div>
<div className="rule-detail-label">Kind</div>
<div>
<span className={`badge badge-${rule.kind}`}>{rule.kind}</span>
</div>
<div className="rule-detail-label">Capability</div>
<div>{rule.cap}</div>
<div className="rule-detail-label">Case Sensitive</div>
<div>{rule.case_sensitive ? 'Yes' : 'No'}</div>
<div className="rule-detail-label">Status</div>
<div>
{rule.enabled ? (
<span style={{ color: 'var(--success)' }}>Enabled</span>
) : (
<span style={{ color: 'var(--text-tertiary)' }}>Disabled</span>
)}
</div>
<div className="rule-detail-label">Findings</div>
<div>
{rule.finding_count}
{rule.suppression_rate > 0
? ` (${(rule.suppression_rate * 100).toFixed(0)}% suppressed)`
: ''}
</div>
</div>
{rule.is_custom && (
<div style={{ marginTop: 'var(--space-3)' }}>
<span className="badge-custom">Custom Rule</span>
</div>
)}
{rule.is_gated && (
<div style={{ marginTop: 'var(--space-3)' }}>
<span className="badge-builtin">Gated Sink</span>
</div>
)}
<div style={{ marginTop: 'var(--space-4)' }}>
<div
className="rule-detail-label"
style={{ marginBottom: 'var(--space-2)' }}
>
Matchers
</div>
<div>
{rule.matchers.map((m) => (
<code key={m} className="matcher-tag">
{m}
</code>
))}
</div>
</div>
<div
style={{
marginTop: 'var(--space-5)',
display: 'flex',
gap: 'var(--space-2)',
}}
>
<button className="btn btn-sm" onClick={onToggle}>
{rule.enabled ? 'Disable' : 'Enable'}
</button>
{!rule.is_custom && (
<button className="btn btn-primary btn-sm" onClick={onClone}>
Clone to Custom
</button>
)}
</div>
</div>
);
}
// ── Rules Table ──────────────────────────────────────────────────────────────
function RulesTable({
rules,
selectedId,
onSelect,
onToggle,
}: {
rules: RuleListItem[];
selectedId: string | null;
onSelect: (id: string) => void;
onToggle: (id: string) => void;
}) {
if (rules.length === 0) {
return (
<div className="empty-state" style={{ padding: 20 }}>
<p>No rules match filters</p>
</div>
);
}
return (
<table className="rules-table">
<colgroup>
<col className="col-toggle" />
<col />
<col className="col-lang" />
<col className="col-kind" />
<col className="col-cap" />
<col className="col-finds" />
</colgroup>
<thead>
<tr>
<th></th>
<th>Title</th>
<th>Lang</th>
<th>Kind</th>
<th>Cap</th>
<th>Finds</th>
</tr>
</thead>
<tbody>
{rules.map((r) => (
<tr
key={r.id}
className={`rule-row${r.id === selectedId ? ' selected' : ''}${!r.enabled ? ' rule-disabled' : ''}`}
onClick={() => onSelect(r.id)}
>
<td>
<button
className={`rule-toggle${r.enabled ? ' toggle-on' : ' toggle-off'}`}
title={r.enabled ? 'Disable' : 'Enable'}
onClick={(e) => {
e.stopPropagation();
onToggle(r.id);
}}
>
{r.enabled ? 'On' : 'Off'}
</button>
</td>
<td className="col-title-cell">
<span className="rule-title-text">
{r.title}
{r.is_custom && (
<>
{' '}
<span className="badge-custom">custom</span>
</>
)}
{r.is_gated && (
<>
{' '}
<span className="badge-builtin">gated</span>
</>
)}
</span>
</td>
<td>{r.language}</td>
<td>
<span className={`badge badge-${r.kind}`}>{r.kind}</span>
</td>
<td>{r.cap}</td>
<td>{r.finding_count}</td>
</tr>
))}
</tbody>
</table>
);
}
// ── Page ─────────────────────────────────────────────────────────────────────
export function RulesPage() {
const params = useParams<{ id?: string }>();
const { data: rules, isLoading, error } = useRules();
const toggleRule = useToggleRule();
const cloneRule = useCloneRule();
const [selectedId, setSelectedId] = useState<string | null>(
params.id || null,
);
const [langFilter, setLangFilter] = useState('');
const [kindFilter, setKindFilter] = useState('');
const [customOnly, setCustomOnly] = useState(false);
const [searchInput, setSearchInput] = useState('');
const search = useDebounce(searchInput, 200);
const langs = useMemo(() => {
if (!rules) return [];
return [...new Set(rules.map((r) => r.language))].sort();
}, [rules]);
const kinds = ['source', 'sanitizer', 'sink'];
const filtered = useMemo(() => {
if (!rules) return [];
return rules.filter((r) => {
if (langFilter && r.language !== langFilter) return false;
if (kindFilter && r.kind !== kindFilter) return false;
if (customOnly && !r.is_custom) return false;
if (
search &&
!r.matchers.some((m) =>
m.toLowerCase().includes(search.toLowerCase()),
) &&
!r.title.toLowerCase().includes(search.toLowerCase())
)
return false;
return true;
});
}, [rules, langFilter, kindFilter, customOnly, search]);
const selectedRule = useMemo(
() => (selectedId && rules ? rules.find((r) => r.id === selectedId) : null),
[selectedId, rules],
);
const handleSelect = useCallback((id: string) => {
setSelectedId(id);
history.replaceState(
null,
'',
id ? '/rules/' + encodeURIComponent(id) : '/rules',
);
}, []);
const handleToggle = useCallback(
(id: string) => {
toggleRule.mutate(id);
},
[toggleRule],
);
const handleClone = useCallback(() => {
if (!selectedId) return;
cloneRule.mutate({ rule_id: selectedId });
}, [selectedId, cloneRule]);
if (isLoading) return <LoadingState message="Loading rules..." />;
if (error) return <ErrorState message={error.message} />;
return (
<>
<div className="page-header">
<h2>Rules</h2>
<span
style={{
color: 'var(--text-secondary)',
fontSize: 'var(--text-sm)',
marginLeft: 'var(--space-3)',
}}
>
{(rules || []).length} rules
</span>
</div>
<div className="rules-layout">
<div className="rules-list-panel">
<div className="rules-filters">
<select
value={langFilter}
onChange={(e) => setLangFilter(e.target.value)}
>
<option value="">All Languages</option>
{langs.map((l) => (
<option key={l} value={l}>
{l}
</option>
))}
</select>
<select
value={kindFilter}
onChange={(e) => setKindFilter(e.target.value)}
>
<option value="">All Kinds</option>
{kinds.map((k) => (
<option key={k} value={k}>
{k}
</option>
))}
</select>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
fontSize: 'var(--text-sm)',
}}
>
<input
type="checkbox"
checked={customOnly}
onChange={(e) => setCustomOnly(e.target.checked)}
/>{' '}
Custom only
</label>
<input
type="text"
placeholder="Search matchers..."
style={{ flex: 1, minWidth: 100 }}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
/>
</div>
<div id="rules-table-wrap">
<RulesTable
rules={filtered}
selectedId={selectedId}
onSelect={handleSelect}
onToggle={handleToggle}
/>
</div>
</div>
<div className="rules-detail-panel" id="rules-detail">
{selectedRule ? (
<RuleDetail
rule={selectedRule}
onToggle={() => handleToggle(selectedRule.id)}
onClone={handleClone}
/>
) : (
<div className="empty-state" style={{ padding: 40 }}>
<p>Select a rule to view details</p>
</div>
)}
</div>
</div>
</>
);
}

View file

@ -0,0 +1,414 @@
import { useState, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useScanCompare } from '../api/queries/scans';
import { LoadingState } from '../components/ui/LoadingState';
import { ErrorState } from '../components/ui/ErrorState';
import type {
CompareResponse,
ComparedFinding,
ChangedFinding,
} from '../api/types';
function truncPath(p?: string, max = 50): string {
if (!p) return '';
if (p.length <= max) return p;
return '...' + p.slice(p.length - max + 3);
}
function fmtDate(iso?: string): string {
return iso ? new Date(iso).toLocaleString() : '-';
}
function shortId(id: string): string {
return id.length > 8 ? id.slice(0, 8) : id;
}
// ── Finding Row ──────────────────────────────────────────────────────────────
function CompareRow({
f,
rowCls,
showChanges,
}: {
f: ComparedFinding | ChangedFinding;
rowCls: string;
showChanges: boolean;
}) {
const navigate = useNavigate();
// Both ComparedFinding and ChangedFinding extend FindingView directly
const severity = f.severity || '';
const ruleId = f.rule_id || '';
const path = f.path || '';
const line = f.line || '-';
const confidence = f.confidence;
const index = f.index;
const changes =
showChanges && 'changes' in f ? (f as ChangedFinding).changes : [];
return (
<div
className={`compare-finding-row ${rowCls}`}
onClick={() => index != null && navigate(`/findings/${index}`)}
style={{ cursor: 'pointer' }}
>
<span className={`badge badge-${severity.toLowerCase()}`}>
{severity}
</span>
<span style={{ fontSize: 'var(--text-xs)' }}>{ruleId}</span>
<span className="finding-path" title={path}>
{truncPath(path)}
</span>
<span
style={{ fontSize: 'var(--text-xs)', color: 'var(--text-secondary)' }}
>
L{line}
</span>
{confidence && (
<span className={`badge badge-conf-${confidence.toLowerCase()}`}>
{confidence}
</span>
)}
{changes &&
changes.length > 0 &&
changes.map((c, i) => (
<span key={i} className="compare-delta-inline">
{c.field}: {c.old_value} <span className="delta-arrow">&rarr;</span>{' '}
{c.new_value}
</span>
))}
</div>
);
}
// ── Collapsible Section ──────────────────────────────────────────────────────
function CollapsibleSection({
sectionKey,
headerContent,
defaultCollapsed = false,
children,
}: {
sectionKey: string;
headerContent: React.ReactNode;
defaultCollapsed?: boolean;
children: React.ReactNode;
}) {
const [collapsed, setCollapsed] = useState(defaultCollapsed);
return (
<div className="compare-section" data-section={sectionKey}>
<div
className="compare-section-header"
onClick={() => setCollapsed(!collapsed)}
>
<span className={`section-toggle ${collapsed ? 'collapsed' : ''}`}>
&#9660;
</span>
{headerContent}
</div>
<div
className="compare-section-body"
style={{ display: collapsed ? 'none' : undefined }}
>
{children}
</div>
</div>
);
}
// ── By Status Tab ────────────────────────────────────────────────────────────
function CompareByStatus({ data }: { data: CompareResponse }) {
const sections = [
{
key: 'new',
label: 'New Findings',
badge: 'compare-badge--new',
rowCls: 'compare-finding-row--new',
items: data.new_findings,
},
{
key: 'fixed',
label: 'Fixed Findings',
badge: 'compare-badge--fixed',
rowCls: 'compare-finding-row--fixed',
items: data.fixed_findings,
},
{
key: 'changed',
label: 'Changed Findings',
badge: 'compare-badge--changed',
rowCls: 'compare-finding-row--changed',
items: data.changed_findings as (ComparedFinding | ChangedFinding)[],
},
{
key: 'unchanged',
label: 'Unchanged Findings',
badge: 'compare-badge--unchanged',
rowCls: 'compare-finding-row--unchanged',
items: data.unchanged_findings,
},
];
return (
<>
{sections.map((sec) => {
if (sec.items.length === 0) return null;
return (
<CollapsibleSection
key={sec.key}
sectionKey={sec.key}
defaultCollapsed={sec.key === 'unchanged'}
headerContent={
<>
<span className={sec.badge}>{sec.key.toUpperCase()}</span>
<span>
{sec.label} ({sec.items.length})
</span>
</>
}
>
{sec.items.map((f, i) => (
<CompareRow
key={i}
f={f}
rowCls={sec.rowCls}
showChanges={sec.key === 'changed'}
/>
))}
</CollapsibleSection>
);
})}
</>
);
}
// ── By Group Tab ─────────────────────────────────────────────────────────────
interface TaggedFinding extends ComparedFinding {
_status: string;
}
function CompareByGroup({
data,
groupField,
}: {
data: CompareResponse;
groupField: 'rule_id' | 'path';
}) {
const groups = useMemo(() => {
const all: TaggedFinding[] = [];
data.new_findings.forEach((f) => all.push({ ...f, _status: 'new' }));
data.fixed_findings.forEach((f) => all.push({ ...f, _status: 'fixed' }));
data.changed_findings.forEach((f) =>
all.push({ ...(f as unknown as ComparedFinding), _status: 'changed' }),
);
data.unchanged_findings.forEach((f) =>
all.push({ ...f, _status: 'unchanged' }),
);
const grouped: Record<string, TaggedFinding[]> = {};
all.forEach((f) => {
// ComparedFinding extends FindingView, so groupField is directly on f
const key = f[groupField] || '(unknown)';
if (!grouped[key]) grouped[key] = [];
grouped[key].push(f);
});
return Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b));
}, [data, groupField]);
return (
<>
{groups.map(([key, items]) => {
const counts = { new: 0, fixed: 0, changed: 0, unchanged: 0 };
items.forEach(
(f) =>
(counts[f._status as keyof typeof counts] =
(counts[f._status as keyof typeof counts] || 0) + 1),
);
const summary =
[
counts.new > 0 ? `+${counts.new}` : '',
counts.fixed > 0 ? `-${counts.fixed}` : '',
counts.changed > 0 ? `~${counts.changed}` : '',
]
.filter(Boolean)
.join(' ') || `${counts.unchanged} unchanged`;
return (
<CollapsibleSection
key={key}
sectionKey={key}
headerContent={
<>
<span
style={{
fontFamily: 'var(--font-mono)',
fontSize: 'var(--text-xs)',
}}
>
{key}
</span>
<span className="compare-group-summary">{summary}</span>
</>
}
>
{items.map((f, i) => (
<CompareRow
key={i}
f={f}
rowCls={`compare-finding-row--${f._status}`}
showChanges={f._status === 'changed'}
/>
))}
</CollapsibleSection>
);
})}
</>
);
}
// ── Page ─────────────────────────────────────────────────────────────────────
type CompareTab = 'status' | 'rule' | 'file';
export function ScanComparePage() {
const { left, right } = useParams<{ left: string; right: string }>();
const navigate = useNavigate();
const { data, isLoading, error } = useScanCompare(left || '', right || '');
const [activeTab, setActiveTab] = useState<CompareTab>('status');
if (isLoading) return <LoadingState message="Loading comparison..." />;
if (error)
return <ErrorState title="Comparison failed" message={error.message} />;
if (!data) return <ErrorState message="No comparison data" />;
const severities = ['HIGH', 'MEDIUM', 'LOW'];
return (
<>
<button
className="btn btn-sm"
style={{ marginBottom: 'var(--space-4)' }}
onClick={() => navigate('/scans')}
>
Back to Scans
</button>
<div className="page-header">
<h2>Scan Comparison</h2>
</div>
<div className="compare-header">
<div className="compare-scan-pill">
<span>Left</span>
<span className="pill-id">{shortId(data.left_scan.id)}</span>
<span className="pill-count">
{data.left_scan.finding_count} findings
</span>
<span
style={{
color: 'var(--text-tertiary)',
fontSize: 'var(--text-xs)',
}}
>
{fmtDate(data.left_scan.started_at)}
</span>
</div>
<span className="compare-vs">vs</span>
<div className="compare-scan-pill">
<span>Right</span>
<span className="pill-id">{shortId(data.right_scan.id)}</span>
<span className="pill-count">
{data.right_scan.finding_count} findings
</span>
<span
style={{
color: 'var(--text-tertiary)',
fontSize: 'var(--text-xs)',
}}
>
{fmtDate(data.right_scan.started_at)}
</span>
</div>
</div>
<div className="compare-summary-grid">
<div className="compare-card compare-card--new">
<div className="compare-card-label">New</div>
<div className="compare-card-value">{data.summary.new_count}</div>
</div>
<div className="compare-card compare-card--fixed">
<div className="compare-card-label">Fixed</div>
<div className="compare-card-value">{data.summary.fixed_count}</div>
</div>
<div className="compare-card compare-card--changed">
<div className="compare-card-label">Changed</div>
<div className="compare-card-value">{data.summary.changed_count}</div>
</div>
<div className="compare-card compare-card--unchanged">
<div className="compare-card-label">Unchanged</div>
<div className="compare-card-value">
{data.summary.unchanged_count}
</div>
</div>
</div>
<div className="severity-delta">
{severities.map((s) => {
const d = data.summary.severity_delta[s] || 0;
let cls = 'delta-zero';
let prefix = '';
if (d > 0) {
cls = 'delta-positive';
prefix = '+';
} else if (d < 0) {
cls = 'delta-negative';
}
return (
<span key={s} className="severity-delta-item">
<span className={`badge badge-${s.toLowerCase()}`}>{s}</span>
<span className={cls}>
{prefix}
{d}
</span>
</span>
);
})}
</div>
<div className="scan-detail-tabs">
<button
className={`scan-detail-tab ${activeTab === 'status' ? 'active' : ''}`}
onClick={() => setActiveTab('status')}
>
By Status
</button>
<button
className={`scan-detail-tab ${activeTab === 'rule' ? 'active' : ''}`}
onClick={() => setActiveTab('rule')}
>
By Rule
</button>
<button
className={`scan-detail-tab ${activeTab === 'file' ? 'active' : ''}`}
onClick={() => setActiveTab('file')}
>
By File
</button>
</div>
<div id="compare-tab-content">
{activeTab === 'status' && <CompareByStatus data={data} />}
{activeTab === 'rule' && (
<CompareByGroup data={data} groupField="rule_id" />
)}
{activeTab === 'file' && (
<CompareByGroup data={data} groupField="path" />
)}
</div>
</>
);
}

View file

@ -0,0 +1,467 @@
import { useState, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
useScan,
useScans,
useScanFindings,
useScanLogs,
useScanMetrics,
} from '../api/queries/scans';
import { LoadingState } from '../components/ui/LoadingState';
import { ErrorState } from '../components/ui/ErrorState';
import type { ScanView, ScanLogEntry, ScanMetricsSnapshot } from '../api/types';
function truncPath(p?: string, max = 50): string {
if (!p) return '';
if (p.length <= max) return p;
return '...' + p.slice(p.length - max + 3);
}
function fmtDate(iso?: string): string {
return iso ? new Date(iso).toLocaleString() : '-';
}
function fmtNum(n?: number | null): string {
return n != null ? n.toLocaleString() : '-';
}
// ── Summary Tab ──────────────────────────────────────────────────────────────
function SummaryTab({ scan }: { scan: ScanView }) {
const duration =
scan.duration_secs != null ? scan.duration_secs.toFixed(2) + 's' : '-';
const langs = (scan.languages || []).join(', ') || '-';
const timing = scan.timing;
let total = 0;
if (timing) {
total =
timing.walk_ms +
timing.pass1_ms +
timing.call_graph_ms +
timing.pass2_ms +
timing.post_process_ms;
}
const pct = (ms: number) => ((ms / total) * 100).toFixed(1);
return (
<>
<div className="scan-stat-grid">
<div className="scan-stat-card">
<div className="scan-stat-label">Files Scanned</div>
<div className="scan-stat-value">{scan.files_scanned ?? '-'}</div>
</div>
<div className="scan-stat-card">
<div className="scan-stat-label">Findings</div>
<div className="scan-stat-value">{scan.finding_count ?? '-'}</div>
</div>
<div className="scan-stat-card">
<div className="scan-stat-label">Duration</div>
<div className="scan-stat-value">{duration}</div>
</div>
<div className="scan-stat-card">
<div className="scan-stat-label">Languages</div>
<div
className="scan-stat-value"
style={{ fontSize: 'var(--text-base)' }}
>
{langs}
</div>
</div>
</div>
<div className="card">
<div className="card-header">Details</div>
<table>
<tbody>
<tr>
<td style={{ color: 'var(--text-secondary)', width: 140 }}>
Scan ID
</td>
<td
style={{
fontFamily: 'var(--font-mono)',
fontSize: 'var(--text-xs)',
}}
>
{scan.id}
</td>
</tr>
<tr>
<td style={{ color: 'var(--text-secondary)' }}>Root</td>
<td
style={{
fontFamily: 'var(--font-mono)',
fontSize: 'var(--text-sm)',
}}
>
{scan.scan_root}
</td>
</tr>
<tr>
<td style={{ color: 'var(--text-secondary)' }}>Engine</td>
<td>{scan.engine_version || '-'}</td>
</tr>
<tr>
<td style={{ color: 'var(--text-secondary)' }}>Started</td>
<td>{fmtDate(scan.started_at)}</td>
</tr>
<tr>
<td style={{ color: 'var(--text-secondary)' }}>Finished</td>
<td>{fmtDate(scan.finished_at)}</td>
</tr>
{scan.error && (
<tr>
<td style={{ color: 'var(--text-secondary)' }}>Error</td>
<td style={{ color: 'var(--sev-high)' }}>{scan.error}</td>
</tr>
)}
</tbody>
</table>
</div>
{timing && total > 0 && (
<div className="card" style={{ marginTop: 'var(--space-4)' }}>
<div className="card-header">Timing Breakdown</div>
<div className="timing-bar">
<div
className="timing-bar-segment walk"
style={{ width: `${pct(timing.walk_ms)}%` }}
title={`Walk: ${timing.walk_ms}ms`}
></div>
<div
className="timing-bar-segment pass1"
style={{ width: `${pct(timing.pass1_ms)}%` }}
title={`Pass 1: ${timing.pass1_ms}ms`}
></div>
<div
className="timing-bar-segment callgraph"
style={{ width: `${pct(timing.call_graph_ms)}%` }}
title={`Call Graph: ${timing.call_graph_ms}ms`}
></div>
<div
className="timing-bar-segment pass2"
style={{ width: `${pct(timing.pass2_ms)}%` }}
title={`Pass 2: ${timing.pass2_ms}ms`}
></div>
<div
className="timing-bar-segment postprocess"
style={{ width: `${pct(timing.post_process_ms)}%` }}
title={`Post-process: ${timing.post_process_ms}ms`}
></div>
</div>
<div className="timing-legend">
<span className="timing-legend-item">
<span
className="timing-legend-dot"
style={{ background: 'var(--sev-low)' }}
></span>{' '}
Walk {timing.walk_ms}ms
</span>
<span className="timing-legend-item">
<span
className="timing-legend-dot"
style={{ background: 'var(--accent)' }}
></span>{' '}
Pass 1 {timing.pass1_ms}ms
</span>
<span className="timing-legend-item">
<span
className="timing-legend-dot"
style={{ background: 'var(--sev-medium)' }}
></span>{' '}
Call Graph {timing.call_graph_ms}ms
</span>
<span className="timing-legend-item">
<span
className="timing-legend-dot"
style={{ background: 'var(--success)' }}
></span>{' '}
Pass 2 {timing.pass2_ms}ms
</span>
<span className="timing-legend-item">
<span
className="timing-legend-dot"
style={{ background: 'var(--text-tertiary)' }}
></span>{' '}
Post {timing.post_process_ms}ms
</span>
</div>
</div>
)}
</>
);
}
// ── Findings Tab ─────────────────────────────────────────────────────────────
function FindingsTab({ scanId }: { scanId: string }) {
const navigate = useNavigate();
const { data, isLoading, error } = useScanFindings(scanId);
if (isLoading) return <LoadingState message="Loading findings..." />;
if (error) return <ErrorState message={error.message} />;
if (!data?.findings || data.findings.length === 0) {
return (
<div className="empty-state">
<h3>No findings</h3>
<p>This scan produced no findings.</p>
</div>
);
}
return (
<>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Severity</th>
<th>Rule</th>
<th>File</th>
<th>Line</th>
<th>Confidence</th>
</tr>
</thead>
<tbody>
{data.findings.map((f) => (
<tr
key={f.index}
className="clickable"
onClick={() => navigate(`/findings/${f.index}`)}
>
<td>
<span className={`badge badge-${f.severity.toLowerCase()}`}>
{f.severity}
</span>
</td>
<td>{f.rule_id}</td>
<td className="cell-path" title={f.path}>
{truncPath(f.path)}
</td>
<td>{f.line}</td>
<td>
{f.confidence ? (
<span
className={`badge badge-conf-${f.confidence.toLowerCase()}`}
>
{f.confidence}
</span>
) : (
'-'
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div
style={{
marginTop: 'var(--space-2)',
fontSize: 'var(--text-sm)',
color: 'var(--text-secondary)',
}}
>
Showing {data.findings.length} of {data.total} findings
</div>
</>
);
}
// ── Logs Tab ─────────────────────────────────────────────────────────────────
function LogsTab({ scanId }: { scanId: string }) {
const [levelFilter, setLevelFilter] = useState<string | undefined>(undefined);
const { data: logs, isLoading, error } = useScanLogs(scanId, levelFilter);
if (isLoading) return <LoadingState message="Loading logs..." />;
if (error) return <ErrorState message={error.message} />;
const levels: Array<{ value: string | undefined; label: string }> = [
{ value: undefined, label: 'All' },
{ value: 'info', label: 'Info' },
{ value: 'warn', label: 'Warn' },
{ value: 'error', label: 'Error' },
];
return (
<>
<div className="log-filters">
{levels.map((l) => (
<button
key={l.label}
className={`log-filter-btn ${levelFilter === l.value ? 'active' : ''}`}
onClick={() => setLevelFilter(l.value)}
>
{l.label}
</button>
))}
</div>
{!logs || logs.length === 0 ? (
<div className="empty-state">
<p>No log entries</p>
</div>
) : (
<div className="log-viewer">
{logs.map((l: ScanLogEntry, i: number) => (
<div key={i} className={`log-entry log-${l.level}`}>
<span className={`log-level ${l.level}`}>{l.level}</span>
<span className="log-time">
{new Date(l.timestamp).toLocaleTimeString()}
</span>
<span className="log-message">
{l.message}
{l.file_path && (
<span style={{ color: 'var(--text-tertiary)' }}>
{' '}
{l.file_path}
</span>
)}
</span>
</div>
))}
</div>
)}
</>
);
}
// ── Metrics Tab ──────────────────────────────────────────────────────────────
function MetricsTab({ scanId, scan }: { scanId: string; scan: ScanView }) {
const { data: fetchedMetrics } = useScanMetrics(scanId);
const metrics: ScanMetricsSnapshot | undefined =
scan.metrics || fetchedMetrics || undefined;
if (!metrics) {
return (
<div className="empty-state">
<p>No metrics available for this scan.</p>
</div>
);
}
return (
<div className="metric-grid">
<div className="metric-card">
<div className="metric-card-label">CFG Nodes</div>
<div className="metric-card-value">{fmtNum(metrics.cfg_nodes)}</div>
</div>
<div className="metric-card">
<div className="metric-card-label">Call Edges</div>
<div className="metric-card-value">{fmtNum(metrics.call_edges)}</div>
</div>
<div className="metric-card">
<div className="metric-card-label">Functions Analyzed</div>
<div className="metric-card-value">
{fmtNum(metrics.functions_analyzed)}
</div>
</div>
<div className="metric-card">
<div className="metric-card-label">Summaries Reused</div>
<div className="metric-card-value">
{fmtNum(metrics.summaries_reused)}
</div>
</div>
<div className="metric-card">
<div className="metric-card-label">Unresolved Calls</div>
<div className="metric-card-value">
{fmtNum(metrics.unresolved_calls)}
</div>
</div>
</div>
);
}
// ── Scan Detail Page ─────────────────────────────────────────────────────────
type TabId = 'summary' | 'findings' | 'logs' | 'metrics';
export function ScanDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: scan, isLoading, error } = useScan(id || '');
const { data: allScans } = useScans();
const [activeTab, setActiveTab] = useState<TabId>('summary');
const prevScanId = useMemo(() => {
if (!scan || scan.status !== 'completed' || !allScans) return null;
const completed = allScans
.filter((s) => s.status === 'completed' && s.started_at)
.sort((a, b) => (a.started_at || '').localeCompare(b.started_at || ''));
const myIdx = completed.findIndex((s) => s.id === id);
if (myIdx > 0) return completed[myIdx - 1].id;
return null;
}, [scan, allScans, id]);
if (isLoading) return <LoadingState message="Loading scan..." />;
if (error || !scan) {
return (
<ErrorState
title="Scan not found"
message={error?.message || 'Not found'}
/>
);
}
const tabs: { id: TabId; label: string }[] = [
{ id: 'summary', label: 'Summary' },
{ id: 'findings', label: 'Findings' },
{ id: 'logs', label: 'Logs' },
{ id: 'metrics', label: 'Metrics' },
];
return (
<>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2)',
marginBottom: 'var(--space-4)',
}}
>
<button className="btn btn-sm" onClick={() => navigate('/scans')}>
Back to Scans
</button>
{prevScanId && (
<button
className="btn btn-sm"
style={{ marginLeft: 'auto' }}
onClick={() => navigate(`/scans/compare/${prevScanId}/${id}`)}
>
Compare with Previous
</button>
)}
</div>
<div className="page-header">
<h2>Scan Detail</h2>
<span className={`status-badge ${scan.status}`}>
<span className={`status-dot ${scan.status}`}></span>
{scan.status}
</span>
</div>
<div className="scan-detail-tabs">
{tabs.map((tab) => (
<button
key={tab.id}
className={`scan-detail-tab ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
<div id="scan-tab-content">
{activeTab === 'summary' && <SummaryTab scan={scan} />}
{activeTab === 'findings' && <FindingsTab scanId={id!} />}
{activeTab === 'logs' && <LogsTab scanId={id!} />}
{activeTab === 'metrics' && <MetricsTab scanId={id!} scan={scan} />}
</div>
</>
);
}

View file

@ -0,0 +1,297 @@
import { useState, useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useScans } from '../api/queries/scans';
import { useDeleteScan } from '../api/mutations/scans';
import { useSSE } from '../contexts/SSEContext';
import { LoadingState } from '../components/ui/LoadingState';
import { ErrorState } from '../components/ui/ErrorState';
import type { ScanView } from '../api/types';
function relTime(iso?: string): string {
if (!iso) return '-';
const d = new Date(iso);
const diff = Date.now() - d.getTime();
if (diff < 60000) return 'just now';
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
return d.toLocaleDateString();
}
function truncPath(p?: string, max = 50): string {
if (!p) return '';
if (p.length <= max) return p;
return '...' + p.slice(p.length - max + 3);
}
function ScanProgress({
data,
}: {
data: NonNullable<ReturnType<typeof useSSE>['scanProgress']>;
}) {
const stages = [
'discovering',
'indexing',
'loading_summaries',
'building_call_graph',
'analyzing',
'post_processing',
'complete',
] as const;
const stageLabels: Record<string, string> = {
discovering: 'Discovering',
indexing: 'Indexing',
loading_summaries: 'Loading Summaries',
building_call_graph: 'Call Graph',
analyzing: 'Analyzing',
post_processing: 'Post-Process',
complete: 'Complete',
};
const currentIdx = stages.indexOf(data.stage as (typeof stages)[number]);
const total = data.files_discovered || 1;
const processed =
data.stage === 'indexing'
? data.files_parsed
: data.stage === 'analyzing' || data.stage === 'post_processing'
? data.files_analyzed
: data.stage === 'complete'
? total
: 0;
const pct = Math.min(100, (processed / total) * 100);
const elapsed = data.elapsed_ms
? (data.elapsed_ms / 1000).toFixed(1) + 's'
: '-';
return (
<div className="scan-progress">
<div className="scan-progress-header">
<h3>Scan in Progress</h3>
<span
style={{ fontSize: 'var(--text-sm)', color: 'var(--text-secondary)' }}
>
{elapsed} elapsed
</span>
</div>
<div className="stage-pipeline">
{stages.map((s, i) => {
const cls =
i < currentIdx ? 'done' : i === currentIdx ? 'active' : '';
return (
<div key={s} className={`stage-step ${cls}`}>
<div className="stage-dot"></div>
<span className="stage-label">{stageLabels[s]}</span>
</div>
);
})}
</div>
<div className="progress-bar">
<div className="progress-bar-fill" style={{ width: `${pct}%` }}></div>
</div>
<div className="progress-stats">
<span>
{processed} / {data.files_discovered || 0} files
</span>
<span>{pct.toFixed(0)}%</span>
</div>
<div className="progress-stats">
<span>{data.files_parsed || 0} indexed</span>
<span>{data.files_skipped || 0} reused</span>
<span>{data.files_analyzed || 0} analyzed</span>
</div>
{data.batches_total > 0 && (
<div className="progress-stats">
<span>
Batch {Math.min(data.batches_completed, data.batches_total)} /{' '}
{data.batches_total}
</span>
<span>{stageLabels[data.stage] || data.stage}</span>
</div>
)}
<div className="progress-stats">
<span>Walk {data.timing.walk_ms}ms</span>
<span>Index {data.timing.pass1_ms}ms</span>
<span>Graph {data.timing.call_graph_ms}ms</span>
<span>Analyze {data.timing.pass2_ms}ms</span>
</div>
{data.current_file && (
<div className="progress-current-file">
{truncPath(data.current_file, 80)}
</div>
)}
</div>
);
}
export function ScansPage() {
const navigate = useNavigate();
const { data: scans, isLoading, error } = useScans();
const deleteScan = useDeleteScan();
const { scanProgress, isScanRunning } = useSSE();
const [selectedScans, setSelectedScans] = useState<Set<string>>(new Set());
const completedScans = useMemo(
() => (scans || []).filter((s) => s.status === 'completed'),
[scans],
);
const runningScans = useMemo(
() => (scans || []).filter((s) => s.status === 'running'),
[scans],
);
const handleCheckbox = useCallback((e: React.MouseEvent, scanId: string) => {
e.stopPropagation();
setSelectedScans((prev) => {
const next = new Set(prev);
if (next.has(scanId)) {
next.delete(scanId);
} else {
if (next.size >= 2) return prev;
next.add(scanId);
}
return next;
});
}, []);
const handleCompare = useCallback(() => {
if (selectedScans.size !== 2) return;
const ids = [...selectedScans];
// Sort by started_at so left=older, right=newer
const scanMap = new Map((scans || []).map((s) => [s.id, s]));
ids.sort((a, b) =>
(scanMap.get(a)?.started_at || '').localeCompare(
scanMap.get(b)?.started_at || '',
),
);
navigate(`/scans/compare/${ids[0]}/${ids[1]}`);
}, [selectedScans, scans, navigate]);
if (isLoading) return <LoadingState message="Loading scans..." />;
if (error) return <ErrorState message={error.message} />;
const showCheckboxes = completedScans.length >= 2;
return (
<>
<div className="page-header">
<h2>Scans</h2>
</div>
{(runningScans.length > 0 || isScanRunning) && scanProgress && (
<ScanProgress data={scanProgress} />
)}
{selectedScans.size > 0 && (
<div className="compare-select-bar" style={{ display: 'flex' }}>
<span>
{selectedScans.size === 2
? '2 scans selected'
: `Select ${2 - selectedScans.size} more completed scan${selectedScans.size === 0 ? 's' : ''}`}
</span>
<button
className="btn btn-sm"
disabled={selectedScans.size !== 2}
onClick={handleCompare}
>
Compare Selected
</button>
</div>
)}
{!scans || scans.length === 0 ? (
<div className="empty-state">
<h3>No scans yet</h3>
<p>
Use the &quot;Start Scan&quot; button in the header to start your
first scan.
</p>
</div>
) : (
<div className="table-wrap">
<table>
<thead>
<tr>
{showCheckboxes && <th style={{ width: 32 }}></th>}
<th>Status</th>
<th>Root</th>
<th>Duration</th>
<th>Findings</th>
<th>Languages</th>
<th>Started</th>
<th style={{ width: 60 }}></th>
</tr>
</thead>
<tbody>
{scans.map((s: ScanView) => (
<tr
key={s.id}
className="clickable"
onClick={() => navigate(`/scans/${s.id}`)}
>
{showCheckboxes && (
<td>
{s.status === 'completed' && (
<input
type="checkbox"
className="scan-compare-cb"
checked={selectedScans.has(s.id)}
onClick={(e) => handleCheckbox(e, s.id)}
onChange={() => {}}
/>
)}
</td>
)}
<td>
<span className={`status-badge ${s.status}`}>
<span className={`status-dot ${s.status}`}></span>
{s.status}
</span>
</td>
<td
style={{
fontFamily: 'var(--font-mono)',
fontSize: '0.82rem',
}}
>
{truncPath(s.scan_root)}
</td>
<td>
{s.duration_secs != null
? s.duration_secs.toFixed(2) + 's'
: '-'}
</td>
<td>{s.finding_count ?? '-'}</td>
<td>
{(s.languages || []).length > 0
? (s.languages || []).map((l) => (
<span key={l} className="lang-badge">
{l}
</span>
))
: '-'}
</td>
<td>{relTime(s.started_at)}</td>
<td>
{s.status !== 'running' && (
<button
className="btn btn-sm btn-danger"
onClick={(e) => {
e.stopPropagation();
if (confirm('Delete this scan?')) {
deleteScan.mutate(s.id);
}
}}
>
Delete
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
);
}

View file

@ -0,0 +1,56 @@
import { useLocation } from 'react-router-dom';
import { ICONS } from '../components/icons/Icons';
const STUB_DESCRIPTIONS: Record<string, string> = {
'/explorer':
'Browse the scanned codebase, view file trees, and inspect individual files with inline annotations.',
'/debug':
'Inspect internal analysis state — control flow graphs, SSA IR, call graphs, and taint propagation.',
'/debug/cfg':
'Visualize control flow graphs for individual functions with block-level detail.',
'/debug/ssa':
'Inspect SSA intermediate representation including phi nodes, value numbering, and taint state.',
'/debug/call-graph':
'Explore the inter-procedural call graph with SCC highlighting and topo-order visualization.',
'/debug/taint':
'Step through taint propagation with per-instruction state snapshots and path tracking.',
'/settings': 'Application settings and preferences.',
};
const ROUTE_LABELS: Record<string, string> = {
'/explorer': 'Explorer',
'/debug': 'Debug',
'/debug/cfg': 'CFG Viewer',
'/debug/ssa': 'SSA Viewer',
'/debug/call-graph': 'Call Graph',
'/debug/taint': 'Taint Debugger',
'/settings': 'Settings',
};
function sectionFromPath(pathname: string): string {
if (pathname === '/') return 'overview';
const first = pathname.split('/')[1];
return first || 'overview';
}
export function StubPage() {
const { pathname } = useLocation();
const label = ROUTE_LABELS[pathname] ?? sectionFromPath(pathname);
const description =
STUB_DESCRIPTIONS[pathname] ?? 'This page is under construction.';
const section = sectionFromPath(pathname);
const IconComponent = ICONS[section];
return (
<div className="stub-page">
{IconComponent && (
<div className="stub-icon">
<IconComponent size={48} />
</div>
)}
<h2 className="stub-title">{label}</h2>
<p className="stub-description">{description}</p>
<span className="stub-badge">Coming Soon</span>
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,170 @@
import { useDebugAbstractInterp } from '../../api/queries/debug';
import { ApiError } from '../../api/client';
import { EmptyState } from '../../components/ui/EmptyState';
import { ErrorState } from '../../components/ui/ErrorState';
import { LoadingState } from '../../components/ui/LoadingState';
import type {
AbstractBlockView,
AbstractValueView,
TypeFactView,
ConstValueViewEntry,
} from '../../api/types';
interface AbstractInterpAnalysisPanelProps {
file: string;
functionName: string;
}
export function AbstractInterpAnalysisPanel({
file,
functionName,
}: AbstractInterpAnalysisPanelProps) {
const { data, isLoading, error } = useDebugAbstractInterp(file, functionName);
if (isLoading) {
return <LoadingState message="Loading abstract interpretation..." />;
}
if (error) {
if (error instanceof ApiError && error.status === 404) {
return (
<EmptyState message="Abstract interpretation data is not available for the selected function." />
);
}
return <ErrorState message="Failed to load abstract interpretation." />;
}
if (
!data ||
(data.blocks.length === 0 &&
data.type_facts.length === 0 &&
data.const_values.length === 0)
) {
return (
<EmptyState message="No abstract domain facts are tracked for this function." />
);
}
return (
<div className="abstract-interp-viewer">
{data.blocks.length > 0 && (
<>
<h3>Abstract Domain Facts</h3>
{data.blocks.map((block) => (
<AbstractBlock key={block.block_id} block={block} />
))}
</>
)}
{data.type_facts.length > 0 && (
<div className="abstract-block">
<div className="abstract-block-header">
<h3 style={{ margin: 0 }}>Type Facts</h3>
<span className="text-secondary">
{data.type_facts.length} typed values
</span>
</div>
<table className="abstract-table">
<thead>
<tr>
<th>Value</th>
<th>Name</th>
<th>Type</th>
<th>Nullable</th>
</tr>
</thead>
<tbody>
{data.type_facts.map((tf) => (
<tr key={tf.ssa_value}>
<td className="mono">v{tf.ssa_value}</td>
<td className="mono">{tf.var_name ?? '-'}</td>
<td className="mono">{tf.type_kind}</td>
<td>{tf.nullable ? 'Yes' : 'No'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{data.const_values.length > 0 && (
<div className="abstract-block">
<div className="abstract-block-header">
<h3 style={{ margin: 0 }}>Constant Values</h3>
<span className="text-secondary">
{data.const_values.length} constants
</span>
</div>
<table className="abstract-table">
<thead>
<tr>
<th>Value</th>
<th>Name</th>
<th>Constant</th>
</tr>
</thead>
<tbody>
{data.const_values.map((cv) => (
<tr key={cv.ssa_value}>
<td className="mono">v{cv.ssa_value}</td>
<td className="mono">{cv.var_name ?? '-'}</td>
<td className="mono">{cv.value}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
function AbstractBlock({ block }: { block: AbstractBlockView }) {
return (
<div className="abstract-block">
<div className="abstract-block-header">
<span className="ssa-block-id">B{block.block_id}</span>
<span className="text-secondary">
{block.values.length} tracked values
</span>
</div>
<table className="abstract-table">
<thead>
<tr>
<th>Value</th>
<th>Name</th>
<th>Interval</th>
<th>String Prefix</th>
<th>String Suffix</th>
<th>Bit Masks</th>
</tr>
</thead>
<tbody>
{block.values.map((v) => (
<AbstractValueRow key={v.ssa_value} value={v} />
))}
</tbody>
</table>
</div>
);
}
function AbstractValueRow({ value }: { value: AbstractValueView }) {
const lo = value.interval_lo != null ? `${value.interval_lo}` : '-inf';
const hi = value.interval_hi != null ? `${value.interval_hi}` : '+inf';
const interval = `[${lo}, ${hi}]`;
const hasBits = value.known_zero !== 0 || value.known_one !== 0;
return (
<tr>
<td className="mono">v{value.ssa_value}</td>
<td className="mono">{value.var_name ?? '-'}</td>
<td className="mono">{interval}</td>
<td className="mono">{value.string_prefix ?? '-'}</td>
<td className="mono">{value.string_suffix ?? '-'}</td>
<td className="mono">
{hasBits
? `zero=0x${value.known_zero.toString(16)} one=0x${value.known_one.toString(16)}`
: '-'}
</td>
</tr>
);
}

View file

@ -0,0 +1,63 @@
import { useState } from 'react';
import { useDebugCallGraph } from '../../api/queries/debug';
import { CallGraphCanvas } from '../../graph/components/CallGraphCanvas';
export function CallGraphPage() {
const [selectedNode, setSelectedNode] = useState<number | null>(null);
const { data, isLoading, error } = useDebugCallGraph('project');
if (isLoading) return <div className="loading">Loading call graph...</div>;
if (error)
return (
<div className="error-state">
Failed to load call graph. Have you run a scan?
</div>
);
if (!data) return null;
const selectedInfo = data.nodes.find((n) => n.id === selectedNode);
return (
<div className="debug-split">
<div className="debug-split-main">
<div className="debug-toolbar">
<span className="debug-toolbar-label">Project scope</span>
<span className="text-secondary">
{data.nodes.length} functions, {data.edges.length} edges
{data.sccs.length > 0 && `, ${data.sccs.length} recursive SCCs`}
{data.unresolved_count > 0 &&
`, ${data.unresolved_count} unresolved`}
</span>
</div>
<CallGraphCanvas
data={data}
selectedNodeId={selectedNode}
onSelectNode={setSelectedNode}
/>
</div>
{selectedInfo && (
<div className="debug-split-sidebar">
<h3>{selectedInfo.name}</h3>
<div className="debug-node-detail">
<div className="debug-detail-row">
<span className="debug-detail-label">Language</span>
<span className="debug-detail-value">{selectedInfo.lang}</span>
</div>
<div className="debug-detail-row">
<span className="debug-detail-label">Namespace</span>
<span className="debug-detail-value mono">
{selectedInfo.namespace}
</span>
</div>
{selectedInfo.arity != null && (
<div className="debug-detail-row">
<span className="debug-detail-label">Arity</span>
<span className="debug-detail-value">{selectedInfo.arity}</span>
</div>
)}
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,37 @@
import { useDebugCfg } from '../../api/queries/debug';
import { ApiError } from '../../api/client';
import { EmptyState } from '../../components/ui/EmptyState';
import { ErrorState } from '../../components/ui/ErrorState';
import { LoadingState } from '../../components/ui/LoadingState';
import { CfgGraphCanvas } from '../../graph/components/CfgGraphCanvas';
interface CfgAnalysisPanelProps {
file: string;
functionName: string;
}
export function CfgAnalysisPanel({
file,
functionName,
}: CfgAnalysisPanelProps) {
const { data, isLoading, error } = useDebugCfg(file, functionName);
if (isLoading) {
return <LoadingState message="Loading CFG..." />;
}
if (error) {
if (error instanceof ApiError && error.status === 404) {
return (
<EmptyState message="CFG data is not available for the selected function." />
);
}
return <ErrorState message="Failed to load CFG." />;
}
if (!data || data.nodes.length === 0) {
return (
<EmptyState message="No CFG nodes are available for this function." />
);
}
return <CfgGraphCanvas data={data} />;
}

View file

@ -0,0 +1,31 @@
import { NavLink, Outlet } from 'react-router-dom';
const TABS = [
{ path: '/debug/call-graph', label: 'Call Graph' },
{ path: '/debug/summaries', label: 'Summaries' },
];
export function DebugLayout() {
return (
<div className="debug-layout debug-layout-global">
<div className="debug-main">
<nav className="debug-tabs">
{TABS.map((tab) => (
<NavLink
key={tab.path}
to={tab.path}
className={({ isActive }) =>
`debug-tab${isActive ? ' debug-tab-active' : ''}`
}
>
{tab.label}
</NavLink>
))}
</nav>
<div className="debug-content">
<Outlet />
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,56 @@
import { useDebugFunctions } from '../../api/queries/debug';
import type { FunctionInfo } from '../../api/types';
interface Props {
file: string;
selectedFunction: string | null;
onFunctionChange: (fn_name: string | null) => void;
showFilePath?: boolean;
}
export function FunctionSelector({
file,
selectedFunction,
onFunctionChange,
showFilePath = true,
}: Props) {
const { data: functions, isLoading } = useDebugFunctions(file || null);
return (
<div className="function-selector">
{showFilePath && (
<div className="function-selector-path">
<span className="function-selector-path-label">File:</span>
<code className="function-selector-path-value">
{file || 'No file selected'}
</code>
</div>
)}
<div className="function-selector-field">
<label>Function</label>
<select
value={selectedFunction ?? ''}
onChange={(e) => onFunctionChange(e.target.value || null)}
disabled={!functions || functions.length === 0}
className="function-selector-select"
>
<option value="">
{isLoading
? 'Loading...'
: !functions || functions.length === 0
? 'No functions found'
: 'Select function'}
</option>
{functions?.map((fn: FunctionInfo) => (
<option key={fn.name} value={fn.name}>
{fn.name}({fn.param_count} params) L{fn.line}
{fn.source_caps.length > 0 &&
` [src: ${fn.source_caps.join(',')}]`}
{fn.sink_caps.length > 0 && ` [sink: ${fn.sink_caps.join(',')}]`}
</option>
))}
</select>
</div>
</div>
);
}

View file

@ -0,0 +1,112 @@
import { useDebugSsa } from '../../api/queries/debug';
import { ApiError } from '../../api/client';
import { EmptyState } from '../../components/ui/EmptyState';
import { ErrorState } from '../../components/ui/ErrorState';
import { LoadingState } from '../../components/ui/LoadingState';
import type { SsaBlockView, SsaInstView } from '../../api/types';
interface SsaAnalysisPanelProps {
file: string;
functionName: string;
}
export function SsaAnalysisPanel({
file,
functionName,
}: SsaAnalysisPanelProps) {
const { data, isLoading, error } = useDebugSsa(file, functionName);
if (isLoading) {
return <LoadingState message="Loading SSA..." />;
}
if (error) {
if (error instanceof ApiError && error.status === 404) {
return (
<EmptyState message="SSA data is not available for the selected function." />
);
}
return <ErrorState message="Failed to load SSA." />;
}
if (!data) {
return <EmptyState message="No SSA data is available for this function." />;
}
// Render entry block first, then the rest in order
const entryBlock = data.blocks.find((b) => b.id === data.entry);
const otherBlocks = data.blocks.filter((b) => b.id !== data.entry);
const ordered = entryBlock ? [entryBlock, ...otherBlocks] : data.blocks;
return (
<div className="ssa-viewer">
<div className="ssa-header">
<span className="text-secondary">
{data.num_values} SSA values, {data.blocks.length} blocks
</span>
</div>
{ordered.map((block) => (
<SsaBlock
key={block.id}
block={block}
isEntry={block.id === data.entry}
/>
))}
</div>
);
}
function SsaBlock({
block,
isEntry,
}: {
block: SsaBlockView;
isEntry: boolean;
}) {
return (
<div className={`ssa-block${isEntry ? ' ssa-block-entry' : ''}`}>
<div className="ssa-block-header">
<span className="ssa-block-id">B{block.id}</span>
{isEntry && <span className="badge-info">entry</span>}
{block.preds.length > 0 && (
<span className="text-secondary ssa-block-preds">
preds: {block.preds.map((p) => `B${p}`).join(', ')}
</span>
)}
{block.succs.length > 0 && (
<span className="text-secondary ssa-block-succs">
succs: {block.succs.map((s) => `B${s}`).join(', ')}
</span>
)}
</div>
{block.phis.length > 0 && (
<div className="ssa-phi-section">
{block.phis.map((inst) => (
<SsaInstLine key={inst.value} inst={inst} isPhi />
))}
</div>
)}
<div className="ssa-body-section">
{block.body.map((inst) => (
<SsaInstLine key={inst.value} inst={inst} />
))}
</div>
<div className="ssa-terminator">{block.terminator}</div>
</div>
);
}
function SsaInstLine({ inst, isPhi }: { inst: SsaInstView; isPhi?: boolean }) {
const operands =
inst.operands.length > 0 ? `(${inst.operands.join(', ')})` : '';
return (
<div className={`ssa-inst${isPhi ? ' ssa-inst-phi' : ''}`}>
<span className="ssa-value">v{inst.value}</span>
<span className="ssa-eq"> = </span>
<span className="ssa-op">{inst.op}</span>
<span className="ssa-operands">{operands}</span>
{inst.var_name && (
<span className="ssa-var-name"> # {inst.var_name}</span>
)}
<span className="ssa-line-ref"> L{inst.line}</span>
</div>
);
}

View file

@ -0,0 +1,230 @@
import { useState } from 'react';
import { useDebugSummaries } from '../../api/queries/debug';
import { ApiError } from '../../api/client';
import { EmptyState } from '../../components/ui/EmptyState';
import { ErrorState } from '../../components/ui/ErrorState';
import { LoadingState } from '../../components/ui/LoadingState';
import type { FuncSummaryView } from '../../api/types';
interface SummaryAnalysisPanelProps {
file?: string | null;
functionName?: string | null;
scope?: 'file' | 'global';
}
export function SummaryAnalysisPanel({
file,
functionName,
scope = 'file',
}: SummaryAnalysisPanelProps) {
const { data, isLoading, error } = useDebugSummaries(
scope === 'global' ? null : (file ?? null),
scope === 'global' ? null : (functionName ?? null),
);
const [expanded, setExpanded] = useState<string | null>(null);
if (isLoading) {
return <LoadingState message="Loading summaries..." />;
}
if (error) {
if (error instanceof ApiError && error.status === 404) {
return (
<EmptyState message="Summaries are not available for the selected scope." />
);
}
return (
<ErrorState message="Failed to load summaries. Have you run a scan?" />
);
}
if (!data || data.length === 0) {
return (
<EmptyState
message={
scope === 'global'
? 'No global summaries found. Run a scan first.'
: 'No summaries found for this file.'
}
/>
);
}
return (
<div className="summary-explorer">
<div className="summary-header">
<span className="text-secondary">
{data.length}{' '}
{scope === 'global'
? 'functions across the project'
: 'functions in this file'}
</span>
</div>
<table className="summary-table">
<thead>
<tr>
<th>Function</th>
<th>Lang</th>
<th>Params</th>
<th>Sources</th>
<th>Sanitizers</th>
<th>Sinks</th>
<th>Propagates</th>
</tr>
</thead>
<tbody>
{data.map((s) => (
<SummaryRow
key={`${s.namespace}::${s.name}`}
summary={s}
isExpanded={expanded === `${s.namespace}::${s.name}`}
onToggle={() =>
setExpanded(
expanded === `${s.namespace}::${s.name}`
? null
: `${s.namespace}::${s.name}`,
)
}
/>
))}
</tbody>
</table>
</div>
);
}
export function SummaryExplorerPage() {
return <SummaryAnalysisPanel scope="global" />;
}
function SummaryRow({
summary,
isExpanded,
onToggle,
}: {
summary: FuncSummaryView;
isExpanded: boolean;
onToggle: () => void;
}) {
return (
<>
<tr onClick={onToggle} style={{ cursor: 'pointer' }}>
<td className="mono">{summary.name}</td>
<td>{summary.lang}</td>
<td>{summary.param_count}</td>
<td>
{summary.source_caps.map((c, i) => (
<span key={i} className="cap-badge cap-badge-source">
{c}
</span>
))}
</td>
<td>
{summary.sanitizer_caps.map((c, i) => (
<span key={i} className="cap-badge cap-badge-sanitizer">
{c}
</span>
))}
</td>
<td>
{summary.sink_caps.map((c, i) => (
<span key={i} className="cap-badge cap-badge-sink">
{c}
</span>
))}
</td>
<td>{summary.propagates_taint ? 'Yes' : 'No'}</td>
</tr>
{isExpanded && (
<tr>
<td colSpan={7}>
<div className="summary-detail">
<div className="debug-detail-row">
<span className="debug-detail-label">File</span>
<span className="debug-detail-value mono">
{summary.file_path}
</span>
</div>
{summary.propagating_params.length > 0 && (
<div className="debug-detail-row">
<span className="debug-detail-label">Propagating params</span>
<span className="debug-detail-value">
{summary.propagating_params.join(', ')}
</span>
</div>
)}
{summary.tainted_sink_params.length > 0 && (
<div className="debug-detail-row">
<span className="debug-detail-label">Sink params</span>
<span className="debug-detail-value">
{summary.tainted_sink_params.join(', ')}
</span>
</div>
)}
{summary.callees.length > 0 && (
<div className="debug-detail-row">
<span className="debug-detail-label">Callees</span>
<span className="debug-detail-value mono">
{summary.callees.join(', ')}
</span>
</div>
)}
{summary.ssa_summary && (
<div className="summary-ssa-detail">
<h4>SSA Summary</h4>
{summary.ssa_summary.source_caps.length > 0 && (
<div className="debug-detail-row">
<span className="debug-detail-label">Source caps</span>
<span>
{summary.ssa_summary.source_caps.map((c, i) => (
<span key={i} className="cap-badge cap-badge-source">
{c}
</span>
))}
</span>
</div>
)}
{summary.ssa_summary.param_to_return.length > 0 && (
<div className="debug-detail-row">
<span className="debug-detail-label">
Param-to-return
</span>
<span>
{summary.ssa_summary.param_to_return.map((p, i) => (
<span key={i} className="mono">
p{p.param_index} {p.transform}
{i < summary.ssa_summary!.param_to_return.length - 1
? ', '
: ''}
</span>
))}
</span>
</div>
)}
{summary.ssa_summary.param_to_sink.length > 0 && (
<div className="debug-detail-row">
<span className="debug-detail-label">Param-to-sink</span>
<span>
{summary.ssa_summary.param_to_sink.map((p, i) => (
<span key={i}>
p{p.param_index} {' '}
{p.sink_caps.map((c, j) => (
<span
key={j}
className="cap-badge cap-badge-sink"
>
{c}
</span>
))}
</span>
))}
</span>
</div>
)}
</div>
)}
</div>
</td>
</tr>
)}
</>
);
}

View file

@ -0,0 +1,90 @@
import { useDebugSymex } from '../../api/queries/debug';
import { ApiError } from '../../api/client';
import { EmptyState } from '../../components/ui/EmptyState';
import { ErrorState } from '../../components/ui/ErrorState';
import { LoadingState } from '../../components/ui/LoadingState';
interface SymexAnalysisPanelProps {
file: string;
functionName: string;
}
export function SymexAnalysisPanel({
file,
functionName,
}: SymexAnalysisPanelProps) {
const { data, isLoading, error } = useDebugSymex(file, functionName);
if (isLoading) {
return <LoadingState message="Loading symbolic execution..." />;
}
if (error) {
if (error instanceof ApiError && error.status === 404) {
return (
<EmptyState message="Symbolic execution data is not available for the selected function." />
);
}
return <ErrorState message="Failed to load symbolic execution." />;
}
if (!data) {
return (
<EmptyState message="No symbolic execution data is available for this function." />
);
}
return (
<div className="symex-viewer">
{data.tainted_roots.length > 0 && (
<div className="symex-section">
<h3>Tainted Roots</h3>
<div className="symex-roots">
{data.tainted_roots.map((r) => (
<span key={r} className="cap-badge cap-badge-source">
v{r}
</span>
))}
</div>
</div>
)}
{data.path_constraints.length > 0 && (
<div className="symex-section">
<h3>Path Constraints</h3>
{data.path_constraints.map((pc, i) => (
<div key={i} className="symex-constraint">
<span className="text-secondary">B{pc.block}</span>
<span
className={`symex-polarity ${pc.polarity ? 'symex-true' : 'symex-false'}`}
>
{pc.polarity ? 'TRUE' : 'FALSE'}
</span>
<span className="mono">{pc.condition}</span>
</div>
))}
</div>
)}
<div className="symex-section">
<h3>Symbolic Values ({data.values.length})</h3>
<table className="symex-table">
<thead>
<tr>
<th>Value</th>
<th>Name</th>
<th>Expression</th>
</tr>
</thead>
<tbody>
{data.values.map((v) => (
<tr key={v.ssa_value}>
<td className="mono">v{v.ssa_value}</td>
<td className="mono">{v.var_name ?? '-'}</td>
<td className="mono">{v.expression}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -0,0 +1,126 @@
import { useDebugTaint } from '../../api/queries/debug';
import { ApiError } from '../../api/client';
import { EmptyState } from '../../components/ui/EmptyState';
import { ErrorState } from '../../components/ui/ErrorState';
import { LoadingState } from '../../components/ui/LoadingState';
import type {
TaintBlockStateView,
TaintEventView,
TaintValueView,
} from '../../api/types';
interface TaintAnalysisPanelProps {
file: string;
functionName: string;
}
export function TaintAnalysisPanel({
file,
functionName,
}: TaintAnalysisPanelProps) {
const { data, isLoading, error } = useDebugTaint(file, functionName);
if (isLoading) {
return <LoadingState message="Loading taint analysis..." />;
}
if (error) {
if (error instanceof ApiError && error.status === 404) {
return (
<EmptyState message="Taint analysis is not available for the selected function." />
);
}
return <ErrorState message="Failed to load taint analysis." />;
}
if (!data) {
return (
<EmptyState message="No taint analysis data is available for this function." />
);
}
return (
<div className="taint-viewer">
{data.events.length > 0 && (
<div className="taint-events-section">
<h3>Sink Events ({data.events.length})</h3>
{data.events.map((e, i) => (
<TaintEvent key={i} event={e} />
))}
</div>
)}
<div className="taint-blocks-section">
<h3>Per-Block Taint State</h3>
{data.block_states.map((bs) => (
<TaintBlockState key={bs.block_id} state={bs} />
))}
</div>
</div>
);
}
function TaintEvent({ event }: { event: TaintEventView }) {
return (
<div
className={`taint-event${event.all_validated ? ' taint-event-validated' : ''}`}
>
<div className="taint-event-header">
<span>Sink node #{event.sink_node}</span>
{event.all_validated && (
<span className="badge-success">validated</span>
)}
{event.uses_summary && <span className="badge-info">via summary</span>}
</div>
<div className="taint-event-caps">
Sink caps:{' '}
{event.sink_caps.map((c, i) => (
<span key={i} className="cap-badge cap-badge-sink">
{c}
</span>
))}
</div>
<div className="taint-event-values">
{event.tainted_values.map((v, i) => (
<TaintValue key={i} value={v} />
))}
</div>
</div>
);
}
function TaintBlockState({ state }: { state: TaintBlockStateView }) {
if (state.values.length === 0) return null;
return (
<div className="taint-block-state">
<div className="taint-block-state-header">
<span className="ssa-block-id">B{state.block_id}</span>
<span className="text-secondary">
{state.values.length} tainted values
</span>
</div>
<div className="taint-block-state-values">
{state.values.map((v, i) => (
<TaintValue key={i} value={v} />
))}
</div>
</div>
);
}
function TaintValue({ value }: { value: TaintValueView }) {
return (
<div className="taint-value">
<span className="taint-value-id">v{value.ssa_value}</span>
{value.var_name && (
<span className="taint-value-name">{value.var_name}</span>
)}
<span className="taint-value-caps">
{value.caps.map((c, i) => (
<span key={i} className="cap-badge cap-badge-source">
{c}
</span>
))}
</span>
{value.uses_summary && <span className="badge-info">summary</span>}
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,38 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { apiPost } from '../../api/client';
describe('api client CSRF handling', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('fetches the session token and sends it on mutating requests', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce({
ok: true,
text: async () => JSON.stringify({ csrf_token: 'token-123' }),
})
.mockResolvedValueOnce({
ok: true,
text: async () => JSON.stringify({ status: 'ok' }),
});
vi.stubGlobal('fetch', fetchMock);
const result = await apiPost<{ status: string }>('/triage/export');
expect(result.status).toBe('ok');
expect(fetchMock).toHaveBeenNthCalledWith(1, '/api/session');
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'/api/triage/export',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'X-Nyx-CSRF': 'token-123',
}),
}),
);
});
});

View file

@ -0,0 +1,95 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { Pagination } from '@/components/ui/Pagination';
describe('Pagination', () => {
const defaultProps = {
page: 1,
perPage: 50,
total: 200,
onPageChange: vi.fn(),
};
it('renders page info text', () => {
render(<Pagination {...defaultProps} />);
expect(screen.getByText('Page 1 of 4')).toBeInTheDocument();
});
it('renders total count', () => {
render(<Pagination {...defaultProps} />);
expect(screen.getByText('200 total')).toBeInTheDocument();
});
it('disables First and Prev buttons on first page', () => {
render(<Pagination {...defaultProps} page={1} />);
expect(screen.getByRole('button', { name: 'First' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Prev' })).toBeDisabled();
});
it('disables Next and Last buttons on last page', () => {
render(<Pagination {...defaultProps} page={4} />);
expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Last' })).toBeDisabled();
});
it('enables all nav buttons on a middle page', () => {
render(<Pagination {...defaultProps} page={2} />);
expect(screen.getByRole('button', { name: 'First' })).not.toBeDisabled();
expect(screen.getByRole('button', { name: 'Prev' })).not.toBeDisabled();
expect(screen.getByRole('button', { name: 'Next' })).not.toBeDisabled();
expect(screen.getByRole('button', { name: 'Last' })).not.toBeDisabled();
});
it('calls onPageChange(1) when First is clicked', () => {
const onPageChange = vi.fn();
render(
<Pagination {...defaultProps} page={3} onPageChange={onPageChange} />,
);
fireEvent.click(screen.getByRole('button', { name: 'First' }));
expect(onPageChange).toHaveBeenCalledWith(1);
});
it('calls onPageChange with previous page when Prev is clicked', () => {
const onPageChange = vi.fn();
render(
<Pagination {...defaultProps} page={3} onPageChange={onPageChange} />,
);
fireEvent.click(screen.getByRole('button', { name: 'Prev' }));
expect(onPageChange).toHaveBeenCalledWith(2);
});
it('calls onPageChange with next page when Next is clicked', () => {
const onPageChange = vi.fn();
render(
<Pagination {...defaultProps} page={2} onPageChange={onPageChange} />,
);
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
expect(onPageChange).toHaveBeenCalledWith(3);
});
it('calls onPageChange with last page when Last is clicked', () => {
const onPageChange = vi.fn();
render(
<Pagination {...defaultProps} page={2} onPageChange={onPageChange} />,
);
fireEvent.click(screen.getByRole('button', { name: 'Last' }));
expect(onPageChange).toHaveBeenCalledWith(4);
});
it('calls onPerPageChange when per-page select changes', () => {
const onPerPageChange = vi.fn();
render(<Pagination {...defaultProps} onPerPageChange={onPerPageChange} />);
fireEvent.change(screen.getByRole('combobox'), { target: { value: '25' } });
expect(onPerPageChange).toHaveBeenCalledWith(25);
});
it('handles zero total gracefully (shows 1 of 1)', () => {
render(<Pagination {...defaultProps} total={0} />);
expect(screen.getByText('Page 1 of 1')).toBeInTheDocument();
});
it('shows 1 page total for total less than perPage', () => {
render(<Pagination {...defaultProps} total={10} perPage={50} />);
expect(screen.getByText('Page 1 of 1')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,65 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { StatCard } from '@/components/ui/StatCard';
describe('StatCard', () => {
it('renders the label', () => {
render(<StatCard label="Total Findings" value={42} />);
expect(screen.getByText('Total Findings')).toBeInTheDocument();
});
it('renders a numeric value', () => {
render(<StatCard label="Count" value={100} />);
expect(screen.getByText('100')).toBeInTheDocument();
});
it('renders a string value', () => {
render(<StatCard label="Status" value="active" />);
expect(screen.getByText('active')).toBeInTheDocument();
});
it('renders a subtitle when provided', () => {
render(<StatCard label="Scans" value={5} subtitle="last 7 days" />);
expect(screen.getByText('last 7 days')).toBeInTheDocument();
});
it('does not render a subtitle element when omitted', () => {
render(<StatCard label="Scans" value={5} />);
expect(screen.queryByText('last 7 days')).not.toBeInTheDocument();
});
it('applies the color style when provided', () => {
render(<StatCard label="Critical" value={3} color="#ef4444" />);
const valueEl = screen.getByText('3');
expect(valueEl).toHaveStyle({ color: '#ef4444' });
});
it('shows an up arrow delta for positive values', () => {
render(<StatCard label="New" value={10} delta={3} />);
// ▲ character followed by the number
expect(screen.getByText(/▲/)).toBeInTheDocument();
expect(screen.getByText(/3/)).toBeInTheDocument();
});
it('shows a down arrow delta for negative values', () => {
render(<StatCard label="Resolved" value={8} delta={-2} />);
expect(screen.getByText(/▼/)).toBeInTheDocument();
});
it('does not render a delta when delta is 0', () => {
const { container } = render(<StatCard label="Same" value={5} delta={0} />);
expect(container.querySelector('.stat-delta')).not.toBeInTheDocument();
});
it('does not render a delta when delta is null', () => {
const { container } = render(
<StatCard label="Same" value={5} delta={null} />,
);
expect(container.querySelector('.stat-delta')).not.toBeInTheDocument();
});
it('does not render a delta when delta is omitted', () => {
const { container } = render(<StatCard label="Same" value={5} />);
expect(container.querySelector('.stat-delta')).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { EmptyState } from '@/components/ui/EmptyState';
import { ErrorState } from '@/components/ui/ErrorState';
import { LoadingState } from '@/components/ui/LoadingState';
describe('EmptyState', () => {
it('renders a message when provided', () => {
render(<EmptyState message="Nothing here" />);
expect(screen.getByText('Nothing here')).toBeInTheDocument();
});
it('renders children when provided', () => {
render(
<EmptyState>
<button>Add item</button>
</EmptyState>,
);
expect(
screen.getByRole('button', { name: 'Add item' }),
).toBeInTheDocument();
});
it('renders an icon when provided', () => {
render(<EmptyState icon={<span data-testid="icon" />} />);
expect(screen.getByTestId('icon')).toBeInTheDocument();
});
it('renders nothing extra when no props are given', () => {
const { container } = render(<EmptyState />);
const root = container.firstChild as HTMLElement;
// Only the wrapper div should exist with no visible content
expect(root.childElementCount).toBe(0);
});
});
describe('ErrorState', () => {
it('renders the error message', () => {
render(<ErrorState message="Something went wrong" />);
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
});
it('renders the default title "Error"', () => {
render(<ErrorState message="Oops" />);
expect(screen.getByRole('heading', { name: 'Error' })).toBeInTheDocument();
});
it('renders a custom title when provided', () => {
render(<ErrorState title="Network Error" message="Timeout" />);
expect(
screen.getByRole('heading', { name: 'Network Error' }),
).toBeInTheDocument();
});
});
describe('LoadingState', () => {
it('renders the default "Loading..." message', () => {
render(<LoadingState />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('renders a custom message when provided', () => {
render(<LoadingState message="Fetching data…" />);
expect(screen.getByText('Fetching data…')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest';
import { adaptCfgGraph, normalizeCfgEdges } from '@/graph/adapters/cfg';
import type { CfgGraphView } from '@/api/types';
describe('normalizeCfgEdges', () => {
it('prefers branch edges over duplicate sequential edges', () => {
expect(
normalizeCfgEdges([
{ source: 10, target: 11, kind: 'Seq' },
{ source: 10, target: 11, kind: 'True' },
{ source: 10, target: 12, kind: 'Seq' },
{ source: 10, target: 12, kind: 'False' },
]),
).toEqual([
{ source: 10, target: 11, kind: 'True' },
{ source: 10, target: 12, kind: 'False' },
]);
});
it('keeps non-duplicate edges intact', () => {
expect(
normalizeCfgEdges([
{ source: 1, target: 2, kind: 'Seq' },
{ source: 2, target: 3, kind: 'Back' },
]),
).toEqual([
{ source: 1, target: 2, kind: 'Seq' },
{ source: 2, target: 3, kind: 'Back' },
]);
});
});
describe('adaptCfgGraph', () => {
it('does not emit duplicate rendered edges for the same branch target', () => {
const graph: CfgGraphView = {
entry: 1,
nodes: [
{
id: 1,
kind: 'If',
span: [0, 0],
line: 20,
uses: [],
labels: [],
condition_text: 'flag',
},
{
id: 2,
kind: 'Seq',
span: [0, 0],
line: 21,
uses: [],
labels: [],
},
],
edges: [
{ source: 1, target: 2, kind: 'Seq' },
{ source: 1, target: 2, kind: 'True' },
],
};
const adapted = adaptCfgGraph(graph);
expect(adapted.edges).toHaveLength(1);
expect(adapted.edges[0]?.kind).toBe('True');
});
it('prefers concise CFG labels over enormous rhs expressions', () => {
const graph: CfgGraphView = {
entry: 1,
nodes: [
{
id: 1,
kind: 'Seq',
span: [0, 0],
line: 10,
defines: 'el.innerHTML',
callee:
'el.innerHTML = `<div style="padding:60px 0;"> giant html blob giant html blob giant html blob</div>`',
uses: [],
labels: [],
},
],
edges: [],
};
const adapted = adaptCfgGraph(graph);
expect(adapted.nodes[0]?.label).toBe('Seq: el.innerHTML');
});
});

View file

@ -0,0 +1,139 @@
import { describe, it, expect } from 'vitest';
import { compactGraph } from '@/graph/reduction/cfgCompaction';
import type { GraphEdge, GraphNode } from '@/graph/types';
function makeNode(id: number, type = 'Stmt'): GraphNode {
return {
key: String(id),
rawId: id,
label: `Node ${id}`,
kind: type,
};
}
function seqEdge(source: number, target: number): GraphEdge {
return {
key: `seq:${source}:${target}`,
source: String(source),
target: String(target),
kind: 'Seq',
};
}
describe('compactGraph', () => {
it('returns the graph unchanged when there are 3 or fewer nodes', () => {
const nodes = [makeNode(1), makeNode(2), makeNode(3)];
const edges = [seqEdge(1, 2), seqEdge(2, 3)];
const result = compactGraph({ kind: 'cfg', nodes, edges });
expect(result.graph.nodes).toEqual(nodes);
expect(result.graph.edges).toEqual(edges);
expect(result.compounds.size).toBe(0);
});
it('returns unchanged graph when no chainable sequences exist', () => {
// All nodes are control-flow types nothing to compact
const nodes = [
makeNode(1, 'Entry'),
makeNode(2, 'If'),
makeNode(3, 'Return'),
makeNode(4, 'Exit'),
];
const edges = [seqEdge(1, 2), seqEdge(2, 3), seqEdge(3, 4)];
const result = compactGraph({ kind: 'cfg', nodes, edges });
expect(result.graph.nodes.length).toBe(4);
expect(result.compounds.size).toBe(0);
});
it('collapses a straight-line sequence of stmt nodes', () => {
// Entry -> Stmt2 -> Stmt3 -> Stmt4 -> Exit
// Stmt2/3/4 are all chainable (1 in / 1 out each)
const nodes = [
makeNode(1, 'Entry'),
makeNode(2, 'Stmt'),
makeNode(3, 'Stmt'),
makeNode(4, 'Stmt'),
makeNode(5, 'Exit'),
];
const edges = [seqEdge(1, 2), seqEdge(2, 3), seqEdge(3, 4), seqEdge(4, 5)];
const result = compactGraph({ kind: 'cfg', nodes, edges });
// The three stmts should be collapsed into one compound node
const compound = result.graph.nodes.find((n) => n.kind === 'Compound');
expect(compound).toBeDefined();
expect(compound?.label).toMatch(/statements/);
// Entry and Exit should still be present
expect(result.graph.nodes.some((n) => n.kind === 'Entry')).toBe(true);
expect(result.graph.nodes.some((n) => n.kind === 'Exit')).toBe(true);
});
it('records the compacted node ids in expandedIds', () => {
const nodes = [
makeNode(1, 'Entry'),
makeNode(2, 'Stmt'),
makeNode(3, 'Stmt'),
makeNode(4, 'Stmt'),
makeNode(5, 'Exit'),
];
const edges = [seqEdge(1, 2), seqEdge(2, 3), seqEdge(3, 4), seqEdge(4, 5)];
const result = compactGraph({ kind: 'cfg', nodes, edges });
expect(result.compounds.size).toBe(1);
const [, origIds] = [...result.compounds.entries()][0];
expect(origIds).toContain('2');
expect(origIds).toContain('3');
expect(origIds).toContain('4');
});
it('does not collapse control-flow node types', () => {
const nodes = [
makeNode(1, 'Entry'),
makeNode(2, 'If'),
makeNode(3, 'Stmt'),
makeNode(4, 'Stmt'),
makeNode(5, 'Exit'),
];
const edges = [
seqEdge(1, 2),
{
key: 'true:2:3',
source: '2',
target: '3',
kind: 'True',
} as GraphEdge,
seqEdge(3, 4),
seqEdge(4, 5),
];
const result = compactGraph({ kind: 'cfg', nodes, edges });
// If node should remain
expect(result.graph.nodes.some((n) => n.kind === 'If')).toBe(true);
});
it('returns unchanged graph when no chains have length >= 2', () => {
// A single stmt between two non-chainable nodes chain length 1, not compacted
const nodes = [
makeNode(1, 'Entry'),
makeNode(2, 'Stmt'),
makeNode(3, 'Exit'),
];
const edges = [seqEdge(1, 2), seqEdge(2, 3)];
// Only 3 nodes, so early return applies anyway
const result = compactGraph({ kind: 'cfg', nodes, edges });
expect(result.compounds.size).toBe(0);
});
it('computes a line range label when nodes have line numbers', () => {
const nodes = [
makeNode(1, 'Entry'),
{ ...makeNode(2, 'Stmt'), line: 10 },
{ ...makeNode(3, 'Stmt'), line: 11 },
{ ...makeNode(4, 'Stmt'), line: 12 },
makeNode(5, 'Exit'),
];
const edges = [seqEdge(1, 2), seqEdge(2, 3), seqEdge(3, 4), seqEdge(4, 5)];
const result = compactGraph({ kind: 'cfg', nodes, edges });
const compound = result.graph.nodes.find((n) => n.kind === 'Compound');
expect(compound?.detail).toMatch(/L10/);
expect(compound?.detail).toMatch(/L12/);
});
});

View file

@ -0,0 +1,93 @@
import { describe, it, expect } from 'vitest';
import { getNodeStyle, getEdgeStyle } from '@/graph/styles';
describe('getNodeStyle', () => {
it('returns a style for Entry nodes', () => {
const s = getNodeStyle('Entry');
expect(s.fill).toBe('#2ecc71');
expect(s.shape).toBe('double');
});
it('returns a style for Exit nodes', () => {
const s = getNodeStyle('Exit');
expect(s.shape).toBe('double');
});
it('returns a style for If nodes', () => {
const s = getNodeStyle('If');
expect(s.shape).toBe('rect');
expect(s.textFill).toBe('#ffffff');
});
it('returns a style for Loop nodes', () => {
const s = getNodeStyle('Loop');
expect(s.shape).toBe('rect');
});
it('returns a style for Call nodes', () => {
const s = getNodeStyle('Call');
expect(s.shape).toBe('rect');
});
it('returns a terminal shape for Return nodes', () => {
const s = getNodeStyle('Return');
expect(s.shape).toBe('terminal');
});
it('returns the default style for unknown node types', () => {
const s = getNodeStyle('Unknown');
expect(s.fill).toContain('rgba');
expect(s.shape).toBe('rect');
});
it('default style has correct text color', () => {
const s = getNodeStyle('Stmt');
expect(s.textFill).toBe('#ffffff');
});
it('returns a specialized style for recursive call graph nodes', () => {
const s = getNodeStyle('Call', 'callgraph', { isRecursive: true });
expect(s.fill).toBe('#7d6450');
});
});
describe('getEdgeStyle', () => {
it('returns green color for True edges', () => {
const s = getEdgeStyle('True');
expect(s.color).toBe('#2ecc71');
expect(s.dash).toEqual([]);
});
it('returns red color for False edges', () => {
const s = getEdgeStyle('False');
expect(s.color).toBe('#e74c3c');
expect(s.dash).toEqual([]);
});
it('returns dashed style for Back edges', () => {
const s = getEdgeStyle('Back');
expect(s.color).toBe('#4f78c2');
expect(s.dash).toEqual([7, 4]);
});
it('returns dashed style for Exception edges', () => {
const s = getEdgeStyle('Exception');
expect(s.dash).toEqual([3, 3]);
});
it('returns default style for Seq edges', () => {
const s = getEdgeStyle('Seq');
expect(s.color).toContain('rgba');
expect(s.dash).toEqual([]);
});
it('returns default style for unknown edge types', () => {
const s = getEdgeStyle('Whatever');
expect(s.color).toContain('rgba');
});
it('returns neutral call graph edges', () => {
const s = getEdgeStyle('Call', 'callgraph');
expect(s.dash).toEqual([]);
});
});

View file

@ -0,0 +1,85 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useDebounce } from '../../hooks/useDebounce';
describe('useDebounce', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('returns the initial value immediately', () => {
const { result } = renderHook(() => useDebounce('hello', 300));
expect(result.current).toBe('hello');
});
it('does not update the value before the delay has elapsed', () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: 'hello', delay: 300 } },
);
rerender({ value: 'world', delay: 300 });
// Still the old value before delay
expect(result.current).toBe('hello');
});
it('updates the value after the delay has elapsed', () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: 'hello', delay: 300 } },
);
rerender({ value: 'world', delay: 300 });
act(() => {
vi.advanceTimersByTime(300);
});
expect(result.current).toBe('world');
});
it('resets the timer when the value changes quickly', () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: 'a', delay: 300 } },
);
rerender({ value: 'ab', delay: 300 });
act(() => vi.advanceTimersByTime(100));
rerender({ value: 'abc', delay: 300 });
act(() => vi.advanceTimersByTime(100));
// Only 200ms elapsed since last change, not yet debounced
expect(result.current).toBe('a');
act(() => vi.advanceTimersByTime(300));
expect(result.current).toBe('abc');
});
it('works with numeric values', () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: 0, delay: 200 } },
);
rerender({ value: 42, delay: 200 });
act(() => vi.advanceTimersByTime(200));
expect(result.current).toBe(42);
});
it('works with object values (referential update)', () => {
const initial = { x: 1 };
const updated = { x: 2 };
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: initial, delay: 100 } },
);
rerender({ value: updated, delay: 100 });
act(() => vi.advanceTimersByTime(100));
expect(result.current).toBe(updated);
});
});

View file

@ -0,0 +1 @@
import '@testing-library/jest-dom';

View file

@ -0,0 +1,188 @@
import { describe, it, expect } from 'vitest';
import {
findingToMarkdown,
findingsToMarkdown,
} from '../../utils/findingMarkdown';
import type { FindingView } from '../../api/types';
const lean: FindingView = {
index: 0,
fingerprint: 'fp-lean',
path: 'src/a.js',
line: 10,
col: 2,
severity: 'High',
rule_id: 'js-xss',
category: 'xss',
labels: [],
path_validated: false,
suppressed: false,
language: 'javascript',
status: 'new',
triage_state: 'open',
related_findings: [],
};
const full: FindingView = {
index: 42,
fingerprint: 'fp-full-abc',
path: 'src/handlers/login.py',
line: 141,
col: 10,
severity: 'High',
rule_id: 'py-sqli',
category: 'sqli',
confidence: 'High',
rank_score: 8.7,
message: 'User input flows into SQL query.\nReview the construction.',
labels: [
['source', 'request'],
['sink', 'cursor.execute'],
],
path_validated: false,
suppressed: false,
language: 'python',
status: 'new',
triage_state: 'investigating',
triage_note: 'Looks real — assigned to alice.',
code_context: {
start_line: 138,
highlight_line: 141,
lines: [
'name = request.args.get("name")',
'',
'query_name = name.strip()',
'cursor.execute(f"SELECT * FROM users WHERE name = \'{name}\'")',
],
},
evidence: {
source: {
path: 'src/handlers/login.py',
line: 138,
col: 7,
kind: 'UserInput',
snippet: 'request.args.get("name")',
},
sink: {
path: 'src/handlers/login.py',
line: 141,
col: 10,
kind: 'SqlQuery',
snippet: 'cursor.execute(...)',
},
guards: [],
sanitizers: [],
notes: ['source_kind:UserInput', 'hop_count:3'],
flow_steps: [
{
step: 1,
kind: 'source',
file: 'src/handlers/login.py',
line: 138,
col: 7,
snippet: 'request.args.get("name")',
variable: 'name',
},
{
step: 2,
kind: 'assignment',
file: 'src/handlers/login.py',
line: 140,
col: 4,
variable: 'query_name',
},
{
step: 3,
kind: 'sink',
file: 'src/handlers/login.py',
line: 141,
col: 10,
callee: 'cursor.execute',
is_cross_file: true,
},
],
explanation: 'Untrusted input reaches a SQL sink without sanitization.',
confidence_limiters: [],
},
rank_reason: [['source_kind', 'direct user input']],
sanitizer_status: 'none',
related_findings: [
{
index: 99,
rule_id: 'py-xss',
path: 'src/handlers/login.py',
line: 160,
severity: 'Medium',
},
],
};
describe('findingToMarkdown', () => {
it('renders the full finding with all sections', () => {
const md = findingToMarkdown(full);
expect(md).toContain('## py-sqli — User input flows into SQL query.');
expect(md).toContain('- **Rule**: `py-sqli` (category: `sqli`)');
expect(md).toContain('- **Severity**: High | **Confidence**: High');
expect(md).toContain('- **Location**: `src/handlers/login.py:141:10`');
expect(md).toContain('- **Fingerprint**: `fp-full-abc`');
expect(md).toContain('- **Sanitizer status**: none');
expect(md).toContain('### Message\nUser input flows into SQL query.');
expect(md).toContain('### Explanation\nUntrusted input reaches');
expect(md).toContain('### Evidence');
expect(md).toContain(
'**Source** — `src/handlers/login.py:138:7` (kind: UserInput)',
);
expect(md).toContain('```python\nrequest.args.get("name")\n```');
expect(md).toContain('**Guards**: none');
expect(md).toContain('**Sanitizers**: none');
expect(md).toContain('### Flow (3 steps)');
expect(md).toContain('[cross-file]');
expect(md).toContain(
'### Code context (lines 138141, highlight line 141)',
);
expect(md).toContain('141> ');
expect(md).toContain('### Labels');
expect(md).toContain('- `source`: `request`');
expect(md).toContain('### Notes');
expect(md).toContain('- Source type: User Input');
expect(md).toContain('- Path length: 3 blocks');
expect(md).toContain('### Triage note\nLooks real — assigned to alice.');
expect(md).toContain('### Confidence reasoning');
expect(md).toContain('Score: 8.7');
expect(md).toContain('- **source_kind**: direct user input');
expect(md).toContain('### Related findings');
expect(md).toContain(
'- `#99` `py-xss` — `src/handlers/login.py:160` (Medium)',
);
});
it('skips optional sections for a lean finding', () => {
const md = findingToMarkdown(lean);
expect(md).toContain('## js-xss — xss');
expect(md).toContain('**Confidence**: unknown');
expect(md).not.toContain('### Message');
expect(md).not.toContain('### Evidence');
expect(md).not.toContain('### Flow');
expect(md).not.toContain('### Code context');
expect(md).not.toContain('### Labels');
expect(md).not.toContain('### Notes');
expect(md).not.toContain('### Related findings');
expect(md).not.toContain('### Triage note');
expect(md).not.toContain('### Confidence reasoning');
});
});
describe('findingsToMarkdown', () => {
it('bundles multiple findings with a count header and separator', () => {
const md = findingsToMarkdown([full, lean]);
expect(md.startsWith('# Nyx findings (2)')).toBe(true);
expect(md).toContain('\n\n---\n\n');
expect(md).toContain('## py-sqli');
expect(md).toContain('## js-xss');
});
it('handles empty selection gracefully', () => {
const md = findingsToMarkdown([]);
expect(md).toBe('# Nyx findings (0)\n\n(none)');
});
});

View file

@ -0,0 +1,136 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { formatShortDate, relTime } from '../../utils/formatDate';
describe('formatShortDate', () => {
it('returns empty string for null', () => {
expect(formatShortDate(null)).toBe('');
});
it('returns empty string for undefined', () => {
expect(formatShortDate(undefined)).toBe('');
});
it('returns empty string for empty string', () => {
expect(formatShortDate('')).toBe('');
});
it('formats a valid ISO date string with M/D H:MM pattern', () => {
const result = formatShortDate('2024-06-15T14:05:00.000Z');
expect(result).toMatch(/^\d+\/\d+ \d+:\d{2}$/);
});
it('zero-pads minutes to two digits', () => {
const d = new Date(2024, 0, 1, 10, 5, 0);
const result = formatShortDate(d.toISOString());
expect(result).toMatch(/:05$/);
});
it('does not zero-pad double-digit minutes', () => {
const d = new Date(2024, 0, 1, 10, 30, 0);
const result = formatShortDate(d.toISOString());
expect(result).toMatch(/:30$/);
});
});
describe('relTime', () => {
let now: number;
beforeEach(() => {
now = Date.now();
vi.useFakeTimers();
vi.setSystemTime(now);
});
afterEach(() => {
vi.useRealTimers();
});
it('returns empty string for null', () => {
expect(relTime(null)).toBe('');
});
it('returns empty string for undefined', () => {
expect(relTime(undefined)).toBe('');
});
it('returns empty string for empty string', () => {
expect(relTime('')).toBe('');
});
it('returns "just now" for a future date', () => {
const future = new Date(now + 5000).toISOString();
expect(relTime(future)).toBe('just now');
});
it('returns "just now" for 0 seconds ago', () => {
expect(relTime(new Date(now).toISOString())).toBe('just now');
});
it('returns "just now" for 1 second ago', () => {
expect(relTime(new Date(now - 1000).toISOString())).toBe('just now');
});
it('returns "Xs ago" for less than 60 seconds', () => {
expect(relTime(new Date(now - 30 * 1000).toISOString())).toBe('30s ago');
});
it('returns "1 minute ago" for exactly 60 seconds', () => {
expect(relTime(new Date(now - 60 * 1000).toISOString())).toBe(
'1 minute ago',
);
});
it('returns "X minutes ago" for less than 60 minutes', () => {
expect(relTime(new Date(now - 5 * 60 * 1000).toISOString())).toBe(
'5 minutes ago',
);
});
it('returns "1 hour ago" for exactly 1 hour', () => {
expect(relTime(new Date(now - 60 * 60 * 1000).toISOString())).toBe(
'1 hour ago',
);
});
it('returns "X hours ago" for less than 24 hours', () => {
expect(relTime(new Date(now - 5 * 60 * 60 * 1000).toISOString())).toBe(
'5 hours ago',
);
});
it('returns "1 day ago" for exactly 1 day', () => {
expect(relTime(new Date(now - 24 * 60 * 60 * 1000).toISOString())).toBe(
'1 day ago',
);
});
it('returns "X days ago" for less than 30 days', () => {
expect(
relTime(new Date(now - 10 * 24 * 60 * 60 * 1000).toISOString()),
).toBe('10 days ago');
});
it('returns "1 month ago" for ~30 days', () => {
expect(
relTime(new Date(now - 30 * 24 * 60 * 60 * 1000).toISOString()),
).toBe('1 month ago');
});
it('returns "X months ago" for less than 12 months', () => {
expect(
relTime(new Date(now - 6 * 30 * 24 * 60 * 60 * 1000).toISOString()),
).toBe('6 months ago');
});
it('returns "1 year ago" for ~12 months', () => {
expect(
relTime(new Date(now - 12 * 30 * 24 * 60 * 60 * 1000).toISOString()),
).toBe('1 year ago');
});
it('returns "X years ago" for multiple years', () => {
expect(
relTime(new Date(now - 2 * 12 * 30 * 24 * 60 * 60 * 1000).toISOString()),
).toBe('2 years ago');
});
});

View file

@ -0,0 +1,116 @@
import { describe, it, expect } from 'vitest';
import { escapeHtml, highlightSyntax } from '../../utils/syntaxHighlight';
describe('escapeHtml', () => {
it('escapes ampersands', () => {
expect(escapeHtml('a & b')).toBe('a &amp; b');
});
it('escapes less-than signs', () => {
expect(escapeHtml('<div>')).toBe('&lt;div&gt;');
});
it('escapes greater-than signs', () => {
expect(escapeHtml('1 > 0')).toBe('1 &gt; 0');
});
it('escapes double quotes', () => {
expect(escapeHtml('"hello"')).toBe('&quot;hello&quot;');
});
it('escapes all special chars together', () => {
expect(escapeHtml('<a href="x&y">z</a>')).toBe(
'&lt;a href=&quot;x&amp;y&quot;&gt;z&lt;/a&gt;',
);
});
it('returns plain text unchanged', () => {
expect(escapeHtml('hello world')).toBe('hello world');
});
it('returns empty string unchanged', () => {
expect(escapeHtml('')).toBe('');
});
});
describe('highlightSyntax', () => {
it('returns input unchanged for an unknown language', () => {
const code = 'const x = 1;';
expect(highlightSyntax(code, 'cobol')).toBe(code);
});
it('wraps JavaScript keywords in tok-keyword spans', () => {
const result = highlightSyntax('const x = 1;', 'javascript');
expect(result).toContain('<span class="tok-keyword">const</span>');
});
it('wraps string literals in tok-string spans', () => {
const result = highlightSyntax('"hello"', 'javascript');
expect(result).toContain('<span class="tok-string">"hello"</span>');
});
it('wraps numbers in tok-number spans', () => {
const result = highlightSyntax('42', 'javascript');
expect(result).toContain('<span class="tok-number">42</span>');
});
it('wraps line comments in tok-comment spans', () => {
const result = highlightSyntax('// a comment', 'javascript');
expect(result).toContain('<span class="tok-comment">// a comment</span>');
});
it('treats typescript as a javascript alias', () => {
const result = highlightSyntax('const x = 1;', 'typescript');
expect(result).toContain('<span class="tok-keyword">const</span>');
});
it('highlights Python keywords', () => {
const result = highlightSyntax('def foo():', 'python');
expect(result).toContain('<span class="tok-keyword">def</span>');
});
it('highlights Rust keywords', () => {
const result = highlightSyntax('fn main()', 'rust');
expect(result).toContain('<span class="tok-keyword">fn</span>');
});
it('highlights Go keywords', () => {
const result = highlightSyntax('func main()', 'go');
expect(result).toContain('<span class="tok-keyword">func</span>');
});
it('highlights Java keywords', () => {
const result = highlightSyntax('public class Foo', 'java');
expect(result).toContain('<span class="tok-keyword">public</span>');
});
it('highlights C keywords', () => {
const result = highlightSyntax('int main()', 'c');
expect(result).toContain('<span class="tok-keyword">int</span>');
});
it('treats c++ as a c alias', () => {
const result = highlightSyntax('int x = 0;', 'c++');
expect(result).toContain('<span class="tok-keyword">int</span>');
});
it('gives comments priority over keywords inside a comment', () => {
const code = '// const x = 1;';
const result = highlightSyntax(code, 'javascript');
// The whole line should be a comment span, not split into keyword spans
expect(result).toContain(
'<span class="tok-comment">// const x = 1;</span>',
);
expect(result).not.toContain('tok-keyword');
});
it('returns unchanged text when no tokens match', () => {
const code = 'hello world';
expect(highlightSyntax(code, 'python')).toBe('hello world');
});
it('skips regex highlighting for very long lines', () => {
const code = 'const ' + 'x'.repeat(25_000);
expect(highlightSyntax(code, 'javascript')).toBe(code);
});
});

View file

@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest';
import { truncPath } from '../../utils/truncPath';
describe('truncPath', () => {
it('returns empty string for null', () => {
expect(truncPath(null)).toBe('');
});
it('returns empty string for undefined', () => {
expect(truncPath(undefined)).toBe('');
});
it('returns path unchanged when shorter than maxLen', () => {
expect(truncPath('src/foo.ts')).toBe('src/foo.ts');
});
it('returns path unchanged when equal to maxLen', () => {
const p = 'a'.repeat(60);
expect(truncPath(p)).toBe(p);
});
it('truncates a long path with leading "..."', () => {
const p =
'/very/long/path/that/exceeds/the/default/max/length/limit/file.ts';
const result = truncPath(p);
expect(result.startsWith('...')).toBe(true);
expect(result.length).toBe(60);
});
it('keeps the tail of the path after truncation', () => {
const p =
'/very/long/path/that/exceeds/the/default/max/length/limit/file.ts';
const result = truncPath(p);
expect(result.endsWith('file.ts')).toBe(true);
});
it('respects a custom maxLen', () => {
const p = '/some/path/to/a/file.ts';
const result = truncPath(p, 10);
expect(result.length).toBe(10);
expect(result.startsWith('...')).toBe(true);
});
it('returns path unchanged when shorter than custom maxLen', () => {
expect(truncPath('short.ts', 20)).toBe('short.ts');
});
});

View file

@ -0,0 +1,205 @@
import type {
FindingView,
Evidence,
FlowStep,
SpanEvidence,
CodeContextView,
RelatedFindingView,
} from '../api/types';
import { parseNoteText } from './parseNote';
function firstLine(s: string): string {
const nl = s.indexOf('\n');
return nl === -1 ? s : s.slice(0, nl);
}
function fence(lang: string | undefined, body: string): string {
const hint = (lang || '').toLowerCase();
return `\`\`\`${hint}\n${body}\n\`\`\``;
}
function formatSpan(s: SpanEvidence, lang: string | undefined): string {
const header = `\`${s.path}:${s.line}:${s.col}\` (kind: ${s.kind})`;
if (!s.snippet) return header;
return `${header}\n${fence(lang, s.snippet)}`;
}
function formatEvidence(ev: Evidence, lang: string | undefined): string {
const parts: string[] = [];
if (ev.explanation) {
parts.push(`### Explanation\n${ev.explanation}`);
}
const hasSpans =
ev.source ||
ev.sink ||
(ev.guards && ev.guards.length > 0) ||
(ev.sanitizers && ev.sanitizers.length > 0) ||
ev.state;
if (hasSpans) {
const lines: string[] = ['### Evidence'];
if (ev.source) {
lines.push(`**Source** — ${formatSpan(ev.source, lang)}`);
}
if (ev.sink) {
lines.push(`**Sink** — ${formatSpan(ev.sink, lang)}`);
}
if (ev.source || ev.sink) {
if (!ev.guards || ev.guards.length === 0) {
lines.push(`**Guards**: none`);
} else {
lines.push(`**Guards**:`);
for (const g of ev.guards) {
lines.push(`- ${formatSpan(g, lang)}`);
}
}
if (!ev.sanitizers || ev.sanitizers.length === 0) {
lines.push(`**Sanitizers**: none`);
} else {
lines.push(`**Sanitizers**:`);
for (const s of ev.sanitizers) {
lines.push(`- ${formatSpan(s, lang)}`);
}
}
}
if (ev.state) {
const st = ev.state;
const subj = st.subject ? ` ${st.subject}:` : '';
lines.push(
`**State**: ${st.machine}${subj} ${st.from_state}${st.to_state}`,
);
}
parts.push(lines.join('\n'));
}
if (ev.confidence_limiters && ev.confidence_limiters.length > 0) {
const lines: string[] = ['**Confidence limiters**:'];
for (const l of ev.confidence_limiters) lines.push(`- ${l}`);
parts.push(lines.join('\n'));
}
return parts.join('\n\n');
}
function formatFlow(steps: FlowStep[]): string {
const lines: string[] = [`### Flow (${steps.length} steps)`];
for (const s of steps) {
const segs: string[] = [`${s.step}. **${s.kind}** \`${s.file}:${s.line}\``];
if (s.snippet) segs.push(`\`${s.snippet}\``);
if (s.variable) segs.push(`(var \`${s.variable}\`)`);
if (s.callee) segs.push(`(callee \`${s.callee}\`)`);
if (s.is_cross_file) segs.push(`[cross-file]`);
lines.push(segs.join(' '));
}
return lines.join('\n');
}
function formatCodeContext(
cc: CodeContextView,
lang: string | undefined,
): string {
const width = String(cc.start_line + cc.lines.length - 1).length;
const body = cc.lines
.map((line, i) => {
const ln = cc.start_line + i;
const marker = ln === cc.highlight_line ? '>' : ' ';
return `${String(ln).padStart(width, ' ')}${marker} ${line}`;
})
.join('\n');
return `### Code context (lines ${cc.start_line}${
cc.start_line + cc.lines.length - 1
}, highlight line ${cc.highlight_line})\n${fence(lang, body)}`;
}
function formatRelated(related: RelatedFindingView[]): string {
const lines: string[] = ['### Related findings'];
for (const r of related) {
lines.push(
`- \`#${r.index}\` \`${r.rule_id}\`\`${r.path}:${r.line}\` (${r.severity})`,
);
}
return lines.join('\n');
}
export function findingToMarkdown(f: FindingView): string {
const lang = f.language;
const heading = firstLine(f.message || '').trim() || f.category;
const parts: string[] = [];
parts.push(`## ${f.rule_id}${heading}`);
const meta: string[] = [];
meta.push(`- **Rule**: \`${f.rule_id}\` (category: \`${f.category}\`)`);
meta.push(
`- **Severity**: ${f.severity} | **Confidence**: ${f.confidence ?? 'unknown'}`,
);
meta.push(`- **Location**: \`${f.path}:${f.line}:${f.col}\``);
meta.push(`- **Language**: ${f.language ?? 'unknown'}`);
meta.push(
`- **Status**: ${f.status} | **Triage**: ${f.triage_state || 'open'}`,
);
meta.push(`- **Fingerprint**: \`${f.fingerprint}\``);
if (f.sanitizer_status) {
meta.push(`- **Sanitizer status**: ${f.sanitizer_status}`);
}
parts.push(meta.join('\n'));
if (f.message) {
parts.push(`### Message\n${f.message}`);
}
if (f.evidence) {
const ev = formatEvidence(f.evidence, lang);
if (ev) parts.push(ev);
if (f.evidence.flow_steps && f.evidence.flow_steps.length > 0) {
parts.push(formatFlow(f.evidence.flow_steps));
}
}
if (f.code_context) {
parts.push(formatCodeContext(f.code_context, lang));
}
if (f.labels && f.labels.length > 0) {
const lines: string[] = ['### Labels'];
for (const [k, v] of f.labels) lines.push(`- \`${k}\`: \`${v}\``);
parts.push(lines.join('\n'));
}
if (f.evidence?.notes && f.evidence.notes.length > 0) {
const lines: string[] = ['### Notes'];
for (const n of f.evidence.notes) lines.push(`- ${parseNoteText(n)}`);
parts.push(lines.join('\n'));
}
if (f.triage_note) {
parts.push(`### Triage note\n${f.triage_note}`);
}
if (
f.confidence &&
(f.rank_score != null || (f.rank_reason && f.rank_reason.length > 0))
) {
const lines: string[] = ['### Confidence reasoning'];
if (f.rank_score != null) lines.push(`Score: ${f.rank_score.toFixed(1)}`);
if (f.rank_reason && f.rank_reason.length > 0) {
for (const [k, v] of f.rank_reason) lines.push(`- **${k}**: ${v}`);
}
parts.push(lines.join('\n'));
}
if (f.related_findings && f.related_findings.length > 0) {
parts.push(formatRelated(f.related_findings));
}
return parts.join('\n\n');
}
export function findingsToMarkdown(fs: FindingView[]): string {
const header = `# Nyx findings (${fs.length})`;
if (fs.length === 0) return `${header}\n\n(none)`;
return [header, ...fs.map(findingToMarkdown)].join('\n\n---\n\n');
}

View file

@ -0,0 +1,47 @@
/**
* Format an ISO date string into a short "M/D H:MM" form suitable for chart labels.
*/
export function formatShortDate(isoStr: string | undefined | null): string {
if (!isoStr) return '';
try {
const d = new Date(isoStr);
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`;
} catch {
return '';
}
}
/**
* Return a human-readable relative time string (e.g. "3 minutes ago", "2 days ago").
*/
export function relTime(isoStr: string | undefined | null): string {
if (!isoStr) return '';
try {
const d = new Date(isoStr);
const now = Date.now();
const diffMs = now - d.getTime();
if (diffMs < 0) return 'just now';
const seconds = Math.floor(diffMs / 1000);
if (seconds < 60) return seconds <= 1 ? 'just now' : `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60)
return minutes === 1 ? '1 minute ago' : `${minutes} minutes ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return hours === 1 ? '1 hour ago' : `${hours} hours ago`;
const days = Math.floor(hours / 24);
if (days < 30) return days === 1 ? '1 day ago' : `${days} days ago`;
const months = Math.floor(days / 30);
if (months < 12)
return months === 1 ? '1 month ago' : `${months} months ago`;
const years = Math.floor(months / 12);
return years === 1 ? '1 year ago' : `${years} years ago`;
} catch {
return '';
}
}

View file

@ -0,0 +1,23 @@
export function parseNoteText(note: string): string {
if (note.startsWith('source_kind:')) {
const kind = note.split(':')[1];
const readable: Record<string, string> = {
UserInput: 'User Input',
EnvironmentConfig: 'Environment/Config',
Database: 'Database',
FileSystem: 'File System',
CaughtException: 'Caught Exception',
Unknown: 'Unclassified',
};
return `Source type: ${readable[kind] || kind}`;
}
if (note.startsWith('hop_count:'))
return `Path length: ${note.split(':')[1]} blocks`;
if (note === 'uses_summary') return 'Uses cross-file summary';
if (note === 'path_validated') return 'Path has validation guard';
if (note.startsWith('cap_specificity:'))
return `Cap specificity: ${note.split(':')[1]}`;
if (note.startsWith('degraded:'))
return `Degraded analysis: ${note.split(':')[1]}`;
return note;
}

View file

@ -0,0 +1,145 @@
interface SyntaxRules {
keywords: RegExp;
strings: RegExp;
comments: RegExp;
numbers: RegExp;
}
const MAX_HIGHLIGHT_INPUT_CHARS = 20_000;
const SYNTAX_RULES: Record<string, SyntaxRules> = {
javascript: {
keywords:
/\b(const|let|var|function|return|if|else|for|while|do|switch|case|break|continue|new|this|class|extends|import|export|from|default|try|catch|finally|throw|async|await|yield|typeof|instanceof|in|of|null|undefined|true|false)\b/g,
strings: /(["'`])(?:(?!\1|\\).|\\.)*?\1/g,
comments: /(\/\/.*$|\/\*[\s\S]*?\*\/)/gm,
numbers: /\b(\d+\.?\d*(?:e[+-]?\d+)?)\b/gi,
},
python: {
keywords:
/\b(def|class|return|if|elif|else|for|while|import|from|as|try|except|finally|raise|with|yield|lambda|pass|break|continue|and|or|not|in|is|None|True|False|self|async|await|global|nonlocal)\b/g,
strings:
/("""[\s\S]*?"""|'''[\s\S]*?'''|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/g,
comments: /(#.*$)/gm,
numbers: /\b(\d+\.?\d*(?:e[+-]?\d+)?)\b/gi,
},
go: {
keywords:
/\b(func|return|if|else|for|range|switch|case|default|break|continue|go|defer|select|chan|map|struct|interface|package|import|var|const|type|nil|true|false|make|new|append|len|cap|error)\b/g,
strings: /(["'`])(?:(?!\1|\\).|\\.)*?\1/g,
comments: /(\/\/.*$|\/\*[\s\S]*?\*\/)/gm,
numbers: /\b(\d+\.?\d*(?:e[+-]?\d+)?)\b/gi,
},
java: {
keywords:
/\b(public|private|protected|static|final|abstract|class|interface|extends|implements|return|if|else|for|while|do|switch|case|break|continue|new|this|super|try|catch|finally|throw|throws|import|package|void|int|long|double|float|boolean|char|byte|short|String|null|true|false|instanceof|synchronized|volatile|transient)\b/g,
strings: /(["'])(?:(?!\1|\\).|\\.)*?\1/g,
comments: /(\/\/.*$|\/\*[\s\S]*?\*\/)/gm,
numbers: /\b(\d+\.?\d*(?:e[+-]?\d+)?[lLfFdD]?)\b/g,
},
rust: {
keywords:
/\b(fn|let|mut|const|static|return|if|else|for|while|loop|match|break|continue|use|mod|pub|crate|self|super|struct|enum|impl|trait|where|type|as|in|ref|move|async|await|unsafe|extern|dyn|true|false|None|Some|Ok|Err|Self)\b/g,
strings: /(["'])(?:(?!\1|\\).|\\.)*?\1/g,
comments: /(\/\/.*$|\/\*[\s\S]*?\*\/)/gm,
numbers: /\b(\d+\.?\d*(?:e[+-]?\d+)?(?:_\d+)*[uif]?\d*)\b/g,
},
php: {
keywords:
/\b(function|return|if|else|elseif|for|foreach|while|do|switch|case|break|continue|class|extends|implements|new|public|private|protected|static|echo|print|require|include|use|namespace|try|catch|finally|throw|null|true|false|array|isset|empty|unset)\b/g,
strings: /(["'])(?:(?!\1|\\).|\\.)*?\1/g,
comments: /(\/\/.*$|#.*$|\/\*[\s\S]*?\*\/)/gm,
numbers: /\b(\d+\.?\d*(?:e[+-]?\d+)?)\b/gi,
},
ruby: {
keywords:
/\b(def|end|class|module|return|if|elsif|else|unless|for|while|until|do|begin|rescue|ensure|raise|yield|block_given\?|require|include|extend|attr_accessor|attr_reader|attr_writer|self|nil|true|false|and|or|not|in|then|when|case)\b/g,
strings: /(["'])(?:(?!\1|\\).|\\.)*?\1/g,
comments: /(#.*$)/gm,
numbers: /\b(\d+\.?\d*(?:e[+-]?\d+)?)\b/gi,
},
c: {
keywords:
/\b(int|char|float|double|void|long|short|unsigned|signed|const|static|extern|struct|union|enum|typedef|return|if|else|for|while|do|switch|case|break|continue|goto|sizeof|NULL|true|false|include|define|ifdef|ifndef|endif)\b/g,
strings: /(["'])(?:(?!\1|\\).|\\.)*?\1/g,
comments: /(\/\/.*$|\/\*[\s\S]*?\*\/)/gm,
numbers: /\b(\d+\.?\d*(?:e[+-]?\d+)?[uUlLfF]*)\b/g,
},
};
// Aliases
SYNTAX_RULES.typescript = SYNTAX_RULES.javascript;
SYNTAX_RULES['c++'] = SYNTAX_RULES.c;
interface Token {
start: number;
end: number;
cls: string;
text: string;
}
/**
* Apply simple regex-based syntax highlighting to already-escaped HTML.
* Returns HTML string with `<span class="tok-*">` wrappers.
*/
export function highlightSyntax(escapedHtml: string, lang: string): string {
const rules = SYNTAX_RULES[lang];
if (!rules || escapedHtml.length > MAX_HIGHLIGHT_INPUT_CHARS)
return escapedHtml;
const tokens: Token[] = [];
const addTokens = (regex: RegExp, cls: string) => {
regex.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = regex.exec(escapedHtml)) !== null) {
tokens.push({
start: m.index,
end: m.index + m[0].length,
cls,
text: m[0],
});
}
};
// Order matters: comments first (highest priority), then strings, then keywords/numbers
addTokens(rules.comments, 'tok-comment');
addTokens(rules.strings, 'tok-string');
addTokens(rules.keywords, 'tok-keyword');
addTokens(rules.numbers, 'tok-number');
// Sort by start position
tokens.sort((a, b) => a.start - b.start);
// Remove overlapping tokens (earlier/higher-priority wins)
const filtered: Token[] = [];
let lastEnd = 0;
for (const t of tokens) {
if (t.start >= lastEnd) {
filtered.push(t);
lastEnd = t.end;
}
}
// Build result
let result = '';
let pos = 0;
for (const t of filtered) {
result += escapedHtml.slice(pos, t.start);
result += `<span class="${t.cls}">${t.text}</span>`;
pos = t.end;
}
result += escapedHtml.slice(pos);
return result;
}
/**
* Escape a raw string for safe insertion as HTML.
*/
export function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

View file

@ -0,0 +1,8 @@
/**
* Truncate a file path to maxLen characters, keeping the tail and prefixing with "...".
*/
export function truncPath(p: string | undefined | null, maxLen = 60): string {
if (!p) return '';
if (p.length <= maxLen) return p;
return '...' + p.slice(-(maxLen - 3));
}

1
frontend/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />