diff --git a/Cargo.lock b/Cargo.lock index e8d839a..6ed0caa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2721,6 +2721,7 @@ dependencies = [ "pcap-file-tokio", "pycrate-rs", "serde", + "serde_json", "telcom-parser", "thiserror 1.0.69", "tokio", diff --git a/check/src/main.rs b/check/src/main.rs index a13495f..97fd477 100644 --- a/check/src/main.rs +++ b/check/src/main.rs @@ -65,10 +65,10 @@ impl Report { EventType::Informational => { info!("{}: INFO - {} {}", self.file_path, timestamp, event.message,); } - EventType::QualitativeWarning { severity } => { + EventType::Low | EventType::Medium | EventType::High => { warn!( "{}: WARNING (Severity: {:?}) - {} {}", - self.file_path, severity, timestamp, event.message, + self.file_path, event.event_type, timestamp, event.message, ); self.warnings += 1; } diff --git a/daemon/Cargo.toml b/daemon/Cargo.toml index 9e2ec0f..d971c8b 100644 --- a/daemon/Cargo.toml +++ b/daemon/Cargo.toml @@ -18,7 +18,7 @@ tokio-util = { version = "0.7.10", features = ["rt", "io", "compat"] } futures-macro = "0.3.30" include_dir = "0.7.3" chrono = { version = "0.4.31", features = ["serde"] } -tokio-stream = { version = "0.1.14", default-features = false } +tokio-stream = { version = "0.1.14", default-features = false, features = ["io-util"] } futures = { version = "0.3.30", default-features = false } serde_json = "1.0.114" image = { version = "0.25.1", default-features = false, features = ["png", "gif"] } diff --git a/daemon/src/analysis.rs b/daemon/src/analysis.rs index 8118095..94a0a21 100644 --- a/daemon/src/analysis.rs +++ b/daemon/src/analysis.rs @@ -1,5 +1,5 @@ use std::sync::Arc; -use std::{future, pin}; +use std::{cmp, future, pin}; use axum::Json; use axum::{ @@ -8,7 +8,7 @@ use axum::{ }; use futures::TryStreamExt; use log::{error, info}; -use rayhunter::analysis::analyzer::{AnalyzerConfig, Harness}; +use rayhunter::analysis::analyzer::{AnalyzerConfig, EventType, Harness}; use rayhunter::diag::{DataType, MessagesContainer}; use rayhunter::qmdl::QmdlReader; use serde::Serialize; @@ -47,15 +47,19 @@ impl AnalysisWriter { // Runs the analysis harness on the given container, serializing the results // to the analysis file, returning the whether any warnings were detected - pub async fn analyze(&mut self, container: MessagesContainer) -> Result { - let mut warning_detected = false; + pub async fn analyze( + &mut self, + container: MessagesContainer, + ) -> Result { + let mut max_type = EventType::Informational; + for row in self.harness.analyze_qmdl_messages(container) { if !row.is_empty() { self.write(&row).await?; } - warning_detected |= row.contains_warnings(); + max_type = cmp::max(max_type, row.get_max_event_type()); } - Ok(warning_detected) + Ok(max_type) } async fn write(&mut self, value: &T) -> Result<(), std::io::Error> { diff --git a/daemon/src/diag.rs b/daemon/src/diag.rs index 9a9f7be..fab9c33 100644 --- a/daemon/src/diag.rs +++ b/daemon/src/diag.rs @@ -8,17 +8,19 @@ use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::http::header::CONTENT_TYPE; use axum::response::{IntoResponse, Response}; -use futures::{StreamExt, TryStreamExt}; +use futures::{StreamExt, TryStreamExt, future}; use log::{debug, error, info, warn}; -use rayhunter::analysis::analyzer::AnalyzerConfig; +use tokio::fs::File; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::sync::mpsc::{Receiver, Sender}; +use tokio::sync::{RwLock, oneshot}; +use tokio_stream::wrappers::LinesStream; +use tokio_util::task::TaskTracker; + +use rayhunter::analysis::analyzer::{AnalysisLineNormalizer, AnalyzerConfig, EventType}; use rayhunter::diag::{DataType, MessagesContainer}; use rayhunter::diag_device::DiagDevice; use rayhunter::qmdl::QmdlWriter; -use tokio::fs::File; -use tokio::sync::mpsc::{Receiver, Sender}; -use tokio::sync::{RwLock, oneshot}; -use tokio_util::io::ReaderStream; -use tokio_util::task::TaskTracker; use crate::analysis::{AnalysisCtrlMessage, AnalysisWriter}; use crate::display; @@ -45,6 +47,7 @@ pub struct DiagTask { analyzer_config: AnalyzerConfig, notification_channel: tokio::sync::mpsc::Sender, state: DiagState, + max_type_seen: EventType, } enum DiagState { @@ -68,6 +71,7 @@ impl DiagTask { analyzer_config, notification_channel, state: DiagState::Stopped, + max_type_seen: EventType::Informational, } } @@ -194,16 +198,13 @@ impl DiagTask { .await .expect("failed to update qmdl file size"); debug!("done!"); - let heuristic_warning = analysis_writer + let max_type = analysis_writer .analyze(container) .await .expect("failed to analyze container"); - if heuristic_warning { + + if max_type > EventType::Informational { info!("a heuristic triggered on this run!"); - self.ui_update_sender - .send(display::DisplayState::WarningDetected) - .await - .expect("couldn't send ui update message: {}"); self.notification_channel .send(Notification::new( "heuristic-warning".to_string(), @@ -213,6 +214,19 @@ impl DiagTask { .await .expect("Failed to send to notification channel"); } + + if max_type > self.max_type_seen { + self.max_type_seen = max_type; + if self.max_type_seen > EventType::Informational { + self.ui_update_sender + .send(display::DisplayState::WarningDetected { + event_type: self.max_type_seen, + }) + .await + .expect("couldn't send ui update message: {}"); + } + + } } else { debug!("no qmdl_writer set, continuing..."); } @@ -422,9 +436,17 @@ pub async fn get_analysis_report( .open_entry_analysis(entry_index) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?; - let analysis_stream = ReaderStream::new(analysis_file); + + // Read and normalize the NDJSON file + let reader = BufReader::new(analysis_file); + let lines_stream = LinesStream::new(reader.lines()); + + let mut normalizer = AnalysisLineNormalizer::new(); + let normalized_stream = lines_stream + .try_filter(|line| future::ready(!line.is_empty())) + .map_ok(move |line| normalizer.normalize_line(line)); let headers = [(CONTENT_TYPE, "application/x-ndjson")]; - let body = Body::from_stream(analysis_stream); + let body = Body::from_stream(normalized_stream); Ok((headers, body).into_response()) } diff --git a/daemon/src/display/generic_framebuffer.rs b/daemon/src/display/generic_framebuffer.rs index f75b59d..ad24eb7 100644 --- a/daemon/src/display/generic_framebuffer.rs +++ b/daemon/src/display/generic_framebuffer.rs @@ -5,6 +5,7 @@ use std::time::Duration; use crate::config; use crate::display::DisplayState; +use rayhunter::analysis::analyzer::EventType; use log::{error, info}; use tokio::sync::mpsc::Receiver; @@ -20,6 +21,13 @@ pub struct Dimensions { pub width: u32, } +#[derive(Copy, Clone)] +pub enum LinePattern { + Solid, + Dashed, // _ _ _ _ + Dotted, // . . . . +} + #[allow(dead_code)] #[derive(Copy, Clone)] pub enum Color { @@ -48,18 +56,36 @@ impl Color { } } -impl Color { - fn from_state(state: DisplayState, colorblind_mode: bool) -> Self { - match state { - DisplayState::Paused => Color::White, - DisplayState::Recording => { - if colorblind_mode { - Color::Blue - } else { - Color::Green - } +fn display_style_from_state(state: DisplayState, colorblind_mode: bool) -> (Color, LinePattern) { + match state { + DisplayState::Paused => (Color::White, LinePattern::Solid), + DisplayState::Recording => { + if colorblind_mode { + (Color::Blue, LinePattern::Solid) + } else { + (Color::Green, LinePattern::Solid) } - DisplayState::WarningDetected => Color::Red, + } + DisplayState::WarningDetected { event_type } => { + let pattern = match event_type { + EventType::Informational => LinePattern::Solid, + EventType::Low => LinePattern::Dotted, + EventType::Medium => LinePattern::Dashed, + EventType::High => LinePattern::Solid, + }; + + let color = match event_type { + EventType::Informational => { + if colorblind_mode { + Color::Blue + } else { + Color::Green + } + } + _ => Color::Red, + }; + + (color, pattern) } } } @@ -120,11 +146,28 @@ pub trait GenericFramebuffer: Send + 'static { } async fn draw_line(&mut self, color: Color, height: u32) { + self.draw_patterned_line(color, height, LinePattern::Solid) + .await + } + + async fn draw_patterned_line(&mut self, color: Color, height: u32, pattern: LinePattern) { let width = self.dimensions().width; - let px_num = height * width; let mut buffer = Vec::new(); - for _ in 0..px_num { - buffer.push(color.rgb()); + + for _row in 0..height { + for col in 0..width { + let should_draw = match pattern { + LinePattern::Solid => true, + LinePattern::Dashed => (col / 4) % 2 == 0, // 4 pixels on, 4 pixels off + LinePattern::Dotted => col % 4 == 0, // 1 pixel on, 3 pixels off + }; + + if should_draw { + buffer.push(color.rgb()); + } else { + buffer.push((0, 0, 0)); // Black background + } + } } self.write_buffer(buffer).await @@ -145,7 +188,7 @@ pub fn update_ui( } let colorblind_mode = config.colorblind_mode; - let mut display_color = Color::from_state(DisplayState::Recording, colorblind_mode); + let mut display_style = display_style_from_state(DisplayState::Recording, colorblind_mode); task_tracker.spawn(async move { // this feels wrong, is there a more rusty way to do this? @@ -176,7 +219,7 @@ pub fn update_ui( } match ui_update_rx.try_recv() { Ok(state) => { - display_color = Color::from_state(state, colorblind_mode); + display_style = display_style_from_state(state, colorblind_mode); } Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {} Err(e) => error!("error receiving framebuffer update message: {e}"), @@ -196,7 +239,8 @@ pub fn update_ui( // unknown value is used _ => {} }; - fb.draw_line(display_color, 2).await; + let (color, pattern) = display_style; + fb.draw_patterned_line(color, 2, pattern).await; tokio::time::sleep(Duration::from_millis(1000)).await; } }); diff --git a/daemon/src/display/mod.rs b/daemon/src/display/mod.rs index ce06324..f30681d 100644 --- a/daemon/src/display/mod.rs +++ b/daemon/src/display/mod.rs @@ -1,3 +1,6 @@ +use rayhunter::analysis::analyzer::EventType; +use serde::{Deserialize, Serialize}; + mod generic_framebuffer; pub mod headless; @@ -9,9 +12,15 @@ pub mod tplink_onebit; pub mod uz801; pub mod wingtech; -#[derive(Clone, Copy, PartialEq)] +#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)] pub enum DisplayState { + /// We're recording but no warning has been found yet. Recording, + /// We're not recording. Paused, - WarningDetected, + /// A non-informational event has been detected. + /// + /// Note that EventType::Informational is never sent through this. If it is, it's the same as + /// Recording + WarningDetected { event_type: EventType }, } diff --git a/daemon/src/display/tmobile.rs b/daemon/src/display/tmobile.rs index 8cbcc8d..2a20a59 100644 --- a/daemon/src/display/tmobile.rs +++ b/daemon/src/display/tmobile.rs @@ -1,7 +1,7 @@ /// Display module for Tmobile TMOHS1, blink LEDs on the front of the device. /// DisplayState::Recording => Signal LED slowly blinks blue. /// DisplayState::Paused => WiFi LED blinks white. -/// DisplayState::WarningDetected => Signal LED slowly blinks red. +/// DisplayState::WarningDetected { .. } => Signal LED slowly blinks red. use log::{error, info}; use tokio::sync::mpsc; use tokio::sync::oneshot; @@ -68,7 +68,7 @@ pub fn update_ui( stop_blinking(led!("signal_red")).await; start_blinking(led!("signal_blue")).await; } - DisplayState::WarningDetected => { + DisplayState::WarningDetected { .. } => { stop_blinking(led!("wlan_white")).await; stop_blinking(led!("signal_blue")).await; start_blinking(led!("signal_red")).await; diff --git a/daemon/src/display/tplink_onebit.rs b/daemon/src/display/tplink_onebit.rs index 34aa48e..d33c4b0 100644 --- a/daemon/src/display/tplink_onebit.rs +++ b/daemon/src/display/tplink_onebit.rs @@ -136,7 +136,7 @@ pub fn update_ui( match ui_update_rx.try_recv() { Ok(DisplayState::Paused) => pixels = STATUS_PAUSED, Ok(DisplayState::Recording) => pixels = STATUS_SMILING, - Ok(DisplayState::WarningDetected) => pixels = STATUS_WARNING, + Ok(DisplayState::WarningDetected { .. }) => pixels = STATUS_WARNING, Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {} Err(e) => { error!("error receiving framebuffer update message: {e}"); diff --git a/daemon/src/display/uz801.rs b/daemon/src/display/uz801.rs index ea0fd7e..6127258 100644 --- a/daemon/src/display/uz801.rs +++ b/daemon/src/display/uz801.rs @@ -73,7 +73,7 @@ pub fn update_ui( led_off(led!("wifi")).await; led_on(led!("green")).await; } - DisplayState::WarningDetected => { + DisplayState::WarningDetected { .. } => { led_off(led!("green")).await; led_off(led!("wifi")).await; led_on(led!("red")).await; diff --git a/daemon/src/main.rs b/daemon/src/main.rs index 51701e3..7019812 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -20,7 +20,9 @@ use crate::error::RayhunterError; use crate::notifications::{NotificationService, run_notification_worker}; use crate::pcap::get_pcap; use crate::qmdl_store::RecordingStore; -use crate::server::{ServerState, get_config, get_qmdl, get_zip, serve_static, set_config}; +use crate::server::{ + ServerState, debug_set_display_state, get_config, get_qmdl, get_zip, serve_static, set_config, +}; use crate::stats::{get_qmdl_manifest, get_system_stats}; use analysis::{ @@ -62,6 +64,7 @@ fn get_router() -> AppRouter { .route("/api/analysis/{name}", post(start_analysis)) .route("/api/config", get(get_config)) .route("/api/config", post(set_config)) + .route("/api/debug/display-state", post(debug_set_display_state)) .route("/", get(|| async { Redirect::permanent("/index.html") })) .route("/{*path}", get(serve_static)) } @@ -300,6 +303,7 @@ async fn run_with_config( analysis_status_lock, analysis_sender: analysis_tx, daemon_restart_tx: Arc::new(RwLock::new(Some(daemon_restart_tx))), + ui_update_sender: Some(ui_update_tx), }); run_server(&task_tracker, state, server_shutdown_rx).await; diff --git a/daemon/src/server.rs b/daemon/src/server.rs index b733b4a..0eeec4f 100644 --- a/daemon/src/server.rs +++ b/daemon/src/server.rs @@ -21,6 +21,7 @@ use tokio_util::io::ReaderStream; use crate::DiagDeviceCtrlMessage; use crate::analysis::{AnalysisCtrlMessage, AnalysisStatus}; use crate::config::Config; +use crate::display::DisplayState; use crate::pcap::generate_pcap_data; use crate::qmdl_store::RecordingStore; @@ -32,6 +33,7 @@ pub struct ServerState { pub analysis_status_lock: Arc>, pub analysis_sender: Sender, pub daemon_restart_tx: Arc>>>, + pub ui_update_sender: Option>, } pub async fn get_qmdl( @@ -242,6 +244,29 @@ pub async fn get_zip( Ok((headers, body).into_response()) } +pub async fn debug_set_display_state( + State(state): State>, + Json(display_state): Json, +) -> Result<(StatusCode, String), (StatusCode, String)> { + if let Some(ui_sender) = &state.ui_update_sender { + ui_sender.send(display_state).await.map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to send display state update".to_string(), + ) + })?; + Ok(( + StatusCode::OK, + "display state updated successfully".to_string(), + )) + } else { + Err(( + StatusCode::SERVICE_UNAVAILABLE, + "display system not available".to_string(), + )) + } +} + #[cfg(test)] mod tests { use super::*; @@ -307,6 +332,7 @@ mod tests { analysis_status_lock: Arc::new(RwLock::new(analysis_status)), analysis_sender: analysis_tx, daemon_restart_tx: Arc::new(RwLock::new(None)), + ui_update_sender: None, }) } diff --git a/daemon/web/src/lib/analysis.svelte.spec.ts b/daemon/web/src/lib/analysis.svelte.spec.ts index fbaf479..caee3db 100644 --- a/daemon/web/src/lib/analysis.svelte.spec.ts +++ b/daemon/web/src/lib/analysis.svelte.spec.ts @@ -1,43 +1,7 @@ import { describe, it, expect } from 'vitest'; -import { AnalysisRowType, EventType, parse_finished_report, Severity } from './analysis.svelte'; +import { AnalysisRowType, parse_finished_report } from './analysis.svelte'; import { type NewlineDeliminatedJson } from './ndjson'; -const SAMPLE_V1_REPORT_NDJSON: NewlineDeliminatedJson = [ - { - analyzers: [ - { - name: 'Analyzer 1', - description: 'A first analyzer', - }, - { - name: 'Analyzer 2', - description: 'A second analyzer', - }, - ], - }, - { - timestamp: '2024-10-08T13:25:43.011689003-07:00', - skipped_message_reasons: ['The reason why the message was skipped'], - analysis: [], - }, - { - timestamp: '2024-10-08T13:25:43.480872496-07:00', - skipped_message_reasons: [], - analysis: [ - { - timestamp: '2024-08-19T03:33:54.318Z', - events: [ - null, - { - event_type: { type: 'QualitativeWarning', severity: 'Low' }, - message: 'Something nasty happened', - }, - ], - }, - ], - }, -]; - const SAMPLE_V2_REPORT_NDJSON: NewlineDeliminatedJson = [ { analyzers: [ @@ -62,7 +26,7 @@ const SAMPLE_V2_REPORT_NDJSON: NewlineDeliminatedJson = [ events: [ null, { - event_type: { type: 'QualitativeWarning', severity: 'Low' }, + event_type: 'Low', message: 'Something nasty happened', }, ], @@ -70,40 +34,6 @@ const SAMPLE_V2_REPORT_NDJSON: NewlineDeliminatedJson = [ ]; describe('analysis report parsing', () => { - it('parses v1 example analysis', () => { - const report = parse_finished_report(SAMPLE_V1_REPORT_NDJSON); - expect(report.metadata.report_version).toEqual(1); - expect(report.metadata.analyzers).toEqual([ - { - name: 'Analyzer 1', - description: 'A first analyzer', - version: 0, - }, - { - name: 'Analyzer 2', - description: 'A second analyzer', - version: 0, - }, - ]); - expect(report.rows).toHaveLength(2); - expect(report.rows[0].type).toBe(AnalysisRowType.Skipped); - if (report.rows[1].type === AnalysisRowType.Analysis) { - const row = report.rows[1]; - expect(row.events).toHaveLength(2); - expect(row.events[0]).toBeNull(); - const event = row.events[1]; - const expected_timestamp = new Date('2024-08-19T03:33:54.318Z'); - expect(row.packet_timestamp.getTime()).toEqual(expected_timestamp.getTime()); - if (event !== null && event.type === EventType.Warning) { - expect(event.severity).toEqual(Severity.Low); - } else { - throw 'wrong event type'; - } - } else { - throw 'wrong row type'; - } - }); - it('parses v2 example analysis', () => { const report = parse_finished_report(SAMPLE_V2_REPORT_NDJSON); expect(report.metadata.report_version).toEqual(2); @@ -128,11 +58,7 @@ describe('analysis report parsing', () => { const event = row.events[1]; const expected_timestamp = new Date('2024-08-19T03:33:54.318Z'); expect(row.packet_timestamp.getTime()).toEqual(expected_timestamp.getTime()); - if (event !== null && event.type === EventType.Warning) { - expect(event.severity).toEqual(Severity.Low); - } else { - throw 'wrong event type'; - } + expect(event!.event_type).toEqual('Low'); } else { throw 'wrong row type'; } diff --git a/daemon/web/src/lib/analysis.svelte.ts b/daemon/web/src/lib/analysis.svelte.ts index 0dae3a5..73948db 100644 --- a/daemon/web/src/lib/analysis.svelte.ts +++ b/daemon/web/src/lib/analysis.svelte.ts @@ -21,17 +21,7 @@ export class ReportMetadata { constructor(ndjson: any) { this.analyzers = ndjson.analyzers; this.rayhunter = ndjson.rayhunter; - if (ndjson.report_version === undefined) { - this.report_version = 1; - // we consider our legacy (unversioned) heuristics to be v0 -- - // this'll let us clearly differentiate some known false-positive - // results from the pre-versioned era from v1 heuristics - this.analyzers.forEach((analyzer) => { - analyzer.version = 0; - }); - } else { - this.report_version = ndjson.report_version; - } + this.report_version = ndjson.report_version || 2; // Default to v2 } } @@ -64,77 +54,22 @@ export type PacketAnalysis = { events: Event[]; }; -export type Event = QualitativeWarning | InformationalEvent | null; -export enum EventType { - Informational, - Warning, -} +export type EventType = 'Informational' | 'Low' | 'Medium' | 'High'; -export type QualitativeWarning = { - type: EventType.Warning; - severity: Severity; +export type Event = { + event_type: EventType; message: string; -}; - -export enum Severity { - Low, - Medium, - High, -} - -export type InformationalEvent = { - type: EventType.Informational; - message: string; -}; +} | null; function get_event(event_json: any): Event { - if (event_json.event_type.type === 'Informational') { - return { - type: EventType.Informational, - message: event_json.message, - }; - } else { - return { - type: EventType.Warning, - severity: - event_json.event_type.severity === 'High' - ? Severity.High - : event_json.event_type.severity === 'Medium' - ? Severity.Medium - : Severity.Low, - message: event_json.message, - }; + if (!['Informational', 'Low', 'Medium', 'High'].includes(event_json.event_type)) { + throw `Invalid/unhandled event type: ${event_json.event_type}`; } + + return event_json; } -function get_v1_rows(row_jsons: any[]): AnalysisRow[] { - const rows: AnalysisRow[] = []; - for (const row_json of row_jsons) { - for (const reason of row_json.skipped_message_reasons) { - rows.push({ - type: AnalysisRowType.Skipped, - reason, - }); - } - for (const analysis_json of row_json.analysis) { - const events: Event[] = analysis_json.events.map((event_json: any): Event | null => { - if (event_json === null) { - return null; - } else { - return get_event(event_json); - } - }); - rows.push({ - type: AnalysisRowType.Analysis, - packet_timestamp: new Date(analysis_json.timestamp), - events, - }); - } - } - return rows; -} - -function get_v2_rows(row_jsons: any[]): AnalysisRow[] { +function get_rows(row_jsons: any[]): AnalysisRow[] { const rows: AnalysisRow[] = []; for (const row_json of row_jsons) { if (row_json.skipped_message_reason) { @@ -170,7 +105,7 @@ function get_report_stats(rows: AnalysisRow[]): ReportStatistics { } else { for (const event of row.events) { if (event !== null) { - if (event.type === EventType.Informational) { + if (event.event_type === 'Informational') { num_informational_logs++; } else { num_warnings++; @@ -188,12 +123,7 @@ function get_report_stats(rows: AnalysisRow[]): ReportStatistics { export function parse_finished_report(report_json: NewlineDeliminatedJson): AnalysisReport { const metadata = new ReportMetadata(report_json[0]); - let rows; - if (metadata.report_version === 1) { - rows = get_v1_rows(report_json.slice(1)); - } else { - rows = get_v2_rows(report_json.slice(1)); - } + const rows = get_rows(report_json.slice(1)); const statistics = get_report_stats(rows); return { statistics, diff --git a/daemon/web/src/lib/components/AnalysisTable.svelte b/daemon/web/src/lib/components/AnalysisTable.svelte index f5f6e01..ed4e724 100644 --- a/daemon/web/src/lib/components/AnalysisTable.svelte +++ b/daemon/web/src/lib/components/AnalysisTable.svelte @@ -1,5 +1,5 @@