Initial commit: dashboard module for Sage with auto-refresh statistics

This commit is contained in:
yumoqing 2026-05-24 14:06:03 +08:00
parent 089411c72e
commit 6ae20954f3
10 changed files with 477 additions and 0 deletions

42
build.sh Normal file
View File

@ -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 ==="

View File

View File

@ -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

17
pyproject.toml Normal file
View File

@ -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*"]

View File

@ -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}

View File

@ -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 []

View File

@ -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
}

204
wwwroot/index.ui Normal file
View File

@ -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": "<div id='refresh_indicator' style='position:fixed;bottom:10px;right:10px;font-size:12px;color:#999;'>数据每10秒自动刷新</div>"
}
}
]
}

13
wwwroot/menu.ui Normal file
View File

@ -0,0 +1,13 @@
{
"widgettype": "Menu",
"id": "dashboard_for_sage_menu",
"options": {
"items": [
{
"name": "dashboard",
"label": "数据看板",
"url": "{{entire_url('index.ui')}}"
}
]
}
}

View File

@ -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);
})();