mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-21 20:18:06 +02:00
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:
parent
c4ce08b452
commit
41128177d2
2144 changed files with 201812 additions and 8927 deletions
17
frontend/src/App.tsx
Normal file
17
frontend/src/App.tsx
Normal 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
104
frontend/src/api/client.ts
Normal 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 });
|
||||
}
|
||||
157
frontend/src/api/mutations/config.ts
Normal file
157
frontend/src/api/mutations/config.ts
Normal 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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
24
frontend/src/api/mutations/rules.ts
Normal file
24
frontend/src/api/mutations/rules.ts
Normal 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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
34
frontend/src/api/mutations/scans.ts
Normal file
34
frontend/src/api/mutations/scans.ts
Normal 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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
86
frontend/src/api/mutations/triage.ts
Normal file
86
frontend/src/api/mutations/triage.ts
Normal 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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
48
frontend/src/api/queries/config.ts
Normal file
48
frontend/src/api/queries/config.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
111
frontend/src/api/queries/debug.ts
Normal file
111
frontend/src/api/queries/debug.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
37
frontend/src/api/queries/explorer.ts
Normal file
37
frontend/src/api/queries/explorer.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
63
frontend/src/api/queries/findings.ts
Normal file
63
frontend/src/api/queries/findings.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
11
frontend/src/api/queries/health.ts
Normal file
11
frontend/src/api/queries/health.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
17
frontend/src/api/queries/overview.ts
Normal file
17
frontend/src/api/queries/overview.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
18
frontend/src/api/queries/rules.ts
Normal file
18
frontend/src/api/queries/rules.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
89
frontend/src/api/queries/scans.ts
Normal file
89
frontend/src/api/queries/scans.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
67
frontend/src/api/queries/triage.ts
Normal file
67
frontend/src/api/queries/triage.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
11
frontend/src/api/queryClient.ts
Normal file
11
frontend/src/api/queryClient.ts
Normal 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
593
frontend/src/api/types.ts
Normal 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;
|
||||
}
|
||||
171
frontend/src/components/CopyMarkdownButton.tsx
Normal file
171
frontend/src/components/CopyMarkdownButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
frontend/src/components/charts/HorizontalBarChart.tsx
Normal file
84
frontend/src/components/charts/HorizontalBarChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
139
frontend/src/components/charts/LineChart.tsx
Normal file
139
frontend/src/components/charts/LineChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
185
frontend/src/components/data-display/CodeViewer.tsx
Normal file
185
frontend/src/components/data-display/CodeViewer.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
155
frontend/src/components/data-display/FileTree.tsx
Normal file
155
frontend/src/components/data-display/FileTree.tsx
Normal 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)';
|
||||
}
|
||||
}
|
||||
35
frontend/src/components/explorer/AnalysisWorkspace.tsx
Normal file
35
frontend/src/components/explorer/AnalysisWorkspace.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
170
frontend/src/components/icons/Icons.tsx
Normal file
170
frontend/src/components/icons/Icons.tsx
Normal 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,
|
||||
};
|
||||
67
frontend/src/components/layout/AppLayout.tsx
Normal file
67
frontend/src/components/layout/AppLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
frontend/src/components/layout/HeaderBar.tsx
Normal file
90
frontend/src/components/layout/HeaderBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
178
frontend/src/components/layout/Sidebar.tsx
Normal file
178
frontend/src/components/layout/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
frontend/src/components/ui/Dropdown.tsx
Normal file
103
frontend/src/components/ui/Dropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
frontend/src/components/ui/EmptyState.tsx
Normal file
17
frontend/src/components/ui/EmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
frontend/src/components/ui/ErrorState.tsx
Normal file
13
frontend/src/components/ui/ErrorState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
frontend/src/components/ui/LoadingState.tsx
Normal file
7
frontend/src/components/ui/LoadingState.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
interface LoadingStateProps {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function LoadingState({ message = 'Loading...' }: LoadingStateProps) {
|
||||
return <div className="loading">{message}</div>;
|
||||
}
|
||||
38
frontend/src/components/ui/Modal.tsx
Normal file
38
frontend/src/components/ui/Modal.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
75
frontend/src/components/ui/Pagination.tsx
Normal file
75
frontend/src/components/ui/Pagination.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
frontend/src/components/ui/StatCard.tsx
Normal file
32
frontend/src/components/ui/StatCard.tsx
Normal 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'} {Math.abs(delta)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{subtitle && <div className="stat-subtitle">{subtitle}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
frontend/src/contexts/SSEContext.tsx
Normal file
109
frontend/src/contexts/SSEContext.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
frontend/src/graph/adapters/callgraph.ts
Normal file
57
frontend/src/graph/adapters/callgraph.ts
Normal 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,
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
85
frontend/src/graph/adapters/cfg.ts
Normal file
85
frontend/src/graph/adapters/cfg.ts
Normal 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,
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
125
frontend/src/graph/components/CallGraphCanvas.tsx
Normal file
125
frontend/src/graph/components/CallGraphCanvas.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
204
frontend/src/graph/components/CfgGraphCanvas.tsx
Normal file
204
frontend/src/graph/components/CfgGraphCanvas.tsx
Normal 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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
88
frontend/src/graph/components/GraphToolbar.tsx
Normal file
88
frontend/src/graph/components/GraphToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
99
frontend/src/graph/hooks/useElkLayout.ts
Normal file
99
frontend/src/graph/hooks/useElkLayout.ts
Normal 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;
|
||||
}
|
||||
288
frontend/src/graph/layout/elk.ts
Normal file
288
frontend/src/graph/layout/elk.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
119
frontend/src/graph/layout/text.ts
Normal file
119
frontend/src/graph/layout/text.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
165
frontend/src/graph/reduction/cfgCompaction.ts
Normal file
165
frontend/src/graph/reduction/cfgCompaction.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
66
frontend/src/graph/reduction/neighborhood.ts
Normal file
66
frontend/src/graph/reduction/neighborhood.ts
Normal 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),
|
||||
),
|
||||
};
|
||||
}
|
||||
332
frontend/src/graph/rendering/sigma/SigmaGraph.tsx
Normal file
332
frontend/src/graph/rendering/sigma/SigmaGraph.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
frontend/src/graph/rendering/sigma/buildGraph.ts
Normal file
53
frontend/src/graph/rendering/sigma/buildGraph.ts
Normal 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;
|
||||
}
|
||||
656
frontend/src/graph/rendering/sigma/edgeOverlay.ts
Normal file
656
frontend/src/graph/rendering/sigma/edgeOverlay.ts
Normal 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);
|
||||
}
|
||||
258
frontend/src/graph/styles.ts
Normal file
258
frontend/src/graph/styles.ts
Normal 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
111
frontend/src/graph/types.ts
Normal 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;
|
||||
}
|
||||
16
frontend/src/hooks/useDebounce.ts
Normal file
16
frontend/src/hooks/useDebounce.ts
Normal 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;
|
||||
}
|
||||
129
frontend/src/hooks/useFileTree.ts
Normal file
129
frontend/src/hooks/useFileTree.ts
Normal 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;
|
||||
}
|
||||
117
frontend/src/hooks/useFindingsURLState.ts
Normal file
117
frontend/src/hooks/useFindingsURLState.ts
Normal 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
10
frontend/src/main.tsx
Normal 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>,
|
||||
);
|
||||
42
frontend/src/modals/CodeViewerModal.tsx
Normal file
42
frontend/src/modals/CodeViewerModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
frontend/src/modals/NewScanModal.tsx
Normal file
114
frontend/src/modals/NewScanModal.tsx
Normal 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. ~2–3× 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>
|
||||
);
|
||||
}
|
||||
519
frontend/src/pages/ConfigPage.tsx
Normal file
519
frontend/src/pages/ConfigPage.tsx
Normal 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' : ''}`}
|
||||
>
|
||||
▼
|
||||
</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> — 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
878
frontend/src/pages/ExplorerPage.tsx
Normal file
878
frontend/src/pages/ExplorerPage.tsx
Normal 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">→</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)
|
||||
);
|
||||
}
|
||||
1024
frontend/src/pages/FindingDetailPage.tsx
Normal file
1024
frontend/src/pages/FindingDetailPage.tsx
Normal file
File diff suppressed because it is too large
Load diff
729
frontend/src/pages/FindingsPage.tsx
Normal file
729
frontend/src/pages/FindingsPage.tsx
Normal 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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
341
frontend/src/pages/OverviewPage.tsx
Normal file
341
frontend/src/pages/OverviewPage.tsx
Normal 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 →
|
||||
</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 →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
360
frontend/src/pages/RulesPage.tsx
Normal file
360
frontend/src/pages/RulesPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
414
frontend/src/pages/ScanComparePage.tsx
Normal file
414
frontend/src/pages/ScanComparePage.tsx
Normal 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">→</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' : ''}`}>
|
||||
▼
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
467
frontend/src/pages/ScanDetailPage.tsx
Normal file
467
frontend/src/pages/ScanDetailPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
297
frontend/src/pages/ScansPage.tsx
Normal file
297
frontend/src/pages/ScansPage.tsx
Normal 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 "Start Scan" 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
56
frontend/src/pages/StubPage.tsx
Normal file
56
frontend/src/pages/StubPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1305
frontend/src/pages/TriagePage.tsx
Normal file
1305
frontend/src/pages/TriagePage.tsx
Normal file
File diff suppressed because it is too large
Load diff
170
frontend/src/pages/debug/AbstractInterpPage.tsx
Normal file
170
frontend/src/pages/debug/AbstractInterpPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
frontend/src/pages/debug/CallGraphPage.tsx
Normal file
63
frontend/src/pages/debug/CallGraphPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
frontend/src/pages/debug/CfgViewerPage.tsx
Normal file
37
frontend/src/pages/debug/CfgViewerPage.tsx
Normal 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} />;
|
||||
}
|
||||
31
frontend/src/pages/debug/DebugLayout.tsx
Normal file
31
frontend/src/pages/debug/DebugLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
frontend/src/pages/debug/FunctionSelector.tsx
Normal file
56
frontend/src/pages/debug/FunctionSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
frontend/src/pages/debug/SsaViewerPage.tsx
Normal file
112
frontend/src/pages/debug/SsaViewerPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
230
frontend/src/pages/debug/SummaryExplorerPage.tsx
Normal file
230
frontend/src/pages/debug/SummaryExplorerPage.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
90
frontend/src/pages/debug/SymexPage.tsx
Normal file
90
frontend/src/pages/debug/SymexPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
126
frontend/src/pages/debug/TaintViewerPage.tsx
Normal file
126
frontend/src/pages/debug/TaintViewerPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5144
frontend/src/styles/global.css
Normal file
5144
frontend/src/styles/global.css
Normal file
File diff suppressed because it is too large
Load diff
38
frontend/src/test/api/client.test.ts
Normal file
38
frontend/src/test/api/client.test.ts
Normal 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',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
95
frontend/src/test/components/Pagination.test.tsx
Normal file
95
frontend/src/test/components/Pagination.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
65
frontend/src/test/components/StatCard.test.tsx
Normal file
65
frontend/src/test/components/StatCard.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
66
frontend/src/test/components/stateComponents.test.tsx
Normal file
66
frontend/src/test/components/stateComponents.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
91
frontend/src/test/graph/cfgAdapter.test.ts
Normal file
91
frontend/src/test/graph/cfgAdapter.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
139
frontend/src/test/graph/compactGraph.test.ts
Normal file
139
frontend/src/test/graph/compactGraph.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
93
frontend/src/test/graph/nodeStyles.test.ts
Normal file
93
frontend/src/test/graph/nodeStyles.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
85
frontend/src/test/hooks/useDebounce.test.ts
Normal file
85
frontend/src/test/hooks/useDebounce.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
1
frontend/src/test/setup.ts
Normal file
1
frontend/src/test/setup.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
import '@testing-library/jest-dom';
|
||||
188
frontend/src/test/utils/findingMarkdown.test.ts
Normal file
188
frontend/src/test/utils/findingMarkdown.test.ts
Normal 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 138–141, 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)');
|
||||
});
|
||||
});
|
||||
136
frontend/src/test/utils/formatDate.test.ts
Normal file
136
frontend/src/test/utils/formatDate.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
116
frontend/src/test/utils/syntaxHighlight.test.ts
Normal file
116
frontend/src/test/utils/syntaxHighlight.test.ts
Normal 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 & b');
|
||||
});
|
||||
|
||||
it('escapes less-than signs', () => {
|
||||
expect(escapeHtml('<div>')).toBe('<div>');
|
||||
});
|
||||
|
||||
it('escapes greater-than signs', () => {
|
||||
expect(escapeHtml('1 > 0')).toBe('1 > 0');
|
||||
});
|
||||
|
||||
it('escapes double quotes', () => {
|
||||
expect(escapeHtml('"hello"')).toBe('"hello"');
|
||||
});
|
||||
|
||||
it('escapes all special chars together', () => {
|
||||
expect(escapeHtml('<a href="x&y">z</a>')).toBe(
|
||||
'<a href="x&y">z</a>',
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
47
frontend/src/test/utils/truncPath.test.ts
Normal file
47
frontend/src/test/utils/truncPath.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
205
frontend/src/utils/findingMarkdown.ts
Normal file
205
frontend/src/utils/findingMarkdown.ts
Normal 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');
|
||||
}
|
||||
47
frontend/src/utils/formatDate.ts
Normal file
47
frontend/src/utils/formatDate.ts
Normal 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 '';
|
||||
}
|
||||
}
|
||||
23
frontend/src/utils/parseNote.ts
Normal file
23
frontend/src/utils/parseNote.ts
Normal 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;
|
||||
}
|
||||
145
frontend/src/utils/syntaxHighlight.ts
Normal file
145
frontend/src/utils/syntaxHighlight.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
8
frontend/src/utils/truncPath.ts
Normal file
8
frontend/src/utils/truncPath.ts
Normal 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
1
frontend/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
Loading…
Add table
Add a link
Reference in a new issue