feat: Add Brain Monitor dashboard and live API stats
This commit is contained in:
1 parent
3239e32866
commit
18fd05bb11
10 files changed
+672
-80
No files matched your search
@@ -0,0 +1,73 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Brain Monitor</title>
|
||||
<style>
|
||||
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; padding: 40px; background-color: #f0f2f5; color: #333; }
|
||||
h1 { color: #2c3e50; text-align: center; margin-bottom: 30px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; max-width: 800px; margin: 0 auto; }
|
||||
.stat-card { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.05); text-align: center; transition: transform 0.2s ease; }
|
||||
.stat-card:hover { transform: translateY(-5px); }
|
||||
.stat-value { font-size: 2.5em; font-weight: bold; color: #3498db; margin: 10px 0; }
|
||||
.stat-label { font-size: 1.1em; color: #7f8c8d; text-transform: uppercase; letter-spacing: 1px; }
|
||||
.status-dot { display: inline-block; width: 10px; height: 10px; background-color: #2ecc71; border-radius: 50%; margin-right: 8px; box-shadow: 0 0 5px #2ecc71; }
|
||||
.header-bar { max-width: 800px; margin: 0 auto 20px; display: flex; justify-content: space-between; align-items: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header-bar">
|
||||
<h1>🧠 Antigravity Brain Monitor</h1>
|
||||
<div><span class="status-dot"></span>System Online</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Entities</div>
|
||||
<div class="stat-value" id="val-entities">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Relations</div>
|
||||
<div class="stat-value" id="val-relations">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Active Tasks</div>
|
||||
<div class="stat-value" id="val-tasks">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Snippets</div>
|
||||
<div class="stat-value" id="val-snippets">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Tech Debt</div>
|
||||
<div class="stat-value" id="val-tech_debts">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">ADRs</div>
|
||||
<div class="stat-value" id="val-adrs">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const response = await fetch('/api/stats');
|
||||
const data = await response.json();
|
||||
|
||||
document.getElementById('val-entities').innerText = data.entities;
|
||||
document.getElementById('val-relations').innerText = data.relations;
|
||||
document.getElementById('val-tasks').innerText = data.tasks;
|
||||
document.getElementById('val-snippets').innerText = data.snippets;
|
||||
document.getElementById('val-tech_debts').innerText = data.tech_debts;
|
||||
document.getElementById('val-adrs').innerText = data.adrs;
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch stats:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Initial fetch
|
||||
fetchStats();
|
||||
// Poll every 2 seconds
|
||||
setInterval(fetchStats, 2000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+63
-71
@@ -120,6 +120,9 @@ impl ServerHandler for MemoryHandler {
|
||||
} {
|
||||
let entity: Entity = e;
|
||||
if !entity.name.is_empty() {
|
||||
if let Ok(idx) = self.state.search_index.read() {
|
||||
let _ = idx.index_entity(&entity);
|
||||
}
|
||||
g.entities.insert(entity.name.clone(), entity);
|
||||
}
|
||||
}
|
||||
@@ -267,29 +270,19 @@ impl ServerHandler for MemoryHandler {
|
||||
}
|
||||
"search_nodes" => {
|
||||
let req: SearchNodesTool = parse_args(args)?;
|
||||
let query = req.query.to_lowercase();
|
||||
let tokens: Vec<&str> = query.split_whitespace().collect();
|
||||
let matches = if let Ok(idx) = self.state.search_index.read() {
|
||||
idx.search(&req.query, req.namespace.as_deref()).unwrap_or_default()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let mut result = KnowledgeGraph::default();
|
||||
for (name, e) in self.state.get_full_graph().entities {
|
||||
if let Some(ns) = &req.namespace
|
||||
&& e.namespace != *ns
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let name_low = name.to_lowercase();
|
||||
let type_low = e.entity_type.to_lowercase();
|
||||
let full_txt = format!(
|
||||
"{} {} {}",
|
||||
name_low,
|
||||
type_low,
|
||||
e.observations.join(" ").to_lowercase()
|
||||
);
|
||||
let matched = full_txt.contains(&query)
|
||||
|| (!tokens.is_empty() && tokens.iter().all(|&t| full_txt.contains(t)))
|
||||
|| strsim::jaro_winkler(&name_low, &query) > 0.85
|
||||
|| strsim::jaro_winkler(&type_low, &query) > 0.85;
|
||||
if matched {
|
||||
result.entities.insert(name, e);
|
||||
let full = self.state.get_full_graph();
|
||||
for (id, doc_type) in matches {
|
||||
if doc_type == "entity" {
|
||||
if let Some(e) = full.entities.get(&id) {
|
||||
result.entities.insert(id, e.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let data = serde_json::to_string(&result).unwrap_or_default();
|
||||
@@ -456,16 +449,20 @@ impl ServerHandler for MemoryHandler {
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let task = Task {
|
||||
id: id.clone(),
|
||||
title: req.title,
|
||||
status: "pending".to_string(),
|
||||
description: req.description,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
git_branch: req.git_branch,
|
||||
};
|
||||
if let Ok(idx) = self.state.search_index.read() {
|
||||
let _ = idx.index_task(&task);
|
||||
}
|
||||
self.state.tasks.modify(|tasks| {
|
||||
tasks.push(Task {
|
||||
id: id.clone(),
|
||||
title: req.title,
|
||||
status: "pending".to_string(),
|
||||
description: req.description,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
git_branch: req.git_branch,
|
||||
});
|
||||
tasks.push(task);
|
||||
});
|
||||
Ok(ServerResult::from(CallToolResult::text_content(vec![
|
||||
format!("Task added with ID: {}", id).into(),
|
||||
@@ -1098,47 +1095,42 @@ impl ServerHandler for MemoryHandler {
|
||||
}
|
||||
"omni_search" => {
|
||||
let req: OmniSearchTool = parse_args(args)?;
|
||||
let matches = if let Ok(idx) = self.state.search_index.read() {
|
||||
idx.search(&req.query, req.namespace.as_deref()).unwrap_or_default()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let mut kg = KnowledgeGraph::default();
|
||||
let mut tasks = Vec::new();
|
||||
let mut snippets = Vec::new();
|
||||
let mut adrs = Vec::new();
|
||||
|
||||
let full = self.state.get_full_graph();
|
||||
for (id, doc_type) in &matches {
|
||||
if doc_type == "entity" {
|
||||
if let Some(e) = full.entities.get(id) {
|
||||
kg.entities.insert(id.clone(), e.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
for t in self.state.tasks.read() {
|
||||
if matches.iter().any(|(id, typ)| id == &t.id && typ == "task") {
|
||||
tasks.push(t);
|
||||
}
|
||||
}
|
||||
for s in self.state.snippets.read() {
|
||||
if matches.iter().any(|(id, typ)| id == &s.name && typ == "snippet") {
|
||||
snippets.push(s);
|
||||
}
|
||||
}
|
||||
for a in self.state.adrs.read() {
|
||||
if matches.iter().any(|(id, typ)| id == &a.id && typ == "adr") {
|
||||
adrs.push(a);
|
||||
}
|
||||
}
|
||||
|
||||
let q = req.query.to_lowercase();
|
||||
|
||||
let mut kg = self.state.get_full_graph();
|
||||
kg.entities.retain(|name, e| {
|
||||
(req.namespace.is_none() || e.namespace == *req.namespace.as_ref().unwrap())
|
||||
&& (name.to_lowercase().contains(&q)
|
||||
|| e.entity_type.to_lowercase().contains(&q)
|
||||
|| e.observations.join(" ").to_lowercase().contains(&q))
|
||||
});
|
||||
|
||||
let tasks: Vec<_> = self
|
||||
.state
|
||||
.tasks
|
||||
.read()
|
||||
.into_iter()
|
||||
.filter(|t| {
|
||||
t.title.to_lowercase().contains(&q)
|
||||
|| t.description.to_lowercase().contains(&q)
|
||||
})
|
||||
.collect();
|
||||
let snippets: Vec<_> = self
|
||||
.state
|
||||
.snippets
|
||||
.read()
|
||||
.into_iter()
|
||||
.filter(|s| {
|
||||
s.name.to_lowercase().contains(&q)
|
||||
|| s.description.to_lowercase().contains(&q)
|
||||
})
|
||||
.collect();
|
||||
let adrs: Vec<_> = self
|
||||
.state
|
||||
.adrs
|
||||
.read()
|
||||
.into_iter()
|
||||
.filter(|a| {
|
||||
a.title.to_lowercase().contains(&q)
|
||||
|| a.context.to_lowercase().contains(&q)
|
||||
|| a.decision.to_lowercase().contains(&q)
|
||||
})
|
||||
.collect();
|
||||
let tech_debts: Vec<_> = self
|
||||
.state
|
||||
.tech_debts
|
||||
|
||||
+35
-1
@@ -3,6 +3,7 @@ mod models;
|
||||
mod state;
|
||||
mod store;
|
||||
mod tools;
|
||||
mod search;
|
||||
|
||||
use crate::handlers::MemoryHandler;
|
||||
use crate::models::*;
|
||||
@@ -84,6 +85,7 @@ async fn reconcile_worker(state: Arc<MemoryState>) {
|
||||
let has_files = glob::glob(&pattern).map(|p| p.count() > 0).unwrap_or(false);
|
||||
if has_local || has_files {
|
||||
state.apply_sync_write(|_master| {});
|
||||
state.rebuild_index();
|
||||
}
|
||||
|
||||
let now = SystemTime::now()
|
||||
@@ -152,6 +154,35 @@ fn run_server(state: Arc<MemoryState>) -> SdkResult<()> {
|
||||
axum::routing::get(|| async move {
|
||||
axum::http::StatusCode::OK
|
||||
}),
|
||||
).with_route(
|
||||
"/dashboard",
|
||||
axum::routing::get(|| async move {
|
||||
axum::response::Html(include_str!("dashboard.html"))
|
||||
}),
|
||||
).with_route(
|
||||
"/api/stats",
|
||||
axum::routing::get({
|
||||
let state_clone = state.clone();
|
||||
move || async move {
|
||||
let (entities, relations) = {
|
||||
let graph = state_clone.get_full_graph();
|
||||
(graph.entities.len(), graph.relations.len())
|
||||
};
|
||||
let tasks = state_clone.tasks.read().len();
|
||||
let snippets = state_clone.snippets.read().len();
|
||||
let tech_debts = state_clone.tech_debts.read().len();
|
||||
let adrs = state_clone.adrs.read().len();
|
||||
|
||||
axum::Json(serde_json::json!({
|
||||
"entities": entities,
|
||||
"relations": relations,
|
||||
"tasks": tasks,
|
||||
"snippets": snippets,
|
||||
"tech_debts": tech_debts,
|
||||
"adrs": adrs
|
||||
}))
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
println!("MCP Memory Server running on http://0.0.0.0:3000/sse");
|
||||
@@ -190,7 +221,7 @@ fn main() -> SdkResult<()> {
|
||||
{
|
||||
// This shouldn't be executed on linux natively anymore due to workspace split,
|
||||
// but keeping it as a fallback.
|
||||
let target_url = cli.target.as_deref().unwrap_or("http://host.docker.internal:3000");
|
||||
let target_url = cli.target.as_deref().unwrap_or("http://127.0.0.1:3000");
|
||||
let _ = proxy::run_proxy(target_url);
|
||||
return Ok(());
|
||||
}
|
||||
@@ -211,6 +242,7 @@ fn main() -> SdkResult<()> {
|
||||
session_graph: RwLock::new(KnowledgeGraph::default()),
|
||||
base_dir: base.clone(),
|
||||
master_cache: RwLock::new((KnowledgeGraph::default(), SystemTime::UNIX_EPOCH)),
|
||||
search_index: RwLock::new(crate::search::MemoryIndex::new().unwrap()),
|
||||
ledger: Store::new(base.join("audit_ledger.json")),
|
||||
sticky: Store::new(base.join("sticky_notes.json")),
|
||||
tasks: Store::new(base.join("tasks.json")),
|
||||
@@ -231,6 +263,8 @@ fn main() -> SdkResult<()> {
|
||||
context_workspaces: Store::new(base.join("context_workspaces.json")),
|
||||
});
|
||||
|
||||
state.rebuild_index();
|
||||
|
||||
if let Some(command) = cli.command {
|
||||
match command {
|
||||
Commands::Gate { subcmd } => {
|
||||
|
||||
+36
-6
@@ -1,7 +1,7 @@
|
||||
use tantivy::schema::*;
|
||||
use tantivy::{doc, Index, IndexWriter, IndexReader, ReloadPolicy};
|
||||
use std::sync::Mutex;
|
||||
use crate::models::{Entity, Task, StickyNote, Adr, Snippet};
|
||||
use crate::models::{Entity, Task, Adr, Snippet};
|
||||
|
||||
pub struct MemoryIndex {
|
||||
index: Index,
|
||||
@@ -71,21 +71,51 @@ impl MemoryIndex {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn search(&self, query: &str, namespace: Option<&str>) -> tantivy::Result<Vec<String>> {
|
||||
pub fn search(&self, query: &str, namespace: Option<&str>) -> tantivy::Result<Vec<(String, String)>> {
|
||||
let searcher = self.reader.searcher();
|
||||
let query_parser = tantivy::query::QueryParser::for_index(&self.index, vec![self.title_field, self.body_field]);
|
||||
let q = query_parser.parse_query(query)?;
|
||||
|
||||
let top_docs = searcher.search(&q, &tantivy::collector::TopDocs::with_limit(50))?;
|
||||
let top_docs = searcher.search(&q, &tantivy::collector::TopDocs::with_limit(50).order_by_score())?;
|
||||
let mut results = Vec::new();
|
||||
for (_score, doc_address) in top_docs {
|
||||
let retrieved_doc = searcher.doc::<tantivy::TantivyDocument>(doc_address)?;
|
||||
if let Some(val) = retrieved_doc.get_first(self.title_field) {
|
||||
if let Some(t) = val.as_str() {
|
||||
results.push(t.to_string());
|
||||
let id = retrieved_doc.get_first(self.id_field).and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let doc_type = retrieved_doc.get_first(self.type_field).and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let doc_ns = retrieved_doc.get_first(self.namespace_field).and_then(|v| v.as_str()).unwrap_or("");
|
||||
if let Some(ns) = namespace {
|
||||
if doc_ns != ns && doc_ns != "global" {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
results.push((id, doc_type));
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
pub fn index_snippet(&self, s: &Snippet) -> tantivy::Result<()> {
|
||||
let mut writer = self.writer.lock().unwrap();
|
||||
writer.add_document(doc!(
|
||||
self.id_field => s.name.clone(),
|
||||
self.title_field => s.name.clone(),
|
||||
self.body_field => format!("{} {}", s.language, s.description),
|
||||
self.type_field => "snippet",
|
||||
self.namespace_field => "global"
|
||||
))?;
|
||||
writer.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn index_adr(&self, a: &Adr) -> tantivy::Result<()> {
|
||||
let mut writer = self.writer.lock().unwrap();
|
||||
writer.add_document(doc!(
|
||||
self.id_field => a.id.clone(),
|
||||
self.title_field => a.title.clone(),
|
||||
self.body_field => format!("{} {} {}", a.context, a.decision, a.consequence),
|
||||
self.type_field => "adr",
|
||||
self.namespace_field => "global"
|
||||
))?;
|
||||
writer.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::models::*;
|
||||
use crate::store::{Store, read_json_file, write_json_atomic};
|
||||
use crate::search::MemoryIndex;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
@@ -11,6 +12,7 @@ pub struct MemoryState {
|
||||
pub master_path: PathBuf,
|
||||
pub session_graph: RwLock<KnowledgeGraph>,
|
||||
pub master_cache: RwLock<(KnowledgeGraph, SystemTime)>,
|
||||
pub search_index: RwLock<MemoryIndex>,
|
||||
pub ledger: Store<Vec<CodeChange>>,
|
||||
pub sticky: Store<Vec<StickyNote>>,
|
||||
pub tasks: Store<Vec<Task>>,
|
||||
@@ -136,5 +138,27 @@ impl MemoryState {
|
||||
|
||||
let _ = fs::remove_file(&lock_path);
|
||||
}
|
||||
|
||||
pub fn rebuild_index(&self) {
|
||||
if let Ok(new_idx) = MemoryIndex::new() {
|
||||
let full = self.get_full_graph();
|
||||
for (_, e) in &full.entities {
|
||||
let _ = new_idx.index_entity(e);
|
||||
}
|
||||
for t in self.tasks.read() {
|
||||
let _ = new_idx.index_task(&t);
|
||||
}
|
||||
for s in self.snippets.read() {
|
||||
let _ = new_idx.index_snippet(&s);
|
||||
}
|
||||
for a in self.adrs.read() {
|
||||
let _ = new_idx.index_adr(&a);
|
||||
}
|
||||
if let Ok(mut w) = self.search_index.write() {
|
||||
*w = new_idx;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user