feat: add admin log management routes

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-06-13 11:00:48 +08:00
co-authored by Codex
parent 23044393e4
commit f3b37211a4
9 changed files with 395 additions and 37 deletions
+41
View File
@@ -271,6 +271,47 @@ Example item:
}
```
### `GET /admin/logs/state`
Returns the current retained logging configuration plus the number of stored SQLite event logs.
Success response:
```json
{
"code": 200,
"message": "OK",
"data": {
"enabled": false,
"capacity": 2000,
"captureBody": false,
"storedCount": 42
},
"requestId": "req_01"
}
```
### `GET /admin/logs/{id}`
Returns a single stored event log by id using the same event shape as list items. Missing ids return HTTP `404` with body code `40401`.
### `DELETE /admin/logs`
Clears stored SQLite event logs.
Success response:
```json
{
"code": 200,
"message": "OK",
"data": {
"cleared": 42
},
"requestId": "req_01"
}
```
### `GET /admin/usage-stats`
Returns cursor-paginated account usage counters recorded after `/v1/*` calls. The endpoint never returns account access tokens, refresh tokens, Cookie values, or client API keys.
+1 -1
View File
@@ -11,7 +11,7 @@ Update this file before each feature commit.
| SQLite storage | Completed | `ffec8b5` + `b0c10f2` | `cargo test sqlite_schema_creates_accounts_and_event_tables`; `cargo test connect_sqlite_creates_parent_directory_for_file_database`; full `fmt/test/clippy` before commit | Creates SQLite WAL connector, parent directory bootstrap, sqlx-managed schema for accounts/API keys/sessions/cookies/fingerprints/events, and event/account indexes. |
| Admin auth and client API keys | Partial | `24d5799` + `4e0d1e5` + `e9fdea0` + `8af350d` + `960bbb2` | `cargo test client_api_key_has_proxy_prefix_and_verifies_against_hash`; `cargo test admin_password_hash_is_not_a_client_api_key`; `cargo test --test admin_login_route_test`; `cargo test --test admin_api_keys_route_test`; `cargo fmt --check`; `cargo test`; `cargo clippy --all-targets --all-features --locked -- -D warnings`; full `fmt/test/clippy` before `8af350d`; full `fmt/test/clippy` before `960bbb2`; current local key import/export targeted `cargo test --test admin_api_keys_route_test` | Admin passwords use Argon2id, client API keys use `cpr_` prefix plus HMAC-SHA256 with server-side pepper; `/admin/login` issues HttpOnly sessions and rejects client API keys as admin credentials. `/admin/api-keys` can create/list local client keys, status-enable/status-disable local client keys, label local client keys, delete local client keys, batch-delete local client keys, export local client key metadata, import metadata by rotating to new local `cpr_` keys, and `/v1/*` verifies only enabled keys against SQLite instead of accepting any `cpr_` string. These routes are Rust-local client auth utilities, not TypeScript third-party provider API-key parity. Provider API key pools and provider key import/export remain out of scope; local `cpr_` key plaintext and key hashes are not exportable after HMAC storage, and import returns newly generated plaintext values only once. |
| Secret encryption | Completed | `105aa58` + `228febd` | `cargo test secret_box_encrypts_and_decrypts_without_storing_plaintext`; `cargo test secret_box_load_or_create_should_reuse_existing_master_key_file`; full `fmt/test/clippy` before commit | AES-256-GCM `SecretBox` stores upstream secrets as `v1:<nonce>:<ciphertext>` without plaintext and can load/create the runtime master key file used by repositories. |
| Logging and pagination | Completed | `eb0341e` + `fc16c48` + `b0c10f2` + `ee0ed90` + `fdb9374` + `5bf012c` | `cargo test event_logs_are_cursor_paginated`; `cargo test admin_logs_are_cursor_paginated_and_include_request_id`; `cargo test rotating_writer_splits_files_when_size_limit_is_reached`; `cargo test --test admin_usage_stats_route_test`; `cargo test --test v1_upstream_route_test`; full `fmt/test/clippy` before commit | Adds cursor pagination, `Page<T>` camelCase serialization, admin `/admin/logs` query API, admin `/admin/usage-stats` and `/admin/usage-stats/summary`, event log repository, runtime tracing initialization, HTTP trace layer, date/size-based file rotation, v1 response lifecycle events with request/account/route/model/status/latency/non-secret metadata, and non-secret `account.refresh` events for upstream-401 refresh failures. |
| Logging and pagination | Completed | `eb0341e` + `fc16c48` + `b0c10f2` + `ee0ed90` + `fdb9374` + `5bf012c` + `pending` | `cargo test event_logs_are_cursor_paginated`; `cargo test admin_logs_are_cursor_paginated_and_include_request_id`; `cargo test --test admin_logs_route_test admin_logs_`; `cargo test rotating_writer_splits_files_when_size_limit_is_reached`; `cargo test --test admin_usage_stats_route_test`; `cargo test --test v1_upstream_route_test`; full `fmt/test/clippy` before commit | Adds cursor pagination, `Page<T>` camelCase serialization, admin `/admin/logs` query/detail/state/clear APIs, admin `/admin/usage-stats` and `/admin/usage-stats/summary`, event log repository, runtime tracing initialization, HTTP trace layer, date/size-based file rotation, v1 response lifecycle events with request/account/route/model/status/latency/non-secret metadata, and non-secret `account.refresh` events for upstream-401 refresh failures. |
| Diagnostics | Partial | `pending` | `cargo test --test diagnostics_route_test` | Adds local-only `GET /debug/diagnostics` with runtime package metadata, config/database/log/security paths, Codex backend transport settings, static Desktop fingerprint summary, account repository/pool state, runtime capacity summary, and retained settings summary without tokens or client API keys. Remote forwarded requests are rejected with `403`. Still missing active upstream test-connection checks and richer admin diagnostics surfaces. |
| TLS headers and fingerprint | Completed | `1bdd08c` | `cargo test codex_headers_include_desktop_identity_and_turn_state`; `cargo tree \| rg 'reqwest\|rustls'`; full `fmt/test/clippy` before commit | Adds Codex Desktop fingerprint model, exact identity/request headers, and pinned reqwest/rustls client builder. |
| Cookie persistence | Completed | `4710926` + `5d3cc6e` + `203270f` | `cargo test cookie_jar_captures_and_replays_account_scoped_cookies`; `cargo test cookie_repository_encrypts_and_replays_account_scoped_cookies`; full `fmt/test/clippy` before `203270f` | Adds account-scoped in-memory Cookie jar and encrypted SQLite Cookie repository; Cookies are replayed only for the same account/domain. Admin-pasted browser Cookie headers are stored encrypted and can be cleared per account. |
@@ -2977,7 +2977,7 @@ The Rust project has no legacy compatibility requirement, but the in-scope behav
- [x] **Client API key management:** implement admin CRUD for local client API keys: list/create/delete/batch-delete/label/status/export/import. Create/list/status/delete/batch-delete/label/export/import and `/v1/*` SQLite verification are complete. Remove provider/model binding semantics from the TypeScript version; Rust client API keys authorize only local `/v1/*` access.
- [x] **Settings:** implement admin settings for server/runtime fields that remain in scope: default model, reasoning effort, service tier, model aliases, refresh enabled/margin/concurrency, max concurrent per account, request interval, rotation strategy, tier priority, quota refresh/thresholds/skip exhausted, logs state/capacity/body capture, and usage history retention. GET and PATCH `/admin/settings` are admin-session gated, PATCH validates retained fields, writes a root `local.yaml` overlay, and rejects proxy URL, Ollama, third-party provider, OpenAI official key, proxy API key, and Electron/self-update settings. Existing constructed runtime services still use construction-time config until restart or a later runtime reconfiguration task.
- [ ] **Diagnostics:** expand health/admin diagnostics to include authenticated state, pool summary, capacity summary, transport/fingerprint status, paths, runtime metadata, and test-connection checks. Local-only `/debug/diagnostics` now returns runtime metadata, paths, account repository/pool state, capacity summary, transport config, and static fingerprint status without secrets. Remaining: active upstream test-connection checks and richer admin diagnostics surfaces. Keep production-local gating for sensitive debug endpoints.
- [ ] **Logs and usage stats:** add `/admin/logs/state`, clear/detail, error-log equivalents if retained, and richer bounded history queries if needed. Keep log file rotation and SQLite event pagination. Initial `/admin/usage-stats` pagination and `/admin/usage-stats/summary` are complete.
- [ ] **Logs and usage stats:** add `/admin/logs/state`, clear/detail, error-log equivalents if retained, and richer bounded history queries if needed. `/admin/logs/state`, `/admin/logs/{id}`, `DELETE /admin/logs`, log file rotation, SQLite event pagination, `/admin/usage-stats` pagination, and `/admin/usage-stats/summary` are complete. Remaining: decide whether to retain old error-log grouping/raw/count/seen/report APIs and richer bounded body-history queries.
- [x] **Model catalog:** replace the hard-coded `/v1/models` list with a model store that supports static defaults, configured aliases, `-low/-medium/-high/-xhigh/-fast/-flex` suffix parsing, backend model fetching per plan, cache persistence, `/v1/models/catalog`, `/v1/models/{id}`, `/v1/models/{id}/info`, `/admin/refresh-models`, and `/debug/models`. Static defaults, aliases, suffix parsing, cached backend snapshots, plan allowlists, admin refresh, read-only model routes, and debug summary are complete.
- [ ] **Account scheduling parity:** extend account acquisition beyond `active + least last_used`. Completed in the in-memory pool: max concurrent slots per account, stale slot cleanup, least-used/round-robin/sticky strategies, quota-cache skip, tier priority, model-plan filtering, exclude IDs, preferred account from session affinity, Cloudflare cooldown skip, request-staggering metadata, capacity summary, settings-backed construction, import-to-pool sync, startup pool snapshots from SQLite, HTTP fallback acquisition across alternate accounts, WebSocket fallback for non-history requests, previous-response account affinity, and runtime pool clearing on logout. Remaining: durable release-time slot state and durable quota-cache/cooldown restore.
- [ ] **Upstream Codex lifecycle:** implement account acquire -> Codex request -> SSE/collect-or-stream -> rate-limit capture -> usage release -> retry/fallback. HTTP SSE collection, streaming passthrough, 401-triggered refresh retry, Codex request context fields, SSE `error`/`response.failed` mapping, 429/402/403 account-state classification, successful WebSocket rate-limit capture, and fallback across alternate accounts for non-streaming Responses, HTTP SSE setup, non-history WebSocket setup, and Chat Completions are complete. Remaining: durable 429 quota cooldown persistence, path-block Cookie clearing, and richer OpenAI-compatible error mapping.
@@ -2995,7 +2995,7 @@ This pass re-read the TypeScript routes and core modules under `/home/zyy/Codes/
- [ ] **Admin account management:** Rust has paginated sanitized list, native/Sub2API import, native/Sub2API export, manual token add with upstream refresh-token exchange and JWT metadata/expires extraction, device-code token import, label/status/delete, batch delete/status, Cookie get/set/delete, per-account refresh/probe, reset usage, quota fetch/update, quota warnings, auth status, and logout. Missing persistence-health reporting and durable quota-window restore.
- [x] **Client API key management:** Rust creates/lists/status-toggles/deletes/batch-deletes/labels/exports/imports local `cpr_` keys and verifies them for `/v1/*`. Do not port the TypeScript provider/model binding or provider model-fetch APIs.
- [x] **Settings:** Rust exposes admin-session-gated GET/PATCH `/admin/settings` for retained fields. PATCH validates default model, default reasoning effort, service tier, aliases, refresh settings, max concurrent accounts, request interval, rotation strategy, tier priority, quota refresh/threshold/skip settings, log state/capacity/body capture, and usage retention, writes root `local.yaml`, and does not re-add proxy URL, provider, Ollama, proxy API key, or Electron/self-update settings. Existing constructed runtime services still use construction-time config until restart or a later runtime reconfiguration task.
- [ ] **Logs and error diagnostics:** Rust has cursor-paginated SQLite event logs and rotating files. Missing `/admin/logs/state`, pause/enable mutation, clear, detail-by-id, direction/search filters, bounded in-memory request/response body capture, and the original local error-log grouping/raw/count/seen/report/clear APIs if retained for troubleshooting.
- [ ] **Logs and error diagnostics:** Rust has cursor-paginated SQLite event logs, rotating files, `/admin/logs/state`, detail-by-id, and clear. Missing pause/enable mutation, direction/search filters, bounded in-memory request/response body capture, and the original local error-log grouping/raw/count/seen/report/clear APIs if retained for troubleshooting.
- [ ] **Usage and quota background jobs:** Rust records cumulative account usage from `/v1/*` and computes admin quota warnings from cached SQLite quota snapshots. Missing usage history snapshots (`raw`, `five_min`, `hourly`, `daily`), retention cleanup, passive quota-window recovery, active quota refresher for dirty/limit-reached accounts, and startup background scheduling.
- [ ] **Model catalog:** Rust has a static default catalog, aliases, suffix parsing, read routes, debug summary, backend Codex model fetch/probe, per-plan snapshots, model-plan index feeding account scheduling, `/admin/refresh-models`, and runtime cache persistence. Missing richer metadata fields and optional external model-cache loading if retained.
- [ ] **Account scheduling and retry:** Rust has in-memory slots, stale cleanup, strategies, tier priority, preferred/exclude IDs, SQLite startup restore, request-staggering metadata, 429/402/403 fallback across alternate accounts, Cloudflare cooldown skip, successful WebSocket rate-limit header application, non-history WebSocket fallback, previous-response history affinity, and status/token/logout mutation sync for the runtime pool. Missing strict request-interval sleep enforcement, durable quota/cache restore, Cloudflare path-block tracker, cooldown clearing, and richer capacity/health endpoints.
+6 -3
View File
@@ -186,7 +186,7 @@ impl AppState {
let account_pool = account_pool_from_config(&config);
let oauth_sessions = Arc::new(Mutex::new(PkceSessionStore::default()));
let api_keys = api_key_service(pool_ref, api_key_hasher.as_ref());
let logs = log_service(pool_ref);
let logs = log_service(&config, pool_ref);
let usage = usage_service(pool_ref);
let settings = SettingsService::new(
config.clone(),
@@ -371,8 +371,11 @@ fn api_key_service(pool: Option<&SqlitePool>, hasher: Option<&ApiKeyHasher>) ->
)
}
fn log_service(pool: Option<&SqlitePool>) -> LogService {
LogService::new(pool.cloned().map(EventLogRepository::new))
fn log_service(config: &AppConfig, pool: Option<&SqlitePool>) -> LogService {
LogService::new(
config.logging.clone(),
pool.cloned().map(EventLogRepository::new),
)
}
fn usage_service(pool: Option<&SqlitePool>) -> UsageService {
+116 -4
View File
@@ -1,17 +1,19 @@
use axum::{
extract::{Query, State},
extract::{Path, Query, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
Extension,
};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use crate::{
app::state::AppState, http::middleware::RequestId, service::log::LogServiceError,
app::state::AppState,
http::middleware::RequestId,
service::log::{ClearLogs, LogServiceError, LogState},
utils::pagination::clamp_limit,
};
use super::{require_admin_session, AdminError, AdminPageEnvelope, AdminResponse};
use super::{require_admin_session, AdminEnvelope, AdminError, AdminPageEnvelope, AdminResponse};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -20,6 +22,21 @@ pub struct LogsQuery {
pub limit: Option<u32>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LogStateData {
pub enabled: bool,
pub capacity: u32,
pub capture_body: bool,
pub stored_count: u64,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClearLogsData {
pub cleared: u64,
}
pub async fn logs(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
@@ -39,6 +56,83 @@ pub async fn logs(
}
}
pub async fn logs_state(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
match state.services.logs.state().await {
Ok(state) => Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(LogStateData::from(state), request_id),
)),
Err(error) => Err(log_service_error(error, request_id)),
}
}
pub async fn log_detail(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
Path(log_id): Path<String>,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
match state.services.logs.get(&log_id).await {
Ok(Some(log)) => Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(log, request_id),
)),
Ok(None) => Err(AdminError::new(
StatusCode::NOT_FOUND,
40401,
"Log event not found",
request_id,
)),
Err(error) => Err(log_service_error(error, request_id)),
}
}
pub async fn clear_logs(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
match state.services.logs.clear().await {
Ok(cleared) => Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(ClearLogsData::from(cleared), request_id),
)),
Err(error) => Err(log_service_error(error, request_id)),
}
}
impl From<LogState> for LogStateData {
fn from(state: LogState) -> Self {
Self {
enabled: state.enabled,
capacity: state.capacity,
capture_body: state.capture_body,
stored_count: state.stored_count,
}
}
}
impl From<ClearLogs> for ClearLogsData {
fn from(cleared: ClearLogs) -> Self {
Self {
cleared: cleared.cleared,
}
}
}
fn log_service_error(error: LogServiceError, request_id: String) -> AdminError {
match error {
LogServiceError::RepositoryUnavailable => AdminError::new(
@@ -53,5 +147,23 @@ fn log_service_error(error: LogServiceError, request_id: String) -> AdminError {
"Failed to list event logs",
request_id,
),
LogServiceError::Get => AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Failed to load event log",
request_id,
),
LogServiceError::Count => AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Failed to count event logs",
request_id,
),
LogServiceError::Clear => AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Failed to clear event logs",
request_id,
),
}
}
+4 -2
View File
@@ -21,7 +21,7 @@ use super::{
auth_callback, auth_code_relay, auth_device_login, auth_device_poll, auth_login_start,
auth_logout, auth_status, login,
},
logs::logs,
logs::{clear_logs, log_detail, logs, logs_state},
models::refresh_models,
settings::{settings, update_settings},
usage::{usage_stats, usage_stats_summary},
@@ -42,7 +42,9 @@ pub fn router() -> Router<AppState> {
"/admin/auth/device-poll/{device_code}",
get(auth_device_poll),
)
.route("/admin/logs", get(logs))
.route("/admin/logs", get(logs).delete(clear_logs))
.route("/admin/logs/state", get(logs_state))
.route("/admin/logs/{log_id}", get(log_detail))
.route("/admin/settings", get(settings).patch(update_settings))
.route("/admin/refresh-models", post(refresh_models))
.route("/admin/usage-stats", get(usage_stats))
+49 -23
View File
@@ -67,29 +67,7 @@ impl EventLogRepository {
rows.truncate(limit as usize);
}
let items = rows
.into_iter()
.map(|row| EventLog {
id: row.get("id"),
request_id: row.get("request_id"),
kind: row.get("kind"),
level: match row.get::<String, _>("level").as_str() {
"debug" => EventLevel::Debug,
"warn" => EventLevel::Warn,
"error" => EventLevel::Error,
_ => EventLevel::Info,
},
account_id: row.get("account_id"),
route: row.get("route"),
model: row.get("model"),
status_code: row.get("status_code"),
latency_ms: row.get("latency_ms"),
message: row.get("message"),
metadata: serde_json::from_str(&row.get::<String, _>("metadata_json"))
.unwrap_or_else(|_| serde_json::json!({})),
created_at: row.get("created_at"),
})
.collect::<Vec<_>>();
let items = rows.into_iter().map(row_to_event_log).collect::<Vec<_>>();
let next_cursor = if has_next {
items.last().map(|e| encode_cursor(&e.created_at, &e.id))
} else {
@@ -97,4 +75,52 @@ impl EventLogRepository {
};
Ok(Page { items, next_cursor })
}
pub async fn get(&self, id: &str) -> Result<Option<EventLog>, sqlx::Error> {
sqlx::query(
"select id, request_id, kind, level, account_id, route, model, status_code, latency_ms, message, metadata_json, created_at from event_logs where id = ?",
)
.bind(id)
.fetch_optional(&self.pool)
.await
.map(|row| row.map(row_to_event_log))
}
pub async fn count(&self) -> Result<u64, sqlx::Error> {
let row = sqlx::query("select count(*) as count from event_logs")
.fetch_one(&self.pool)
.await?;
let count = row.get::<i64, _>("count");
Ok(count.max(0) as u64)
}
pub async fn clear(&self) -> Result<u64, sqlx::Error> {
sqlx::query("delete from event_logs")
.execute(&self.pool)
.await
.map(|result| result.rows_affected())
}
}
fn row_to_event_log(row: sqlx::sqlite::SqliteRow) -> EventLog {
EventLog {
id: row.get("id"),
request_id: row.get("request_id"),
kind: row.get("kind"),
level: match row.get::<String, _>("level").as_str() {
"debug" => EventLevel::Debug,
"warn" => EventLevel::Warn,
"error" => EventLevel::Error,
_ => EventLevel::Info,
},
account_id: row.get("account_id"),
route: row.get("route"),
model: row.get("model"),
status_code: row.get("status_code"),
latency_ms: row.get("latency_ms"),
message: row.get("message"),
metadata: serde_json::from_str(&row.get::<String, _>("metadata_json"))
.unwrap_or_else(|_| serde_json::json!({})),
created_at: row.get("created_at"),
}
}
+49 -2
View File
@@ -1,10 +1,12 @@
use crate::{
config::LoggingConfig,
logs::{event::EventLog, repository::EventLogRepository},
utils::pagination::Page,
};
#[derive(Clone)]
pub struct LogService {
config: LoggingConfig,
repository: Option<EventLogRepository>,
}
@@ -12,11 +14,27 @@ pub struct LogService {
pub enum LogServiceError {
RepositoryUnavailable,
List,
Get,
Count,
Clear,
}
#[derive(Debug)]
pub struct LogState {
pub enabled: bool,
pub capacity: u32,
pub capture_body: bool,
pub stored_count: u64,
}
#[derive(Debug)]
pub struct ClearLogs {
pub cleared: u64,
}
impl LogService {
pub fn new(repository: Option<EventLogRepository>) -> Self {
Self { repository }
pub fn new(config: LoggingConfig, repository: Option<EventLogRepository>) -> Self {
Self { config, repository }
}
pub async fn list(
@@ -30,6 +48,35 @@ impl LogService {
.map_err(|_| LogServiceError::List)
}
pub async fn state(&self) -> Result<LogState, LogServiceError> {
let stored_count = self
.repository()?
.count()
.await
.map_err(|_| LogServiceError::Count)?;
Ok(LogState {
enabled: self.config.enabled,
capacity: self.config.capacity,
capture_body: self.config.capture_body,
stored_count,
})
}
pub async fn get(&self, id: &str) -> Result<Option<EventLog>, LogServiceError> {
self.repository()?
.get(id)
.await
.map_err(|_| LogServiceError::Get)
}
pub async fn clear(&self) -> Result<ClearLogs, LogServiceError> {
self.repository()?
.clear()
.await
.map(|cleared| ClearLogs { cleared })
.map_err(|_| LogServiceError::Clear)
}
fn repository(&self) -> Result<&EventLogRepository, LogServiceError> {
self.repository
.as_ref()
+127
View File
@@ -145,3 +145,130 @@ async fn admin_logs_reject_missing_admin_session_cookie() {
assert_eq!(body["code"], 40101);
assert_eq!(body["requestId"], "req_admin");
}
#[tokio::test]
async fn admin_logs_state_should_return_runtime_logging_state_and_stored_count() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("admin-logs-state.sqlite");
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
seed_admin_session(&pool, "session_1").await;
let repo = EventLogRepository::new(pool.clone());
repo.insert(EventLog::new("request", EventLevel::Info, "first"))
.await
.unwrap();
repo.insert(EventLog::new("request", EventLevel::Warn, "second"))
.await
.unwrap();
let app = build_router(AppState::with_pool(test_config(url), pool));
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/admin/logs/state")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_logs_state")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["code"], 200);
assert_eq!(body["requestId"], "req_logs_state");
assert_eq!(body["data"]["enabled"], false);
assert_eq!(body["data"]["captureBody"], false);
assert_eq!(body["data"]["capacity"], 2000);
assert_eq!(body["data"]["storedCount"], 2);
}
#[tokio::test]
async fn admin_logs_detail_should_return_one_event_by_id() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("admin-logs-detail.sqlite");
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
seed_admin_session(&pool, "session_1").await;
let repo = EventLogRepository::new(pool.clone());
let mut event = EventLog::new("request", EventLevel::Error, "detail");
event.id = "log_detail".to_string();
event.request_id = Some("req_upstream".to_string());
repo.insert(event).await.unwrap();
let app = build_router(AppState::with_pool(test_config(url), pool));
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/admin/logs/log_detail")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_logs_detail")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["code"], 200);
assert_eq!(body["requestId"], "req_logs_detail");
assert_eq!(body["data"]["id"], "log_detail");
assert_eq!(body["data"]["requestId"], "req_upstream");
assert_eq!(body["data"]["level"], "error");
assert_eq!(body["data"]["message"], "detail");
}
#[tokio::test]
async fn admin_logs_clear_should_delete_stored_events() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("admin-logs-clear.sqlite");
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
seed_admin_session(&pool, "session_1").await;
let repo = EventLogRepository::new(pool.clone());
repo.insert(EventLog::new("request", EventLevel::Info, "first"))
.await
.unwrap();
repo.insert(EventLog::new("request", EventLevel::Info, "second"))
.await
.unwrap();
let app = build_router(AppState::with_pool(test_config(url), pool));
let response = app
.clone()
.oneshot(
Request::builder()
.method("DELETE")
.uri("/admin/logs")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_logs_clear")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["code"], 200);
assert_eq!(body["requestId"], "req_logs_clear");
assert_eq!(body["data"]["cleared"], 2);
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/admin/logs?limit=50")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = response_json(response).await;
assert_eq!(body["data"].as_array().unwrap().len(), 0);
}