mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-22 01:29:31 -04:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c65a66655a | ||
|
|
23cbb03b39 | ||
|
|
d3cb23753a | ||
|
|
47c0cd23f8 | ||
|
|
f398b3af99 | ||
|
|
8bd0e9219b | ||
|
|
298aff708e | ||
|
|
197ec95012 |
+124
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
-80
@@ -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"
|
||||
}
|
||||
-12
@@ -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"
|
||||
}
|
||||
+124
@@ -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"
|
||||
}
|
||||
+6
@@ -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;
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
ALTER TABLE debug_logs
|
||||
DROP INDEX index_maybe_log_level;
|
||||
+6
@@ -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)
|
||||
|
||||
|
||||
+32
@@ -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;
|
||||
+72
@@ -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>,
|
||||
}
|
||||
+30
@@ -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>,
|
||||
}
|
||||
+37
@@ -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;
|
||||
|
||||
+5
-1
@@ -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")?,
|
||||
})
|
||||
}
|
||||
+30
-9
@@ -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();
|
||||
|
||||
|
||||
+134
@@ -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,
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+89
@@ -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,
|
||||
}))
|
||||
}
|
||||
+30
-50
@@ -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,
|
||||
}))
|
||||
|
||||
+86
@@ -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,
|
||||
}))
|
||||
}
|
||||
+2
@@ -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(),
|
||||
|
||||
+3
@@ -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(),
|
||||
|
||||
+2
@@ -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(),
|
||||
|
||||
+3
@@ -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(),
|
||||
|
||||
+1
@@ -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(),
|
||||
|
||||
+1
@@ -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(),
|
||||
|
||||
+1
@@ -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(),
|
||||
|
||||
+10
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user