appChart.tsx 16 KB

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