diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..600a1b5 --- /dev/null +++ b/build.sh @@ -0,0 +1,42 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SAGE_ROOT="$(cd "$SCRIPT_DIR/../.." 2>/dev/null && pwd || echo "$SCRIPT_DIR")" +VENV_PYTHON="$SAGE_ROOT/py3/bin/python" +VENV_PIP="$SAGE_ROOT/py3/bin/pip" +WWWROOT="$SAGE_ROOT/wwwroot" + +MODULE_NAME="dashboard_for_sage" + +echo "=== Building $MODULE_NAME ===" + +# Install the module +cd "$SCRIPT_DIR" +$VENV_PIP install -e . + +# Link wwwroot files to Sage wwwroot +MODULE_WWWROOT="$SCRIPT_DIR/wwwroot" +SAGE_MODULE_WWWROOT="$WWWROOT/$MODULE_NAME" + +echo "Linking wwwroot..." +mkdir -p "$SAGE_MODULE_WWWROOT" + +# Link all .ui files +for f in "$MODULE_WWWROOT"/*.ui; do + [ -f "$f" ] && ln -sf "$f" "$SAGE_MODULE_WWWROOT/" +done + +# Link api/ directory +mkdir -p "$SAGE_MODULE_WWWROOT/api" +for f in "$MODULE_WWWROOT/api"/*.dspy; do + [ -f "$f" ] && ln -sf "$f" "$SAGE_MODULE_WWWROOT/api/" +done + +# Link scripts/ directory +mkdir -p "$SAGE_MODULE_WWWROOT/scripts" +for f in "$MODULE_WWWROOT/scripts"/*.js; do + [ -f "$f" ] && ln -sf "$f" "$SAGE_MODULE_WWWROOT/scripts/" +done + +echo "=== $MODULE_NAME build complete ===" diff --git a/dashboard_for_sage/__init__.py b/dashboard_for_sage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dashboard_for_sage/init.py b/dashboard_for_sage/init.py new file mode 100644 index 0000000..96ab9da --- /dev/null +++ b/dashboard_for_sage/init.py @@ -0,0 +1,12 @@ +from ahserver.serverenv import ServerEnv + +MODULE_NAME = "dashboard_for_sage" +MODULE_VERSION = "1.0.0" + + +def load_dashboard_for_sage(): + """Register dashboard module with ServerEnv.""" + env = ServerEnv() + # Dashboard is pure display - all logic is in .dspy files + # which use globals() functions directly. + return True diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5b83315 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[build-system] +requires = ["setuptools>=45", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "dashboard_for_sage" +version = "1.0.0" +description = "Dashboard module for Sage - LLM usage and user statistics" +requires-python = ">=3.8" +dependencies = [ + "sqlor", + "bricks_for_python", +] + +[tool.setuptools.packages.find] +where = ["."] +include = ["dashboard_for_sage*"] diff --git a/wwwroot/api/get_today_usage.dspy b/wwwroot/api/get_today_usage.dspy new file mode 100644 index 0000000..d54a3e1 --- /dev/null +++ b/wwwroot/api/get_today_usage.dspy @@ -0,0 +1,24 @@ +"""获取当天llmusage笔数和交易金额""" +import json +from datetime import date + +ns = params_kw.copy() + +sql = """ +SELECT + COUNT(*) as cnt, + COALESCE(SUM(amount), 0) as total_amount +FROM llmusage +WHERE use_date = ${today}$ +""" + +today = date.today().isoformat() +db = DBPools() +async with db.sqlorContext('sage') as sor: + recs = await sor.sqlExe(sql, {'today': today}) + if recs: + return { + 'cnt': int(recs[0].get('cnt', 0)), + 'total_amount': float(recs[0].get('total_amount', 0)) + } +return {'cnt': 0, 'total_amount': 0} diff --git a/wwwroot/api/get_top_models.dspy b/wwwroot/api/get_top_models.dspy new file mode 100644 index 0000000..06d502d --- /dev/null +++ b/wwwroot/api/get_top_models.dspy @@ -0,0 +1,32 @@ +"""获取当天排名前三的模型数量和金额""" +from datetime import date + +ns = params_kw.copy() +today = date.today().isoformat() + +sql = """ +SELECT + b.name as model_name, + COUNT(*) as cnt, + COALESCE(SUM(a.amount), 0) as total_amount +FROM llmusage a +LEFT JOIN llm b ON a.llmid = b.id +WHERE a.use_date = ${today}$ +GROUP BY a.llmid, b.name +ORDER BY cnt DESC +LIMIT 3 +""" + +db = DBPools() +async with db.sqlorContext('sage') as sor: + recs = await sor.sqlExe(sql, {'today': today}) + result = [] + for r in recs: + result.append({ + 'model_name': r.get('model_name', 'Unknown'), + 'cnt': int(r.get('cnt', 0)), + 'total_amount': float(r.get('total_amount', 0)) + }) + return result + +return [] diff --git a/wwwroot/api/get_user_stats.dspy b/wwwroot/api/get_user_stats.dspy new file mode 100644 index 0000000..fd5210e --- /dev/null +++ b/wwwroot/api/get_user_stats.dspy @@ -0,0 +1,35 @@ +"""获取当前用户总数和并发用户数(近5分钟有活跃记录的用户)""" +from datetime import datetime, timedelta + +ns = params_kw.copy() +db = DBPools() + +# 总用户数 +sql_users = "SELECT COUNT(*) as total_users FROM users" + +# 并发用户数:近5分钟内在llmusage中有记录的不同用户 +now = datetime.now() +five_min_ago = (now - timedelta(minutes=5)).strftime('%Y-%m-%d %H:%M:%S') +today = now.strftime('%Y-%m-%d') + +sql_concurrent = """ +SELECT COUNT(DISTINCT userid) as concurrent_users +FROM llmusage +WHERE use_date = ${today}$ + AND use_time >= ${five_min_ago}$ +""" + +async with db.sqlorContext('sage') as sor: + user_recs = await sor.sqlExe(sql_users, {}) + total_users = int(user_recs[0].get('total_users', 0)) if user_recs else 0 + + conc_recs = await sor.sqlExe(sql_concurrent, { + 'today': today, + 'five_min_ago': five_min_ago + }) + concurrent_users = int(conc_recs[0].get('concurrent_users', 0)) if conc_recs else 0 + +return { + 'total_users': total_users, + 'concurrent_users': concurrent_users +} diff --git a/wwwroot/index.ui b/wwwroot/index.ui new file mode 100644 index 0000000..a88a00e --- /dev/null +++ b/wwwroot/index.ui @@ -0,0 +1,204 @@ +{ + "widgettype": "VBox", + "options": { + "width": "100%", + "height": "100%", + "padding": "20px", + "bgcolor": "#f0f2f5" + }, + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "text": "Dashboard", + "fontSize": "24px", + "fontWeight": "bold", + "color": "#333", + "marginBottom": "20px" + } + }, + { + "widgettype": "ResponsableBox", + "options": { + "gap": "16px", + "minWidth": "250px" + }, + "subwidgets": [ + { + "widgettype": "VBox", + "id": "card_today_cnt", + "options": { + "bgcolor": "#FFFFFF", + "padding": "24px", + "borderRadius": "8px", + "flex": "1", + "minHeight": "120px", + "boxShadow": "0 2px 8px rgba(0,0,0,0.1)" + }, + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "text": "今日调用笔数", + "fontSize": "14px", + "color": "#888", + "marginBottom": "8px" + } + }, + { + "widgettype": "Text", + "id": "today_cnt_value", + "options": { + "text": "--", + "fontSize": "32px", + "fontWeight": "bold", + "color": "#1890ff" + } + } + ] + }, + { + "widgettype": "VBox", + "id": "card_today_amount", + "options": { + "bgcolor": "#FFFFFF", + "padding": "24px", + "borderRadius": "8px", + "flex": "1", + "minHeight": "120px", + "boxShadow": "0 2px 8px rgba(0,0,0,0.1)" + }, + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "text": "今日交易金额", + "fontSize": "14px", + "color": "#888", + "marginBottom": "8px" + } + }, + { + "widgettype": "Text", + "id": "today_amount_value", + "options": { + "text": "--", + "fontSize": "32px", + "fontWeight": "bold", + "color": "#52c41a" + } + } + ] + }, + { + "widgettype": "VBox", + "id": "card_total_users", + "options": { + "bgcolor": "#FFFFFF", + "padding": "24px", + "borderRadius": "8px", + "flex": "1", + "minHeight": "120px", + "boxShadow": "0 2px 8px rgba(0,0,0,0.1)" + }, + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "text": "用户总数", + "fontSize": "14px", + "color": "#888", + "marginBottom": "8px" + } + }, + { + "widgettype": "Text", + "id": "total_users_value", + "options": { + "text": "--", + "fontSize": "32px", + "fontWeight": "bold", + "color": "#722ed1" + } + } + ] + }, + { + "widgettype": "VBox", + "id": "card_concurrent_users", + "options": { + "bgcolor": "#FFFFFF", + "padding": "24px", + "borderRadius": "8px", + "flex": "1", + "minHeight": "120px", + "boxShadow": "0 2px 8px rgba(0,0,0,0.1)" + }, + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "text": "当前并发用户", + "fontSize": "14px", + "color": "#888", + "marginBottom": "8px" + } + }, + { + "widgettype": "Text", + "id": "concurrent_users_value", + "options": { + "text": "--", + "fontSize": "32px", + "fontWeight": "bold", + "color": "#fa8c16" + } + } + ] + } + ] + }, + { + "widgettype": "VBox", + "id": "chart_section", + "options": { + "bgcolor": "#FFFFFF", + "padding": "24px", + "borderRadius": "8px", + "marginTop": "20px", + "minHeight": "350px", + "boxShadow": "0 2px 8px rgba(0,0,0,0.1)" + }, + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "text": "Top 3 模型(今日)", + "fontSize": "18px", + "fontWeight": "bold", + "color": "#333", + "marginBottom": "16px" + } + }, + { + "widgettype": "ChartBar", + "id": "top_models_chart", + "options": { + "height": "300px", + "width": "100%", + "data_url": "{{entire_url('api/get_top_models.dspy')}}", + "nameField": "model_name", + "valueFields": ["cnt", "total_amount"] + } + } + ] + }, + { + "widgettype": "Html", + "id": "auto_refresh", + "options": { + "html": "
数据每10秒自动刷新
" + } + } + ] +} diff --git a/wwwroot/menu.ui b/wwwroot/menu.ui new file mode 100644 index 0000000..f38a7e9 --- /dev/null +++ b/wwwroot/menu.ui @@ -0,0 +1,13 @@ +{ + "widgettype": "Menu", + "id": "dashboard_for_sage_menu", + "options": { + "items": [ + { + "name": "dashboard", + "label": "数据看板", + "url": "{{entire_url('index.ui')}}" + } + ] + } +} diff --git a/wwwroot/scripts/dashboard_refresh.js b/wwwroot/scripts/dashboard_refresh.js new file mode 100644 index 0000000..bddc405 --- /dev/null +++ b/wwwroot/scripts/dashboard_refresh.js @@ -0,0 +1,98 @@ +/** + * Dashboard auto-refresh script + * Polls API every 10 seconds and updates stat cards + chart + * Auto-loaded by Sage's header.tmpl from wwwroot/scripts/ + */ +(function() { + 'use strict'; + + // Derive base URL from current page (e.g. /dashboard_for_sage/index.ui -> /dashboard_for_sage) + function getBaseUrl() { + var path = window.location.pathname; + // Remove .ui or .dspy extension + var idx = path.lastIndexOf('/'); + return path.substring(0, idx); + } + + function buildUrl(dspyFile) { + var base = getBaseUrl(); + // Add _webbricks_=1 for non-initial requests + return base + '/' + dspyFile + '?_webbricks_=1'; + } + + function updateCard(id, value) { + try { + var widget = bricks.app.find_widget_by_id(id); + if (widget && widget.el) { + widget.el.textContent = value; + } + } catch(e) { + // Widget may not be ready yet + } + } + + function formatNumber(n) { + if (typeof n === 'number') { + return n.toLocaleString(); + } + return n; + } + + function formatAmount(n) { + if (typeof n === 'number') { + return '¥' + n.toFixed(2); + } + return '¥0.00'; + } + + async function refreshStats() { + try { + var url = buildUrl('api/get_today_usage.dspy'); + var resp = await fetch(url, { credentials: 'include' }); + if (!resp.ok) return; + var data = await resp.json(); + updateCard('today_cnt_value', formatNumber(data.cnt)); + updateCard('today_amount_value', formatAmount(data.total_amount)); + } catch(e) { + // Silently fail on refresh errors + } + } + + async function refreshUsers() { + try { + var url = buildUrl('api/get_user_stats.dspy'); + var resp = await fetch(url, { credentials: 'include' }); + if (!resp.ok) return; + var data = await resp.json(); + updateCard('total_users_value', formatNumber(data.total_users)); + updateCard('concurrent_users_value', formatNumber(data.concurrent_users)); + } catch(e) { + // Silently fail on refresh errors + } + } + + async function refreshChart() { + try { + var chart = bricks.app.find_widget_by_id('top_models_chart'); + if (chart && chart.render_urldata) { + await chart.render_urldata({}); + } + } catch(e) { + // Silently fail on refresh errors + } + } + + async function refreshAll() { + await refreshStats(); + await refreshUsers(); + await refreshChart(); + } + + // Start auto-refresh every 10 seconds + // Delay initial load to allow bricks framework to initialize + setTimeout(function() { + refreshAll(); + setInterval(refreshAll, 10000); + }, 1000); + +})();