Compare commits

...
Author SHA1 Message Date
Brandon Thomas c65a66655a performance 2026-07-02 16:38:11 -04:00
Brandon Thomas 23cbb03b39 codegen db 2026-07-02 16:36:17 -04:00
Brandon Thomas d3cb23753a format page 2026-07-02 16:32:26 -04:00
Brandon Thomas 47c0cd23f8 frontend updates 2026-07-02 16:27:32 -04:00
Brandon Thomas f398b3af99 sticky footers 2026-07-02 16:15:42 -04:00
Brandon Thomas 8bd0e9219b links 2026-07-02 16:11:56 -04:00
Brandon Thomas 298aff708e frontend update 2026-07-02 16:06:23 -04:00
Brandon Thomas 197ec95012 debug logs: new field, new queries, new APIs 2026-07-02 15:54:59 -04:00
48 changed files with 2221 additions and 382 deletions
@@ -0,0 +1,124 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n d.id as `id: u64`,\n d.event_token as `event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,\n d.debug_log_type as `debug_log_type: enums::by_table::debug_logs::debug_log_type::DebugLogType`,\n d.maybe_log_level as `maybe_log_level: enums::by_table::debug_logs::debug_log_level::DebugLogLevel`,\n d.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n d.message,\n d.created_at,\n u.display_name as `maybe_user_display_name?`,\n u.username as `maybe_user_username?`,\n u.email_gravatar_hash as `maybe_user_gravatar_hash?`\nFROM debug_logs d\nLEFT OUTER JOIN users u\n ON u.token = d.maybe_creator_user_token\nWHERE d.maybe_creator_user_token = ?\n AND d.id < ?\nORDER BY d.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "debug_log_type: enums::by_table::debug_logs::debug_log_type::DebugLogType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 96
}
},
{
"ordinal": 3,
"name": "maybe_log_level: enums::by_table::debug_logs::debug_log_level::DebugLogLevel",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 4,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "message",
"type_info": {
"type": "Blob",
"flags": "NOT_NULL | BLOB | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 67108860
}
},
{
"ordinal": 6,
"name": "created_at",
"type_info": {
"type": "Datetime",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 7,
"name": "maybe_user_display_name?",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 8,
"name": "maybe_user_username?",
"type_info": {
"type": "VarString",
"flags": "UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 9,
"name": "maybe_user_gravatar_hash?",
"type_info": {
"type": "String",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
false,
false,
true,
true,
false,
false,
true,
true,
true
]
},
"hash": "1197c08c8b390fb8bffbe10ad9f7d073a58b6c9124ee11573c6723d6d5471881"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO debug_logs\nSET\n event_token = ?,\n debug_log_type = ?,\n maybe_log_level = ?,\n maybe_creator_user_token = ?,\n message = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "62995374005dc0553d007fb69e642d89a95cd5aaeeababd110c92c565023cfcf"
}
@@ -1,80 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n id as `id: u64`,\n event_token as `event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,\n debug_log_type as `debug_log_type: enums::by_table::debug_logs::debug_log_type::DebugLogType`,\n maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n message,\n created_at\nFROM debug_logs\nWHERE event_token = ?\nORDER BY id ASC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "debug_log_type: enums::by_table::debug_logs::debug_log_type::DebugLogType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 96
}
},
{
"ordinal": 3,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "message",
"type_info": {
"type": "Blob",
"flags": "NOT_NULL | BLOB | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 67108860
}
},
{
"ordinal": 5,
"name": "created_at",
"type_info": {
"type": "Datetime",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
true,
false,
false
]
},
"hash": "a62b88fec40a11952cc199383090ea338f97091e8bfc6b68aad67416685889af"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO debug_logs\nSET\n event_token = ?,\n debug_log_type = ?,\n maybe_creator_user_token = ?,\n message = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "a9745ecf60b564ab096ee1dbe124221b5f288a4ae38e788e19bbd359db0b580f"
}
@@ -0,0 +1,124 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n d.id as `id: u64`,\n d.event_token as `event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,\n d.debug_log_type as `debug_log_type: enums::by_table::debug_logs::debug_log_type::DebugLogType`,\n d.maybe_log_level as `maybe_log_level: enums::by_table::debug_logs::debug_log_level::DebugLogLevel`,\n d.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n d.message,\n d.created_at,\n u.display_name as `maybe_user_display_name?`,\n u.username as `maybe_user_username?`,\n u.email_gravatar_hash as `maybe_user_gravatar_hash?`\nFROM debug_logs d\nLEFT OUTER JOIN users u\n ON u.token = d.maybe_creator_user_token\nWHERE d.event_token = ?\nORDER BY d.id ASC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "debug_log_type: enums::by_table::debug_logs::debug_log_type::DebugLogType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 96
}
},
{
"ordinal": 3,
"name": "maybe_log_level: enums::by_table::debug_logs::debug_log_level::DebugLogLevel",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 4,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "message",
"type_info": {
"type": "Blob",
"flags": "NOT_NULL | BLOB | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 67108860
}
},
{
"ordinal": 6,
"name": "created_at",
"type_info": {
"type": "Datetime",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 7,
"name": "maybe_user_display_name?",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 8,
"name": "maybe_user_username?",
"type_info": {
"type": "VarString",
"flags": "UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 9,
"name": "maybe_user_gravatar_hash?",
"type_info": {
"type": "String",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
true,
true,
false,
false,
true,
true,
true
]
},
"hash": "ae311f7fde7c73f166cd8c0a5c9965850e314936a401b24a3900585ab53a7db6"
}
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE debug_logs
DROP COLUMN maybe_log_level;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE debug_logs
ADD COLUMN maybe_log_level VARCHAR(16) DEFAULT NULL;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE debug_logs
DROP INDEX index_maybe_log_level;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE debug_logs
ADD INDEX index_maybe_log_level (maybe_log_level);
@@ -13,6 +13,9 @@ CREATE TABLE debug_logs (
-- The type of debug log entry.
debug_log_type VARCHAR(24) NOT NULL,
-- The severity level of the log entry (info, warn, error, debug, trace).
maybe_log_level VARCHAR(16) DEFAULT NULL,
-- The user who created the log entry (if any).
maybe_creator_user_token VARCHAR(32) DEFAULT NULL,
@@ -25,6 +28,7 @@ CREATE TABLE debug_logs (
PRIMARY KEY (id),
KEY index_event_token (event_token),
KEY index_debug_log_type (debug_log_type),
KEY index_maybe_log_level (maybe_log_level),
KEY index_maybe_creator_user_token (maybe_creator_user_token),
KEY index_created_at (created_at)
@@ -0,0 +1,32 @@
use chrono::{DateTime, Utc};
use serde_derive::Serialize;
use utoipa::ToSchema;
use enums::by_table::debug_logs::debug_log_level::DebugLogLevel;
use enums::by_table::debug_logs::debug_log_type::DebugLogType;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::users::UserToken;
/// A single debug log row.
/// Shared by the moderation debug-log list endpoints.
#[derive(Serialize, ToSchema)]
pub struct ModerationDebugLogEntry {
pub id: u64,
pub event_token: DebugLogEventToken,
pub debug_log_type: DebugLogType,
pub maybe_log_level: Option<DebugLogLevel>,
pub maybe_creator_user_token: Option<UserToken>,
pub message: String,
pub created_at: DateTime<Utc>,
/// The creator user, joined in when the row has a user token.
pub maybe_user: Option<ModerationDebugLogUser>,
}
/// The creator user of a debug log row.
#[derive(Serialize, ToSchema)]
pub struct ModerationDebugLogUser {
pub user_token: UserToken,
pub display_name: String,
pub username: String,
pub gravatar_hash: String,
}
@@ -0,0 +1,4 @@
pub mod debug_log_entry;
pub mod moderation_list_all_debug_logs;
pub mod moderation_list_debug_logs_for_token;
pub mod moderation_list_debug_logs_for_user;
@@ -0,0 +1,72 @@
use chrono::{DateTime, Utc};
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use enums::by_table::debug_logs::debug_log_level::DebugLogLevel;
use enums::by_table::debug_logs::debug_log_type::DebugLogType;
use enums::error::enum_error::EnumError;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::users::UserToken;
use crate::moderation::debug_logs::debug_log_entry::ModerationDebugLogUser;
pub const MODERATION_LIST_ALL_DEBUG_LOGS_PATH: &str = "/v1/moderation/debug_logs/list_all";
// ── Query params ──
#[derive(Deserialize, IntoParams, ToSchema)]
pub struct ModerationListAllDebugLogsQueryParams {
/// Optional comma-separated list of severity levels to include,
/// eg. `severities=error,warn`. Omit to include all rows.
pub severities: Option<String>,
/// Cursor for pagination. Pass the `next_cursor` from a previous response.
pub cursor: Option<u64>,
/// Max rows per page (default 50, max 200).
pub limit: Option<u32>,
}
impl ModerationListAllDebugLogsQueryParams {
/// Parse the comma-separated `severities` parameter.
/// Returns `None` when the parameter is absent or empty.
pub fn parsed_severities(&self) -> Result<Option<Vec<DebugLogLevel>>, EnumError> {
let raw = match self.severities.as_deref() {
Some(value) if !value.trim().is_empty() => value,
_ => return Ok(None),
};
let levels = raw
.split(',')
.map(str::trim)
.filter(|part| !part.is_empty())
.map(DebugLogLevel::from_str)
.collect::<Result<Vec<DebugLogLevel>, EnumError>>()?;
Ok(if levels.is_empty() { None } else { Some(levels) })
}
}
// ── Response ──
#[derive(Serialize, ToSchema)]
pub struct ModerationListAllDebugLogsSuccessResponse {
pub success: bool,
/// Most recent first.
pub debug_logs: Vec<ModerationListAllDebugLogsEntry>,
/// Cursor for the next page. `None` if there are no more results.
pub next_cursor: Option<u64>,
}
/// A debug log row with the creator user joined in (when one exists).
#[derive(Serialize, ToSchema)]
pub struct ModerationListAllDebugLogsEntry {
pub id: u64,
pub event_token: DebugLogEventToken,
pub debug_log_type: DebugLogType,
pub maybe_log_level: Option<DebugLogLevel>,
pub maybe_creator_user_token: Option<UserToken>,
pub message: String,
pub created_at: DateTime<Utc>,
pub maybe_user: Option<ModerationDebugLogUser>,
}
@@ -0,0 +1,30 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use crate::moderation::debug_logs::debug_log_entry::ModerationDebugLogEntry;
pub const MODERATION_LIST_DEBUG_LOGS_FOR_TOKEN_PATH: &str = "/v1/moderation/debug_logs/list/{token}";
// ── Path params ──
#[derive(Deserialize, ToSchema)]
pub struct ModerationListDebugLogsForTokenPathInfo {
pub token: DebugLogEventToken,
}
// ── Query params ──
#[derive(Deserialize, IntoParams, ToSchema)]
pub struct ModerationListDebugLogsForTokenQueryParams {
pub limit: Option<u32>,
}
// ── Response ──
#[derive(Serialize, ToSchema)]
pub struct ModerationListDebugLogsForTokenSuccessResponse {
pub success: bool,
pub debug_logs: Vec<ModerationDebugLogEntry>,
}
@@ -0,0 +1,37 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use tokens::tokens::users::UserToken;
use crate::moderation::debug_logs::debug_log_entry::ModerationDebugLogEntry;
pub const MODERATION_LIST_DEBUG_LOGS_FOR_USER_PATH: &str = "/v1/moderation/debug_logs/user_list/{user_token}";
// ── Path params ──
#[derive(Deserialize, ToSchema)]
pub struct ModerationListDebugLogsForUserPathInfo {
pub user_token: UserToken,
}
// ── Query params ──
#[derive(Deserialize, IntoParams, ToSchema)]
pub struct ModerationListDebugLogsForUserQueryParams {
/// Cursor for pagination. Pass the `next_cursor` from a previous response.
pub cursor: Option<u64>,
/// Max rows per page (default 50, max 200).
pub limit: Option<u32>,
}
// ── Response ──
#[derive(Serialize, ToSchema)]
pub struct ModerationListDebugLogsForUserSuccessResponse {
pub success: bool,
/// Most recent first.
pub debug_logs: Vec<ModerationDebugLogEntry>,
/// Cursor for the next page. `None` if there are no more results.
pub next_cursor: Option<u64>,
}
@@ -1,4 +1,5 @@
pub mod alerts;
pub mod debug_logs;
pub mod jobs;
pub mod user;
pub mod user_referrals;
@@ -3,6 +3,7 @@ use std::marker::PhantomData;
use log::warn;
use sqlx::{Executor, MySql, QueryBuilder};
use enums::by_table::debug_logs::debug_log_level::DebugLogLevel;
use enums::by_table::debug_logs::debug_log_type::DebugLogType;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::users::UserToken;
@@ -31,6 +32,7 @@ where
/// Callers should use `serde_json::to_string(...)` before constructing this.
pub struct DebugLogEvent {
pub debug_log_type: DebugLogType,
pub maybe_log_level: Option<DebugLogLevel>,
pub message: String,
}
@@ -56,7 +58,7 @@ where
let maybe_user_token_str = args.maybe_creator_user_token.map(|t| t.as_str().to_string());
let mut query_builder = QueryBuilder::new(
"INSERT INTO debug_logs (event_token, debug_log_type, maybe_creator_user_token, message) VALUES "
"INSERT INTO debug_logs (event_token, debug_log_type, maybe_log_level, maybe_creator_user_token, message) VALUES "
);
for (i, event) in args.events.iter().enumerate() {
@@ -65,6 +67,8 @@ where
query_builder.push(", ");
query_builder.push_bind(event.debug_log_type.to_str());
query_builder.push(", ");
query_builder.push_bind(event.maybe_log_level.map(|l| l.to_str()));
query_builder.push(", ");
query_builder.push_bind(&maybe_user_token_str);
query_builder.push(", ");
query_builder.push_bind(&event.message);
@@ -2,6 +2,7 @@ use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use enums::by_table::debug_logs::debug_log_level::DebugLogLevel;
use enums::by_table::debug_logs::debug_log_type::DebugLogType;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::users::UserToken;
@@ -20,6 +21,9 @@ where
/// The type of debug log event.
pub debug_log_type: DebugLogType,
/// The severity level of the log event (if any).
pub maybe_log_level: Option<DebugLogLevel>,
/// The log message body (pre-serialized JSON or any text).
pub message: &'e str,
@@ -47,11 +51,13 @@ INSERT INTO debug_logs
SET
event_token = ?,
debug_log_type = ?,
maybe_log_level = ?,
maybe_creator_user_token = ?,
message = ?
"#,
event_token.as_str(),
args.debug_log_type.to_str(),
args.maybe_log_level.map(|l| l.to_str()),
args.maybe_creator_user_token.map(|t| t.as_str()),
args.message,
)
@@ -0,0 +1,142 @@
use std::marker::PhantomData;
use chrono::{DateTime, Utc};
use sqlx::mysql::MySqlRow;
use sqlx::{Executor, MySql, QueryBuilder, Row};
use enums::by_table::debug_logs::debug_log_level::DebugLogLevel;
use enums::by_table::debug_logs::debug_log_type::DebugLogType;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::users::UserToken;
const DEFAULT_LIMIT: u32 = 50;
const MAX_LIMIT: u32 = 200;
pub struct ListAllDebugLogsArgs<'c, E>
where
E: Executor<'c, Database = MySql>,
{
/// If provided, only rows with one of these log levels are returned.
/// (Rows with a NULL log level are excluded when filtering.)
pub maybe_log_levels: Option<Vec<DebugLogLevel>>,
/// Cursor for pagination: only rows with `id` strictly below this are
/// returned. Pass the `next_cursor` from a previous page.
pub maybe_id_cursor: Option<u64>,
pub limit: Option<u32>,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
pub struct ListAllDebugLogsResult {
pub debug_logs: Vec<AllDebugLogRow>,
/// Cursor for the next page. `None` if there are no more results.
pub next_cursor: Option<u64>,
}
/// A debug log row left-outer-joined against the creator user (if any).
#[derive(Debug)]
pub struct AllDebugLogRow {
pub id: u64,
pub event_token: DebugLogEventToken,
pub debug_log_type: DebugLogType,
pub maybe_log_level: Option<DebugLogLevel>,
pub maybe_creator_user_token: Option<UserToken>,
pub message: String,
pub created_at: DateTime<Utc>,
// Joined user fields (present when the creator user exists).
pub maybe_user_display_name: Option<String>,
pub maybe_user_username: Option<String>,
pub maybe_user_gravatar_hash: Option<String>,
}
/// List all debug logs, most recent first, cursor-paginated by `id`,
/// optionally scoped to a set of log levels.
///
/// NB: Uses `QueryBuilder` (not the compile-time macros) because of the
/// dynamic `WHERE maybe_log_level IN (...)` clause.
pub async fn list_all_debug_logs<'c, E>(
args: ListAllDebugLogsArgs<'c, E>,
) -> Result<ListAllDebugLogsResult, sqlx::Error>
where
E: Executor<'c, Database = MySql>,
{
let limit = args.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
// Fetch limit + 1 so we can detect whether there's a next page.
let fetch_limit = (limit + 1) as i64;
let id_cursor = args.maybe_id_cursor.unwrap_or(u64::MAX);
let mut query_builder: QueryBuilder<MySql> = QueryBuilder::new(
r#"
SELECT
d.id,
d.event_token,
d.debug_log_type,
d.maybe_log_level,
d.maybe_creator_user_token,
d.message,
d.created_at,
u.display_name as maybe_user_display_name,
u.username as maybe_user_username,
u.email_gravatar_hash as maybe_user_gravatar_hash
FROM debug_logs d
LEFT OUTER JOIN users u
ON u.token = d.maybe_creator_user_token
WHERE d.id < "#,
);
query_builder.push_bind(id_cursor);
if let Some(levels) = args.maybe_log_levels.as_ref().filter(|l| !l.is_empty()) {
query_builder.push(" AND d.maybe_log_level IN (");
let mut separated = query_builder.separated(", ");
for level in levels {
separated.push_bind(level.to_str());
}
separated.push_unseparated(")");
}
query_builder.push(" ORDER BY d.id DESC LIMIT ");
query_builder.push_bind(fetch_limit);
let raw_rows: Vec<MySqlRow> = query_builder
.build()
.fetch_all(args.mysql_executor)
.await?;
let mut debug_logs = raw_rows
.into_iter()
.map(row_from_mysql_row)
.collect::<Result<Vec<AllDebugLogRow>, sqlx::Error>>()?;
let next_cursor = if debug_logs.len() > limit as usize {
debug_logs.truncate(limit as usize);
debug_logs.last().map(|row| row.id)
} else {
None
};
Ok(ListAllDebugLogsResult {
debug_logs,
next_cursor,
})
}
fn row_from_mysql_row(row: MySqlRow) -> Result<AllDebugLogRow, sqlx::Error> {
let created_at: DateTime<Utc> = row.try_get("created_at")?;
Ok(AllDebugLogRow {
id: row.try_get("id")?,
event_token: row.try_get("event_token")?,
debug_log_type: row.try_get("debug_log_type")?,
maybe_log_level: row.try_get("maybe_log_level")?,
maybe_creator_user_token: row.try_get("maybe_creator_user_token")?,
message: row.try_get("message")?,
created_at,
maybe_user_display_name: row.try_get("maybe_user_display_name")?,
maybe_user_username: row.try_get("maybe_user_username")?,
maybe_user_gravatar_hash: row.try_get("maybe_user_gravatar_hash")?,
})
}
@@ -3,6 +3,7 @@ use std::marker::PhantomData;
use chrono::{DateTime, NaiveDateTime, Utc};
use sqlx::{Executor, MySql};
use enums::by_table::debug_logs::debug_log_level::DebugLogLevel;
use enums::by_table::debug_logs::debug_log_type::DebugLogType;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::users::UserToken;
@@ -24,9 +25,15 @@ pub struct DebugLogRow {
pub id: u64,
pub event_token: DebugLogEventToken,
pub debug_log_type: DebugLogType,
pub maybe_log_level: Option<DebugLogLevel>,
pub maybe_creator_user_token: Option<UserToken>,
pub message: String,
pub created_at: DateTime<Utc>,
// Joined user fields (present when the creator user exists).
pub maybe_user_display_name: Option<String>,
pub maybe_user_username: Option<String>,
pub maybe_user_gravatar_hash: Option<String>,
}
#[derive(Debug)]
@@ -34,9 +41,13 @@ struct RawDebugLogRow {
id: u64,
event_token: DebugLogEventToken,
debug_log_type: DebugLogType,
maybe_log_level: Option<DebugLogLevel>,
maybe_creator_user_token: Option<UserToken>,
message: String,
created_at: NaiveDateTime,
maybe_user_display_name: Option<String>,
maybe_user_username: Option<String>,
maybe_user_gravatar_hash: Option<String>,
}
pub async fn list_debug_logs_for_token<'e, 'c: 'e, E>(
@@ -51,15 +62,21 @@ where
RawDebugLogRow,
r#"
SELECT
id as `id: u64`,
event_token as `event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,
debug_log_type as `debug_log_type: enums::by_table::debug_logs::debug_log_type::DebugLogType`,
maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,
message,
created_at
FROM debug_logs
WHERE event_token = ?
ORDER BY id ASC
d.id as `id: u64`,
d.event_token as `event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,
d.debug_log_type as `debug_log_type: enums::by_table::debug_logs::debug_log_type::DebugLogType`,
d.maybe_log_level as `maybe_log_level: enums::by_table::debug_logs::debug_log_level::DebugLogLevel`,
d.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,
d.message,
d.created_at,
u.display_name as `maybe_user_display_name?`,
u.username as `maybe_user_username?`,
u.email_gravatar_hash as `maybe_user_gravatar_hash?`
FROM debug_logs d
LEFT OUTER JOIN users u
ON u.token = d.maybe_creator_user_token
WHERE d.event_token = ?
ORDER BY d.id ASC
LIMIT ?
"#,
args.event_token.as_str(),
@@ -73,9 +90,13 @@ LIMIT ?
id: row.id,
event_token: row.event_token,
debug_log_type: row.debug_log_type,
maybe_log_level: row.maybe_log_level,
maybe_creator_user_token: row.maybe_creator_user_token,
message: row.message,
created_at: row.created_at.and_utc(),
maybe_user_display_name: row.maybe_user_display_name,
maybe_user_username: row.maybe_user_username,
maybe_user_gravatar_hash: row.maybe_user_gravatar_hash,
}
}).collect();
@@ -0,0 +1,134 @@
use std::marker::PhantomData;
use chrono::{DateTime, NaiveDateTime, Utc};
use sqlx::{Executor, MySql};
use enums::by_table::debug_logs::debug_log_level::DebugLogLevel;
use enums::by_table::debug_logs::debug_log_type::DebugLogType;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::users::UserToken;
const DEFAULT_LIMIT: u32 = 50;
const MAX_LIMIT: u32 = 200;
pub struct ListDebugLogsForUserArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub user_token: &'e UserToken,
/// Cursor for pagination: only rows with `id` strictly below this are
/// returned. Pass the `next_cursor` from a previous page.
pub maybe_id_cursor: Option<u64>,
pub limit: Option<u32>,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
pub struct ListDebugLogsForUserResult {
pub debug_logs: Vec<UserDebugLogRow>,
/// Cursor for the next page. `None` if there are no more results.
pub next_cursor: Option<u64>,
}
#[derive(Debug)]
pub struct UserDebugLogRow {
pub id: u64,
pub event_token: DebugLogEventToken,
pub debug_log_type: DebugLogType,
pub maybe_log_level: Option<DebugLogLevel>,
pub maybe_creator_user_token: Option<UserToken>,
pub message: String,
pub created_at: DateTime<Utc>,
// Joined user fields (present when the creator user exists).
pub maybe_user_display_name: Option<String>,
pub maybe_user_username: Option<String>,
pub maybe_user_gravatar_hash: Option<String>,
}
#[derive(Debug)]
struct RawUserDebugLogRow {
id: u64,
event_token: DebugLogEventToken,
debug_log_type: DebugLogType,
maybe_log_level: Option<DebugLogLevel>,
maybe_creator_user_token: Option<UserToken>,
message: String,
created_at: NaiveDateTime,
maybe_user_display_name: Option<String>,
maybe_user_username: Option<String>,
maybe_user_gravatar_hash: Option<String>,
}
/// List debug logs for a user, most recent first, cursor-paginated by `id`.
pub async fn list_debug_logs_for_user<'e, 'c: 'e, E>(
args: ListDebugLogsForUserArgs<'e, 'c, E>,
) -> Result<ListDebugLogsForUserResult, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let limit = args.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
// Fetch limit + 1 so we can detect whether there's a next page.
let fetch_limit = (limit + 1) as i64;
// Use u64::MAX when no cursor so all rows are included.
let id_cursor = args.maybe_id_cursor.unwrap_or(u64::MAX);
let rows = sqlx::query_as!(
RawUserDebugLogRow,
r#"
SELECT
d.id as `id: u64`,
d.event_token as `event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,
d.debug_log_type as `debug_log_type: enums::by_table::debug_logs::debug_log_type::DebugLogType`,
d.maybe_log_level as `maybe_log_level: enums::by_table::debug_logs::debug_log_level::DebugLogLevel`,
d.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,
d.message,
d.created_at,
u.display_name as `maybe_user_display_name?`,
u.username as `maybe_user_username?`,
u.email_gravatar_hash as `maybe_user_gravatar_hash?`
FROM debug_logs d
LEFT OUTER JOIN users u
ON u.token = d.maybe_creator_user_token
WHERE d.maybe_creator_user_token = ?
AND d.id < ?
ORDER BY d.id DESC
LIMIT ?
"#,
args.user_token.as_str(),
id_cursor,
fetch_limit,
)
.fetch_all(args.mysql_executor)
.await?;
let mut debug_logs: Vec<UserDebugLogRow> = rows.into_iter().map(|row| {
UserDebugLogRow {
id: row.id,
event_token: row.event_token,
debug_log_type: row.debug_log_type,
maybe_log_level: row.maybe_log_level,
maybe_creator_user_token: row.maybe_creator_user_token,
message: row.message,
created_at: row.created_at.and_utc(),
maybe_user_display_name: row.maybe_user_display_name,
maybe_user_username: row.maybe_user_username,
maybe_user_gravatar_hash: row.maybe_user_gravatar_hash,
}
}).collect();
let next_cursor = if debug_logs.len() > limit as usize {
debug_logs.truncate(limit as usize);
debug_logs.last().map(|row| row.id)
} else {
None
};
Ok(ListDebugLogsForUserResult {
debug_logs,
next_cursor,
})
}
@@ -1,3 +1,5 @@
pub mod bulk_insert_debug_logs;
pub mod insert_debug_log;
pub mod list_all_debug_logs;
pub mod list_debug_logs_for_token;
pub mod list_debug_logs_for_user;
@@ -0,0 +1,151 @@
use std::collections::BTreeSet;
#[cfg(test)]
use strum::EnumCount;
#[cfg(test)]
use strum::EnumIter;
use utoipa::ToSchema;
use crate::error::enum_error::EnumError;
/// Maximum serialized string length for database storage.
pub const MAX_LENGTH: usize = 16;
/// Used in the `debug_logs` table in a `VARCHAR(16)` field (`maybe_log_level`).
///
/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY.
#[cfg_attr(test, derive(EnumIter, EnumCount))]
#[derive(Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum DebugLogLevel {
Info,
Warn,
Error,
Debug,
Trace,
}
impl_enum_display_and_debug_using_to_str!(DebugLogLevel);
impl_mysql_enum_coders!(DebugLogLevel);
impl_mysql_from_row!(DebugLogLevel);
impl DebugLogLevel {
pub fn to_str(&self) -> &'static str {
match self {
Self::Info => "info",
Self::Warn => "warn",
Self::Error => "error",
Self::Debug => "debug",
Self::Trace => "trace",
}
}
pub fn from_str(value: &str) -> Result<Self, EnumError> {
match value {
"info" => Ok(Self::Info),
"warn" => Ok(Self::Warn),
"error" => Ok(Self::Error),
"debug" => Ok(Self::Debug),
"trace" => Ok(Self::Trace),
_ => Err(EnumError::CouldNotConvertFromString(value.to_string())),
}
}
pub fn all_variants() -> BTreeSet<Self> {
BTreeSet::from([
Self::Info,
Self::Warn,
Self::Error,
Self::Debug,
Self::Trace,
])
}
}
#[cfg(test)]
mod tests {
use crate::by_table::debug_logs::debug_log_level::DebugLogLevel;
use crate::by_table::debug_logs::debug_log_level::MAX_LENGTH;
use crate::test_helpers::assert_serialization;
mod explicit_checks {
use super::*;
use crate::error::enum_error::EnumError;
#[test]
fn test_serialization() {
assert_serialization(DebugLogLevel::Info, "info");
assert_serialization(DebugLogLevel::Warn, "warn");
assert_serialization(DebugLogLevel::Error, "error");
assert_serialization(DebugLogLevel::Debug, "debug");
assert_serialization(DebugLogLevel::Trace, "trace");
}
#[test]
fn to_str() {
assert_eq!(DebugLogLevel::Info.to_str(), "info");
assert_eq!(DebugLogLevel::Warn.to_str(), "warn");
assert_eq!(DebugLogLevel::Error.to_str(), "error");
assert_eq!(DebugLogLevel::Debug.to_str(), "debug");
assert_eq!(DebugLogLevel::Trace.to_str(), "trace");
}
#[test]
fn from_str() {
assert_eq!(DebugLogLevel::from_str("info").unwrap(), DebugLogLevel::Info);
assert_eq!(DebugLogLevel::from_str("warn").unwrap(), DebugLogLevel::Warn);
assert_eq!(DebugLogLevel::from_str("error").unwrap(), DebugLogLevel::Error);
assert_eq!(DebugLogLevel::from_str("debug").unwrap(), DebugLogLevel::Debug);
assert_eq!(DebugLogLevel::from_str("trace").unwrap(), DebugLogLevel::Trace);
}
#[test]
fn from_str_err() {
let result = DebugLogLevel::from_str("invalid");
assert!(result.is_err());
if let Err(EnumError::CouldNotConvertFromString(value)) = result {
assert_eq!(value, "invalid");
} else {
panic!("Expected EnumError::CouldNotConvertFromString");
}
}
#[test]
fn all_variants() {
let mut variants = DebugLogLevel::all_variants();
assert_eq!(variants.len(), 5);
assert_eq!(variants.pop_first(), Some(DebugLogLevel::Info));
assert_eq!(variants.pop_first(), Some(DebugLogLevel::Warn));
assert_eq!(variants.pop_first(), Some(DebugLogLevel::Error));
assert_eq!(variants.pop_first(), Some(DebugLogLevel::Debug));
assert_eq!(variants.pop_first(), Some(DebugLogLevel::Trace));
assert_eq!(variants.pop_first(), None);
}
}
mod mechanical_checks {
use super::*;
#[test]
fn variant_length() {
use strum::IntoEnumIterator;
assert_eq!(DebugLogLevel::all_variants().len(), DebugLogLevel::iter().len());
}
#[test]
fn round_trip() {
for variant in DebugLogLevel::all_variants() {
assert_eq!(variant, DebugLogLevel::from_str(variant.to_str()).unwrap());
assert_eq!(variant, DebugLogLevel::from_str(&format!("{}", variant)).unwrap());
assert_eq!(variant, DebugLogLevel::from_str(&format!("{:?}", variant)).unwrap());
}
}
#[test]
fn max_length() {
for variant in DebugLogLevel::all_variants() {
assert!(variant.to_str().len() <= MAX_LENGTH);
}
}
}
}
@@ -1 +1,2 @@
pub mod debug_log_level;
pub mod debug_log_type;
@@ -88,7 +88,10 @@ use crate::http_server::endpoints::media_files::upsert_upload::write_error::Medi
use crate::http_server::endpoints::media_files::upsert_upload::write_scene_file::write_scene_file_media_file_handler::*;
use crate::http_server::endpoints::model_download::enqueue_gptsovits_model_download_handler::*;
use artcraft_api_defs::generate::video::edit::beeble_switchx_edit_video::*;
use crate::http_server::endpoints::moderation::debug_logs::moderation_list_debug_logs_for_token_handler::*;
use artcraft_api_defs::moderation::debug_logs::debug_log_entry::*;
use artcraft_api_defs::moderation::debug_logs::moderation_list_all_debug_logs::*;
use artcraft_api_defs::moderation::debug_logs::moderation_list_debug_logs_for_token::*;
use artcraft_api_defs::moderation::debug_logs::moderation_list_debug_logs_for_user::*;
use crate::http_server::endpoints::moderation::jobs::moderation_get_job_by_token_handler::*;
use crate::http_server::endpoints::moderation::user_feature_flags::moderator_edit_user_feature_flags_handler::*;
use crate::http_server::endpoints::moderation::user_feature_flags::moderator_list_all_available_user_feature_flags_handler::*;
@@ -602,7 +605,9 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
crate::http_server::endpoints::moderation::wallets::moderator_add_banked_balance_to_wallet_handler::moderator_add_banked_balance_to_wallet_handler,
crate::http_server::endpoints::moderation::wallets::moderator_create_wallet_for_user_handler::moderator_create_wallet_for_user_handler,
crate::http_server::endpoints::moderation::wallets::moderator_get_wallet_handler::moderator_get_wallet_handler,
crate::http_server::endpoints::moderation::debug_logs::moderation_list_all_debug_logs_handler::moderation_list_all_debug_logs_handler,
crate::http_server::endpoints::moderation::debug_logs::moderation_list_debug_logs_for_token_handler::moderation_list_debug_logs_for_token_handler,
crate::http_server::endpoints::moderation::debug_logs::moderation_list_debug_logs_for_user_handler::moderation_list_debug_logs_for_user_handler,
crate::http_server::endpoints::moderation::jobs::moderation_get_job_by_token_handler::moderation_get_job_by_token_handler,
crate::http_server::endpoints::moderation::staff_audit_logs::moderator_list_staff_audit_logs_handler::moderator_list_staff_audit_logs_handler,
crate::http_server::endpoints::moderation::user_referrals::moderator_list_global_user_referrals_handler::moderator_list_global_user_referrals_handler,
@@ -1030,10 +1035,17 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
GetJobByTokenPathInfo,
GetJobByTokenSuccessResponse,
ModerationJobResponse,
ListDebugLogsPathInfo,
ListDebugLogsQueryParams,
ListDebugLogsSuccessResponse,
DebugLogEntry,
ModerationDebugLogEntry,
ModerationDebugLogUser,
ModerationListAllDebugLogsEntry,
ModerationListAllDebugLogsQueryParams,
ModerationListAllDebugLogsSuccessResponse,
ModerationListDebugLogsForTokenPathInfo,
ModerationListDebugLogsForTokenQueryParams,
ModerationListDebugLogsForTokenSuccessResponse,
ModerationListDebugLogsForUserPathInfo,
ModerationListDebugLogsForUserQueryParams,
ModerationListDebugLogsForUserSuccessResponse,
ListStaffAuditLogsQueryParams,
ListStaffAuditLogsSuccessResponse,
ListImpersonationRequestsQueryParams,
@@ -1 +1,3 @@
pub mod moderation_list_all_debug_logs_handler;
pub mod moderation_list_debug_logs_for_token_handler;
pub mod moderation_list_debug_logs_for_user_handler;
@@ -0,0 +1,89 @@
use std::sync::Arc;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::moderation::debug_logs::debug_log_entry::ModerationDebugLogUser;
use artcraft_api_defs::moderation::debug_logs::moderation_list_all_debug_logs::{
ModerationListAllDebugLogsEntry, ModerationListAllDebugLogsQueryParams,
ModerationListAllDebugLogsSuccessResponse,
};
use mysql_queries::queries::debug_logs::list_all_debug_logs::{
list_all_debug_logs, ListAllDebugLogsArgs,
};
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::user_lookup::user_session::require_moderator::require_moderator;
use crate::state::server_state::ServerState;
#[utoipa::path(
get,
tag = "Moderation",
path = "/v1/moderation/debug_logs/list_all",
params(
ModerationListAllDebugLogsQueryParams,
),
responses(
(status = 200, description = "Success", body = ModerationListAllDebugLogsSuccessResponse),
(status = 400, description = "Bad request (invalid severity)"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Server error"),
),
)]
pub async fn moderation_list_all_debug_logs_handler(
http_request: HttpRequest,
query: web::Query<ModerationListAllDebugLogsQueryParams>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ModerationListAllDebugLogsSuccessResponse>, CommonWebError> {
let maybe_log_levels = query.parsed_severities().map_err(|err| {
CommonWebError::BadInputWithSimpleMessage(format!("invalid severities parameter: {}", err))
})?;
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
let _user_session = require_moderator(&http_request, &server_state.session_checker, &mut *mysql_connection).await?;
let result = list_all_debug_logs(ListAllDebugLogsArgs {
maybe_log_levels,
maybe_id_cursor: query.cursor,
limit: query.limit,
mysql_executor: &mut *mysql_connection,
phantom: Default::default(),
}).await.map_err(|err| {
warn!("Error listing all debug logs: {:?}", err);
CommonWebError::from_error(err)
})?;
let debug_logs: Vec<ModerationListAllDebugLogsEntry> = result.debug_logs.into_iter().map(|row| {
// The join only yields user fields when the creator user exists.
let maybe_user = match (row.maybe_creator_user_token.clone(), row.maybe_user_display_name, row.maybe_user_username, row.maybe_user_gravatar_hash) {
(Some(user_token), Some(display_name), Some(username), Some(gravatar_hash)) => {
Some(ModerationDebugLogUser {
user_token,
display_name,
username,
gravatar_hash,
})
}
_ => None,
};
ModerationListAllDebugLogsEntry {
id: row.id,
event_token: row.event_token,
debug_log_type: row.debug_log_type,
maybe_log_level: row.maybe_log_level,
maybe_creator_user_token: row.maybe_creator_user_token,
message: row.message,
created_at: row.created_at,
maybe_user,
}
}).collect();
Ok(Json(ModerationListAllDebugLogsSuccessResponse {
success: true,
debug_logs,
next_cursor: result.next_cursor,
}))
}
@@ -2,80 +2,45 @@ use std::sync::Arc;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use chrono::{DateTime, Utc};
use log::warn;
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use enums::by_table::debug_logs::debug_log_type::DebugLogType;
use artcraft_api_defs::moderation::debug_logs::debug_log_entry::{ModerationDebugLogEntry, ModerationDebugLogUser};
use artcraft_api_defs::moderation::debug_logs::moderation_list_debug_logs_for_token::{
ModerationListDebugLogsForTokenPathInfo, ModerationListDebugLogsForTokenQueryParams,
ModerationListDebugLogsForTokenSuccessResponse,
};
use mysql_queries::queries::debug_logs::list_debug_logs_for_token::{
list_debug_logs_for_token, ListDebugLogsForTokenArgs,
};
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::users::UserToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::user_lookup::user_session::require_moderator::require_moderator;
use crate::state::server_state::ServerState;
// ── Path params ──
#[derive(Deserialize, ToSchema)]
pub struct ListDebugLogsPathInfo {
pub token: DebugLogEventToken,
}
// ── Query params ──
#[derive(Deserialize, IntoParams, ToSchema)]
pub struct ListDebugLogsQueryParams {
pub limit: Option<u32>,
}
// ── Response ──
#[derive(Serialize, ToSchema)]
pub struct ListDebugLogsSuccessResponse {
pub success: bool,
pub debug_logs: Vec<DebugLogEntry>,
}
#[derive(Serialize, ToSchema)]
pub struct DebugLogEntry {
pub id: u64,
pub event_token: DebugLogEventToken,
pub debug_log_type: DebugLogType,
pub maybe_creator_user_token: Option<UserToken>,
pub message: String,
pub created_at: DateTime<Utc>,
}
// ── Handler ──
#[utoipa::path(
get,
tag = "Moderation",
path = "/v1/moderation/debug_logs/list/{token}",
params(
("token" = String, Path, description = "Debug log event token"),
ListDebugLogsQueryParams,
ModerationListDebugLogsForTokenQueryParams,
),
responses(
(status = 200, description = "Success", body = ListDebugLogsSuccessResponse),
(status = 200, description = "Success", body = ModerationListDebugLogsForTokenSuccessResponse),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Server error"),
),
)]
pub async fn moderation_list_debug_logs_for_token_handler(
http_request: HttpRequest,
path: web::Path<ListDebugLogsPathInfo>,
query: web::Query<ListDebugLogsQueryParams>,
path: web::Path<ModerationListDebugLogsForTokenPathInfo>,
query: web::Query<ModerationListDebugLogsForTokenQueryParams>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ListDebugLogsSuccessResponse>, CommonWebError> {
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
) -> Result<Json<ModerationListDebugLogsForTokenSuccessResponse>, CommonWebError> {
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
let _user_session = require_moderator(&http_request, &server_state.session_checker, &mut *mysql_connection).await?;
let rows = list_debug_logs_for_token(ListDebugLogsForTokenArgs {
event_token: &path.token,
limit: query.limit,
@@ -86,18 +51,33 @@ pub async fn moderation_list_debug_logs_for_token_handler(
CommonWebError::from_error(err)
})?;
let debug_logs: Vec<DebugLogEntry> = rows.into_iter().map(|row| {
DebugLogEntry {
let debug_logs: Vec<ModerationDebugLogEntry> = rows.into_iter().map(|row| {
// The join only yields user fields when the creator user exists.
let maybe_user = match (row.maybe_creator_user_token.clone(), row.maybe_user_display_name, row.maybe_user_username, row.maybe_user_gravatar_hash) {
(Some(user_token), Some(display_name), Some(username), Some(gravatar_hash)) => {
Some(ModerationDebugLogUser {
user_token,
display_name,
username,
gravatar_hash,
})
}
_ => None,
};
ModerationDebugLogEntry {
id: row.id,
event_token: row.event_token,
debug_log_type: row.debug_log_type,
maybe_log_level: row.maybe_log_level,
maybe_creator_user_token: row.maybe_creator_user_token,
message: row.message,
created_at: row.created_at,
maybe_user,
}
}).collect();
Ok(Json(ListDebugLogsSuccessResponse {
Ok(Json(ModerationListDebugLogsForTokenSuccessResponse {
success: true,
debug_logs,
}))
@@ -0,0 +1,86 @@
use std::sync::Arc;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::moderation::debug_logs::debug_log_entry::{ModerationDebugLogEntry, ModerationDebugLogUser};
use artcraft_api_defs::moderation::debug_logs::moderation_list_debug_logs_for_user::{
ModerationListDebugLogsForUserPathInfo, ModerationListDebugLogsForUserQueryParams,
ModerationListDebugLogsForUserSuccessResponse,
};
use mysql_queries::queries::debug_logs::list_debug_logs_for_user::{
list_debug_logs_for_user, ListDebugLogsForUserArgs,
};
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::user_lookup::user_session::require_moderator::require_moderator;
use crate::state::server_state::ServerState;
#[utoipa::path(
get,
tag = "Moderation",
path = "/v1/moderation/debug_logs/user_list/{user_token}",
params(
("user_token" = String, Path, description = "User token"),
ModerationListDebugLogsForUserQueryParams,
),
responses(
(status = 200, description = "Success", body = ModerationListDebugLogsForUserSuccessResponse),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Server error"),
),
)]
pub async fn moderation_list_debug_logs_for_user_handler(
http_request: HttpRequest,
path: web::Path<ModerationListDebugLogsForUserPathInfo>,
query: web::Query<ModerationListDebugLogsForUserQueryParams>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ModerationListDebugLogsForUserSuccessResponse>, CommonWebError> {
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
let _user_session = require_moderator(&http_request, &server_state.session_checker, &mut *mysql_connection).await?;
let result = list_debug_logs_for_user(ListDebugLogsForUserArgs {
user_token: &path.user_token,
maybe_id_cursor: query.cursor,
limit: query.limit,
mysql_executor: &mut *mysql_connection,
phantom: Default::default(),
}).await.map_err(|err| {
warn!("Error listing debug logs for user {}: {:?}", path.user_token, err);
CommonWebError::from_error(err)
})?;
let debug_logs: Vec<ModerationDebugLogEntry> = result.debug_logs.into_iter().map(|row| {
// The join only yields user fields when the creator user exists.
let maybe_user = match (row.maybe_creator_user_token.clone(), row.maybe_user_display_name, row.maybe_user_username, row.maybe_user_gravatar_hash) {
(Some(user_token), Some(display_name), Some(username), Some(gravatar_hash)) => {
Some(ModerationDebugLogUser {
user_token,
display_name,
username,
gravatar_hash,
})
}
_ => None,
};
ModerationDebugLogEntry {
id: row.id,
event_token: row.event_token,
debug_log_type: row.debug_log_type,
maybe_log_level: row.maybe_log_level,
maybe_creator_user_token: row.maybe_creator_user_token,
message: row.message,
created_at: row.created_at,
maybe_user,
}
}).collect();
Ok(Json(ModerationListDebugLogsForUserSuccessResponse {
success: true,
debug_logs,
next_cursor: result.next_cursor,
}))
}
@@ -138,6 +138,7 @@ pub async fn omni_api_image_generate_handler(
apriori_debug_log_event_token: Some(&debug_log_event_token),
maybe_creator_user_token: Some(user_token),
debug_log_type: DebugLogType::HttpRequest,
maybe_log_level: None,
message: &serde_json::to_string(&*request).unwrap_or_default(),
mysql_executor: &mut *mysql_connection,
phantom: Default::default(),
@@ -169,6 +170,7 @@ pub async fn omni_api_image_generate_handler(
apriori_debug_log_event_token: Some(&debug_log_event_token),
maybe_creator_user_token: Some(user_token),
debug_log_type: DebugLogType::FalRequest,
maybe_log_level: None,
message: &format!("{:#?}", outbound_request),
mysql_executor: &mut *mysql_connection,
phantom: Default::default(),
@@ -205,6 +205,7 @@ pub async fn omni_api_video_generate_handler(
apriori_debug_log_event_token: Some(&debug_log_event_token),
maybe_creator_user_token: Some(user_token),
debug_log_type: DebugLogType::HttpRequest,
maybe_log_level: None,
message: &serde_json::to_string(&*request).unwrap_or_default(),
mysql_executor: &mut *mysql_connection,
phantom: Default::default(),
@@ -236,6 +237,7 @@ pub async fn omni_api_video_generate_handler(
apriori_debug_log_event_token: Some(&debug_log_event_token),
maybe_creator_user_token: Some(user_token),
debug_log_type: DebugLogType::FalRequest,
maybe_log_level: None,
message: &format!("{:#?}", outbound_request),
mysql_executor: &mut *mysql_connection,
phantom: Default::default(),
@@ -253,6 +255,7 @@ pub async fn omni_api_video_generate_handler(
apriori_debug_log_event_token: Some(&debug_log_event_token),
maybe_creator_user_token: Some(user_token),
debug_log_type: DebugLogType::GrokApiRequest,
maybe_log_level: None,
message: &format!("{:#?}", outbound_request),
mysql_executor: &mut *mysql_connection,
phantom: Default::default(),
@@ -134,6 +134,7 @@ pub async fn omni_gen_image_generate_handler(
apriori_debug_log_event_token: Some(&debug_log_event_token),
maybe_creator_user_token: Some(user_token),
debug_log_type: DebugLogType::HttpRequest,
maybe_log_level: None,
message: &serde_json::to_string(&*request).unwrap_or_default(),
mysql_executor: &mut *mysql_connection,
phantom: Default::default(),
@@ -165,6 +166,7 @@ pub async fn omni_gen_image_generate_handler(
apriori_debug_log_event_token: Some(&debug_log_event_token),
maybe_creator_user_token: Some(user_token),
debug_log_type: DebugLogType::FalRequest,
maybe_log_level: None,
message: &format!("{:#?}", outbound_request),
mysql_executor: &mut *mysql_connection,
phantom: Default::default(),
@@ -195,6 +195,7 @@ pub async fn omni_gen_video_generate_handler(
apriori_debug_log_event_token: Some(&debug_log_event_token),
maybe_creator_user_token: Some(user_token),
debug_log_type: DebugLogType::HttpRequest,
maybe_log_level: None,
message: &serde_json::to_string(&*request).unwrap_or_default(),
mysql_executor: &mut *mysql_connection,
phantom: Default::default(),
@@ -226,6 +227,7 @@ pub async fn omni_gen_video_generate_handler(
apriori_debug_log_event_token: Some(&debug_log_event_token),
maybe_creator_user_token: Some(user_token),
debug_log_type: DebugLogType::FalRequest,
maybe_log_level: None,
message: &format!("{:#?}", outbound_request),
mysql_executor: &mut *mysql_connection,
phantom: Default::default(),
@@ -243,6 +245,7 @@ pub async fn omni_gen_video_generate_handler(
apriori_debug_log_event_token: Some(&debug_log_event_token),
maybe_creator_user_token: Some(user_token),
debug_log_type: DebugLogType::GrokApiRequest,
maybe_log_level: None,
message: &format!("{:#?}", outbound_request),
mysql_executor: &mut *mysql_connection,
phantom: Default::default(),
@@ -101,6 +101,7 @@ pub async fn beeble_webhook_handler(
apriori_debug_log_event_token: Some(debug_log_event_token),
maybe_creator_user_token: job.maybe_creator_user_token.as_ref(),
debug_log_type: DebugLogType::BeebleWebhook,
maybe_log_level: None,
message: &raw_body,
mysql_executor: &mut *mysql_connection,
phantom: Default::default(),
@@ -52,6 +52,7 @@ pub async fn handle_failed_fal_webhook(
apriori_debug_log_event_token: Some(debug_log_event_token),
maybe_creator_user_token: job.maybe_creator_user_token.as_ref(),
debug_log_type: DebugLogType::FalWebhook,
maybe_log_level: None,
message: raw_body,
mysql_executor: &mut **mysql_connection,
phantom: Default::default(),
@@ -52,6 +52,7 @@ pub async fn handle_successful_fal_webhook(
apriori_debug_log_event_token: Some(debug_log_event_token),
maybe_creator_user_token: job.maybe_creator_user_token.as_ref(),
debug_log_type: DebugLogType::FalWebhook,
maybe_log_level: None,
message: raw_body,
mysql_executor: &mut **mysql_connection,
phantom: Default::default(),
@@ -36,7 +36,9 @@ use crate::http_server::endpoints::moderation::user::moderator_user_lookup_by_st
use crate::http_server::endpoints::moderation::user::moderator_user_lookup_handler::moderator_user_lookup_handler;
use crate::http_server::endpoints::moderation::alerts::moderation_send_alert_handler::moderation_send_alert_handler;
use crate::http_server::endpoints::moderation::staff_audit_logs::moderator_list_staff_audit_logs_handler::moderator_list_staff_audit_logs_handler;
use crate::http_server::endpoints::moderation::debug_logs::moderation_list_all_debug_logs_handler::moderation_list_all_debug_logs_handler;
use crate::http_server::endpoints::moderation::debug_logs::moderation_list_debug_logs_for_token_handler::moderation_list_debug_logs_for_token_handler;
use crate::http_server::endpoints::moderation::debug_logs::moderation_list_debug_logs_for_user_handler::moderation_list_debug_logs_for_user_handler;
use crate::http_server::endpoints::moderation::jobs::moderation_get_job_by_token_handler::moderation_get_job_by_token_handler;
use crate::http_server::endpoints::moderation::user_feature_flags::moderator_edit_user_feature_flags_handler::moderator_edit_user_feature_flags_handler;
use crate::http_server::endpoints::moderation::user_feature_flags::moderator_list_all_available_user_feature_flags_handler::moderator_list_all_available_user_feature_flags_handler;
@@ -102,6 +104,14 @@ pub fn add_moderator_routes<T, B> (app: App<T>) -> App<T>
.route(web::get().to(moderation_list_debug_logs_for_token_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
.service(web::resource("/list_all")
.route(web::get().to(moderation_list_all_debug_logs_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
.service(web::resource("/user_list/{user_token}")
.route(web::get().to(moderation_list_debug_logs_for_user_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
)
.service(
web::scope("/user_emails")
+15 -2
View File
@@ -1,4 +1,4 @@
import { BrowserRouter, Routes, Route, Navigate, useLocation, useSearchParams } from "react-router-dom";
import { BrowserRouter, Routes, Route, Navigate, useLocation, useSearchParams, useParams } from "react-router-dom";
import { AuthProvider, useAuth } from "@/context/AuthContext";
import { DashboardLayout } from "@/layouts/DashboardLayout";
import { LoginPage } from "@/pages/Login";
@@ -22,6 +22,8 @@ import { UserReferrals } from "@/pages/UserReferrals";
import { JobInfo } from "@/pages/JobInfo";
import { JobTokenSearch } from "@/pages/JobTokenSearch";
import { DebugLogs } from "@/pages/DebugLogs";
import { DebugLogsAll } from "@/pages/DebugLogsAll";
import { UserDebugLogs } from "@/pages/UserDebugLogs";
import { DebugLogsSearch } from "@/pages/DebugLogsSearch";
import { SpendEvents } from "@/pages/SpendEvents";
import { ReengagementList } from "@/pages/ReengagementList";
@@ -84,9 +86,13 @@ function AppRoutes() {
path="moderation/debug-logs-search"
element={<DebugLogsSearch />}
/>
<Route path="debug_logs/list" element={<DebugLogsAll />} />
<Route path="debug_logs/user/:userToken" element={<UserDebugLogs />} />
<Route path="debug_logs/:eventToken" element={<DebugLogs />} />
{/* Legacy URL — redirect old links/bookmarks to the new path. */}
<Route
path="moderation/debug-logs/:eventToken"
element={<DebugLogs />}
element={<LegacyDebugLogsRedirect />}
/>
</Route>
@@ -109,3 +115,10 @@ function App() {
}
export default App;
function LegacyDebugLogsRedirect() {
const { eventToken } = useParams<{ eventToken: string }>();
return (
<Navigate to={`/debug_logs/${encodeURIComponent(eventToken || "")}`} replace />
);
}
@@ -1,5 +1,6 @@
import { ApiManager, type ApiResponse } from "./ApiManager";
import type {
AllDebugLog,
Wallet,
WalletWithOwner,
WalletLedgerEntry,
@@ -739,6 +740,74 @@ export class ModerationApi extends ApiManager {
}));
}
public async ListAllDebugLogs(params: {
severities?: string[];
cursor?: number;
limit?: number;
}): Promise<
ApiResponse<{ debug_logs: AllDebugLog[]; next_cursor: number | null }>
> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/debug_logs/list_all`;
return await this.get<{
success: boolean;
debug_logs: AllDebugLog[];
next_cursor: number | null;
error_message?: string;
}>({
endpoint,
query: {
severities: params.severities?.length
? params.severities.join(",")
: undefined,
cursor: params.cursor,
limit: params.limit,
},
})
.then((response) => ({
success: response.success,
data: {
debug_logs: response.debug_logs || [],
next_cursor: response.next_cursor ?? null,
},
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async ListUserDebugLogs(
user_token: string,
cursor?: number,
limit?: number,
): Promise<
ApiResponse<{ debug_logs: DebugLog[]; next_cursor: number | null }>
> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/debug_logs/user_list/${encodeURIComponent(user_token)}`;
return await this.get<{
success: boolean;
debug_logs: DebugLog[];
next_cursor: number | null;
error_message?: string;
}>({
endpoint,
query: { cursor, limit },
})
.then((response) => ({
success: response.success,
data: {
debug_logs: response.debug_logs || [],
next_cursor: response.next_cursor ?? null,
},
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async ListUserJobs(
user_token: string,
): Promise<ApiResponse<{ jobs: UserJob[] }>> {
@@ -0,0 +1,359 @@
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { humanize } from "@/lib/utils";
import type { DebugLog } from "@/types";
import {
IconCheck,
IconChevronDown,
IconChevronRight,
IconCopy,
IconListSearch,
IconUser,
} from "@tabler/icons-react";
export function formatDebugLogDateTime(iso: string): string {
return new Date(iso).toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
});
}
function getLogTypeBadgeProps(logType: string): {
variant: "default" | "secondary" | "destructive" | "outline";
className: string;
} {
switch (logType) {
case "http_request":
return {
variant: "secondary",
className: "bg-blue-500/10 text-blue-400 border-transparent",
};
case "fal_request":
return {
variant: "secondary",
className: "bg-purple-500/10 text-purple-400 border-transparent",
};
case "fal_queue":
return {
variant: "secondary",
className: "bg-fuchsia-500/10 text-fuchsia-400 border-transparent",
};
case "fal_webhook":
return {
variant: "secondary",
className: "bg-amber-500/10 text-amber-400 border-transparent",
};
case "kinovi_request":
return {
variant: "secondary",
className: "bg-cyan-500/10 text-cyan-400 border-transparent",
};
default:
return { variant: "outline", className: "text-muted-foreground" };
}
}
export function getLogLevelBadgeClassName(level: string): string {
switch (level) {
case "error":
return "bg-red-500/10 text-red-400 border-transparent";
case "warn":
return "bg-amber-500/10 text-amber-400 border-transparent";
case "info":
return "bg-sky-500/10 text-sky-400 border-transparent";
case "debug":
return "bg-slate-500/10 text-slate-400 border-transparent";
case "trace":
return "bg-zinc-500/10 text-zinc-400 border-transparent";
default:
return "text-muted-foreground";
}
}
/** Broadcast expand/collapse-all. Bump `generation` to re-apply. */
export interface ExpandAllState {
mode: "expand" | "collapse";
generation: number;
}
export interface DebugLogCardProps {
log: DebugLog;
copiedId: string | null;
onCopy: (value: string, id: string) => void;
/** Show a direct link to the event's debug-logs page in the header row. */
showEventLink?: boolean;
/** Start expanded (defaults to true). */
defaultExpanded?: boolean;
/** Expand/collapse-all broadcast from the page. */
expandAll?: ExpandAllState;
}
export function DebugLogCard({
log,
copiedId,
onCopy,
showEventLink = false,
defaultExpanded = true,
expandAll,
}: DebugLogCardProps) {
const maybeUser = log.maybe_user;
const [expanded, setExpanded] = useState(defaultExpanded);
// Apply page-level expand/collapse-all; individual toggles keep working after.
useEffect(() => {
if (expandAll) setExpanded(expandAll.mode === "expand");
}, [expandAll?.generation]);
const badge = getLogTypeBadgeProps(log.debug_log_type);
const copyId = `log_${log.id}`;
return (
<div className="rounded-xl border bg-card text-card-foreground shadow-sm overflow-hidden">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="w-full flex items-center gap-3 p-4 hover:bg-muted/30 transition-colors text-left"
>
{expanded ? (
<IconChevronDown className="size-4 shrink-0 text-muted-foreground" />
) : (
<IconChevronRight className="size-4 shrink-0 text-muted-foreground" />
)}
<Badge variant={badge.variant} className={badge.className}>
{humanize(log.debug_log_type)}
</Badge>
{log.maybe_log_level && (
<Badge
variant="secondary"
className={getLogLevelBadgeClassName(log.maybe_log_level)}
>
{log.maybe_log_level.toUpperCase()}
</Badge>
)}
{showEventLink && (
<span
className="inline-flex items-center min-w-0"
onClick={(e) => e.stopPropagation()}
>
<Link
to={`/debug_logs/${encodeURIComponent(log.event_token)}`}
className="font-mono text-xs text-muted-foreground hover:text-foreground hover:underline truncate"
title={`View event ${log.event_token}`}
>
{log.event_token}
</Link>
</span>
)}
{maybeUser && (
<span
className="inline-flex items-center gap-1 text-xs text-muted-foreground min-w-0"
onClick={(e) => e.stopPropagation()}
>
<Link
to={`/user/profile/${encodeURIComponent(maybeUser.username)}`}
className="inline-flex items-center gap-1 hover:underline text-foreground/80 min-w-0"
title={maybeUser.username}
>
<IconUser className="size-3.5 shrink-0" />
<span className="truncate">{maybeUser.display_name}</span>
</Link>
<Link
to={`/debug_logs/user/${encodeURIComponent(maybeUser.user_token)}`}
className="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground shrink-0 hover:underline"
title="View this user's debug logs"
>
<IconListSearch className="size-3.5" />
logs
</Link>
</span>
)}
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
{formatDebugLogDateTime(log.created_at)}
</span>
<span className="text-xs text-muted-foreground/60 font-mono ml-auto">
#{log.id}
</span>
<span
className="ml-2 inline-flex items-center"
onClick={(e) => {
e.stopPropagation();
onCopy(log.message, copyId);
}}
>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
title="Copy payload"
>
{copiedId === copyId ? (
<IconCheck className="size-3.5 text-emerald-400" />
) : (
<IconCopy className="size-3.5" />
)}
</Button>
</span>
</button>
{expanded && (
<div className="border-t border-border/50 p-4 flex flex-col gap-3">
<div className="flex flex-wrap gap-x-6 gap-y-1 text-xs text-muted-foreground">
<div>
<span className="uppercase tracking-wider font-medium">
Event:
</span>{" "}
<Link
to={`/debug_logs/${encodeURIComponent(log.event_token)}`}
className="font-mono text-foreground/80 hover:underline"
>
{log.event_token}
</Link>
</div>
{log.maybe_creator_user_token && (
<span className="inline-flex items-center gap-3 min-w-0">
<button
type="button"
onClick={() =>
onCopy(log.maybe_creator_user_token!, `user_token_${log.id}`)
}
className="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground cursor-pointer min-w-0"
title="Copy user token"
>
<span className="whitespace-nowrap">user token:</span>
<span className="font-mono truncate">
{log.maybe_creator_user_token}
</span>
{copiedId === `user_token_${log.id}` ? (
<IconCheck className="size-3 shrink-0 text-emerald-400" />
) : (
<IconCopy className="size-3 shrink-0 opacity-60" />
)}
</button>
<span className="text-muted-foreground/40">|</span>
{maybeUser && (
<Link
to={`/user/profile/${encodeURIComponent(maybeUser.username)}`}
className="inline-flex items-center gap-1.5 text-foreground/80 hover:text-foreground hover:underline min-w-0"
title="View profile"
>
<IconUser className="size-3.5 shrink-0" />
<span className="truncate">{maybeUser.display_name}</span>
</Link>
)}
<Link
to={`/debug_logs/user/${encodeURIComponent(log.maybe_creator_user_token)}`}
className="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground hover:underline shrink-0"
title="View this user's debug logs"
>
<IconListSearch className="size-3.5" />
logs
</Link>
</span>
)}
</div>
<PrettyPayload raw={log.message} />
</div>
)}
</div>
);
}
function tryParseJson(raw: string): unknown | undefined {
const trimmed = raw.trim();
if (
!(
(trimmed.startsWith("{") && trimmed.endsWith("}")) ||
(trimmed.startsWith("[") && trimmed.endsWith("]"))
)
) {
return undefined;
}
try {
return JSON.parse(trimmed);
} catch {
return undefined;
}
}
export function PrettyPayload({ raw }: { raw: string }) {
const parsed = useMemo(() => tryParseJson(raw), [raw]);
if (parsed !== undefined) {
return (
<pre className="text-xs font-mono whitespace-pre-wrap wrap-break-word bg-muted/40 p-4 rounded-lg max-h-[600px] overflow-auto text-foreground/90">
{JSON.stringify(parsed, null, 2)}
</pre>
);
}
// Likely a Rust struct debug print or plain text. Re-indent loosely so it's readable.
const formatted = formatRustyDebug(raw);
return (
<pre className="text-xs font-mono whitespace-pre-wrap wrap-break-word bg-muted/40 p-4 rounded-lg max-h-[600px] overflow-auto text-foreground/80">
{formatted}
</pre>
);
}
// Lightweight reformatter for Rust's `Debug` output (e.g. `Foo { a: 1, b: Bar { c: 2 } }`).
// Adds line breaks and indentation around `{`, `}`, `[`, `]`, and top-level commas.
// Falls back gracefully for anything it doesn't understand.
function formatRustyDebug(raw: string): string {
const looksRusty = /[{[].+[}\]]/s.test(raw);
if (!looksRusty) return raw;
let depth = 0;
let out = "";
let inString = false;
let stringChar: '"' | "'" | null = null;
const indent = () => " ".repeat(depth);
for (let i = 0; i < raw.length; i++) {
const ch = raw[i];
const prev = raw[i - 1];
// String tracking (handle escapes)
if (inString) {
out += ch;
if (ch === stringChar && prev !== "\\") {
inString = false;
stringChar = null;
}
continue;
}
if (ch === '"' || ch === "'") {
inString = true;
stringChar = ch;
out += ch;
continue;
}
if (ch === "{" || ch === "[") {
depth++;
out += ch + "\n" + indent();
continue;
}
if (ch === "}" || ch === "]") {
depth = Math.max(0, depth - 1);
out = out.replace(/[ \t]+$/, "");
out += "\n" + indent() + ch;
continue;
}
if (ch === "," && depth > 0) {
out += ",\n" + indent();
// Skip following whitespace
while (i + 1 < raw.length && raw[i + 1] === " ") i++;
continue;
}
out += ch;
}
return out.trim();
}
@@ -71,8 +71,8 @@ const data = {
label: "Oncall",
items: [
{ title: "Page Oncall", url: "/send-pager", icon: IconBellRinging },
{ title: "Debug Logs", url: "/debug_logs/list", icon: IconBug },
{ title: "Search Job by Token", url: "/moderation/job-search", icon: IconBriefcase },
{ title: "Search Debug Logs", url: "/moderation/debug-logs-search", icon: IconBug },
{ title: "Impersonation Logs", url: "/impersonation", icon: IconKey },
{ title: "Staff Audit Logs", url: "/staff-audit-logs", icon: IconClipboardList },
{ title: "Feature Flags", url: "/feature-flags", icon: IconFlag },
@@ -3,71 +3,21 @@ import { useParams, useNavigate } from "react-router-dom";
import { ModerationApi } from "@/api/ModerationApi";
import type { DebugLog } from "@/types";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Input } from "@/components/ui/input";
import { usePageTitle } from "@/hooks/usePageTitle";
import { humanize } from "@/lib/utils";
import { RawTag } from "@/components/RawTag";
import { DebugLogCard } from "@/components/DebugLogCard";
import {
IconAlertCircle,
IconArrowLeft,
IconBug,
IconCheck,
IconCopy,
IconChevronDown,
IconChevronRight,
IconRefresh,
IconSearch,
} from "@tabler/icons-react";
function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
});
}
function getLogTypeBadgeProps(logType: string): {
variant: "default" | "secondary" | "destructive" | "outline";
className: string;
} {
switch (logType) {
case "http_request":
return {
variant: "secondary",
className: "bg-blue-500/10 text-blue-400 border-transparent",
};
case "fal_request":
return {
variant: "secondary",
className: "bg-purple-500/10 text-purple-400 border-transparent",
};
case "fal_queue":
return {
variant: "secondary",
className: "bg-fuchsia-500/10 text-fuchsia-400 border-transparent",
};
case "fal_webhook":
return {
variant: "secondary",
className: "bg-amber-500/10 text-amber-400 border-transparent",
};
case "kinovi_request":
return {
variant: "secondary",
className: "bg-cyan-500/10 text-cyan-400 border-transparent",
};
default:
return { variant: "outline", className: "text-muted-foreground" };
}
}
const DEFAULT_LIMIT = 100;
export function DebugLogs() {
@@ -244,172 +194,3 @@ export function DebugLogs() {
</div>
);
}
interface DebugLogCardProps {
log: DebugLog;
copiedId: string | null;
onCopy: (value: string, id: string) => void;
}
function DebugLogCard({ log, copiedId, onCopy }: DebugLogCardProps) {
const [expanded, setExpanded] = useState(true);
const badge = getLogTypeBadgeProps(log.debug_log_type);
const copyId = `log_${log.id}`;
return (
<div className="rounded-xl border bg-card text-card-foreground shadow-sm overflow-hidden">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="w-full flex items-center gap-3 p-4 hover:bg-muted/30 transition-colors text-left"
>
{expanded ? (
<IconChevronDown className="size-4 shrink-0 text-muted-foreground" />
) : (
<IconChevronRight className="size-4 shrink-0 text-muted-foreground" />
)}
<Badge variant={badge.variant} className={badge.className}>
{humanize(log.debug_log_type)}
</Badge>
<RawTag value={log.debug_log_type} />
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
{formatDateTime(log.created_at)}
</span>
<span className="text-xs text-muted-foreground/60 font-mono ml-auto">
#{log.id}
</span>
<span
className="ml-2 inline-flex items-center"
onClick={(e) => {
e.stopPropagation();
onCopy(log.message, copyId);
}}
>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
title="Copy payload"
>
{copiedId === copyId ? (
<IconCheck className="size-3.5 text-emerald-400" />
) : (
<IconCopy className="size-3.5" />
)}
</Button>
</span>
</button>
{expanded && (
<div className="border-t border-border/50 p-4 flex flex-col gap-3">
{log.maybe_creator_user_token && (
<div className="text-xs text-muted-foreground">
<span className="uppercase tracking-wider font-medium">
User Token:
</span>{" "}
<span className="font-mono text-foreground/80">
{log.maybe_creator_user_token}
</span>
</div>
)}
<PrettyPayload raw={log.message} />
</div>
)}
</div>
);
}
function tryParseJson(raw: string): unknown | undefined {
const trimmed = raw.trim();
if (
!(
(trimmed.startsWith("{") && trimmed.endsWith("}")) ||
(trimmed.startsWith("[") && trimmed.endsWith("]"))
)
) {
return undefined;
}
try {
return JSON.parse(trimmed);
} catch {
return undefined;
}
}
function PrettyPayload({ raw }: { raw: string }) {
const parsed = useMemo(() => tryParseJson(raw), [raw]);
if (parsed !== undefined) {
return (
<pre className="text-xs font-mono whitespace-pre-wrap wrap-break-word bg-muted/40 p-4 rounded-lg max-h-[600px] overflow-auto text-foreground/90">
{JSON.stringify(parsed, null, 2)}
</pre>
);
}
// Likely a Rust struct debug print or plain text. Re-indent loosely so it's readable.
const formatted = formatRustyDebug(raw);
return (
<pre className="text-xs font-mono whitespace-pre-wrap wrap-break-word bg-muted/40 p-4 rounded-lg max-h-[600px] overflow-auto text-foreground/80">
{formatted}
</pre>
);
}
// Lightweight reformatter for Rust's `Debug` output (e.g. `Foo { a: 1, b: Bar { c: 2 } }`).
// Adds line breaks and indentation around `{`, `}`, `[`, `]`, and top-level commas.
// Falls back gracefully for anything it doesn't understand.
function formatRustyDebug(raw: string): string {
const looksRusty = /[{[].+[}\]]/s.test(raw);
if (!looksRusty) return raw;
let depth = 0;
let out = "";
let inString = false;
let stringChar: '"' | "'" | null = null;
const indent = () => " ".repeat(depth);
for (let i = 0; i < raw.length; i++) {
const ch = raw[i];
const prev = raw[i - 1];
// String tracking (handle escapes)
if (inString) {
out += ch;
if (ch === stringChar && prev !== "\\") {
inString = false;
stringChar = null;
}
continue;
}
if (ch === '"' || ch === "'") {
inString = true;
stringChar = ch;
out += ch;
continue;
}
if (ch === "{" || ch === "[") {
depth++;
out += ch + "\n" + indent();
continue;
}
if (ch === "}" || ch === "]") {
depth = Math.max(0, depth - 1);
out = out.replace(/[ \t]+$/, "");
out += "\n" + indent() + ch;
continue;
}
if (ch === "," && depth > 0) {
out += ",\n" + indent();
// Skip following whitespace
while (i + 1 < raw.length && raw[i + 1] === " ") i++;
continue;
}
out += ch;
}
return out.trim();
}
@@ -0,0 +1,290 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { ModerationApi } from "@/api/ModerationApi";
import type { AllDebugLog, DebugLogLevel } from "@/types";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { usePageTitle } from "@/hooks/usePageTitle";
import {
DebugLogCard,
getLogLevelBadgeClassName,
type ExpandAllState,
} from "@/components/DebugLogCard";
import {
IconAlertCircle,
IconBug,
IconChevronsDown,
IconChevronsUp,
IconLoader2,
IconRefresh,
IconSearch,
} from "@tabler/icons-react";
const PAGE_SIZE = 50;
const ALL_SEVERITIES: DebugLogLevel[] = [
"error",
"warn",
"info",
"debug",
"trace",
];
export function DebugLogsAll() {
usePageTitle("Debug Logs");
const [logs, setLogs] = useState<AllDebugLog[]>([]);
const [nextCursor, setNextCursor] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [expandAll, setExpandAll] = useState<ExpandAllState | undefined>();
const broadcastExpandAll = (mode: ExpandAllState["mode"]) => {
setExpandAll((prev) => ({ mode, generation: (prev?.generation ?? 0) + 1 }));
};
const [severities, setSeverities] = useState<DebugLogLevel[]>([]);
const observerRef = useRef<IntersectionObserver | null>(null);
const sentinelRef = useRef<HTMLDivElement | null>(null);
const loadingRef = useRef(false);
const copy = (value: string, id: string) => {
navigator.clipboard.writeText(value).then(() => {
setCopiedId(id);
setTimeout(() => setCopiedId((c) => (c === id ? null : c)), 2000);
});
};
const loadData = useCallback(
async (cursor: number | null, append: boolean, levels: DebugLogLevel[]) => {
if (loadingRef.current) return;
loadingRef.current = true;
if (append) {
setIsLoadingMore(true);
} else {
setIsLoading(true);
setError(null);
}
try {
const api = new ModerationApi();
const resp = await api.ListAllDebugLogs({
severities: levels,
cursor: cursor ?? undefined,
limit: PAGE_SIZE,
});
if (resp.success && resp.data) {
const page = resp.data.debug_logs;
setLogs((prev) => (append ? [...prev, ...page] : page));
setNextCursor(resp.data.next_cursor);
} else {
setError(resp.errorMessage || "Failed to load debug logs");
}
} catch (err: any) {
setError(err.message || "Failed to load debug logs");
} finally {
setIsLoading(false);
setIsLoadingMore(false);
loadingRef.current = false;
}
},
[],
);
// Initial load + reload on severity filter change.
useEffect(() => {
setLogs([]);
setNextCursor(null);
loadData(null, false, severities);
}, [severities, loadData]);
// Infinite scroll sentinel.
useEffect(() => {
observerRef.current?.disconnect();
if (nextCursor === null) return;
observerRef.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadData(nextCursor, true, severities);
}
},
{ threshold: 0.1 },
);
if (sentinelRef.current) observerRef.current.observe(sentinelRef.current);
return () => observerRef.current?.disconnect();
}, [nextCursor, severities, loadData]);
const toggleSeverity = (level: DebugLogLevel) => {
setSeverities((prev) =>
prev.includes(level)
? prev.filter((l) => l !== level)
: [...prev, level],
);
};
return (
<div className="flex flex-col gap-8 pb-10">
{/* Header */}
<div className="flex items-center gap-4">
<div className="flex-1 min-w-0">
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-3">
<IconBug className="size-6 text-muted-foreground" />
Debug Logs
</h1>
<p className="text-muted-foreground text-sm mt-1">
All debug logs across the system, most recent first.
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" asChild>
<Link
to="/moderation/debug-logs-search"
title="Search debug logs by event token"
>
<IconSearch className="size-4" />
Search
</Link>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => loadData(null, false, severities)}
disabled={isLoading}
>
<IconRefresh className="size-4" />
Refresh
</Button>
</div>
</div>
{/* Severity filter */}
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs uppercase tracking-wider text-muted-foreground font-medium">
Severity:
</span>
{ALL_SEVERITIES.map((level) => {
const active = severities.includes(level);
return (
<button
key={level}
type="button"
onClick={() => toggleSeverity(level)}
className="cursor-pointer"
>
<Badge
variant={active ? "secondary" : "outline"}
className={
active
? getLogLevelBadgeClassName(level) + " ring-1 ring-current"
: "text-muted-foreground/60 hover:text-muted-foreground"
}
>
{level.toUpperCase()}
</Badge>
</button>
);
})}
{severities.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-muted-foreground"
onClick={() => setSeverities([])}
>
Clear
</Button>
)}
</div>
{error && !isLoading && (
<Alert variant="destructive" className="max-w-xl">
<IconAlertCircle className="size-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{isLoading && (
<div className="flex flex-col gap-3">
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="rounded-xl border bg-card shadow-sm flex flex-col p-4 gap-3"
>
<div className="flex items-center gap-3">
<Skeleton className="h-5 w-24 rounded-full" />
<Skeleton className="h-4 w-40" />
</div>
<Skeleton className="h-14 w-full" />
</div>
))}
</div>
)}
{!isLoading && logs.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-20 bg-muted/20 border border-dashed rounded-xl">
<IconBug className="size-10 text-muted-foreground mb-4 opacity-20" />
<p className="text-muted-foreground">
{severities.length > 0
? "No debug logs match the selected severities."
: "No debug logs found."}
</p>
</div>
)}
{/* Sticky expand/collapse toolbar */}
<div className="sticky top-0 z-20 -mx-4 lg:-mx-6 px-4 lg:px-6 py-2 -my-4 bg-background/80 backdrop-blur-md border-b flex items-center gap-2">
<span className="text-xs text-muted-foreground mr-1">
{logs.length} log{logs.length === 1 ? "" : "s"}
</span>
<Button
variant="outline"
size="sm"
className="h-7"
onClick={() => broadcastExpandAll("expand")}
>
<IconChevronsDown className="size-4" />
Expand all
</Button>
<Button
variant="outline"
size="sm"
className="h-7"
onClick={() => broadcastExpandAll("collapse")}
>
<IconChevronsUp className="size-4" />
Collapse all
</Button>
</div>
{!isLoading && logs.length > 0 && (
<div className="flex flex-col gap-3">
{logs.map((log) => (
<DebugLogCard
key={log.id}
log={log}
copiedId={copiedId}
onCopy={copy}
showEventLink
defaultExpanded={false}
expandAll={expandAll}
/>
))}
</div>
)}
{/* Infinite scroll sentinel */}
{nextCursor !== null && !isLoading && (
<div ref={sentinelRef} className="flex justify-center py-6">
{isLoadingMore && (
<IconLoader2 className="size-5 animate-spin text-muted-foreground" />
)}
</div>
)}
</div>
);
}
@@ -13,7 +13,7 @@ export function DebugLogsSearch() {
buttonLabel="Open"
icon={IconBug}
buildHref={(token) =>
`/moderation/debug-logs/${encodeURIComponent(token)}`
`/debug_logs/${encodeURIComponent(token)}`
}
/>
);
@@ -314,7 +314,7 @@ export function JobInfo() {
{job.maybe_debug_log_event_token && (
<Button variant="default" size="sm" asChild>
<Link
to={`/moderation/debug-logs/${encodeURIComponent(
to={`/debug_logs/${encodeURIComponent(
job.maybe_debug_log_event_token,
)}`}
>
@@ -0,0 +1,275 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { ModerationApi } from "@/api/ModerationApi";
import type { DebugLog } from "@/types";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { usePageTitle } from "@/hooks/usePageTitle";
import { DebugLogCard, type ExpandAllState } from "@/components/DebugLogCard";
import {
IconAlertCircle,
IconArrowLeft,
IconBug,
IconChevronsDown,
IconChevronsUp,
IconLoader2,
IconRefresh,
IconUser,
} from "@tabler/icons-react";
const PAGE_SIZE = 50;
interface UserHeaderInfo {
display_name: string;
username: string;
}
export function UserDebugLogs() {
const { userToken } = useParams<{ userToken: string }>();
const navigate = useNavigate();
const [user, setUser] = useState<UserHeaderInfo | null>(null);
const [logs, setLogs] = useState<DebugLog[]>([]);
const [nextCursor, setNextCursor] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [expandAll, setExpandAll] = useState<ExpandAllState | undefined>();
const broadcastExpandAll = (mode: ExpandAllState["mode"]) => {
setExpandAll((prev) => ({ mode, generation: (prev?.generation ?? 0) + 1 }));
};
usePageTitle(
user ? `Debug Logs · ${user.display_name}` : "User Debug Logs",
);
const observerRef = useRef<IntersectionObserver | null>(null);
const sentinelRef = useRef<HTMLDivElement | null>(null);
const loadingRef = useRef(false);
const copy = (value: string, id: string) => {
navigator.clipboard.writeText(value).then(() => {
setCopiedId(id);
setTimeout(() => setCopiedId((c) => (c === id ? null : c)), 2000);
});
};
const loadLogs = useCallback(
async (cursor: number | null, append: boolean) => {
if (!userToken || loadingRef.current) return;
loadingRef.current = true;
if (append) {
setIsLoadingMore(true);
} else {
setIsLoading(true);
setError(null);
}
try {
const api = new ModerationApi();
const resp = await api.ListUserDebugLogs(
userToken,
cursor ?? undefined,
PAGE_SIZE,
);
if (resp.success && resp.data) {
const page = resp.data.debug_logs;
setLogs((prev) => (append ? [...prev, ...page] : page));
setNextCursor(resp.data.next_cursor);
} else {
setError(resp.errorMessage || "Failed to load debug logs");
}
} catch (err: any) {
setError(err.message || "Failed to load debug logs");
} finally {
setIsLoading(false);
setIsLoadingMore(false);
loadingRef.current = false;
}
},
[userToken],
);
// Load logs + basic user info (for the header) on mount.
useEffect(() => {
setLogs([]);
setNextCursor(null);
setUser(null);
loadLogs(null, false);
if (userToken) {
const api = new ModerationApi();
api.UserLookup(userToken).then((resp) => {
if (resp.success && resp.data?.maybe_user) {
setUser({
display_name: resp.data.maybe_user.display_name,
username: resp.data.maybe_user.username,
});
}
});
}
}, [userToken, loadLogs]);
// Infinite scroll sentinel.
useEffect(() => {
observerRef.current?.disconnect();
if (nextCursor === null) return;
observerRef.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadLogs(nextCursor, true);
}
},
{ threshold: 0.1 },
);
if (sentinelRef.current) observerRef.current.observe(sentinelRef.current);
return () => observerRef.current?.disconnect();
}, [nextCursor, loadLogs]);
return (
<div className="flex flex-col gap-8 pb-10">
{/* Header */}
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(-1)}
className="p-0! h-auto w-auto hover:bg-transparent! text-foreground/70 hover:text-foreground/50"
>
<IconArrowLeft className="size-6" />
</Button>
<div className="flex-1 min-w-0">
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-3">
<IconBug className="size-6 text-muted-foreground" />
User Debug Logs
</h1>
<p className="text-muted-foreground text-sm mt-1 flex items-center gap-2 min-w-0">
{user ? (
<>
<Link
to={`/user/profile/${encodeURIComponent(user.username)}`}
className="inline-flex items-center gap-1.5 hover:underline text-foreground/80 min-w-0"
title="View profile"
>
<IconUser className="size-4 shrink-0" />
<span className="truncate">{user.display_name}</span>
</Link>
<span className="font-mono text-muted-foreground/70 truncate">
{userToken}
</span>
</>
) : (
<span className="font-mono truncate">{userToken}</span>
)}
</p>
</div>
<div className="flex gap-2">
{user && (
<Button variant="outline" size="sm" asChild>
<Link to={`/user/profile/${encodeURIComponent(user.username)}`}>
<IconUser className="size-4" />
Profile
</Link>
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => loadLogs(null, false)}
disabled={isLoading}
>
<IconRefresh className="size-4" />
Refresh
</Button>
</div>
</div>
{error && !isLoading && (
<Alert variant="destructive" className="max-w-xl">
<IconAlertCircle className="size-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{isLoading && (
<div className="flex flex-col gap-3">
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="rounded-xl border bg-card shadow-sm flex flex-col p-4 gap-3"
>
<div className="flex items-center gap-3">
<Skeleton className="h-5 w-24 rounded-full" />
<Skeleton className="h-4 w-40" />
</div>
<Skeleton className="h-14 w-full" />
</div>
))}
</div>
)}
{!isLoading && logs.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-20 bg-muted/20 border border-dashed rounded-xl">
<IconBug className="size-10 text-muted-foreground mb-4 opacity-20" />
<p className="text-muted-foreground">
No debug logs found for this user.
</p>
</div>
)}
{/* Sticky expand/collapse toolbar */}
<div className="sticky top-0 z-20 -mx-4 lg:-mx-6 px-4 lg:px-6 py-2 -my-4 bg-background/80 backdrop-blur-md border-b flex items-center gap-2">
<span className="text-xs text-muted-foreground mr-1">
{logs.length} log{logs.length === 1 ? "" : "s"}
</span>
<Button
variant="outline"
size="sm"
className="h-7"
onClick={() => broadcastExpandAll("expand")}
>
<IconChevronsDown className="size-4" />
Expand all
</Button>
<Button
variant="outline"
size="sm"
className="h-7"
onClick={() => broadcastExpandAll("collapse")}
>
<IconChevronsUp className="size-4" />
Collapse all
</Button>
</div>
{!isLoading && logs.length > 0 && (
<div className="flex flex-col gap-3">
{logs.map((log) => (
<DebugLogCard
key={log.id}
log={log}
copiedId={copiedId}
onCopy={copy}
showEventLink
defaultExpanded={false}
expandAll={expandAll}
/>
))}
</div>
)}
{/* Infinite scroll sentinel */}
{nextCursor !== null && !isLoading && (
<div ref={sentinelRef} className="flex justify-center py-6">
{isLoadingMore && (
<IconLoader2 className="size-5 animate-spin text-muted-foreground" />
)}
</div>
)}
</div>
);
}
@@ -25,6 +25,7 @@ import {
IconMail,
IconUser,
IconShieldLock,
IconBug,
IconChartLine,
IconCoin,
IconReceipt2,
@@ -568,6 +569,12 @@ export function UserProfile() {
Spend History
</Link>
</Button>
<Button variant="outline" size="sm" asChild>
<Link to={`/debug_logs/user/${user.token}`}>
<IconBug className="size-4" />
User Debug Logs
</Link>
</Button>
<Button
variant="outline"
size="sm"
@@ -111,15 +111,35 @@ export type DebugLogType =
| "kinovi_request"
| (string & {});
export type DebugLogLevel =
| "info"
| "warn"
| "error"
| "debug"
| "trace"
| (string & {});
export interface DebugLog {
created_at: string;
debug_log_type: DebugLogType;
event_token: string;
id: number;
maybe_log_level: DebugLogLevel | null;
maybe_creator_user_token: string | null;
message: string;
maybe_user: DebugLogUser | null;
}
/** Creator user info joined onto a debug log row. */
export interface DebugLogUser {
user_token: string;
display_name: string;
username: string;
gravatar_hash: string;
}
export type AllDebugLog = DebugLog;
export interface SignupUser {
created_at: string;
display_name: string;