feat(logging): Implement structured logging across the frontend
- Introduced a new `logger` module for structured logging with levels, timestamps, and styled console output. - Replaced ad-hoc console logging with structured logger in various modules including API client, WebSocket, auth, and feature components. - Enhanced logging in `app.rs`, `auth.rs`, `dashboard`, `messages`, `live`, and `polish` features. - Updated UI components to include logging for user interactions and state changes. - Rewrote `app.css` for a premium design overhaul, introducing glassmorphism, gradients, and improved responsiveness. - Added a new `plan.md` file outlining the scope and changes made in this commit.
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
use crate::api::client::{request, ApiError};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::{log_error, log_info, log_warn, make_logger};
|
||||
|
||||
make_logger!();
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LoginPayload {
|
||||
|
||||
@@ -2,6 +2,9 @@ use serde::de::DeserializeOwned;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use web_sys::{Headers, Request, RequestInit, RequestMode, Response};
|
||||
use crate::{log_debug, log_error, make_logger};
|
||||
|
||||
make_logger!();
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ApiError {
|
||||
@@ -44,9 +47,13 @@ pub async fn request<T: DeserializeOwned>(
|
||||
) -> Result<T, ApiError> {
|
||||
let url = format!("{}{}", get_base_url(), path);
|
||||
|
||||
let headers = Headers::new().map_err(|_| ApiError {
|
||||
message: "Failed to create headers".to_string(),
|
||||
status_code: 0,
|
||||
let headers = Headers::new().map_err(|_| {
|
||||
let msg = "Failed to create headers";
|
||||
log_error!("API {} {} failed: {}", method, path, msg);
|
||||
ApiError {
|
||||
message: msg.to_string(),
|
||||
status_code: 0,
|
||||
}
|
||||
})?;
|
||||
|
||||
if let Some(password) = get_auth_header() {
|
||||
@@ -57,6 +64,8 @@ pub async fn request<T: DeserializeOwned>(
|
||||
headers.set("Content-Type", "application/json").ok();
|
||||
}
|
||||
|
||||
log_debug!("{} {} ->", method, path);
|
||||
|
||||
let opts = RequestInit::new();
|
||||
opts.set_method(method);
|
||||
opts.set_headers(&headers);
|
||||
@@ -66,67 +75,106 @@ pub async fn request<T: DeserializeOwned>(
|
||||
opts.set_body(&JsValue::from_str(json_body));
|
||||
}
|
||||
|
||||
let request = Request::new_with_str_and_init(&url, &opts).map_err(|e| ApiError {
|
||||
message: format!("Failed to create request: {:?}", e),
|
||||
status_code: 0,
|
||||
let request = Request::new_with_str_and_init(&url, &opts).map_err(|e| {
|
||||
let msg = format!("Failed to create request: {:?}", e);
|
||||
log_error!("API {} {} failed: {}", method, path, msg);
|
||||
ApiError {
|
||||
message: msg,
|
||||
status_code: 0,
|
||||
}
|
||||
})?;
|
||||
|
||||
let window = web_sys::window().ok_or(ApiError {
|
||||
message: "No window".to_string(),
|
||||
status_code: 0,
|
||||
let window = web_sys::window().ok_or_else(|| {
|
||||
let msg = "No window";
|
||||
log_error!("API {} {} failed: {}", method, path, msg);
|
||||
ApiError {
|
||||
message: msg.to_string(),
|
||||
status_code: 0,
|
||||
}
|
||||
})?;
|
||||
|
||||
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
||||
.await
|
||||
.map_err(|e| ApiError {
|
||||
message: format!("Fetch failed: {:?}", e),
|
||||
status_code: 0,
|
||||
.map_err(|e| {
|
||||
let msg = format!("Fetch failed: {:?}", e);
|
||||
log_error!("API {} {} failed: {}", method, path, msg);
|
||||
ApiError {
|
||||
message: msg,
|
||||
status_code: 0,
|
||||
}
|
||||
})?;
|
||||
|
||||
let response: Response = resp_value.dyn_into().map_err(|_| ApiError {
|
||||
message: "Invalid response".to_string(),
|
||||
status_code: 0,
|
||||
let response: Response = resp_value.dyn_into().map_err(|_| {
|
||||
let msg = "Invalid response";
|
||||
log_error!("API {} {} failed: {}", method, path, msg);
|
||||
ApiError {
|
||||
message: msg.to_string(),
|
||||
status_code: 0,
|
||||
}
|
||||
})?;
|
||||
|
||||
let status = response.status();
|
||||
if status >= 400 {
|
||||
let text = JsFuture::from(response.text().map_err(|_| ApiError {
|
||||
message: "Failed to read error body".to_string(),
|
||||
status_code: status,
|
||||
let text = JsFuture::from(response.text().map_err(|_| {
|
||||
let msg = "Failed to read error body";
|
||||
log_error!("API {} {} failed: {}", method, path, msg);
|
||||
ApiError {
|
||||
message: msg.to_string(),
|
||||
status_code: status,
|
||||
}
|
||||
})?)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|v| v.as_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
log_error!("API {} {} failed: status={} {}", method, path, status, text);
|
||||
return Err(ApiError {
|
||||
message: text,
|
||||
status_code: status,
|
||||
});
|
||||
}
|
||||
|
||||
let text = JsFuture::from(response.text().map_err(|_| ApiError {
|
||||
message: "Failed to read response body".to_string(),
|
||||
status_code: status,
|
||||
let text = JsFuture::from(response.text().map_err(|_| {
|
||||
let msg = "Failed to read response body";
|
||||
log_error!("API {} {} failed: {}", method, path, msg);
|
||||
ApiError {
|
||||
message: msg.to_string(),
|
||||
status_code: status,
|
||||
}
|
||||
})?)
|
||||
.await
|
||||
.map_err(|_| ApiError {
|
||||
message: "Failed to await response".to_string(),
|
||||
status_code: status,
|
||||
.map_err(|_| {
|
||||
let msg = "Failed to await response";
|
||||
log_error!("API {} {} failed: {}", method, path, msg);
|
||||
ApiError {
|
||||
message: msg.to_string(),
|
||||
status_code: status,
|
||||
}
|
||||
})?
|
||||
.as_string()
|
||||
.ok_or(ApiError {
|
||||
message: "Response is not text".to_string(),
|
||||
status_code: status,
|
||||
.ok_or_else(|| {
|
||||
let msg = "Response is not text";
|
||||
log_error!("API {} {} failed: {}", method, path, msg);
|
||||
ApiError {
|
||||
message: msg.to_string(),
|
||||
status_code: status,
|
||||
}
|
||||
})?;
|
||||
|
||||
serde_json::from_str(&text).map_err(|e| ApiError {
|
||||
message: format!(
|
||||
log_debug!("{} {} <- {}", method, path, status);
|
||||
|
||||
serde_json::from_str(&text).map_err(|e| {
|
||||
let msg = format!(
|
||||
"JSON parse error: {} — body: {}",
|
||||
e,
|
||||
&text[..text.len().min(200)]
|
||||
),
|
||||
status_code: status,
|
||||
);
|
||||
log_error!("API {} {} failed: {}", method, path, msg);
|
||||
ApiError {
|
||||
message: msg,
|
||||
status_code: status,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use crate::api::client::{request, ApiError};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AppConfigResponse {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
use crate::api::client::{request, ApiError};
|
||||
use shared_types::dashboard::*;
|
||||
use crate::{log_debug, make_logger};
|
||||
|
||||
make_logger!();
|
||||
|
||||
/// GET /api/dashboard/stats
|
||||
pub async fn get_dashboard_stats() -> Result<DashboardStats, ApiError> {
|
||||
log_debug!("get_dashboard_stats");
|
||||
request("GET", "/api/dashboard/stats", None).await
|
||||
}
|
||||
|
||||
@@ -12,6 +16,7 @@ pub async fn get_dashboard_users(
|
||||
cursor: Option<&str>,
|
||||
search: Option<&str>,
|
||||
) -> Result<PaginatedUsers, ApiError> {
|
||||
log_debug!("get_dashboard_users: limit={:?}, cursor={:?}, search={:?}", limit, cursor, search);
|
||||
let mut path = "/api/dashboard/users".to_string();
|
||||
let mut params = vec![];
|
||||
if let Some(l) = limit {
|
||||
@@ -38,6 +43,7 @@ pub struct PaginatedUsers {
|
||||
|
||||
/// GET /api/dashboard/users/{userId}
|
||||
pub async fn get_dashboard_user_detail(user_id: &str) -> Result<DashboardUserDetail, ApiError> {
|
||||
log_debug!("get_dashboard_user_detail: user_id={}", user_id);
|
||||
request("GET", &format!("/api/dashboard/users/{}", user_id), None).await
|
||||
}
|
||||
|
||||
@@ -48,6 +54,7 @@ pub async fn get_dashboard_channels(
|
||||
search: Option<&str>,
|
||||
guild_id: Option<&str>,
|
||||
) -> Result<PaginatedChannels, ApiError> {
|
||||
log_debug!("get_dashboard_channels: limit={:?}, cursor={:?}, search={:?}, guild_id={:?}", limit, cursor, search, guild_id);
|
||||
let mut path = "/api/dashboard/channels".to_string();
|
||||
let mut params = vec![];
|
||||
if let Some(l) = limit {
|
||||
@@ -79,6 +86,7 @@ pub struct PaginatedChannels {
|
||||
pub async fn get_dashboard_channel_detail(
|
||||
channel_id: &str,
|
||||
) -> Result<DashboardChannelDetail, ApiError> {
|
||||
log_debug!("get_dashboard_channel_detail: channel_id={}", channel_id);
|
||||
request(
|
||||
"GET",
|
||||
&format!("/api/dashboard/channels/{}", channel_id),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use crate::api::client::{request, ApiError};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct MascotChatRequest<'a> {
|
||||
message: &'a str,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use crate::api::client::{request, ApiError};
|
||||
use shared_types::message::{MessageRecord, PageResult};
|
||||
use crate::{log_debug, make_logger};
|
||||
|
||||
make_logger!();
|
||||
|
||||
/// GET /api/messages?guildId=&limit=&channelId=&cursor=
|
||||
pub async fn get_messages(
|
||||
@@ -8,6 +11,7 @@ pub async fn get_messages(
|
||||
channel_id: Option<&str>,
|
||||
cursor: Option<&str>,
|
||||
) -> Result<PageResult<MessageRecord>, ApiError> {
|
||||
log_debug!("get_messages: guild_id={}, limit={:?}, channel_id={:?}, cursor={:?}", guild_id, limit, channel_id, cursor);
|
||||
let mut path = format!("/api/messages?guildId={}", guild_id);
|
||||
if let Some(l) = limit {
|
||||
path.push_str(&format!("&limit={}", l));
|
||||
@@ -27,6 +31,7 @@ pub async fn get_review_messages(
|
||||
limit: Option<u32>,
|
||||
channel_id: Option<&str>,
|
||||
) -> Result<PageResult<MessageRecord>, ApiError> {
|
||||
log_debug!("get_review_messages: limit={:?}, channel_id={:?}", limit, channel_id);
|
||||
let mut path = "/api/review".to_string();
|
||||
let mut params = vec![];
|
||||
if let Some(l) = limit {
|
||||
@@ -46,6 +51,7 @@ pub async fn get_images(
|
||||
guild_id: &str,
|
||||
limit: Option<u32>,
|
||||
) -> Result<PageResult<MessageRecord>, ApiError> {
|
||||
log_debug!("get_images: guild_id={}, limit={:?}", guild_id, limit);
|
||||
let mut path = format!("/api/messages/images?guildId={}", guild_id);
|
||||
if let Some(l) = limit {
|
||||
path.push_str(&format!("&limit={}", l));
|
||||
@@ -55,11 +61,13 @@ pub async fn get_images(
|
||||
|
||||
/// GET /api/messages/detail/{id}
|
||||
pub async fn get_message_detail(id: &str) -> Result<Option<MessageRecord>, ApiError> {
|
||||
log_debug!("get_message_detail: id={}", id);
|
||||
request("GET", &format!("/api/messages/detail/{}", id), None).await
|
||||
}
|
||||
|
||||
/// POST /api/messages/{id}/reanalyze
|
||||
pub async fn reanalyze_message(id: &str) -> Result<(), ApiError> {
|
||||
log_debug!("reanalyze_message: id={}", id);
|
||||
let _: serde_json::Value = request(
|
||||
"POST",
|
||||
&format!("/api/messages/{}/reanalyze", id),
|
||||
@@ -71,6 +79,7 @@ pub async fn reanalyze_message(id: &str) -> Result<(), ApiError> {
|
||||
|
||||
/// POST /api/messages/reanalyze-batch
|
||||
pub async fn reanalyze_batch() -> Result<u64, ApiError> {
|
||||
log_debug!("reanalyze_batch");
|
||||
#[derive(serde::Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct BatchResp {
|
||||
@@ -86,6 +95,7 @@ pub async fn search_messages(
|
||||
query: &str,
|
||||
limit: Option<u32>,
|
||||
) -> Result<Vec<MessageRecord>, ApiError> {
|
||||
log_debug!("search_messages: query={}, limit={:?}", query, limit);
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SearchResult {
|
||||
results: Vec<MessageRecord>,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use crate::api::client::{request, request_no_body, ApiError};
|
||||
use shared_types::recording::VoiceRecordingListResponse;
|
||||
|
||||
/// GET /api/recordings?limit=&cursor=
|
||||
pub async fn get_recordings(
|
||||
limit: Option<u32>,
|
||||
|
||||
@@ -3,7 +3,6 @@ use serde::Serialize;
|
||||
use shared_types::guild::{Channel, Guild};
|
||||
use shared_types::media::MediaState;
|
||||
use shared_types::voice::VoiceStatus;
|
||||
|
||||
/// GET /api/guilds
|
||||
pub async fn get_guilds() -> Result<Vec<Guild>, ApiError> {
|
||||
request("GET", "/api/guilds", None).await
|
||||
|
||||
+343
-1326
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,9 @@ use crate::ws::context::WsContext;
|
||||
use leptos::prelude::*;
|
||||
use shared_types::ui_state::Tab;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use crate::{log_info, log_warn, make_logger};
|
||||
|
||||
make_logger!();
|
||||
|
||||
/// Derive WebSocket URL from the page's own origin.
|
||||
/// In development (serve on :8080, backend on :3001) use the detected host + /ws path.
|
||||
@@ -77,6 +80,7 @@ pub fn App() -> impl IntoView {
|
||||
provide_context(ws.clone());
|
||||
|
||||
ws.connect();
|
||||
log_info!("App mounted, WS connecting to {}", get_ws_url());
|
||||
|
||||
// Try to fetch config on startup (works if password is already in localStorage)
|
||||
spawn_local({
|
||||
@@ -84,16 +88,11 @@ pub fn App() -> impl IntoView {
|
||||
async move {
|
||||
match config_api::get_config().await {
|
||||
Ok(cfg) => {
|
||||
web_sys::console::log_2(
|
||||
&"[config] fetched OK".into(),
|
||||
&format!("monitorGuildId={:?}", cfg.monitor_guild_id).into(),
|
||||
);
|
||||
log_info!("[config] fetched OK — monitorGuildId={:?}", cfg.monitor_guild_id);
|
||||
config.monitor_guild_id.set(cfg.monitor_guild_id);
|
||||
}
|
||||
Err(e) => {
|
||||
web_sys::console::log_1(
|
||||
&format!("[config] failed to fetch: {}", e).into(),
|
||||
);
|
||||
log_warn!("[config] failed to fetch: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,9 +109,7 @@ pub fn App() -> impl IntoView {
|
||||
config.monitor_guild_id.set(cfg.monitor_guild_id);
|
||||
}
|
||||
Err(e) => {
|
||||
web_sys::console::log_1(
|
||||
&format!("[config] fetch after auth failed: {}", e).into(),
|
||||
);
|
||||
log_info!("[config] fetch after auth failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ use crate::app::UiContext;
|
||||
use leptos::prelude::*;
|
||||
use shared_types::ui_state::Tab;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use crate::{log_error, log_info, log_warn, make_logger};
|
||||
|
||||
make_logger!();
|
||||
|
||||
#[component]
|
||||
pub fn AuthOverlay() -> impl IntoView {
|
||||
@@ -32,6 +35,7 @@ pub fn AuthOverlay() -> impl IntoView {
|
||||
spawn_local(async move {
|
||||
match auth_api::login(&pwd_clone).await {
|
||||
Ok(true) => {
|
||||
log_info!("Auth login successful");
|
||||
// Store password in sessionStorage
|
||||
if let Some(storage) = web_sys::window()
|
||||
.and_then(|w| w.local_storage().ok())
|
||||
@@ -43,9 +47,11 @@ pub fn AuthOverlay() -> impl IntoView {
|
||||
auth_clone.password.set(pwd_clone);
|
||||
}
|
||||
Ok(false) => {
|
||||
log_warn!("Auth login failed - wrong password");
|
||||
set_error_clone.set(Some("Login gagal — password salah".to_string()));
|
||||
}
|
||||
Err(e) => {
|
||||
log_error!("Auth login error: {}", e.message);
|
||||
set_error_clone.set(Some(format!("Error: {}", e.message)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use leptos::prelude::*;
|
||||
use shared_types::dashboard::{DashboardStats, TopChannel};
|
||||
|
||||
#[component]
|
||||
pub fn StatsOverview(
|
||||
stats: Option<DashboardStats>,
|
||||
|
||||
@@ -5,6 +5,9 @@ use leptos::prelude::*;
|
||||
use shared_types::dashboard::{DashboardChannel, DashboardStats, DashboardUser};
|
||||
use std::sync::Arc;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use crate::{log_error, log_info, make_logger};
|
||||
|
||||
make_logger!();
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
enum DashboardTab {
|
||||
@@ -36,10 +39,17 @@ pub fn DashboardPanel() -> impl IntoView {
|
||||
let fetch_stats: Arc<dyn Fn() + Send + Sync + 'static> = Arc::new(move || {
|
||||
stats_loading.set(true);
|
||||
stats_error.set(None);
|
||||
log_info!("Dashboard fetching stats...");
|
||||
spawn_local(async move {
|
||||
match crate::api::dashboard::get_dashboard_stats().await {
|
||||
Ok(data) => stats.set(Some(data)),
|
||||
Err(err) => stats_error.set(Some(format!("Failed to load stats: {}", err))),
|
||||
Ok(data) => {
|
||||
log_info!("Dashboard stats loaded: {} messages", data.total_messages);
|
||||
stats.set(Some(data));
|
||||
}
|
||||
Err(err) => {
|
||||
log_error!("Dashboard stats error: {}", err);
|
||||
stats_error.set(Some(format!("Failed to load stats: {}", err)));
|
||||
}
|
||||
}
|
||||
stats_loading.set(false);
|
||||
});
|
||||
@@ -51,6 +61,7 @@ pub fn DashboardPanel() -> impl IntoView {
|
||||
}
|
||||
users_loading.set(true);
|
||||
users_error.set(None);
|
||||
log_info!("Dashboard fetching users...");
|
||||
|
||||
let cursor = if reset { None } else { users_cursor.get() };
|
||||
let search = users_search.get();
|
||||
@@ -64,6 +75,7 @@ pub fn DashboardPanel() -> impl IntoView {
|
||||
.await
|
||||
{
|
||||
Ok(page) => {
|
||||
log_info!("Dashboard users loaded: {} users", page.data.len());
|
||||
if reset {
|
||||
users.set(page.data);
|
||||
} else {
|
||||
@@ -73,7 +85,10 @@ pub fn DashboardPanel() -> impl IntoView {
|
||||
}
|
||||
users_cursor.set(page.next_cursor);
|
||||
}
|
||||
Err(err) => users_error.set(Some(format!("Failed to load users: {}", err))),
|
||||
Err(err) => {
|
||||
log_error!("Dashboard users error: {}", err);
|
||||
users_error.set(Some(format!("Failed to load users: {}", err)));
|
||||
}
|
||||
}
|
||||
users_loading.set(false);
|
||||
});
|
||||
@@ -85,6 +100,7 @@ pub fn DashboardPanel() -> impl IntoView {
|
||||
}
|
||||
channels_loading.set(true);
|
||||
channels_error.set(None);
|
||||
log_info!("Dashboard fetching channels...");
|
||||
|
||||
let cursor = if reset { None } else { channels_cursor.get() };
|
||||
let search = channels_search.get();
|
||||
@@ -99,6 +115,7 @@ pub fn DashboardPanel() -> impl IntoView {
|
||||
.await
|
||||
{
|
||||
Ok(page) => {
|
||||
log_info!("Dashboard channels loaded: {} channels", page.data.len());
|
||||
if reset {
|
||||
channels.set(page.data);
|
||||
} else {
|
||||
@@ -108,7 +125,10 @@ pub fn DashboardPanel() -> impl IntoView {
|
||||
}
|
||||
channels_cursor.set(page.next_cursor);
|
||||
}
|
||||
Err(err) => channels_error.set(Some(format!("Failed to load channels: {}", err))),
|
||||
Err(err) => {
|
||||
log_error!("Dashboard channels error: {}", err);
|
||||
channels_error.set(Some(format!("Failed to load channels: {}", err)));
|
||||
}
|
||||
}
|
||||
channels_loading.set(false);
|
||||
});
|
||||
|
||||
@@ -12,6 +12,9 @@ use components::{
|
||||
use leptos::prelude::*;
|
||||
use shared_types::media::MediaState;
|
||||
use shared_types::voice::ActiveSpeaker;
|
||||
use crate::{log_debug, log_info, make_logger};
|
||||
|
||||
make_logger!();
|
||||
|
||||
/// LivePanel — Composition shell for all voice and media components.
|
||||
/// Shows an auth overlay if not authenticated, otherwise shows voice controls.
|
||||
@@ -28,10 +31,12 @@ pub fn LivePanel() -> impl IntoView {
|
||||
|
||||
// ── Wire WS events (runs on mount, persists while LivePanel is active) ──
|
||||
if let Some(ref ws) = ws {
|
||||
log_info!("LivePanel wiring WS handlers");
|
||||
// Voice active user — update speakers list
|
||||
*ws.on_voice_active_user.borrow_mut() = Some(Box::new({
|
||||
let speakers = speakers.clone();
|
||||
move |speaker: ActiveSpeaker| {
|
||||
log_debug!("LivePanel voice_active_user: {}", speaker.user_id);
|
||||
speakers.update(|list| {
|
||||
if let Some(pos) = list.iter().position(|s| s.user_id == speaker.user_id) {
|
||||
list[pos] = speaker;
|
||||
@@ -46,6 +51,7 @@ pub fn LivePanel() -> impl IntoView {
|
||||
*ws.on_media_state.borrow_mut() = Some(Box::new({
|
||||
let ms = media_state.clone();
|
||||
move |state: MediaState| {
|
||||
log_debug!("LivePanel media_state received");
|
||||
ms.set(Some(state));
|
||||
}
|
||||
}));
|
||||
@@ -54,6 +60,7 @@ pub fn LivePanel() -> impl IntoView {
|
||||
*ws.on_voice_recording_uploaded.borrow_mut() = Some(Box::new({
|
||||
let set_refresh = set_recordings_refresh;
|
||||
move |_recording| {
|
||||
log_debug!("LivePanel recording_uploaded received");
|
||||
set_refresh.update(|v| *v = v.wrapping_add(1));
|
||||
}
|
||||
}));
|
||||
@@ -62,6 +69,7 @@ pub fn LivePanel() -> impl IntoView {
|
||||
*ws.on_binary.borrow_mut() = Some(Box::new({
|
||||
let playback = audio_playback.clone();
|
||||
move |data: Vec<u8>| {
|
||||
log_debug!("LivePanel binary PCM data received: {} bytes", data.len());
|
||||
hooks::use_audio_playback::process_pcm_data(&playback, data);
|
||||
// Auto-start playback on first PCM data
|
||||
if !playback.active.get_untracked() {
|
||||
|
||||
@@ -4,6 +4,7 @@ use shared_types::message::{AiSeverity, AiStatus, AttachmentRef, MessageRecord};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────
|
||||
|
||||
fn custom_emoji_regex() -> &'static Regex {
|
||||
|
||||
@@ -6,6 +6,7 @@ use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
use web_sys::IntersectionObserver;
|
||||
|
||||
|
||||
const GROUP_WINDOW_MS: i64 = 5 * 60 * 1000;
|
||||
|
||||
fn group_messages(messages: Vec<MessageRecord>) -> Vec<Vec<MessageRecord>> {
|
||||
|
||||
@@ -4,6 +4,9 @@ use shared_types::message::{MessageRecord, PageResult};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use crate::{log_debug, log_error, log_info, log_trace, log_warn, make_logger};
|
||||
|
||||
make_logger!();
|
||||
|
||||
/// Merges current messages with incoming messages, deduplicating by ID and sorting
|
||||
pub fn merge_messages(current: &[MessageRecord], incoming: &[MessageRecord]) -> Vec<MessageRecord> {
|
||||
@@ -77,9 +80,11 @@ pub fn use_messages() -> MessagesState {
|
||||
async move {
|
||||
error_signal.set(None);
|
||||
set_loading.set(true);
|
||||
log_info!("Messages fetch start for guild {}", guild_id);
|
||||
|
||||
match get_messages(&guild_id, Some(30), None, None).await {
|
||||
Ok(PageResult { data, next_cursor }) => {
|
||||
log_info!("Messages fetch OK: count={}, cursor={:?}", data.len(), next_cursor);
|
||||
web_sys::console::log_3(
|
||||
&"[messages] fetch OK".into(),
|
||||
&format!("count={}", data.len()).into(),
|
||||
@@ -91,6 +96,7 @@ pub fn use_messages() -> MessagesState {
|
||||
set_loading.set(false);
|
||||
}
|
||||
Err(e) => {
|
||||
log_warn!("Messages fetch error: {}", e);
|
||||
web_sys::console::log_2(
|
||||
&"[messages] fetch ERROR".into(),
|
||||
&format!("{}", e).into(),
|
||||
@@ -124,15 +130,18 @@ pub fn use_messages() -> MessagesState {
|
||||
|
||||
loading_more_signal.set(true);
|
||||
error_signal.set(None);
|
||||
log_info!("Messages load more for guild {}", guild_id);
|
||||
|
||||
match get_messages(&guild_id, Some(30), None, Some(&cursor)).await {
|
||||
Ok(PageResult { data, next_cursor }) => {
|
||||
log_info!("Messages load more OK: count={}, cursor={:?}", data.len(), next_cursor);
|
||||
let current = messages_signal.get();
|
||||
messages_signal.set(merge_messages(¤t, &data));
|
||||
cursor_signal.set(next_cursor);
|
||||
loading_more_signal.set(false);
|
||||
}
|
||||
Err(e) => {
|
||||
log_warn!("Messages load more error: {}", e);
|
||||
error_signal.set(Some(format!("Failed to load more: {}", e)));
|
||||
loading_more_signal.set(false);
|
||||
}
|
||||
@@ -145,6 +154,7 @@ pub fn use_messages() -> MessagesState {
|
||||
spawn_local({
|
||||
let message_id = message_id.clone();
|
||||
async move {
|
||||
log_info!("Messages reanalyze start for message {}", message_id);
|
||||
// Optimistic: flip status to Processing
|
||||
let mut msgs = messages_signal.get();
|
||||
if let Some(pos) = msgs.iter().position(|m| m.id == message_id) {
|
||||
@@ -157,9 +167,11 @@ pub fn use_messages() -> MessagesState {
|
||||
// Call API
|
||||
match reanalyze_message(&message_id).await {
|
||||
Ok(_) => {
|
||||
log_info!("Messages reanalyze OK for message {}", message_id);
|
||||
// Success: keep the Processing status (will be updated via WS)
|
||||
}
|
||||
Err(e) => {
|
||||
log_warn!("Messages reanalyze error for message {}: {}", message_id, e);
|
||||
// Revert to Error status on failure
|
||||
let mut msgs = messages_signal.get();
|
||||
if let Some(pos) = msgs.iter().position(|m| m.id == message_id) {
|
||||
@@ -179,8 +191,10 @@ pub fn use_messages() -> MessagesState {
|
||||
// Reanalyze all error messages
|
||||
let reanalyze_all_errors_impl = Arc::new(move || {
|
||||
spawn_local(async move {
|
||||
log_info!("Messages reanalyze all errors start");
|
||||
match reanalyze_batch().await {
|
||||
Ok(_count) => {
|
||||
log_info!("Messages reanalyze all errors OK: count={}", _count);
|
||||
error_signal.set(None);
|
||||
// Optimistically mark all error messages as Processing
|
||||
let mut msgs = messages_signal.get();
|
||||
@@ -192,6 +206,7 @@ pub fn use_messages() -> MessagesState {
|
||||
messages_signal.set(msgs);
|
||||
}
|
||||
Err(e) => {
|
||||
log_info!("Messages reanalyze all errors failed: {}", e);
|
||||
error_signal.set(Some(format!("Batch reanalyze failed: {}", e)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ use leptos::prelude::*;
|
||||
use shared_types::message::{AiStatus, MessageRecord, PageResult};
|
||||
use std::sync::Arc;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use crate::{log_debug, log_error, log_info, log_trace, log_warn, make_logger};
|
||||
|
||||
make_logger!();
|
||||
|
||||
/// Threads whose messages should be hidden from both the feed and the
|
||||
/// Images tab. A bot or selfbot may be spamming in a thread, polluting
|
||||
@@ -109,13 +112,16 @@ pub fn MessagesPanel() -> impl IntoView {
|
||||
}
|
||||
set_is_searching.set(true);
|
||||
let q_clone = query.trim().to_string();
|
||||
log_info!("Messages searching for: {}", q_clone);
|
||||
spawn_local(async move {
|
||||
match crate::api::messages::search_messages(&q_clone, Some(50)).await {
|
||||
Ok(results) => {
|
||||
log_info!("Messages search found {} results", results.len());
|
||||
set_search_results.set(results);
|
||||
set_show_search.set(true);
|
||||
}
|
||||
Err(_) => {
|
||||
log_warn!("Messages search failed");
|
||||
set_search_results.set(Vec::new());
|
||||
}
|
||||
}
|
||||
@@ -143,10 +149,12 @@ pub fn MessagesPanel() -> impl IntoView {
|
||||
// WS event handlers (wire once on mount)
|
||||
let ws = use_context::<crate::ws::context::WsContext>();
|
||||
if let Some(ref ws) = ws {
|
||||
log_info!("MessagesPanel wiring WS handlers");
|
||||
// Subscribe to real-time message events
|
||||
{
|
||||
let msgs = state.messages;
|
||||
*ws.on_message_created.borrow_mut() = Some(Box::new(move |msg| {
|
||||
log_debug!("WS message_created received: {}", msg.id);
|
||||
let current = msgs.get();
|
||||
msgs.set(merge_messages(¤t, &[msg]));
|
||||
}));
|
||||
@@ -154,6 +162,7 @@ pub fn MessagesPanel() -> impl IntoView {
|
||||
{
|
||||
let msgs = state.messages;
|
||||
*ws.on_message_updated.borrow_mut() = Some(Box::new(move |msg| {
|
||||
log_debug!("WS message_updated received: {}", msg.id);
|
||||
let current = msgs.get();
|
||||
msgs.set(merge_messages(¤t, &[msg]));
|
||||
}));
|
||||
@@ -161,6 +170,7 @@ pub fn MessagesPanel() -> impl IntoView {
|
||||
{
|
||||
let msgs = state.messages;
|
||||
*ws.on_message_deleted.borrow_mut() = Some(Box::new(move |id| {
|
||||
log_debug!("WS message_deleted received: {}", id);
|
||||
let current = msgs.get();
|
||||
msgs.set(current.into_iter().filter(|m| m.id != id).collect());
|
||||
}));
|
||||
@@ -168,6 +178,7 @@ pub fn MessagesPanel() -> impl IntoView {
|
||||
{
|
||||
let msgs = state.messages;
|
||||
*ws.on_message_analyzed.borrow_mut() = Some(Box::new(move |msg| {
|
||||
log_debug!("WS message_analyzed received: {}", msg.id);
|
||||
let current = msgs.get();
|
||||
msgs.set(merge_messages(¤t, &[msg]));
|
||||
}));
|
||||
@@ -191,15 +202,13 @@ pub fn MessagesPanel() -> impl IntoView {
|
||||
let guild_id = use_context::<crate::app::AppConfig>()
|
||||
.and_then(|c| c.monitor_guild_id.get());
|
||||
if let Some(gid) = guild_id {
|
||||
log_info!("Messages fetching images for guild {}", gid);
|
||||
spawn_local({
|
||||
let image_messages = image_messages.clone();
|
||||
async move {
|
||||
match crate::api::messages::get_images(&gid, Some(100)).await {
|
||||
Ok(PageResult { data, .. }) => {
|
||||
web_sys::console::log_2(
|
||||
&"[images] fetch OK".into(),
|
||||
&format!("count={}", data.len()).into(),
|
||||
);
|
||||
log_info!("Messages images loaded: count={}", data.len());
|
||||
image_messages.set(
|
||||
data.into_iter()
|
||||
.filter(|m| !is_excluded_thread(m))
|
||||
@@ -207,10 +216,7 @@ pub fn MessagesPanel() -> impl IntoView {
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
web_sys::console::log_2(
|
||||
&"[images] fetch ERROR".into(),
|
||||
&format!("{}", e).into(),
|
||||
);
|
||||
log_error!("Messages images error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use crate::features::polish::{persist_theme, ThemeContext};
|
||||
use leptos::prelude::*;
|
||||
use crate::{log_info, make_logger};
|
||||
|
||||
make_logger!();
|
||||
|
||||
#[component]
|
||||
pub fn ThemeToggle() -> impl IntoView {
|
||||
@@ -21,6 +24,7 @@ pub fn ThemeToggle() -> impl IntoView {
|
||||
} else {
|
||||
"dark"
|
||||
};
|
||||
log_info!("Theme toggled to {}", next);
|
||||
ctx.theme.set(next.to_string());
|
||||
persist_theme(next);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
pub mod components;
|
||||
|
||||
use leptos::prelude::*;
|
||||
use crate::{log_info, make_logger};
|
||||
|
||||
make_logger!();
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ThemeContext {
|
||||
@@ -8,14 +11,17 @@ pub struct ThemeContext {
|
||||
}
|
||||
|
||||
pub fn initial_theme() -> String {
|
||||
web_sys::window()
|
||||
let theme = web_sys::window()
|
||||
.and_then(|window| window.local_storage().ok().flatten())
|
||||
.and_then(|storage| storage.get_item("imphnen-theme").ok().flatten())
|
||||
.filter(|value| value == "dark" || value == "light")
|
||||
.unwrap_or_else(|| "light".to_string())
|
||||
.unwrap_or_else(|| "light".to_string());
|
||||
log_info!("Initial theme resolved: {}", theme);
|
||||
theme
|
||||
}
|
||||
|
||||
pub fn persist_theme(theme: &str) {
|
||||
log_info!("Persisting theme: {}", theme);
|
||||
if let Some(storage) =
|
||||
web_sys::window().and_then(|window| window.local_storage().ok().flatten())
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ use super::tab_strip::TabStrip;
|
||||
use leptos::children::Children;
|
||||
use leptos::prelude::*;
|
||||
|
||||
|
||||
#[component]
|
||||
pub fn DashboardLayout(children: Children) -> impl IntoView {
|
||||
view! {
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::ws::context::WsContext;
|
||||
use crate::ws::socket::WsStatus;
|
||||
use leptos::prelude::*;
|
||||
|
||||
|
||||
#[component]
|
||||
pub fn Header() -> impl IntoView {
|
||||
let ws = use_context::<WsContext>().expect("WsContext not provided");
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::app::UiContext;
|
||||
use leptos::prelude::*;
|
||||
use shared_types::ui_state::Tab;
|
||||
|
||||
|
||||
#[component]
|
||||
pub fn MobileTabBar() -> impl IntoView {
|
||||
let ui = use_context::<UiContext>().expect("UiContext not provided");
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::app::UiContext;
|
||||
use leptos::prelude::*;
|
||||
use shared_types::ui_state::Tab;
|
||||
|
||||
|
||||
#[component]
|
||||
pub fn Sidebar() -> impl IntoView {
|
||||
let ui = use_context::<UiContext>().expect("UiContext not provided");
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::app::UiContext;
|
||||
use leptos::prelude::*;
|
||||
use shared_types::ui_state::Tab;
|
||||
|
||||
|
||||
#[component]
|
||||
pub fn TabStrip() -> impl IntoView {
|
||||
let ui = use_context::<UiContext>().expect("UiContext not provided");
|
||||
|
||||
@@ -3,18 +3,19 @@ pub mod app;
|
||||
pub mod auth;
|
||||
pub mod features;
|
||||
pub mod layout;
|
||||
pub mod logger;
|
||||
pub mod ui;
|
||||
pub mod ws;
|
||||
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
make_logger!();
|
||||
|
||||
#[wasm_bindgen(start)]
|
||||
pub fn start() {
|
||||
// Set up panic hook for better error messages in the browser console
|
||||
console_error_panic_hook::set_once();
|
||||
// Initialize logger
|
||||
wasm_logger::init(wasm_logger::Config::default());
|
||||
|
||||
// Mount the Leptos app to the body
|
||||
log_info!("IMPHNEN frontend starting...");
|
||||
leptos::mount::mount_to_body(app::App);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// services/frontend/frontend/src/logger.rs
|
||||
// Structured logging for WASM browser console with levels, timestamps, and styled output.
|
||||
|
||||
/// Log level with numeric priority (lower = more verbose).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum LogLevel {
|
||||
Trace = 0,
|
||||
Debug = 1,
|
||||
Info = 2,
|
||||
Warn = 3,
|
||||
Error = 4,
|
||||
}
|
||||
|
||||
impl LogLevel {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
LogLevel::Trace => "TRACE",
|
||||
LogLevel::Debug => "DEBUG",
|
||||
LogLevel::Info => "INFO",
|
||||
LogLevel::Warn => "WARN",
|
||||
LogLevel::Error => "ERROR",
|
||||
}
|
||||
}
|
||||
|
||||
/// CSS color for the browser console label.
|
||||
fn console_style(&self) -> &'static str {
|
||||
match self {
|
||||
LogLevel::Trace => "color:#888",
|
||||
LogLevel::Debug => "color:#54a2ff",
|
||||
LogLevel::Info => "color:#23a1eb;font-weight:bold",
|
||||
LogLevel::Warn => "color:#f59e0b;font-weight:bold",
|
||||
LogLevel::Error => "color:#e4405f;font-weight:bold",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A per-module logger that produces styled, timestamped console output.
|
||||
#[derive(Clone)]
|
||||
pub struct Logger {
|
||||
module: &'static str,
|
||||
min_level: LogLevel,
|
||||
}
|
||||
|
||||
impl Logger {
|
||||
/// Create a logger for a given module path (call with `module_path!()`).
|
||||
pub const fn new(module: &'static str, min_level: LogLevel) -> Self {
|
||||
Self { module, min_level }
|
||||
}
|
||||
|
||||
/// Create a logger that shows everything (min_level = Trace).
|
||||
pub const fn verbose(module: &'static str) -> Self {
|
||||
Self::new(module, LogLevel::Trace)
|
||||
}
|
||||
|
||||
/// Format an ISO-like timestamp from `Date.now()`.
|
||||
fn timestamp() -> String {
|
||||
let d = js_sys::Date::new_0();
|
||||
// HH:MM:SS.mmm
|
||||
format!(
|
||||
"{:02}:{:02}:{:02}.{:03}",
|
||||
d.get_hours(),
|
||||
d.get_minutes(),
|
||||
d.get_seconds(),
|
||||
d.get_milliseconds()
|
||||
)
|
||||
}
|
||||
|
||||
fn should_log(&self, level: LogLevel) -> bool {
|
||||
level >= self.min_level
|
||||
}
|
||||
|
||||
fn log_inner(&self, level: LogLevel, message: &str) {
|
||||
if !self.should_log(level) {
|
||||
return;
|
||||
}
|
||||
let ts = Self::timestamp();
|
||||
let lvl_str = level.as_str();
|
||||
let style = level.console_style();
|
||||
let styled = format!("%c{:.7} [{}] {}", ts, self.module, message);
|
||||
match level {
|
||||
LogLevel::Error => {
|
||||
web_sys::console::error_3(
|
||||
&styled.into(),
|
||||
&style.into(),
|
||||
&"".into(),
|
||||
);
|
||||
}
|
||||
LogLevel::Warn => {
|
||||
web_sys::console::warn_3(
|
||||
&styled.into(),
|
||||
&style.into(),
|
||||
&"".into(),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
web_sys::console::log_3(
|
||||
&styled.into(),
|
||||
&style.into(),
|
||||
&"".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn trace(&self, msg: &str) {
|
||||
self.log_inner(LogLevel::Trace, msg);
|
||||
}
|
||||
|
||||
pub fn debug(&self, msg: &str) {
|
||||
self.log_inner(LogLevel::Debug, msg);
|
||||
}
|
||||
|
||||
pub fn info(&self, msg: &str) {
|
||||
self.log_inner(LogLevel::Info, msg);
|
||||
}
|
||||
|
||||
pub fn warn(&self, msg: &str) {
|
||||
self.log_inner(LogLevel::Warn, msg);
|
||||
}
|
||||
|
||||
pub fn error(&self, msg: &str) {
|
||||
self.log_inner(LogLevel::Error, msg);
|
||||
}
|
||||
|
||||
/// Log with a dynamic format string.
|
||||
pub fn info_fmt(&self, fmt: &str, args: &[&dyn std::fmt::Display]) {
|
||||
let msg = if args.is_empty() {
|
||||
fmt.to_string()
|
||||
} else {
|
||||
let mut s = String::new();
|
||||
let mut iter = args.iter();
|
||||
for part in fmt.split("{}") {
|
||||
s.push_str(part);
|
||||
if let Some(arg) = iter.next() {
|
||||
s.push_str(&arg.to_string());
|
||||
}
|
||||
}
|
||||
s
|
||||
};
|
||||
self.log_inner(LogLevel::Info, &msg);
|
||||
}
|
||||
}
|
||||
|
||||
/// Macro to create a module-level logger at `Info` level.
|
||||
/// Usage: `log::module!()` at the top of a source file (after imports).
|
||||
#[macro_export]
|
||||
macro_rules! make_logger {
|
||||
() => {
|
||||
static LOGGER: std::sync::LazyLock<$crate::logger::Logger> =
|
||||
std::sync::LazyLock::new(|| {
|
||||
$crate::logger::Logger::new(module_path!(), $crate::logger::LogLevel::Trace)
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/// Convenience macros that log through the module's static LOGGER.
|
||||
/// Usage: `log_info!("something happened")`.
|
||||
#[macro_export]
|
||||
macro_rules! log_trace {
|
||||
($($arg:tt)*) => { LOGGER.trace(&format!($($arg)*)); };
|
||||
}
|
||||
#[macro_export]
|
||||
macro_rules! log_debug {
|
||||
($($arg:tt)*) => { LOGGER.debug(&format!($($arg)*)); };
|
||||
}
|
||||
#[macro_export]
|
||||
macro_rules! log_info {
|
||||
($($arg:tt)*) => { LOGGER.info(&format!($($arg)*)); };
|
||||
}
|
||||
#[macro_export]
|
||||
macro_rules! log_warn {
|
||||
($($arg:tt)*) => { LOGGER.warn(&format!($($arg)*)); };
|
||||
}
|
||||
#[macro_export]
|
||||
macro_rules! log_error {
|
||||
($($arg:tt)*) => { LOGGER.error(&format!($($arg)*)); };
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
use leptos::prelude::*;
|
||||
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub enum ButtonVariant {
|
||||
#[default]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use leptos::prelude::*;
|
||||
|
||||
|
||||
#[component]
|
||||
pub fn Card(
|
||||
#[prop(optional)] elevated: bool,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// services/frontend-leptos/frontend/src/ui/empty_state.rs
|
||||
use leptos::prelude::*;
|
||||
|
||||
|
||||
#[component]
|
||||
pub fn EmptyState(
|
||||
#[prop(optional)] icon: Option<AnyView>,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
use leptos::prelude::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
#[component]
|
||||
pub fn Modal(
|
||||
is_open: RwSignal<bool>,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// services/frontend-leptos/frontend/src/ui/toast.rs
|
||||
use leptos::prelude::*;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::fmt;
|
||||
use crate::{log_info, make_logger};
|
||||
|
||||
make_logger!();
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum ToastType {
|
||||
@@ -10,6 +14,17 @@ pub enum ToastType {
|
||||
Warning,
|
||||
}
|
||||
|
||||
impl fmt::Display for ToastType {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ToastType::Info => write!(f, "info"),
|
||||
ToastType::Success => write!(f, "success"),
|
||||
ToastType::Error => write!(f, "error"),
|
||||
ToastType::Warning => write!(f, "warning"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ToastMessage {
|
||||
pub id: u64,
|
||||
@@ -38,6 +53,7 @@ impl ToastContext {
|
||||
}
|
||||
|
||||
pub fn show(&self, message: &str, toast_type: ToastType) {
|
||||
log_info!("Toast: {} ({})", message, toast_type);
|
||||
let id = {
|
||||
let mut n = self.next_id.lock().unwrap();
|
||||
*n += 1;
|
||||
|
||||
@@ -5,6 +5,9 @@ use shared_types::media::MediaState;
|
||||
use shared_types::message::MessageRecord;
|
||||
use shared_types::recording::VoiceRecording;
|
||||
use shared_types::voice::ActiveSpeaker;
|
||||
use crate::{log_debug, log_error, log_info, log_trace, log_warn, make_logger};
|
||||
|
||||
make_logger!();
|
||||
|
||||
#[derive(Clone)]
|
||||
#[allow(clippy::type_complexity)]
|
||||
@@ -61,6 +64,7 @@ impl WsContext {
|
||||
|
||||
match event_type.as_str() {
|
||||
"message_created" => {
|
||||
log_debug!("WS event: message_created");
|
||||
if let Some(d) = data.and_then(|v| {
|
||||
serde_json::from_value::<MessageRecord>(v.clone()).ok()
|
||||
}) {
|
||||
@@ -70,6 +74,7 @@ impl WsContext {
|
||||
}
|
||||
}
|
||||
"message_updated" => {
|
||||
log_debug!("WS event: message_updated");
|
||||
if let Some(d) = data.and_then(|v| {
|
||||
serde_json::from_value::<MessageRecord>(v.clone()).ok()
|
||||
}) {
|
||||
@@ -79,6 +84,7 @@ impl WsContext {
|
||||
}
|
||||
}
|
||||
"message_deleted" => {
|
||||
log_debug!("WS event: message_deleted");
|
||||
if let Some(d) = data.and_then(|v| v.as_str().map(String::from)) {
|
||||
if let Some(cb) = self.on_message_deleted.borrow().as_ref() {
|
||||
cb(d);
|
||||
@@ -86,6 +92,7 @@ impl WsContext {
|
||||
}
|
||||
}
|
||||
"message_analyzed" => {
|
||||
log_debug!("WS event: message_analyzed");
|
||||
if let Some(d) = data.and_then(|v| {
|
||||
serde_json::from_value::<MessageRecord>(v.clone()).ok()
|
||||
}) {
|
||||
@@ -95,6 +102,7 @@ impl WsContext {
|
||||
}
|
||||
}
|
||||
"voice_active_user" => {
|
||||
log_debug!("WS event: voice_active_user");
|
||||
if let Some(d) = data.and_then(|v| {
|
||||
serde_json::from_value::<ActiveSpeaker>(v.clone()).ok()
|
||||
}) {
|
||||
@@ -104,6 +112,7 @@ impl WsContext {
|
||||
}
|
||||
}
|
||||
"voice_recording_uploaded" => {
|
||||
log_debug!("WS event: voice_recording_uploaded");
|
||||
if let Some(d) = data.and_then(|v| {
|
||||
serde_json::from_value::<VoiceRecording>(v.clone()).ok()
|
||||
}) {
|
||||
@@ -114,6 +123,7 @@ impl WsContext {
|
||||
}
|
||||
}
|
||||
"media_state" => {
|
||||
log_debug!("WS event: media_state");
|
||||
// Backend sends initial state with "state" key, live updates with "data"
|
||||
let raw = data.or_else(|| parsed.get("state")).cloned();
|
||||
if let Some(d) =
|
||||
@@ -126,14 +136,13 @@ impl WsContext {
|
||||
}
|
||||
_ => {
|
||||
// Unknown event type — log and ignore
|
||||
web_sys::console::log_1(
|
||||
&format!("[WS] unhandled event: {}", event_type).into(),
|
||||
);
|
||||
log_warn!("WS unhandled event type: {}", event_type);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
WsEvent::Binary(data) => {
|
||||
log_debug!("WS event: binary ({} bytes)", data.len());
|
||||
if let Some(cb) = self.on_binary.borrow().as_ref() {
|
||||
cb(data);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ use leptos::prelude::*;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
use web_sys::{CloseEvent, ErrorEvent, MessageEvent, WebSocket};
|
||||
use crate::{log_debug, log_error, log_info, log_trace, log_warn, make_logger};
|
||||
|
||||
make_logger!();
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum WsStatus {
|
||||
@@ -85,6 +88,7 @@ impl WsHandle {
|
||||
reconnect_attempt: *const std::cell::Cell<u32>,
|
||||
) {
|
||||
let url_owned = url.to_string();
|
||||
let url_close = url_owned.clone();
|
||||
let status1 = set_status;
|
||||
let status2 = set_status;
|
||||
let status3 = set_status;
|
||||
@@ -98,6 +102,7 @@ impl WsHandle {
|
||||
// onopen
|
||||
let onopen_cb = Closure::<dyn Fn(web_sys::ProgressEvent)>::new(move |_| {
|
||||
status1.set(WsStatus::Connected);
|
||||
log_info!("WS connected to {}", url_owned);
|
||||
unsafe { (*reconnect_attempt).set(0) };
|
||||
});
|
||||
ws.set_onopen(Some(onopen_cb.as_ref().unchecked_ref()));
|
||||
@@ -107,6 +112,7 @@ impl WsHandle {
|
||||
let event_for_close = event_clone.clone();
|
||||
let onclose_cb = Closure::<dyn Fn(CloseEvent)>::new(move |_| {
|
||||
status2.set(WsStatus::Disconnected);
|
||||
log_info!("WS disconnected from {}", url_close);
|
||||
unsafe { *(*ws_holder).borrow_mut() = None };
|
||||
|
||||
let attempt = unsafe { (*reconnect_attempt).get() };
|
||||
@@ -114,6 +120,7 @@ impl WsHandle {
|
||||
status2.set(WsStatus::Error(
|
||||
"Max reconnect attempts reached".to_string(),
|
||||
));
|
||||
log_error!("WS reconnect max attempts reached for {}", url_close);
|
||||
return;
|
||||
}
|
||||
// Full-jitter exponential backoff: min(1000 * 2^attempt, 30000) * (0.5 + random * 0.5)
|
||||
@@ -122,7 +129,9 @@ impl WsHandle {
|
||||
let delay_ms = (base as f64 * jitter) as u32;
|
||||
unsafe { (*reconnect_attempt).set(attempt + 1) };
|
||||
|
||||
let url_reconnect = url_owned.clone();
|
||||
log_info!("WS reconnecting to {} in {}ms (attempt {})", url_close, delay_ms, attempt + 1);
|
||||
|
||||
let url_reconnect = url_close.clone();
|
||||
let status_rc = status2;
|
||||
let event_rc = event_for_close.clone();
|
||||
let reconnect_fn = Closure::<dyn Fn()>::new(move || {
|
||||
@@ -148,6 +157,7 @@ impl WsHandle {
|
||||
|
||||
// onerror
|
||||
let onerror_cb = Closure::<dyn Fn(ErrorEvent)>::new(move |e: ErrorEvent| {
|
||||
log_error!("WS error: {}", e.message());
|
||||
status3.set(WsStatus::Error(e.message()));
|
||||
});
|
||||
ws.set_onerror(Some(onerror_cb.as_ref().unchecked_ref()));
|
||||
@@ -173,12 +183,12 @@ impl WsHandle {
|
||||
onmsg_cb.forget();
|
||||
}
|
||||
Err(e) => {
|
||||
set_status.set(WsStatus::Error(
|
||||
js_sys::Error::from(e)
|
||||
.to_string()
|
||||
.as_string()
|
||||
.unwrap_or_default(),
|
||||
));
|
||||
let msg = js_sys::Error::from(e)
|
||||
.to_string()
|
||||
.as_string()
|
||||
.unwrap_or_default();
|
||||
log_error!("WS connect failed: {}", msg);
|
||||
set_status.set(WsStatus::Error(msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user