downcity 1.1.410 → 1.1.411

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -106,8 +106,8 @@ async function render_overview() {
106
106
  ]);
107
107
  const calls_chart = mixed_daily_chart(usage.days);
108
108
  const top_users = await api(analytics_url("users"));
109
- const ranking = top_users.items.slice(0, 6).map((item, index) => `<div class="ranking-row"><span class="rank">${index + 1}</span><div class="ranking-user"><strong>${escape_html(item.email || item.user_id)}</strong><small>${escape_html(item.top_model_id || "暂无模型")}</small></div><div class="ranking-value"><strong>${compact_number(item.total_tokens)}</strong><small>${number(item.execution_count)} calls</small></div></div>`).join("") || `<div class="empty compact-empty">暂无活跃用户</div>`;
110
- content.innerHTML = `${metric_cards(metrics)}<div class="analytics-grid wide-left">${panel("活跃用户趋势", activity_chart, usage.timezone)}${panel("高消耗用户", ranking, "TOP 6")}</div><div class="analytics-grid">${panel("调用与成功率", calls_chart, range.options[range.selectedIndex].text)}${panel("Token 构成", stacked_chart(usage.days, [["未缓存输入", "uncached_input_tokens", "#2563eb"], ["缓存输入", "cached_input_tokens", "#60a5fa"], ["输出", "output_tokens", "#8b5cf6"], ["推理", "reasoning_tokens", "#c4b5fd"]]), "STACKED")}</div>`;
109
+ const ranking = sort_usage_users(top_users.items, "total_tokens").slice(0, 6).map((item, index) => `<div class="ranking-row"><span class="rank">${index + 1}</span><div class="ranking-user"><strong>${escape_html(item.email || item.user_id)}</strong><small>${escape_html(item.top_model_id || "暂无模型")}</small></div><div class="ranking-value"><strong>${compact_number(item.total_tokens)}</strong><small>${number(item.execution_count)} calls</small></div></div>`).join("") || `<div class="empty compact-empty">暂无活跃用户</div>`;
110
+ content.innerHTML = `${metric_cards(metrics)}<div class="analytics-grid wide-left">${panel("活跃用户趋势", activity_chart, usage.timezone)}${panel("Token 消耗排行", ranking, "TOTAL TOKENS · TOP 6")}</div><div class="analytics-grid">${panel("调用与成功率", calls_chart, range.options[range.selectedIndex].text)}${panel("Token 构成", stacked_chart(usage.days, [["未缓存输入", "uncached_input_tokens", "#2563eb"], ["缓存输入", "cached_input_tokens", "#60a5fa"], ["输出", "output_tokens", "#8b5cf6"], ["推理", "reasoning_tokens", "#c4b5fd"]]), "STACKED")}</div>`;
111
111
  }
112
112
 
113
113
  async function render_activity() {
@@ -215,7 +215,7 @@ async function render_usage_users() {
215
215
  const data = await api(analytics_url("users"));
216
216
  state.usage_users = data.items;
217
217
  state.usage_page = 1;
218
- content.innerHTML = `<div class="toolbar"><div class="search-field"><span>⌕</span><input id="usage-search" placeholder="搜索邮箱或 user_id"></div><select id="usage-sort"><option value="execution_count">按调用量</option><option value="total_tokens">按 Token</option><option value="credits_used">按 Credits</option><option value="p95_duration_ms">按 P95 耗时</option><option value="last_active_at">按最后活跃</option></select><span class="badge">${data.items.length} USERS</span></div><div id="usage-table"></div><div id="usage-drawer"></div>`;
218
+ content.innerHTML = `<div class="toolbar"><div class="search-field"><span>⌕</span><input id="usage-search" placeholder="搜索邮箱或 user_id"></div><select id="usage-sort"><option value="total_tokens">按 Token 消耗</option><option value="credits_used">按 Credits 消耗</option><option value="execution_count">按调用量</option><option value="p95_duration_ms">按 P95 耗时</option><option value="last_active_at">按最后活跃</option></select><span class="badge">${data.items.length} USERS</span></div><div id="usage-table"></div><div id="usage-drawer"></div>`;
219
219
  document.querySelector("#usage-search").addEventListener("input", () => { state.usage_page = 1; render_usage_table(); });
220
220
  document.querySelector("#usage-sort").addEventListener("change", () => { state.usage_page = 1; render_usage_table(); });
221
221
  render_usage_table();
@@ -223,8 +223,11 @@ async function render_usage_users() {
223
223
 
224
224
  function render_usage_table() {
225
225
  const query = value("usage-search").toLowerCase();
226
- const sort_key = value("usage-sort") || "execution_count";
227
- const items = state.usage_users.filter((item) => `${item.user_id} ${item.email}`.toLowerCase().includes(query)).sort((left, right) => sort_key === "last_active_at" ? String(right.last_active_at).localeCompare(String(left.last_active_at)) : Number(right[sort_key] || 0) - Number(left[sort_key] || 0));
226
+ const sort_key = value("usage-sort") || "total_tokens";
227
+ const items = sort_usage_users(
228
+ state.usage_users.filter((item) => `${item.user_id} ${item.email}`.toLowerCase().includes(query)),
229
+ sort_key,
230
+ );
228
231
  const page_size = 25;
229
232
  const page_count = Math.max(1, Math.ceil(items.length / page_size));
230
233
  state.usage_page = Math.min(state.usage_page, page_count);
@@ -235,6 +238,15 @@ function render_usage_table() {
235
238
  document.querySelectorAll("[data-usage-user]").forEach((button) => button.addEventListener("click", () => show_usage_detail(decodeURIComponent(button.dataset.usageUser))));
236
239
  }
237
240
 
241
+ function sort_usage_users(items, sort_key) {
242
+ return [...items].sort((left, right) => {
243
+ const difference = sort_key === "last_active_at"
244
+ ? String(right.last_active_at || "").localeCompare(String(left.last_active_at || ""))
245
+ : Number(right[sort_key] || 0) - Number(left[sort_key] || 0);
246
+ return difference || String(left.user_id || "").localeCompare(String(right.user_id || ""));
247
+ });
248
+ }
249
+
238
250
  function usage_table(items) {
239
251
  if (!items.length) return `<div class="empty">没有匹配的用户</div>`;
240
252
  return `<div class="table-wrap"><table><thead><tr><th>用户</th><th>最后活跃</th><th>调用</th><th>Total Tokens</th><th>Credits</th><th>成功率</th><th>平均 / P95</th><th>Top Model</th><th></th></tr></thead><tbody>${items.map((item) => `<tr><td><div class="user-cell"><span class="avatar">${escape_html((item.email || item.user_id || "U").slice(0, 1).toUpperCase())}</span><div><strong>${escape_html(item.email || "—")}</strong><small>${escape_html(item.user_id)}</small></div></div></td><td>${item.last_active_at ? new Date(item.last_active_at).toLocaleString() : "从未活跃"}</td><td>${number(item.execution_count)}</td><td>${compact_number(item.total_tokens)}</td><td>${compact_number(item.credits_used)}</td><td>${percent(item.success_rate)}</td><td>${duration(item.average_duration_ms)} / ${duration(item.p95_duration_ms)}</td><td><span class="model-pill">${escape_html(item.top_model_id || "—")}</span></td><td><button class="quiet-button" data-usage-user="${encodeURIComponent(item.user_id)}">详情 →</button></td></tr>`).join("")}</tbody></table></div>`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "downcity",
3
- "version": "1.1.410",
3
+ "version": "1.1.411",
4
4
  "type": "module",
5
5
  "description": "Downcity CLI — local City container and Federation Server management",
6
6
  "bin": {