From a2507b1373bf6c400d96e23f78ac3666397e9fb6 Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Sat, 14 Feb 2026 14:57:06 +0800 Subject: [PATCH] feat(usage): add service health card with 7-day contribution grid --- src/components/usage/ServiceHealthCard.tsx | 181 +++++++++++++++ src/components/usage/index.ts | 3 + src/i18n/locales/en.json | 6 + src/i18n/locales/ru.json | 6 + src/i18n/locales/zh-CN.json | 6 + src/pages/UsagePage.module.scss | 245 +++++++++++++++++++++ src/pages/UsagePage.tsx | 4 + src/utils/usage.ts | 93 ++++++++ 8 files changed, 544 insertions(+) create mode 100644 src/components/usage/ServiceHealthCard.tsx diff --git a/src/components/usage/ServiceHealthCard.tsx b/src/components/usage/ServiceHealthCard.tsx new file mode 100644 index 0000000..6c9def1 --- /dev/null +++ b/src/components/usage/ServiceHealthCard.tsx @@ -0,0 +1,181 @@ +import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + collectUsageDetails, + calculateServiceHealthData, + type ServiceHealthData, + type StatusBlockDetail, +} from '@/utils/usage'; +import type { UsagePayload } from './hooks/useUsageData'; +import styles from '@/pages/UsagePage.module.scss'; + +const COLOR_STOPS = [ + { r: 239, g: 68, b: 68 }, // #ef4444 + { r: 250, g: 204, b: 21 }, // #facc15 + { r: 34, g: 197, b: 94 }, // #22c55e +] as const; + +function rateToColor(rate: number): string { + const t = Math.max(0, Math.min(1, rate)); + const segment = t < 0.5 ? 0 : 1; + const localT = segment === 0 ? t * 2 : (t - 0.5) * 2; + const from = COLOR_STOPS[segment]; + const to = COLOR_STOPS[segment + 1]; + const r = Math.round(from.r + (to.r - from.r) * localT); + const g = Math.round(from.g + (to.g - from.g) * localT); + const b = Math.round(from.b + (to.b - from.b) * localT); + return `rgb(${r}, ${g}, ${b})`; +} + +function formatDateTime(timestamp: number): string { + const date = new Date(timestamp); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const day = date.getDate().toString().padStart(2, '0'); + const h = date.getHours().toString().padStart(2, '0'); + const m = date.getMinutes().toString().padStart(2, '0'); + return `${month}/${day} ${h}:${m}`; +} + +export interface ServiceHealthCardProps { + usage: UsagePayload | null; + loading: boolean; +} + +export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) { + const { t } = useTranslation(); + const [activeTooltip, setActiveTooltip] = useState(null); + const gridRef = useRef(null); + + const healthData: ServiceHealthData = useMemo(() => { + const details = usage ? collectUsageDetails(usage) : []; + return calculateServiceHealthData(details); + }, [usage]); + + const hasData = healthData.totalSuccess + healthData.totalFailure > 0; + + useEffect(() => { + if (activeTooltip === null) return; + const handler = (e: PointerEvent) => { + if (gridRef.current && !gridRef.current.contains(e.target as Node)) { + setActiveTooltip(null); + } + }; + document.addEventListener('pointerdown', handler); + return () => document.removeEventListener('pointerdown', handler); + }, [activeTooltip]); + + const handlePointerEnter = useCallback((e: React.PointerEvent, idx: number) => { + if (e.pointerType === 'mouse') { + setActiveTooltip(idx); + } + }, []); + + const handlePointerLeave = useCallback((e: React.PointerEvent) => { + if (e.pointerType === 'mouse') { + setActiveTooltip(null); + } + }, []); + + const handlePointerDown = useCallback((e: React.PointerEvent, idx: number) => { + if (e.pointerType === 'touch') { + e.preventDefault(); + setActiveTooltip((prev) => (prev === idx ? null : idx)); + } + }, []); + + const getTooltipPositionClass = (idx: number): string => { + const col = Math.floor(idx / healthData.rows); + if (col <= 2) return styles.healthTooltipLeft; + if (col >= healthData.cols - 3) return styles.healthTooltipRight; + return ''; + }; + + const getTooltipVerticalClass = (idx: number): string => { + const row = idx % healthData.rows; + if (row <= 1) return styles.healthTooltipBelow; + return ''; + }; + + const renderTooltip = (detail: StatusBlockDetail, idx: number) => { + const total = detail.success + detail.failure; + const posClass = getTooltipPositionClass(idx); + const vertClass = getTooltipVerticalClass(idx); + const timeRange = `${formatDateTime(detail.startTime)} – ${formatDateTime(detail.endTime)}`; + + return ( +
+ {timeRange} + {total > 0 ? ( + + {t('status_bar.success_short')} {detail.success} + {t('status_bar.failure_short')} {detail.failure} + ({(detail.rate * 100).toFixed(1)}%) + + ) : ( + {t('status_bar.no_requests')} + )} +
+ ); + }; + + const rateClass = !hasData + ? '' + : healthData.successRate >= 90 + ? styles.healthRateHigh + : healthData.successRate >= 50 + ? styles.healthRateMedium + : styles.healthRateLow; + + return ( +
+
+

{t('service_health.title')}

+
+ {t('service_health.window')} + + {loading ? '--' : hasData ? `${healthData.successRate.toFixed(1)}%` : '--'} + +
+
+
+ {healthData.blockDetails.map((detail, idx) => { + const isIdle = detail.rate === -1; + const blockStyle = isIdle ? undefined : { backgroundColor: rateToColor(detail.rate) }; + const isActive = activeTooltip === idx; + + return ( +
handlePointerEnter(e, idx)} + onPointerLeave={handlePointerLeave} + onPointerDown={(e) => handlePointerDown(e, idx)} + > +
+ {isActive && renderTooltip(detail, idx)} +
+ ); + })} +
+
+ {t('service_health.oldest')} +
+
+
+
+
+
+ {t('service_health.newest')} +
+
+ ); +} diff --git a/src/components/usage/index.ts b/src/components/usage/index.ts index e07bc8b..7c4776d 100644 --- a/src/components/usage/index.ts +++ b/src/components/usage/index.ts @@ -35,3 +35,6 @@ export type { TokenBreakdownChartProps } from './TokenBreakdownChart'; export { CostTrendChart } from './CostTrendChart'; export type { CostTrendChartProps } from './CostTrendChart'; + +export { ServiceHealthCard } from './ServiceHealthCard'; +export type { ServiceHealthCardProps } from './ServiceHealthCard'; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 8efa501..4fcc00b 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -837,6 +837,12 @@ "failure_short": "✗", "no_requests": "No requests" }, + "service_health": { + "title": "Service Health", + "window": "Last 7 days", + "oldest": "Oldest", + "newest": "Latest" + }, "logs": { "title": "Logs Viewer", "refresh_button": "Refresh Logs", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 2afb0cf..788e1d3 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -840,6 +840,12 @@ "failure_short": "✗", "no_requests": "Нет запросов" }, + "service_health": { + "title": "Состояние сервиса", + "window": "Последние 7 дней", + "oldest": "Старые", + "newest": "Новые" + }, "logs": { "title": "Просмотр журналов", "refresh_button": "Обновить журналы", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 3a7bb65..6ae82af 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -837,6 +837,12 @@ "failure_short": "✗", "no_requests": "无请求" }, + "service_health": { + "title": "服务健康监测", + "window": "最近 7 天", + "oldest": "最早", + "newest": "最新" + }, "logs": { "title": "日志查看", "refresh_button": "刷新日志", diff --git a/src/pages/UsagePage.module.scss b/src/pages/UsagePage.module.scss index a2ffadb..92d432d 100644 --- a/src/pages/UsagePage.module.scss +++ b/src/pages/UsagePage.module.scss @@ -910,3 +910,248 @@ color: var(--text-tertiary); margin: 10px 0 0 0; } + +// Service Health Card +.healthCard { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: $radius-lg; + padding: 18px; + display: flex; + flex-direction: column; + gap: 14px; + box-shadow: var(--shadow-lg); +} + +.healthHeader { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.healthTitle { + font-size: 14px; + font-weight: 700; + color: var(--text-primary); + margin: 0; +} + +.healthMeta { + display: flex; + align-items: center; + gap: 10px; +} + +.healthWindow { + font-size: 11px; + color: var(--text-tertiary); +} + +.healthRate { + display: flex; + align-items: center; + font-size: 12px; + font-weight: 600; + white-space: nowrap; + padding: 4px 8px; + border-radius: 6px; + background: var(--bg-tertiary); +} + +.healthRateHigh { + color: var(--success-badge-text, #065f46); + background: var(--success-badge-bg, #d1fae5); +} + +.healthRateMedium { + color: var(--warning-text, #92400e); + background: var(--warning-bg, #fef3c7); +} + +.healthRateLow { + color: var(--failure-badge-text); + background: var(--failure-badge-bg); +} + +.healthGrid { + display: grid; + gap: 3px; + grid-auto-flow: column; + width: fit-content; + margin: 0 auto; +} + +.healthBlockWrapper { + position: relative; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + width: 10px; + height: 10px; +} + +.healthBlock { + width: 100%; + height: 100%; + border-radius: 2px; + transition: transform 0.15s ease, opacity 0.15s ease; + + .healthBlockWrapper:hover &, + .healthBlockWrapper.healthBlockActive & { + transform: scaleY(1.6); + opacity: 0.85; + } +} + +.healthBlockIdle { + background-color: var(--border-secondary, #e5e7eb); +} + +.healthTooltip { + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + background: var(--bg-primary, #fff); + border: 1px solid var(--border-secondary, #e5e7eb); + border-radius: 6px; + padding: 6px 10px; + font-size: 11px; + line-height: 1.5; + white-space: nowrap; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); + z-index: $z-dropdown; + pointer-events: none; + color: var(--text-primary); + + &::after { + content: ''; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border: 5px solid transparent; + border-top-color: var(--bg-primary, #fff); + } + + &::before { + content: ''; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border: 6px solid transparent; + border-top-color: var(--border-secondary, #e5e7eb); + } +} + +// When tooltip should appear below (for top rows) +.healthTooltipBelow { + bottom: auto; + top: calc(100% + 8px); + + &::after { + top: auto; + bottom: 100%; + border-top-color: transparent; + border-bottom-color: var(--bg-primary, #fff); + } + + &::before { + top: auto; + bottom: 100%; + border-top-color: transparent; + border-bottom-color: var(--border-secondary, #e5e7eb); + } +} + +.healthTooltipLeft { + left: 0; + transform: translateX(0); + + &::after, + &::before { + left: 8px; + transform: none; + } +} + +.healthTooltipRight { + left: auto; + right: 0; + transform: translateX(0); + + &::after, + &::before { + left: auto; + right: 8px; + transform: none; + } +} + +.healthTooltipTime { + color: var(--text-secondary); + display: block; + margin-bottom: 2px; +} + +.healthTooltipStats { + display: flex; + align-items: center; + gap: 8px; +} + +.healthTooltipSuccess { + color: var(--success-color, #22c55e); +} + +.healthTooltipFailure { + color: var(--danger-color, #ef4444); +} + +.healthTooltipRate { + color: var(--text-secondary); + margin-left: 2px; +} + +.healthLegend { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + flex-wrap: wrap; +} + +.healthLegendLabel { + font-size: 10px; + color: var(--text-tertiary); +} + +.healthLegendColors { + display: flex; + gap: 3px; +} + +.healthLegendBlock { + width: 10px; + height: 10px; + border-radius: 2px; +} + +@include mobile { + .healthBlockWrapper { + width: 8px; + height: 8px; + } + + .healthTooltip { + font-size: 10px; + padding: 4px 8px; + } + + .healthLegendBlock { + width: 8px; + height: 8px; + } +} diff --git a/src/pages/UsagePage.tsx b/src/pages/UsagePage.tsx index 3502f6e..3f603d3 100644 --- a/src/pages/UsagePage.tsx +++ b/src/pages/UsagePage.tsx @@ -27,6 +27,7 @@ import { CredentialStatsCard, TokenBreakdownChart, CostTrendChart, + ServiceHealthCard, useUsageData, useSparklines, useChartData @@ -308,6 +309,9 @@ export function UsagePage() { onChange={handleChartLinesChange} /> + {/* Service Health */} + + {/* Charts Grid */}
= Array.from( + { length: BLOCK_COUNT }, + () => ({ success: 0, failure: 0 }) + ); + + let totalSuccess = 0; + let totalFailure = 0; + + usageDetails.forEach((detail) => { + const timestamp = Date.parse(detail.timestamp); + if (Number.isNaN(timestamp) || timestamp < windowStart || timestamp > now) { + return; + } + + const ageMs = now - timestamp; + const blockIndex = BLOCK_COUNT - 1 - Math.floor(ageMs / BLOCK_DURATION_MS); + + if (blockIndex >= 0 && blockIndex < BLOCK_COUNT) { + if (detail.failed) { + blockStats[blockIndex].failure += 1; + totalFailure += 1; + } else { + blockStats[blockIndex].success += 1; + totalSuccess += 1; + } + } + }); + + const blocks: StatusBlockState[] = []; + const blockDetails: StatusBlockDetail[] = []; + + blockStats.forEach((stat, idx) => { + const total = stat.success + stat.failure; + if (total === 0) { + blocks.push('idle'); + } else if (stat.failure === 0) { + blocks.push('success'); + } else if (stat.success === 0) { + blocks.push('failure'); + } else { + blocks.push('mixed'); + } + + const blockStartTime = windowStart + idx * BLOCK_DURATION_MS; + blockDetails.push({ + success: stat.success, + failure: stat.failure, + rate: total > 0 ? stat.success / total : -1, + startTime: blockStartTime, + endTime: blockStartTime + BLOCK_DURATION_MS, + }); + }); + + const total = totalSuccess + totalFailure; + const successRate = total > 0 ? (totalSuccess / total) * 100 : 100; + + return { + blocks, + blockDetails, + successRate, + totalSuccess, + totalFailure, + rows: ROWS, + cols: COLS, + }; +} + export function computeKeyStats(usageData: unknown, masker: (val: string) => string = maskApiKey): KeyStats { const apis = getApisRecord(usageData); if (!apis) {