feat(dashboard): LLM stats answer who/what spends tokens — member scope chips and spend breakdowns
Nightly Build / build (push) Successful in 4m27s

The stats section was designed single-user: four global charts, no
attribution. Request metadata rows now carry the session's source and
the frame's agent_id/depth (additive ensure_column), denormalized at log
time by the LoggingModel from the owner's pool — off the turn's hot
path, degrading to NULLs, never to a lost row.

The dashboard gains member scope chips filtering every chart, and a
breakdown row splitting the range's billed tokens by member, kind
(chat / sub-agents / cron / system agents / channels), agent, model and
provider — a sub-agent's spend is attributed to the sub-agent itself.
Rows predating the columns group under 'older data'.
This commit is contained in:
Daniele
2026-09-10 14:03:22 +01:00
parent 7e3fa3caad
commit 0958264c6f
14 changed files with 550 additions and 99 deletions
+7
View File
@@ -10,6 +10,13 @@ release PR may merge — and a section is closed at the commit that bumps it.
### Added
- The dashboard's **LLM stats** section answers "who and what is spending tokens", not
just "how much": a row of member chips filters every chart to one person (or the whole
instance, as before), and a new breakdown row splits the billed tokens of the selected
range **by member, by kind** (chat / sub-agents / scheduled tasks / system agents /
channels), **by agent, by model and by provider** — a sub-agent's spend is attributed to
the sub-agent itself. Request metadata rows now carry the session's source and the
frame's agent/depth, denormalized at log time; older rows group under "Older data".
- The file viewer opens **word-processor documents** (`.docx`, `.doc`, `.odt`, `.rtf`):
when LibreOffice is installed on the server they are converted to PDF and shown as the
document, live-reloading when the file changes, exactly like a compiled `.tex`. A
+15 -3
View File
@@ -6,7 +6,8 @@
//! of the traffic is known — `user_id` is what the UI filters on).
//! Payloads (request/response bodies + headers) live in `llm_request_payloads`
//! in the owner bucket (`{userid}.db`), correlated by `request_id`.
//! Rows are retained for `llm.request_log.retention_days` days (default 14).
//! Rows are retained for `llm.requests_log.cleanup_rows_after` days (the
//! shipped `default.config.yaml` sets 90; unset = kept forever).
use anyhow::Result;
use sqlx::SqlitePool;
@@ -31,6 +32,13 @@ pub struct LlmRequestRow {
pub cache_read_tokens: Option<i64>,
/// Tokens written into the provider's prompt cache (Anthropic only).
pub cache_creation_tokens: Option<i64>,
/// Denormalized attribution (see `db::mod`): the session's `source`, the
/// frame's `agent_id` (the sub-agent's own for a child frame) and `depth`
/// (0 = main agent, >0 = sub-agent). Resolved by the LoggingModel from the
/// owner's pool; `None` when it was unavailable.
pub source: Option<String>,
pub agent_id: Option<String>,
pub depth: Option<i64>,
}
// ── Writes ────────────────────────────────────────────────────────────────────
@@ -40,8 +48,9 @@ pub async fn insert(pool: &SqlitePool, row: LlmRequestRow) -> Result<i64> {
"INSERT INTO llm_requests (
request_id, user_id, session_id, stack_id, model_name,
error_text, input_tokens, output_tokens, duration_ms,
cache_read_tokens, cache_creation_tokens
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
cache_read_tokens, cache_creation_tokens,
source, agent_id, depth
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id",
)
.bind(&row.request_id)
@@ -55,6 +64,9 @@ pub async fn insert(pool: &SqlitePool, row: LlmRequestRow) -> Result<i64> {
.bind(row.duration_ms)
.bind(row.cache_read_tokens)
.bind(row.cache_creation_tokens)
.bind(&row.source)
.bind(&row.agent_id)
.bind(row.depth)
.fetch_one(pool)
.await?;
+10
View File
@@ -421,6 +421,16 @@ pub(crate) async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
.execute(pool)
.await?;
// Attribution columns for the multi-user stats (who/what consumed): the
// session's `source`, the frame's `agent_id` and its `depth` (0 = the
// conversation's main agent, >0 = a sub-agent). They live in the owner
// bucket, so the LoggingModel denormalizes them onto the row at insert
// time — a registry query cannot join an encrypted per-user file. NULL on
// rows predating the columns and when the owner's pool was not available.
ensure_column(pool, "llm_requests", "source", "TEXT").await?;
ensure_column(pool, "llm_requests", "agent_id", "TEXT").await?;
ensure_column(pool, "llm_requests", "depth", "INTEGER").await?;
// User directory + auth material. Read before every login, so it lives in
// the registry — which means it must never hold anything that derives a
// user's key: `database_password` is the DEK sealed under a key derived
+108 -37
View File
@@ -5,7 +5,8 @@
//!
//! * a **metadata-only** row in `llm_requests` (`system.db`) — cost, tokens,
//! timing, plus the correlation the UI filters on (`user_id`, `session_id`,
//! `stack_id`);
//! `stack_id`) and the denormalized attribution the dashboard breaks down on
//! (`source`, `agent_id`, `depth`);
//! * the **payload** (request/response bodies + headers) in
//! `llm_request_payloads` in the caller's own database, keyed by the same
//! `request_id`.
@@ -90,6 +91,90 @@ impl LoggingModel {
}
}
/// Resolves the denormalized attribution columns from the owner's pool: the
/// session's `source`, and the frame's `agent_id`/`depth`. The frame row
/// carries the sub-agent's own id for a child frame, so it wins over the
/// session's agent; the root frame carries the session's main agent. Runs
/// inside the spawned insert task — never on the turn's hot path — and any
/// failure degrades to NULLs, never to a lost metadata row.
async fn resolve_attribution(
pool: &SqlitePool,
session_id: i64,
stack_id: Option<i64>,
) -> (Option<String>, Option<String>, Option<i64>) {
let session = sqlx::query_as::<_, (String, String)>(
"SELECT source, agent_id FROM chat_sessions WHERE id = ?",
)
.bind(session_id)
.fetch_optional(pool)
.await
.ok()
.flatten();
let frame = match stack_id {
Some(id) => sqlx::query_as::<_, (i64, String)>(
"SELECT depth, agent_id FROM chat_sessions_stack WHERE id = ?",
)
.bind(id)
.fetch_optional(pool)
.await
.ok()
.flatten(),
None => None,
};
let source = session.as_ref().map(|(s, _)| s.clone());
let agent_id = frame
.as_ref()
.map(|(_, a)| a.clone())
.or_else(|| session.as_ref().map(|(_, a)| a.clone()));
let depth = frame.as_ref().map(|(d, _)| *d);
(source, agent_id, depth)
}
#[allow(clippy::too_many_arguments)]
fn spawn_insert(
registry: Arc<SqlitePool>,
owner: Option<Arc<SqlitePool>>,
request_id: Option<String>,
user_id: Option<String>,
session_id: Option<i64>,
stack_id: Option<i64>,
model_name: String,
error_text: Option<String>,
duration_ms: i64,
// (input, output, cache_read, cache_creation) — all None on HTTP failure.
usage: (Option<i64>, Option<i64>, Option<i64>, Option<i64>),
) {
tokio::spawn(async move {
// Attribution needs the owner's pool (chat_sessions lives there); a
// locked owner still gets the metadata row, only without attribution.
let (source, agent_id, depth) = match (owner.as_deref(), session_id) {
(Some(p), Some(sid)) => resolve_attribution(p, sid, stack_id).await,
_ => (None, None, None),
};
let (input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens) = usage;
if let Err(e) = llm_requests::insert(&registry, llm_requests::LlmRequestRow {
request_id,
user_id,
session_id,
stack_id,
model_name,
error_text,
input_tokens,
output_tokens,
duration_ms,
cache_read_tokens,
cache_creation_tokens,
source,
agent_id,
depth,
}).await {
warn!(error = %e, "llm_requests: failed to insert log row");
}
});
}
#[async_trait]
impl Model for LoggingModel {
async fn complete(
@@ -108,11 +193,12 @@ impl Model for LoggingModel {
let request_id = Some(req.request_id.clone());
let model_name = self.model_name.clone();
let pool = Arc::clone(&self.registry);
let owner = self.target.payloads.clone();
match &result {
Ok(resp) => {
let usage = resp.usage();
let (input_tokens, output_tokens, cache_read, cache_write) = (
let usage = (
usage.input_tokens.map(|n| n as i64),
usage.output_tokens.map(|n| n as i64),
usage.cache_read.map(|n| n as i64),
@@ -121,23 +207,8 @@ impl Model for LoggingModel {
if let Some(raw) = resp.raw() {
self.spawn_payload(&req.request_id, raw);
}
tokio::spawn(async move {
if let Err(e) = llm_requests::insert(&pool, llm_requests::LlmRequestRow {
request_id,
user_id,
session_id,
stack_id,
model_name,
error_text: None,
input_tokens,
output_tokens,
duration_ms,
cache_read_tokens: cache_read,
cache_creation_tokens: cache_write,
}).await {
warn!(error = %e, "llm_requests: failed to insert log row");
}
});
spawn_insert(pool, owner, request_id, user_id, session_id, stack_id,
model_name, None, duration_ms, usage);
}
Err(e) => {
// Only an HTTP failure carries a body (a provider 400 is exactly
@@ -145,24 +216,9 @@ impl Model for LoggingModel {
if let Some(raw) = e.raw.as_ref() {
self.spawn_payload(&req.request_id, raw);
}
let error_text = e.to_string();
tokio::spawn(async move {
if let Err(log_err) = llm_requests::insert(&pool, llm_requests::LlmRequestRow {
request_id,
user_id,
session_id,
stack_id,
model_name,
error_text: Some(error_text),
input_tokens: None,
output_tokens: None,
duration_ms,
cache_read_tokens: None,
cache_creation_tokens: None,
}).await {
warn!(error = %log_err, "llm_requests: failed to insert error log row");
}
});
spawn_insert(pool, owner, request_id, user_id, session_id, stack_id,
model_name, Some(e.to_string()), duration_ms,
(None, None, None, None));
}
}
@@ -240,6 +296,14 @@ mod tests {
let path = temp_db_path("llmlog-ok");
let pool = Arc::new(crate::db::init_system_pool(&path).await.unwrap());
// The attribution the row should carry: session 42 is a cron session
// whose agent is "assistant", frame 7 is a child frame of the
// "researcher" sub-agent — the frame's agent wins over the session's.
sqlx::query("INSERT INTO chat_sessions (id, source, agent_id) VALUES (42, 'cron', 'assistant')")
.execute(&*pool).await.unwrap();
sqlx::query("INSERT INTO chat_sessions_stack (id, session_id, agent_id, depth) VALUES (7, 42, 'researcher', 1)")
.execute(&*pool).await.unwrap();
let mut resp = ModelResponse::message("hi");
*resp.usage_mut() = Usage {
input_tokens: Some(11),
@@ -270,6 +334,13 @@ mod tests {
assert_eq!(model_name, "gpt-test");
assert_eq!((input, output), (Some(11), Some(7)));
let (source, agent_id, depth): (Option<String>, Option<String>, Option<i64>) =
sqlx::query_as("SELECT source, agent_id, depth FROM llm_requests WHERE request_id = 'req-1'")
.fetch_one(&*pool).await.unwrap();
assert_eq!(source.as_deref(), Some("cron"), "the dashboard breaks down on source");
assert_eq!(agent_id.as_deref(), Some("researcher"), "the frame's agent wins (sub-agent)");
assert_eq!(depth, Some(1));
wait_for(&pool, "SELECT COUNT(*) FROM llm_request_payloads").await;
let body: String = sqlx::query_scalar(
"SELECT request_json FROM llm_request_payloads WHERE request_id = 'req-1'")
+1 -1
View File
@@ -52,7 +52,7 @@ Two independent things have to be true, and both were violated at some point:
| `copilot-render.js` | (helpers) | `renderMsg`, `renderTool`, `renderDiff`, etc. — shared by copilot and chat-page |
| `sidebar.js` | `<app-sidebar>` | Nav sidebar; role-driven (`ui_mode`); inbox badge is **live** — the chat WS forwards the inbox lifecycle events (`approval_requested/resolved`, `clarification_*`, `elicitation_*`) regardless of `source`, `chat-session.js` re-dispatches them as the `inbox-changed` window event, and the sidebar (+ `agent-inbox.js`) refreshes on it; a 60 s poll remains as fallback |
| `topbar.js` | `<app-topbar>` | Top nav bar; per-user avatar color hashed from the username |
| `dashboard-page.js` | `<dashboard-page>` | `#dashboard` — status hero, LLM stats charts, pending inbox, quick guide |
| `dashboard-page.js` | `<dashboard-page>` | `#dashboard` — status hero, LLM stats (member-scope chips + spend breakdowns by member/kind/agent/model/provider over `llm_requests` attribution columns), pending inbox, quick guide |
| `shared/file-viewer-base.js` | `FileViewerBase` (base) | Shared file-viewer engine (fetch, kind detection, markdown/PDF/SVG/LaTeX/word-docs, watcher, `_renderBody`); driven by `_show`/`_hide`. Extended by desktop + mobile |
| `file-viewer-page.js` | `<file-viewer-page>` | Desktop file viewer: `FileViewerBase` + hash routing via `window.openFile(path)``#file_viewer?path=...` |
| `shared/file-viewer-mobile.js` | `<mobile-file-viewer-page>` | Mobile file viewer: `FileViewerBase` + prop-driven (`visible`/`path`), full-screen with back button |
+1 -1
View File
@@ -8,7 +8,7 @@
## The client layer (`crates/skald-core/src/llm/`)
LLM client abstraction (OpenAI-compat, Anthropic, Ollama…). OpenAI-compatible provider *types* are runtime data, not code: `providers/declared.rs` loads `providers.yaml` at boot (see Config in [../CLAUDE.md](../CLAUDE.md)); only non-OpenAI-compatible or bespoke providers (anthropic, ollama, openai, openrouter) stay native. **Retriability** (`Model::is_retriable`, `agent-loop`) keys on the real HTTP status carried by `ModelError { status }`, **not** a substring of the message — a model id/token count containing "404"/"401" cannot mis-classify; 401/403/404/422 don't retry, 400/429/5xx/network do. **Request logging** is the `logging.rs::LoggingModel` decorator, attached by the *caller's* `ModelSelector` (`loop_adapters/selector.rs::SkaldSelector::with_log`) — never by `LlmManager`, which builds one shared client per model and cannot know whose traffic it serves. The decorator's `RequestLogTarget` carries the owner: metadata → `llm_requests` in the registry (`user_id`, the column the UI filters on), payload bodies/headers → `llm_request_payloads` in that user's own encrypted DB, keyed by `request_id`; session + frame come from the request's own `conversation`/`frame`, so kernel rounds, sub-agent frames and compaction summaries are all attributed with no extra plumbing (`ModelRequest::log` is unused here)
LLM client abstraction (OpenAI-compat, Anthropic, Ollama…). OpenAI-compatible provider *types* are runtime data, not code: `providers/declared.rs` loads `providers.yaml` at boot (see Config in [../CLAUDE.md](../CLAUDE.md)); only non-OpenAI-compatible or bespoke providers (anthropic, ollama, openai, openrouter) stay native. **Retriability** (`Model::is_retriable`, `agent-loop`) keys on the real HTTP status carried by `ModelError { status }`, **not** a substring of the message — a model id/token count containing "404"/"401" cannot mis-classify; 401/403/404/422 don't retry, 400/429/5xx/network do. **Request logging** is the `logging.rs::LoggingModel` decorator, attached by the *caller's* `ModelSelector` (`loop_adapters/selector.rs::SkaldSelector::with_log`) — never by `LlmManager`, which builds one shared client per model and cannot know whose traffic it serves. The decorator's `RequestLogTarget` carries the owner: metadata → `llm_requests` in the registry (`user_id`, the column the UI filters on), payload bodies/headers → `llm_request_payloads` in that user's own encrypted DB, keyed by `request_id`; session + frame come from the request's own `conversation`/`frame`, so kernel rounds, sub-agent frames and compaction summaries are all attributed with no extra plumbing (`ModelRequest::log` is unused here). The metadata row also carries **denormalized attribution** for the dashboard's spend breakdowns — `source`, `agent_id`, `depth` — resolved by the spawned insert task from the owner's pool (`chat_sessions` + `chat_sessions_stack`; the frame row supplies the sub-agent's own id for child frames). That resolution is the only read the log path ever does, stays off the turn's hot path, and any failure degrades to NULLs — a missing attribution must never cost the row. Rows pre-dating the columns read as the stats' "older data" bucket.
## `providers.yaml` — two traps in the model metadata
+16 -6
View File
@@ -15,17 +15,27 @@ The status reflects the **whole instance**, not one person's account: there is o
## LLM usage stats
Four charts with a range switch (last hour / 24 hours / 7 days / 30 days):
The section answers two questions: *how much is the instance being used?* and *who or what is spending the tokens?* A range switch (last hour / 24 hours / 7 days / 30 days) applies to everything on it, and — on an instance with more than one member — a row of member chips filters **all** the charts to one person; **Everyone** is the whole instance, as before.
Three charts show the trend over the range:
- **Requests** — how many LLM calls per minute, hour or day.
- **Tokens** — the metered volume, split into input (split again into cached and non-cached) and output. The tooltip shows the cache-hit percentage: repeated context that was *cached* costs less and answers faster, so a high hit rate is good news, not a sign something is stuck.
- **Avg latency** — how long a model call took on average.
- **Models** — the top models by requests in the range.
Below them, **How the spend splits** breaks the range's billed tokens down into bars:
- **By member** — who consumed what (hidden while a member chip is selected: it would be a single bar).
- **By kind** — direct chats vs **sub-agents** (specialists the assistant delegates to) vs **scheduled tasks** and the other background system agents. Data from before this breakdown existed groups under *Older data*.
- **By agent** — which assistant or specialist consumed the most.
- **By model** and **By provider** — where the money actually goes.
A bar's tooltip shows the request count and the input/output/cached split.
Three honest answers to give with a straight face:
- **These numbers are everyone's, together.** The charts aggregate the whole instance; there is no per-person breakdown on this page.
- **They record how much, when and which model — never what was said.** The content of a request lives in the requester's own encrypted space; the charts read only counters.
- **They record how much, when, by whom and on which model — never what was said.** The content of a request lives in the requester's own encrypted space; the charts read only counters.
- **The per-member chips are a consumption view, not a surveillance tool.** They show token counts, not conversations.
- **Empty is normal on a new instance.** "No LLM requests in the selected range" means exactly that: nothing has run in that window.
## Pending
@@ -41,12 +51,12 @@ The same cards as the [Inbox](inbox.md) — approvals, questions and sign-in pro
- **Not a monitor.** Nothing here alerts anyone; it shows the present state to whoever is looking.
- **Not where models are configured.** Adding providers and models, and their priority order, is the admin's Models and Providers pages.
- **Not per-person.** No page on the instance shows "who used how much" — deliberately; usage is shared, like the models.
- **Not a message log.** The per-member and per-kind charts show volumes of tokens, never what anyone asked or was answered.
## Common questions
- *"It says Degraded — should I worry?"* — it means the model checks are not all passing. Individual chats may still work on a fallback model; if it persists, the admin checks the provider (its key, its quota) on the Models/Providers pages.
- *"Why are the bars so high at odd hours?"* — scheduled background work (system agents, cron tasks) uses the same models. The Tasks page and the system-agents page show what ran when.
- *"Why are the bars so high at odd hours?"* — scheduled background work (system agents, cron tasks) uses the same models. The **By kind** chart shows exactly how much of the spend is background work versus direct chats; the Tasks page and the system-agents page show what ran when.
- *"What is a token?"* — the unit LLM providers meter and bill by, roughly a word fragment. Input is what was sent (long history = more input; caching repeats cheaply), output is what was written back.
- *"Why doesn't my child see this page?"* — their role uses the simple interface: chat, inbox and projects only.
- *"Does the dashboard show what people asked?"* — no. Only counts, timings and model names; never content.
+1 -1
View File
@@ -12,7 +12,7 @@ This index will grow over time. Right now it covers the chat window, the inbox,
| --- | --- |
| [chat.md](chat.md) | The chat window: full-page vs docked, the tab bar and what lands where, the composer's controls, the slash commands, and what happens while an answer is being written |
| [inbox.md](inbox.md) | The Inbox: the three kinds of pending request, why background work asks here rather than in the chat, answering one (and the time-limited approvals), and why an unanswered card means a stopped job |
| [dashboard.md](dashboard.md) | The Dashboard: the instance status line, the LLM usage charts (everyone's together, counts never content), the pending-inbox section, and what the no-models banner means |
| [dashboard.md](dashboard.md) | The Dashboard: the instance status line, the LLM usage charts (filterable per member, with spend broken down by member, kind, agent, model and provider — counts never content), the pending-inbox section, and what the no-models banner means |
| [security-groups.md](security-groups.md) | Security groups: allow / ask / deny per tool, the shield in the chat, what the default group already permits, and how an admin edits the rules |
| [memory.md](memory.md) | Private and shared memory: what goes where, the indexes and history log, why some shared facts can't be changed on request |
| [agents.md](agents.md) | Agents: the three kinds (chat, task, system), which one you are talking to and why, the specialist agents the assistant delegates to, how the model is chosen, and adding a custom agent |
+151 -15
View File
@@ -22,6 +22,9 @@ pub enum StatsRange {
#[derive(Deserialize)]
pub struct StatsQuery {
pub range: Option<StatsRange>,
/// Scope filter: a `user_id` restricts every series and breakdown to that
/// member; absent = the whole instance.
pub user: Option<String>,
}
#[derive(Serialize)]
@@ -34,18 +37,40 @@ pub struct DailyStats {
pub avg_duration_ms: f64,
}
/// One row of a "how the spend splits" chart (by user, kind, agent, model,
/// provider). Bars are drawn on `total_tokens` — tokens are what the provider
/// bills — with the rest in the tooltip.
#[derive(Serialize)]
pub struct ModelStats {
pub model_name: String,
pub struct BreakdownRow {
pub key: String,
pub requests: i64,
pub input_tokens: i64,
pub output_tokens: i64,
pub cache_read_tokens: i64,
pub total_tokens: i64,
}
/// A selectable member for the scope chips (id + display label only).
#[derive(Serialize)]
pub struct MemberEntry {
pub id: String,
pub label: String,
}
#[derive(Serialize)]
pub struct LlmStatsResponse {
pub daily: Vec<DailyStats>,
pub models: Vec<ModelStats>,
pub members: Vec<MemberEntry>,
pub by_user: Vec<BreakdownRow>,
pub by_kind: Vec<BreakdownRow>,
pub by_agent: Vec<BreakdownRow>,
pub by_model: Vec<BreakdownRow>,
pub by_provider: Vec<BreakdownRow>,
}
// Every query shares the same two filters: the time window and the optional
// user scope (`? IS NULL OR user_id = ?`).
const SQL_DAILY_HOUR: &str =
"SELECT strftime('%H:%M', created_at, 'localtime') AS day,
COUNT(*) AS requests,
@@ -55,6 +80,7 @@ const SQL_DAILY_HOUR: &str =
AVG(duration_ms) AS avg_duration_ms
FROM llm_requests
WHERE created_at >= datetime('now', ?)
AND (? IS NULL OR user_id = ?)
GROUP BY strftime('%H:%M', created_at, 'localtime')
ORDER BY day ASC";
@@ -67,6 +93,7 @@ const SQL_DAILY_HOUR_BUCKET: &str =
AVG(duration_ms) AS avg_duration_ms
FROM llm_requests
WHERE created_at >= datetime('now', ?)
AND (? IS NULL OR user_id = ?)
GROUP BY strftime('%m-%d %H:00', created_at, 'localtime')
ORDER BY day ASC";
@@ -79,22 +106,121 @@ const SQL_DAILY_DATE: &str =
AVG(duration_ms) AS avg_duration_ms
FROM llm_requests
WHERE created_at >= datetime('now', ?)
AND (? IS NULL OR user_id = ?)
GROUP BY DATE(created_at, 'localtime')
ORDER BY day ASC";
const SQL_MODELS: &str =
"SELECT model_name, COUNT(*) AS requests
// The breakdown projections. The SELECT tail is identical for all of them —
// counts + token sums, grouped and ordered by billed volume.
const SQL_BY_USER: &str =
"SELECT COALESCE(user_id, '') AS key,
COUNT(*) AS requests,
COALESCE(SUM(input_tokens), 0) AS input_tokens,
COALESCE(SUM(output_tokens), 0) AS output_tokens,
COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens,
COALESCE(SUM(input_tokens), 0)
+ COALESCE(SUM(output_tokens), 0) AS total_tokens
FROM llm_requests
WHERE created_at >= datetime('now', ?)
GROUP BY model_name
ORDER BY requests DESC
LIMIT 6";
AND (? IS NULL OR user_id = ?)
GROUP BY user_id ORDER BY total_tokens DESC";
// "What consumed it": a child frame (depth > 0) is sub-agent work regardless
// of the session's source; otherwise the source itself (web / mobile /
// telegram / cron / a system agent's name). NULL = rows predating the column.
const SQL_BY_KIND: &str =
"SELECT CASE WHEN depth > 0 THEN 'sub-agent'
WHEN source IS NULL OR source = '' THEN 'unknown'
ELSE source END AS key,
COUNT(*) AS requests,
COALESCE(SUM(input_tokens), 0) AS input_tokens,
COALESCE(SUM(output_tokens), 0) AS output_tokens,
COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens,
COALESCE(SUM(input_tokens), 0)
+ COALESCE(SUM(output_tokens), 0) AS total_tokens
FROM llm_requests
WHERE created_at >= datetime('now', ?)
AND (? IS NULL OR user_id = ?)
GROUP BY key ORDER BY total_tokens DESC";
const SQL_BY_AGENT: &str =
"SELECT COALESCE(NULLIF(agent_id, ''), 'unknown') AS key,
COUNT(*) AS requests,
COALESCE(SUM(input_tokens), 0) AS input_tokens,
COALESCE(SUM(output_tokens), 0) AS output_tokens,
COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens,
COALESCE(SUM(input_tokens), 0)
+ COALESCE(SUM(output_tokens), 0) AS total_tokens
FROM llm_requests
WHERE created_at >= datetime('now', ?)
AND (? IS NULL OR user_id = ?)
GROUP BY agent_id ORDER BY total_tokens DESC LIMIT 10";
const SQL_BY_MODEL: &str =
"SELECT model_name AS key,
COUNT(*) AS requests,
COALESCE(SUM(input_tokens), 0) AS input_tokens,
COALESCE(SUM(output_tokens), 0) AS output_tokens,
COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens,
COALESCE(SUM(input_tokens), 0)
+ COALESCE(SUM(output_tokens), 0) AS total_tokens
FROM llm_requests
WHERE created_at >= datetime('now', ?)
AND (? IS NULL OR user_id = ?)
GROUP BY model_name ORDER BY total_tokens DESC LIMIT 10";
// Provider is resolved through the model row. No `removed_at` filter on
// purpose: models are soft-deleted precisely so telemetry keeps resolving.
const SQL_BY_PROVIDER: &str =
"SELECT COALESCE(p.name, 'other') AS key,
COUNT(*) AS requests,
COALESCE(SUM(r.input_tokens), 0) AS input_tokens,
COALESCE(SUM(r.output_tokens), 0) AS output_tokens,
COALESCE(SUM(r.cache_read_tokens), 0) AS cache_read_tokens,
COALESCE(SUM(r.input_tokens), 0)
+ COALESCE(SUM(r.output_tokens), 0) AS total_tokens
FROM llm_requests r
LEFT JOIN llm_models m ON m.name = r.model_name
LEFT JOIN llm_providers p ON p.id = m.provider_id
WHERE r.created_at >= datetime('now', ?)
AND (? IS NULL OR r.user_id = ?)
GROUP BY p.name ORDER BY total_tokens DESC";
const SQL_MEMBERS: &str =
"SELECT id, COALESCE(NULLIF(display_name, ''), username) AS label
FROM users
WHERE active = 1
ORDER BY label COLLATE NOCASE ASC";
type DailyRow = (String, i64, i64, i64, i64, f64);
type BreakdownSql = (String, i64, i64, i64, i64, i64);
async fn run_breakdown(
pool: &sqlx::SqlitePool,
sql: &'static str,
window: &str,
user: Option<&str>,
) -> Result<Vec<BreakdownRow>, sqlx::Error> {
let rows = sqlx::query_as::<_, BreakdownSql>(sql)
.bind(window)
.bind(user).bind(user)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|(key, requests, input_tokens, output_tokens, cache_read_tokens, total_tokens)| {
BreakdownRow { key, requests, input_tokens, output_tokens, cache_read_tokens, total_tokens }
})
.collect())
}
pub async fn llm_stats(
State(skald): State<Arc<Skald>>,
Query(params): Query<StatsQuery>,
) -> Result<impl IntoResponse, ApiError> {
let range = params.range.unwrap_or_default();
let user = params.user.filter(|u| !u.is_empty());
let (window, daily_sql) = match range {
StatsRange::Hour => ("-60 minutes", SQL_DAILY_HOUR),
@@ -103,9 +229,12 @@ pub async fn llm_stats(
StatsRange::Month => ("-30 days", SQL_DAILY_DATE),
};
let daily = sqlx::query_as::<_, (String, i64, i64, i64, i64, f64)>(daily_sql)
let db = &**skald.db();
let daily = sqlx::query_as::<_, DailyRow>(daily_sql)
.bind(window)
.fetch_all(&**skald.db())
.bind(user.as_deref()).bind(user.as_deref())
.fetch_all(db)
.await?
.into_iter()
.map(|(day, requests, input_tokens, output_tokens, cache_read_tokens, avg_duration_ms)| {
@@ -113,13 +242,20 @@ pub async fn llm_stats(
})
.collect::<Vec<_>>();
let models = sqlx::query_as::<_, (String, i64)>(SQL_MODELS)
.bind(window)
.fetch_all(&**skald.db())
let members = sqlx::query_as::<_, (String, String)>(SQL_MEMBERS)
.fetch_all(db)
.await?
.into_iter()
.map(|(model_name, requests)| ModelStats { model_name, requests })
.map(|(id, label)| MemberEntry { id, label })
.collect::<Vec<_>>();
Ok(Json(LlmStatsResponse { daily, models }))
let (by_user, by_kind, by_agent, by_model, by_provider) = tokio::try_join!(
run_breakdown(db, SQL_BY_USER, window, user.as_deref()),
run_breakdown(db, SQL_BY_KIND, window, user.as_deref()),
run_breakdown(db, SQL_BY_AGENT, window, user.as_deref()),
run_breakdown(db, SQL_BY_MODEL, window, user.as_deref()),
run_breakdown(db, SQL_BY_PROVIDER, window, user.as_deref()),
)?;
Ok(Json(LlmStatsResponse { daily, members, by_user, by_kind, by_agent, by_model, by_provider }))
}
+144 -14
View File
@@ -3,6 +3,38 @@ import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
import { InboxMixin } from '../lib/inbox-mixin.js';
// Shared palette for the breakdown bars (cycled when there are more rows).
const PALETTE = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#06b6d4'];
// The `kind` values that have a proper label (the server sends the session's
// source, or 'sub-agent' for child frames); anything else renders as-is, so a
// new source needs no frontend change to appear.
const KIND_LABELS = {
'web': 'dashboard.stats.kind.web',
'mobile': 'dashboard.stats.kind.mobile',
'telegram': 'dashboard.stats.kind.telegram',
'cron': 'dashboard.stats.kind.cron',
'sub-agent': 'dashboard.stats.kind.sub_agent',
'event-triage': 'dashboard.stats.kind.event_triage',
'memory-lint': 'dashboard.stats.kind.memory_lint',
'conversation-review': 'dashboard.stats.kind.conversation_review',
'unknown': 'dashboard.stats.kind.unknown',
};
function fmtTok(n) {
if (n == null) return '0';
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
return String(n);
}
function kindLabel(key) {
const i18nKey = KIND_LABELS[key];
if (!i18nKey) return key;
const label = t(i18nKey);
return label === i18nKey ? key : label;
}
export class DashboardPage extends InboxMixin(LightElement) {
static get properties() {
@@ -13,6 +45,7 @@ export class DashboardPage extends InboxMixin(LightElement) {
_plugins: { state: true },
_stats: { state: true },
_statsRange: { state: true },
_statsUser: { state: true },
};
}
@@ -24,6 +57,9 @@ export class DashboardPage extends InboxMixin(LightElement) {
this._pollTimer = null;
this._stats = null; // null = loading
this._statsRange = 'week';
this._statsUser = ''; // '' = whole instance
this._members = []; // cached out of the stats response: the chips
// must survive a reload that filters them away
this._chartInstances = {};
this._statsTimer = null;
}
@@ -100,11 +136,16 @@ export class DashboardPage extends InboxMixin(LightElement) {
async _loadStats() {
try {
const res = await fetch(`/api/stats/llm?range=${this._statsRange}`);
const params = new URLSearchParams({ range: this._statsRange });
if (this._statsUser) params.set('user', this._statsUser);
const res = await fetch(`/api/stats/llm?${params}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
this._stats = await res.json();
if (Array.isArray(this._stats.members) && this._stats.members.length) {
this._members = this._stats.members;
}
} catch {
this._stats = { daily: [], models: [] };
this._stats = { daily: [], by_user: [], by_kind: [], by_agent: [], by_model: [], by_provider: [] };
}
}
@@ -115,6 +156,13 @@ export class DashboardPage extends InboxMixin(LightElement) {
await this._loadStats();
}
async _setUser(user) {
if (user === this._statsUser) return;
this._statsUser = user;
this._stats = null;
await this._loadStats();
}
get _honchoActive() {
return this._plugins?.some(p => p.id === 'honcho' && p.enabled && p.running) ?? false;
}
@@ -161,6 +209,39 @@ export class DashboardPage extends InboxMixin(LightElement) {
.replace(/-\d{8}$/, '');
}
_memberLabel(key) {
if (!key) return t('dashboard.stats.user_unknown');
const m = this._members.find(m => m.id === key);
return m ? m.label : key;
}
// The breakdown cards to show for the current data, in display order. Both
// the render and the chart init consume this, so they can never disagree
// about which canvases exist. The per-member card is pointless — and empty
// by construction — when the scope is already a single member.
get _breakdownSpecs() {
const s = this._stats;
if (!s) return [];
return [
!this._statsUser && { id: 'chart-by-user', title: t('dashboard.stats.by_user'), rows: s.by_user ?? [] },
{ id: 'chart-by-kind', title: t('dashboard.stats.by_kind'), rows: s.by_kind ?? [] },
{ id: 'chart-by-agent', title: t('dashboard.stats.by_agent'), rows: s.by_agent ?? [] },
{ id: 'chart-by-model', title: t('dashboard.stats.by_model'), rows: s.by_model ?? [] },
{ id: 'chart-by-provider', title: t('dashboard.stats.by_provider'), rows: s.by_provider ?? [] },
]
.filter(Boolean)
.filter(c => c.rows.length > 0)
.map(c => ({
...c,
labelFn: c.id === 'chart-by-user' ? k => this._memberLabel(k)
: c.id === 'chart-by-kind' ? k => kindLabel(k)
: c.id === 'chart-by-model' ? k => this._shortModelName(k)
: k => k,
// The axis may carry a shortened label; the tooltip shows the key itself.
titleFn: c.id === 'chart-by-model' ? k => k : null,
}));
}
get _periodLabel() {
return { hour: t('dashboard.stats.per_min'), day: t('dashboard.stats.per_hour'), week: t('dashboard.stats.per_day'), month: t('dashboard.stats.per_day') }[this._statsRange] ?? t('dashboard.stats.per_day');
}
@@ -212,7 +293,6 @@ export class DashboardPage extends InboxMixin(LightElement) {
const cache = filled.map(d => d.cache_read_tokens);
// null for empty slots so the latency line doesn't touch zero where there were no requests
const lat = filled.map(d => d.requests > 0 ? Math.round(d.avg_duration_ms) : null);
const models = this._stats.models;
const axisDefaults = () => ({
ticks: { color: textColor, font: { size: 11 } },
@@ -322,38 +402,76 @@ export class DashboardPage extends InboxMixin(LightElement) {
options: baseOpts(),
});
// Models — always horizontal bar
const c4 = get('chart-models');
if (c4) this._chartInstances.models = new Chart(c4, {
// Breakdowns — "how the spend splits": horizontal bars on billed tokens,
// requests and the input/output/cache split in the tooltip.
for (const spec of this._breakdownSpecs) {
const c = get(spec.id);
if (!c) continue;
this._chartInstances[spec.id] = new Chart(c, {
type: 'bar',
data: {
labels: models.map(m => this._shortModelName(m.model_name)),
labels: spec.rows.map(r => spec.labelFn(r.key)),
datasets: [{
data: models.map(m => m.requests),
backgroundColor: ['#3b82f6','#10b981','#f59e0b','#8b5cf6','#ef4444','#06b6d4'],
data: spec.rows.map(r => r.total_tokens),
backgroundColor: spec.rows.map((_, i) => PALETTE[i % PALETTE.length]),
borderRadius: 4,
borderSkipped: false,
}],
},
options: {
...baseOpts(),
...baseOpts({
tooltip: {
callbacks: {
title: items => {
const r = spec.rows[items[0]?.dataIndex];
return r ? (spec.titleFn ?? spec.labelFn)(r.key) : '';
},
label: item => {
const r = spec.rows[item.dataIndex];
if (!r) return '';
return [
`${fmtTok(r.total_tokens)} tokens · ${r.requests} ${t('dashboard.stats.tip.requests')}`,
`${t('dashboard.stats.chart.input')}: ${fmtTok(r.input_tokens)} · ${t('dashboard.stats.chart.output')}: ${fmtTok(r.output_tokens)} · ${t('dashboard.stats.chart.cached')}: ${fmtTok(r.cache_read_tokens)}`,
];
},
},
},
}),
indexAxis: 'y',
scales: {
x: { ...axisDefaults(), beginAtZero: true },
x: { ...axisDefaults(), beginAtZero: true,
ticks: { color: textColor, font: { size: 10 }, callback: v => fmtTok(v) } },
y: { ...axisDefaults(), ticks: { color: textColor, font: { size: 10 } } },
},
},
});
}
}
// ── Render ────────────────────────────────────────────────────────────────
_renderScopeChips() {
if (this._members.length < 2) return nothing;
return html`
<div class="home-stats-scope">
<button class="home-stats-range-btn ${this._statsUser === '' ? 'active' : ''}"
@click=${() => this._setUser('')}>${t('dashboard.stats.scope.all')}</button>
${this._members.map(m => html`
<button class="home-stats-range-btn ${this._statsUser === m.id ? 'active' : ''}"
@click=${() => this._setUser(m.id)}>${m.label}</button>
`)}
</div>
`;
}
_renderStats() {
if (this._stats === null) {
return html`<div class="home-stats-loading"><i class="bi bi-hourglass-split"></i> ${t('dashboard.stats.loading')}</div>`;
}
const empty = this._stats.daily.length === 0 && this._stats.models.length === 0;
const empty = this._stats.daily.length === 0
&& !(this._stats.by_user ?? []).length
&& !(this._stats.by_model ?? []).length;
if (empty) {
return html`
<div class="home-stats-empty">
@@ -363,6 +481,8 @@ export class DashboardPage extends InboxMixin(LightElement) {
`;
}
const breakdown = this._breakdownSpecs;
return html`
<div class="home-stats-grid">
<div class="home-stat-card">
@@ -377,11 +497,20 @@ export class DashboardPage extends InboxMixin(LightElement) {
<div class="home-stat-card-title">${t('dashboard.stats.latency')}</div>
<div class="home-stat-canvas-wrap"><canvas id="chart-latency"></canvas></div>
</div>
</div>
${breakdown.length ? html`
<div class="home-stats-sub">${t('dashboard.stats.sub.breakdown')}</div>
<div class="home-stats-grid">
${breakdown.map(c => html`
<div class="home-stat-card">
<div class="home-stat-card-title">${t('dashboard.stats.models')}</div>
<div class="home-stat-canvas-wrap"><canvas id="chart-models"></canvas></div>
<div class="home-stat-card-title">${c.title}</div>
<div class="home-stat-canvas-wrap" style="height:${Math.max(140, c.rows.length * 30)}px">
<canvas id=${c.id}></canvas>
</div>
</div>
`)}
</div>
` : nothing}
`;
}
@@ -437,6 +566,7 @@ export class DashboardPage extends InboxMixin(LightElement) {
`)}
</div>
</div>
${this._renderScopeChips()}
${this._renderStats()}
<!-- ── Pending inbox ── -->
+19 -1
View File
@@ -312,6 +312,24 @@ dashboard-page {
color: #fff;
}
/* Scope chips (member filter) — same pill look as the range buttons. */
.home-stats-scope {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: -0.25rem 0 1rem;
}
/* Subsection label above the breakdown grid. */
.home-stats-sub {
font-size: 0.78rem;
font-weight: 600;
color: var(--bs-secondary-color);
text-transform: uppercase;
letter-spacing: 0.04em;
margin: 0.25rem 0 0.75rem;
}
.home-stats-loading,
.home-stats-empty {
display: flex;
@@ -325,7 +343,7 @@ dashboard-page {
.home-stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
+20 -1
View File
@@ -419,7 +419,6 @@ export default {
'dashboard.stats.requests': 'Requests {per}',
'dashboard.stats.tokens': 'Tokens {per}',
'dashboard.stats.latency': 'Avg latency (ms)',
'dashboard.stats.models': 'Models',
'dashboard.stats.per_min': '/ min',
'dashboard.stats.per_hour': '/ hour',
'dashboard.stats.per_day': '/ day',
@@ -435,6 +434,26 @@ export default {
'dashboard.stats.chart.non_cached': 'Non-cached',
'dashboard.stats.chart.cache_hit': 'Cache hit: {pct}%',
'dashboard.stats.scope.all': 'Everyone',
'dashboard.stats.sub.breakdown': 'How the spend splits in the selected range',
'dashboard.stats.by_user': 'By member',
'dashboard.stats.by_kind': 'By kind',
'dashboard.stats.by_agent': 'By agent',
'dashboard.stats.by_model': 'By model',
'dashboard.stats.by_provider': 'By provider',
'dashboard.stats.user_unknown': 'Unknown',
'dashboard.stats.tip.requests': 'requests',
'dashboard.stats.kind.web': 'Chat',
'dashboard.stats.kind.mobile': 'Mobile',
'dashboard.stats.kind.telegram': 'Telegram',
'dashboard.stats.kind.cron': 'Scheduled tasks',
'dashboard.stats.kind.sub_agent': 'Sub-agents',
'dashboard.stats.kind.event_triage': 'Event triage',
'dashboard.stats.kind.memory_lint': 'Memory lint',
'dashboard.stats.kind.conversation_review': 'Conversation review',
'dashboard.stats.kind.unknown': 'Older data',
'dashboard.hero.subtitle': 'Your AI command centre — research, code, plan, and orchestrate. All in one place.',
'dashboard.banner.no_models.title': 'No LLM models configured.',
+20 -1
View File
@@ -416,7 +416,6 @@ export default {
'dashboard.stats.requests': 'Requêtes {per}',
'dashboard.stats.tokens': 'Tokens {per}',
'dashboard.stats.latency': 'Latence moyenne (ms)',
'dashboard.stats.models': 'Modèles',
'dashboard.stats.per_min': '/ min',
'dashboard.stats.per_hour': '/ heure',
'dashboard.stats.per_day': '/ jour',
@@ -432,6 +431,26 @@ export default {
'dashboard.stats.chart.non_cached': 'Non en cache',
'dashboard.stats.chart.cache_hit': 'Cache hit : {pct}%',
'dashboard.stats.scope.all': 'Tous',
'dashboard.stats.sub.breakdown': 'Répartition des dépenses sur la période sélectionnée',
'dashboard.stats.by_user': 'Par membre',
'dashboard.stats.by_kind': 'Par type',
'dashboard.stats.by_agent': 'Par agent',
'dashboard.stats.by_model': 'Par modèle',
'dashboard.stats.by_provider': 'Par fournisseur',
'dashboard.stats.user_unknown': 'Inconnu',
'dashboard.stats.tip.requests': 'requêtes',
'dashboard.stats.kind.web': 'Chat',
'dashboard.stats.kind.mobile': 'Mobile',
'dashboard.stats.kind.telegram': 'Telegram',
'dashboard.stats.kind.cron': 'Tâches planifiées',
'dashboard.stats.kind.sub_agent': 'Sous-agents',
'dashboard.stats.kind.event_triage': 'Triage des événements',
'dashboard.stats.kind.memory_lint': 'Lint mémoire',
'dashboard.stats.kind.conversation_review': 'Revue des conversations',
'dashboard.stats.kind.unknown': 'Données anciennes',
'dashboard.hero.subtitle': 'Votre centre de commande IA — recherche, code, planification et orchestration. Tout en un seul endroit.',
'dashboard.banner.no_models.title': 'Aucun modèle LLM configuré.',
+20 -1
View File
@@ -416,7 +416,6 @@ export default {
'dashboard.stats.requests': 'Richieste {per}',
'dashboard.stats.tokens': 'Token {per}',
'dashboard.stats.latency': 'Latenza media (ms)',
'dashboard.stats.models': 'Modelli',
'dashboard.stats.per_min': '/ min',
'dashboard.stats.per_hour': '/ h',
'dashboard.stats.per_day': '/ giorno',
@@ -432,6 +431,26 @@ export default {
'dashboard.stats.chart.non_cached': 'Non in cache',
'dashboard.stats.chart.cache_hit': 'Cache hit: {pct}%',
'dashboard.stats.scope.all': 'Tutti',
'dashboard.stats.sub.breakdown': 'Come si distribuisce la spesa nel periodo selezionato',
'dashboard.stats.by_user': 'Per membro',
'dashboard.stats.by_kind': 'Per tipo',
'dashboard.stats.by_agent': 'Per agente',
'dashboard.stats.by_model': 'Per modello',
'dashboard.stats.by_provider': 'Per provider',
'dashboard.stats.user_unknown': 'Sconosciuto',
'dashboard.stats.tip.requests': 'richieste',
'dashboard.stats.kind.web': 'Chat',
'dashboard.stats.kind.mobile': 'Mobile',
'dashboard.stats.kind.telegram': 'Telegram',
'dashboard.stats.kind.cron': 'Attività pianificate',
'dashboard.stats.kind.sub_agent': 'Sub-agent',
'dashboard.stats.kind.event_triage': 'Triage eventi',
'dashboard.stats.kind.memory_lint': 'Lint memoria',
'dashboard.stats.kind.conversation_review': 'Revisione conversazioni',
'dashboard.stats.kind.unknown': 'Dati precedenti',
'dashboard.hero.subtitle': 'Il tuo centro di comando AI — ricerca, codice, pianificazione e orchestrazione. Tutto in un unico posto.',
'dashboard.banner.no_models.title': 'Nessun modello LLM configurato.',