Stats history with year filter, folding and XLSX/CSV export; snippets, read-only links, commit suggestions, weekly review, offline copies, server speech-to-text, planning model, job reports; fal.ai health fix
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c08c6c7436
commit
388ca96419
31 files changed
+1518
-82
No files matched your search
@@ -0,0 +1,249 @@
|
||||
//! Statistics as CSV (one table) or XLSX (all tables, one sheet each).
|
||||
|
||||
use serde_json::Value;
|
||||
use std::io::Write;
|
||||
|
||||
/// A table: header row and rows of cells.
|
||||
pub struct Table {
|
||||
pub id: &'static str,
|
||||
pub title: String,
|
||||
pub rows: Vec<Vec<Cell>>,
|
||||
}
|
||||
|
||||
pub enum Cell {
|
||||
Text(String),
|
||||
Num(f64),
|
||||
}
|
||||
|
||||
fn num(v: &Value, k: &str) -> Cell {
|
||||
Cell::Num(v[k].as_f64().unwrap_or(0.0))
|
||||
}
|
||||
|
||||
/// Column titles (English, German).
|
||||
fn columns(first: &str, de: bool) -> Vec<Cell> {
|
||||
let t: [&str; 10] = if de {
|
||||
["Anfragen", "Input-Tokens", "Cache gelesen", "Cache geschrieben", "Output-Tokens", "Fehler", "Ø Latenz (ms)", "Output (Tok/s)", "Prefill (Tok/s)", "Kosten"]
|
||||
} else {
|
||||
["Requests", "Input tokens", "Cache read", "Cache write", "Output tokens", "Errors", "Avg TTFT (ms)", "Output (tok/s)", "Prefill (tok/s)", "Cost"]
|
||||
};
|
||||
std::iter::once(first).chain(t).map(|s| Cell::Text(s.to_string())).collect()
|
||||
}
|
||||
|
||||
fn agg_table(id: &'static str, title: &str, first: &str, key: &str, rows: &Value, de: bool) -> Table {
|
||||
let mut out = vec![columns(first, de)];
|
||||
for r in rows.as_array().into_iter().flatten() {
|
||||
out.push(vec![
|
||||
Cell::Text(r[key].as_str().unwrap_or("").to_string()),
|
||||
num(r, "requests"),
|
||||
num(r, "input_tokens"),
|
||||
num(r, "cache_read_tokens"),
|
||||
num(r, "cache_write_tokens"),
|
||||
num(r, "output_tokens"),
|
||||
num(r, "errors"),
|
||||
num(r, "avg_ttft_ms"),
|
||||
num(r, "output_tps"),
|
||||
num(r, "prefill_tps"),
|
||||
num(r, "cost"),
|
||||
]);
|
||||
}
|
||||
Table { id, title: title.to_string(), rows: out }
|
||||
}
|
||||
|
||||
/// All tables of a statistics result (as returned by `Store::global_stats`).
|
||||
pub fn tables(stats: &Value, de: bool) -> Vec<Table> {
|
||||
let (day, month, model, provider, agent, user, session) = if de {
|
||||
("Tag", "Monat", "Modell", "Anbieter", "Modus", "Benutzer", "Chat")
|
||||
} else {
|
||||
("Day", "Month", "Model", "Provider", "Agent", "User", "Chat")
|
||||
};
|
||||
let mut out = vec![
|
||||
agg_table("day", if de { "Pro Tag" } else { "Per day" }, day, "day", &stats["by_day"], de),
|
||||
agg_table("month", if de { "Pro Monat" } else { "Per month" }, month, "month", &stats["by_month"], de),
|
||||
agg_table("model", model, model, "model", &stats["by_model"], de),
|
||||
agg_table("provider", provider, provider, "provider", &stats["by_provider"], de),
|
||||
agg_table("agent", agent, agent, "agent", &stats["by_agent"], de),
|
||||
agg_table("user", user, user, "user", &stats["by_user"], de),
|
||||
agg_table("session", if de { "Teuerste Chats" } else { "Top chats" }, session, "title", &stats["top_sessions"], de),
|
||||
];
|
||||
// The grand total first, as a one-row table.
|
||||
let mut total = vec![columns(if de { "Zeitraum" } else { "Range" }, de)];
|
||||
let t = &stats["total"];
|
||||
total.push(vec![
|
||||
Cell::Text(format!("{} – {}", date(stats["from"].as_i64().unwrap_or(0)), date(stats["to"].as_i64().unwrap_or(0)))),
|
||||
num(t, "requests"),
|
||||
num(t, "input_tokens"),
|
||||
num(t, "cache_read_tokens"),
|
||||
num(t, "cache_write_tokens"),
|
||||
num(t, "output_tokens"),
|
||||
num(t, "errors"),
|
||||
num(t, "avg_ttft_ms"),
|
||||
num(t, "output_tps"),
|
||||
num(t, "prefill_tps"),
|
||||
num(t, "cost"),
|
||||
]);
|
||||
out.insert(0, Table { id: "total", title: (if de { "Gesamt" } else { "Total" }).to_string(), rows: total });
|
||||
out
|
||||
}
|
||||
|
||||
fn date(ms: i64) -> String {
|
||||
if ms <= 0 {
|
||||
return "-".into();
|
||||
}
|
||||
chrono::DateTime::from_timestamp_millis(ms).map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or_default()
|
||||
}
|
||||
|
||||
// --- CSV -----------------------------------------------------------------------------
|
||||
|
||||
/// CSV for spreadsheets: `;` and decimal comma in German, `,` and decimal point otherwise;
|
||||
/// UTF-8 with BOM (Excel). Cells starting with = + - @ are prefixed against formula injection.
|
||||
pub fn csv(table: &Table, de: bool) -> String {
|
||||
let sep = if de { ';' } else { ',' };
|
||||
let mut out = String::from('\u{feff}');
|
||||
for row in &table.rows {
|
||||
let line: Vec<String> = row
|
||||
.iter()
|
||||
.map(|c| match c {
|
||||
Cell::Num(n) => {
|
||||
let s = if n.fract() == 0.0 { format!("{n:.0}") } else { format!("{n:.4}") };
|
||||
if de { s.replace('.', ",") } else { s }
|
||||
}
|
||||
Cell::Text(t) => {
|
||||
let t = if t.starts_with(['=', '+', '-', '@', '\t', '\r']) { format!("'{t}") } else { t.clone() };
|
||||
if t.contains([sep, '"', '\n', '\r']) { format!("\"{}\"", t.replace('"', "\"\"")) } else { t }
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
out.push_str(&line.join(&sep.to_string()));
|
||||
out.push_str("\r\n");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// --- XLSX ----------------------------------------------------------------------------
|
||||
|
||||
fn esc(s: &str) -> String {
|
||||
s.chars()
|
||||
.filter(|c| !c.is_control() || matches!(c, '\n' | '\t'))
|
||||
.map(|c| match c {
|
||||
'&' => "&".to_string(),
|
||||
'<' => "<".to_string(),
|
||||
'>' => ">".to_string(),
|
||||
'"' => """.to_string(),
|
||||
c => c.to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn col_name(mut i: usize) -> String {
|
||||
let mut s = String::new();
|
||||
loop {
|
||||
s.insert(0, (b'A' + (i % 26) as u8) as char);
|
||||
if i < 26 {
|
||||
break;
|
||||
}
|
||||
i = i / 26 - 1;
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn sheet_xml(t: &Table) -> String {
|
||||
let mut x = String::from(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews><cols><col min="1" max="1" width="34" customWidth="1"/><col min="2" max="11" width="16" customWidth="1"/></cols><sheetData>"#);
|
||||
for (r, row) in t.rows.iter().enumerate() {
|
||||
x.push_str(&format!(r#"<row r="{}">"#, r + 1));
|
||||
for (c, cell) in row.iter().enumerate() {
|
||||
let at = format!("{}{}", col_name(c), r + 1);
|
||||
let style = if r == 0 { r#" s="1""# } else { "" };
|
||||
match cell {
|
||||
// Text as inline strings: no shared string table needed.
|
||||
Cell::Text(s) => x.push_str(&format!(r#"<c r="{at}" t="inlineStr"{style}><is><t xml:space="preserve">{}</t></is></c>"#, esc(s))),
|
||||
Cell::Num(n) => x.push_str(&format!(r#"<c r="{at}"{style}><v>{n}</v></c>"#)),
|
||||
}
|
||||
}
|
||||
x.push_str("</row>");
|
||||
}
|
||||
x.push_str("</sheetData></worksheet>");
|
||||
x
|
||||
}
|
||||
|
||||
/// Sheet names: at most 31 characters, none of []:*?/\
|
||||
fn sheet_name(s: &str) -> String {
|
||||
s.chars().filter(|c| !"[]:*?/\\".contains(*c)).take(31).collect()
|
||||
}
|
||||
|
||||
pub fn xlsx(tables: &[Table]) -> anyhow::Result<Vec<u8>> {
|
||||
let mut buf = std::io::Cursor::new(Vec::new());
|
||||
{
|
||||
let mut z = zip::ZipWriter::new(&mut buf);
|
||||
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
let mut put = |name: &str, body: &str| -> anyhow::Result<()> {
|
||||
z.start_file(name, opts)?;
|
||||
z.write_all(body.as_bytes())?;
|
||||
Ok(())
|
||||
};
|
||||
let mut types = String::from(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>"#);
|
||||
let mut sheets = String::new();
|
||||
let mut rels = String::new();
|
||||
for (i, t) in tables.iter().enumerate() {
|
||||
let n = i + 1;
|
||||
types.push_str(&format!(r#"<Override PartName="/xl/worksheets/sheet{n}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>"#));
|
||||
sheets.push_str(&format!(r#"<sheet name="{}" sheetId="{n}" r:id="rId{n}"/>"#, esc(&sheet_name(&t.title))));
|
||||
rels.push_str(&format!(r#"<Relationship Id="rId{n}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet{n}.xml"/>"#));
|
||||
}
|
||||
types.push_str("</Types>");
|
||||
let styles_id = tables.len() + 1;
|
||||
rels.push_str(&format!(r#"<Relationship Id="rId{styles_id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>"#));
|
||||
put("[Content_Types].xml", &types)?;
|
||||
put("_rels/.rels", r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>"#)?;
|
||||
put("xl/workbook.xml", &format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>{sheets}</sheets></workbook>"#))?;
|
||||
put("xl/_rels/workbook.xml.rels", &format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{rels}</Relationships>"#))?;
|
||||
// Style 1 = bold header.
|
||||
put("xl/styles.xml", r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts><fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills><borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/></cellXfs></styleSheet>"#)?;
|
||||
for (i, t) in tables.iter().enumerate() {
|
||||
put(&format!("xl/worksheets/sheet{}.xml", i + 1), &sheet_xml(t))?;
|
||||
}
|
||||
z.finish()?;
|
||||
}
|
||||
Ok(buf.into_inner())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn sample() -> Value {
|
||||
json!({
|
||||
"from": 0, "to": 1_700_000_000_000i64,
|
||||
"total": {"requests": 3, "cost": 1.5},
|
||||
"by_day": [{"day": "2026-01-02", "requests": 2, "cost": 1.25, "input_tokens": 10}],
|
||||
"by_month": [], "by_model": [{"model": "=evil, \"x\"", "requests": 1}],
|
||||
"by_provider": [], "by_agent": [], "by_user": [], "top_sessions": []
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_uses_locale_and_blocks_formulas() {
|
||||
let tabs = tables(&sample(), true);
|
||||
let day = tabs.iter().find(|t| t.id == "day").unwrap();
|
||||
let c = csv(day, true);
|
||||
assert!(c.starts_with('\u{feff}'));
|
||||
assert!(c.contains("2026-01-02;2;10;0;0;0;0;0;0;0;1,2500"), "{c}");
|
||||
let model = csv(tabs.iter().find(|t| t.id == "model").unwrap(), false);
|
||||
assert!(model.contains("\"'=evil, \"\"x\"\"\""), "{model}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xlsx_is_a_zip_with_every_sheet() {
|
||||
let tabs = tables(&sample(), false);
|
||||
let bytes = xlsx(&tabs).unwrap();
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
for i in 1..=tabs.len() {
|
||||
assert!(z.by_name(&format!("xl/worksheets/sheet{i}.xml")).is_ok());
|
||||
}
|
||||
let mut wb = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("xl/workbook.xml").unwrap(), &mut wb).unwrap();
|
||||
assert!(wb.contains("Per day") && wb.contains("Total"));
|
||||
assert_eq!(col_name(0), "A");
|
||||
assert_eq!(col_name(26), "AA");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user