Skip to main content

mnemo_admin/
handlers.rs

1use std::sync::Arc;
2
3use axum::Json;
4use axum::extract::{Path, Query, State};
5use axum::http::StatusCode;
6use axum::response::{Html, IntoResponse, Response};
7use serde::{Deserialize, Serialize};
8use uuid::Uuid;
9
10use mnemo_core::error::Error as CoreError;
11use mnemo_core::query::MnemoEngine;
12use mnemo_core::storage::MemoryFilter;
13
14type AppState = Arc<MnemoEngine>;
15
16// ---------------------------------------------------------------------------
17// Error handling
18// ---------------------------------------------------------------------------
19
20pub struct AdminError(CoreError);
21
22impl IntoResponse for AdminError {
23    fn into_response(self) -> Response {
24        let (status, msg) = match &self.0 {
25            CoreError::Validation(m) => (StatusCode::BAD_REQUEST, m.clone()),
26            CoreError::PermissionDenied(m) => (StatusCode::FORBIDDEN, m.clone()),
27            CoreError::NotFound(m) => (StatusCode::NOT_FOUND, m.clone()),
28            other => (StatusCode::INTERNAL_SERVER_ERROR, other.to_string()),
29        };
30        (status, Json(serde_json::json!({"error": msg}))).into_response()
31    }
32}
33
34impl From<CoreError> for AdminError {
35    fn from(e: CoreError) -> Self {
36        AdminError(e)
37    }
38}
39
40// ---------------------------------------------------------------------------
41// Response types
42// ---------------------------------------------------------------------------
43
44#[derive(Debug, Serialize)]
45pub struct StatsResponse {
46    pub memory_count: usize,
47    pub event_count: usize,
48    pub agent_ids: Vec<String>,
49}
50
51#[derive(Debug, Serialize)]
52pub struct MemorySummary {
53    pub id: String,
54    pub agent_id: String,
55    pub content_preview: String,
56    pub memory_type: String,
57    pub scope: String,
58    pub importance: f32,
59    pub quarantined: bool,
60    pub quarantine_reason: Option<String>,
61    pub tags: Vec<String>,
62    pub created_at: String,
63    pub updated_at: String,
64}
65
66#[derive(Debug, Serialize)]
67pub struct EventSummary {
68    pub id: String,
69    pub agent_id: String,
70    pub event_type: String,
71    pub thread_id: Option<String>,
72    pub timestamp: String,
73    pub model: Option<String>,
74    pub tokens_input: Option<i64>,
75    pub tokens_output: Option<i64>,
76}
77
78#[derive(Debug, Serialize)]
79pub struct PaginatedMemories {
80    pub memories: Vec<MemorySummary>,
81    pub total: usize,
82    pub limit: usize,
83    pub offset: usize,
84}
85
86#[derive(Debug, Serialize)]
87pub struct PaginatedEvents {
88    pub events: Vec<EventSummary>,
89    pub total: usize,
90    pub limit: usize,
91    pub offset: usize,
92}
93
94#[derive(Debug, Serialize)]
95pub struct QuarantineResponse {
96    pub id: String,
97    pub quarantined: bool,
98    pub message: String,
99}
100
101// ---------------------------------------------------------------------------
102// Query parameters
103// ---------------------------------------------------------------------------
104
105#[derive(Debug, Deserialize)]
106pub struct MemoryQueryParams {
107    pub limit: Option<usize>,
108    pub offset: Option<usize>,
109    pub agent_id: Option<String>,
110}
111
112#[derive(Debug, Deserialize)]
113pub struct EventQueryParams {
114    pub limit: Option<usize>,
115    pub offset: Option<usize>,
116}
117
118// ---------------------------------------------------------------------------
119// Handlers
120// ---------------------------------------------------------------------------
121
122/// GET /admin/ -- serve the embedded HTML dashboard.
123pub async fn dashboard_handler() -> Html<&'static str> {
124    Html(include_str!("dashboard.html"))
125}
126
127/// GET /admin/api/stats -- aggregate statistics.
128pub async fn stats_handler(
129    State(engine): State<AppState>,
130) -> Result<Json<StatsResponse>, AdminError> {
131    // Fetch a large batch of memories to count and extract unique agent IDs.
132    // The storage backend does not expose a dedicated count or distinct-agents
133    // query, so we page through with a generous limit.
134    let filter = MemoryFilter::default();
135    let memories = engine.storage.list_memories(&filter, 10_000, 0).await?;
136
137    let memory_count = memories.len();
138    let mut agent_ids: Vec<String> = memories
139        .iter()
140        .map(|m| m.agent_id.clone())
141        .collect::<std::collections::BTreeSet<_>>()
142        .into_iter()
143        .collect();
144    agent_ids.sort();
145
146    // Sum up events across all known agents.
147    let mut event_count: usize = 0;
148    for aid in &agent_ids {
149        let events = engine.storage.list_events(aid, 10_000, 0).await?;
150        event_count += events.len();
151    }
152
153    Ok(Json(StatsResponse {
154        memory_count,
155        event_count,
156        agent_ids,
157    }))
158}
159
160/// GET /admin/api/agents -- list distinct agent IDs.
161pub async fn agents_handler(
162    State(engine): State<AppState>,
163) -> Result<Json<Vec<String>>, AdminError> {
164    let filter = MemoryFilter::default();
165    let memories = engine.storage.list_memories(&filter, 10_000, 0).await?;
166
167    let mut agent_ids: Vec<String> = memories
168        .iter()
169        .map(|m| m.agent_id.clone())
170        .collect::<std::collections::BTreeSet<_>>()
171        .into_iter()
172        .collect();
173    agent_ids.sort();
174
175    Ok(Json(agent_ids))
176}
177
178/// GET /admin/api/memories?limit=50&offset=0&agent_id=X -- paginated memory browser.
179pub async fn memories_handler(
180    State(engine): State<AppState>,
181    Query(params): Query<MemoryQueryParams>,
182) -> Result<Json<PaginatedMemories>, AdminError> {
183    let limit = params.limit.unwrap_or(50).min(500);
184    let offset = params.offset.unwrap_or(0);
185
186    let filter = MemoryFilter {
187        agent_id: params.agent_id,
188        ..Default::default()
189    };
190
191    // Fetch one extra so we can tell if there are more pages.
192    let memories = engine
193        .storage
194        .list_memories(&filter, limit + 1, offset)
195        .await?;
196
197    let has_more = memories.len() > limit;
198    let page: Vec<_> = memories.into_iter().take(limit).collect();
199
200    let summaries: Vec<MemorySummary> = page
201        .iter()
202        .map(|m| {
203            let preview = if m.content.len() > 100 {
204                let end = m
205                    .content
206                    .char_indices()
207                    .take_while(|(i, _)| *i <= 100)
208                    .last()
209                    .map(|(i, c)| i + c.len_utf8())
210                    .unwrap_or(m.content.len());
211                format!("{}...", &m.content[..end])
212            } else {
213                m.content.clone()
214            };
215            MemorySummary {
216                id: m.id.to_string(),
217                agent_id: m.agent_id.clone(),
218                content_preview: preview,
219                memory_type: m.memory_type.to_string(),
220                scope: m.scope.to_string(),
221                importance: m.importance,
222                quarantined: m.quarantined,
223                quarantine_reason: m.quarantine_reason.clone(),
224                tags: m.tags.clone(),
225                created_at: m.created_at.clone(),
226                updated_at: m.updated_at.clone(),
227            }
228        })
229        .collect();
230
231    // We cannot know the exact total without a COUNT query, so estimate.
232    let total = if has_more {
233        offset + limit + 1
234    } else {
235        offset + page.len()
236    };
237
238    Ok(Json(PaginatedMemories {
239        memories: summaries,
240        total,
241        limit,
242        offset,
243    }))
244}
245
246/// GET /admin/api/events?limit=50&offset=0 -- paginated event timeline.
247pub async fn events_handler(
248    State(engine): State<AppState>,
249    Query(params): Query<EventQueryParams>,
250) -> Result<Json<PaginatedEvents>, AdminError> {
251    let limit = params.limit.unwrap_or(50).min(500);
252    let offset = params.offset.unwrap_or(0);
253
254    // The storage backend requires an agent_id for list_events, so we first
255    // discover all agents, then collect events from each.
256    let filter = MemoryFilter::default();
257    let memories = engine.storage.list_memories(&filter, 10_000, 0).await?;
258
259    let agent_ids: Vec<String> = memories
260        .iter()
261        .map(|m| m.agent_id.clone())
262        .collect::<std::collections::BTreeSet<_>>()
263        .into_iter()
264        .collect();
265
266    let mut all_events = Vec::new();
267    for aid in &agent_ids {
268        let events = engine.storage.list_events(aid, 10_000, 0).await?;
269        all_events.extend(events);
270    }
271
272    // Sort by timestamp descending (newest first).
273    all_events.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
274
275    let total = all_events.len();
276    let page: Vec<_> = all_events.into_iter().skip(offset).take(limit).collect();
277
278    let summaries: Vec<EventSummary> = page
279        .iter()
280        .map(|e| EventSummary {
281            id: e.id.to_string(),
282            agent_id: e.agent_id.clone(),
283            event_type: e.event_type.to_string(),
284            thread_id: e.thread_id.clone(),
285            timestamp: e.timestamp.clone(),
286            model: e.model.clone(),
287            tokens_input: e.tokens_input,
288            tokens_output: e.tokens_output,
289        })
290        .collect();
291
292    Ok(Json(PaginatedEvents {
293        events: summaries,
294        total,
295        limit,
296        offset,
297    }))
298}
299
300/// POST /admin/api/quarantine/:id -- quarantine a memory.
301pub async fn quarantine_handler(
302    State(engine): State<AppState>,
303    Path(id): Path<Uuid>,
304) -> Result<Json<QuarantineResponse>, AdminError> {
305    let record = engine
306        .storage
307        .get_memory(id)
308        .await?
309        .ok_or_else(|| CoreError::NotFound(format!("memory {id} not found")))?;
310
311    let mut updated = record;
312    updated.quarantined = true;
313    updated.quarantine_reason = Some("Quarantined by admin".to_string());
314    engine.storage.update_memory(&updated).await?;
315
316    Ok(Json(QuarantineResponse {
317        id: id.to_string(),
318        quarantined: true,
319        message: "Memory quarantined successfully".to_string(),
320    }))
321}
322
323/// POST /admin/api/unquarantine/:id -- release a memory from quarantine.
324pub async fn unquarantine_handler(
325    State(engine): State<AppState>,
326    Path(id): Path<Uuid>,
327) -> Result<Json<QuarantineResponse>, AdminError> {
328    let record = engine
329        .storage
330        .get_memory(id)
331        .await?
332        .ok_or_else(|| CoreError::NotFound(format!("memory {id} not found")))?;
333
334    let mut updated = record;
335    updated.quarantined = false;
336    updated.quarantine_reason = None;
337    engine.storage.update_memory(&updated).await?;
338
339    Ok(Json(QuarantineResponse {
340        id: id.to_string(),
341        quarantined: false,
342        message: "Memory released from quarantine".to_string(),
343    }))
344}
345
346/// GET /admin/api/health -- simple health check.
347pub async fn health_handler() -> Json<serde_json::Value> {
348    Json(serde_json::json!({"status": "ok", "service": "mnemo-admin"}))
349}