Skip to content
SlashClone

Agent cost dashboard

@agentcosts/agent-cost-dashboard

Turn LLM usage logs into one self-contained HTML cost dashboard for an agentic product: cost per customer, per task, per successful outcome, model mix, cache hit rate, and gross margin when you add revenue by customer. Prices blank costs from an editable price table.

SkillDatav1.0.0 (latest)Tested
Activity
New: be the first to try it
  1. 1// Builds a single self-contained HTML page with inline SVG charts.
  2. 2// No external scripts, fonts, or images. Light and dark themes follow the viewer's OS setting.
  3. 3import { escapeHtml as esc } from "./lib.mjs";
  4. 4
  5. 5export const SERIES = ["var(--s1)", "var(--s2)", "var(--s3)", "var(--s4)", "var(--s5)", "var(--s6)"];
  6. 6
  7. 7const CSS = `
  8. 8:root{color-scheme:light;--bg:#f9f9f7;--card:#fcfcfb;--ink:#0b0b0b;--ink2:#52514e;--muted:#898781;--grid:#e1e0d9;--axis:#c3c2b7;--ring:rgba(11,11,11,.10);
  9. 9--s1:#2a78d6;--s2:#eb6834;--s3:#1baf7a;--s4:#eda100;--s5:#e87ba4;--s6:#6250d6;--good:#0ca30c;--warn:#fab219;--bad:#d03b3b;--up:#006300;
  10. 10--q1:#cde2fb;--q2:#9ec5f4;--q3:#6da7ec;--q4:#3987e5;--q5:#256abf;--q6:#184f95;--hiink:#ffffff}
  11. 11@media (prefers-color-scheme:dark){:root{color-scheme:dark;--bg:#0d0d0d;--card:#1a1a19;--ink:#f0efec;--ink2:#c3c2b7;--muted:#898781;--grid:#2c2c2a;--axis:#383835;--ring:rgba(255,255,255,.10);
  12. 12--s1:#3987e5;--s2:#d95926;--s3:#199e70;--s4:#c98500;--s5:#d55181;--s6:#9085e9;--up:#0ca30c;
  13. 13--q1:#184f95;--q2:#1c5cab;--q3:#256abf;--q4:#3987e5;--q5:#6da7ec;--q6:#9ec5f4;--hiink:#0b0b0b}}
  14. 14*{box-sizing:border-box}
  15. 15body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.45 system-ui,-apple-system,"Segoe UI",sans-serif}
  16. 16main{max-width:1120px;margin:0 auto;padding:24px 16px 48px}
  17. 17h1{font-size:22px;margin:0 0 4px}h2{font-size:16px;margin:0 0 4px}
  18. 18.sub{color:var(--ink2);margin:0 0 20px}.note{color:var(--muted);font-size:12px;margin:6px 0 0}
  19. 19.grid{display:grid;gap:16px;grid-template-columns:repeat(auto-fit,minmax(min(100%,340px),1fr));margin-bottom:16px}
  20. 20.tiles{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));margin-bottom:16px}
  21. 21.card{background:var(--card);border:1px solid var(--ring);border-radius:10px;padding:16px;min-width:0}
  22. 22.tile .v{font-size:26px;font-weight:600;margin-top:2px}.tile .l{color:var(--ink2);font-size:12px}.tile .h{color:var(--muted);font-size:12px}
  23. 23svg{display:block;width:100%;height:auto;overflow:visible}
  24. 24svg text{fill:var(--muted);font-size:11px}
  25. 25.tw{overflow-x:auto}
  26. 26table{border-collapse:collapse;width:100%;font-size:13px;font-variant-numeric:tabular-nums}
  27. 27th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--grid);white-space:nowrap}
  28. 28th{color:var(--ink2);font-weight:600}td.n,th.n{text-align:right}
  29. 29.hm td.c{text-align:center;color:var(--ink)}
  30. 30.flag{display:inline-block;padding:1px 8px;border-radius:999px;font-size:12px;border:1px solid var(--ring)}
  31. 31.flag.bad::before{content:"\\25BC ";color:var(--bad)}.flag.warn::before{content:"\\25B2 ";color:var(--warn)}.flag.good::before{content:"\\25CF ";color:var(--good)}
  32. 32.legend{display:flex;flex-wrap:wrap;gap:12px;margin:8px 0 0;color:var(--ink2);font-size:12px}
  33. 33.legend i{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:4px;vertical-align:-1px}
  34. 34.mark:hover{opacity:.8}
  35. 35`;
  36. 37export function page(title, subtitle, body) {
  37. 38 return `<!doctype html>
  38. 39<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
  39. 40<title>${esc(title)}</title><style>${CSS}</style></head>
  40. 41<body><main><h1>${esc(title)}</h1><p class="sub">${esc(subtitle)}</p>
  41. 42${body}
  42. 43</main></body></html>
  43. 44`;
  44. 47export function tiles(items) {
  45. 48 return `<div class="tiles">${items
  46. 49 .map(
  47. 50 (t) =>
  48. 51 `<div class="card tile"><div class="l">${esc(t.label)}</div><div class="v">${esc(t.value)}</div>${
  49. 52 t.hint ? `<div class="h">${esc(t.hint)}</div>` : ""
  50. 53 }</div>`,
  51. 54 )
  52. 55 .join("")}</div>`;
  53. 58export function card(title, inner, note) {
  54. 59 return `<section class="card"><h2>${esc(title)}</h2>${inner}${note ? `<p class="note">${esc(note)}</p>` : ""}</section>`;
  55. 62function niceMax(v) {
  56. 63 if (!(v > 0)) return 1;
  57. 64 const p = 10 ** Math.floor(Math.log10(v));
  58. 65 for (const m of [1, 2, 2.5, 5, 10]) if (v <= m * p) return m * p;
  59. 66 return 10 * p;
  60. 69/**
  61. 70 * Line chart. series: [{ name, values: [number|null] }], labels: x labels (same length).
  62. 71 * format: value formatter for axis and tooltips.
  63. 72 */
  64. 73export function lineChart(labels, series, format = String, { height = 200 } = {}) {
  65. 74 const W = 640;
  66. 75 const H = height;
  67. 76 const L = 48;
  68. 77 const R = 12;
  69. 78 const T = 10;
  70. 79 const B = 24;
  71. 80 const all = series.flatMap((s) => s.values).filter((v) => Number.isFinite(v));
  72. 81 const max = niceMax(Math.max(0, ...all));
  73. 82 const n = labels.length;
  74. 83 const x = (i) => L + (n <= 1 ? 0 : (i * (W - L - R)) / (n - 1));
  75. 84 const y = (v) => T + (H - T - B) * (1 - v / max);
  76. 85 let g = "";
  77. 86 for (let k = 0; k <= 4; k++) {
  78. 87 const v = (max * k) / 4;
  79. 88 g += `<line x1="${L}" x2="${W - R}" y1="${y(v)}" y2="${y(v)}" stroke="var(--grid)" stroke-width="1"/>`;
  80. 89 g += `<text x="${L - 6}" y="${y(v) + 4}" text-anchor="end">${esc(format(v))}</text>`;
  81. 90 }
  82. 91 const step = Math.max(1, Math.ceil(n / 6));
  83. 92 for (let i = 0; i < n; i += step)
  84. 93 g += `<text x="${x(i)}" y="${H - 6}" text-anchor="middle">${esc(labels[i])}</text>`;
  85. 94 series.forEach((s, si) => {
  86. 95 const color = SERIES[si % SERIES.length];
  87. 96 let d = "";
  88. 97 s.values.forEach((v, i) => {
  89. 98 if (!Number.isFinite(v)) return;
  90. 99 d += `${d ? "L" : "M"}${x(i).toFixed(1)},${y(v).toFixed(1)}`;
  91. 100 });
  92. 101 g += `<path d="${d}" fill="none" stroke="${color}" stroke-width="2" stroke-linejoin="round"/>`;
  93. 102 s.values.forEach((v, i) => {
  94. 103 if (!Number.isFinite(v)) return;
  95. 104 g += `<circle class="mark" cx="${x(i).toFixed(1)}" cy="${y(v).toFixed(1)}" r="6" fill="transparent"><title>${esc(
  96. 105 `${labels[i]} ${s.name}: ${format(v)}`,
  97. 106 )}</title></circle>`;
  98. 107 });
  99. 108 });
  100. 109 const legend =
  101. 110 series.length > 1
  102. 111 ? `<div class="legend">${series
  103. 112 .map((s, i) => `<span><i style="background:${SERIES[i % SERIES.length]}"></i>${esc(s.name)}</span>`)
  104. 113 .join("")}</div>`
  105. 114 : "";
  106. 115 return `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="line chart">${g}</svg>${legend}`;
  107. 118/** Horizontal bar chart. items: [{ label, value, color? }] */
  108. 119export function barChart(items, format = String, { color = "var(--s1)" } = {}) {
  109. 120 const W = 640;
  110. 121 const rowH = 26;
  111. 122 const labelW = 170;
  112. 123 const valueW = 80;
  113. 124 const H = Math.max(rowH, items.length * rowH);
  114. 125 const max = Math.max(0, ...items.map((i) => i.value));
  115. 126 const span = W - labelW - valueW;
  116. 127 let g = "";
  117. 128 items.forEach((it, i) => {
  118. 129 const w = max > 0 ? Math.max(2, (span * Math.max(0, it.value)) / max) : 2;
  119. 130 const yy = i * rowH;
  120. 131 const label = it.label.length > 26 ? `${it.label.slice(0, 25)}...` : it.label;
  121. 132 g += `<text x="${labelW - 8}" y="${yy + 17}" text-anchor="end" style="fill:var(--ink2)">${esc(label)}</text>`;
  122. 133 g += `<rect class="mark" x="${labelW}" y="${yy + 6}" width="${w.toFixed(1)}" height="${rowH - 12}" rx="3" fill="${
  123. 134 it.color || color
  124. 135 }"><title>${esc(`${it.label}: ${format(it.value)}`)}</title></rect>`;
  125. 136 g += `<text x="${labelW + w + 6}" y="${yy + 17}" style="fill:var(--ink)">${esc(format(it.value))}</text>`;
  126. 137 });
  127. 138 return `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="bar chart">${g}</svg>`;
  128. 141/** Plain table. columns: [{ label, numeric? }], rows: arrays of already-formatted cells (strings may contain safe HTML if html: true). */
  129. 142export function table(columns, rows, { html = false } = {}) {
  130. 143 const th = columns.map((c) => `<th${c.numeric ? ' class="n"' : ""}>${esc(c.label)}</th>`).join("");
  131. 144 const tr = rows
  132. 145 .map(
  133. 146 (r) =>
  134. 147 `<tr>${r
  135. 148 .map((cell, i) => `<td${columns[i]?.numeric ? ' class="n"' : ""}>${html ? cell : esc(cell)}</td>`)
  136. 149 .join("")}</tr>`,
  137. 150 )
  138. 151 .join("");
  139. 152 return `<div class="tw"><table><thead><tr>${th}</tr></thead><tbody>${tr}</tbody></table></div>`;
  140. 155/** Retention-style heatmap: values 0..1, null for not yet observable. */
  141. 156export function heatmap(rowLabels, colLabels, values, extraCol) {
  142. 157 const steps = ["--q1", "--q2", "--q3", "--q4", "--q5", "--q6"];
  143. 158 const th = `<th>Cohort</th>${extraCol ? `<th class="n">${esc(extraCol.label)}</th>` : ""}${colLabels
  144. 159 .map((c) => `<th class="n">${esc(c)}</th>`)
  145. 160 .join("")}`;
  146. 161 const rows = rowLabels
  147. 162 .map((rl, ri) => {
  148. 163 const cells = values[ri]
  149. 164 .map((v) => {
  150. 165 if (v === null || v === undefined) return `<td class="c"></td>`;
  151. 166 const s = steps[Math.min(steps.length - 1, Math.floor(v * steps.length))];
  152. 167 const ink = v >= 0.5 ? "var(--hiink)" : "var(--ink)";
  153. 168 return `<td class="c" style="background:var(${s});color:${ink}">${Math.round(v * 100)}%</td>`;
  154. 169 })
  155. 170 .join("");
  156. 171 return `<tr><td>${esc(rl)}</td>${extraCol ? `<td class="n">${esc(extraCol.values[ri])}</td>` : ""}${cells}</tr>`;
  157. 172 })
  158. 173 .join("");
  159. 174 return `<div class="tw"><table class="hm"><thead><tr>${th}</tr></thead><tbody>${rows}</tbody></table></div>`;
  160. 177export function flag(kind, text) {
  161. 178 return `<span class="flag ${kind}">${esc(text)}</span>`;