appChart.tsx 12 KB

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