Initial commit: dashboard module for Sage with auto-refresh statistics
This commit is contained in:
parent
089411c72e
commit
6ae20954f3
42
build.sh
Normal file
42
build.sh
Normal 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 ==="
|
||||||
0
dashboard_for_sage/__init__.py
Normal file
0
dashboard_for_sage/__init__.py
Normal file
12
dashboard_for_sage/init.py
Normal file
12
dashboard_for_sage/init.py
Normal 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
17
pyproject.toml
Normal 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*"]
|
||||||
24
wwwroot/api/get_today_usage.dspy
Normal file
24
wwwroot/api/get_today_usage.dspy
Normal 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}
|
||||||
32
wwwroot/api/get_top_models.dspy
Normal file
32
wwwroot/api/get_top_models.dspy
Normal 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 []
|
||||||
35
wwwroot/api/get_user_stats.dspy
Normal file
35
wwwroot/api/get_user_stats.dspy
Normal 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
204
wwwroot/index.ui
Normal 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
13
wwwroot/menu.ui
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"widgettype": "Menu",
|
||||||
|
"id": "dashboard_for_sage_menu",
|
||||||
|
"options": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "dashboard",
|
||||||
|
"label": "数据看板",
|
||||||
|
"url": "{{entire_url('index.ui')}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
98
wwwroot/scripts/dashboard_refresh.js
Normal file
98
wwwroot/scripts/dashboard_refresh.js
Normal 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);
|
||||||
|
|
||||||
|
})();
|
||||||
Loading…
x
Reference in New Issue
Block a user