appChart.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  1. 'use client'
  2. import type { FC } from 'react'
  3. import React from 'react'
  4. import ReactECharts from 'echarts-for-react'
  5. import type { EChartsOption } from 'echarts'
  6. import useSWR from 'swr'
  7. import dayjs from 'dayjs'
  8. import { get } from 'lodash-es'
  9. import { useTranslation } from 'react-i18next'
  10. import { formatNumber } from '@/utils/format'
  11. import Basic from '@/app/components/app-sidebar/basic'
  12. import Loading from '@/app/components/base/loading'
  13. import type { AppDailyConversationsResponse, AppDailyEndUsersResponse, AppTokenCostsResponse } from '@/models/app'
  14. import { getAppDailyConversations, getAppDailyEndUsers, getAppStatistics, getAppTokenCosts } from '@/service/apps'
  15. const valueFormatter = (v: string | number) => v
  16. const COLOR_TYPE_MAP = {
  17. green: {
  18. lineColor: 'rgba(6, 148, 162, 1)',
  19. bgColor: ['rgba(6, 148, 162, 0.2)', 'rgba(67, 174, 185, 0.08)'],
  20. },
  21. orange: {
  22. lineColor: 'rgba(255, 138, 76, 1)',
  23. bgColor: ['rgba(254, 145, 87, 0.2)', 'rgba(255, 138, 76, 0.1)'],
  24. },
  25. blue: {
  26. lineColor: 'rgba(28, 100, 242, 1)',
  27. bgColor: ['rgba(28, 100, 242, 0.3)', 'rgba(28, 100, 242, 0.1)'],
  28. },
  29. }
  30. const COMMON_COLOR_MAP = {
  31. label: '#9CA3AF',
  32. splitLineLight: '#F3F4F6',
  33. splitLineDark: '#E5E7EB',
  34. }
  35. type IColorType = 'green' | 'orange' | 'blue'
  36. type IChartType = 'conversations' | 'endUsers' | 'costs'
  37. type IChartConfigType = { colorType: IColorType; showTokens?: boolean }
  38. const commonDateFormat = 'MMM D, YYYY'
  39. const CHART_TYPE_CONFIG: Record<string, IChartConfigType> = {
  40. conversations: {
  41. colorType: 'green',
  42. },
  43. endUsers: {
  44. colorType: 'orange',
  45. },
  46. costs: {
  47. colorType: 'blue',
  48. showTokens: true,
  49. },
  50. }
  51. const sum = (arr: number[]): number => {
  52. return arr.reduce((acr, cur) => {
  53. return acr + cur
  54. })
  55. }
  56. export type PeriodParams = {
  57. name: string
  58. query: {
  59. start: string
  60. end: string
  61. }
  62. }
  63. export type IBizChartProps = {
  64. period: PeriodParams
  65. id: string
  66. }
  67. export type IChartProps = {
  68. className?: string
  69. basicInfo: { title: string; explanation: string; timePeriod: string }
  70. valueKey?: string
  71. isAvg?: boolean
  72. unit?: string
  73. yMax?: number
  74. chartType: IChartType
  75. chartData: AppDailyConversationsResponse | AppDailyEndUsersResponse | AppTokenCostsResponse | { data: Array<{ date: string; count: number }> }
  76. }
  77. const Chart: React.FC<IChartProps> = ({
  78. basicInfo: { title, explanation, timePeriod },
  79. chartType = 'conversations',
  80. chartData,
  81. valueKey,
  82. isAvg,
  83. unit = '',
  84. yMax,
  85. className,
  86. }) => {
  87. const { t } = useTranslation()
  88. const statistics = chartData.data
  89. const statisticsLen = statistics.length
  90. const extraDataForMarkLine = new Array(statisticsLen >= 2 ? statisticsLen - 2 : statisticsLen).fill('1')
  91. extraDataForMarkLine.push('')
  92. extraDataForMarkLine.unshift('')
  93. const xData = statistics.map(({ date }) => date)
  94. const yField = valueKey || Object.keys(statistics[0]).find(name => name.includes('count')) || ''
  95. const yData = statistics.map((item) => {
  96. // @ts-expect-error field is valid
  97. return item[yField] || 0
  98. })
  99. const options: EChartsOption = {
  100. dataset: {
  101. dimensions: ['date', yField],
  102. source: statistics,
  103. },
  104. grid: { top: 8, right: 36, bottom: 0, left: 0, containLabel: true },
  105. tooltip: {
  106. trigger: 'item',
  107. position: 'top',
  108. borderWidth: 0,
  109. },
  110. xAxis: [{
  111. type: 'category',
  112. boundaryGap: false,
  113. axisLabel: {
  114. color: COMMON_COLOR_MAP.label,
  115. hideOverlap: true,
  116. overflow: 'break',
  117. formatter(value) {
  118. return dayjs(value).format(commonDateFormat)
  119. },
  120. },
  121. axisLine: { show: false },
  122. axisTick: { show: false },
  123. splitLine: {
  124. show: true,
  125. lineStyle: {
  126. color: COMMON_COLOR_MAP.splitLineLight,
  127. width: 1,
  128. type: [10, 10],
  129. },
  130. interval(index) {
  131. return index === 0 || index === xData.length - 1
  132. },
  133. },
  134. }, {
  135. position: 'bottom',
  136. boundaryGap: false,
  137. data: extraDataForMarkLine,
  138. axisLabel: { show: false },
  139. axisLine: { show: false },
  140. axisTick: { show: false },
  141. splitLine: {
  142. show: true,
  143. lineStyle: {
  144. color: COMMON_COLOR_MAP.splitLineDark,
  145. },
  146. interval(index, value) {
  147. return !!value
  148. },
  149. },
  150. }],
  151. yAxis: {
  152. max: yMax ?? 'dataMax',
  153. type: 'value',
  154. axisLabel: { color: COMMON_COLOR_MAP.label, hideOverlap: true },
  155. splitLine: {
  156. lineStyle: {
  157. color: COMMON_COLOR_MAP.splitLineLight,
  158. },
  159. },
  160. },
  161. series: [
  162. {
  163. type: 'line',
  164. showSymbol: true,
  165. // symbol: 'circle',
  166. // triggerLineEvent: true,
  167. symbolSize: 4,
  168. lineStyle: {
  169. color: COLOR_TYPE_MAP[CHART_TYPE_CONFIG[chartType].colorType].lineColor,
  170. width: 2,
  171. },
  172. itemStyle: {
  173. color: COLOR_TYPE_MAP[CHART_TYPE_CONFIG[chartType].colorType].lineColor,
  174. },
  175. areaStyle: {
  176. color: {
  177. type: 'linear',
  178. x: 0,
  179. y: 0,
  180. x2: 0,
  181. y2: 1,
  182. colorStops: [{
  183. offset: 0, color: COLOR_TYPE_MAP[CHART_TYPE_CONFIG[chartType].colorType].bgColor[0],
  184. }, {
  185. offset: 1, color: COLOR_TYPE_MAP[CHART_TYPE_CONFIG[chartType].colorType].bgColor[1],
  186. }],
  187. global: false,
  188. },
  189. },
  190. tooltip: {
  191. padding: [8, 12, 8, 12],
  192. formatter(params) {
  193. return `<div style='color:#6B7280;font-size:12px'>${params.name}</div>
  194. <div style='font-size:14px;color:#1F2A37'>${valueFormatter((params.data as any)[yField])}
  195. ${!CHART_TYPE_CONFIG[chartType].showTokens
  196. ? ''
  197. : `<span style='font-size:12px'>
  198. <span style='margin-left:4px;color:#6B7280'>(</span>
  199. <span style='color:#FF8A4C'>~$${get(params.data, 'total_price', 0)}</span>
  200. <span style='color:#6B7280'>)</span>
  201. </span>`}
  202. </div>`
  203. },
  204. },
  205. },
  206. ],
  207. }
  208. const sumData = isAvg ? (sum(yData) / yData.length) : sum(yData)
  209. return (
  210. <div className={`flex flex-col w-full px-6 py-4 border-[0.5px] rounded-lg border-gray-200 shadow-sm ${className ?? ''}`}>
  211. <div className='mb-3'>
  212. <Basic name={title} type={timePeriod} hoverTip={explanation} />
  213. </div>
  214. <div className='mb-4'>
  215. <Basic
  216. name={chartType !== 'costs' ? (sumData.toLocaleString() + unit) : `${sumData < 1000 ? sumData : (`${formatNumber(Math.round(sumData / 1000))}k`)}`}
  217. type={!CHART_TYPE_CONFIG[chartType].showTokens
  218. ? ''
  219. : <span>{t('appOverview.analysis.tokenUsage.consumed')} Tokens<span className='text-sm'>
  220. <span className='ml-1 text-gray-500'>(</span>
  221. <span className='text-orange-400'>~{sum(statistics.map(item => parseFloat(get(item, 'total_price', '0')))).toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 4 })}</span>
  222. <span className='text-gray-500'>)</span>
  223. </span></span>}
  224. textStyle={{ main: `!text-3xl !font-normal ${sumData === 0 ? '!text-gray-300' : ''}` }} />
  225. </div>
  226. <ReactECharts option={options} style={{ height: 160 }} />
  227. </div>
  228. )
  229. }
  230. const getDefaultChartData = ({ start, end, key = 'count' }: { start: string; end: string; key?: string }) => {
  231. const diffDays = dayjs(end).diff(dayjs(start), 'day')
  232. return Array.from({ length: diffDays || 1 }, () => ({ date: '', [key]: 0 })).map((item, index) => {
  233. item.date = dayjs(start).add(index, 'day').format(commonDateFormat)
  234. return item
  235. })
  236. }
  237. export const ConversationsChart: FC<IBizChartProps> = ({ id, period }) => {
  238. const { t } = useTranslation()
  239. const { data: response } = useSWR({ url: `/apps/${id}/statistics/daily-conversations`, params: period.query }, getAppDailyConversations)
  240. if (!response)
  241. return <Loading />
  242. const noDataFlag = !response.data || response.data.length === 0
  243. return <Chart
  244. basicInfo={{ title: t('appOverview.analysis.totalMessages.title'), explanation: t('appOverview.analysis.totalMessages.explanation'), timePeriod: period.name }}
  245. chartData={!noDataFlag ? response : { data: getDefaultChartData(period.query) }}
  246. chartType='conversations'
  247. {...(noDataFlag && { yMax: 500 })}
  248. />
  249. }
  250. export const EndUsersChart: FC<IBizChartProps> = ({ id, period }) => {
  251. const { t } = useTranslation()
  252. const { data: response } = useSWR({ url: `/apps/${id}/statistics/daily-end-users`, id, params: period.query }, getAppDailyEndUsers)
  253. if (!response)
  254. return <Loading />
  255. const noDataFlag = !response.data || response.data.length === 0
  256. return <Chart
  257. basicInfo={{ title: t('appOverview.analysis.activeUsers.title'), explanation: t('appOverview.analysis.activeUsers.explanation'), timePeriod: period.name }}
  258. chartData={!noDataFlag ? response : { data: getDefaultChartData(period.query) }}
  259. chartType='endUsers'
  260. {...(noDataFlag && { yMax: 500 })}
  261. />
  262. }
  263. export const AvgSessionInteractions: FC<IBizChartProps> = ({ id, period }) => {
  264. const { t } = useTranslation()
  265. const { data: response } = useSWR({ url: `/apps/${id}/statistics/average-session-interactions`, params: period.query }, getAppStatistics)
  266. if (!response)
  267. return <Loading />
  268. const noDataFlag = !response.data || response.data.length === 0
  269. return <Chart
  270. basicInfo={{ title: t('appOverview.analysis.avgSessionInteractions.title'), explanation: t('appOverview.analysis.avgSessionInteractions.explanation'), timePeriod: period.name }}
  271. chartData={!noDataFlag ? response : { data: getDefaultChartData({ ...period.query, key: 'interactions' }) } as any}
  272. chartType='conversations'
  273. valueKey='interactions'
  274. isAvg
  275. {...(noDataFlag && { yMax: 500 })}
  276. />
  277. }
  278. export const AvgResponseTime: FC<IBizChartProps> = ({ id, period }) => {
  279. const { t } = useTranslation()
  280. const { data: response } = useSWR({ url: `/apps/${id}/statistics/average-response-time`, params: period.query }, getAppStatistics)
  281. if (!response)
  282. return <Loading />
  283. const noDataFlag = !response.data || response.data.length === 0
  284. return <Chart
  285. basicInfo={{ title: t('appOverview.analysis.avgResponseTime.title'), explanation: t('appOverview.analysis.avgResponseTime.explanation'), timePeriod: period.name }}
  286. chartData={!noDataFlag ? response : { data: getDefaultChartData({ ...period.query, key: 'latency' }) } as any}
  287. valueKey='latency'
  288. chartType='conversations'
  289. isAvg
  290. unit={t('appOverview.analysis.ms') as string}
  291. {...(noDataFlag && { yMax: 500 })}
  292. />
  293. }
  294. export const UserSatisfactionRate: FC<IBizChartProps> = ({ id, period }) => {
  295. const { t } = useTranslation()
  296. const { data: response } = useSWR({ url: `/apps/${id}/statistics/user-satisfaction-rate`, params: period.query }, getAppStatistics)
  297. if (!response)
  298. return <Loading />
  299. const noDataFlag = !response.data || response.data.length === 0
  300. return <Chart
  301. basicInfo={{ title: t('appOverview.analysis.userSatisfactionRate.title'), explanation: t('appOverview.analysis.userSatisfactionRate.explanation'), timePeriod: period.name }}
  302. chartData={!noDataFlag ? response : { data: getDefaultChartData({ ...period.query, key: 'rate' }) } as any}
  303. valueKey='rate'
  304. chartType='endUsers'
  305. isAvg
  306. {...(noDataFlag && { yMax: 1000 })}
  307. />
  308. }
  309. export const CostChart: FC<IBizChartProps> = ({ id, period }) => {
  310. const { t } = useTranslation()
  311. const { data: response } = useSWR({ url: `/apps/${id}/statistics/token-costs`, params: period.query }, getAppTokenCosts)
  312. if (!response)
  313. return <Loading />
  314. const noDataFlag = !response.data || response.data.length === 0
  315. return <Chart
  316. basicInfo={{ title: t('appOverview.analysis.tokenUsage.title'), explanation: t('appOverview.analysis.tokenUsage.explanation'), timePeriod: period.name }}
  317. chartData={!noDataFlag ? response : { data: getDefaultChartData(period.query) }}
  318. chartType='costs'
  319. {...(noDataFlag && { yMax: 100 })}
  320. />
  321. }
  322. export default Chart