Skip to content

Commit 7beded7

Browse files
dashboard: redesign home, add explore page, import/export settings
- Home page now shows project cards (derived from document project fields) with doc counts and last activity dates, plus recent activity table below - New /project/<name> route for per-project activity view - New Explore page replaces graph as primary way to browse connections: topic search with dossier, relationship list, related objects, and entity browser with size-weighted chips - Entities page merged into Explore as browsable entity grid - Documents and Graph pages use project dropdowns instead of free-text input - Settings page gains Obsidian vault import and markdown export - Graph page marked as under development with link to Explore - Template cleanup: inline styles replaced with CSS classes
1 parent 2706df1 commit 7beded7

12 files changed

Lines changed: 814 additions & 52 deletions

File tree

src/cortex/dashboard/server.py

Lines changed: 249 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -183,13 +183,51 @@ async def home(request: Request):
183183
return RedirectResponse("/login", status_code=302)
184184

185185
stats = await mcp_client.status()
186-
recent = await mcp_client.list_objects(limit=10)
187186
alerts = stats.get("alerts", []) if isinstance(stats, dict) else []
188187

188+
# Derive projects from document project fields.
189+
all_docs = await mcp_client.list_objects(limit=500)
190+
191+
doc_counts: dict[str, int] = {}
192+
last_activity: dict[str, str] = {}
193+
for doc in all_docs:
194+
proj = doc.get("project", "")
195+
if proj:
196+
doc_counts[proj] = doc_counts.get(proj, 0) + 1
197+
created = doc.get("created_at", "")
198+
if created > last_activity.get(proj, ""):
199+
last_activity[proj] = created
200+
201+
# Build project list sorted by doc count descending.
202+
projects = [
203+
{
204+
"name": name,
205+
"doc_count": count,
206+
"last_activity": last_activity.get(name, "")[:10],
207+
}
208+
for name, count in sorted(doc_counts.items(), key=lambda x: -x[1])
209+
]
210+
211+
recent = all_docs[:10]
212+
189213
return templates.TemplateResponse(
190214
request,
191215
"home.html",
192-
_ctx(stats=stats, alerts=alerts, recent=recent),
216+
_ctx(stats=stats, alerts=alerts, projects=projects, recent=recent),
217+
)
218+
219+
@app.get("/project/{project_name}", response_class=HTMLResponse)
220+
async def project_detail(request: Request, project_name: str):
221+
session = _require_auth(request)
222+
if session is None:
223+
return RedirectResponse("/login", status_code=302)
224+
225+
docs = await mcp_client.list_objects(project=project_name, limit=50)
226+
227+
return templates.TemplateResponse(
228+
request,
229+
"project.html",
230+
_ctx(project_name=project_name, documents=docs),
193231
)
194232

195233
@app.get("/documents", response_class=HTMLResponse)
@@ -217,6 +255,10 @@ async def documents_page(
217255
limit=50,
218256
)
219257

258+
# Get distinct project names for the dropdown.
259+
all_docs = await mcp_client.list_objects(limit=500)
260+
project_names = sorted({d.get("project", "") for d in all_docs} - {""})
261+
220262
return templates.TemplateResponse(
221263
request,
222264
"documents.html",
@@ -225,6 +267,7 @@ async def documents_page(
225267
query=q,
226268
doc_type=doc_type,
227269
project=project,
270+
project_names=project_names,
228271
),
229272
)
230273

@@ -241,12 +284,111 @@ async def document_detail(request: Request, obj_id: str):
241284
# Access tracking happens server-side inside cortex_read.
242285
return templates.TemplateResponse(request, "detail.html", _ctx(doc=doc))
243286

