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