Add WAM (Whack-a-Mole) ticket manager with 25 proactive ticket sources New internal tool: ratatui TUI + HTTP API for operational ticket management. SQLite-backed, tailnet-secured (no auth layer needed). WAM features (v0.1.0): - CLI: create, list, show, resolve, close (git-style prefix matching) - TUI: priority-colored list, detail view, inline status changes, search, filter by status/priority/source, create popup - HTTP API: POST/GET/PATCH /tickets (axum, for programmatic integration) MNW server integration (13 ticket sources): - User-facing: license key gen failed, Fan+ credit failed, file quarantined, subscription payment failed, Stripe Connect degraded, build failed - Infrastructure: health status change, DB pool pressure - Periodic: sales count drift, stale subscriptions, email bounce spike - Existing: refund escalation, webhook dead letter PoM integration (12 ticket sources): - All non-recovery alerts migrated from email to WAM tickets - Health, TLS, peer, route, DNS, WHOIS, CORS, latency, backup, monitoring - Recovery alerts remain email-only
- Co-Authored-By
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> - 2026-04-25 20:07 UTC
Commit:
5b85d927947d9438adc614e3ff817554d2798aebParent:
23 files changed,
+3681 insertions,
-15 deletions
client: reqwest::Client, pool: SqlitePool, instance_name: String, wam_url: Option<String>,}impl Alerter { .timeout(std::time::Duration::from_secs(10)) .build() .unwrap_or_default(); Self { config, client, pool, instance_name } let wam_url = config.wam_url.clone(); Self { config, client, pool, instance_name, wam_url } } #[instrument(skip_all)] } body.push_str("\n- PoM"); self.send_email(&subject, &body).await; let priority = match to_status { "error" | "unreachable" => "critical", "degraded" => "high", _ => "medium", }; self.wam_ticket(&subject, &body, priority, "pom-health", Some(target)).await; self.record_alert(&alert_key, AlertCategory::Health, Some(from_status), Some(to_status), error).await; } chrono::Utc::now().to_rfc3339(), ); self.send_email(&subject, &body).await; let priority = if days_remaining <= 3 { "critical" } else if days_remaining <= 7 { "high" } else { "medium" }; self.wam_ticket(&subject, &body, priority, "pom-tls", Some(&format!("{target}:{host}"))).await; self.record_alert(&alert_key, AlertCategory::TlsExpiry, None, None, None).await; } chrono::Utc::now().to_rfc3339(), ); self.send_email(&subject, &body).await; self.wam_ticket(&subject, &body, "high", "pom-tls", Some(&format!("{target}:{host}"))).await; self.record_alert(&alert_key, AlertCategory::TlsError, None, None, Some(error)).await; } chrono::Utc::now().to_rfc3339(), ); self.send_email(&subject, &body).await; self.wam_ticket(&subject, &body, "high", "pom-peer", Some(peer_name)).await; self.record_alert(&alert_key, AlertCategory::PeerMissing, None, None, None).await; } chrono::Utc::now().to_rfc3339(), ); self.send_email(&subject, &body).await; self.wam_ticket(&subject, &body, "high", "pom-routes", Some(target)).await; self.record_alert(&alert_key, AlertCategory::RouteFailure, None, None, None).await; } chrono::Utc::now().to_rfc3339(), ); self.send_email(&subject, &body).await; self.wam_ticket(&subject, &body, "high", "pom-dns", Some(target)).await; self.record_alert(&alert_key, AlertCategory::DnsMismatch, None, None, None).await; } chrono::Utc::now().to_rfc3339(), ); self.send_email(&subject, &body).await; let priority = if days_remaining <= 7 { "critical" } else if days_remaining <= 14 { "high" } else { "medium" }; self.wam_ticket(&subject, &body, priority, "pom-whois", Some(&format!("{target}:{domain}"))).await; self.record_alert(&alert_key, AlertCategory::WhoisExpiry, None, None, None).await; } chrono::Utc::now().to_rfc3339(), ); self.send_email(&subject, &body).await; self.wam_ticket(&subject, &body, "high", "pom-whois", Some(&format!("{target}:{domain}"))).await; self.record_alert(&alert_key, AlertCategory::WhoisError, None, None, Some(error)).await; } chrono::Utc::now().to_rfc3339(), ); self.send_email(&subject, &body).await; self.wam_ticket(&subject, &body, "high", "pom-cors", Some(target)).await; self.record_alert(&alert_key, AlertCategory::CorsFailure, None, None, None).await; } chrono::Utc::now().to_rfc3339(), ); self.send_email(&subject, &body).await; self.wam_ticket(&subject, &body, "medium", "pom-latency", Some(target)).await; self.record_alert(&alert_key, AlertCategory::LatencyDrift, None, None, Some(drift_message)).await; } chrono::Utc::now().to_rfc3339(), ); self.send_email(&subject, &body).await; self.wam_ticket(&subject, &body, "medium", "pom-test-duration", Some(target)).await; self.record_alert(&alert_key, AlertCategory::TestDurationDrift, None, None, Some(drift_message)).await; } chrono::Utc::now().to_rfc3339(), ); self.send_email(&subject, &body).await; let priority = if status == "missing" { "critical" } else { "high" }; self.wam_ticket(&subject, &body, priority, "pom-backup", Some(&format!("{target}:{database}"))).await; self.record_alert(&alert_key, AlertCategory::BackupStale, None, Some(status), None).await; } chrono::Utc::now().to_rfc3339(), ); self.send_email(&subject, &body).await; self.wam_ticket(&subject, &body, "critical", "pom-monitoring", Some("self")).await; self.record_alert(alert_key, AlertCategory::MonitoringOffline, None, None, None).await; } warn!("failed to record alert: {e}"); } } /// Create a WAM ticket (best-effort, fire-and-forget). async fn wam_ticket( &self, title: &str, body: &str, priority: &str, source: &str, source_ref: Option<&str>, ) { let Some(ref base_url) = self.wam_url else { return }; let url = format!("{base_url}/tickets"); let mut payload = serde_json::json!({ "title": title, "body": body, "priority": priority, "source": source, }); if let Some(r) = source_ref { payload["source_ref"] = serde_json::json!(r); } match self.client.post(&url).json(&payload).send().await { Ok(resp) if resp.status().is_success() => { info!("WAM ticket created: {title}"); } Ok(resp) => { warn!("WAM ticket creation returned {}: {title}", resp.status()); } Err(e) => { warn!("WAM unreachable: {e}"); } } }}#[cfg(test)] to: "test@example.com".to_string(), from: "PoM Alerts <pom-alerts@makenot.work>".to_string(), cooldown_secs: 300, wam_url: None, }; Alerter::new(config, pool, "test-instance".to_string()) } /// Minimum seconds between repeated alerts for the same target. #[serde(default = "default_cooldown_secs")] pub cooldown_secs: u64, /// WAM ticket manager URL (tailnet). When set, alerts also create WAM tickets. pub wam_url: Option<String>,}#[derive(Debug, Clone, Default, Deserialize)] to: "test@example.com".to_string(), from: "PoM Alerts <pom@test.com>".to_string(), cooldown_secs: 300, wam_url: None, }; let alerter = pom::alerts::Alerter::new(config, pool.clone(), "test".to_string()); postmark_inbound_webhook_token: None, internal_shared_secret: None, cli_service_token: None, wam_url: None, }; assert!(require_admin(&user, &config).is_ok()); } postmark_inbound_webhook_token: None, internal_shared_secret: None, cli_service_token: None, wam_url: None, }; assert!(require_admin(&user, &config).is_err()); } tracing::error!(build_id = %build.id, status = %status, error = ?e, "failed to update final build status"); } if status == BuildStatus::Failed { if let Some(ref wam) = state.wam { let title = format!("Build failed: {} v{}", build.tag, build.version); let body = format!( "Build {} for {} v{} failed.\n\nError: {}", build.id, build.tag, build.version, first_error.as_deref().unwrap_or("unknown"), ); wam.create_ticket(&title, Some(&body), "high", "build-failed", Some(&build.id.to_string())).await; } } tracing::info!( build_id = %build.id, version = %build.version, /// Bearer token for authenticating CLI SSH server → MNW internal API calls. /// When unset, internal API endpoints return 503. pub cli_service_token: Option<String>, /// Base URL of the WAM ticket manager (e.g., "http://100.x.x.x:7890"). /// When set, operational events create WAM tickets for human triage. pub wam_url: Option<String>,}/// S3-compatible storage configuration (Hetzner Object Storage) // CLI service token for SSH server → internal API authentication let cli_service_token = std::env::var("CLI_SERVICE_TOKEN").ok(); // WAM ticket manager URL (tailnet, e.g. "http://100.x.x.x:7890") let wam_url = std::env::var("WAM_URL").ok(); Ok(Config { host, port, postmark_inbound_webhook_token, internal_shared_secret, cli_service_token, wam_url, }) } postmark_inbound_webhook_token: None, internal_shared_secret: None, cli_service_token: None, wam_url: None, }; let addr = config.socket_addr(); assert_eq!(addr.port(), 8080);pub mod metrics;pub mod monitor;pub mod mt_client;pub mod wam_client;pub mod payments;pub mod pricing;pub mod scheduler; pub session_cache: Arc<DashMap<UserSessionId, Instant>>, /// HTTP client for the Multithreaded internal API (community/thread provisioning). pub mt_client: Option<mt_client::MtClient>, /// HTTP client for the WAM ticket manager (operational alerts). pub wam: Option<wam_client::WamClient>, /// Cache of verified custom domains → user IDs (populated on startup, updated on verify/delete). pub domain_cache: Arc<DashMap<String, db::UserId>>, /// Unix timestamp when the server will restart (0 = no restart pending). } }; // WAM ticket manager client (tailnet-only, for operational alerts) let wam = config.wam_url.as_ref().map(|url| { tracing::info!(url = %url, "WAM integration enabled"); makenotwork::wam_client::WamClient::new(url.clone()) }); // Warm custom domain cache let domain_cache = std::sync::Arc::new(dashmap::DashMap::new()); match makenotwork::db::custom_domains::get_all_verified_domains(&db).await { start_instant, session_cache: std::sync::Arc::new(dashmap::DashMap::new()), mt_client, wam, domain_cache, restart_at: std::sync::Arc::new(std::sync::atomic::AtomicI64::new(0)), sync_notify: std::sync::Arc::new(dashmap::DashMap::new()), } } // Create WAM ticket on degradation/error transitions if snap.status != MonitorStatus::Operational { if let Some(ref wam) = state.wam { let priority = match snap.status { MonitorStatus::Error => "critical", MonitorStatus::Degraded => "high", MonitorStatus::Operational => unreachable!(), }; let title = format!("Health status: {}", snap.status.as_str()); let body = format!( "db: {}\ns3: {}\nsessions: {}\ncheck_ms: {}", snap.db_ok, snap.s3_ok, snap.sessions_ok, snap.check_duration_ms, ); wam.create_ticket(&title, Some(&body), priority, "health-status-change", None).await; } } previous_status = Some(snap.status); } // DB pool pressure check (>80% active connections) { let pool_size = state.db.size(); let pool_idle = state.db.num_idle() as u32; let active = pool_size.saturating_sub(pool_idle); if pool_size > 0 && active * 100 / pool_size > 80 { tracing::warn!(pool_size, active, idle = pool_idle, "DB pool pressure >80%"); if let Some(ref wam) = state.wam { let title = format!("DB pool pressure: {active}/{pool_size} active"); wam.create_ticket(&title, None, "high", "db-pool-pressure", None).await; } } } // Persist snapshot (best-effort) if let Err(e) = db::monitor::insert_health_history( &state.db, // Retry failed webhook events retry_failed_webhooks(&state).await; // Weekly storage drift correction // Escalate stale pending refunds (unmatched for >24 hours) escalate_stale_refunds(&state).await; // Weekly storage drift correction + integrity checks if tick_count.is_multiple_of(DRIFT_CORRECTION_INTERVAL) { recalculate_all_storage_used(&state).await; check_sales_count_drift(&state).await; } // Daily checks (every 1440 ticks at 60s interval) if tick_count.is_multiple_of(1440) { check_stale_subscriptions(&state).await; check_email_bounce_spike(&state).await; } } })}// ============================================================================// Periodic integrity checks// ============================================================================/// Detect items where denormalized sales_count has drifted from actual transaction count.async fn check_sales_count_drift(state: &AppState) { let rows = match sqlx::query_as::<_, (db::ItemId, i32, i64)>( r#" SELECT i.id, i.sales_count, COUNT(t.id) FROM items i LEFT JOIN transactions t ON t.item_id = i.id AND t.status = 'completed' GROUP BY i.id HAVING i.sales_count != COUNT(t.id) LIMIT 50 "#, ) .fetch_all(&state.db) .await { Ok(r) if r.is_empty() => return, Ok(r) => r, Err(e) => { tracing::error!(error = ?e, "sales count drift check failed"); return; } }; tracing::warn!(count = rows.len(), "sales count drift detected"); if let Some(ref wam) = state.wam { let items: Vec<String> = rows .iter() .map(|(id, cached, actual)| format!(" {id}: cached={cached}, actual={actual}")) .collect(); let body = format!("Items with drifted sales_count:\n{}", items.join("\n")); wam.create_ticket( &format!("Sales count drift: {} items", rows.len()), Some(&body), "medium", "sales-count-drift", None, ) .await; }}/// Find subscriptions stuck in past_due for >7 days (possible missed webhook).async fn check_stale_subscriptions(state: &AppState) { let count: i64 = match sqlx::query_scalar( r#" SELECT COUNT(*) FROM ( SELECT 1 FROM creator_subscriptions WHERE status = 'past_due' AND updated_at < NOW() - INTERVAL '7 days' UNION ALL SELECT 1 FROM subscriptions WHERE status = 'past_due' AND updated_at < NOW() - INTERVAL '7 days' ) stale "#, ) .fetch_one(&state.db) .await { Ok(c) => c, Err(e) => { tracing::error!(error = ?e, "stale subscription check failed"); return; } }; if count > 0 { tracing::warn!(count, "stale past_due subscriptions detected"); if let Some(ref wam) = state.wam { wam.create_ticket( &format!("{count} subscriptions past_due >7 days"), Some("Subscriptions stuck in past_due for over 7 days. A Stripe webhook may have been missed. Check the Stripe dashboard."), "medium", "subscription-stale-past-due", None, ).await; } }}/// Detect email bounce/complaint spikes (>10 suppressions in 24h).async fn check_email_bounce_spike(state: &AppState) { let count: i64 = match sqlx::query_scalar( "SELECT COUNT(*) FROM email_suppressions WHERE created_at > NOW() - INTERVAL '24 hours'", ) .fetch_one(&state.db) .await { Ok(c) => c, Err(e) => { tracing::error!(error = ?e, "email bounce spike check failed"); return; } }; if count > 10 { tracing::warn!(count, "email bounce/complaint spike"); if let Some(ref wam) = state.wam { wam.create_ticket( &format!("Email bounce spike: {count} suppressions in 24h"), Some("Elevated bounce/complaint rate may indicate a deliverability problem. Check Postmark dashboard."), "high", "email-bounce-spike", None, ).await; } }}// ============================================================================// Webhook retry// ============================================================================ } } Err(e) => { let is_dead = attempt >= 5; tracing::warn!( event_id = %event.id, attempt = attempt, error = ?e, dead = is_dead, "webhook retry failed" ); if let Err(e) = db::webhook_events::schedule_retry( ).await { tracing::error!(error = ?e, "failed to schedule webhook retry"); } // Create WAM ticket when retries are exhausted (dead letter) if is_dead { if let Some(ref wam) = state.wam { let title = format!( "Dead webhook: {} ({})", event.event_type, event.id ); let body = format!( "Webhook event exhausted all {} retry attempts.\n\ Source: {}\nType: {}\nLast error: {:?}", attempt, event.source, event.event_type, e, ); wam.create_ticket( &title, Some(&body), "high", "webhook-dead-letter", Some(&event.id.to_string()), ) .await; } } } } }}// ============================================================================// Pending refund escalation// ============================================================================/// Alert the admin about pending refunds that have gone unmatched for >24 hours.////// These represent charge.refunded webhooks that arrived before their matching/// checkout.session.completed and never got resolved. Likely indicates a lost/// payment webhook that needs manual investigation.async fn escalate_stale_refunds(state: &AppState) { let stale = match db::pending_refunds::get_stale_refunds( &state.db, chrono::Duration::hours(24), ) .await { Ok(s) if s.is_empty() => return, Ok(s) => s, Err(e) => { tracing::error!(error = ?e, "failed to query stale pending refunds"); return; } }; let alert_email = std::env::var("ALERT_EMAIL").ok(); for refund in &stale { tracing::error!( payment_intent_id = %refund.payment_intent_id, amount = refund.amount, amount_refunded = refund.amount_refunded, created_at = %refund.created_at, "STALE PENDING REFUND: unmatched for >24h, needs manual investigation" ); if let Some(ref to) = alert_email { let subject = format!( "Unmatched refund: {} ({}c refunded)", refund.payment_intent_id, refund.amount_refunded ); let body = format!( "A charge.refunded webhook for payment intent {} has been pending for >24 hours \ with no matching completed transaction.\n\n\ Amount: {}c\nAmount refunded: {}c\nReceived: {}\n\n\ This likely means the checkout.session.completed webhook was lost. \ Check the Stripe dashboard and reconcile manually.", refund.payment_intent_id, refund.amount, refund.amount_refunded, refund.created_at, ); if let Err(e) = state.email.send_alert(to, &subject, &body).await { tracing::error!(error = ?e, "failed to send stale refund alert email"); } } // Create WAM ticket alongside the alert email if let Some(ref wam) = state.wam { let title = format!( "Unmatched refund: {} ({}c)", refund.payment_intent_id, refund.amount_refunded ); let body = format!( "charge.refunded webhook pending >24h with no matching completed transaction.\n\ Amount: {}c\nRefunded: {}c\nReceived: {}\n\ Check Stripe dashboard and reconcile manually.", refund.amount, refund.amount_refunded, refund.created_at, ); wam.create_ticket( &title, Some(&body), "critical", "refund-escalation", Some(&refund.payment_intent_id), ) .await; } if let Err(e) = db::pending_refunds::mark_escalated(&state.db, refund.id).await { tracing::error!(error = ?e, "failed to mark pending refund as escalated"); } }}// ============================================================================// MT thread provisioning helpers// ============================================================================