refactor: use individual RefreshWidget per stat card with Jinja2 .ui templates

Architecture:
- load_dashboard.py: async data functions registered via load_dashboard()
- init.py: calls load_dashboard() to expose on ServerEnv
- Individual .ui files (Jinja2 templates):
  - today_usage.ui: calls get_today_usage(request)
  - today_amount.ui: calls get_today_amount(request)
  - total_users.ui: calls get_total_users(request)
  - concurrent_users.ui: calls get_concurrent_users(request)
  - top_models_chart.ui: calls get_top_models(request) for ChartBar
- index.ui: each stat card wrapped in own RefreshWidget (10s period)
- Removed deprecated .dspy files (dashboard_cards, get_today_usage,
  get_user_stats, get_top_models)
This commit is contained in:
yumoqing 2026-05-24 17:20:22 +08:00
parent d2210a2996
commit a2a6e9a2d5
12 changed files with 273 additions and 183 deletions

View File

@ -1,12 +1 @@
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
from .load_dashboard import load_dashboard

View File

@ -0,0 +1,92 @@
"""Dashboard data functions - exposed via load_dashboard() on ServerEnv"""
from ahserver.serverenv import ServerEnv
from sqlor.dbpools import get_sor_context
from datetime import datetime, timedelta, date
async def get_today_usage(request):
"""获取当天llmusage笔数"""
today = date.today().isoformat()
env = request._run_ns
async with get_sor_context(env, 'sage') as sor:
sql = "SELECT COUNT(*) as cnt FROM llmusage WHERE use_date = ${today}$"
recs = await sor.sqlExe(sql, {'today': today})
cnt = int(recs[0].get('cnt', 0)) if recs else 0
return cnt
async def get_today_amount(request):
"""获取当天交易金额"""
today = date.today().isoformat()
env = request._run_ns
async with get_sor_context(env, 'sage') as sor:
sql = "SELECT COALESCE(SUM(amount), 0) as total_amount FROM llmusage WHERE use_date = ${today}$"
recs = await sor.sqlExe(sql, {'today': today})
amount = float(recs[0].get('total_amount', 0)) if recs else 0.0
return amount
async def get_total_users(request):
"""获取用户总数"""
env = request._run_ns
async with get_sor_context(env, 'sage') as sor:
sql = "SELECT COUNT(*) as total_users FROM users"
recs = await sor.sqlExe(sql, {})
total = int(recs[0].get('total_users', 0)) if recs else 0
return total
async def get_concurrent_users(request):
"""获取当前并发用户数近5分钟有活跃记录的用户"""
now = datetime.now()
five_min_ago = (now - timedelta(minutes=5)).strftime('%Y-%m-%d %H:%M:%S')
today = now.strftime('%Y-%m-%d')
env = request._run_ns
async with get_sor_context(env, 'sage') as sor:
sql = """
SELECT COUNT(DISTINCT userid) as concurrent_users
FROM llmusage
WHERE use_date = ${today}$
AND use_time >= ${five_min_ago}$
"""
recs = await sor.sqlExe(sql, {'today': today, 'five_min_ago': five_min_ago})
cnt = int(recs[0].get('concurrent_users', 0)) if recs else 0
return cnt
async def get_top_models(request):
"""获取当天排名前三的模型返回记录列表供ChartBar使用"""
today = date.today().isoformat()
env = request._run_ns
async with get_sor_context(env, 'sage') as sor:
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
"""
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
def load_dashboard():
"""Register dashboard functions on ServerEnv"""
g = ServerEnv()
g.get_today_usage = get_today_usage
g.get_today_amount = get_today_amount
g.get_total_users = get_total_users
g.get_concurrent_users = get_concurrent_users
g.get_top_models = get_top_models

View File

@ -1,73 +0,0 @@
"""获取当天llmusage笔数和交易金额 + 用户统计 — 返回完整卡片widget树供RefreshWidget加载"""
# datetime, json, DBPools 由 ahserver 预加载,无需 import
today = datetime.date.today().isoformat()
now = datetime.datetime.now()
five_min_ago = (now - datetime.timedelta(minutes=5)).strftime('%Y-%m-%d %H:%M:%S')
db = DBPools()
# 今日用量
async with db.sqlorContext('sage') as sor:
recs = await sor.sqlExe(
"SELECT COUNT(*) as cnt, COALESCE(SUM(amount), 0) as total_amount FROM llmusage WHERE use_date = ${today}$",
{'today': today}
)
cnt = int(recs[0].get('cnt', 0)) if recs else 0
total_amount = float(recs[0].get('total_amount', 0)) if recs else 0.0
# 总用户数
async with db.sqlorContext('sage') as sor:
user_recs = await sor.sqlExe("SELECT COUNT(*) as total_users FROM users", {})
total_users = int(user_recs[0].get('total_users', 0)) if user_recs else 0
# 并发用户
async with db.sqlorContext('sage') as sor:
conc = await sor.sqlExe(
"SELECT COUNT(DISTINCT userid) as concurrent_users FROM llmusage WHERE use_date = ${today}$ AND use_time >= ${five_min_ago}$",
{'today': today, 'five_min_ago': five_min_ago}
)
concurrent_users = int(conc[0].get('concurrent_users', 0)) if conc else 0
return {
"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": str(cnt), "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": "\u00a5%.2f" % total_amount, "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": str(total_users), "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": str(concurrent_users), "fontSize": "32px", "fontWeight": "bold", "color": "#fa8c16"}}
]
}
]
}

View File

@ -1,22 +0,0 @@
"""获取当天llmusage笔数和交易金额"""
# datetime, json, DBPools 由 ahserver 预加载,无需 import
today = datetime.date.today().isoformat()
sql = """
SELECT
COUNT(*) as cnt,
COALESCE(SUM(amount), 0) as total_amount
FROM llmusage
WHERE use_date = ${today}$
"""
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

@ -1,31 +0,0 @@
"""获取当天排名前三的模型数量和金额"""
# datetime, json, DBPools 由 ahserver 预加载,无需 import
today = datetime.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

@ -1,31 +0,0 @@
"""获取当前用户总数和并发用户数近5分钟有活跃记录的用户"""
# datetime, json, DBPools 由 ahserver 预加载,无需 import
now = datetime.datetime.now()
five_min_ago = (now - datetime.timedelta(minutes=5)).strftime('%Y-%m-%d %H:%M:%S')
today = now.strftime('%Y-%m-%d')
sql_users = "SELECT COUNT(*) as total_users FROM users"
sql_concurrent = """
SELECT COUNT(DISTINCT userid) as concurrent_users
FROM llmusage
WHERE use_date = ${today}$
AND use_time >= ${five_min_ago}$
"""
db = DBPools()
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
}

View File

@ -0,0 +1,32 @@
{
"widgettype": "VBox",
"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": "{{get_concurrent_users(request)}}",
"fontSize": "32px",
"fontWeight": "bold",
"color": "#fa8c16"
}
}
]
}

View File

@ -18,16 +18,48 @@
}
},
{
"widgettype": "RefreshWidget",
"id": "cards_refresh",
"widgettype": "ResponsableBox",
"options": {
"period_seconds": 10,
"url": "{{entire_url('api/dashboard_cards.dspy')}}"
}
"gap": "16px",
"minWidth": "250px"
},
"subwidgets": [
{
"widgettype": "RefreshWidget",
"id": "refresh_today_usage",
"options": {
"period_seconds": 10,
"url": "{{entire_url('today_usage.ui')}}"
}
},
{
"widgettype": "RefreshWidget",
"id": "refresh_today_amount",
"options": {
"period_seconds": 10,
"url": "{{entire_url('today_amount.ui')}}"
}
},
{
"widgettype": "RefreshWidget",
"id": "refresh_total_users",
"options": {
"period_seconds": 10,
"url": "{{entire_url('total_users.ui')}}"
}
},
{
"widgettype": "RefreshWidget",
"id": "refresh_concurrent_users",
"options": {
"period_seconds": 10,
"url": "{{entire_url('concurrent_users.ui')}}"
}
}
]
},
{
"widgettype": "VBox",
"id": "chart_section",
"options": {
"bgcolor": "#FFFFFF",
"padding": "24px",
@ -48,15 +80,11 @@
}
},
{
"widgettype": "ChartBar",
"id": "top_models_chart",
"widgettype": "RefreshWidget",
"id": "refresh_top_models_chart",
"options": {
"height": "300px",
"width": "100%",
"data_url": "{{entire_url('api/get_top_models.dspy')}}",
"nameField": "model_name",
"valueFields": ["cnt", "total_amount"],
"refresh_period": 10
"period_seconds": 10,
"url": "{{entire_url('top_models_chart.ui')}}"
}
}
]

32
wwwroot/today_amount.ui Normal file
View File

@ -0,0 +1,32 @@
{
"widgettype": "VBox",
"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": "¥{{get_today_amount(request)|round(2)}}",
"fontSize": "32px",
"fontWeight": "bold",
"color": "#52c41a"
}
}
]
}

32
wwwroot/today_usage.ui Normal file
View File

@ -0,0 +1,32 @@
{
"widgettype": "VBox",
"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": "{{get_today_usage(request)}}",
"fontSize": "32px",
"fontWeight": "bold",
"color": "#1890ff"
}
}
]
}

View File

@ -0,0 +1,10 @@
{
"widgettype": "ChartBar",
"options": {
"height": "300px",
"width": "100%",
"nameField": "model_name",
"valueFields": ["cnt", "total_amount"],
"data": {{get_top_models(request)|tojson}}
}
}

32
wwwroot/total_users.ui Normal file
View File

@ -0,0 +1,32 @@
{
"widgettype": "VBox",
"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": "{{get_total_users(request)}}",
"fontSize": "32px",
"fontWeight": "bold",
"color": "#722ed1"
}
}
]
}