A lexicon-driven AppView for ATProto. happyview.dev
backfill firehose jetstream atproto appview oauth lexicon
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(dashboard): add UI for managing dead letters

Trezy 611f927f 3c264f54

+1445 -18
+2
migrations/postgres/20260421000000_dead_letters_resolved_at.sql
··· 1 + ALTER TABLE dead_letter_hooks ADD COLUMN resolved_at TIMESTAMPTZ; 2 + CREATE INDEX idx_dead_letter_hooks_resolved_at ON dead_letter_hooks (resolved_at);
+2
migrations/sqlite/20260421000000_dead_letters_resolved_at.sql
··· 1 + ALTER TABLE dead_letter_hooks ADD COLUMN resolved_at TEXT; 2 + CREATE INDEX idx_dead_letter_hooks_resolved_at ON dead_letter_hooks (resolved_at);
+13 -1
packages/docs/docs/guides/event-logs.md
··· 79 79 | `hook.executed` | info | Record AT URI | `lexicon_id` | 80 80 | `hook.dead_lettered` | error | Record AT URI | `lexicon_id`, `error` | 81 81 82 - Logged when [index hooks](index-hooks.md) run. Dead-lettered events indicate a hook failed all retry attempts. 82 + Logged when [index hooks](index-hooks.md) run. Dead-lettered events indicate a hook failed all retry attempts. You can manage dead letters from the **Data > Dead Letters** page in the dashboard — see [Dead Letters](#dead-letters) below. 83 83 84 84 ### Backfill events 85 85 ··· 127 127 Set `EVENT_LOG_RETENTION_DAYS=0` to disable automatic cleanup and keep logs indefinitely. 128 128 129 129 See [Configuration](../getting-started/configuration.md) for all environment variables. 130 + 131 + ## Dead Letters 132 + 133 + When an index hook fails after all retry attempts, the event is stored in the dead letters queue. You can manage dead letters from the **Data > Dead Letters** page in the dashboard. 134 + 135 + From the dead letters page you can: 136 + 137 + - **Retry Hook** — replay the stored record through the index hook (use after fixing a hook script) 138 + - **Re-index** — fetch the record fresh from the PDS and run it through the full indexing pipeline (use when the record may have changed) 139 + - **Dismiss** — mark the dead letter as resolved without retrying 140 + 141 + Bulk actions are available for selected rows or all entries matching the current filters. 130 142 131 143 ## Next steps 132 144
+600
src/admin/dead_letters.rs
··· 1 + use axum::{ 2 + Json, 3 + extract::{Path, Query, State}, 4 + }; 5 + use serde::{Deserialize, Serialize}; 6 + use serde_json::Value; 7 + 8 + use super::auth::UserAuth; 9 + use super::permissions::Permission; 10 + use crate::AppState; 11 + use crate::db::{adapt_sql, now_rfc3339, parse_dt}; 12 + use crate::error::AppError; 13 + use crate::lua::{HookEvent, run_hook_once}; 14 + use crate::record_handler::RecordEvent; 15 + 16 + // --------------------------------------------------------------------------- 17 + // Query / request / response types 18 + // --------------------------------------------------------------------------- 19 + 20 + #[derive(Deserialize)] 21 + pub struct ListQuery { 22 + pub collection: Option<String>, 23 + pub resolved: Option<String>, 24 + pub cursor: Option<String>, 25 + pub limit: Option<i64>, 26 + } 27 + 28 + #[derive(Deserialize)] 29 + pub struct CountQuery { 30 + pub resolved: Option<String>, 31 + } 32 + 33 + #[derive(Serialize)] 34 + pub struct DeadLetterSummary { 35 + pub id: String, 36 + pub lexicon_id: String, 37 + pub uri: String, 38 + pub did: String, 39 + pub collection: String, 40 + pub rkey: String, 41 + pub action: String, 42 + pub error: String, 43 + pub attempts: i64, 44 + pub created_at: chrono::DateTime<chrono::Utc>, 45 + #[serde(skip_serializing_if = "Option::is_none")] 46 + pub resolved_at: Option<chrono::DateTime<chrono::Utc>>, 47 + } 48 + 49 + #[derive(Serialize)] 50 + pub struct DeadLetterDetail { 51 + #[serde(flatten)] 52 + pub summary: DeadLetterSummary, 53 + pub record: Option<Value>, 54 + } 55 + 56 + #[derive(Serialize)] 57 + pub struct ListResponse { 58 + pub dead_letters: Vec<DeadLetterSummary>, 59 + #[serde(skip_serializing_if = "Option::is_none")] 60 + pub cursor: Option<String>, 61 + } 62 + 63 + #[derive(Serialize)] 64 + pub struct CountResponse { 65 + pub count: i64, 66 + } 67 + 68 + #[derive(Deserialize)] 69 + pub struct BulkRequest { 70 + pub ids: Option<Vec<String>>, 71 + pub all: Option<bool>, 72 + pub collection: Option<String>, 73 + } 74 + 75 + /// Internal row type for fetching action data needed by retry/reindex. 76 + #[allow(dead_code)] 77 + struct DeadLetterRow { 78 + id: String, 79 + lexicon_id: String, 80 + uri: String, 81 + did: String, 82 + collection: String, 83 + rkey: String, 84 + action: String, 85 + record: Option<String>, 86 + error: String, 87 + attempts: i64, 88 + } 89 + 90 + // --------------------------------------------------------------------------- 91 + // Handlers 92 + // --------------------------------------------------------------------------- 93 + 94 + /// GET /admin/dead-letters 95 + pub(super) async fn list( 96 + auth: UserAuth, 97 + State(state): State<AppState>, 98 + Query(query): Query<ListQuery>, 99 + ) -> Result<Json<ListResponse>, AppError> { 100 + auth.require(Permission::DeadLettersRead).await?; 101 + let backend = state.db_backend; 102 + let limit = query.limit.unwrap_or(50).clamp(1, 100); 103 + 104 + let mut sql = String::from( 105 + "SELECT id, lexicon_id, uri, did, collection, rkey, action, error, attempts, created_at, resolved_at 106 + FROM dead_letter_hooks WHERE 1=1", 107 + ); 108 + 109 + let resolved_filter = query.resolved.as_deref().unwrap_or("false"); 110 + match resolved_filter { 111 + "false" => sql.push_str(" AND resolved_at IS NULL"), 112 + "true" => sql.push_str(" AND resolved_at IS NOT NULL"), 113 + _ => {} // no filter 114 + } 115 + 116 + if query.collection.is_some() { 117 + sql.push_str(" AND collection = ?"); 118 + } 119 + if query.cursor.is_some() { 120 + sql.push_str(" AND created_at < ?"); 121 + } 122 + 123 + sql.push_str(" ORDER BY created_at DESC LIMIT ?"); 124 + 125 + let sql = adapt_sql(&sql, backend); 126 + 127 + #[allow(clippy::type_complexity)] 128 + let mut q = sqlx::query_as::< 129 + _, 130 + ( 131 + String, 132 + String, 133 + String, 134 + String, 135 + String, 136 + String, 137 + String, 138 + String, 139 + i64, 140 + String, 141 + Option<String>, 142 + ), 143 + >(&sql); 144 + 145 + if let Some(ref collection) = query.collection { 146 + q = q.bind(collection); 147 + } 148 + if let Some(ref cursor) = query.cursor { 149 + q = q.bind(cursor); 150 + } 151 + q = q.bind(limit); 152 + 153 + let rows = q 154 + .fetch_all(&state.db) 155 + .await 156 + .map_err(|e| AppError::Internal(format!("failed to query dead letters: {e}")))?; 157 + 158 + let dead_letters: Vec<DeadLetterSummary> = rows 159 + .into_iter() 160 + .map(|row| DeadLetterSummary { 161 + id: row.0, 162 + lexicon_id: row.1, 163 + uri: row.2, 164 + did: row.3, 165 + collection: row.4, 166 + rkey: row.5, 167 + action: row.6, 168 + error: row.7, 169 + attempts: row.8, 170 + created_at: parse_dt(&row.9), 171 + resolved_at: row.10.as_deref().map(parse_dt), 172 + }) 173 + .collect(); 174 + 175 + let cursor = if dead_letters.len() as i64 >= limit { 176 + dead_letters.last().map(|dl| dl.created_at.to_rfc3339()) 177 + } else { 178 + None 179 + }; 180 + 181 + Ok(Json(ListResponse { 182 + dead_letters, 183 + cursor, 184 + })) 185 + } 186 + 187 + /// GET /admin/dead-letters/count 188 + pub(super) async fn count( 189 + auth: UserAuth, 190 + State(state): State<AppState>, 191 + Query(query): Query<CountQuery>, 192 + ) -> Result<Json<CountResponse>, AppError> { 193 + auth.require(Permission::DeadLettersRead).await?; 194 + let backend = state.db_backend; 195 + 196 + let mut sql = String::from("SELECT COUNT(*) FROM dead_letter_hooks WHERE 1=1"); 197 + 198 + let resolved_filter = query.resolved.as_deref().unwrap_or("false"); 199 + match resolved_filter { 200 + "false" => sql.push_str(" AND resolved_at IS NULL"), 201 + "true" => sql.push_str(" AND resolved_at IS NOT NULL"), 202 + _ => {} 203 + } 204 + 205 + let sql = adapt_sql(&sql, backend); 206 + let (count,): (i64,) = sqlx::query_as(&sql) 207 + .fetch_one(&state.db) 208 + .await 209 + .map_err(|e| AppError::Internal(format!("failed to count dead letters: {e}")))?; 210 + 211 + Ok(Json(CountResponse { count })) 212 + } 213 + 214 + /// GET /admin/dead-letters/{id} 215 + pub(super) async fn detail( 216 + auth: UserAuth, 217 + State(state): State<AppState>, 218 + Path(id): Path<String>, 219 + ) -> Result<Json<DeadLetterDetail>, AppError> { 220 + auth.require(Permission::DeadLettersRead).await?; 221 + let backend = state.db_backend; 222 + 223 + let sql = adapt_sql( 224 + "SELECT id, lexicon_id, uri, did, collection, rkey, action, error, attempts, created_at, resolved_at, record 225 + FROM dead_letter_hooks WHERE id = ?", 226 + backend, 227 + ); 228 + 229 + #[allow(clippy::type_complexity)] 230 + let row: ( 231 + String, 232 + String, 233 + String, 234 + String, 235 + String, 236 + String, 237 + String, 238 + String, 239 + i64, 240 + String, 241 + Option<String>, 242 + Option<String>, 243 + ) = sqlx::query_as(&sql) 244 + .bind(&id) 245 + .fetch_optional(&state.db) 246 + .await 247 + .map_err(|e| AppError::Internal(format!("failed to fetch dead letter: {e}")))? 248 + .ok_or_else(|| AppError::NotFound(format!("dead letter {id} not found")))?; 249 + 250 + let summary = DeadLetterSummary { 251 + id: row.0, 252 + lexicon_id: row.1, 253 + uri: row.2, 254 + did: row.3, 255 + collection: row.4, 256 + rkey: row.5, 257 + action: row.6, 258 + error: row.7, 259 + attempts: row.8, 260 + created_at: parse_dt(&row.9), 261 + resolved_at: row.10.as_deref().map(parse_dt), 262 + }; 263 + 264 + let record = row.11.as_deref().and_then(|r| serde_json::from_str(r).ok()); 265 + 266 + Ok(Json(DeadLetterDetail { summary, record })) 267 + } 268 + 269 + /// POST /admin/dead-letters/{id}/dismiss 270 + pub(super) async fn dismiss( 271 + auth: UserAuth, 272 + State(state): State<AppState>, 273 + Path(id): Path<String>, 274 + ) -> Result<Json<Value>, AppError> { 275 + auth.require(Permission::DeadLettersManage).await?; 276 + let dl = fetch_dead_letter_for_action(&state, &id).await?; 277 + if dl.id.is_empty() { 278 + return Err(AppError::NotFound(format!("dead letter {id} not found"))); 279 + } 280 + mark_resolved(&state, &id).await?; 281 + Ok(Json(serde_json::json!({ "ok": true }))) 282 + } 283 + 284 + /// POST /admin/dead-letters/{id}/retry 285 + pub(super) async fn retry( 286 + auth: UserAuth, 287 + State(state): State<AppState>, 288 + Path(id): Path<String>, 289 + ) -> Result<Json<Value>, AppError> { 290 + auth.require(Permission::DeadLettersManage).await?; 291 + retry_single(&state, &id).await?; 292 + Ok(Json(serde_json::json!({ "ok": true }))) 293 + } 294 + 295 + /// POST /admin/dead-letters/{id}/reindex 296 + pub(super) async fn reindex( 297 + auth: UserAuth, 298 + State(state): State<AppState>, 299 + Path(id): Path<String>, 300 + ) -> Result<Json<Value>, AppError> { 301 + auth.require(Permission::DeadLettersManage).await?; 302 + reindex_single(&state, &id).await?; 303 + Ok(Json(serde_json::json!({ "ok": true }))) 304 + } 305 + 306 + /// POST /admin/dead-letters/bulk/dismiss 307 + pub(super) async fn bulk_dismiss( 308 + auth: UserAuth, 309 + State(state): State<AppState>, 310 + Json(body): Json<BulkRequest>, 311 + ) -> Result<Json<Value>, AppError> { 312 + auth.require(Permission::DeadLettersManage).await?; 313 + let backend = state.db_backend; 314 + let now = now_rfc3339(); 315 + 316 + if body.all == Some(true) { 317 + let mut sql = 318 + String::from("UPDATE dead_letter_hooks SET resolved_at = ? WHERE resolved_at IS NULL"); 319 + if body.collection.is_some() { 320 + sql.push_str(" AND collection = ?"); 321 + } 322 + let sql = adapt_sql(&sql, backend); 323 + let mut q = sqlx::query(&sql).bind(&now); 324 + if let Some(ref collection) = body.collection { 325 + q = q.bind(collection); 326 + } 327 + q.execute(&state.db) 328 + .await 329 + .map_err(|e| AppError::Internal(format!("bulk dismiss failed: {e}")))?; 330 + } else if let Some(ref ids) = body.ids { 331 + for id in ids { 332 + let sql = adapt_sql( 333 + "UPDATE dead_letter_hooks SET resolved_at = ? WHERE id = ? AND resolved_at IS NULL", 334 + backend, 335 + ); 336 + sqlx::query(&sql) 337 + .bind(&now) 338 + .bind(id) 339 + .execute(&state.db) 340 + .await 341 + .map_err(|e| AppError::Internal(format!("bulk dismiss failed for {id}: {e}")))?; 342 + } 343 + } else { 344 + return Err(AppError::BadRequest( 345 + "must provide 'ids' or 'all: true'".into(), 346 + )); 347 + } 348 + 349 + Ok(Json(serde_json::json!({ "ok": true }))) 350 + } 351 + 352 + /// POST /admin/dead-letters/bulk/retry 353 + pub(super) async fn bulk_retry( 354 + auth: UserAuth, 355 + State(state): State<AppState>, 356 + Json(body): Json<BulkRequest>, 357 + ) -> Result<Json<Value>, AppError> { 358 + auth.require(Permission::DeadLettersManage).await?; 359 + let ids = resolve_bulk_ids(&state, &body).await?; 360 + for id in &ids { 361 + retry_single(&state, id).await?; 362 + } 363 + Ok(Json(serde_json::json!({ "ok": true }))) 364 + } 365 + 366 + /// POST /admin/dead-letters/bulk/reindex 367 + pub(super) async fn bulk_reindex( 368 + auth: UserAuth, 369 + State(state): State<AppState>, 370 + Json(body): Json<BulkRequest>, 371 + ) -> Result<Json<Value>, AppError> { 372 + auth.require(Permission::DeadLettersManage).await?; 373 + let ids = resolve_bulk_ids(&state, &body).await?; 374 + for id in &ids { 375 + reindex_single(&state, id).await?; 376 + } 377 + Ok(Json(serde_json::json!({ "ok": true }))) 378 + } 379 + 380 + // --------------------------------------------------------------------------- 381 + // Helper functions 382 + // --------------------------------------------------------------------------- 383 + 384 + /// Fetch an unresolved dead letter by ID, returning an error if not found or already resolved. 385 + async fn fetch_dead_letter_for_action( 386 + state: &AppState, 387 + id: &str, 388 + ) -> Result<DeadLetterRow, AppError> { 389 + let backend = state.db_backend; 390 + let sql = adapt_sql( 391 + "SELECT id, lexicon_id, uri, did, collection, rkey, action, record, error, attempts 392 + FROM dead_letter_hooks WHERE id = ? AND resolved_at IS NULL", 393 + backend, 394 + ); 395 + 396 + let row: ( 397 + String, 398 + String, 399 + String, 400 + String, 401 + String, 402 + String, 403 + String, 404 + Option<String>, 405 + String, 406 + i64, 407 + ) = sqlx::query_as(&sql) 408 + .bind(id) 409 + .fetch_optional(&state.db) 410 + .await 411 + .map_err(|e| AppError::Internal(format!("failed to fetch dead letter: {e}")))? 412 + .ok_or_else(|| { 413 + AppError::NotFound(format!("dead letter {id} not found or already resolved")) 414 + })?; 415 + 416 + Ok(DeadLetterRow { 417 + id: row.0, 418 + lexicon_id: row.1, 419 + uri: row.2, 420 + did: row.3, 421 + collection: row.4, 422 + rkey: row.5, 423 + action: row.6, 424 + record: row.7, 425 + error: row.8, 426 + attempts: row.9, 427 + }) 428 + } 429 + 430 + /// Mark a dead letter as resolved. 431 + async fn mark_resolved(state: &AppState, id: &str) -> Result<(), AppError> { 432 + let backend = state.db_backend; 433 + let now = now_rfc3339(); 434 + let sql = adapt_sql( 435 + "UPDATE dead_letter_hooks SET resolved_at = ? WHERE id = ?", 436 + backend, 437 + ); 438 + sqlx::query(&sql) 439 + .bind(&now) 440 + .bind(id) 441 + .execute(&state.db) 442 + .await 443 + .map_err(|e| AppError::Internal(format!("failed to mark dead letter resolved: {e}")))?; 444 + Ok(()) 445 + } 446 + 447 + /// Update the error message and increment attempts. 448 + async fn update_error(state: &AppState, id: &str, error: &str) -> Result<(), AppError> { 449 + let backend = state.db_backend; 450 + let sql = adapt_sql( 451 + "UPDATE dead_letter_hooks SET error = ?, attempts = attempts + 1 WHERE id = ?", 452 + backend, 453 + ); 454 + sqlx::query(&sql) 455 + .bind(error) 456 + .bind(id) 457 + .execute(&state.db) 458 + .await 459 + .map_err(|e| AppError::Internal(format!("failed to update dead letter error: {e}")))?; 460 + Ok(()) 461 + } 462 + 463 + /// Resolve a BulkRequest into a list of dead letter IDs. 464 + async fn resolve_bulk_ids(state: &AppState, body: &BulkRequest) -> Result<Vec<String>, AppError> { 465 + if body.all == Some(true) { 466 + let backend = state.db_backend; 467 + let mut sql = String::from("SELECT id FROM dead_letter_hooks WHERE resolved_at IS NULL"); 468 + if body.collection.is_some() { 469 + sql.push_str(" AND collection = ?"); 470 + } 471 + let sql = adapt_sql(&sql, backend); 472 + let mut q = sqlx::query_as::<_, (String,)>(&sql); 473 + if let Some(ref collection) = body.collection { 474 + q = q.bind(collection); 475 + } 476 + let rows = q 477 + .fetch_all(&state.db) 478 + .await 479 + .map_err(|e| AppError::Internal(format!("failed to resolve bulk ids: {e}")))?; 480 + Ok(rows.into_iter().map(|r| r.0).collect()) 481 + } else if let Some(ref ids) = body.ids { 482 + Ok(ids.clone()) 483 + } else { 484 + Err(AppError::BadRequest( 485 + "must provide 'ids' or 'all: true'".into(), 486 + )) 487 + } 488 + } 489 + 490 + /// Fetch the index_hook script directly from the lexicons table, bypassing the in-memory registry. 491 + async fn get_index_hook_from_db(state: &AppState, lexicon_id: &str) -> Result<Option<String>, AppError> { 492 + let backend = state.db_backend; 493 + let sql = adapt_sql( 494 + "SELECT index_hook FROM lexicons WHERE id = ?", 495 + backend, 496 + ); 497 + let row: Option<(Option<String>,)> = sqlx::query_as(&sql) 498 + .bind(lexicon_id) 499 + .fetch_optional(&state.db) 500 + .await 501 + .map_err(|e| AppError::Internal(format!("failed to fetch index hook: {e}")))?; 502 + Ok(row.and_then(|r| r.0)) 503 + } 504 + 505 + /// Retry a single dead letter by re-running its hook script. 506 + async fn retry_single(state: &AppState, id: &str) -> Result<(), AppError> { 507 + let dl = fetch_dead_letter_for_action(state, id).await?; 508 + 509 + let script = get_index_hook_from_db(state, &dl.lexicon_id) 510 + .await? 511 + .ok_or_else(|| { 512 + AppError::NotFound(format!( 513 + "no index hook found for lexicon {}", 514 + dl.lexicon_id 515 + )) 516 + })?; 517 + 518 + let record: Option<Value> = dl 519 + .record 520 + .as_deref() 521 + .and_then(|r| serde_json::from_str(r).ok()); 522 + 523 + let event = HookEvent { 524 + state, 525 + lexicon_id: &dl.lexicon_id, 526 + script: &script, 527 + action: &dl.action, 528 + uri: &dl.uri, 529 + did: &dl.did, 530 + collection: &dl.collection, 531 + rkey: &dl.rkey, 532 + record: record.as_ref(), 533 + }; 534 + 535 + match run_hook_once(&event).await { 536 + Ok(_) => { 537 + mark_resolved(state, id).await?; 538 + Ok(()) 539 + } 540 + Err(e) => { 541 + update_error(state, id, &e).await?; 542 + Err(AppError::Internal(format!( 543 + "retry failed for dead letter {id}: {e}" 544 + ))) 545 + } 546 + } 547 + } 548 + 549 + /// Reindex a single dead letter by fetching the record fresh from the PDS. 550 + async fn reindex_single(state: &AppState, id: &str) -> Result<(), AppError> { 551 + let dl = fetch_dead_letter_for_action(state, id).await?; 552 + 553 + let pds_endpoint = 554 + crate::profile::resolve_pds_endpoint(&state.http, &state.config.plc_url, &dl.did).await?; 555 + 556 + let url = format!( 557 + "{}/xrpc/com.atproto.repo.getRecord?repo={}&collection={}&rkey={}", 558 + pds_endpoint, dl.did, dl.collection, dl.rkey 559 + ); 560 + 561 + let resp = state 562 + .http 563 + .get(&url) 564 + .send() 565 + .await 566 + .map_err(|e| AppError::Internal(format!("failed to fetch record from PDS: {e}")))?; 567 + 568 + if !resp.status().is_success() { 569 + let status = resp.status(); 570 + let body = resp.text().await.unwrap_or_else(|_| "unknown error".into()); 571 + return Err(AppError::Internal(format!( 572 + "PDS returned {status} fetching record: {body}" 573 + ))); 574 + } 575 + 576 + let body: Value = resp 577 + .json() 578 + .await 579 + .map_err(|e| AppError::Internal(format!("failed to parse PDS response: {e}")))?; 580 + 581 + let record = body.get("value").cloned(); 582 + let cid = body 583 + .get("cid") 584 + .and_then(|v| v.as_str()) 585 + .map(|s| s.to_string()); 586 + 587 + let event = RecordEvent { 588 + did: dl.did.clone(), 589 + collection: dl.collection.clone(), 590 + rkey: dl.rkey.clone(), 591 + action: dl.action.clone(), 592 + record, 593 + cid, 594 + }; 595 + 596 + crate::record_handler::handle_record_event(state, &event).await; 597 + mark_resolved(state, id).await?; 598 + 599 + Ok(()) 600 + }
+16
src/admin/mod.rs
··· 2 2 mod api_keys; 3 3 pub(crate) mod auth; 4 4 mod backfill; 5 + mod dead_letters; 5 6 mod domains; 6 7 mod events; 7 8 mod labelers; ··· 102 103 .route("/domains", post(domains::create).get(domains::list)) 103 104 .route("/domains/{id}", delete(domains::delete)) 104 105 .route("/domains/{id}/primary", post(domains::set_primary)) 106 + .route("/dead-letters", get(dead_letters::list)) 107 + .route("/dead-letters/count", get(dead_letters::count)) 108 + .route( 109 + "/dead-letters/bulk/dismiss", 110 + post(dead_letters::bulk_dismiss), 111 + ) 112 + .route("/dead-letters/bulk/retry", post(dead_letters::bulk_retry)) 113 + .route( 114 + "/dead-letters/bulk/reindex", 115 + post(dead_letters::bulk_reindex), 116 + ) 117 + .route("/dead-letters/{id}", get(dead_letters::detail)) 118 + .route("/dead-letters/{id}/dismiss", post(dead_letters::dismiss)) 119 + .route("/dead-letters/{id}/retry", post(dead_letters::retry)) 120 + .route("/dead-letters/{id}/reindex", post(dead_letters::reindex)) 105 121 }
+12 -1
src/admin/permissions.rs
··· 2 2 3 3 use serde::{Deserialize, Serialize}; 4 4 5 - /// All 27 permissions in the system. 5 + /// All 29 permissions in the system. 6 6 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] 7 7 pub enum Permission { 8 8 #[serde(rename = "lexicons:create")] ··· 78 78 ApiClientsEdit, 79 79 #[serde(rename = "api-clients:delete")] 80 80 ApiClientsDelete, 81 + 82 + #[serde(rename = "dead-letters:read")] 83 + DeadLettersRead, 84 + #[serde(rename = "dead-letters:manage")] 85 + DeadLettersManage, 81 86 } 82 87 83 88 impl Permission { ··· 115 120 Self::ApiClientsCreate => "api-clients:create", 116 121 Self::ApiClientsEdit => "api-clients:edit", 117 122 Self::ApiClientsDelete => "api-clients:delete", 123 + Self::DeadLettersRead => "dead-letters:read", 124 + Self::DeadLettersManage => "dead-letters:manage", 118 125 } 119 126 } 120 127 ··· 152 159 Self::ApiClientsCreate, 153 160 Self::ApiClientsEdit, 154 161 Self::ApiClientsDelete, 162 + Self::DeadLettersRead, 163 + Self::DeadLettersManage, 155 164 ]) 156 165 } 157 166 } ··· 178 187 Permission::BackfillRead, 179 188 Permission::StatsRead, 180 189 Permission::EventsRead, 190 + Permission::DeadLettersRead, 181 191 ]), 182 192 Self::Operator => { 183 193 let mut perms = Self::Viewer.permissions(); 184 194 perms.insert(Permission::BackfillCreate); 185 195 perms.insert(Permission::ApiKeysCreate); 186 196 perms.insert(Permission::ApiKeysDelete); 197 + perms.insert(Permission::DeadLettersManage); 187 198 perms 188 199 } 189 200 Self::Manager => {
+1 -1
src/lua/execute.rs
··· 969 969 /// Returns `Ok(None)` when `handle()` returns nil (meaning "skip indexing"), 970 970 /// `Ok(Some(value))` when it returns a table (use that as the record), or 971 971 /// `Ok(Some(original))` for other non-nil types. 972 - async fn run_hook_once(event: &HookEvent<'_>) -> Result<Option<Value>, String> { 972 + pub async fn run_hook_once(event: &HookEvent<'_>) -> Result<Option<Value>, String> { 973 973 let lua = sandbox::create_sandbox().map_err(|e| format!("failed to create Lua VM: {e}"))?; 974 974 let backend = event.state.db_backend; 975 975
+1 -1
src/lua/mod.rs
··· 9 9 mod xrpc_api; 10 10 11 11 pub(crate) use execute::{ 12 - HookEvent, execute_hook_script, execute_procedure_script, execute_query_script, 12 + HookEvent, execute_hook_script, execute_procedure_script, execute_query_script, run_hook_once, 13 13 }; 14 14 pub(crate) use sandbox::validate_script;
+616
web/src/app/dashboard/dead-letters/page.tsx
··· 1 + "use client"; 2 + 3 + import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 4 + import { 5 + type ColumnDef, 6 + type ColumnFiltersState, 7 + type RowSelectionState, 8 + type VisibilityState, 9 + getCoreRowModel, 10 + useReactTable, 11 + } from "@tanstack/react-table"; 12 + 13 + import { 14 + getDeadLetters, 15 + getDeadLetter, 16 + retryDeadLetter, 17 + reindexDeadLetter, 18 + dismissDeadLetter, 19 + bulkRetryDeadLetters, 20 + bulkReindexDeadLetters, 21 + bulkDismissDeadLetters, 22 + } from "@/lib/api"; 23 + import type { DeadLetterSummary, DeadLetterDetail } from "@/types/dead-letters"; 24 + import { DataTable } from "@/components/data-table/data-table"; 25 + import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"; 26 + import { DataTableToolbar } from "@/components/data-table/data-table-toolbar"; 27 + import { CodeBlock } from "@/components/code-block"; 28 + import { MonacoEditor } from "@/components/monaco-editor"; 29 + import { SiteHeader } from "@/components/site-header"; 30 + import { Badge } from "@/components/ui/badge"; 31 + import { Button } from "@/components/ui/button"; 32 + import { 33 + Sheet, 34 + SheetContent, 35 + SheetFooter, 36 + SheetHeader, 37 + SheetTitle, 38 + } from "@/components/ui/sheet"; 39 + import { 40 + DropdownMenu, 41 + DropdownMenuContent, 42 + DropdownMenuItem, 43 + DropdownMenuTrigger, 44 + } from "@/components/ui/dropdown-menu"; 45 + import { 46 + Select, 47 + SelectContent, 48 + SelectItem, 49 + SelectTrigger, 50 + SelectValue, 51 + } from "@/components/ui/select"; 52 + import { 53 + ChevronLeft, 54 + ChevronRight, 55 + RotateCcw, 56 + RefreshCw, 57 + XCircle, 58 + MoreHorizontal, 59 + } from "lucide-react"; 60 + import { Checkbox } from "@/components/ui/checkbox"; 61 + 62 + function actionBadge(action: string) { 63 + switch (action) { 64 + case "delete": 65 + return <Badge variant="destructive">delete</Badge>; 66 + case "update": 67 + return ( 68 + <Badge className="bg-amber-500/15 text-amber-700 dark:text-amber-400 hover:bg-amber-500/25 border-amber-500/20"> 69 + update 70 + </Badge> 71 + ); 72 + default: 73 + return <Badge variant="secondary">{action}</Badge>; 74 + } 75 + } 76 + 77 + function timeAgo(dateStr: string): string { 78 + const now = Date.now(); 79 + const then = new Date(dateStr).getTime(); 80 + const seconds = Math.floor((now - then) / 1000); 81 + if (seconds < 60) return `${seconds}s ago`; 82 + const minutes = Math.floor(seconds / 60); 83 + if (minutes < 60) return `${minutes}m ago`; 84 + const hours = Math.floor(minutes / 60); 85 + if (hours < 24) return `${hours}h ago`; 86 + const days = Math.floor(hours / 24); 87 + return `${days}d ago`; 88 + } 89 + 90 + function DetailBody({ detail }: { detail: DeadLetterDetail }) { 91 + return ( 92 + <div className="flex flex-col gap-4 flex-1 min-h-0"> 93 + <div className="grid grid-cols-2 gap-4 text-sm"> 94 + <div> 95 + <span className="text-muted-foreground">URI</span> 96 + <p className="font-mono text-xs break-all">{detail.uri}</p> 97 + </div> 98 + <div> 99 + <span className="text-muted-foreground">DID</span> 100 + <p className="font-mono text-xs break-all">{detail.did}</p> 101 + </div> 102 + <div> 103 + <span className="text-muted-foreground">Collection</span> 104 + <p className="font-mono text-xs">{detail.collection}</p> 105 + </div> 106 + <div> 107 + <span className="text-muted-foreground">Lexicon</span> 108 + <p className="font-mono text-xs">{detail.lexicon_id}</p> 109 + </div> 110 + <div> 111 + <span className="text-muted-foreground">Action</span> 112 + <p>{actionBadge(detail.action)}</p> 113 + </div> 114 + <div> 115 + <span className="text-muted-foreground">Attempts</span> 116 + <p className="text-xs tabular-nums">{detail.attempts}</p> 117 + </div> 118 + <div> 119 + <span className="text-muted-foreground">Created</span> 120 + <p className="text-xs"> 121 + {new Date(detail.created_at).toLocaleString()} 122 + </p> 123 + </div> 124 + {detail.resolved_at && ( 125 + <div> 126 + <span className="text-muted-foreground">Resolved</span> 127 + <p className="text-xs"> 128 + {new Date(detail.resolved_at).toLocaleString()} 129 + </p> 130 + </div> 131 + )} 132 + </div> 133 + 134 + <div> 135 + <span className="text-muted-foreground text-sm">Error</span> 136 + <div className="bg-destructive/10 text-destructive mt-1 rounded-md p-3 font-mono text-xs whitespace-pre-wrap"> 137 + {detail.error} 138 + </div> 139 + </div> 140 + 141 + {detail.record && ( 142 + <div className="flex flex-col flex-1 min-h-0"> 143 + <span className="text-muted-foreground text-sm">Record</span> 144 + <MonacoEditor 145 + value={JSON.stringify(detail.record, null, 2)} 146 + language="json" 147 + readOnly 148 + className="mt-1 flex-1 min-h-[200px] rounded-md overflow-hidden border" 149 + /> 150 + </div> 151 + )} 152 + </div> 153 + ); 154 + } 155 + 156 + export default function DeadLettersPage() { 157 + const [items, setItems] = useState<DeadLetterSummary[]>([]); 158 + const [error, setError] = useState<string | null>(null); 159 + const [loading, setLoading] = useState(false); 160 + const [viewDetail, setViewDetail] = useState<DeadLetterDetail | null>(null); 161 + const [actionLoading, setActionLoading] = useState(false); 162 + const [resolvedFilter, setResolvedFilter] = useState("false"); 163 + const [rowSelection, setRowSelection] = useState<RowSelectionState>({}); 164 + 165 + const [cursorStack, setCursorStack] = useState<string[]>([]); 166 + const [nextCursor, setNextCursor] = useState<string | null>(null); 167 + 168 + const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]); 169 + const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({}); 170 + const debounceRef = useRef<ReturnType<typeof setTimeout>>(null); 171 + const [debouncedFilters, setDebouncedFilters] = 172 + useState<ColumnFiltersState>(columnFilters); 173 + 174 + useEffect(() => { 175 + if (debounceRef.current) clearTimeout(debounceRef.current); 176 + debounceRef.current = setTimeout(() => { 177 + setDebouncedFilters(columnFilters); 178 + }, 300); 179 + }, [columnFilters]); 180 + 181 + const collectionFilter = useMemo( 182 + () => 183 + ( 184 + debouncedFilters.find((f) => f.id === "collection")?.value as 185 + | string[] 186 + | undefined 187 + )?.join(",") || undefined, 188 + [debouncedFilters], 189 + ); 190 + 191 + const fetchItems = useCallback( 192 + async (cursor?: string) => { 193 + setLoading(true); 194 + setError(null); 195 + try { 196 + const data = await getDeadLetters({ 197 + collection: collectionFilter, 198 + resolved: resolvedFilter, 199 + cursor, 200 + limit: 50, 201 + }); 202 + setItems(data.dead_letters); 203 + setNextCursor(data.cursor); 204 + setRowSelection({}); 205 + } catch (e: unknown) { 206 + setError(e instanceof Error ? e.message : String(e)); 207 + setItems([]); 208 + setNextCursor(null); 209 + } finally { 210 + setLoading(false); 211 + } 212 + }, 213 + [collectionFilter, resolvedFilter], 214 + ); 215 + 216 + useEffect(() => { 217 + setCursorStack([]); 218 + fetchItems(); 219 + }, [fetchItems]); 220 + 221 + function handleNext() { 222 + if (!nextCursor) return; 223 + setCursorStack((prev) => [...prev, nextCursor]); 224 + fetchItems(nextCursor); 225 + } 226 + 227 + function handlePrevious() { 228 + if (cursorStack.length === 0) return; 229 + const stack = [...cursorStack]; 230 + stack.pop(); 231 + const prevCursor = stack.length > 0 ? stack[stack.length - 1] : undefined; 232 + setCursorStack(stack); 233 + fetchItems(prevCursor); 234 + } 235 + 236 + async function openDetail(row: DeadLetterSummary) { 237 + try { 238 + const detail = await getDeadLetter(row.id); 239 + setViewDetail(detail); 240 + } catch { 241 + setError("Failed to load detail"); 242 + } 243 + } 244 + 245 + async function handleDetailAction(action: "retry" | "reindex" | "dismiss") { 246 + if (!viewDetail) return; 247 + setActionLoading(true); 248 + try { 249 + if (action === "retry") await retryDeadLetter(viewDetail.id); 250 + else if (action === "reindex") await reindexDeadLetter(viewDetail.id); 251 + else await dismissDeadLetter(viewDetail.id); 252 + setViewDetail(null); 253 + fetchItems(); 254 + } catch (e: unknown) { 255 + setError(e instanceof Error ? e.message : String(e)); 256 + } finally { 257 + setActionLoading(false); 258 + } 259 + } 260 + 261 + const selectedIds = useMemo( 262 + () => Object.keys(rowSelection).filter((k) => rowSelection[k]), 263 + [rowSelection], 264 + ); 265 + 266 + async function handleBulkAction( 267 + action: "retry" | "reindex" | "dismiss", 268 + scope: "selected" | "all", 269 + ) { 270 + setLoading(true); 271 + try { 272 + const body = 273 + scope === "all" 274 + ? { all: true, collection: collectionFilter } 275 + : { ids: selectedIds }; 276 + if (action === "retry") await bulkRetryDeadLetters(body); 277 + else if (action === "reindex") await bulkReindexDeadLetters(body); 278 + else await bulkDismissDeadLetters(body); 279 + setRowSelection({}); 280 + fetchItems(); 281 + } catch (e: unknown) { 282 + setError(e instanceof Error ? e.message : String(e)); 283 + } finally { 284 + setLoading(false); 285 + } 286 + } 287 + 288 + const columns = useMemo<ColumnDef<DeadLetterSummary>[]>( 289 + () => [ 290 + { 291 + id: "select", 292 + header: ({ table }) => ( 293 + <Checkbox 294 + checked={ 295 + table.getIsAllPageRowsSelected() || 296 + (table.getIsSomePageRowsSelected() && "indeterminate") 297 + } 298 + onCheckedChange={(value) => 299 + table.toggleAllPageRowsSelected(!!value) 300 + } 301 + aria-label="Select all" 302 + /> 303 + ), 304 + cell: ({ row }) => ( 305 + <div onClick={(e) => e.stopPropagation()}> 306 + <Checkbox 307 + checked={row.getIsSelected()} 308 + onCheckedChange={(value) => row.toggleSelected(!!value)} 309 + aria-label="Select row" 310 + /> 311 + </div> 312 + ), 313 + enableSorting: false, 314 + enableColumnFilter: false, 315 + size: 32, 316 + }, 317 + { 318 + id: "collection", 319 + accessorKey: "collection", 320 + header: ({ column }) => ( 321 + <DataTableColumnHeader column={column} label="Collection" /> 322 + ), 323 + cell: ({ row }) => ( 324 + <span 325 + className="font-mono text-xs block max-w-[200px] truncate" 326 + title={row.original.collection} 327 + > 328 + {row.original.collection} 329 + </span> 330 + ), 331 + enableColumnFilter: true, 332 + enableSorting: false, 333 + meta: { 334 + label: "Collection", 335 + placeholder: "Filter by collection...", 336 + variant: "text", 337 + }, 338 + }, 339 + { 340 + id: "uri", 341 + accessorKey: "uri", 342 + header: ({ column }) => ( 343 + <DataTableColumnHeader column={column} label="URI" /> 344 + ), 345 + cell: ({ row }) => ( 346 + <span 347 + className="font-mono text-xs block max-w-xs truncate" 348 + title={row.original.uri} 349 + > 350 + {row.original.uri} 351 + </span> 352 + ), 353 + enableSorting: false, 354 + }, 355 + { 356 + id: "action", 357 + accessorKey: "action", 358 + header: ({ column }) => ( 359 + <DataTableColumnHeader column={column} label="Action" /> 360 + ), 361 + cell: ({ row }) => actionBadge(row.original.action), 362 + enableSorting: false, 363 + }, 364 + { 365 + id: "error", 366 + accessorKey: "error", 367 + header: ({ column }) => ( 368 + <DataTableColumnHeader column={column} label="Error" /> 369 + ), 370 + cell: ({ row }) => ( 371 + <span 372 + className="text-destructive text-xs block max-w-xs truncate" 373 + title={row.original.error} 374 + > 375 + {row.original.error.split("\n")[0]} 376 + </span> 377 + ), 378 + enableSorting: false, 379 + }, 380 + { 381 + id: "attempts", 382 + accessorKey: "attempts", 383 + header: ({ column }) => ( 384 + <DataTableColumnHeader column={column} label="Attempts" /> 385 + ), 386 + cell: ({ row }) => ( 387 + <span className="text-sm tabular-nums">{row.original.attempts}</span> 388 + ), 389 + enableSorting: false, 390 + }, 391 + { 392 + id: "created_at", 393 + accessorKey: "created_at", 394 + header: ({ column }) => ( 395 + <DataTableColumnHeader column={column} label="Created" /> 396 + ), 397 + cell: ({ row }) => ( 398 + <span 399 + className="text-muted-foreground whitespace-nowrap text-sm tabular-nums" 400 + title={new Date(row.original.created_at).toLocaleString()} 401 + > 402 + {timeAgo(row.original.created_at)} 403 + </span> 404 + ), 405 + enableSorting: false, 406 + }, 407 + ...(resolvedFilter !== "false" 408 + ? [ 409 + { 410 + id: "status", 411 + accessorKey: "resolved_at" as const, 412 + header: ({ column }) => ( 413 + <DataTableColumnHeader column={column} label="Status" /> 414 + ), 415 + cell: ({ row }) => 416 + row.original.resolved_at ? ( 417 + <Badge variant="secondary">resolved</Badge> 418 + ) : ( 419 + <Badge variant="destructive">unresolved</Badge> 420 + ), 421 + enableSorting: false, 422 + } satisfies ColumnDef<DeadLetterSummary>, 423 + ] 424 + : []), 425 + ], 426 + [resolvedFilter], 427 + ); 428 + 429 + const table = useReactTable({ 430 + data: items, 431 + columns, 432 + state: { columnFilters, columnVisibility, rowSelection }, 433 + defaultColumn: { enableColumnFilter: false }, 434 + onColumnFiltersChange: setColumnFilters, 435 + onColumnVisibilityChange: setColumnVisibility, 436 + onRowSelectionChange: setRowSelection, 437 + getCoreRowModel: getCoreRowModel(), 438 + getRowId: (row) => row.id, 439 + }); 440 + 441 + return ( 442 + <> 443 + <SiteHeader title="Dead Letters" /> 444 + <div className="flex flex-1 flex-col gap-4 p-4 md:p-6"> 445 + {error && <p className="text-destructive text-sm">{error}</p>} 446 + 447 + <div className="flex items-center gap-2"> 448 + <DataTableToolbar table={table} /> 449 + <Select value={resolvedFilter} onValueChange={setResolvedFilter}> 450 + <SelectTrigger className="w-[160px] h-8"> 451 + <SelectValue /> 452 + </SelectTrigger> 453 + <SelectContent> 454 + <SelectItem value="false">Unresolved</SelectItem> 455 + <SelectItem value="true">Resolved</SelectItem> 456 + <SelectItem value="all">All</SelectItem> 457 + </SelectContent> 458 + </Select> 459 + </div> 460 + 461 + {selectedIds.length > 0 && ( 462 + <div className="flex items-center gap-2 rounded-md border p-2 text-sm"> 463 + <span className="text-muted-foreground"> 464 + {selectedIds.length} selected 465 + </span> 466 + <Button 467 + size="sm" 468 + variant="outline" 469 + disabled={loading} 470 + onClick={() => handleBulkAction("retry", "selected")} 471 + > 472 + <RotateCcw className="mr-1 size-3.5" /> 473 + Retry 474 + </Button> 475 + <Button 476 + size="sm" 477 + variant="outline" 478 + disabled={loading} 479 + onClick={() => handleBulkAction("reindex", "selected")} 480 + > 481 + <RefreshCw className="mr-1 size-3.5" /> 482 + Re-index 483 + </Button> 484 + <Button 485 + size="sm" 486 + variant="ghost" 487 + disabled={loading} 488 + onClick={() => handleBulkAction("dismiss", "selected")} 489 + > 490 + <XCircle className="mr-1 size-3.5" /> 491 + Dismiss 492 + </Button> 493 + <DropdownMenu> 494 + <DropdownMenuTrigger asChild> 495 + <Button size="sm" variant="ghost" disabled={loading}> 496 + <MoreHorizontal className="size-4" /> 497 + All matching 498 + </Button> 499 + </DropdownMenuTrigger> 500 + <DropdownMenuContent> 501 + <DropdownMenuItem 502 + onClick={() => handleBulkAction("retry", "all")} 503 + > 504 + Retry all matching 505 + </DropdownMenuItem> 506 + <DropdownMenuItem 507 + onClick={() => handleBulkAction("reindex", "all")} 508 + > 509 + Re-index all matching 510 + </DropdownMenuItem> 511 + <DropdownMenuItem 512 + onClick={() => handleBulkAction("dismiss", "all")} 513 + > 514 + Dismiss all matching 515 + </DropdownMenuItem> 516 + </DropdownMenuContent> 517 + </DropdownMenu> 518 + </div> 519 + )} 520 + 521 + <DataTable 522 + table={table} 523 + showPagination={false} 524 + onRowClick={openDetail} 525 + /> 526 + 527 + <div className="flex w-full items-center justify-between gap-4 overflow-auto p-1"> 528 + <p className="text-muted-foreground flex-1 whitespace-nowrap text-sm"> 529 + {items.length} item(s) on this page. 530 + </p> 531 + <div className="flex items-center space-x-2"> 532 + <Button 533 + aria-label="Go to previous page" 534 + title="Previous page" 535 + variant="outline" 536 + size="icon" 537 + className="size-8" 538 + disabled={cursorStack.length === 0 || loading} 539 + onClick={handlePrevious} 540 + > 541 + <ChevronLeft /> 542 + </Button> 543 + <Button 544 + aria-label="Go to next page" 545 + title="Next page" 546 + variant="outline" 547 + size="icon" 548 + className="size-8" 549 + disabled={!nextCursor || loading} 550 + onClick={handleNext} 551 + > 552 + <ChevronRight /> 553 + </Button> 554 + </div> 555 + </div> 556 + 557 + <Sheet 558 + open={viewDetail != null} 559 + onOpenChange={(open) => { 560 + if (!open) setViewDetail(null); 561 + }} 562 + > 563 + <SheetContent className="sm:max-w-xl overflow-hidden flex flex-col"> 564 + {viewDetail && ( 565 + <> 566 + <SheetHeader> 567 + <SheetTitle className="flex items-center gap-2"> 568 + {actionBadge(viewDetail.action)} 569 + <span className="font-mono text-sm"> 570 + {viewDetail.collection} 571 + </span> 572 + </SheetTitle> 573 + </SheetHeader> 574 + <div className="flex-1 min-h-0 flex flex-col px-4"> 575 + <DetailBody detail={viewDetail} /> 576 + </div> 577 + {viewDetail.resolved_at == null && ( 578 + <SheetFooter className="border-t flex-row"> 579 + <div className="mr-auto"> 580 + <Button 581 + size="sm" 582 + variant="destructive" 583 + disabled={actionLoading} 584 + onClick={() => handleDetailAction("dismiss")} 585 + > 586 + Dismiss 587 + </Button> 588 + </div> 589 + <Button 590 + size="sm" 591 + variant="outline" 592 + disabled={actionLoading} 593 + onClick={() => handleDetailAction("retry")} 594 + > 595 + <RotateCcw className="mr-1 size-3.5" /> 596 + Retry Hook 597 + </Button> 598 + <Button 599 + size="sm" 600 + variant="outline" 601 + disabled={actionLoading} 602 + onClick={() => handleDetailAction("reindex")} 603 + > 604 + <RefreshCw className="mr-1 size-3.5" /> 605 + Re-index 606 + </Button> 607 + </SheetFooter> 608 + )} 609 + </> 610 + )} 611 + </SheetContent> 612 + </Sheet> 613 + </div> 614 + </> 615 + ); 616 + }
+52 -14
web/src/components/app-sidebar.tsx
··· 1 1 "use client"; 2 2 3 + import { useEffect, useState } from "react"; 3 4 import { 4 5 IconDashboard, 5 6 IconFileDescription, ··· 17 18 IconInfoCircle, 18 19 IconApps, 19 20 IconArrowUpCircle, 21 + IconSkull, 20 22 } from "@tabler/icons-react"; 21 23 import Image from "next/image"; 22 24 import Link from "next/link"; ··· 52 54 { title: "Lexicons", url: "/dashboard/lexicons", icon: IconFileDescription }, 53 55 { title: "Records", url: "/dashboard/records", icon: IconTable }, 54 56 { title: "Backfill", url: "/dashboard/backfill", icon: IconDatabase }, 57 + { 58 + title: "Dead Letters", 59 + url: "/dashboard/dead-letters", 60 + icon: IconSkull, 61 + requiredPermissions: ["dead-letters:read"], 62 + }, 55 63 ]; 56 64 57 65 const accessItems: NavItem[] = [ ··· 123 131 const { hasPermission } = useCurrentUser(); 124 132 const { hasUpdates } = usePluginUpdates(); 125 133 134 + const [deadLetterCount, setDeadLetterCount] = useState(0); 135 + 136 + useEffect(() => { 137 + let cancelled = false; 138 + async function fetchCount() { 139 + try { 140 + const { getDeadLetterCount } = await import("@/lib/api"); 141 + const data = await getDeadLetterCount("false"); 142 + if (!cancelled) setDeadLetterCount(data.count); 143 + } catch { 144 + // sidebar badge is best-effort 145 + } 146 + } 147 + fetchCount(); 148 + const interval = setInterval(fetchCount, 30000); 149 + return () => { 150 + cancelled = true; 151 + clearInterval(interval); 152 + }; 153 + }, []); 154 + 126 155 function filterByPermission(items: NavItem[]) { 127 156 return items.filter( 128 157 (item) => ··· 200 229 <SidebarGroupLabel>Data</SidebarGroupLabel> 201 230 <SidebarGroupContent> 202 231 <SidebarMenu> 203 - {visibleData.map((item) => ( 204 - <SidebarMenuItem key={item.title}> 205 - <SidebarMenuButton 206 - asChild 207 - tooltip={item.title} 208 - isActive={isActive(item.url)} 209 - > 210 - <Link href={item.url}> 211 - <item.icon /> 212 - <span>{item.title}</span> 213 - </Link> 214 - </SidebarMenuButton> 215 - </SidebarMenuItem> 216 - ))} 232 + {visibleData.map((item) => { 233 + const showDeadLetterBadge = 234 + item.title === "Dead Letters" && deadLetterCount > 0; 235 + return ( 236 + <SidebarMenuItem key={item.title}> 237 + <SidebarMenuButton 238 + asChild 239 + tooltip={item.title} 240 + isActive={isActive(item.url)} 241 + > 242 + <Link href={item.url}> 243 + <item.icon /> 244 + <span>{item.title}</span> 245 + {showDeadLetterBadge && ( 246 + <span className="ml-auto rounded-full bg-destructive px-1.5 py-0.5 text-[10px] font-medium text-destructive-foreground"> 247 + {deadLetterCount} 248 + </span> 249 + )} 250 + </Link> 251 + </SidebarMenuButton> 252 + </SidebarMenuItem> 253 + ); 254 + })} 217 255 </SidebarMenu> 218 256 </SidebarGroupContent> 219 257 </SidebarGroup>
+100
web/src/lib/api.ts
··· 18 18 UnlinkResponse, 19 19 ConnectResponse, 20 20 } from "@/types/external-accounts" 21 + import type { 22 + DeadLettersListResponse, 23 + DeadLetterDetail, 24 + DeadLetterCountResponse, 25 + BulkActionResponse, 26 + } from "@/types/dead-letters" 21 27 22 28 export type { ApiKeySummary, CreateApiKeyResponse } from "@/types/api-keys" 23 29 export type { CollectionStat, StatsResponse } from "@/types/stats" ··· 43 49 ConfigSchema, 44 50 ConfigProperty, 45 51 } from "@/types/external-accounts" 52 + export type { 53 + DeadLetterSummary, 54 + DeadLetterDetail, 55 + DeadLettersListResponse, 56 + DeadLetterCountResponse, 57 + BulkActionResponse, 58 + } from "@/types/dead-letters" 46 59 47 60 export class ApiError extends Error { 48 61 status: number ··· 564 577 signal, 565 578 }) 566 579 } 580 + 581 + // --------------------------------------------------------------------------- 582 + // Dead Letters 583 + // --------------------------------------------------------------------------- 584 + 585 + export function getDeadLetters(params?: { 586 + collection?: string 587 + resolved?: string 588 + cursor?: string 589 + limit?: number 590 + }) { 591 + const searchParams = new URLSearchParams() 592 + if (params?.collection) searchParams.set("collection", params.collection) 593 + if (params?.resolved) searchParams.set("resolved", params.resolved) 594 + if (params?.cursor) searchParams.set("cursor", params.cursor) 595 + if (params?.limit) searchParams.set("limit", String(params.limit)) 596 + const qs = searchParams.toString() 597 + return apiFetch<DeadLettersListResponse>( 598 + `/admin/dead-letters${qs ? `?${qs}` : ""}`, 599 + ) 600 + } 601 + 602 + export function getDeadLetterCount(resolved?: string) { 603 + const searchParams = new URLSearchParams() 604 + if (resolved) searchParams.set("resolved", resolved) 605 + const qs = searchParams.toString() 606 + return apiFetch<DeadLetterCountResponse>( 607 + `/admin/dead-letters/count${qs ? `?${qs}` : ""}`, 608 + ) 609 + } 610 + 611 + export function getDeadLetter(id: string) { 612 + return apiFetch<DeadLetterDetail>( 613 + `/admin/dead-letters/${encodeURIComponent(id)}`, 614 + ) 615 + } 616 + 617 + export function retryDeadLetter(id: string) { 618 + return apiFetch(`/admin/dead-letters/${encodeURIComponent(id)}/retry`, { 619 + method: "POST", 620 + }) 621 + } 622 + 623 + export function reindexDeadLetter(id: string) { 624 + return apiFetch(`/admin/dead-letters/${encodeURIComponent(id)}/reindex`, { 625 + method: "POST", 626 + }) 627 + } 628 + 629 + export function dismissDeadLetter(id: string) { 630 + return apiFetch(`/admin/dead-letters/${encodeURIComponent(id)}/dismiss`, { 631 + method: "POST", 632 + }) 633 + } 634 + 635 + export function bulkDismissDeadLetters(body: { 636 + ids?: string[] 637 + all?: boolean 638 + collection?: string 639 + }) { 640 + return apiFetch<BulkActionResponse>("/admin/dead-letters/bulk/dismiss", { 641 + method: "POST", 642 + body: JSON.stringify(body), 643 + }) 644 + } 645 + 646 + export function bulkRetryDeadLetters(body: { 647 + ids?: string[] 648 + all?: boolean 649 + collection?: string 650 + }) { 651 + return apiFetch<BulkActionResponse>("/admin/dead-letters/bulk/retry", { 652 + method: "POST", 653 + body: JSON.stringify(body), 654 + }) 655 + } 656 + 657 + export function bulkReindexDeadLetters(body: { 658 + ids?: string[] 659 + all?: boolean 660 + collection?: string 661 + }) { 662 + return apiFetch<BulkActionResponse>("/admin/dead-letters/bulk/reindex", { 663 + method: "POST", 664 + body: JSON.stringify(body), 665 + }) 666 + }
+30
web/src/types/dead-letters.ts
··· 1 + export interface DeadLetterSummary { 2 + id: string 3 + lexicon_id: string 4 + uri: string 5 + did: string 6 + collection: string 7 + rkey: string 8 + action: string 9 + error: string 10 + attempts: number 11 + created_at: string 12 + resolved_at: string | null 13 + } 14 + 15 + export interface DeadLetterDetail extends DeadLetterSummary { 16 + record: Record<string, unknown> | null 17 + } 18 + 19 + export interface DeadLettersListResponse { 20 + dead_letters: DeadLetterSummary[] 21 + cursor: string | null 22 + } 23 + 24 + export interface DeadLetterCountResponse { 25 + count: number 26 + } 27 + 28 + export interface BulkActionResponse { 29 + ok: boolean 30 + }