stats endpoint perf boost + bugfix

This commit is contained in:
Gamosoft 2026-06-30 13:47:19 +02:00
parent f40239784f
commit e9b88516ae
3 changed files with 58 additions and 46 deletions

View File

@ -24,6 +24,7 @@ from slowapi.errors import RateLimitExceeded
from .utils import (
scan_notes_fast_walk,
ensure_index_built,
get_note_content,
save_note,
delete_note,
@ -1558,55 +1559,33 @@ async def get_index_stats():
@api_router.get("/stats", tags=["Stats"])
@limiter.limit("30/minute")
async def get_stats(request: Request):
"""
Get application statistics at a glance.
"""At-a-glance counts for dashboard widgets (Homepage etc.).
Designed for dashboard widgets (e.g., Homepage) - lightweight and cached.
Returns counts of notes, folders, tags, templates, media, and other metadata.
"""
All vault aggregates are read from the in-memory index no file walk on
the request path. Templates / plugins / version are looked up directly."""
try:
notes_dir = config['storage']['notes_dir']
# Get notes and folders (cached)
notes, folders = scan_notes_fast_walk(notes_dir, include_media=True)
# Separate notes from media
note_items = [n for n in notes if n.get('type') == 'note']
media_items = [n for n in notes if n.get('type') != 'note']
# Count unique tags
all_tags = set()
for note in note_items:
all_tags.update(note.get('tags', []))
# Get templates count
templates = get_templates(notes_dir)
# Calculate total size
total_size = sum(n.get('size', 0) for n in notes)
# Get last modified (notes are already sorted by modified desc)
last_modified = note_items[0].get('modified') if note_items else None
# Count enabled plugins
ensure_index_built(notes_dir)
s = note_index.summary()
templates_count = len(get_templates(notes_dir))
enabled_plugins = sum(1 for p in plugin_manager.plugins.values() if p.enabled)
# Read version
version = "unknown"
version_file = Path(__file__).parent.parent / "VERSION"
if version_file.exists():
version = version_file.read_text().strip()
return {
"notes_count": len(note_items),
"folders_count": len(folders),
"tags_count": len(all_tags),
"templates_count": len(templates),
"media_count": len(media_items),
"total_size_bytes": total_size,
"last_modified": last_modified,
"notes_count": s["notes_count"],
"folders_count": s["folders_count"],
"tags_count": s["tags_count"],
"templates_count": templates_count,
"media_count": s["media_count"],
"total_size_bytes": s["total_size_bytes"],
"last_modified": s["last_modified"],
"plugins_enabled": enabled_plugins,
"version": version
"version": version,
}
except Exception as e:
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to get stats"))

View File

@ -499,6 +499,34 @@ class NoteIndex:
break
return result
def summary(self) -> Dict[str, Any]:
"""Aggregate counts + total size + last-modified note. Powers /api/stats
without a vault scan every field is computed from records already in
memory."""
with self._lock:
notes_count = 0
media_count = 0
total_size = 0
last_modified: Optional[str] = None
last_mtime = -1.0
for rec in self._notes.values():
total_size += rec.size
if rec.type == "note":
notes_count += 1
if rec.mtime > last_mtime:
last_mtime = rec.mtime
last_modified = rec.modified
else:
media_count += 1
return {
"notes_count": notes_count,
"media_count": media_count,
"folders_count": len(self._folders),
"tags_count": len(self._tags_backward),
"total_size_bytes": total_size,
"last_modified": last_modified,
}
def stats(self) -> Dict[str, Any]:
"""Snapshot of counters + size metrics."""
with self._lock:
@ -968,6 +996,10 @@ def try_get_extraction(
return _index.try_get_extraction(rel_path, mtime)
def summary() -> Dict[str, Any]:
return _index.summary()
def stats() -> Dict[str, Any]:
return _index.stats()

View File

@ -167,11 +167,12 @@ def _scan_cache_invalidate() -> None:
_SCAN_WALK_CACHE.clear()
def _ensure_index_built(notes_dir: str) -> None:
def ensure_index_built(notes_dir: str) -> None:
"""Trigger a fresh scan when the NoteIndex hasn't been populated yet.
Idempotent: the scan short-circuits when nothing changed in the vault."""
Scans with include_media=True so every index consumer (stats, notes,
backlinks, search) shares one fingerprint and never thrashes."""
if not note_index.get_index().is_built():
scan_notes_fast_walk(notes_dir, use_cache=False, include_media=False)
scan_notes_fast_walk(notes_dir, use_cache=False, include_media=True)
def get_tags_and_links_cached(
@ -545,7 +546,7 @@ def search_notes(notes_dir: str, query: str) -> List[Dict]:
from html import escape
results: List[Dict] = []
_ensure_index_built(notes_dir)
ensure_index_built(notes_dir)
note_index.ensure_search_index(notes_dir)
candidates = note_index.get_search_candidates(query)
@ -875,13 +876,13 @@ def parse_tags(content: str) -> List[str]:
def get_all_tags(notes_dir: str) -> Dict[str, int]:
"""All tags in the vault with note counts."""
_ensure_index_built(notes_dir)
ensure_index_built(notes_dir)
return note_index.get_all_tags()
def get_notes_by_tag(notes_dir: str, tag: str) -> List[Dict]:
"""All notes carrying `tag` (case-insensitive)."""
_ensure_index_built(notes_dir)
ensure_index_built(notes_dir)
idx = note_index.get_index()
records = [idx.get_note_record(p) for p in note_index.get_paths_for_tag(tag.lower())]
matching = [
@ -1151,7 +1152,7 @@ def get_backlinks(notes_dir: str, target_note_path: str) -> List[Dict]:
Path(target_path).stem.lower(),
}
_ensure_index_built(notes_dir)
ensure_index_built(notes_dir)
idx = note_index.get_index()
candidates = note_index.get_backlink_candidates(target_path)
records = [(p, idx.get_note_record(p)) for p in candidates]