some basic statistics and invite tree, plus watch new data
This commit is contained in:
@@ -86,6 +86,62 @@ def _start_serve(data: Path, api_port: int, site: Path) -> None:
|
||||
server.run()
|
||||
|
||||
|
||||
def _watch_data(data: Path) -> None:
|
||||
"""Watch the data directory for sidecar/activity changes and re-merge.
|
||||
|
||||
Monitors every user's edits/ and activities/ subdirectories. When any file
|
||||
changes (new activity extracted, sidecar saved), re-runs merge_all for that
|
||||
user so the _merged/ symlink tree stays current. Astro dev picks up the
|
||||
result automatically because public/data is a symlink into the live data dir.
|
||||
|
||||
Uses watchfiles (bundled with uvicorn[standard]) for efficient OS-level
|
||||
file watching — no polling.
|
||||
"""
|
||||
from watchfiles import watch, Change
|
||||
|
||||
watch_paths = []
|
||||
for user_dir in _user_dirs(data):
|
||||
for sub in ("edits", "activities"):
|
||||
p = user_dir / sub
|
||||
p.mkdir(exist_ok=True)
|
||||
watch_paths.append(p)
|
||||
|
||||
if not watch_paths:
|
||||
return
|
||||
|
||||
console.print(f" [dim]Watching {len(watch_paths)} director{'y' if len(watch_paths) == 1 else 'ies'} for changes…[/dim]")
|
||||
|
||||
# Build a map from path prefix → user dir for targeted merge
|
||||
prefix_to_user: dict[str, Path] = {}
|
||||
for user_dir in _user_dirs(data):
|
||||
for sub in ("edits", "activities"):
|
||||
prefix_to_user[str(user_dir / sub)] = user_dir
|
||||
|
||||
for changes in watch(*watch_paths, yield_on_timeout=False):
|
||||
# Find which users were affected
|
||||
affected: set[Path] = set()
|
||||
for change_type, path in changes:
|
||||
# Skip timeseries / geojson / index churn written by merge itself
|
||||
if any(path.endswith(s) for s in (".timeseries.json", ".geojson", "index.json")):
|
||||
continue
|
||||
for prefix, user_dir in prefix_to_user.items():
|
||||
if path.startswith(prefix):
|
||||
affected.add(user_dir)
|
||||
break
|
||||
|
||||
if not affected:
|
||||
continue
|
||||
|
||||
for user_dir in affected:
|
||||
handle = user_dir.name
|
||||
try:
|
||||
from bincio.render.merge import merge_all
|
||||
merge_all(user_dir)
|
||||
console.print(f" [dim]↺ {handle}: merged[/dim]")
|
||||
except Exception as exc:
|
||||
console.print(f" [yellow]⚠ {handle}: merge failed — {exc}[/yellow]")
|
||||
|
||||
|
||||
@click.command("dev")
|
||||
@click.option("--data-dir", default=None, help="BAS data directory (must contain instance.db)")
|
||||
@click.option("--site-dir", default=None, help="Astro project directory (default: ./site)")
|
||||
@@ -144,6 +200,10 @@ def dev(
|
||||
t = threading.Thread(target=_start_serve, args=(data, api_port, site), daemon=True)
|
||||
t.start()
|
||||
|
||||
# Watch data dir for sidecar/activity changes → auto-merge
|
||||
watcher = threading.Thread(target=_watch_data, args=(data,), daemon=True)
|
||||
watcher.start()
|
||||
|
||||
# Build env for astro dev
|
||||
env = {
|
||||
**os.environ,
|
||||
|
||||
@@ -154,6 +154,33 @@ def delete_user(db: sqlite3.Connection, handle: str) -> None:
|
||||
db.commit()
|
||||
|
||||
|
||||
def get_member_tree(db: sqlite3.Connection) -> list[dict]:
|
||||
"""Return users with their inviter handle and join timestamp.
|
||||
|
||||
Each entry: {handle, display_name, created_at, invited_by (handle or None)}.
|
||||
Ordered oldest-first so callers can build the tree top-down.
|
||||
"""
|
||||
users = {r["handle"]: r for r in db.execute(
|
||||
"SELECT handle, display_name, created_at FROM users ORDER BY created_at"
|
||||
).fetchall()}
|
||||
# Map invitee → inviter from the used invites
|
||||
invited_by: dict[str, str] = {}
|
||||
for row in db.execute(
|
||||
"SELECT created_by, used_by FROM invites WHERE used_by IS NOT NULL"
|
||||
).fetchall():
|
||||
invited_by[row["used_by"]] = row["created_by"]
|
||||
|
||||
return [
|
||||
{
|
||||
"handle": r["handle"],
|
||||
"display_name": r["display_name"],
|
||||
"created_at": r["created_at"],
|
||||
"invited_by": invited_by.get(r["handle"]),
|
||||
}
|
||||
for r in users.values()
|
||||
]
|
||||
|
||||
|
||||
def count_users(db: sqlite3.Connection) -> int:
|
||||
"""Return the total number of registered users."""
|
||||
row = db.execute("SELECT COUNT(*) FROM users").fetchone()
|
||||
|
||||
@@ -29,6 +29,7 @@ from bincio.serve.db import (
|
||||
create_user,
|
||||
delete_session,
|
||||
get_invite,
|
||||
get_member_tree,
|
||||
get_session,
|
||||
get_setting,
|
||||
get_user,
|
||||
@@ -173,6 +174,27 @@ async def me(bincio_session: Optional[str] = Cookie(default=None)) -> JSONRespon
|
||||
})
|
||||
|
||||
|
||||
@app.get("/api/stats")
|
||||
async def stats() -> JSONResponse:
|
||||
"""Public endpoint: member count, join dates, and invitation tree."""
|
||||
import time as _time
|
||||
now = int(_time.time())
|
||||
members = get_member_tree(_get_db())
|
||||
return JSONResponse({
|
||||
"user_count": len(members),
|
||||
"members": [
|
||||
{
|
||||
"handle": m["handle"],
|
||||
"display_name": m["display_name"],
|
||||
"member_since": m["created_at"],
|
||||
"member_for_days": (now - m["created_at"]) // 86400,
|
||||
"invited_by": m["invited_by"],
|
||||
}
|
||||
for m in members
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
async def login(request: Request) -> JSONResponse:
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
|
||||
Reference in New Issue
Block a user