287+
@app.get("/explore", response_class=HTMLResponse)
288+
async def explore_page(
289+
request: Request,
290+
topic: str = "",
291+
entity_type: str = "",
292+
):
293+
session = _require_auth(request)
294+
if session is None:
295+
return RedirectResponse("/login", status_code=302)
296+
297+
dossier = None
298+
connections: list[dict[str, Any]] = []
299+
300+
if topic:
301+
# Fetch dossier for the topic.
302+
try:
303+
dossier = await mcp_client.dossier(topic)
304+
except MCPClientError:
305+
dossier = None
306+
307+
# If dossier found objects, resolve relationships for the
308+
# first object to show how it connects.
309+
if dossier and dossier.get("objects"):
310+
first_id = dossier["objects"][0]["id"]
311+
try:
312+
graph_info = await mcp_client.graph(obj_id=first_id)
313+
rels = graph_info.get("relationships", [])
314+
obj_lookup = {
315+
o["id"]: o for o in dossier.get("objects", [])
316+
}
317+
for rel in rels:
318+
other = obj_lookup.get(rel["other_id"])
319+
if other:
320+
connections.append({
321+
"direction": rel["direction"],
322+
"rel_type": rel["rel_type"],
323+
"other_id": rel["other_id"],
324+
"other_title": other.get("title", rel["other_id"][:8]),
325+
"other_type": other.get("type", ""),
326+
})
327+
except MCPClientError:
328+
pass
329+
330+
# Always fetch entities for the browse section.
331+
entities = await mcp_client.list_entities(entity_type=entity_type)
332+
333+
# Compute connection counts per entity from graph data.
334+
graph_data = await mcp_client.graph_data(limit=500)
335+
edge_counts: dict[str, int] = {}
336+
node_lookup: dict[str, str] = {} # id -> label
337+
for node in graph_data.get("nodes", []):
338+
d = node.get("data", {})
339+
node_lookup[d.get("id", "")] = d.get("label", "")
340+
for edge in graph_data.get("edges", []):
341+
d = edge.get("data", {})
342+
src = node_lookup.get(d.get("source", ""), "")
343+
tgt = node_lookup.get(d.get("target", ""), "")
344+
if src:
345+
edge_counts[src] = edge_counts.get(src, 0) + 1
346+
if tgt:
347+
edge_counts[tgt] = edge_counts.get(tgt, 0) + 1
348+
349+
# Attach connection count and size tier to each entity.
350+
max_count = max((edge_counts.get(e.get("name", ""), 0) for e in entities), default=1) or 1
351+
for ent in entities:
352+
count = edge_counts.get(ent.get("name", ""), 0)
353+
ent["connection_count"] = count
354+
# Size tiers: sm (0-20%), md (20-50%), lg (50-80%), xl (80%+)
355+
ratio = count / max_count if max_count else 0
356+
if ratio >= 0.8:
357+
ent["size"] = "xl"
358+
elif ratio >= 0.5:
359+
ent["size"] = "lg"
360+
elif ratio >= 0.2:
361+
ent["size"] = "md"
362+
else:
363+
ent["size"] = "sm"
364+
365+
# Sort entities by connection count descending.
366+
entities.sort(key=lambda e: e.get("connection_count", 0), reverse=True)
367+
368+
return templates.TemplateResponse(
369+
request,
370+
"explore.html",
371+
_ctx(
372+
topic=topic,
373+
dossier=dossier,
374+
connections=connections,
375+
entities=entities,
376+
entity_type=entity_type,
377+
),
378+
)
379+
244380
@app.get("/graph", response_class=HTMLResponse)
245381
async def graph_page(request: Request):
246382
session = _require_auth(request)
247383
if session is None:
248384
return RedirectResponse("/login", status_code=302)
249-
return templates.TemplateResponse(request, "graph.html")
385+
386+
all_docs = await mcp_client.list_objects(limit=500)
387+
project_names = sorted({d.get("project", "") for d in all_docs} - {""})
388+
389+
return templates.TemplateResponse(
390+
request, "graph.html", _ctx(project_names=project_names)
391+
)
250392

251393
@app.get("/entities", response_class=HTMLResponse)
252394
async def entities_page(request: Request, entity_type: str = ""):
@@ -301,17 +443,119 @@ async def query_trail(request: Request):
301443
return templates.TemplateResponse(request, "trail.html", _ctx(logs=logs))
302444

303445
@app.get("/settings", response_class=HTMLResponse)
304-
async def settings_page(request: Request):
446+
async def settings_page(request: Request, msg: str = "", msg_type: str = "info"):
305447
session = _require_auth(request)
306448
if session is None:
307449
return RedirectResponse("/login", status_code=302)
308450

