mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-18 02:30:51 +08:00
feat(usage): add service health card with 7-day contribution grid
This commit is contained in:
181
src/components/usage/ServiceHealthCard.tsx
Normal file
181
src/components/usage/ServiceHealthCard.tsx
Normal file
@@ -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<number | null>(null);
|
||||
const gridRef = useRef<HTMLDivElement>(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 (
|
||||
<div className={`${styles.healthTooltip} ${posClass} ${vertClass}`}>
|
||||
<span className={styles.healthTooltipTime}>{timeRange}</span>
|
||||
{total > 0 ? (
|
||||
<span className={styles.healthTooltipStats}>
|
||||
<span className={styles.healthTooltipSuccess}>{t('status_bar.success_short')} {detail.success}</span>
|
||||
<span className={styles.healthTooltipFailure}>{t('status_bar.failure_short')} {detail.failure}</span>
|
||||
<span className={styles.healthTooltipRate}>({(detail.rate * 100).toFixed(1)}%)</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className={styles.healthTooltipStats}>{t('status_bar.no_requests')}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const rateClass = !hasData
|
||||
? ''
|
||||
: healthData.successRate >= 90
|
||||
? styles.healthRateHigh
|
||||
: healthData.successRate >= 50
|
||||
? styles.healthRateMedium
|
||||
: styles.healthRateLow;
|
||||
|
||||
return (
|
||||
<div className={styles.healthCard}>
|
||||
<div className={styles.healthHeader}>
|
||||
<h3 className={styles.healthTitle}>{t('service_health.title')}</h3>
|
||||
<div className={styles.healthMeta}>
|
||||
<span className={styles.healthWindow}>{t('service_health.window')}</span>
|
||||
<span className={`${styles.healthRate} ${rateClass}`}>
|
||||
{loading ? '--' : hasData ? `${healthData.successRate.toFixed(1)}%` : '--'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={styles.healthGrid}
|
||||
ref={gridRef}
|
||||
style={{
|
||||
gridTemplateRows: `repeat(${healthData.rows}, 10px)`,
|
||||
}}
|
||||
>
|
||||
{healthData.blockDetails.map((detail, idx) => {
|
||||
const isIdle = detail.rate === -1;
|
||||
const blockStyle = isIdle ? undefined : { backgroundColor: rateToColor(detail.rate) };
|
||||
const isActive = activeTooltip === idx;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`${styles.healthBlockWrapper} ${isActive ? styles.healthBlockActive : ''}`}
|
||||
onPointerEnter={(e) => handlePointerEnter(e, idx)}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
onPointerDown={(e) => handlePointerDown(e, idx)}
|
||||
>
|
||||
<div
|
||||
className={`${styles.healthBlock} ${isIdle ? styles.healthBlockIdle : ''}`}
|
||||
style={blockStyle}
|
||||
/>
|
||||
{isActive && renderTooltip(detail, idx)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className={styles.healthLegend}>
|
||||
<span className={styles.healthLegendLabel}>{t('service_health.oldest')}</span>
|
||||
<div className={styles.healthLegendColors}>
|
||||
<div className={`${styles.healthLegendBlock} ${styles.healthBlockIdle}`} />
|
||||
<div className={styles.healthLegendBlock} style={{ backgroundColor: '#ef4444' }} />
|
||||
<div className={styles.healthLegendBlock} style={{ backgroundColor: '#facc15' }} />
|
||||
<div className={styles.healthLegendBlock} style={{ backgroundColor: '#22c55e' }} />
|
||||
</div>
|
||||
<span className={styles.healthLegendLabel}>{t('service_health.newest')}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -840,6 +840,12 @@
|
||||
"failure_short": "✗",
|
||||
"no_requests": "Нет запросов"
|
||||
},
|
||||
"service_health": {
|
||||
"title": "Состояние сервиса",
|
||||
"window": "Последние 7 дней",
|
||||
"oldest": "Старые",
|
||||
"newest": "Новые"
|
||||
},
|
||||
"logs": {
|
||||
"title": "Просмотр журналов",
|
||||
"refresh_button": "Обновить журналы",
|
||||
|
||||
@@ -837,6 +837,12 @@
|
||||
"failure_short": "✗",
|
||||
"no_requests": "无请求"
|
||||
},
|
||||
"service_health": {
|
||||
"title": "服务健康监测",
|
||||
"window": "最近 7 天",
|
||||
"oldest": "最早",
|
||||
"newest": "最新"
|
||||
},
|
||||
"logs": {
|
||||
"title": "日志查看",
|
||||
"refresh_button": "刷新日志",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
CredentialStatsCard,
|
||||
TokenBreakdownChart,
|
||||
CostTrendChart,
|
||||
ServiceHealthCard,
|
||||
useUsageData,
|
||||
useSparklines,
|
||||
useChartData
|
||||
@@ -308,6 +309,9 @@ export function UsagePage() {
|
||||
onChange={handleChartLinesChange}
|
||||
/>
|
||||
|
||||
{/* Service Health */}
|
||||
<ServiceHealthCard usage={usage} loading={loading} />
|
||||
|
||||
{/* Charts Grid */}
|
||||
<div className={styles.chartsGrid}>
|
||||
<UsageChart
|
||||
|
||||
@@ -1236,6 +1236,99 @@ export function calculateStatusBarData(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务健康监测数据(最近168小时/7天,7×96网格)
|
||||
* 每个格子代表15分钟的健康度
|
||||
*/
|
||||
export interface ServiceHealthData {
|
||||
blocks: StatusBlockState[];
|
||||
blockDetails: StatusBlockDetail[];
|
||||
successRate: number;
|
||||
totalSuccess: number;
|
||||
totalFailure: number;
|
||||
rows: number;
|
||||
cols: number;
|
||||
}
|
||||
|
||||
export function calculateServiceHealthData(
|
||||
usageDetails: UsageDetail[]
|
||||
): ServiceHealthData {
|
||||
const ROWS = 7;
|
||||
const COLS = 96;
|
||||
const BLOCK_COUNT = ROWS * COLS; // 672
|
||||
const BLOCK_DURATION_MS = 15 * 60 * 1000; // 15 minutes
|
||||
const WINDOW_MS = BLOCK_COUNT * BLOCK_DURATION_MS; // 168 hours (7 days)
|
||||
|
||||
const now = Date.now();
|
||||
const windowStart = now - WINDOW_MS;
|
||||
|
||||
const blockStats: Array<{ success: number; failure: number }> = 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) {
|
||||
|
||||
Reference in New Issue
Block a user