mirror of
https://github.com/coaidev/coai.git
synced 2025-05-28 17:30:15 +09:00
feat: support user type chart and model usage percentage chart (with #59)
This commit is contained in:
parent
518963aad1
commit
28d2287cd7
@ -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
|
||||
}
|
||||
|
@ -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))
|
||||
|
@ -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)
|
||||
|
@ -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> {
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
95
app/src/components/admin/assemblies/ModelUsageChart.tsx
Normal file
95
app/src/components/admin/assemblies/ModelUsageChart.tsx
Normal 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;
|
80
app/src/components/admin/assemblies/UserTypeChart.tsx
Normal file
80
app/src/components/admin/assemblies/UserTypeChart.tsx
Normal 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;
|
@ -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": "模型名称",
|
||||
|
@ -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",
|
||||
|
@ -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": "プリセット設定",
|
||||
|
@ -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": "Настройки маски",
|
||||
|
@ -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, "");
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user