309451
return templates.TemplateResponse(
310452
request,
311453
"settings.html",
312-
_ctx(config=config, weights={}),
454+
_ctx(config=config, weights={}, msg=msg, msg_type=msg_type),
313455
)
314456

457+
@app.post("/settings/import")
458+
async def settings_import(request: Request, vault_path: str = Form(...)):
459+
"""Import an Obsidian vault into Cortex."""
460+
session = _require_auth(request)
461+
if session is None:
462+
return RedirectResponse("/login", status_code=302)
463+
464+
from pathlib import Path as _Path
465+
466+
vault = _Path(vault_path).expanduser().resolve()
467+
if not vault.is_dir():
468+
return RedirectResponse(
469+
f"/settings?msg=Directory not found: {vault_path}&msg_type=danger",
470+
status_code=302,
471+
)
472+
473+
try:
474+
from cortex.db.store import Store
475+
from cortex.pipeline.importer import ObsidianImporter
476+
477+
store = Store(config.data_dir)
478+
importer = ObsidianImporter(store, pipeline=None)
479+
result = importer.run(vault)
480+
store.close()
481+
482+
imported = result.get("imported", 0)
483+
skipped = result.get("skipped", 0)
484+
failed = result.get("failed", 0)
485+
wiki = result.get("wiki_links_created", 0)
486+
msg = (
487+
f"Import complete: {imported} imported, {skipped} skipped, "
488+
f"{failed} failed, {wiki} wiki-links"
489+
)
490+
return RedirectResponse(
491+
f"/settings?msg={msg}&msg_type=info", status_code=302,
492+
)
493+
except Exception as e:
494+
return RedirectResponse(
495+
f"/settings?msg=Import error: {e}&msg_type=danger",
496+
status_code=302,
497+
)
498+
499+
@app.post("/settings/export")
500+
async def settings_export(request: Request, export_path: str = Form(...)):
501+
"""Export all Cortex documents as an Obsidian-compatible vault."""
502+
session = _require_auth(request)
503+
if session is None:
504+
return RedirectResponse("/login", status_code=302)
505+
506+
from pathlib import Path as _Path
507+
508+
target = _Path(export_path).expanduser().resolve()
509+
try:
510+
target.mkdir(parents=True, exist_ok=True)
511+
except OSError as e:
512+
return RedirectResponse(
513+
f"/settings?msg=Cannot create directory: {e}&msg_type=danger",
514+
status_code=302,
515+
)
516+
517+
try:
518+
all_docs = await mcp_client.list_objects(limit=5000)
519+
exported = 0
520+
for doc in all_docs:
521+
obj_id = doc.get("id", "")
522+
full = await mcp_client.read(obj_id)
523+
if isinstance(full, str) or full is None:
524+
continue
525+
526+
title = full.get("title", obj_id[:8])
527+
# Sanitize filename.
528+
safe_name = "".join(
529+
c if c.isalnum() or c in " -_" else "_" for c in title
530+
).strip()[:100]
531+
if not safe_name:
532+
safe_name = obj_id[:8]
533+
534+
# Build markdown with YAML frontmatter.
535+
md = "---\n"
536+
md += f"id: {obj_id}\n"
537+
md += f"type: {full.get('type', '')}\n"
538+
md += f"project: {full.get('project', '')}\n"
539+
md += f"tags: {full.get('tags', '')}\n"
540+
md += f"created: {full.get('created_at', '')}\n"
541+
md += "---\n\n"
542+
md += f"# {title}\n\n"
543+
md += full.get("content", "")
544+
545+
filepath = target / f"{safe_name}.md"
546+
filepath.write_text(md, encoding="utf-8")
547+
exported += 1
548+
549+
msg = f"Exported {exported} documents to {target}"
550+
return RedirectResponse(
551+
f"/settings?msg={msg}&msg_type=info", status_code=302,
552+
)
553+
except Exception as e:
554+
return RedirectResponse(
555+
f"/settings?msg=Export error: {e}&msg_type=danger",
556+
status_code=302,
557+
)
558+
315559
# ─── API Endpoints (for HTMX/Cytoscape) ───────────────────────
316560

317561
@app.get("/api/graph-data")

0 commit comments

Comments
 (0)