From 28d2287cd7dfd0a6a400747eda638e0651647067 Mon Sep 17 00:00:00 2001 From: Zhang Minghan Date: Wed, 24 Jan 2024 23:33:54 +0800 Subject: [PATCH] feat: support user type chart and model usage percentage chart (with #59) --- admin/analysis.go | 68 +++++++++++++ admin/controller.go | 9 ++ admin/router.go | 1 + app/src/admin/api/chart.ts | 18 ++++ app/src/admin/types.ts | 9 ++ app/src/assets/admin/dashboard.less | 13 +++ app/src/components/admin/ChartBox.tsx | 30 +++++- .../admin/assemblies/ModelUsageChart.tsx | 95 +++++++++++++++++++ .../admin/assemblies/UserTypeChart.tsx | 80 ++++++++++++++++ app/src/resources/i18n/cn.json | 10 ++ app/src/resources/i18n/en.json | 12 ++- app/src/resources/i18n/ja.json | 12 ++- app/src/resources/i18n/ru.json | 12 ++- app/src/utils/base.ts | 12 +++ 14 files changed, 375 insertions(+), 6 deletions(-) create mode 100644 app/src/components/admin/assemblies/ModelUsageChart.tsx create mode 100644 app/src/components/admin/assemblies/UserTypeChart.tsx diff --git a/admin/analysis.go b/admin/analysis.go index 31660936..4947b684 100644 --- a/admin/analysis.go +++ b/admin/analysis.go @@ -8,6 +8,15 @@ import ( "time" ) +type UserTypeForm struct { + Normal int64 `json:"normal"` + ApiPaid int64 `json:"api_paid"` + BasicPlan int64 `json:"basic_plan"` + StandardPlan int64 `json:"standard_plan"` + ProPlan int64 `json:"pro_plan"` + Total int64 `json:"total"` +} + func getDates(t []time.Time) []string { return utils.Each[time.Time, string](t, func(date time.Time) string { return date.Format("1/2") @@ -102,3 +111,62 @@ func GetErrorData(cache *redis.Client) ErrorChartForm { }), } } + +func GetUserTypeData(db *sql.DB) (UserTypeForm, error) { + var form UserTypeForm + + // get total users + if err := db.QueryRow(` + SELECT COUNT(*) FROM auth + `).Scan(&form.Total); err != nil { + return form, err + } + + // get subscription users count (current subscription) + // level 1: basic plan, level 2: standard plan, level 3: pro plan + if err := db.QueryRow(` + SELECT + (SELECT COUNT(*) FROM subscription WHERE level = 1 AND expired_at > NOW()), + (SELECT COUNT(*) FROM subscription WHERE level = 2 AND expired_at > NOW()), + (SELECT COUNT(*) FROM subscription WHERE level = 3 AND expired_at > NOW()) + `).Scan(&form.BasicPlan, &form.StandardPlan, &form.ProPlan); err != nil { + return form, err + } + + initialQuota := channel.SystemInstance.GetInitialQuota() + + // get api paid users count + // match any of the following conditions to be considered as api paid user + // condition 1: `quota` + `used` > initial_quota in `quota` table + // condition 2: have subscription `total_month` > 0 but expired in `subscription` table + + // condition 1: get `quota` + `used` > initial_quota count in `quota` table but do not have subscription + var quotaPaid int64 + if err := db.QueryRow(` + SELECT COUNT(*) FROM ( + SELECT + (SELECT COUNT(*) FROM quota WHERE quota + used > ? AND user_id NOT IN (SELECT user_id FROM subscription WHERE total_month > 0 AND expired_at > NOW())) + ) AS quota_paid + `, initialQuota).Scan("aPaid); err != nil { + return form, err + } + + // condition 2: get subscription `total_month` > 0 but expired count in `subscription` table, but do not have `quota` + `used` > initial_quota + var subscriptionPaid int64 + if err := db.QueryRow(` + SELECT COUNT(*) FROM ( + SELECT + (SELECT COUNT(*) FROM subscription WHERE total_month > 0 AND expired_at > NOW() + AND user_id NOT IN (SELECT user_id FROM quota WHERE quota + used > ?)) + ) AS subscription_paid + `, initialQuota).Scan(&subscriptionPaid); err != nil { + return form, err + } + + form.ApiPaid = quotaPaid + subscriptionPaid + + // get normal users count + form.Normal = form.Total - form.ApiPaid - form.BasicPlan - form.StandardPlan - form.ProPlan + + return form, nil +} diff --git a/admin/controller.go b/admin/controller.go index 1f6bca93..e505e867 100644 --- a/admin/controller.go +++ b/admin/controller.go @@ -88,6 +88,15 @@ func ErrorAnalysisAPI(c *gin.Context) { c.JSON(http.StatusOK, GetErrorData(cache)) } +func UserTypeAnalysisAPI(c *gin.Context) { + db := utils.GetDBFromContext(c) + if form, err := GetUserTypeData(db); err != nil { + c.JSON(http.StatusOK, &UserTypeForm{}) + } else { + c.JSON(http.StatusOK, form) + } +} + func RedeemListAPI(c *gin.Context) { db := utils.GetDBFromContext(c) c.JSON(http.StatusOK, GetRedeemData(db)) diff --git a/admin/router.go b/admin/router.go index 33310412..b01accce 100644 --- a/admin/router.go +++ b/admin/router.go @@ -13,6 +13,7 @@ func Register(app *gin.RouterGroup) { app.GET("/admin/analytics/request", RequestAnalysisAPI) app.GET("/admin/analytics/billing", BillingAnalysisAPI) app.GET("/admin/analytics/error", ErrorAnalysisAPI) + app.GET("/admin/analytics/user", UserTypeAnalysisAPI) app.GET("/admin/invitation/list", InvitationPaginationAPI) app.POST("/admin/invitation/generate", GenerateInvitationAPI) diff --git a/app/src/admin/api/chart.ts b/app/src/admin/api/chart.ts index 4b4cb6a7..7f8c1126 100644 --- a/app/src/admin/api/chart.ts +++ b/app/src/admin/api/chart.ts @@ -9,6 +9,7 @@ import { RedeemResponse, RequestChartResponse, UserResponse, + UserTypeChartResponse, } from "@/admin/types.ts"; import axios from "axios"; import { getErrorMessage } from "@/utils/base.ts"; @@ -68,6 +69,23 @@ export async function getErrorChart(): Promise { } } +export async function getUserTypeChart(): Promise { + try { + const response = await axios.get("/admin/analytics/user"); + return response.data as UserTypeChartResponse; + } catch (e) { + console.warn(e); + return { + total: 0, + normal: 0, + api_paid: 0, + basic_plan: 0, + standard_plan: 0, + pro_plan: 0, + }; + } +} + export async function getInvitationList( page: number, ): Promise { diff --git a/app/src/admin/types.ts b/app/src/admin/types.ts index 52643ab1..6da3eff1 100644 --- a/app/src/admin/types.ts +++ b/app/src/admin/types.ts @@ -32,6 +32,15 @@ export type ErrorChartResponse = { value: number[]; }; +export type UserTypeChartResponse = { + total: number; + normal: number; + api_paid: number; + basic_plan: number; + standard_plan: number; + pro_plan: number; +}; + export type InvitationData = { code: string; quota: number; diff --git a/app/src/assets/admin/dashboard.less b/app/src/assets/admin/dashboard.less index f244d020..bf9540c8 100644 --- a/app/src/assets/admin/dashboard.less +++ b/app/src/assets/admin/dashboard.less @@ -127,12 +127,25 @@ user-select: none; .chart { + #model-usage-chart { + max-height: 8rem !important; + margin: 1rem 0; + } + + canvas { + max-height: 10rem !important; + } + .chart-title { display: flex; flex-direction: row; align-items: center; flex-wrap: nowrap; + .chart-title-info { + color: hsl(var(--text-secondary)); + } + svg { position: relative; top: 1px; diff --git a/app/src/components/admin/ChartBox.tsx b/app/src/components/admin/ChartBox.tsx index 552d3b35..87a10568 100644 --- a/app/src/components/admin/ChartBox.tsx +++ b/app/src/components/admin/ChartBox.tsx @@ -5,6 +5,7 @@ import { ErrorChartResponse, ModelChartResponse, RequestChartResponse, + UserTypeChartResponse, } from "@/admin/types.ts"; import { ArcElement, Chart, Filler, LineElement, PointElement } from "chart.js"; @@ -29,7 +30,10 @@ import { getErrorChart, getModelChart, getRequestChart, + getUserTypeChart, } from "@/admin/api/chart.ts"; +import ModelUsageChart from "@/components/admin/assemblies/ModelUsageChart.tsx"; +import UserTypeChart from "@/components/admin/assemblies/UserTypeChart.tsx"; Chart.register( CategoryScale, @@ -96,11 +100,21 @@ function ChartBox() { value: [], }); + const [user, setUser] = useState({ + total: 0, + normal: 0, + api_paid: 0, + basic_plan: 0, + standard_plan: 0, + pro_plan: 0, + }); + useEffectAsync(async () => { setModel(await getModelChart()); setRequest(await getRequestChart()); setBilling(await getBillingChart()); setError(await getErrorChart()); + setUser(await getUserTypeChart()); }, []); return ( @@ -109,9 +123,9 @@ function ChartBox() {
-
@@ -122,6 +136,16 @@ function ChartBox() { dark={dark} /> +
+ +
+
+ +
diff --git a/app/src/components/admin/assemblies/ModelUsageChart.tsx b/app/src/components/admin/assemblies/ModelUsageChart.tsx new file mode 100644 index 00000000..10d1b6f2 --- /dev/null +++ b/app/src/components/admin/assemblies/ModelUsageChart.tsx @@ -0,0 +1,95 @@ +import { Doughnut } from "react-chartjs-2"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Loader2 } from "lucide-react"; +import Tips from "@/components/Tips.tsx"; +import { sum } from "@/utils/base.ts"; +import { getModelColor } from "@/admin/colors.ts"; + +type ModelChartProps = { + labels: string[]; + datasets: { + model: string; + data: number[]; + }[]; + dark?: boolean; +}; + +type DataUsage = { + model: string; + usage: number; +}; + +function ModelUsageChart({ labels, datasets, dark }: ModelChartProps) { + const { t } = useTranslation(); + + const usage = useMemo((): Record => { + const usage: Record = {}; + datasets.forEach((dataset) => { + usage[dataset.model] = sum(dataset.data); + }); + return usage; + }, [datasets]); + + const data = useMemo((): DataUsage[] => { + const models: string[] = Object.keys(usage); + const data: number[] = models.map((model) => usage[model]); + + // sort by usage + return models + .map((model, i): DataUsage => ({ model, usage: data[i] })) + .sort((a, b) => b.usage - a.usage); + }, [usage]); + + const chartData = useMemo(() => { + return { + labels: data.map((item) => item.model), + datasets: [ + { + data: data.map((item) => item.usage), + backgroundColor: data.map((item) => getModelColor(item.model)), + borderWidth: 0, + }, + ], + }; + }, [labels, datasets]); + + const options = useMemo(() => { + const text = dark ? "#fff" : "#000"; + + return { + responsive: true, + color: text, + borderWidth: 0, + defaultFontColor: text, + defaultFontSize: 16, + defaultFontFamily: "Andika", + // set labels to right side + plugins: { + legend: { + position: "right", + }, + }, + }; + }, [dark]); + + return ( +
+

+

+ {t("admin.model-usage-chart")} + +

+ {labels.length === 0 && ( + + )} +

+ { + // @ts-ignore + + } +
+ ); +} + +export default ModelUsageChart; diff --git a/app/src/components/admin/assemblies/UserTypeChart.tsx b/app/src/components/admin/assemblies/UserTypeChart.tsx new file mode 100644 index 00000000..cd1ef10b --- /dev/null +++ b/app/src/components/admin/assemblies/UserTypeChart.tsx @@ -0,0 +1,80 @@ +import { Doughnut } from "react-chartjs-2"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Loader2 } from "lucide-react"; +import { UserTypeChartResponse } from "@/admin/types.ts"; + +type UserTypeChartProps = { + data: UserTypeChartResponse; + dark?: boolean; +}; + +function UserTypeChart({ data, dark }: UserTypeChartProps) { + const { t } = useTranslation(); + + const chart = useMemo(() => { + return { + labels: [ + t("admin.identity.normal"), + t("admin.identity.api_paid"), + t("admin.identity.basic_plan"), + t("admin.identity.standard_plan"), + t("admin.identity.pro_plan"), + ], + datasets: [ + { + data: [ + data.normal, + data.api_paid, + data.basic_plan, + data.standard_plan, + data.pro_plan, + ], + backgroundColor: ["#fff", "#aaa", "#ffa64e", "#ff840b", "#ff7e00"], + borderWidth: 0, + }, + ], + }; + }, [data]); + + const options = useMemo(() => { + const text = dark ? "#fff" : "#000"; + + return { + responsive: true, + color: text, + borderWidth: 0, + defaultFontColor: text, + defaultFontSize: 16, + defaultFontFamily: "Andika", + // set labels to right side + plugins: { + legend: { + position: "right", + }, + }, + }; + }, [dark]); + + return ( +
+

+

+

{t("admin.user-type-chart")}

+

+ {t("admin.user-type-chart-info", { total: data.total })} +

+

+ {data.total === 0 && ( + + )} +

+ { + // @ts-ignore + + } +
+ ); +} + +export default UserTypeChart; diff --git a/app/src/resources/i18n/cn.json b/app/src/resources/i18n/cn.json index 5892432f..86cb4bc7 100644 --- a/app/src/resources/i18n/cn.json +++ b/app/src/resources/i18n/cn.json @@ -351,6 +351,9 @@ "seat": "位", "model-chart": "模型使用统计", "model-chart-tip": "Token 用量", + "model-usage-chart": "模型使用占比", + "user-type-chart": "用户类型占比", + "user-type-chart-info": "总共 {{total}} 用户", "request-chart": "请求量统计", "billing-chart": "收入统计", "error-chart": "错误统计", @@ -403,6 +406,13 @@ "generate": "批量生成", "generate-result": "生成结果", "error": "请求失败", + "identity": { + "normal": "普通用户", + "api_paid": "其他付费用户", + "basic_plan": "基础版订阅用户", + "standard_plan": "标准版订阅用户", + "pro_plan": "专业版订阅用户" + }, "market": { "title": "模型市场", "model-name": "模型名称", diff --git a/app/src/resources/i18n/en.json b/app/src/resources/i18n/en.json index 3a55145f..e2521759 100644 --- a/app/src/resources/i18n/en.json +++ b/app/src/resources/i18n/en.json @@ -548,7 +548,17 @@ "item-models-placeholder": "{{length}} models selected", "add-item": "add", "import-item": "Import" - } + }, + "model-usage-chart": "Proportion of models used", + "user-type-chart": "Proportion of user types", + "identity": { + "normal": "Normal", + "api_paid": "Other paying users", + "basic_plan": "Basic Subscribers", + "standard_plan": "Standard Subscribers", + "pro_plan": "Pro Subscribers" + }, + "user-type-chart-info": "Total {{total}} users" }, "mask": { "title": "Mask Settings", diff --git a/app/src/resources/i18n/ja.json b/app/src/resources/i18n/ja.json index 25333ab8..fd7ea0df 100644 --- a/app/src/resources/i18n/ja.json +++ b/app/src/resources/i18n/ja.json @@ -548,7 +548,17 @@ "item-models-placeholder": "{{length}}モデルが選択されました", "add-item": "登録", "import-item": "導入" - } + }, + "model-usage-chart": "使用機種の割合", + "user-type-chart": "ユーザータイプの割合", + "identity": { + "normal": "一般ユーザー", + "api_paid": "その他の有料ユーザー", + "basic_plan": "ベーシックサブスクライバー", + "standard_plan": "標準サブスクライバー", + "pro_plan": "Pro Subscribers" + }, + "user-type-chart-info": "合計{{total}}ユーザー" }, "mask": { "title": "プリセット設定", diff --git a/app/src/resources/i18n/ru.json b/app/src/resources/i18n/ru.json index fe75684a..cd3794de 100644 --- a/app/src/resources/i18n/ru.json +++ b/app/src/resources/i18n/ru.json @@ -548,7 +548,17 @@ "item-models-placeholder": "Выбрано моделей: {{length}}", "add-item": "Добавить", "import-item": "Импорт" - } + }, + "model-usage-chart": "Доля используемых моделей", + "user-type-chart": "Доля типов пользователей", + "identity": { + "normal": "обычный пользователь", + "api_paid": "Другие платящие пользователи", + "basic_plan": "Базовые подписчики", + "standard_plan": "Стандартные подписчики", + "pro_plan": "Подписчики Pro" + }, + "user-type-chart-info": "Всего пользователей: {{total}}" }, "mask": { "title": "Настройки маски", diff --git a/app/src/utils/base.ts b/app/src/utils/base.ts index fe73c0a3..45df9a18 100644 --- a/app/src/utils/base.ts +++ b/app/src/utils/base.ts @@ -27,6 +27,18 @@ export function asyncCaller(fn: (...args: any[]) => Promise) { }; } +export function sum(arr: number[]): number { + return arr.reduce((a, b) => a + b, 0); +} + +export function average(arr: number[]): number { + return sum(arr) / arr.length; +} + +export function getUniqueList(arr: T[]): T[] { + return [...new Set(arr)]; +} + export function getNumber(value: string, supportNegative = true): string { return value.replace(supportNegative ? /[^-0-9.]/g : /[^0-9.]/g, ""); }