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:
asepharyana
2026-07-05 19:55:13 +07:00
parent 03b257d5a7
commit e3228af196
34 changed files with 767 additions and 1400 deletions
@@ -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 {
+79 -31
View File
@@ -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
File diff suppressed because it is too large Load Diff
+7 -10
View File
@@ -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);
}
}
}
+6
View File
@@ -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(&current, &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(&current, &[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(&current, &[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(&current, &[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");
+5 -4
View File
@@ -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);
}
+177
View File
@@ -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;
+12 -3
View File
@@ -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);
}
+17 -7
View File
@@ -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));
}
}
}