appChart.tsx 17 KB

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