mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-16 09:31:37 +00:00
fix(dashboard): scope sessions and analytics to selected profile (#45598)
This commit is contained in:
parent
2abcae9678
commit
62b4618e9a
3 changed files with 175 additions and 65 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: () =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue