feat: support user type chart and model usage percentage chart (with #59)

This commit is contained in:
Zhang Minghan 2024-01-24 23:33:54 +08:00
parent 518963aad1
commit 28d2287cd7
14 changed files with 375 additions and 6 deletions

View File

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

View File

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

View File

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

View File

@ -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<ErrorChartResponse> {
}
}
export async function getUserTypeChart(): Promise<UserTypeChartResponse> {
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<InvitationResponse> {

View File

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

View File

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

View File

@ -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<UserTypeChartResponse>({
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() {
<ModelChart labels={model.date} datasets={model.value} dark={dark} />
</div>
<div className={`chart-box`}>
<RequestChart
labels={request.date}
datasets={request.value}
<ModelUsageChart
labels={model.date}
datasets={model.value}
dark={dark}
/>
</div>
@ -122,6 +136,16 @@ function ChartBox() {
dark={dark}
/>
</div>
<div className={`chart-box`}>
<UserTypeChart data={user} dark={dark} />
</div>
<div className={`chart-box`}>
<RequestChart
labels={request.date}
datasets={request.value}
dark={dark}
/>
</div>
<div className={`chart-box`}>
<ErrorChart labels={error.date} datasets={error.value} dark={dark} />
</div>

View File

@ -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<string, number> => {
const usage: Record<string, number> = {};
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 (
<div className={`chart`}>
<p className={`chart-title mb-2`}>
<p className={`flex flex-row items-center`}>
{t("admin.model-usage-chart")}
<Tips content={t("admin.model-chart-tip")} />
</p>
{labels.length === 0 && (
<Loader2 className={`h-4 w-4 inline-block animate-spin`} />
)}
</p>
{
// @ts-ignore
<Doughnut id={`model-usage-chart`} data={chartData} options={options} />
}
</div>
);
}
export default ModelUsageChart;

View File

@ -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 (
<div className={`chart`}>
<p className={`chart-title mb-2`}>
<p className={`flex flex-row items-center w-full`}>
<p>{t("admin.user-type-chart")}</p>
<p className={`text-sm ml-auto chart-title-info`}>
{t("admin.user-type-chart-info", { total: data.total })}
</p>
</p>
{data.total === 0 && (
<Loader2 className={`h-4 w-4 inline-block animate-spin`} />
)}
</p>
{
// @ts-ignore
<Doughnut id={`user-type-chart`} data={chart} options={options} />
}
</div>
);
}
export default UserTypeChart;

View File

@ -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": "模型名称",

View File

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

View File

@ -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": "プリセット設定",

View File

@ -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": "Настройки маски",

View File

@ -27,6 +27,18 @@ export function asyncCaller<T>(fn: (...args: any[]) => Promise<T>) {
};
}
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<T>(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, "");
}