appChart.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  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-xs ${className ?? ''}`}>
  215. <div className='mb-3'>
  216. <Basic name={title} type={timePeriod} hoverTip={explanation} />
  217. </div>
  218. <div className='mb-4'>
  219. <Basic
  220. isExtraInLine={CHART_TYPE_CONFIG[chartType].showTokens}
  221. name={chartType !== 'costs' ? (sumData.toLocaleString() + unit) : `${sumData < 1000 ? sumData : (`${formatNumber(Math.round(sumData / 1000))}k`)}`}
  222. type={!CHART_TYPE_CONFIG[chartType].showTokens
  223. ? ''
  224. : <span>{t('appOverview.analysis.tokenUsage.consumed')} Tokens<span className='text-sm'>
  225. <span className='ml-1 text-gray-500'>(</span>
  226. <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>
  227. <span className='text-gray-500'>)</span>
  228. </span></span>}
  229. textStyle={{ main: `!text-3xl !font-normal ${sumData === 0 ? '!text-gray-300' : ''}` }} />
  230. </div>
  231. <ReactECharts option={options} style={{ height: 160 }} />
  232. </div>
  233. )
  234. }
  235. const getDefaultChartData = ({ start, end, key = 'count' }: { start: string; end: string; key?: string }) => {
  236. const diffDays = dayjs(end).diff(dayjs(start), 'day')
  237. return Array.from({ length: diffDays || 1 }, () => ({ date: '', [key]: 0 })).map((item, index) => {
  238. item.date = dayjs(start).add(index, 'day').format(commonDateFormat)
  239. return item
  240. })
  241. }
  242. export const ConversationsChart: FC<IBizChartProps> = ({ id, period }) => {
  243. const { t } = useTranslation()
  244. const { data: response } = useSWR({ url: `/apps/${id}/statistics/daily-conversations`, params: period.query }, getAppDailyConversations)
  245. if (!response)
  246. return <Loading />
  247. const noDataFlag = !response.data || response.data.length === 0
  248. return <Chart
  249. basicInfo={{ title: t('appOverview.analysis.totalMessages.title'), explanation: t('appOverview.analysis.totalMessages.explanation'), timePeriod: period.name }}
  250. chartData={!noDataFlag ? response : { data: getDefaultChartData(period.query ?? defaultPeriod) }}
  251. chartType='conversations'
  252. {...(noDataFlag && { yMax: 500 })}
  253. />
  254. }
  255. export const EndUsersChart: FC<IBizChartProps> = ({ id, period }) => {
  256. const { t } = useTranslation()
  257. const { data: response } = useSWR({ url: `/apps/${id}/statistics/daily-end-users`, id, params: period.query }, getAppDailyEndUsers)
  258. if (!response)
  259. return <Loading />
  260. const noDataFlag = !response.data || response.data.length === 0
  261. return <Chart
  262. basicInfo={{ title: t('appOverview.analysis.activeUsers.title'), explanation: t('appOverview.analysis.activeUsers.explanation'), timePeriod: period.name }}
  263. chartData={!noDataFlag ? response : { data: getDefaultChartData(period.query ?? defaultPeriod) }}
  264. chartType='endUsers'
  265. {...(noDataFlag && { yMax: 500 })}
  266. />
  267. }
  268. export const AvgSessionInteractions: FC<IBizChartProps> = ({ id, period }) => {
  269. const { t } = useTranslation()
  270. const { data: response } = useSWR({ url: `/apps/${id}/statistics/average-session-interactions`, params: period.query }, getAppStatistics)
  271. if (!response)
  272. return <Loading />
  273. const noDataFlag = !response.data || response.data.length === 0
  274. return <Chart
  275. basicInfo={{ title: t('appOverview.analysis.avgSessionInteractions.title'), explanation: t('appOverview.analysis.avgSessionInteractions.explanation'), timePeriod: period.name }}
  276. chartData={!noDataFlag ? response : { data: getDefaultChartData({ ...(period.query ?? defaultPeriod), key: 'interactions' }) } as any}
  277. chartType='conversations'
  278. valueKey='interactions'
  279. isAvg
  280. {...(noDataFlag && { yMax: 500 })}
  281. />
  282. }
  283. export const AvgResponseTime: FC<IBizChartProps> = ({ id, period }) => {
  284. const { t } = useTranslation()
  285. const { data: response } = useSWR({ url: `/apps/${id}/statistics/average-response-time`, params: period.query }, getAppStatistics)
  286. if (!response)
  287. return <Loading />
  288. const noDataFlag = !response.data || response.data.length === 0
  289. return <Chart
  290. basicInfo={{ title: t('appOverview.analysis.avgResponseTime.title'), explanation: t('appOverview.analysis.avgResponseTime.explanation'), timePeriod: period.name }}
  291. chartData={!noDataFlag ? response : { data: getDefaultChartData({ ...(period.query ?? defaultPeriod), key: 'latency' }) } as any}
  292. valueKey='latency'
  293. chartType='conversations'
  294. isAvg
  295. unit={t('appOverview.analysis.ms') as string}
  296. {...(noDataFlag && { yMax: 500 })}
  297. />
  298. }
  299. export const TokenPerSecond: FC<IBizChartProps> = ({ id, period }) => {
  300. const { t } = useTranslation()
  301. const { data: response } = useSWR({ url: `/apps/${id}/statistics/tokens-per-second`, params: period.query }, getAppStatistics)
  302. if (!response)
  303. return <Loading />
  304. const noDataFlag = !response.data || response.data.length === 0
  305. return <Chart
  306. basicInfo={{ title: t('appOverview.analysis.tps.title'), explanation: t('appOverview.analysis.tps.explanation'), timePeriod: period.name }}
  307. chartData={!noDataFlag ? response : { data: getDefaultChartData({ ...(period.query ?? defaultPeriod), key: 'tps' }) } as any}
  308. valueKey='tps'
  309. chartType='conversations'
  310. isAvg
  311. unit={t('appOverview.analysis.tokenPS') as string}
  312. {...(noDataFlag && { yMax: 100 })}
  313. />
  314. }
  315. export const UserSatisfactionRate: FC<IBizChartProps> = ({ id, period }) => {
  316. const { t } = useTranslation()
  317. const { data: response } = useSWR({ url: `/apps/${id}/statistics/user-satisfaction-rate`, params: period.query }, getAppStatistics)
  318. if (!response)
  319. return <Loading />
  320. const noDataFlag = !response.data || response.data.length === 0
  321. return <Chart
  322. basicInfo={{ title: t('appOverview.analysis.userSatisfactionRate.title'), explanation: t('appOverview.analysis.userSatisfactionRate.explanation'), timePeriod: period.name }}
  323. chartData={!noDataFlag ? response : { data: getDefaultChartData({ ...(period.query ?? defaultPeriod), key: 'rate' }) } as any}
  324. valueKey='rate'
  325. chartType='endUsers'
  326. isAvg
  327. {...(noDataFlag && { yMax: 1000 })}
  328. />
  329. }
  330. export const CostChart: FC<IBizChartProps> = ({ id, period }) => {
  331. const { t } = useTranslation()
  332. const { data: response } = useSWR({ url: `/apps/${id}/statistics/token-costs`, params: period.query }, getAppTokenCosts)
  333. if (!response)
  334. return <Loading />
  335. const noDataFlag = !response.data || response.data.length === 0
  336. return <Chart
  337. basicInfo={{ title: t('appOverview.analysis.tokenUsage.title'), explanation: t('appOverview.analysis.tokenUsage.explanation'), timePeriod: period.name }}
  338. chartData={!noDataFlag ? response : { data: getDefaultChartData(period.query ?? defaultPeriod) }}
  339. chartType='costs'
  340. {...(noDataFlag && { yMax: 100 })}
  341. />
  342. }
  343. export default Chart