Skip to content

Commit

Permalink
feat: support user type chart and model usage percentage chart (with #59
Browse files Browse the repository at this point in the history
)
  • Loading branch information
zmh-program committed Jan 24, 2024
1 parent 518963a commit 28d2287
Show file tree
Hide file tree
Showing 14 changed files with 375 additions and 6 deletions.
68 changes: 68 additions & 0 deletions admin/analysis.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
}
9 changes: 9 additions & 0 deletions admin/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
1 change: 1 addition & 0 deletions admin/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions app/src/admin/api/chart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
RedeemResponse,
RequestChartResponse,
UserResponse,
UserTypeChartResponse,
} from "@/admin/types.ts";
import axios from "axios";
import { getErrorMessage } from "@/utils/base.ts";
Expand Down Expand Up @@ -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> {
Expand Down
9 changes: 9 additions & 0 deletions app/src/admin/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
13 changes: 13 additions & 0 deletions app/src/assets/admin/dashboard.less
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
30 changes: 27 additions & 3 deletions app/src/components/admin/ChartBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
ErrorChartResponse,
ModelChartResponse,
RequestChartResponse,
UserTypeChartResponse,
} from "@/admin/types.ts";

import { ArcElement, Chart, Filler, LineElement, PointElement } from "chart.js";
Expand All @@ -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,
Expand Down Expand Up @@ -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 (
Expand All @@ -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>
Expand All @@ -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>
Expand Down
95 changes: 95 additions & 0 deletions app/src/components/admin/assemblies/ModelUsageChart.tsx
Original file line number Diff line number Diff line change
@@ -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;
Loading

0 comments on commit 28d2287

Please sign in to comment.