fix(dashboard): scope sessions and analytics to selected profile (#45598)

This commit is contained in:
Teknium 2026-06-13 05:42:38 -07:00 committed by GitHub
parent 2abcae9678
commit 62b4618e9a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 175 additions and 65 deletions

View file

@ -2535,6 +2535,7 @@ async def get_sessions(
order: str = "created",
source: str = None,
exclude_sources: str = None,
profile: Optional[str] = None,
):
"""List sessions.
@ -2558,9 +2559,11 @@ async def get_sessions(
status_code=400,
detail="order must be one of: created, recent",
)
profile_name: Optional[str] = None
if profile:
profile_name, _ = _cron_profile_home(profile)
try:
from hermes_state import SessionDB
db = SessionDB()
db = _open_session_db_for_profile(profile)
try:
min_message_count = max(0, min_messages)
archived_only = archived == "only"
@ -2594,11 +2597,16 @@ async def get_sessions(
s.get("ended_at") is None
and (now - s.get("last_active", s.get("started_at", 0))) < 300
)
if profile_name:
s["profile"] = profile_name
s["is_default_profile"] = profile_name == "default"
# SQLite stores the flag as 0/1; expose a real JSON boolean.
s["archived"] = bool(s.get("archived"))
return {"sessions": sessions, "total": total, "limit": limit, "offset": offset}
finally:
db.close()
except HTTPException:
raise
except Exception:
_log.exception("GET /api/sessions failed")
raise HTTPException(status_code=500, detail="Internal server error")
@ -2724,7 +2732,7 @@ async def get_profiles_sessions(
@app.get("/api/sessions/search")
async def search_sessions(q: str = "", limit: int = 20):
async def search_sessions(q: str = "", limit: int = 20, profile: Optional[str] = None):
"""Search sessions by ID plus full-text message content using FTS5.
Direct session-id matches are surfaced first, then FTS message-content
@ -2738,8 +2746,7 @@ async def search_sessions(q: str = "", limit: int = 20):
if not q or not q.strip():
return {"results": []}
try:
from hermes_state import SessionDB
db = SessionDB()
db = _open_session_db_for_profile(profile)
try:
safe_limit = max(1, min(int(limit or 20), 100))
@ -2881,6 +2888,8 @@ async def search_sessions(q: str = "", limit: int = 20):
return {"results": list(seen.values())}
finally:
db.close()
except HTTPException:
raise
except Exception:
_log.exception("GET /api/sessions/search failed")
raise HTTPException(status_code=500, detail="Search failed")
@ -6290,6 +6299,7 @@ def _session_latest_descendant(session_id: str):
# reorder this block, move every route in it together.
class BulkDeleteSessions(BaseModel):
ids: List[str]
profile: Optional[str] = None
@app.post("/api/sessions/bulk-delete")
@ -6334,8 +6344,7 @@ async def bulk_delete_sessions_endpoint(body: BulkDeleteSessions):
status_code=400,
detail="ids must contain at most 500 entries",
)
from hermes_state import SessionDB
db = SessionDB()
db = _open_session_db_for_profile(body.profile)
try:
deleted = db.delete_sessions(body.ids)
return {"ok": True, "deleted": deleted}
@ -6344,15 +6353,14 @@ async def bulk_delete_sessions_endpoint(body: BulkDeleteSessions):
@app.get("/api/sessions/empty/count")
async def count_empty_sessions_endpoint():
async def count_empty_sessions_endpoint(profile: Optional[str] = None):
"""Return the number of empty, ended, non-archived sessions.
Drives the dashboard's "Delete empty (N)" button — when N is 0 the
UI hides the affordance so users aren't presented with a button
that does nothing. Cheap, single-COUNT query.
"""
from hermes_state import SessionDB
db = SessionDB()
db = _open_session_db_for_profile(profile)
try:
return {"count": db.count_empty_sessions()}
finally:
@ -6360,7 +6368,7 @@ async def count_empty_sessions_endpoint():
@app.delete("/api/sessions/empty")
async def delete_empty_sessions_endpoint():
async def delete_empty_sessions_endpoint(profile: Optional[str] = None):
"""Delete every empty (``message_count == 0``), ended,
non-archived session in a single transaction.
@ -6379,8 +6387,7 @@ async def delete_empty_sessions_endpoint():
prune-on-startup pass. Matching that pre-existing trade-off keeps
the two delete endpoints' DB-vs-disk behaviour consistent.
"""
from hermes_state import SessionDB
db = SessionDB()
db = _open_session_db_for_profile(profile)
try:
deleted = db.delete_empty_sessions()
return {"ok": True, "deleted": deleted}
@ -6389,15 +6396,13 @@ async def delete_empty_sessions_endpoint():
@app.get("/api/sessions/stats")
async def get_session_stats():
async def get_session_stats(profile: Optional[str] = None):
"""Session-store statistics for the Sessions page (mirrors `hermes sessions stats`).
Registered before ``/api/sessions/{session_id}`` so the literal ``stats``
path isn't captured as a session id by the parameterized route.
"""
from hermes_state import SessionDB
db = SessionDB()
db = _open_session_db_for_profile(profile)
try:
total = db.session_count(include_archived=True)
active_store = db.session_count(include_archived=False)
@ -6535,11 +6540,9 @@ async def rename_session_endpoint(session_id: str, body: SessionRename):
@app.get("/api/sessions/{session_id}/export")
async def export_session_endpoint(session_id: str):
async def export_session_endpoint(session_id: str, profile: Optional[str] = None):
"""Export a single session (metadata + messages) as JSON."""
from hermes_state import SessionDB
db = SessionDB()
db = _open_session_db_for_profile(profile)
try:
sid = db.resolve_session_id(session_id)
if not sid:
@ -6555,6 +6558,7 @@ async def export_session_endpoint(session_id: str):
class SessionPrune(BaseModel):
older_than_days: int = 90
source: Optional[str] = None
profile: Optional[str] = None
@app.post("/api/sessions/prune")
@ -6562,11 +6566,10 @@ async def prune_sessions_endpoint(body: SessionPrune):
"""Delete ended sessions older than N days (mirrors `hermes sessions prune`)."""
if body.older_than_days < 1:
raise HTTPException(status_code=400, detail="older_than_days must be >= 1")
from hermes_state import SessionDB
db = SessionDB()
profile_home = _cron_profile_home(body.profile)[1] if body.profile else get_hermes_home()
db = _open_session_db_for_profile(body.profile)
try:
sessions_dir = get_hermes_home() / "sessions"
sessions_dir = profile_home / "sessions"
removed = db.prune_sessions(
older_than_days=body.older_than_days,
source=(body.source or None),
@ -9612,11 +9615,10 @@ async def update_config_raw(body: RawConfigUpdate, profile: Optional[str] = None
@app.get("/api/analytics/usage")
async def get_usage_analytics(days: int = 30):
from hermes_state import SessionDB
async def get_usage_analytics(days: int = 30, profile: Optional[str] = None):
from agent.insights import InsightsEngine
db = SessionDB()
db = _open_session_db_for_profile(profile)
try:
cutoff = time.time() - (days * 86400)
cur = db._conn.execute("""
@ -9681,15 +9683,13 @@ async def get_usage_analytics(days: int = 30):
@app.get("/api/analytics/models")
async def get_models_analytics(days: int = 30):
async def get_models_analytics(days: int = 30, profile: Optional[str] = None):
"""Rich per-model analytics for the Models dashboard page.
Returns token/cost/session breakdown per model plus capability metadata
from models.dev (context window, vision, tools, reasoning, etc.).
"""
from hermes_state import SessionDB
db = SessionDB()
db = _open_session_db_for_profile(profile)
try:
cutoff = time.time() - (days * 86400)

View file

@ -520,6 +520,87 @@ class TestWebServerEndpoints:
resp = self.client.get("/api/profiles/sessions?archived=bogus")
assert resp.status_code == 400
def test_sessions_endpoint_reads_requested_profile(self):
"""The machine dashboard's global profile switcher must retarget
the Sessions page, not just config/skills/model pages."""
from hermes_state import SessionDB
from hermes_cli import profiles as profiles_mod
worker_home = profiles_mod.get_profile_dir("worker")
worker_home.mkdir(parents=True)
default_db = SessionDB()
try:
default_db.create_session(session_id="default-only", source="cli")
default_db.append_message("default-only", role="user", content="default")
finally:
default_db.close()
worker_db = SessionDB(db_path=worker_home / "state.db")
try:
worker_db.create_session(session_id="worker-only", source="cli")
worker_db.append_message("worker-only", role="user", content="worker")
finally:
worker_db.close()
resp = self.client.get("/api/sessions?profile=worker&limit=20&min_messages=0")
assert resp.status_code == 200
data = resp.json()
ids = {s["id"] for s in data["sessions"]}
assert "worker-only" in ids
assert "default-only" not in ids
row = next(s for s in data["sessions"] if s["id"] == "worker-only")
assert row["profile"] == "worker"
assert row["is_default_profile"] is False
stats = self.client.get("/api/sessions/stats?profile=worker").json()
assert stats["total"] == 1
assert stats["messages"] == 1
messages = self.client.get("/api/sessions/worker-only/messages?profile=worker").json()
assert [m["content"] for m in messages["messages"]] == ["worker"]
def test_analytics_endpoints_read_requested_profile(self):
from hermes_state import SessionDB
from hermes_cli import profiles as profiles_mod
worker_home = profiles_mod.get_profile_dir("worker")
worker_home.mkdir(parents=True)
default_db = SessionDB()
try:
default_db.create_session(session_id="default-usage", source="cli", model="default/model")
default_db.update_token_counts("default-usage", input_tokens=10, output_tokens=5)
finally:
default_db.close()
worker_db = SessionDB(db_path=worker_home / "state.db")
try:
worker_db.create_session(session_id="worker-usage", source="cli", model="worker/model")
worker_db.update_token_counts(
"worker-usage",
input_tokens=123,
output_tokens=45,
billing_provider="worker-provider",
)
finally:
worker_db.close()
usage = self.client.get("/api/analytics/usage?days=7&profile=worker").json()
assert usage["totals"]["total_sessions"] == 1
assert usage["totals"]["total_input"] == 123
assert [m["model"] for m in usage["by_model"]] == ["worker/model"]
models = self.client.get("/api/analytics/models?days=7&profile=worker").json()
assert models["totals"]["distinct_models"] == 1
assert models["totals"]["total_input"] == 123
assert models["models"][0]["model"] == "worker/model"
assert models["models"][0]["provider"] == "worker-provider"
default_usage = self.client.get("/api/analytics/usage?days=7").json()
assert default_usage["totals"]["total_input"] == 10
assert default_usage["totals"]["total_output"] == 5
def test_get_sessions_rejects_unknown_archived_value(self):
resp = self.client.get("/api/sessions?archived=bogus")
assert resp.status_code == 400

View file

@ -59,11 +59,12 @@ export function getManagementProfile(): string {
}
// Endpoint families that honor ?profile= on the backend (web_server.py
// _profile_scope). Anything else — sessions, analytics, ops, pairing,
// telegram onboarding, cron (which has its own per-job profile params),
// profiles themselves — is machine-global or self-scoped and must NOT be
// rewritten.
// _profile_scope or explicit per-profile DB opens). Anything else — ops,
// pairing, telegram onboarding, cron (which has its own per-job profile
// params), profiles themselves — is machine-global or self-scoped and must
// NOT be rewritten.
const PROFILE_SCOPED_PREFIXES = [
"/api/analytics",
"/api/skills",
"/api/tools/toolsets",
"/api/config",
@ -302,6 +303,11 @@ function profileQuery(profile?: string): string {
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
}
function appendProfileParam(url: string, profile?: string): string {
if (!profile || url.includes("profile=")) return url;
return `${url}${url.includes("?") ? "&" : "?"}profile=${encodeURIComponent(profile)}`;
}
export const api = {
getStatus: () => fetchJSON<StatusResponse>("/api/status"),
/**
@ -336,47 +342,64 @@ export const api = {
window.location.assign("/login");
return r;
}),
getSessions: (limit = 20, offset = 0) =>
fetchJSON<PaginatedSessions>(`/api/sessions?limit=${limit}&offset=${offset}`),
getSessionMessages: (id: string) =>
fetchJSON<SessionMessagesResponse>(`/api/sessions/${encodeURIComponent(id)}/messages`),
getSessions: (limit = 20, offset = 0, profile = getManagementProfile()) =>
fetchJSON<PaginatedSessions>(
appendProfileParam(`/api/sessions?limit=${limit}&offset=${offset}`, profile),
),
getSessionMessages: (id: string, profile = getManagementProfile()) =>
fetchJSON<SessionMessagesResponse>(
appendProfileParam(`/api/sessions/${encodeURIComponent(id)}/messages`, profile),
),
getSessionLatestDescendant: (id: string) =>
fetchJSON<SessionLatestDescendantResponse>(
`/api/sessions/${encodeURIComponent(id)}/latest-descendant`,
),
deleteSession: (id: string) =>
fetchJSON<{ ok: boolean }>(`/api/sessions/${encodeURIComponent(id)}`, {
method: "DELETE",
}),
getEmptySessionsCount: () =>
fetchJSON<{ count: number }>("/api/sessions/empty/count"),
deleteEmptySessions: () =>
fetchJSON<{ ok: boolean; deleted: number }>("/api/sessions/empty", {
method: "DELETE",
}),
bulkDeleteSessions: (ids: string[]) =>
deleteSession: (id: string, profile = getManagementProfile()) =>
fetchJSON<{ ok: boolean }>(
appendProfileParam(`/api/sessions/${encodeURIComponent(id)}`, profile),
{
method: "DELETE",
},
),
getEmptySessionsCount: (profile = getManagementProfile()) =>
fetchJSON<{ count: number }>(
appendProfileParam("/api/sessions/empty/count", profile),
),
deleteEmptySessions: (profile = getManagementProfile()) =>
fetchJSON<{ ok: boolean; deleted: number }>(
appendProfileParam("/api/sessions/empty", profile),
{
method: "DELETE",
},
),
bulkDeleteSessions: (ids: string[], profile = getManagementProfile()) =>
fetchJSON<{ ok: boolean; deleted: number }>("/api/sessions/bulk-delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids }),
body: JSON.stringify({ ids, profile: profile || undefined }),
}),
renameSession: (id: string, title: string) =>
renameSession: (id: string, title: string, profile = getManagementProfile()) =>
fetchJSON<{ ok: boolean; title: string }>(
`/api/sessions/${encodeURIComponent(id)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
body: JSON.stringify({ title, profile: profile || undefined }),
},
),
getSessionStats: () => fetchJSON<SessionStoreStats>("/api/sessions/stats"),
exportSessionUrl: (id: string) =>
`/api/sessions/${encodeURIComponent(id)}/export`,
pruneSessions: (older_than_days: number, source?: string) =>
getSessionStats: (profile = getManagementProfile()) =>
fetchJSON<SessionStoreStats>(appendProfileParam("/api/sessions/stats", profile)),
exportSessionUrl: (id: string, profile = getManagementProfile()) =>
appendProfileParam(`/api/sessions/${encodeURIComponent(id)}/export`, profile),
pruneSessions: (
older_than_days: number,
source?: string,
profile = getManagementProfile(),
) =>
fetchJSON<{ ok: boolean; removed: number }>("/api/sessions/prune", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ older_than_days, source }),
body: JSON.stringify({ older_than_days, source, profile: profile || undefined }),
}),
listFiles: (path?: string) => {
const query = path ? `?path=${encodeURIComponent(path)}` : "";
@ -412,10 +435,14 @@ export const api = {
if (params.component && params.component !== "all") qs.set("component", params.component);
return fetchJSON<LogsResponse>(`/api/logs?${qs.toString()}`);
},
getAnalytics: (days: number) =>
fetchJSON<AnalyticsResponse>(`/api/analytics/usage?days=${days}`),
getModelsAnalytics: (days: number) =>
fetchJSON<ModelsAnalyticsResponse>(`/api/analytics/models?days=${days}`),
getAnalytics: (days: number, profile = getManagementProfile()) =>
fetchJSON<AnalyticsResponse>(
appendProfileParam(`/api/analytics/usage?days=${days}`, profile),
),
getModelsAnalytics: (days: number, profile = getManagementProfile()) =>
fetchJSON<ModelsAnalyticsResponse>(
appendProfileParam(`/api/analytics/models?days=${days}`, profile),
),
getConfig: () => fetchJSON<Record<string, unknown>>("/api/config"),
getDefaults: () => fetchJSON<Record<string, unknown>>("/api/config/defaults"),
getSchema: () => fetchJSON<{ fields: Record<string, unknown>; category_order: string[] }>("/api/config/schema"),
@ -680,8 +707,10 @@ export const api = {
),
// Session search (FTS5)
searchSessions: (q: string) =>
fetchJSON<SessionSearchResponse>(`/api/sessions/search?q=${encodeURIComponent(q)}`),
searchSessions: (q: string, profile = getManagementProfile()) =>
fetchJSON<SessionSearchResponse>(
appendProfileParam(`/api/sessions/search?q=${encodeURIComponent(q)}`, profile),
),
// OAuth provider management
getOAuthProviders: () =>