From 758f1b2ceb50ec1d5f4405e95d24a4ce5e76e31e Mon Sep 17 00:00:00 2001 From: hsn2004 Date: Sun, 23 Nov 2025 16:50:00 -0800 Subject: [PATCH 1/6] Adding the user authentication for basic user API's: create, login, get all, delete --- ...236ecc769f3a38594dd41aaac230fac16b62f.json | 42 ++++ ...c1620736e9ad3720f6145df4c609309c927f6.json | 41 ++++ ...71cef087a159c6ee7182d8ca929ecb748f3b7.json | 14 ++ ...afb487c1b540df63cf7998cce53476baaf253.json | 43 ++++ ...f7262346d7647e4c279806f2e9c6172f0b89f.json | 41 ++++ ...69456beff2b3f5187ee3794b24aa55babdf5.json} | 11 +- ...af172d41e1c7a5aa8e7b843f7973bb345e727.json | 42 ++++ ...d18dd3fabd7c5bd5e5eaf3f618a5fbcd77abd.json | 41 ++++ ...5af3582be51fcfd8a99336f5594c649674e1.json} | 10 +- ...7c461a755fccf5c37ebc642e9a53f1af1bcd3.json | 42 ++++ ...8b90a4f22fe46d0a5aa8df53b28f79ecfcdae.json | 42 ++++ backend/Cargo.lock | 59 ++++++ backend/api-server/Cargo.toml | 3 + backend/api-server/src/main.rs | 191 ++++++++++++------ backend/shared-logic/Cargo.toml | 5 + backend/shared-logic/src/db.rs | 142 +++++++++---- backend/shared-logic/src/models.rs | 17 +- backend/test-lsl/Cargo.toml | 7 + backend/test-lsl/src/main.rs | 3 + 19 files changed, 683 insertions(+), 113 deletions(-) create mode 100644 backend/.sqlx/query-0defdb6b38ff44d5d4ba73e45c0236ecc769f3a38594dd41aaac230fac16b62f.json create mode 100644 backend/.sqlx/query-4ca9e69e9cd8d371c8f3f62115bc1620736e9ad3720f6145df4c609309c927f6.json create mode 100644 backend/.sqlx/query-50293c2e54af11d4c2a553e29b671cef087a159c6ee7182d8ca929ecb748f3b7.json create mode 100644 backend/.sqlx/query-5c1ab37e0e930c0f3049ea98fdbafb487c1b540df63cf7998cce53476baaf253.json create mode 100644 backend/.sqlx/query-68ed910f4a417aa54a35db00a93f7262346d7647e4c279806f2e9c6172f0b89f.json rename backend/.sqlx/{query-171f07975cf260d23d1077aa57c85bbb8741b6f136122653fefac674eadb3dca.json => query-7359e2da92b45a195b98b059989f69456beff2b3f5187ee3794b24aa55babdf5.json} (63%) create mode 100644 backend/.sqlx/query-ac14da374cf38c2625ed478155baf172d41e1c7a5aa8e7b843f7973bb345e727.json create mode 100644 backend/.sqlx/query-c90b5ff74f3f075f08b371a0fb6d18dd3fabd7c5bd5e5eaf3f618a5fbcd77abd.json rename backend/.sqlx/{query-88f23b59e72f3d364e0c83c1046b05c8fa7243caa6e1a6aba4a9228531156474.json => query-d8a2e5a84611aba22339339296a45af3582be51fcfd8a99336f5594c649674e1.json} (64%) create mode 100644 backend/.sqlx/query-db084199deff63e4f835ebd091e7c461a755fccf5c37ebc642e9a53f1af1bcd3.json create mode 100644 backend/.sqlx/query-f7d9635ff56737d43d1afb740f08b90a4f22fe46d0a5aa8df53b28f79ecfcdae.json create mode 100644 backend/test-lsl/Cargo.toml create mode 100644 backend/test-lsl/src/main.rs diff --git a/backend/.sqlx/query-0defdb6b38ff44d5d4ba73e45c0236ecc769f3a38594dd41aaac230fac16b62f.json b/backend/.sqlx/query-0defdb6b38ff44d5d4ba73e45c0236ecc769f3a38594dd41aaac230fac16b62f.json new file mode 100644 index 0000000..f9f5238 --- /dev/null +++ b/backend/.sqlx/query-0defdb6b38ff44d5d4ba73e45c0236ecc769f3a38594dd41aaac230fac16b62f.json @@ -0,0 +1,42 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET username = $1, email = $2 WHERE id = $3 RETURNING id, username, email, password_hash", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "password_hash", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "0defdb6b38ff44d5d4ba73e45c0236ecc769f3a38594dd41aaac230fac16b62f" +} diff --git a/backend/.sqlx/query-4ca9e69e9cd8d371c8f3f62115bc1620736e9ad3720f6145df4c609309c927f6.json b/backend/.sqlx/query-4ca9e69e9cd8d371c8f3f62115bc1620736e9ad3720f6145df4c609309c927f6.json new file mode 100644 index 0000000..0f5b6f0 --- /dev/null +++ b/backend/.sqlx/query-4ca9e69e9cd8d371c8f3f62115bc1620736e9ad3720f6145df4c609309c927f6.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET password_hash = $1 WHERE id = $2 RETURNING id, username, email, password_hash", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "password_hash", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "4ca9e69e9cd8d371c8f3f62115bc1620736e9ad3720f6145df4c609309c927f6" +} diff --git a/backend/.sqlx/query-50293c2e54af11d4c2a553e29b671cef087a159c6ee7182d8ca929ecb748f3b7.json b/backend/.sqlx/query-50293c2e54af11d4c2a553e29b671cef087a159c6ee7182d8ca929ecb748f3b7.json new file mode 100644 index 0000000..cf6c805 --- /dev/null +++ b/backend/.sqlx/query-50293c2e54af11d4c2a553e29b671cef087a159c6ee7182d8ca929ecb748f3b7.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM users WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [] + }, + "hash": "50293c2e54af11d4c2a553e29b671cef087a159c6ee7182d8ca929ecb748f3b7" +} diff --git a/backend/.sqlx/query-5c1ab37e0e930c0f3049ea98fdbafb487c1b540df63cf7998cce53476baaf253.json b/backend/.sqlx/query-5c1ab37e0e930c0f3049ea98fdbafb487c1b540df63cf7998cce53476baaf253.json new file mode 100644 index 0000000..effed01 --- /dev/null +++ b/backend/.sqlx/query-5c1ab37e0e930c0f3049ea98fdbafb487c1b540df63cf7998cce53476baaf253.json @@ -0,0 +1,43 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET username = $1, email = $2, password_hash = $3 WHERE id = $4 RETURNING id, username, email, password_hash", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "password_hash", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "5c1ab37e0e930c0f3049ea98fdbafb487c1b540df63cf7998cce53476baaf253" +} diff --git a/backend/.sqlx/query-68ed910f4a417aa54a35db00a93f7262346d7647e4c279806f2e9c6172f0b89f.json b/backend/.sqlx/query-68ed910f4a417aa54a35db00a93f7262346d7647e4c279806f2e9c6172f0b89f.json new file mode 100644 index 0000000..7213d2c --- /dev/null +++ b/backend/.sqlx/query-68ed910f4a417aa54a35db00a93f7262346d7647e4c279806f2e9c6172f0b89f.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET email = $1 WHERE id = $2 RETURNING id, username, email, password_hash", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "password_hash", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "68ed910f4a417aa54a35db00a93f7262346d7647e4c279806f2e9c6172f0b89f" +} diff --git a/backend/.sqlx/query-171f07975cf260d23d1077aa57c85bbb8741b6f136122653fefac674eadb3dca.json b/backend/.sqlx/query-7359e2da92b45a195b98b059989f69456beff2b3f5187ee3794b24aa55babdf5.json similarity index 63% rename from backend/.sqlx/query-171f07975cf260d23d1077aa57c85bbb8741b6f136122653fefac674eadb3dca.json rename to backend/.sqlx/query-7359e2da92b45a195b98b059989f69456beff2b3f5187ee3794b24aa55babdf5.json index 22226f3..c7b8471 100644 --- a/backend/.sqlx/query-171f07975cf260d23d1077aa57c85bbb8741b6f136122653fefac674eadb3dca.json +++ b/backend/.sqlx/query-7359e2da92b45a195b98b059989f69456beff2b3f5187ee3794b24aa55babdf5.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO users (username, email) VALUES ($1, $2) RETURNING id, username, email", + "query": "SELECT id, username, email, password_hash FROM users WHERE email = $1", "describe": { "columns": [ { @@ -17,19 +17,24 @@ "ordinal": 2, "name": "email", "type_info": "Text" + }, + { + "ordinal": 3, + "name": "password_hash", + "type_info": "Text" } ], "parameters": { "Left": [ - "Text", "Text" ] }, "nullable": [ + false, false, false, false ] }, - "hash": "171f07975cf260d23d1077aa57c85bbb8741b6f136122653fefac674eadb3dca" + "hash": "7359e2da92b45a195b98b059989f69456beff2b3f5187ee3794b24aa55babdf5" } diff --git a/backend/.sqlx/query-ac14da374cf38c2625ed478155baf172d41e1c7a5aa8e7b843f7973bb345e727.json b/backend/.sqlx/query-ac14da374cf38c2625ed478155baf172d41e1c7a5aa8e7b843f7973bb345e727.json new file mode 100644 index 0000000..130fc60 --- /dev/null +++ b/backend/.sqlx/query-ac14da374cf38c2625ed478155baf172d41e1c7a5aa8e7b843f7973bb345e727.json @@ -0,0 +1,42 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO users (username, email, password_hash) VALUES ($1, $2, $3) RETURNING id, username, email, password_hash", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "password_hash", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "ac14da374cf38c2625ed478155baf172d41e1c7a5aa8e7b843f7973bb345e727" +} diff --git a/backend/.sqlx/query-c90b5ff74f3f075f08b371a0fb6d18dd3fabd7c5bd5e5eaf3f618a5fbcd77abd.json b/backend/.sqlx/query-c90b5ff74f3f075f08b371a0fb6d18dd3fabd7c5bd5e5eaf3f618a5fbcd77abd.json new file mode 100644 index 0000000..b317d54 --- /dev/null +++ b/backend/.sqlx/query-c90b5ff74f3f075f08b371a0fb6d18dd3fabd7c5bd5e5eaf3f618a5fbcd77abd.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET username = $1 WHERE id = $2 RETURNING id, username, email, password_hash", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "password_hash", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "c90b5ff74f3f075f08b371a0fb6d18dd3fabd7c5bd5e5eaf3f618a5fbcd77abd" +} diff --git a/backend/.sqlx/query-88f23b59e72f3d364e0c83c1046b05c8fa7243caa6e1a6aba4a9228531156474.json b/backend/.sqlx/query-d8a2e5a84611aba22339339296a45af3582be51fcfd8a99336f5594c649674e1.json similarity index 64% rename from backend/.sqlx/query-88f23b59e72f3d364e0c83c1046b05c8fa7243caa6e1a6aba4a9228531156474.json rename to backend/.sqlx/query-d8a2e5a84611aba22339339296a45af3582be51fcfd8a99336f5594c649674e1.json index 4b34b5b..05ee42d 100644 --- a/backend/.sqlx/query-88f23b59e72f3d364e0c83c1046b05c8fa7243caa6e1a6aba4a9228531156474.json +++ b/backend/.sqlx/query-d8a2e5a84611aba22339339296a45af3582be51fcfd8a99336f5594c649674e1.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, username, email FROM users", + "query": "SELECT id, username, email, password_hash FROM users", "describe": { "columns": [ { @@ -17,16 +17,22 @@ "ordinal": 2, "name": "email", "type_info": "Text" + }, + { + "ordinal": 3, + "name": "password_hash", + "type_info": "Text" } ], "parameters": { "Left": [] }, "nullable": [ + false, false, false, false ] }, - "hash": "88f23b59e72f3d364e0c83c1046b05c8fa7243caa6e1a6aba4a9228531156474" + "hash": "d8a2e5a84611aba22339339296a45af3582be51fcfd8a99336f5594c649674e1" } diff --git a/backend/.sqlx/query-db084199deff63e4f835ebd091e7c461a755fccf5c37ebc642e9a53f1af1bcd3.json b/backend/.sqlx/query-db084199deff63e4f835ebd091e7c461a755fccf5c37ebc642e9a53f1af1bcd3.json new file mode 100644 index 0000000..b4ba2a5 --- /dev/null +++ b/backend/.sqlx/query-db084199deff63e4f835ebd091e7c461a755fccf5c37ebc642e9a53f1af1bcd3.json @@ -0,0 +1,42 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET username = $1, password_hash = $2 WHERE id = $3 RETURNING id, username, email, password_hash", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "password_hash", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "db084199deff63e4f835ebd091e7c461a755fccf5c37ebc642e9a53f1af1bcd3" +} diff --git a/backend/.sqlx/query-f7d9635ff56737d43d1afb740f08b90a4f22fe46d0a5aa8df53b28f79ecfcdae.json b/backend/.sqlx/query-f7d9635ff56737d43d1afb740f08b90a4f22fe46d0a5aa8df53b28f79ecfcdae.json new file mode 100644 index 0000000..3c3c686 --- /dev/null +++ b/backend/.sqlx/query-f7d9635ff56737d43d1afb740f08b90a4f22fe46d0a5aa8df53b28f79ecfcdae.json @@ -0,0 +1,42 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET email = $1, password_hash = $2 WHERE id = $3 RETURNING id, username, email, password_hash", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "password_hash", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "f7d9635ff56737d43d1afb740f08b90a4f22fe46d0a5aa8df53b28f79ecfcdae" +} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index f531389..de35fc5 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -114,12 +114,14 @@ dependencies = [ name = "api-server" version = "0.1.0" dependencies = [ + "argon2 0.4.1", "axum", "chrono", "dotenvy", "env_logger", "log", "pyo3", + "rand_core 0.6.4", "serde", "serde_json", "shared-logic", @@ -128,6 +130,29 @@ dependencies = [ "tower-http", ] +[[package]] +name = "argon2" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db4ce4441f99dbd377ca8a8f57b698c44d0d6e712d8329b5040da5a64aa1ce73" +dependencies = [ + "base64ct", + "blake2", + "password-hash 0.4.2", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash 0.5.0", +] + [[package]] name = "async-trait" version = "0.1.88" @@ -263,6 +288,15 @@ dependencies = [ "serde", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -1309,6 +1343,28 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "password-hash" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -1792,6 +1848,7 @@ dependencies = [ name = "shared-logic" version = "0.1.0" dependencies = [ + "argon2 0.5.3", "chrono", "dotenvy", "futures-util", @@ -1799,8 +1856,10 @@ dependencies = [ "lsl", "numpy", "once_cell", + "password-hash 0.5.0", "pyo3", "rand 0.8.5", + "rand_core 0.6.4", "serde", "serde_json", "sqlx", diff --git a/backend/api-server/Cargo.toml b/backend/api-server/Cargo.toml index 4215795..0bdef5e 100644 --- a/backend/api-server/Cargo.toml +++ b/backend/api-server/Cargo.toml @@ -9,6 +9,9 @@ edition = "2021" axum = { version = "0.7", features = ["macros"] } # Tower for HTTP services (used by Axum) tower-http = { version = "0.5", features = ["cors"] } # For CORS, logging, etc. +argon2 = "0.4" +rand_core = "0.6" + # Serialization/Deserialization for JSON serde = { version = "1.0", features = ["derive"] } diff --git a/backend/api-server/src/main.rs b/backend/api-server/src/main.rs index 88671c6..da11164 100644 --- a/backend/api-server/src/main.rs +++ b/backend/api-server/src/main.rs @@ -1,6 +1,5 @@ use axum::{ extract::State, - extract::Path, http::StatusCode, routing::{get, post}, Json, @@ -17,100 +16,153 @@ use pyo3::Python; use pyo3::types::{PyList, PyModule, PyTuple}; use pyo3::PyResult; use pyo3::{IntoPy, ToPyObject}; +use rand_core::OsRng; // shared logic library use shared_logic::db::{initialize_connection, DbClient}; use shared_logic::models::{User, NewUser, UpdateUser, Session, FrontendState}; +// Argon2 imports +use argon2::{ + Argon2, + password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, +}; + + // Define application state #[derive(Clone)] struct AppState { db_client: DbClient, } + +#[derive(Debug, Clone, Deserialize)] +pub struct LoginRequest { + pub email: String, + pub password: String, +} + +#[derive(Debug, Serialize)] +struct PublicUser { + id: i32, + username: String, + email: String, +} + +#[derive(Debug, Deserialize)] +struct DeleteUserRequest { + id: i32, +} + + // creates new user when POST /users is called async fn create_user( State(app_state): State, Json(new_user_data): Json, -) -> Result, (StatusCode, String)> { +) -> Result, (StatusCode, String)> { info!("Received request to create user: {:?}", new_user_data); - match shared_logic::db::add_user( + // Generate a random salt + let salt = SaltString::generate(&mut OsRng); + + // Hash the password + let argon2 = Argon2::default(); + let password_hash = argon2 + .hash_password(new_user_data.password.as_bytes(), &salt) + .map_err(|e| { + error!("Failed to hash password: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, "Password hashing failed".into()) + })? + .to_string(); + + // Store user in DB + let created_user = shared_logic::db::add_user( &app_state.db_client, new_user_data, + password_hash, ) .await - { - Ok(created_user) => { - info!("User created successfully: {:?}", created_user); - Ok(Json(created_user)) - } - Err(e) => { - error!("Failed to create user: {}", e); - Err(( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to create user: {}", e), - )) - } + .map_err(|e| { + error!("Failed to create user: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create user: {}", e)) + })?; + + // Convert to PublicUser to hide password + let public_user = PublicUser { + id: created_user.id, + username: created_user.username, + email: created_user.email, + }; + + info!("User created successfully: {:?}", public_user); + Ok(Json(public_user)) +} + + + +async fn login_user( + State(app_state): State, + Json(login_data): Json, +) -> Result, (StatusCode, String)> { + info!("Login attempt for email: {}", login_data.email); + + // Fetch user from DB by email + let user = match shared_logic::db::get_user_by_email(&app_state.db_client, &login_data.email).await { + Ok(u) => u, + Err(_) => return Err((StatusCode::UNAUTHORIZED, "Invalid email or password".into())), + }; + + // Verify password + let parsed_hash = PasswordHash::new(&user.password_hash) + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Password parsing failed".into()))?; + + if Argon2::default().verify_password(login_data.password.as_bytes(), &parsed_hash).is_ok() { + let public_user = PublicUser { + id: user.id, + username: user.username, + email: user.email, + }; + Ok(Json(public_user)) + } else { + Err((StatusCode::UNAUTHORIZED, "Invalid email or password".into())) } } + // Handler for GET /users // This function will retrieve all users from the database. async fn get_all_users( - State(app_state): State, // Access shared database client -) -> Result>, (StatusCode, String)> { + State(app_state): State, +) -> Result>, (StatusCode, String)> { info!("Received request to get all users"); - match shared_logic::db::get_users(&app_state.db_client).await { - Ok(users) => { - info!("Retrieved {} users.", users.len()); - Ok(Json(users)) // Return list of users as JSON - } - Err(e) => { - error!("Failed to retrieve users: {}", e); - Err(( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to retrieve users: {}", e), - )) - } - } -} + let users = shared_logic::db::get_users(&app_state.db_client).await.map_err(|e| { + error!("Failed to retrieve users: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to retrieve users: {}", e)) + })?; -// Handler for PUT /users/:id -async fn update_user( - State(app_state): State, - Path(id): Path, - Json(update_data): Json, -) -> Result, (StatusCode, String)> { - info!("Received request to update user {}: {:?}", id, update_data); - - match shared_logic::db::update_user(&app_state.db_client, id, update_data).await { - Ok(user) => { - info!("User updated successfully: {:?}", user); - Ok(Json(user)) - } - Err(e) => { - error!("Failed to update user: {}", e); - Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to update user: {}", e))) - } - } + let public_users: Vec = users.into_iter().map(|u| PublicUser { + id: u.id, + username: u.username, + email: u.email, + }).collect(); + + Ok(Json(public_users)) } -// Handler for DELETE /users/:id async fn delete_user( State(app_state): State, - Path(id): Path, + Json(payload): Json, ) -> Result { - info!("Received request to delete user {}", id); + let user_id = payload.id; - match shared_logic::db::delete_user(&app_state.db_client, id).await { + match shared_logic::db::delete_user(&app_state.db_client, user_id).await { Ok(_) => { - info!("User {} deleted", id); - Ok(StatusCode::NO_CONTENT) + log::info!("User {} deleted successfully", user_id); + Ok(StatusCode::OK) } Err(e) => { - error!("Failed to delete user: {}", e); + log::error!("Failed to delete user {}: {}", user_id, e); Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to delete user: {}", e))) } } @@ -195,9 +247,12 @@ async fn get_frontend_state( } + + async fn run_python_script_handler() -> Result, (StatusCode, String)> { info!("Received request to run Python script."); + // Python::with_gil needs to be run in a blocking context for async Rust let result = tokio::task::spawn_blocking(move || { Python::with_gil(|py| { @@ -205,6 +260,7 @@ async fn run_python_script_handler() -> Result, (StatusCode, String) let current_dir = env::current_dir()?; info!("API Server CWD for Python scripts: {:?}", current_dir); + // Adjust Python script directory to sys.path // Assuming 'python' and 'scripts' folders are at the workspace root level // relative to the `api-server` crate, it would be `../python` and `../scripts`. @@ -212,50 +268,59 @@ async fn run_python_script_handler() -> Result, (StatusCode, String) // the CWD is `backend-server`. So paths are relative to that. let sys = py.import("sys")?; let paths: &PyList = sys.getattr("path")?.downcast()?; - + // Add the directory containing your EyeBlink Python source // This path is relative to the backend-server/ directory - paths.insert(0, "./python/EyeBlink/src")?; + paths.insert(0, "./python/EyeBlink/src")?; info!("Added './python/EyeBlink/src' to Python sys.path"); + // Read and execute test.py let test_py_path = "./python/EyeBlink/src/test.py"; let test_py_src = fs::read_to_string(test_py_path)?; PyModule::from_code(py, &test_py_src, "test.py", "__main__")?; info!("Executed test.py"); + // Read and execute hello.py let hello_py_path = "./scripts/hello.py"; let hello_py_src = fs::read_to_string(hello_py_path)?; let module = PyModule::from_code(py, &hello_py_src, "hello.py", "hello")?; info!("Loaded hello.py module"); + // Call the 'test' function from hello.py let greet_func = module.getattr("test")?.to_object(py); let args = PyTuple::new(py, &[20_i32.into_py(py), 30_i32.into_py(py)]); let py_result = greet_func.call1(py, args)?; + let result_str: String = py_result.extract(py)?; info!("Result from Python: {}", result_str); + Ok(result_str) as PyResult }) }).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Python blocking task failed: {}", e)))?; + match result { Ok(s) => Ok(Json(json!({"python_output": s}))), Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Python script execution failed: {}", e))), } } + #[tokio::main] async fn main() { env_logger::init(); info!("Starting API server..."); + dotenv().ok(); info!("Environment variables loaded."); + let db_client = match initialize_connection().await { Ok(client) => { info!("Database connection initialized successfully."); @@ -267,16 +332,18 @@ async fn main() { } }; + let app_state = AppState { db_client: db_client.clone(), }; + // Build Axum router let app = Router::new() .route("/users", post(create_user)) + .route("/users/login", post(login_user)) // Login route .route("/users", get(get_all_users)) - .route("/users/:id", axum::routing::put(update_user)) - .route("/users/:id", axum::routing::delete(delete_user)) + .route("/users/delete", post(delete_user)) // .route("/run-python-script", get(run_python_script_handler)) .route("/api/sessions", post(create_session)) @@ -288,6 +355,7 @@ async fn main() { // Share application state with all handlers .with_state(app_state); + // Define the address and port for the server to listen on. let host = std::env::var("API_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); let port = std::env::var("API_PORT") @@ -296,13 +364,16 @@ async fn main() { .expect("Invalid API_PORT"); let addr = format!("{}:{}", host, port); + let listener = TcpListener::bind(&addr).await.unwrap_or_else(|e| { error!("Failed to bind to address {}: {}", addr, e); panic!("Exiting due to address binding failure."); }); + info!("API server listening on {}", addr); + // Start the server and wait for it to run. axum::serve(listener, app) .await diff --git a/backend/shared-logic/Cargo.toml b/backend/shared-logic/Cargo.toml index ed01e32..6003901 100644 --- a/backend/shared-logic/Cargo.toml +++ b/backend/shared-logic/Cargo.toml @@ -41,6 +41,11 @@ once_cell = "1.18" #lsl lsl = "0.1.1" +# Argon2 password hashing +argon2 = "0.5" +password-hash = "0.5" +rand_core = "0.6" + # working with python pyo3 = { version = "0.18.0", features = ["auto-initialize"] } numpy = "0.18" \ No newline at end of file diff --git a/backend/shared-logic/src/db.rs b/backend/shared-logic/src/db.rs index b62018b..d082c4b 100644 --- a/backend/shared-logic/src/db.rs +++ b/backend/shared-logic/src/db.rs @@ -11,6 +11,11 @@ use super::models::{User, NewUser, TimeSeriesData, UpdateUser, Session, Frontend use crate::{lsl::EEGDataPacket}; use once_cell::sync::OnceCell; use std::sync::Arc; +use argon2::password_hash::SaltString; +use rand_core::OsRng; +use argon2::{Argon2, password_hash::{PasswordHasher, PasswordHash, PasswordVerifier}}; + + pub static DB_POOL: OnceCell> = OnceCell::new(); @@ -59,13 +64,14 @@ pub fn get_db_client() -> DbClient { DB_POOL.get().expect("DB not initialized").clone() } -pub async fn add_user(client: &DbClient, new_user: NewUser) -> Result { +pub async fn add_user(client: &DbClient, new_user: NewUser, password_hash: String) -> Result { info!("Adding user: {} ({})", new_user.username, new_user.email); let user = sqlx::query_as!( User, - "INSERT INTO users (username, email) VALUES ($1, $2) RETURNING id, username, email", + "INSERT INTO users (username, email, password_hash) VALUES ($1, $2, $3) RETURNING id, username, email, password_hash", new_user.username, new_user.email, + password_hash ) .fetch_one(&**client) .await?; @@ -75,13 +81,24 @@ pub async fn add_user(client: &DbClient, new_user: NewUser) -> Result Result, Error> { info!("Retrieving users..."); - let users = sqlx::query_as!(User, "SELECT id, username, email FROM users") + let users = sqlx::query_as!(User, "SELECT id, username, email, password_hash FROM users") .fetch_all(&**client) .await?; info!("Retrieved {} users.", users.len()); Ok(users) } +pub async fn get_user_by_email(client: &DbClient, email: &str) -> Result { + sqlx::query_as!( + User, + "SELECT id, username, email, password_hash FROM users WHERE email = $1", + email + ) + .fetch_one(&**client) + .await +} + + /// Insert a new record into testtime_series using chrono's DateTime. pub async fn add_testtime_series_data( client: &DbClient, @@ -163,73 +180,114 @@ pub async fn insert_batch_eeg(client: &DbClient, packet: &EEGDataPacket) -> Resu /// Update a user by id. /// /// Returns the updated User on success. -pub async fn update_user(client: &DbClient, user_id: i32, updated: UpdateUser) -> Result { - info!("Updating user id {}", user_id); - - - // see what fields are being updated - if let Some(ref username) = updated.username { - info!("Updating username: {}", username); - } - - if let Some(ref email) = updated.email { - info!("Updating email: {}", email); - } +pub async fn update_user( + client: &DbClient, + user_id: i32, + updated: UpdateUser, +) -> Result { + log::info!("Updating user id {}", user_id); + + // Hash password if provided + let password_hash = if let Some(password) = &updated.password { + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + Some( + argon2 + .hash_password(password.as_bytes(), &salt) + .map_err(|e| { + log::error!("Password hashing failed: {}", e); + Error::RowNotFound // fallback error + })? + .to_string(), + ) + } else { + None + }; - // sql query to update user - let user = match (updated.username, updated.email) { - (Some(username), Some(email)) => { + // Build SQL query dynamically based on which fields are Some + let user = match (updated.username, updated.email, password_hash) { + (Some(username), Some(email), Some(password_hash)) => { sqlx::query_as!( User, - "UPDATE users SET username = $1, email = $2 WHERE id = $3 RETURNING id, username, email", + "UPDATE users SET username = $1, email = $2, password_hash = $3 WHERE id = $4 RETURNING id, username, email, password_hash", username, email, + password_hash, user_id ) - .fetch_one(&**client) + .fetch_one(&**client) .await? } - (Some(username), None) => { + (Some(username), Some(email), None) => { sqlx::query_as!( User, - "UPDATE users SET username = $1 WHERE id = $2 RETURNING id, username, email", + "UPDATE users SET username = $1, email = $2 WHERE id = $3 RETURNING id, username, email, password_hash", username, + email, user_id ) .fetch_one(&**client) .await? } - (None, Some(email)) => { + (Some(username), None, Some(password_hash)) => { sqlx::query_as!( User, - "UPDATE users SET email = $1 WHERE id = $2 RETURNING id, username, email", + "UPDATE users SET username = $1, password_hash = $2 WHERE id = $3 RETURNING id, username, email, password_hash", + username, + password_hash, + user_id + ) + .fetch_one(&**client) + .await? + } + (None, Some(email), Some(password_hash)) => { + sqlx::query_as!( + User, + "UPDATE users SET email = $1, password_hash = $2 WHERE id = $3 RETURNING id, username, email, password_hash", + email, + password_hash, + user_id + ) + .fetch_one(&**client) + .await? + } + (Some(username), None, None) => { + sqlx::query_as!( + User, + "UPDATE users SET username = $1 WHERE id = $2 RETURNING id, username, email, password_hash", + username, + user_id + ) + .fetch_one(&**client) + .await? + } + (None, Some(email), None) => { + sqlx::query_as!( + User, + "UPDATE users SET email = $1 WHERE id = $2 RETURNING id, username, email, password_hash", email, user_id ) .fetch_one(&**client) .await? } - (None, None) => { - info!("No fields to update for user id {}, user not updated", user_id); // unsure of what behavior to do in this case - return Err(Error::RowNotFound); // returning error for now, should this be allowed/be a different error? + (None, None, Some(password_hash)) => { + sqlx::query_as!( + User, + "UPDATE users SET password_hash = $1 WHERE id = $2 RETURNING id, username, email, password_hash", + password_hash, + user_id + ) + .fetch_one(&**client) + .await? + } + (None, None, None) => { + log::info!("No fields to update for user id {}, user not updated", user_id); + return Err(Error::RowNotFound); } }; - /// let user = sqlx::query_as!( - /// User, - /// r#" UPDATE users SET - /// username = COALESCE($1, username), - // email = COALESCE($2, email) - // WHERE id = $3 - // RETURNING id, username, email "#, - /// updated.username, - /// updated.email, - /// user_id - /// ) - /// .fetch_one(&**client) - /// .await?; - - info!("=User updated: {:?}", user); + log::info!("User updated: {:?}", user); Ok(user) } diff --git a/backend/shared-logic/src/models.rs b/backend/shared-logic/src/models.rs index d9fac77..dcee0e4 100644 --- a/backend/shared-logic/src/models.rs +++ b/backend/shared-logic/src/models.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; use chrono::{DateTime, Utc}; + use serde_json::Value; // Existing User struct (used for data coming OUT of the DB) @@ -8,8 +9,10 @@ pub struct User { pub id: i32, pub username: String, pub email: String, + pub password_hash: String, // store hashed password } + // Struct for creating a user (used for data coming INTO the API) // Because User derived Deserialize, the serde library (which Axum used to process incoming JSON request body) // expected all fields in User struct to be present in JSON you sent (id was not part of payload) @@ -17,14 +20,9 @@ pub struct User { pub struct NewUser { pub username: String, pub email: String, + pub password: String, // raw password comes from API request } -// Struct for updating a user (partial updates allowed) -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct UpdateUser { // the fields are optional, allowing you to update them individually if needed - pub username: Option, - pub email: Option, -} #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct TimeSeriesData { @@ -34,6 +32,13 @@ pub struct TimeSeriesData { pub metadata: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateUser { + pub username: Option, + pub email: Option, + pub password: Option, // new field for updating password +} + // Struct for session data #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct Session { diff --git a/backend/test-lsl/Cargo.toml b/backend/test-lsl/Cargo.toml new file mode 100644 index 0000000..30cb15f --- /dev/null +++ b/backend/test-lsl/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "test-lsl" +version = "0.1.0" +edition = "2024" + +[dependencies] +lsl-sys = "0.1.1" diff --git a/backend/test-lsl/src/main.rs b/backend/test-lsl/src/main.rs new file mode 100644 index 0000000..e7a11a9 --- /dev/null +++ b/backend/test-lsl/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("Hello, world!"); +} From 1325e4a57dddd281530c1eb01f509658252ecb6a Mon Sep 17 00:00:00 2001 From: hsn2004 Date: Sun, 23 Nov 2025 21:31:44 -0800 Subject: [PATCH 2/6] Adding changes in main.rs to align with user authentication implementation --- backend/api-server/src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/api-server/src/main.rs b/backend/api-server/src/main.rs index da11164..8e98605 100644 --- a/backend/api-server/src/main.rs +++ b/backend/api-server/src/main.rs @@ -1,5 +1,6 @@ use axum::{ extract::State, + extract::Path, http::StatusCode, routing::{get, post}, Json, From 93538d5a2a8cb011845f6c4a17aba45978a55e52 Mon Sep 17 00:00:00 2001 From: amaloney1 Date: Thu, 12 Feb 2026 20:44:58 -0800 Subject: [PATCH 3/6] WIP: WindowNode component in progress --- .../nodes/window-node/window-combo-box.tsx | 265 ++++++++++++++++++ .../nodes/window-node/window-node.tsx | 175 ++++++++++++ 2 files changed, 440 insertions(+) create mode 100644 frontend/components/nodes/window-node/window-combo-box.tsx create mode 100644 frontend/components/nodes/window-node/window-node.tsx diff --git a/frontend/components/nodes/window-node/window-combo-box.tsx b/frontend/components/nodes/window-node/window-combo-box.tsx new file mode 100644 index 0000000..513540e --- /dev/null +++ b/frontend/components/nodes/window-node/window-combo-box.tsx @@ -0,0 +1,265 @@ +import * as React from 'react'; +import { ChevronDown, ChevronUp } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Slider } from '@/components/ui/slider'; + +const filters = [ + { + value: 'lowpass', + label: 'Low Pass Filter', + }, + { + value: 'highpass', + label: 'High Pass Filter', + }, + { + value: 'bandpass', + label: 'Bandpass Filter', + }, +]; + +interface ComboBoxProps { + value?: string; + onValueChange?: (value: string) => void; + lowCutoff: number + highCutoff: number + setLowCutoff: (v: number) => void + setHighCutoff: (v: number) => void + isConnected?: boolean; + isDataStreamOn?: boolean; +} + +export default function ComboBox({ + value = 'lowpass', + onValueChange, + isConnected = false, + isDataStreamOn = false, +}: ComboBoxProps) { + const [isExpanded, setIsExpanded] = React.useState(false); + const titleRef = React.useRef(null); + const [sliderValue, setSliderValue] = React.useState([75]); + const [cutoff, setCutoff] = React.useState([75]); + + const [lowCutoff, setLowCutoff] = React.useState([25]); + const [highCutoff, setHighCutoff] = React.useState([75]); + + const toggleExpanded = () => { + setIsExpanded(!isExpanded); + }; + + const handleOptionSelect = (optionValue: string) => { + onValueChange?.(optionValue); + // Add animation delay before closing + setTimeout(() => { + setIsExpanded(false); + }, 100); + }; + + return ( +
+ {/* Main button/header */} + + + {/* Slider row under the header */} +
+ {value != 'bandpass' && ( +
+ { + setSliderValue(val); + + if (value === 'lowpass') { + setHighCutoff(val); + } + + if (value === 'highpass') { + setLowCutoff(val); + } + }} + max={100} + min={0} + step={1} + className="w-full" + /> +
+ 0 + 100 +
+
+ )} + + {/* Single slider for lowpass and highpass */} + {value == 'bandpass' && ( +
+ {/* Low cutoff */} +
+ + { + // prevent low from going above high + const next = + val[0] >= highCutoff[0] + ? highCutoff[0] - 1 + : val[0]; + setLowCutoff([next]); + }} + min={0} + max={100} + step={1} + className="w-full mb-1" + /> +
+ +
+ 0 + Low Cutoff + 100 +
+ + {/* High cutoff */} +
+ { + // prevent high from going below low + const next = + val[0] <= lowCutoff[0] + ? lowCutoff[0] + 1 + : val[0]; + setHighCutoff([next]); + }} + min={0} + max={100} + step={1} + className="w-full mb-1" + /> +
+ +
+ 0 + High Cutoff + 100 +
+
+ )} + +
+ + {/* Expandable options section */} +
+
+ {filters.map((filter) => ( + + ))} +
+
+
+ ); +} diff --git a/frontend/components/nodes/window-node/window-node.tsx b/frontend/components/nodes/window-node/window-node.tsx new file mode 100644 index 0000000..2034006 --- /dev/null +++ b/frontend/components/nodes/window-node/window-node.tsx @@ -0,0 +1,175 @@ +'use client'; +import React from 'react'; +import { Handle, Position, useReactFlow } from '@xyflow/react'; +import { useGlobalContext } from '@/context/GlobalContext'; +import WindowComboBox from './window-combo-box'; +import useWebsocket from '@/hooks/useWebsocket'; + +interface WindowNodeProps { + id?: string; + // data?: any; +} + +export default function WindowNode({ id }: WindowNodeProps) { + const DEFAULT_WINDOW_SIZE = 64; + const DEFAULT_OVERLAP_SIZE = 0; + + type WindowOption = 'default' | 'preset' | 'custom'; + + const [windowSize, setWindowSize] = React.useState(DEFAULT_WINDOW_SIZE); + const [overlapSize, setOverlapSize] = React.useState(DEFAULT_OVERLAP_SIZE); + const [selectedOption, setSelectedOption] = React.useState('default'); + + const [isConnected, setIsConnected] = React.useState(false); + + // Get React Flow instance + const reactFlowInstance = useReactFlow(); + + // Get data stream status from global context + const { dataStreaming } = useGlobalContext(); + + const { sendProcessingConfig } = useWebsocket(0, 0) + + const buildConfig = () => { + if (!isConnected) { + return { + chunk_size: DEFAULT_WINDOW_SIZE, + overlap_size: DEFAULT_OVERLAP_SIZE, + }; + } + + return { + chunk_size: windowSize, + overlap_size: overlapSize, + }; +}; + + // Check connection status and update state + const checkConnectionStatus = React.useCallback(() => { + try { + const edges = reactFlowInstance.getEdges(); + const nodes = reactFlowInstance.getNodes(); + + // Check if this node is connected to source node or any activated node + const isConnectedToActivatedNode = (nodeId: string, visited: Set = new Set()): boolean => { + if (visited.has(nodeId)) return false; // Prevent infinite loops + visited.add(nodeId); + + // Find incoming edges to this node + const incomingEdges = edges.filter(edge => edge.target === nodeId); + + for (const edge of incomingEdges) { + const sourceNode = nodes.find(n => n.id === edge.source); + if (!sourceNode) continue; + + // If source is a source-node, we're activated + if (sourceNode.type === 'source-node') { + return true; + } + + // If source is another node, check if it's activated + if (sourceNode.id && isConnectedToActivatedNode(sourceNode.id, visited)) { + return true; + } + } + + return false; + }; + + const isActivated = id ? isConnectedToActivatedNode(id) : false; + setIsConnected(isActivated); + } catch (error) { + console.error('Error checking connection:', error); + setIsConnected(false); + } + }, [id, reactFlowInstance]); + + const isValidConfig = + Number.isInteger(windowSize) && + windowSize > 0 && + Number.isInteger(overlapSize) && + overlapSize >= 0 && + overlapSize < windowSize; + + // Check connection status on mount and when edges might change + React.useEffect(() => { + checkConnectionStatus(); + + // Listen for custom edge change events + const handleEdgeChange = () => { + checkConnectionStatus(); + }; + + window.addEventListener('reactflow-edges-changed', handleEdgeChange); + + // Also set up periodic check as backup + const interval = setInterval(checkConnectionStatus, 1000); + + return () => { + window.removeEventListener('reactflow-edges-changed', handleEdgeChange); + clearInterval(interval); + }; + }, [checkConnectionStatus]); + + React.useEffect(() => { + if (!dataStreaming) return; + if(!isValidConfig) return; + sendProcessingConfig(buildConfig()); + }, [windowSize, overlapSize, selectedOption, isConnected, dataStreaming]) + + return ( +
+ {/* Input Handle - positioned to align with left circle */} + + + {/* Output Handle - positioned to align with right circle */} + + + {/* Just the ComboBox without Card wrapper */} + +
+ ); +} \ No newline at end of file From 948f31d1b2cbeab2d82afbcd0ba73841f0229d43 Mon Sep 17 00:00:00 2001 From: amaloney1 Date: Thu, 19 Feb 2026 05:13:10 -0800 Subject: [PATCH 4/6] frontend window node draft --- .../nodes/window-node/window-combo-box.tsx | 280 +++++++++--------- .../nodes/window-node/window-node.tsx | 12 +- .../ui-react-flow/react-flow-view.tsx | 2 + frontend/components/ui-sidebar/sidebar.tsx | 6 + frontend/hooks/useWebsocket.tsx | 165 +++++++++++ frontend/lib/processing.ts | 4 + 6 files changed, 320 insertions(+), 149 deletions(-) create mode 100644 frontend/hooks/useWebsocket.tsx diff --git a/frontend/components/nodes/window-node/window-combo-box.tsx b/frontend/components/nodes/window-node/window-combo-box.tsx index 513540e..1eb711c 100644 --- a/frontend/components/nodes/window-node/window-combo-box.tsx +++ b/frontend/components/nodes/window-node/window-combo-box.tsx @@ -1,55 +1,101 @@ import * as React from 'react'; import { ChevronDown, ChevronUp } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { Slider } from '@/components/ui/slider'; -const filters = [ - { - value: 'lowpass', - label: 'Low Pass Filter', - }, - { - value: 'highpass', - label: 'High Pass Filter', - }, - { - value: 'bandpass', - label: 'Bandpass Filter', - }, -]; +export type WindowOption = 'default' | 'preset' | 'custom'; interface ComboBoxProps { - value?: string; - onValueChange?: (value: string) => void; - lowCutoff: number - highCutoff: number - setLowCutoff: (v: number) => void - setHighCutoff: (v: number) => void + windowSize: number; + overlapSize: number; + selectedOption: WindowOption; + setWindowSize: (size: number) => void; + setOverlapSize: (size: number) => void; + setSelectedOption: (option: WindowOption) => void; isConnected?: boolean; isDataStreamOn?: boolean; } +const presetWindows: Array<{ value: WindowOption; label: string; size?: number }> = [ + { value: 'default', label: 'Default (64)', size: 64 }, + { value: 'preset', label: 'Preset A (placeholder: 4)', size: 4 }, + { value: 'preset', label: 'Preset B (placeholder: 6)', size: 6 }, + { value: 'custom', label: 'Custom' }, +]; + export default function ComboBox({ - value = 'lowpass', - onValueChange, + windowSize, + overlapSize, + selectedOption, + setWindowSize, + setOverlapSize, + setSelectedOption, isConnected = false, isDataStreamOn = false, }: ComboBoxProps) { const [isExpanded, setIsExpanded] = React.useState(false); - const titleRef = React.useRef(null); - const [sliderValue, setSliderValue] = React.useState([75]); - const [cutoff, setCutoff] = React.useState([75]); + const [customWindowInput, setCustomWindowInput] = React.useState(''); + const[customOverlapInput, setCustomOverlapInput] = React.useState(String(overlapSize)); + const[error, setError] = React.useState(''); - const [lowCutoff, setLowCutoff] = React.useState([25]); - const [highCutoff, setHighCutoff] = React.useState([75]); + React.useEffect(() => { + setCustomOverlapInput(String(overlapSize)); + }, [overlapSize]); const toggleExpanded = () => { setIsExpanded(!isExpanded); }; - const handleOptionSelect = (optionValue: string) => { - onValueChange?.(optionValue); - // Add animation delay before closing + const handlePresetSelect = (optionValue: WindowOption, size?: number) => { + setSelectedOption(optionValue); + if(typeof size == 'number'){ + setWindowSize(size); + if(overlapSize >= size){ + setOverlapSize(Math.max(0,size-1)); + } + } + if(optionValue !== 'custom'){ + setError(''); + setIsExpanded(false); + } + + setTimeout(() => { + setIsExpanded(false); + }, 100); + }; + + const submitCustomWindow = () => { + const parsed = Number(customWindowInput); + if(!Number.isInteger(parsed) || parsed <= 0){ + setError('Window size must be a positive integer'); + return; + } + if(overlapSize >= parsed){ + setError('Overlap size must be less than window size'); + return; + } + setSelectedOption('custom'); + setWindowSize(parsed); + setError(''); + setIsExpanded(false); + + setTimeout(() => { + setIsExpanded(false); + }, 100); + }; + + const submitCustomOverlap = () => { + const parsed = Number(customOverlapInput); + if(!Number.isInteger(parsed) || parsed < 0){ + setError('Overlap size must be a non-negative integer'); + return; + } + if(parsed >= windowSize){ + setError('Overlap size must be less than window size'); + return; + } + setOverlapSize(parsed); + setError(''); + setTimeout(() => { setIsExpanded(false); }, 100); @@ -99,11 +145,9 @@ export default function ComboBox({ {/* Filter text - larger, bold font with ref for measurement */} - {filters.find((filter) => filter.value === value) - ?.label || 'Low Pass Filter'} + Window Node @@ -132,98 +176,23 @@ export default function ComboBox({ - {/* Slider row under the header */} -
- {value != 'bandpass' && ( -
- { - setSliderValue(val); - - if (value === 'lowpass') { - setHighCutoff(val); - } - - if (value === 'highpass') { - setLowCutoff(val); - } - }} - max={100} - min={0} - step={1} - className="w-full" - /> -
- 0 - 100 -
+ {/* Collapsed Header */} + {!isExpanded && ( +
+
+ Size:{' '} + + {windowSize} +
- )} - - {/* Single slider for lowpass and highpass */} - {value == 'bandpass' && ( -
- {/* Low cutoff */} -
- - { - // prevent low from going above high - const next = - val[0] >= highCutoff[0] - ? highCutoff[0] - 1 - : val[0]; - setLowCutoff([next]); - }} - min={0} - max={100} - step={1} - className="w-full mb-1" - /> -
- -
- 0 - Low Cutoff - 100 -
- - {/* High cutoff */} -
- { - // prevent high from going below low - const next = - val[0] <= lowCutoff[0] - ? lowCutoff[0] + 1 - : val[0]; - setHighCutoff([next]); - }} - min={0} - max={100} - step={1} - className="w-full mb-1" - /> -
- -
- 0 - High Cutoff - 100 -
+
+ Overlap Size:{' '} + + {overlapSize} +
- )} - -
+
+ )} {/* Expandable options section */}
-
- {filters.map((filter) => ( +
+
Input size
+ {presetWindows.map((preset) => ( ))} + + {selectedOption === 'custom' && ( +
+ setCustomWindowInput(e.target.value.replace(/[^\d]/g, ''))} // Enfore integer-only input by stripping non-digits. + placeholder="Custom integer" + className="h-8 w-full rounded-md border border-gray-300 px-2 text-sm" + /> + +
+ )} + +
+
Overlap size
+
+ setCustomOverlapInput(e.target.value.replace(/[^\d]/g, ''))} + className="h-8 w-full rounded-md border border-gray-300 px-2 text-sm" + /> + +
+
+ + {error &&
{error}
}
-
+
); } diff --git a/frontend/components/nodes/window-node/window-node.tsx b/frontend/components/nodes/window-node/window-node.tsx index 2034006..ef9deb2 100644 --- a/frontend/components/nodes/window-node/window-node.tsx +++ b/frontend/components/nodes/window-node/window-node.tsx @@ -1,8 +1,8 @@ 'use client'; import React from 'react'; -import { Handle, Position, useReactFlow } from '@xyflow/react'; +import { Handle, Position, useReactFlow} from '@xyflow/react'; import { useGlobalContext } from '@/context/GlobalContext'; -import WindowComboBox from './window-combo-box'; +import WindowComboBox, {type WindowOption} from './window-combo-box'; import useWebsocket from '@/hooks/useWebsocket'; interface WindowNodeProps { @@ -14,8 +14,6 @@ export default function WindowNode({ id }: WindowNodeProps) { const DEFAULT_WINDOW_SIZE = 64; const DEFAULT_OVERLAP_SIZE = 0; - type WindowOption = 'default' | 'preset' | 'custom'; - const [windowSize, setWindowSize] = React.useState(DEFAULT_WINDOW_SIZE); const [overlapSize, setOverlapSize] = React.useState(DEFAULT_OVERLAP_SIZE); const [selectedOption, setSelectedOption] = React.useState('default'); @@ -28,7 +26,7 @@ export default function WindowNode({ id }: WindowNodeProps) { // Get data stream status from global context const { dataStreaming } = useGlobalContext(); - const { sendProcessingConfig } = useWebsocket(0, 0) + const { sendWindowingConfig } = useWebsocket(0, 0); const buildConfig = () => { if (!isConnected) { @@ -37,7 +35,7 @@ export default function WindowNode({ id }: WindowNodeProps) { overlap_size: DEFAULT_OVERLAP_SIZE, }; } - + return { chunk_size: windowSize, overlap_size: overlapSize, @@ -114,7 +112,7 @@ export default function WindowNode({ id }: WindowNodeProps) { React.useEffect(() => { if (!dataStreaming) return; if(!isValidConfig) return; - sendProcessingConfig(buildConfig()); + sendWindowingConfig(buildConfig()); }, [windowSize, overlapSize, selectedOption, isConnected, dataStreaming]) return ( diff --git a/frontend/components/ui-react-flow/react-flow-view.tsx b/frontend/components/ui-react-flow/react-flow-view.tsx index b273e92..3f25c97 100644 --- a/frontend/components/ui-react-flow/react-flow-view.tsx +++ b/frontend/components/ui-react-flow/react-flow-view.tsx @@ -27,6 +27,7 @@ import SourceNode from '@/components/nodes/source-node'; import FilterNode from '@/components/nodes/filter-node/filter-node'; import MachineLearningNode from '@/components/nodes/machine-learning-node/machine-learning-node'; import SignalGraphNode from '@/components/nodes/signal-graph-node/signal-graph-node'; +import WindowNode from '@/components/nodes/window-node/window-node'; import Sidebar from '@/components/ui-sidebar/sidebar'; import { @@ -42,6 +43,7 @@ const nodeTypes = { 'filter-node': FilterNode, 'machine-learning-node': MachineLearningNode, 'signal-graph-node': SignalGraphNode, + 'window-node': WindowNode, }; let id = 0; diff --git a/frontend/components/ui-sidebar/sidebar.tsx b/frontend/components/ui-sidebar/sidebar.tsx index 98eaff6..a7c6094 100644 --- a/frontend/components/ui-sidebar/sidebar.tsx +++ b/frontend/components/ui-sidebar/sidebar.tsx @@ -39,6 +39,12 @@ export default function Sidebar() { description: 'Visualize data and create charts', category: 'Nodes', }, + { + id: 'window-node', + label: 'Window Node', + description: 'Configure windowing parameters for data streams', + category: 'Nodes', + } ]; const [searchTerm, setSearchTerm] = useState(''); diff --git a/frontend/hooks/useWebsocket.tsx b/frontend/hooks/useWebsocket.tsx new file mode 100644 index 0000000..5b8531d --- /dev/null +++ b/frontend/hooks/useWebsocket.tsx @@ -0,0 +1,165 @@ +import { useEffect, useRef, useState } from 'react'; +// import { useGlobalContext } from '@/context/GlobalContext'; +import { ProcessingConfig, WindowingConfig } from '@/lib/processing'; + +export default function useWebsocket( + chartSize: number, + batchesPerSecond: number, + dataStreaming: boolean +) { +// type WsConfigMessage = +// | { type: 'processing'; payload: ProcessingConfig } +// | { type: 'windowing'; payload: WindowingConfig }; + + // const { dataStreaming } = useGlobalContext(); + const [renderData, setRenderData] = useState([]); + const bufferRef = useRef([]); + const wsRef = useRef(null); + const closingTimeoutRef = useRef(null); + const [isClosingGracefully, setIsClosingGracefully] = useState(false); + const processingConfigRef = useRef(null); + const windowingConfigRef = useRef(null); + + const intervalTime = 1000 / batchesPerSecond; + + const sendProcessingConfig = (config: ProcessingConfig) => { + processingConfigRef.current = config + + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify(config)) + console.log('Sent processing config:', config) + } + } + + const sendWindowingConfig = (config: WindowingConfig) => { + windowingConfigRef.current = config + + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify(config)) + console.log('Sent windowing config:', config) + } + } + + useEffect(() => { + console.log('data streaming:', dataStreaming); + + if (!dataStreaming && wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + if (!isClosingGracefully) { + console.log("Initiating graceful close..."); + setIsClosingGracefully(true); + wsRef.current.send('clientClosing'); + + closingTimeoutRef.current = setTimeout(() => { + console.warn("Timeout: No 'confirmed closing' received. Forcing WebSocket close."); + if (wsRef.current) { + wsRef.current.close(); + } + setIsClosingGracefully(false); + }, 5000); + } + return; + } + + if (!dataStreaming && (!wsRef.current || wsRef.current.readyState === WebSocket.CLOSED)) { + return; + } + + if (dataStreaming && (!wsRef.current || wsRef.current.readyState === WebSocket.CLOSED)) { + console.log("Opening new WebSocket connection..."); + const ws = new WebSocket('ws://localhost:8080'); + wsRef.current = ws; + + ws.onopen = () => { + console.log('WebSocket connection opened.'); + + if (processingConfigRef.current) { + ws.send(JSON.stringify(processingConfigRef.current)) + } + if (windowingConfigRef.current) { + ws.send(JSON.stringify(windowingConfigRef.current)) + } + }; + + ws.onmessage = (event) => { + const message = event.data; + if (message === 'confirmed closing') { + console.log("Received 'confirmed closing' from server. Proceeding to close."); + if (closingTimeoutRef.current) { + clearTimeout(closingTimeoutRef.current); + } + if (wsRef.current) { + wsRef.current.close(); + } + setIsClosingGracefully(false); + } else { + try { + const parsedData = JSON.parse(message); + bufferRef.current.push(parsedData); + } catch (e) { + console.error("Failed to parse non-confirmation message as JSON:", e, message); + } + } + }; + + ws.onclose = (event) => { + console.log('WebSocket connection closed:', event.code, event.reason); + wsRef.current = null; + setIsClosingGracefully(false); + }; + + ws.onerror = (error) => { + console.error('WebSocket error:', error); + if (closingTimeoutRef.current) { + clearTimeout(closingTimeoutRef.current); + } + setIsClosingGracefully(false); + }; + } + + const updateRenderData = () => { + if (bufferRef.current.length > 0) { + const nextBatch = bufferRef.current.splice( + 0, + Math.min(bufferRef.current.length, chartSize) + ); + setRenderData((prevData) => + [...(Array.isArray(prevData) ? prevData : []), ...nextBatch].slice(-chartSize) + ); + } + }; + + let intervalId: NodeJS.Timeout | null = null; + if (dataStreaming) { + intervalId = setInterval(updateRenderData, intervalTime); + } + + return () => { + console.log("Cleanup function running."); + if (intervalId) { + clearInterval(intervalId); + } + + if (closingTimeoutRef.current) { + clearTimeout(closingTimeoutRef.current); + } + + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && !isClosingGracefully) { + console.log("Component unmounting or dependencies changed: Initiating graceful close during cleanup."); + wsRef.current.send('clientClosing'); + closingTimeoutRef.current = setTimeout(() => { + console.warn("Timeout: No 'confirmed closing' received during cleanup. Forcing WebSocket close."); + if (wsRef.current) { + wsRef.current.close(); + } + }, 5000); + } else if (wsRef.current && wsRef.current.readyState !== WebSocket.CLOSED) { + console.log("Forcing immediate WebSocket close during cleanup."); + wsRef.current.close(); + } + wsRef.current = null; + setIsClosingGracefully(false); + }; + }, [chartSize, batchesPerSecond, dataStreaming, isClosingGracefully]); + + return { renderData, sendProcessingConfig, sendWindowingConfig }; +} \ No newline at end of file diff --git a/frontend/lib/processing.ts b/frontend/lib/processing.ts index df7ce91..9ce5e72 100644 --- a/frontend/lib/processing.ts +++ b/frontend/lib/processing.ts @@ -7,4 +7,8 @@ export type ProcessingConfig = { sfreq: number n_channels: number } +export type WindowingConfig = { + chunk_size: number + overlap_size: number +} \ No newline at end of file From 9310d2902f1c73bd2b782956ea07b7173035739a Mon Sep 17 00:00:00 2001 From: amaloney1 Date: Sun, 8 Mar 2026 16:21:45 -0700 Subject: [PATCH 5/6] full draft - frontend window node --- .../nodes/window-node/window-combo-box.tsx | 56 ++++++++++++------- 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/frontend/components/nodes/window-node/window-combo-box.tsx b/frontend/components/nodes/window-node/window-combo-box.tsx index 1eb711c..791995f 100644 --- a/frontend/components/nodes/window-node/window-combo-box.tsx +++ b/frontend/components/nodes/window-node/window-combo-box.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { ChevronDown, ChevronUp } from 'lucide-react'; +import { ChevronUp } from 'lucide-react'; import { cn } from '@/lib/utils'; export type WindowOption = 'default' | 'preset' | 'custom'; @@ -17,8 +17,8 @@ interface ComboBoxProps { const presetWindows: Array<{ value: WindowOption; label: string; size?: number }> = [ { value: 'default', label: 'Default (64)', size: 64 }, - { value: 'preset', label: 'Preset A (placeholder: 4)', size: 4 }, - { value: 'preset', label: 'Preset B (placeholder: 6)', size: 6 }, + { value: 'preset', label: 'Preset A (4)', size: 4 }, + { value: 'preset', label: 'Preset B (6)', size: 6 }, { value: 'custom', label: 'Custom' }, ]; @@ -35,7 +35,8 @@ export default function ComboBox({ const [isExpanded, setIsExpanded] = React.useState(false); const [customWindowInput, setCustomWindowInput] = React.useState(''); const[customOverlapInput, setCustomOverlapInput] = React.useState(String(overlapSize)); - const[error, setError] = React.useState(''); + const [windowError, setWindowError] = React.useState(''); + const [overlapError, setOverlapError] = React.useState(''); React.useEffect(() => { setCustomOverlapInput(String(overlapSize)); @@ -54,28 +55,33 @@ export default function ComboBox({ } } if(optionValue !== 'custom'){ - setError(''); + setWindowError(''); + setOverlapError(''); setIsExpanded(false); + setTimeout(() => { + setIsExpanded(false); + }, 100); + return; } + setCustomWindowInput(''); + setWindowError(''); - setTimeout(() => { - setIsExpanded(false); - }, 100); }; const submitCustomWindow = () => { const parsed = Number(customWindowInput); if(!Number.isInteger(parsed) || parsed <= 0){ - setError('Window size must be a positive integer'); + setWindowError('Window size must be a positive integer'); return; } if(overlapSize >= parsed){ - setError('Overlap size must be less than window size'); + setWindowError('Window size must be greater than overlap size'); return; } setSelectedOption('custom'); setWindowSize(parsed); - setError(''); + setWindowError(''); + setOverlapError(''); setIsExpanded(false); setTimeout(() => { @@ -86,15 +92,16 @@ export default function ComboBox({ const submitCustomOverlap = () => { const parsed = Number(customOverlapInput); if(!Number.isInteger(parsed) || parsed < 0){ - setError('Overlap size must be a non-negative integer'); + setOverlapError('Overlap size must be a non-negative integer'); return; } if(parsed >= windowSize){ - setError('Overlap size must be less than window size'); + setOverlapError('Overlap size must be less than window size'); return; } setOverlapSize(parsed); - setError(''); + setOverlapError(''); + setWindowError(''); setTimeout(() => { setIsExpanded(false); @@ -176,7 +183,7 @@ export default function ComboBox({
- {/* Collapsed Header */} + {/* Base Header */} {!isExpanded && (
@@ -204,7 +211,7 @@ export default function ComboBox({ 'max-height 0.3s ease-in-out, opacity 0.3s ease-in-out', }} > -
+
Input size
{presetWindows.map((preset) => (
)} + {selectedOption === 'custom' && windowError && ( +
{windowError}
+ )}
Overlap size
setCustomOverlapInput(e.target.value.replace(/[^\d]/g, ''))} + onChange={(e) => { + setCustomOverlapInput(e.target.value.replace(/[^\d]/g, '')); + setOverlapError(''); + }} className="h-8 w-full rounded-md border border-gray-300 px-2 text-sm" />
+ {overlapError &&
{overlapError}
} - {error &&
{error}
}
From 9faed30e0b92f538daff0ccb597b60a4fedee57f Mon Sep 17 00:00:00 2001 From: hsn2004 Date: Sun, 15 Mar 2026 15:40:33 -0700 Subject: [PATCH 6/6] Implementation of front end and backend for window node --- backend/shared-logic/src/bc.rs | 6 +- backend/shared-logic/src/db.rs | 2 +- backend/shared-logic/src/lsl.rs | 87 ++++++++-- backend/websocket-server/src/main.rs | 141 ++++++++++------ .../nodes/filter-node/filter-node.tsx | 22 +-- .../signal-graph-node/signal-graph-node.tsx | 9 +- .../nodes/window-node/window-node.tsx | 31 ++-- frontend/components/ui/progressbar.tsx | 3 +- frontend/context/GlobalContext.tsx | 29 ++-- frontend/package-lock.json | 150 ++++++++++++++++++ frontend/package.json | 1 + 11 files changed, 373 insertions(+), 108 deletions(-) diff --git a/backend/shared-logic/src/bc.rs b/backend/shared-logic/src/bc.rs index 0ed09a6..6b2baa2 100644 --- a/backend/shared-logic/src/bc.rs +++ b/backend/shared-logic/src/bc.rs @@ -2,7 +2,7 @@ use tokio::sync::broadcast; use tokio::sync::broadcast::Receiver; use crate::mockeeg::{generate_mock_data}; -use crate::lsl::{EEGDataPacket, ProcessingConfig, receive_eeg}; +use crate::lsl::{EEGDataPacket, ProcessingConfig, WindowingConfig, receive_eeg}; use crate::db::{insert_batch_eeg, get_db_client}; use futures_util::stream::SplitSink; use futures_util::{SinkExt}; @@ -15,6 +15,7 @@ use tokio::sync::Mutex; use std::sync::Arc; use std::time::Instant; use tokio_util::sync::CancellationToken; +use tokio::sync::watch; use log::{info, error}; @@ -23,6 +24,7 @@ pub async fn start_broadcast( write: Arc, Message>>>, cancel_token: CancellationToken, processing_config: ProcessingConfig, // takes in signal processing configuration from frontend + windowing_rx: watch::Receiver // takes in windowing configuration from frontend ) { let (tx, _rx) = broadcast::channel::>(1000); // size of the broadcast buffer, not recommand below 500, websocket will miss messages let rx_ws = tx.subscribe(); @@ -42,7 +44,7 @@ pub async fn start_broadcast( let sender_token = cancel_token.clone(); let sender = tokio::spawn(async move { // use the ProcessingConfig provided by the client instead of default - receive_eeg(tx_clone, sender_token, processing_config).await; + receive_eeg(tx_clone, sender_token, processing_config, windowing_rx).await; }); // Subscribe for websocket Receiver diff --git a/backend/shared-logic/src/db.rs b/backend/shared-logic/src/db.rs index d082c4b..8ffafa7 100644 --- a/backend/shared-logic/src/db.rs +++ b/backend/shared-logic/src/db.rs @@ -353,7 +353,7 @@ pub async fn delete_session(client: &DbClient, session_id: i32) -> Result<(), Er .execute(&**client) .await?; - if (res.rows_affected() == 0) { + if res.rows_affected() == 0 { info!("No rows deleted, session id {} not found", session_id); return Err(Error::RowNotFound); } else { diff --git a/backend/shared-logic/src/lsl.rs b/backend/shared-logic/src/lsl.rs index d7fb400..3d17030 100644 --- a/backend/shared-logic/src/lsl.rs +++ b/backend/shared-logic/src/lsl.rs @@ -39,8 +39,23 @@ impl Default for ProcessingConfig { } } +#[derive(Clone, Deserialize, Debug)] +pub struct WindowingConfig { + pub chunk_size: usize, + pub overlap_size: usize, +} + +impl Default for WindowingConfig { + fn default() -> Self { + Self { + chunk_size: 64, + overlap_size: 0, + } + } +} + // Async entry point for EEG data collection. -pub async fn receive_eeg(tx:Sender>, cancel_token: CancellationToken, processing_config: ProcessingConfig) { +pub async fn receive_eeg(tx:Sender>, cancel_token: CancellationToken, processing_config: ProcessingConfig, windowing_rx: tokio::sync::watch::Receiver,) { info!("Starting EEG data receiver"); let python_script_path = std::env::var("SIGNAL_PROCESSING_SCRIPT") .unwrap_or_else(|_| "../shared-logic/src/signal_processing/signalProcessing.py".to_string()); @@ -67,7 +82,7 @@ pub async fn receive_eeg(tx:Sender>, cancel_token: Cancellati }; // Run collection loop - run_eeg_collection(inlet, tx, cancel_token, processing_config, sig_processor) + run_eeg_collection(inlet, tx, cancel_token, processing_config, sig_processor, windowing_rx) }); // Handle results @@ -100,16 +115,39 @@ fn setup_eeg_stream() -> Result { // Main EEG data collection loop. // Returns (successful_count, dropped_count) statistics. -fn run_eeg_collection(inlet: StreamInlet, tx: Sender>, cancel_token: CancellationToken, config: ProcessingConfig, processor: SignalProcessor) -> (u32, u32) { +fn run_eeg_collection(inlet: StreamInlet, + tx: Sender>, + cancel_token: CancellationToken, + config: ProcessingConfig, + processor: SignalProcessor, + mut windowing_rx: tokio::sync::watch::Receiver, +) -> (u32, u32) { let mut count = 0; let mut drop = 0; + + + let mut windowing = windowing_rx.borrow().clone(); + + // Creates a buffer that stores overlapping eeg samples + let mut overlap_buffer: Vec> = vec![Vec::new(); 4]; + let mut packet = EEGDataPacket { - timestamps: Vec::with_capacity(65), - signals: vec![Vec::with_capacity(65); 4], + timestamps: Vec::with_capacity(windowing.chunk_size + 1), + signals: vec![Vec::with_capacity(windowing.chunk_size + 1); 4], }; + // Calculate the offset between LSL clock and Unix epoch let lsl_to_unix_offset = Utc::now().timestamp_nanos_opt().unwrap() as f64 / 1_000_000_000.0 - lsl::local_clock(); loop { + if windowing_rx.has_changed().unwrap_or(false) { + windowing = windowing_rx.borrow().clone(); + info!("Windowing config updated: chunk={}, overlap={}", windowing.chunk_size, windowing.overlap_size); + // Discard old buffer and start fresh with new config + packet.timestamps.clear(); + for ch in &mut packet.signals { ch.clear(); } + for ch in &mut overlap_buffer { ch.clear(); } + } + // Check for cancellation if cancel_token.is_cancelled() { info!("EEG data receiver cancelled."); @@ -130,10 +168,34 @@ fn run_eeg_collection(inlet: StreamInlet, tx: Sender>, cancel // Pull sample with timeout of 1 sec. If it does not see data for 1s, it returns. match inlet.pull_sample(1.0) { Ok((sample, timestamp)) => { - match accumulate_sample(&sample, timestamp + lsl_to_unix_offset, &mut packet) { + match accumulate_sample(&sample, timestamp + lsl_to_unix_offset, &mut packet, windowing.chunk_size) { Ok(true) => { + // Window is full. Prepend overlap from previous window if there are any + if windowing.overlap_size > 0 && !overlap_buffer[0].is_empty() { + // Insert overlap samples at the front of the packet + for (ch_idx, ch) in packet.signals.iter_mut().enumerate() { + let mut new_ch = overlap_buffer[ch_idx].clone(); + new_ch.extend_from_slice(ch); + *ch = new_ch; + } + + // Timestamps: prepend placeholders (or track overlap timestamps) + // For simplicity, pad with copies of the first timestamp + let first_ts = packet.timestamps[0]; + let mut new_ts = vec![first_ts; windowing.overlap_size]; + new_ts.extend_from_slice(&packet.timestamps); + packet.timestamps = new_ts; + } + + // Save the tail as the new overlap_buffer + let n = packet.signals[0].len(); + let keep = windowing.overlap_size.min(n); + for (ch_idx, ch) in packet.signals.iter().enumerate() { + overlap_buffer[ch_idx] = ch[n - keep..].to_vec(); + } + // Packet is full, send it - info!("Packet is full, send it"); + info!("Packet is full, sending window: {} samples (overlap: {})", packet.signals[0].len(), keep); match process_and_send(&mut packet, &processor, &config, &tx) { Ok(_) => count += 1, Err(e) => { @@ -146,9 +208,7 @@ fn run_eeg_collection(inlet: StreamInlet, tx: Sender>, cancel channel.clear(); } } - Ok(false) => { - // Sample added, but packet not full yet - } + Ok(false) => {} // Sample added, but packet not full yet Err(e) => { let error_msg = e.to_string(); if error_msg.contains("Invalid sample length: got 0 channels") { @@ -179,7 +239,8 @@ fn run_eeg_collection(inlet: StreamInlet, tx: Sender>, cancel fn accumulate_sample( sample: &[f32], timestamp: f64, - packet: &mut EEGDataPacket + packet: &mut EEGDataPacket, + chunk_size: usize, ) -> Result { // Validate sample length if sample.len() < 4 { @@ -188,7 +249,7 @@ fn accumulate_sample( // Convert timestamp let timestamp_dt = DateTime::from_timestamp( - timestamp as i64, + timestamp as i64, (timestamp.fract() * 1_000_000_000.0) as u32 ).unwrap_or_else(|| Utc::now()); @@ -202,7 +263,7 @@ fn accumulate_sample( } // Check if packet is full - Ok(packet.signals[0].len() >= 65) + Ok(packet.signals[0].len() >= chunk_size) } // calls the signalProcessing.py to process the packet and sends it diff --git a/backend/websocket-server/src/main.rs b/backend/websocket-server/src/main.rs index 3d9b41f..76042ec 100644 --- a/backend/websocket-server/src/main.rs +++ b/backend/websocket-server/src/main.rs @@ -1,9 +1,10 @@ -use std::os::windows::process; +// use std::os::windows::process; use std::{sync::Arc}; use futures_util::stream::SplitSink; use futures_util::{SinkExt, StreamExt}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::Mutex; +use tokio::sync::watch; use tokio_tungstenite::{ accept_async, tungstenite::{Message}, @@ -12,10 +13,11 @@ use tokio_tungstenite::{ use tokio_util::sync::CancellationToken; use shared_logic::bc::{start_broadcast}; use shared_logic::db::{initialize_connection}; -use shared_logic::lsl::{ProcessingConfig}; // get ProcessingConfig from lsl.rs +use shared_logic::lsl::{ProcessingConfig, WindowingConfig}; // get ProcessingConfig from lsl.rs use dotenvy::dotenv; use log::{info, error}; use serde_json; // used to parse ProcessingConfig from JSON sent by frontend +use serde_json::Value; // used to parse ProcessingConfig from JSON sent by frontend #[tokio::main] @@ -75,83 +77,128 @@ async fn handle_ws(stream: TcpStream) { } } -// handle_connection, starts a async broadcast task, -// then listens for incoming websocket closing request with the read stream in order to stop the broadcast task. async fn handle_connection(ws_stream: WebSocketStream) { - let ( write, mut read) = ws_stream.split(); - // set up for the broadcast task - let write = Arc::new(Mutex::new(write)); + let (write, mut read) = ws_stream.split(); + let write = Arc::new(Mutex::new(write)); let write_clone = write.clone(); let cancel_token = CancellationToken::new(); let cancel_clone = cancel_token.clone(); - // setup registration for signal processing configuration - let signal_config = read.next().await; + let mut processing_config = ProcessingConfig::default(); + let mut initial_windowing = WindowingConfig::default(); - // we have the ProcessingConfig struct - // check if we received a message (two layers of unwrapping needed) - let processing_config: ProcessingConfig = match signal_config { + // Give the frontend a short window to send configs before we start + // Use a timeout so we don't block forever if only one config arrives + let config_timeout = tokio::time::Duration::from_millis(500); + let deadline = tokio::time::Instant::now() + config_timeout; - Some(Ok(config_json)) => { - - // here, we parse the json into a signal config struct using serde_json - let config_text = config_json.to_text().unwrap(); + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + info!("Config window elapsed, starting with current configs."); + break; + } - match serde_json::from_str(config_text) { - Ok(config) => config, - Err(e) => { - error!("Error parsing signal configuration JSON: {}", e); + match tokio::time::timeout(remaining, read.next()).await { + Ok(Some(Ok(msg))) if msg.is_text() => { + let text = msg.to_text().unwrap(); + if text == "clientClosing" { + info!("Client closing during config phase."); return; } + match parse_config_message(text) { + ConfigMessage::Processing(cfg) => { + info!("Received ProcessingConfig"); + processing_config = cfg; + } + ConfigMessage::Windowing(cfg) => { + info!("Received WindowingConfig: chunk={}, overlap={}", cfg.chunk_size, cfg.overlap_size); + initial_windowing = cfg; + } + ConfigMessage::Unknown => { + info!("Non-config message during config phase: {}", text); + break; + } + } } - + Ok(Some(Err(e))) => { error!("Error receiving config: {}", e); return; } + Ok(None) => { error!("Connection closed during config phase."); return; } + Err(_) => { + // Timeout elapsed + info!("Config timeout, starting with received configs."); + break; + } + Ok(_) => {} } + } - Some(Err(e)) => { - error!("Error receiving signal configuration: {}", e); - return; - } + info!("Starting broadcast with chunk={}, overlap={}", initial_windowing.chunk_size, initial_windowing.overlap_size); - None => { - error!("No signal configuration received from client. Closing connection."); - return; - } - }; + let (windowing_tx, windowing_rx) = watch::channel(initial_windowing); - // spawns the broadcast task let mut broadcast = Some(tokio::spawn(async move { - // pass ProcessingConfig into broadcast so it reaches receive_eeg - start_broadcast(write_clone, cancel_clone, processing_config).await; + start_broadcast(write_clone, cancel_clone, processing_config, windowing_rx).await; })); - - //listens for incoming messages while let Some(msg) = read.next().await { match msg { - Ok(msg) if msg.is_text() => { //prep for closing, this currently will not be called, waiting for frontend + Ok(msg) if msg.is_text() => { let text = msg.to_text().unwrap(); - info!("Received request: {}", text); if text == "clientClosing" { - handle_prep_close(&mut broadcast,&cancel_token, &write.clone()).await; + handle_prep_close(&mut broadcast, &cancel_token, &write).await; + break; + } + match parse_config_message(text) { + ConfigMessage::Windowing(cfg) => { + info!("Windowing update: chunk={}, overlap={}", cfg.chunk_size, cfg.overlap_size); + let _ = windowing_tx.send(cfg); + } + ConfigMessage::Processing(_) => { + info!("Processing config update received"); + } + ConfigMessage::Unknown => { + error!("Unknown mid-stream message: {}", text); + } } } - Ok(Message::Close(frame)) => { //handles closing. - info!("Received a close request from the client"); - // cancel_token.cancel(); // remove after frontend updates - let mut write = write.lock().await; - let _ = write.send(Message::Close(frame)).await; + Ok(Message::Close(frame)) => { + let mut w = write.lock().await; + let _ = w.send(Message::Close(frame)).await; break; } Ok(_) => continue, - Err(e) => { - error!("Read error (client likely disconnected): {}", e); - break; - } + Err(e) => { error!("Read error: {}", e); break; } } } info!("Client disconnected."); } +// Discriminate config type by which fields are present +enum ConfigMessage { + Processing(ProcessingConfig), + Windowing(WindowingConfig), + Unknown, +} + +fn parse_config_message(text: &str) -> ConfigMessage { + let Ok(value) = serde_json::from_str::(text) else { + return ConfigMessage::Unknown; + }; + if value.get("chunk_size").is_some() { + match serde_json::from_value::(value) { + Ok(cfg) => ConfigMessage::Windowing(cfg), + Err(e) => { error!("Failed to parse WindowingConfig: {}", e); ConfigMessage::Unknown } + } + } else if value.get("apply_bandpass").is_some() { + match serde_json::from_value::(value) { + Ok(cfg) => ConfigMessage::Processing(cfg), + Err(e) => { error!("Failed to parse ProcessingConfig: {}", e); ConfigMessage::Unknown } + } + } else { + ConfigMessage::Unknown + } +} + // handle_prep_close uses the cancel_token to stop the broadcast sender task, and sends a "prep close complete" message to the client async fn handle_prep_close( broadcast_task: &mut Option>, diff --git a/frontend/components/nodes/filter-node/filter-node.tsx b/frontend/components/nodes/filter-node/filter-node.tsx index b1e21d1..a02a02d 100644 --- a/frontend/components/nodes/filter-node/filter-node.tsx +++ b/frontend/components/nodes/filter-node/filter-node.tsx @@ -1,9 +1,9 @@ 'use client'; -import React from 'react'; -import { Handle, Position, useReactFlow } from '@xyflow/react'; import { useGlobalContext } from '@/context/GlobalContext'; -import ComboBox from './combo-box'; import { ProcessingConfig } from '@/lib/processing'; +import { Handle, Position, useReactFlow } from '@xyflow/react'; +import React from 'react'; +import ComboBox from './combo-box'; interface FilterNodeProps { id?: string; @@ -20,7 +20,7 @@ export default function FilterNode({ id }: FilterNodeProps) { const reactFlowInstance = useReactFlow(); // Get data stream status from global context - const { dataStreaming } = useGlobalContext(); + const { dataStreaming, sendProcessingConfig } = useGlobalContext() const buildConfig = (): ProcessingConfig => { if (!isConnected) { @@ -135,14 +135,14 @@ export default function FilterNode({ id }: FilterNodeProps) { }, [checkConnectionStatus]); React.useEffect(() => { - if (!dataStreaming) return; - window.dispatchEvent( - new CustomEvent('processing-config-update', { - detail: buildConfig(), - }) - ); - }, [selectedFilter, lowCutoff, highCutoff, isConnected, dataStreaming]); + if (!dataStreaming) return + sendProcessingConfig(buildConfig()) + }, [selectedFilter, lowCutoff, highCutoff, isConnected, dataStreaming]) + React.useEffect(() => { + sendProcessingConfig(buildConfig()); + }, []); + return (
{/* Input Handle - positioned to align with left circle */} diff --git a/frontend/components/nodes/signal-graph-node/signal-graph-node.tsx b/frontend/components/nodes/signal-graph-node/signal-graph-node.tsx index 544e67d..e00ea5e 100644 --- a/frontend/components/nodes/signal-graph-node/signal-graph-node.tsx +++ b/frontend/components/nodes/signal-graph-node/signal-graph-node.tsx @@ -1,9 +1,9 @@ import { Card } from '@/components/ui/card'; import { Handle, Position, useReactFlow } from '@xyflow/react'; -import useNodeData from '@/hooks/useNodeData'; -import React from 'react'; +// import useWebsocket from '@/hooks/useWebsocket'; import { useGlobalContext } from '@/context/GlobalContext'; import { ArrowUpRight } from 'lucide-react'; +import React from 'react'; import { Dialog, @@ -16,12 +16,13 @@ import { import SignalGraphView from './signal-graph-full'; export default function SignalGraphNode({ id }: { id?: string }) { - const { renderData } = useNodeData(20, 10); + // const { renderData } = useWebsocket(20, 10); + + const { renderData, dataStreaming } = useGlobalContext(); const processedData = renderData; const reactFlowInstance = useReactFlow(); const [isConnected, setIsConnected] = React.useState(false); - const { dataStreaming } = useGlobalContext() // Determine if this Chart View node has an upstream path from a Source const checkConnectionStatus = React.useCallback(() => { diff --git a/frontend/components/nodes/window-node/window-node.tsx b/frontend/components/nodes/window-node/window-node.tsx index ef9deb2..1d3151e 100644 --- a/frontend/components/nodes/window-node/window-node.tsx +++ b/frontend/components/nodes/window-node/window-node.tsx @@ -1,9 +1,9 @@ 'use client'; -import React from 'react'; -import { Handle, Position, useReactFlow} from '@xyflow/react'; import { useGlobalContext } from '@/context/GlobalContext'; -import WindowComboBox, {type WindowOption} from './window-combo-box'; -import useWebsocket from '@/hooks/useWebsocket'; +import { Handle, Position, useReactFlow } from '@xyflow/react'; +import React from 'react'; +import WindowComboBox, { type WindowOption } from './window-combo-box'; +// import useWebsocket from '@/hooks/useWebsocket'; interface WindowNodeProps { id?: string; @@ -24,23 +24,14 @@ export default function WindowNode({ id }: WindowNodeProps) { const reactFlowInstance = useReactFlow(); // Get data stream status from global context - const { dataStreaming } = useGlobalContext(); + const { dataStreaming, sendWindowingConfig } = useGlobalContext(); - const { sendWindowingConfig } = useWebsocket(0, 0); + // const { sendWindowingConfig } = useWebsocket(0, 0); - const buildConfig = () => { - if (!isConnected) { - return { - chunk_size: DEFAULT_WINDOW_SIZE, - overlap_size: DEFAULT_OVERLAP_SIZE, - }; - } - - return { + const buildConfig = () => ({ chunk_size: windowSize, overlap_size: overlapSize, - }; -}; + }); // Check connection status and update state const checkConnectionStatus = React.useCallback(() => { @@ -88,6 +79,10 @@ export default function WindowNode({ id }: WindowNodeProps) { Number.isInteger(overlapSize) && overlapSize >= 0 && overlapSize < windowSize; + + React.useEffect(() => { + sendWindowingConfig(buildConfig()); + }, []); // Check connection status on mount and when edges might change React.useEffect(() => { @@ -113,7 +108,7 @@ export default function WindowNode({ id }: WindowNodeProps) { if (!dataStreaming) return; if(!isValidConfig) return; sendWindowingConfig(buildConfig()); - }, [windowSize, overlapSize, selectedOption, isConnected, dataStreaming]) + }, [windowSize, overlapSize, selectedOption, isConnected, dataStreaming]); return (
diff --git a/frontend/components/ui/progressbar.tsx b/frontend/components/ui/progressbar.tsx index f5ec162..52408f4 100644 --- a/frontend/components/ui/progressbar.tsx +++ b/frontend/components/ui/progressbar.tsx @@ -1,8 +1,7 @@ "use client" -import * as React from "react" -import * as ProgressPrimitive from "@radix-ui/react-progress" import { cn } from "@/lib/utils" +import * as ProgressPrimitive from "@radix-ui/react-progress" export const ProgressBar = ({ value, diff --git a/frontend/context/GlobalContext.tsx b/frontend/context/GlobalContext.tsx index c22185c..4af5cc5 100644 --- a/frontend/context/GlobalContext.tsx +++ b/frontend/context/GlobalContext.tsx @@ -1,32 +1,41 @@ import React, { createContext, - useContext, - useState, ReactNode, + useContext, + useState } from 'react'; +import useWebsocket from '@/hooks/useWebsocket'; +import { ProcessingConfig, WindowingConfig } from '@/lib/processing'; + type GlobalContextType = { dataStreaming: boolean; setDataStreaming: React.Dispatch>; activeSessionId: number | null; setActiveSessionId: React.Dispatch>; + renderData: any[]; + sendProcessingConfig: (config: ProcessingConfig) => void; + sendWindowingConfig: (config: WindowingConfig) => void; }; const GlobalContext = createContext(undefined); export const GlobalProvider = ({ children }: { children: ReactNode }) => { const [dataStreaming, setDataStreaming] = useState(false); + + const { renderData, sendProcessingConfig, sendWindowingConfig } = useWebsocket(20, 10, dataStreaming); const [activeSessionId, setActiveSessionId] = useState(null); return ( - + {children} ); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 47435d3..f6bdd9f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -43,6 +43,7 @@ "@types/react-dom": "^18", "eslint": "^8", "eslint-config-next": "14.2.16", + "nodemon": "^3.1.14", "postcss": "^8", "prettier": "^3.6.2", "tailwindcss": "^3.4.1", @@ -112,6 +113,7 @@ "version": "7.26.7", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.7.tgz", "integrity": "sha512-SRijHmF0PSPgLIBYlWnG0hyeJLwXE2CgpsXaMOrtt2yp9/86ALw6oUlj9KYuZ0JN07T4eBMVIW4li/9S1j2BGA==", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", @@ -3457,6 +3459,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz", "integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==", "devOptional": true, + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -3467,6 +3470,7 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.5.tgz", "integrity": "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==", "devOptional": true, + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -3505,6 +3509,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.21.0.tgz", "integrity": "sha512-Wy+/sdEH9kI3w9civgACwabHbKl+qIOu0uFZ9IMKzX3Jpv9og0ZBJrZExGrPpFAY7rWsXuxs5e7CPPP17A4eYA==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.21.0", "@typescript-eslint/types": "8.21.0", @@ -3719,6 +3724,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4121,6 +4127,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -4954,6 +4961,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "peer": true, "engines": { "node": ">=12" } @@ -5467,6 +5475,7 @@ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5630,6 +5639,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz", "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", "dev": true, + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.8", @@ -6519,6 +6529,13 @@ "node": ">= 4" } }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -7532,6 +7549,97 @@ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==" }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/nodemon/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/nodemon/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nodemon/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/nodemon/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -7977,6 +8085,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", @@ -8142,6 +8251,13 @@ "react-is": "^16.13.1" } }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -8174,6 +8290,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -8185,6 +8302,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -8934,6 +9052,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -9284,6 +9415,7 @@ "version": "3.4.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -9374,6 +9506,16 @@ "node": ">=8.0" } }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, "node_modules/ts-api-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.0.tgz", @@ -9520,6 +9662,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "devOptional": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9546,6 +9689,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, "node_modules/undici-types": { "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", diff --git a/frontend/package.json b/frontend/package.json index 495e181..a232d54 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -44,6 +44,7 @@ "@types/react-dom": "^18", "eslint": "^8", "eslint-config-next": "14.2.16", + "nodemon": "^3.1.14", "postcss": "^8", "prettier": "^3.6.2", "tailwindcss": "^3.4.1",