index.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. // Get all charmtone colors once from computed styles
  2. const rootStyles = getComputedStyle(document.documentElement);
  3. const colors = {
  4. charple: rootStyles.getPropertyValue("--charple").trim(),
  5. cherry: rootStyles.getPropertyValue("--cherry").trim(),
  6. julep: rootStyles.getPropertyValue("--julep").trim(),
  7. urchin: rootStyles.getPropertyValue("--urchin").trim(),
  8. butter: rootStyles.getPropertyValue("--butter").trim(),
  9. squid: rootStyles.getPropertyValue("--squid").trim(),
  10. pepper: rootStyles.getPropertyValue("--pepper").trim(),
  11. tuna: rootStyles.getPropertyValue("--tuna").trim(),
  12. uni: rootStyles.getPropertyValue("--uni").trim(),
  13. coral: rootStyles.getPropertyValue("--coral").trim(),
  14. violet: rootStyles.getPropertyValue("--violet").trim(),
  15. malibu: rootStyles.getPropertyValue("--malibu").trim(),
  16. };
  17. const easeDuration = 500;
  18. const easeType = "easeOutQuart";
  19. // Helper functions
  20. function formatNumber(n) {
  21. return new Intl.NumberFormat().format(Math.round(n));
  22. }
  23. function formatCompact(n) {
  24. if (n >= 1000000) return (n / 1000000).toFixed(1) + "M";
  25. if (n >= 1000) return (n / 1000).toFixed(1) + "k";
  26. return Math.round(n).toString();
  27. }
  28. function formatCost(n) {
  29. return "$" + n.toFixed(2);
  30. }
  31. function formatTime(ms) {
  32. if (ms < 1000) return Math.round(ms) + "ms";
  33. return (ms / 1000).toFixed(1) + "s";
  34. }
  35. const charpleColor = { r: 107, g: 80, b: 255 };
  36. const tunaColor = { r: 255, g: 109, b: 170 };
  37. function interpolateColor(ratio, alpha = 1) {
  38. const r = Math.round(charpleColor.r + (tunaColor.r - charpleColor.r) * ratio);
  39. const g = Math.round(charpleColor.g + (tunaColor.g - charpleColor.g) * ratio);
  40. const b = Math.round(charpleColor.b + (tunaColor.b - charpleColor.b) * ratio);
  41. if (alpha < 1) {
  42. return `rgba(${r}, ${g}, ${b}, ${alpha})`;
  43. }
  44. return `rgb(${r}, ${g}, ${b})`;
  45. }
  46. function getTopItemsWithOthers(items, countKey, labelKey, topN = 10) {
  47. const topItems = items.slice(0, topN);
  48. const otherItems = items.slice(topN);
  49. const otherCount = otherItems.reduce((sum, item) => sum + item[countKey], 0);
  50. const displayItems = [...topItems];
  51. if (otherItems.length > 0) {
  52. const otherItem = { [countKey]: otherCount, [labelKey]: "others" };
  53. displayItems.push(otherItem);
  54. }
  55. return displayItems;
  56. }
  57. // Populate summary cards
  58. document.getElementById("total-sessions").textContent = formatNumber(
  59. stats.total.total_sessions,
  60. );
  61. document.getElementById("total-messages").textContent = formatCompact(
  62. stats.total.total_messages,
  63. );
  64. document.getElementById("total-tokens").textContent = formatCompact(
  65. stats.total.total_tokens,
  66. );
  67. document.getElementById("total-cost").textContent = formatCost(
  68. stats.total.total_cost,
  69. );
  70. document.getElementById("avg-tokens").innerHTML =
  71. '<span title="Average">x̅</span> ' +
  72. formatCompact(stats.total.avg_tokens_per_session);
  73. document.getElementById("avg-response").innerHTML =
  74. '<span title="Average">x̅</span> ' + formatTime(stats.avg_response_time_ms);
  75. // Chart defaults
  76. Chart.defaults.color = colors.squid;
  77. Chart.defaults.borderColor = colors.squid;
  78. if (stats.recent_activity?.length > 0) {
  79. new Chart(document.getElementById("recentActivityChart"), {
  80. type: "bar",
  81. data: {
  82. labels: stats.recent_activity.map((d) => d.day),
  83. datasets: [
  84. {
  85. label: "Sessions",
  86. data: stats.recent_activity.map((d) => d.session_count),
  87. backgroundColor: colors.charple,
  88. borderRadius: 4,
  89. yAxisID: "y",
  90. },
  91. {
  92. label: "Tokens (K)",
  93. data: stats.recent_activity.map((d) => d.total_tokens / 1000),
  94. backgroundColor: colors.julep,
  95. borderRadius: 4,
  96. yAxisID: "y1",
  97. },
  98. ],
  99. },
  100. options: {
  101. responsive: true,
  102. maintainAspectRatio: false,
  103. animation: { duration: 800, easing: easeType },
  104. interaction: { mode: "index", intersect: false },
  105. scales: {
  106. y: { position: "left", title: { display: true, text: "Sessions" } },
  107. y1: {
  108. position: "right",
  109. title: { display: true, text: "Tokens (K)" },
  110. grid: { drawOnChartArea: false },
  111. },
  112. },
  113. },
  114. });
  115. }
  116. // Heatmap (Hour × Day of Week) - Bubble Chart
  117. const dayLabels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
  118. let maxCount =
  119. stats.hour_day_heatmap?.length > 0
  120. ? Math.max(...stats.hour_day_heatmap.map((h) => h.session_count))
  121. : 0;
  122. if (maxCount === 0) maxCount = 1;
  123. const scaleFactor = 20 / Math.sqrt(maxCount);
  124. if (stats.hour_day_heatmap?.length > 0) {
  125. new Chart(document.getElementById("heatmapChart"), {
  126. type: "bubble",
  127. data: {
  128. datasets: [
  129. {
  130. label: "Sessions",
  131. data: stats.hour_day_heatmap
  132. .filter((h) => h.session_count > 0)
  133. .map((h) => ({
  134. x: h.hour,
  135. y: h.day_of_week,
  136. r: Math.sqrt(h.session_count) * scaleFactor,
  137. count: h.session_count,
  138. })),
  139. backgroundColor: (ctx) => {
  140. const count =
  141. ctx.raw?.count || ctx.dataset.data[ctx.dataIndex]?.count || 0;
  142. const ratio = count / maxCount;
  143. return interpolateColor(ratio);
  144. },
  145. borderWidth: 0,
  146. },
  147. ],
  148. },
  149. options: {
  150. responsive: true,
  151. maintainAspectRatio: false,
  152. animation: false,
  153. scales: {
  154. x: {
  155. min: 0,
  156. max: 23,
  157. grid: { display: false },
  158. title: { display: true, text: "Hour of Day" },
  159. ticks: {
  160. stepSize: 1,
  161. callback: (v) => (Number.isInteger(v) ? v : ""),
  162. },
  163. },
  164. y: {
  165. min: 0,
  166. max: 6,
  167. reverse: true,
  168. grid: { display: false },
  169. title: { display: true, text: "Day of Week" },
  170. ticks: { stepSize: 1, callback: (v) => dayLabels[v] || "" },
  171. },
  172. },
  173. plugins: {
  174. legend: { display: false },
  175. tooltip: {
  176. callbacks: {
  177. label: (ctx) =>
  178. dayLabels[ctx.raw.y] +
  179. " " +
  180. ctx.raw.x +
  181. ":00 - " +
  182. ctx.raw.count +
  183. " sessions",
  184. },
  185. },
  186. },
  187. },
  188. });
  189. }
  190. if (stats.tool_usage?.length > 0) {
  191. const displayTools = getTopItemsWithOthers(
  192. stats.tool_usage,
  193. "call_count",
  194. "tool_name",
  195. );
  196. const maxValue = Math.max(...displayTools.map((t) => t.call_count));
  197. new Chart(document.getElementById("toolChart"), {
  198. type: "bar",
  199. data: {
  200. labels: displayTools.map((t) => t.tool_name),
  201. datasets: [
  202. {
  203. label: "Calls",
  204. data: displayTools.map((t) => t.call_count),
  205. backgroundColor: (ctx) => {
  206. const value = ctx.raw;
  207. const ratio = value / maxValue;
  208. return interpolateColor(ratio);
  209. },
  210. borderRadius: 4,
  211. },
  212. ],
  213. },
  214. options: {
  215. indexAxis: "y",
  216. responsive: true,
  217. maintainAspectRatio: false,
  218. animation: { duration: easeDuration, easing: easeType },
  219. plugins: { legend: { display: false } },
  220. },
  221. });
  222. }
  223. // Token Distribution Pie
  224. new Chart(document.getElementById("tokenPieChart"), {
  225. type: "doughnut",
  226. data: {
  227. labels: ["Prompt Tokens", "Completion Tokens"],
  228. datasets: [
  229. {
  230. data: [
  231. stats.total.total_prompt_tokens,
  232. stats.total.total_completion_tokens,
  233. ],
  234. backgroundColor: [colors.charple, colors.julep],
  235. borderWidth: 0,
  236. },
  237. ],
  238. },
  239. options: {
  240. responsive: true,
  241. maintainAspectRatio: false,
  242. animation: { duration: easeDuration, easing: easeType },
  243. plugins: {
  244. legend: { position: "bottom" },
  245. },
  246. },
  247. });
  248. // Model Usage Chart (horizontal bar)
  249. if (stats.usage_by_model?.length > 0) {
  250. const displayModels = getTopItemsWithOthers(
  251. stats.usage_by_model,
  252. "message_count",
  253. "model",
  254. );
  255. const maxModelValue = Math.max(...displayModels.map((m) => m.message_count));
  256. new Chart(document.getElementById("modelChart"), {
  257. type: "bar",
  258. data: {
  259. labels: displayModels.map((m) =>
  260. m.provider ? `${m.model} (${m.provider})` : m.model,
  261. ),
  262. datasets: [
  263. {
  264. label: "Messages",
  265. data: displayModels.map((m) => m.message_count),
  266. backgroundColor: (ctx) => {
  267. const value = ctx.raw;
  268. const ratio = value / maxModelValue;
  269. return interpolateColor(ratio);
  270. },
  271. borderRadius: 4,
  272. },
  273. ],
  274. },
  275. options: {
  276. indexAxis: "y",
  277. responsive: true,
  278. maintainAspectRatio: false,
  279. animation: { duration: easeDuration, easing: easeType },
  280. plugins: { legend: { display: false } },
  281. },
  282. });
  283. }
  284. if (stats.usage_by_model?.length > 0) {
  285. const providerData = stats.usage_by_model.reduce((acc, m) => {
  286. acc[m.provider] = (acc[m.provider] || 0) + m.message_count;
  287. return acc;
  288. }, {});
  289. const providerColors = [
  290. colors.malibu,
  291. colors.charple,
  292. colors.violet,
  293. colors.tuna,
  294. colors.coral,
  295. colors.uni,
  296. ];
  297. new Chart(document.getElementById("providerPieChart"), {
  298. type: "doughnut",
  299. data: {
  300. labels: Object.keys(providerData),
  301. datasets: [
  302. {
  303. data: Object.values(providerData),
  304. backgroundColor: Object.keys(providerData).map(
  305. (_, i) => providerColors[i % providerColors.length],
  306. ),
  307. borderWidth: 0,
  308. },
  309. ],
  310. },
  311. options: {
  312. responsive: true,
  313. maintainAspectRatio: false,
  314. animation: { duration: easeDuration, easing: easeType },
  315. plugins: {
  316. legend: { position: "bottom" },
  317. },
  318. },
  319. });
  320. }
  321. // Daily Usage Table
  322. const tableBody = document.querySelector("#daily-table tbody");
  323. if (stats.usage_by_day?.length > 0) {
  324. const fragment = document.createDocumentFragment();
  325. stats.usage_by_day.slice(0, 30).forEach((d) => {
  326. const row = document.createElement("tr");
  327. row.innerHTML = `<td>${d.day}</td><td>${d.session_count}</td><td>${formatNumber(
  328. d.prompt_tokens,
  329. )}</td><td>${formatNumber(
  330. d.completion_tokens,
  331. )}</td><td>${formatNumber(d.total_tokens)}</td><td>${formatCost(
  332. d.cost,
  333. )}</td>`;
  334. fragment.appendChild(row);
  335. });
  336. tableBody.appendChild(fragment);
  337. }