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 ### 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`): - 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 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 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). //! of the traffic is known — `user_id` is what the UI filters on).
//! Payloads (request/response bodies + headers) live in `llm_request_payloads` //! Payloads (request/response bodies + headers) live in `llm_request_payloads`
//! in the owner bucket (`{userid}.db`), correlated by `request_id`. //! 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 anyhow::Result;
use sqlx::SqlitePool; use sqlx::SqlitePool;
@@ -31,6 +32,13 @@ pub struct LlmRequestRow {
pub cache_read_tokens: Option<i64>, pub cache_read_tokens: Option<i64>,
/// Tokens written into the provider's prompt cache (Anthropic only). /// Tokens written into the provider's prompt cache (Anthropic only).
pub cache_creation_tokens: Option<i64>, 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 ──────────────────────────────────────────────────────────────────── // ── Writes ────────────────────────────────────────────────────────────────────
@@ -40,8 +48,9 @@ pub async fn insert(pool: &SqlitePool, row: LlmRequestRow) -> Result<i64> {
"INSERT INTO llm_requests ( "INSERT INTO llm_requests (
request_id, user_id, session_id, stack_id, model_name, request_id, user_id, session_id, stack_id, model_name,
error_text, input_tokens, output_tokens, duration_ms, error_text, input_tokens, output_tokens, duration_ms,
cache_read_tokens, cache_creation_tokens cache_read_tokens, cache_creation_tokens,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) source, agent_id, depth
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id", RETURNING id",
) )
.bind(&row.request_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.duration_ms)
.bind(row.cache_read_tokens) .bind(row.cache_read_tokens)
.bind(row.cache_creation_tokens) .bind(row.cache_creation_tokens)
.bind(&row.source)
.bind(&row.agent_id)
.bind(row.depth)
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;
+10
View File
@@ -421,6 +421,16 @@ pub(crate) async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
.execute(pool) .execute(pool)
.await?; .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 // User directory + auth material. Read before every login, so it lives in
// the registry — which means it must never hold anything that derives a // 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 // 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, //! * a **metadata-only** row in `llm_requests` (`system.db`) — cost, tokens,
//! timing, plus the correlation the UI filters on (`user_id`, `session_id`, //! 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 //! * the **payload** (request/response bodies + headers) in
//! `llm_request_payloads` in the caller's own database, keyed by the same //! `llm_request_payloads` in the caller's own database, keyed by the same
//! `request_id`. //! `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] #[async_trait]
impl Model for LoggingModel { impl Model for LoggingModel {
async fn complete( async fn complete(
@@ -108,11 +193,12 @@ impl Model for LoggingModel {
let request_id = Some(req.request_id.clone()); let request_id = Some(req.request_id.clone());
let model_name = self.model_name.clone(); let model_name = self.model_name.clone();
let pool = Arc::clone(&self.registry); let pool = Arc::clone(&self.registry);
let owner = self.target.payloads.clone();
match &result { match &result {
Ok(resp) => { Ok(resp) => {
let usage = resp.usage(); let usage = resp.usage();
let (input_tokens, output_tokens, cache_read, cache_write) = ( let usage = (
usage.input_tokens.map(|n| n as i64), usage.input_tokens.map(|n| n as i64),
usage.output_tokens.map(|n| n as i64), usage.output_tokens.map(|n| n as i64),
usage.cache_read.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() { if let Some(raw) = resp.raw() {
self.spawn_payload(&req.request_id, raw); self.spawn_payload(&req.request_id, raw);
} }
tokio::spawn(async move { spawn_insert(pool, owner, request_id, user_id, session_id, stack_id,
if let Err(e) = llm_requests::insert(&pool, llm_requests::LlmRequestRow { model_name, None, duration_ms, usage);
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");
}
});
} }
Err(e) => { Err(e) => {
// Only an HTTP failure carries a body (a provider 400 is exactly // 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() { if let Some(raw) = e.raw.as_ref() {
self.spawn_payload(&req.request_id, raw); self.spawn_payload(&req.request_id, raw);
} }
let error_text = e.to_string(); spawn_insert(pool, owner, request_id, user_id, session_id, stack_id,
tokio::spawn(async move { model_name, Some(e.to_string()), duration_ms,
if let Err(log_err) = llm_requests::insert(&pool, llm_requests::LlmRequestRow { (None, None, None, None));
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");
}
});
} }
} }
@@ -240,6 +296,14 @@ mod tests {
let path = temp_db_path("llmlog-ok"); let path = temp_db_path("llmlog-ok");
let pool = Arc::new(crate::db::init_system_pool(&path).await.unwrap()); 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"); let mut resp = ModelResponse::message("hi");
*resp.usage_mut() = Usage { *resp.usage_mut() = Usage {
input_tokens: Some(11), input_tokens: Some(11),
@@ -270,6 +334,13 @@ mod tests {
assert_eq!(model_name, "gpt-test"); assert_eq!(model_name, "gpt-test");
assert_eq!((input, output), (Some(11), Some(7))); 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; wait_for(&pool, "SELECT COUNT(*) FROM llm_request_payloads").await;
let body: String = sqlx::query_scalar( let body: String = sqlx::query_scalar(
"SELECT request_json FROM llm_request_payloads WHERE request_id = 'req-1'") "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 | | `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 | | `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 | | `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 | | `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=...` | | `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 | | `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/`) ## 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 ## `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 ## 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. - **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. - **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. - **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: 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, 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.
- **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. - **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. - **Empty is normal on a new instance.** "No LLM requests in the selected range" means exactly that: nothing has run in that window.
## Pending ## 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 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 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 ## 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. - *"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. - *"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. - *"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. - *"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 | | [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 | | [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 | | [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 | | [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 | | [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 |
+153 -17
View File
@@ -22,6 +22,9 @@ pub enum StatsRange {
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct StatsQuery { pub struct StatsQuery {
pub range: Option<StatsRange>, 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)] #[derive(Serialize)]
@@ -34,18 +37,40 @@ pub struct DailyStats {
pub avg_duration_ms: f64, 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)] #[derive(Serialize)]
pub struct ModelStats { pub struct BreakdownRow {
pub model_name: String, pub key: String,
pub requests: i64, 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)] #[derive(Serialize)]
pub struct LlmStatsResponse { pub struct LlmStatsResponse {
pub daily: Vec<DailyStats>, 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 = const SQL_DAILY_HOUR: &str =
"SELECT strftime('%H:%M', created_at, 'localtime') AS day, "SELECT strftime('%H:%M', created_at, 'localtime') AS day,
COUNT(*) AS requests, COUNT(*) AS requests,
@@ -55,6 +80,7 @@ const SQL_DAILY_HOUR: &str =
AVG(duration_ms) AS avg_duration_ms AVG(duration_ms) AS avg_duration_ms
FROM llm_requests FROM llm_requests
WHERE created_at >= datetime('now', ?) WHERE created_at >= datetime('now', ?)
AND (? IS NULL OR user_id = ?)
GROUP BY strftime('%H:%M', created_at, 'localtime') GROUP BY strftime('%H:%M', created_at, 'localtime')
ORDER BY day ASC"; ORDER BY day ASC";
@@ -67,6 +93,7 @@ const SQL_DAILY_HOUR_BUCKET: &str =
AVG(duration_ms) AS avg_duration_ms AVG(duration_ms) AS avg_duration_ms
FROM llm_requests FROM llm_requests
WHERE created_at >= datetime('now', ?) WHERE created_at >= datetime('now', ?)
AND (? IS NULL OR user_id = ?)
GROUP BY strftime('%m-%d %H:00', created_at, 'localtime') GROUP BY strftime('%m-%d %H:00', created_at, 'localtime')
ORDER BY day ASC"; ORDER BY day ASC";
@@ -79,22 +106,121 @@ const SQL_DAILY_DATE: &str =
AVG(duration_ms) AS avg_duration_ms AVG(duration_ms) AS avg_duration_ms
FROM llm_requests FROM llm_requests
WHERE created_at >= datetime('now', ?) WHERE created_at >= datetime('now', ?)
AND (? IS NULL OR user_id = ?)
GROUP BY DATE(created_at, 'localtime') GROUP BY DATE(created_at, 'localtime')
ORDER BY day ASC"; ORDER BY day ASC";
const SQL_MODELS: &str = // The breakdown projections. The SELECT tail is identical for all of them —
"SELECT model_name, COUNT(*) AS requests // 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 FROM llm_requests
WHERE created_at >= datetime('now', ?) WHERE created_at >= datetime('now', ?)
GROUP BY model_name AND (? IS NULL OR user_id = ?)
ORDER BY requests DESC GROUP BY user_id ORDER BY total_tokens DESC";
LIMIT 6";
// "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( pub async fn llm_stats(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Query(params): Query<StatsQuery>, Query(params): Query<StatsQuery>,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
let range = params.range.unwrap_or_default(); let range = params.range.unwrap_or_default();
let user = params.user.filter(|u| !u.is_empty());
let (window, daily_sql) = match range { let (window, daily_sql) = match range {
StatsRange::Hour => ("-60 minutes", SQL_DAILY_HOUR), StatsRange::Hour => ("-60 minutes", SQL_DAILY_HOUR),
@@ -103,9 +229,12 @@ pub async fn llm_stats(
StatsRange::Month => ("-30 days", SQL_DAILY_DATE), 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) .bind(window)
.fetch_all(&**skald.db()) .bind(user.as_deref()).bind(user.as_deref())
.fetch_all(db)
.await? .await?
.into_iter() .into_iter()
.map(|(day, requests, input_tokens, output_tokens, cache_read_tokens, avg_duration_ms)| { .map(|(day, requests, input_tokens, output_tokens, cache_read_tokens, avg_duration_ms)| {
@@ -113,13 +242,20 @@ pub async fn llm_stats(
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let models = sqlx::query_as::<_, (String, i64)>(SQL_MODELS) let members = sqlx::query_as::<_, (String, String)>(SQL_MEMBERS)
.bind(window) .fetch_all(db)
.fetch_all(&**skald.db())
.await? .await?
.into_iter() .into_iter()
.map(|(model_name, requests)| ModelStats { model_name, requests }) .map(|(id, label)| MemberEntry { id, label })
.collect::<Vec<_>>(); .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 }))
} }
+159 -29
View File
@@ -3,6 +3,38 @@ import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js'; import { t } from '../lib/i18n.js';
import { InboxMixin } from '../lib/inbox-mixin.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) { export class DashboardPage extends InboxMixin(LightElement) {
static get properties() { static get properties() {
@@ -13,6 +45,7 @@ export class DashboardPage extends InboxMixin(LightElement) {
_plugins: { state: true }, _plugins: { state: true },
_stats: { state: true }, _stats: { state: true },
_statsRange: { state: true }, _statsRange: { state: true },
_statsUser: { state: true },
}; };
} }
@@ -24,6 +57,9 @@ export class DashboardPage extends InboxMixin(LightElement) {
this._pollTimer = null; this._pollTimer = null;
this._stats = null; // null = loading this._stats = null; // null = loading
this._statsRange = 'week'; 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._chartInstances = {};
this._statsTimer = null; this._statsTimer = null;
} }
@@ -100,11 +136,16 @@ export class DashboardPage extends InboxMixin(LightElement) {
async _loadStats() { async _loadStats() {
try { 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}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
this._stats = await res.json(); this._stats = await res.json();
if (Array.isArray(this._stats.members) && this._stats.members.length) {
this._members = this._stats.members;
}
} catch { } 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(); await this._loadStats();
} }
async _setUser(user) {
if (user === this._statsUser) return;
this._statsUser = user;
this._stats = null;
await this._loadStats();
}
get _honchoActive() { get _honchoActive() {
return this._plugins?.some(p => p.id === 'honcho' && p.enabled && p.running) ?? false; 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}$/, ''); .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() { 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'); 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); 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 // 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 lat = filled.map(d => d.requests > 0 ? Math.round(d.avg_duration_ms) : null);
const models = this._stats.models;
const axisDefaults = () => ({ const axisDefaults = () => ({
ticks: { color: textColor, font: { size: 11 } }, ticks: { color: textColor, font: { size: 11 } },
@@ -322,38 +402,76 @@ export class DashboardPage extends InboxMixin(LightElement) {
options: baseOpts(), options: baseOpts(),
}); });
// Models — always horizontal bar // Breakdowns — "how the spend splits": horizontal bars on billed tokens,
const c4 = get('chart-models'); // requests and the input/output/cache split in the tooltip.
if (c4) this._chartInstances.models = new Chart(c4, { for (const spec of this._breakdownSpecs) {
type: 'bar', const c = get(spec.id);
data: { if (!c) continue;
labels: models.map(m => this._shortModelName(m.model_name)), this._chartInstances[spec.id] = new Chart(c, {
datasets: [{ type: 'bar',
data: models.map(m => m.requests), data: {
backgroundColor: ['#3b82f6','#10b981','#f59e0b','#8b5cf6','#ef4444','#06b6d4'], labels: spec.rows.map(r => spec.labelFn(r.key)),
borderRadius: 4, datasets: [{
borderSkipped: false, data: spec.rows.map(r => r.total_tokens),
}], backgroundColor: spec.rows.map((_, i) => PALETTE[i % PALETTE.length]),
}, borderRadius: 4,
options: { borderSkipped: false,
...baseOpts(), }],
indexAxis: 'y',
scales: {
x: { ...axisDefaults(), beginAtZero: true },
y: { ...axisDefaults(), ticks: { color: textColor, font: { size: 10 } } },
}, },
}, options: {
}); ...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,
ticks: { color: textColor, font: { size: 10 }, callback: v => fmtTok(v) } },
y: { ...axisDefaults(), ticks: { color: textColor, font: { size: 10 } } },
},
},
});
}
} }
// ── Render ──────────────────────────────────────────────────────────────── // ── 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() { _renderStats() {
if (this._stats === null) { if (this._stats === null) {
return html`<div class="home-stats-loading"><i class="bi bi-hourglass-split"></i> ${t('dashboard.stats.loading')}</div>`; 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) { if (empty) {
return html` return html`
<div class="home-stats-empty"> <div class="home-stats-empty">
@@ -363,6 +481,8 @@ export class DashboardPage extends InboxMixin(LightElement) {
`; `;
} }
const breakdown = this._breakdownSpecs;
return html` return html`
<div class="home-stats-grid"> <div class="home-stats-grid">
<div class="home-stat-card"> <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-card-title">${t('dashboard.stats.latency')}</div>
<div class="home-stat-canvas-wrap"><canvas id="chart-latency"></canvas></div> <div class="home-stat-canvas-wrap"><canvas id="chart-latency"></canvas></div>
</div> </div>
<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>
</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">${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>
</div> </div>
${this._renderScopeChips()}
${this._renderStats()} ${this._renderStats()}
<!-- ── Pending inbox ── --> <!-- ── Pending inbox ── -->
+19 -1
View File
@@ -312,6 +312,24 @@ dashboard-page {
color: #fff; 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-loading,
.home-stats-empty { .home-stats-empty {
display: flex; display: flex;
@@ -325,7 +343,7 @@ dashboard-page {
.home-stats-grid { .home-stats-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1rem; gap: 1rem;
margin-bottom: 2rem; margin-bottom: 2rem;
} }
+20 -1
View File
@@ -419,7 +419,6 @@ export default {
'dashboard.stats.requests': 'Requests {per}', 'dashboard.stats.requests': 'Requests {per}',
'dashboard.stats.tokens': 'Tokens {per}', 'dashboard.stats.tokens': 'Tokens {per}',
'dashboard.stats.latency': 'Avg latency (ms)', 'dashboard.stats.latency': 'Avg latency (ms)',
'dashboard.stats.models': 'Models',
'dashboard.stats.per_min': '/ min', 'dashboard.stats.per_min': '/ min',
'dashboard.stats.per_hour': '/ hour', 'dashboard.stats.per_hour': '/ hour',
'dashboard.stats.per_day': '/ day', 'dashboard.stats.per_day': '/ day',
@@ -435,6 +434,26 @@ export default {
'dashboard.stats.chart.non_cached': 'Non-cached', 'dashboard.stats.chart.non_cached': 'Non-cached',
'dashboard.stats.chart.cache_hit': 'Cache hit: {pct}%', '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.hero.subtitle': 'Your AI command centre — research, code, plan, and orchestrate. All in one place.',
'dashboard.banner.no_models.title': 'No LLM models configured.', '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.requests': 'Requêtes {per}',
'dashboard.stats.tokens': 'Tokens {per}', 'dashboard.stats.tokens': 'Tokens {per}',
'dashboard.stats.latency': 'Latence moyenne (ms)', 'dashboard.stats.latency': 'Latence moyenne (ms)',
'dashboard.stats.models': 'Modèles',
'dashboard.stats.per_min': '/ min', 'dashboard.stats.per_min': '/ min',
'dashboard.stats.per_hour': '/ heure', 'dashboard.stats.per_hour': '/ heure',
'dashboard.stats.per_day': '/ jour', 'dashboard.stats.per_day': '/ jour',
@@ -432,6 +431,26 @@ export default {
'dashboard.stats.chart.non_cached': 'Non en cache', 'dashboard.stats.chart.non_cached': 'Non en cache',
'dashboard.stats.chart.cache_hit': 'Cache hit : {pct}%', '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.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é.', '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.requests': 'Richieste {per}',
'dashboard.stats.tokens': 'Token {per}', 'dashboard.stats.tokens': 'Token {per}',
'dashboard.stats.latency': 'Latenza media (ms)', 'dashboard.stats.latency': 'Latenza media (ms)',
'dashboard.stats.models': 'Modelli',
'dashboard.stats.per_min': '/ min', 'dashboard.stats.per_min': '/ min',
'dashboard.stats.per_hour': '/ h', 'dashboard.stats.per_hour': '/ h',
'dashboard.stats.per_day': '/ giorno', 'dashboard.stats.per_day': '/ giorno',
@@ -432,6 +431,26 @@ export default {
'dashboard.stats.chart.non_cached': 'Non in cache', 'dashboard.stats.chart.non_cached': 'Non in cache',
'dashboard.stats.chart.cache_hit': 'Cache hit: {pct}%', '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.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.', 'dashboard.banner.no_models.title': 'Nessun modello LLM configurato.',