diff --git a/src/web/components/ModelAnalysisPanel.tsx b/src/web/components/ModelAnalysisPanel.tsx index 58a0137..9db6f98 100644 --- a/src/web/components/ModelAnalysisPanel.tsx +++ b/src/web/components/ModelAnalysisPanel.tsx @@ -205,9 +205,31 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) { {callRanking.map((item, index) => { const latMs = item.avgLatencyMs; - const latColor = latMs > 10000 ? '#ef4444' : latMs > 3000 ? '#f59e0b' : '#22c55e'; - const latBg = latMs > 10000 ? 'rgba(239,68,68,0.08)' : latMs > 3000 ? 'rgba(245,158,11,0.08)' : 'rgba(34,197,94,0.08)'; - const latText = latMs >= 1000 ? `${(latMs / 1000).toFixed(latMs >= 10000 ? 0 : 1)}s` : `${latMs}ms`; + const latSec = latMs / 1000; + // ≤15s green, 15-60s gradient green→yellow→red, >60s or failed → red + let latColor: string; + let latBg: string; + if (latSec <= 15) { + // green gradient: 0s=#22c55e → 15s=blend towards yellow + const t = Math.min(latSec / 15, 1); + const r = Math.round(34 + t * (245 - 34)); + const g = Math.round(197 + t * (158 - 197)); + const b = Math.round(94 + t * (11 - 94)); + latColor = `rgb(${r},${g},${b})`; + latBg = `rgba(${r},${g},${b},0.08)`; + } else if (latSec <= 60) { + // yellow→red gradient: 15s=#f59e0b → 60s=#ef4444 + const t = Math.min((latSec - 15) / 45, 1); + const r = Math.round(245 + t * (239 - 245)); + const g = Math.round(158 + t * (68 - 158)); + const b = Math.round(11 + t * (68 - 11)); + latColor = `rgb(${r},${g},${b})`; + latBg = `rgba(${r},${g},${b},0.08)`; + } else { + latColor = '#ef4444'; + latBg = 'rgba(239,68,68,0.08)'; + } + const latText = latMs >= 1000 ? `${(latMs / 1000).toFixed(latSec >= 60 ? 0 : 1)}s` : `${latMs}ms`; const rateColor = item.successRate >= 90 ? '#16a34a' : item.successRate >= 60 ? '#d97706' : '#dc2626'; const rateBg = item.successRate >= 90 ? 'rgba(34,197,94,0.1)' : item.successRate >= 60 ? 'rgba(245,158,11,0.1)' : 'rgba(239,68,68,0.1)'; diff --git a/src/web/index.css b/src/web/index.css index 0179978..20da0b1 100644 --- a/src/web/index.css +++ b/src/web/index.css @@ -4028,6 +4028,18 @@ textarea { margin-bottom: 4px; } +.site-observability-count-badge { + display: inline-flex; + align-items: center; + padding: 1px 8px; + border-radius: 999px; + background: var(--color-primary-soft, color-mix(in srgb, var(--color-primary) 12%, transparent)); + color: var(--color-primary); + font-size: 11px; + font-weight: 600; + line-height: 1.6; +} + .site-observability-subtitle { font-size: 12px; color: var(--color-text-muted); @@ -4049,9 +4061,161 @@ textarea { width: 14px; height: 14px; border-radius: 4px; - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-border) 80%, transparent); + box-shadow: + inset 0 0 0 1px color-mix(in srgb, var(--color-border) 62%, transparent), + 0 1px 2px rgba(0, 0, 0, 0.08); } +.site-observability-toggle-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 12px; + border-radius: 999px; + border: 1px solid var(--color-border); + background: var(--color-bg); + color: var(--color-text-secondary); + font-size: 11px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; +} + +.site-observability-toggle-btn:hover { + border-color: var(--color-primary); + color: var(--color-primary); + background: var(--color-primary-soft, color-mix(in srgb, var(--color-primary) 8%, transparent)); +} + +/* Grid layout: 2 columns by default, 3 on wide screens */ +.site-observability-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; +} + +@media (min-width: 1400px) { + .site-observability-grid { + grid-template-columns: repeat(3, 1fr); + } +} + +@media (max-width: 780px) { + .site-observability-grid { + grid-template-columns: 1fr; + } +} + +.site-observability-card { + padding: 12px 14px; + border-radius: var(--radius-md); + border: 1px solid var(--color-border-light); + background: color-mix(in srgb, var(--color-bg) 88%, white); + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.site-observability-card:hover { + border-color: color-mix(in srgb, var(--color-primary) 40%, var(--color-border)); + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06); +} + +.site-observability-card--inactive { + opacity: 0.55; + border-style: dashed; +} + +.site-observability-card--inactive:hover { + opacity: 0.8; +} + +.site-observability-card-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; +} + +.site-observability-card-title { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + overflow: hidden; +} + +.site-observability-card-title .site-observability-site-name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 13px; + font-weight: 600; + color: var(--color-text-primary); +} + +.site-observability-card-metrics { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--color-text-secondary); + margin-bottom: 8px; + flex-wrap: wrap; +} + +.site-observability-metric-main { + font-weight: 700; + font-size: 13px; +} + +.site-observability-metric-sep { + color: var(--color-border); + font-weight: 300; +} + +.site-observability-log-link-compact { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 28px; + height: 28px; + border-radius: var(--radius-sm); + border: 1px solid var(--color-border-light); + background: var(--color-bg); + color: var(--color-text-muted); + text-decoration: none; + transition: all 0.15s ease; +} + +.site-observability-log-link-compact:hover { + border-color: var(--color-primary); + color: var(--color-primary); + background: var(--color-primary-soft, color-mix(in srgb, var(--color-primary) 8%, transparent)); +} + +.site-availability-strip-compact { + display: grid; + grid-template-columns: repeat(24, 10px); + gap: 4px; + align-items: end; + justify-content: space-between; + padding: 2px 0; +} + +.site-availability-strip-compact .site-availability-cell { + width: 10px; + height: 24px; + border-radius: 999px; +} + +.site-availability-cell-pill { + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-border) 68%, transparent); + backface-visibility: hidden; +} + +/* Keep old list styles for backward compat */ .site-observability-list { display: flex; flex-direction: column; @@ -4171,7 +4335,7 @@ textarea { transform: translateY(-1px); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-border) 78%, transparent), - 0 2px 6px rgba(15, 23, 42, 0.12); + 0 2px 4px rgba(15, 23, 42, 0.12); } .site-availability-cell-link:focus-visible, @@ -4313,6 +4477,20 @@ textarea { border-radius: 3px; } + .site-availability-strip-compact { + gap: 3px; + } + + .site-availability-strip-compact .site-availability-cell { + height: 20px; + width: 9px; + border-radius: 999px; + } + + .site-availability-strip-compact { + grid-template-columns: repeat(24, 9px); + } + .monitor-iframe { height: calc(100vh - 320px); min-height: 460px; diff --git a/src/web/pages/Dashboard.tsx b/src/web/pages/Dashboard.tsx index 5d92775..c07c3c5 100644 --- a/src/web/pages/Dashboard.tsx +++ b/src/web/pages/Dashboard.tsx @@ -69,8 +69,30 @@ function getAvailabilityColor(value: number | null | undefined): string { if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) { return 'var(--color-border-light)'; } - const hue = Math.max(0, Math.min(120, value * 1.2)); - return `hsl(${hue}, 72%, 45%)`; + const clamped = Math.max(0, Math.min(100, value)); + const low = { r: 229, g: 80, b: 69 }; // 鲜亮红 + const mid = { r: 217, g: 161, b: 37 }; // 鲜亮黄 + const high = { r: 82, g: 196, b: 26 }; // 鲜亮绿 + + const lerp = (a: number, b: number, t: number) => Math.round(a + (b - a) * t); + + let r: number; + let g: number; + let b: number; + + if (clamped <= 50) { + const t = clamped / 50; + r = lerp(low.r, mid.r, t); + g = lerp(low.g, mid.g, t); + b = lerp(low.b, mid.b, t); + } else { + const t = (clamped - 50) / 50; + r = lerp(mid.r, high.r, t); + g = lerp(mid.g, high.g, t); + b = lerp(mid.b, high.b, t); + } + + return `rgb(${r}, ${g}, ${b})`; } function padDateTimeSegment(value: number): string { @@ -147,6 +169,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN const [sites, setSites] = useState([]); const [siteSpeedStates, setSiteSpeedStates] = useState>({}); const [trendDays, setTrendDays] = useState(7); + const [showInactiveSites, setShowInactiveSites] = useState(false); const toast = useToast(); const normalizedAdminName = (adminName || '').trim() || '\u7ba1\u7406\u5458'; @@ -313,9 +336,14 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN const performanceWindowSeconds = Math.max(1, safeNumber(data?.performance?.windowSeconds) || 60); const requestsPerMinute = safeNumber(data?.performance?.requestsPerMinute); const tokensPerMinute = safeNumber(data?.performance?.tokensPerMinute); - const siteAvailability: SiteAvailabilitySummary[] = Array.isArray(data?.siteAvailability) + const rawSiteAvailability: SiteAvailabilitySummary[] = Array.isArray(data?.siteAvailability) ? data.siteAvailability : []; + const activeSites = rawSiteAvailability + .filter((s) => s.totalRequests > 0) + .sort((a, b) => (b.totalRequests || 0) - (a.totalRequests || 0)); + const inactiveSites = rawSiteAvailability.filter((s) => !s.totalRequests || s.totalRequests === 0); + const siteAvailability = showInactiveSites ? [...activeSites, ...inactiveSites] : activeSites; const getLatencyColor = (ms: number) => ( ms <= 500 @@ -570,68 +598,79 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
站点可用性观测 + {activeSites.length}/{rawSiteAvailability.length}
-
最近 24 小时,每个色块代表 1 小时,按单次上游请求统计
+
最近 24 小时 · 每色块 = 1h · 按使用量排序
-
- 低 - - - - 高 +
+
+ 低 + + + + 高 +
+ {inactiveSites.length > 0 && ( + + )}
{siteAvailability.length > 0 ? ( -
+
{siteAvailability.map((site) => ( -
-
-
-
- {site.siteName} - {site.platform && ( - {site.platform} - )} - {site.siteUrl && ( - - 打开 - - )} -
-
- {formatAvailabilityPercent(site.availabilityPercent)} 可用性 - - {site.averageLatencyMs != null ? `${site.averageLatencyMs}ms` : '—'} 平均响应 - - {Math.round(site.totalRequests || 0)} 次请求 - {Math.round(site.successCount || 0)} 成功 / {Math.round(site.failedCount || 0)} 失败 -
-
-
- - 查看日志 - +
0 ? '' : ' site-observability-card--inactive'}`}> +
+
+ {site.siteName} + {site.platform && ( + {site.platform} + )}
+ + +
-
+
+ + {formatAvailabilityPercent(site.availabilityPercent)} + + · + + {site.averageLatencyMs != null ? `${site.averageLatencyMs}ms` : '—'} + + · + {Math.round(site.totalRequests || 0)} 次 +
+
{site.buckets.map((bucket, index) => ( 0 ? 1 : 0.35, + opacity: bucket.totalRequests > 0 ? 1 : 0.3, }} + data-tooltip={[ + `时间:${formatAvailabilityBucketLabel(bucket)}`, + bucket.totalRequests > 0 + ? `可用性:${formatAvailabilityPercent(bucket.availabilityPercent)}` + : '可用性:无请求', + `请求:${bucket.totalRequests} 次`, + `成功/失败:${bucket.successCount}/${bucket.failedCount}`, + bucket.averageLatencyMs != null ? `平均响应:${bucket.averageLatencyMs}ms` : '平均响应:—', + ].join(' · ')} + data-tooltip-align="start" title={[ formatAvailabilityBucketLabel(bucket), bucket.totalRequests > 0 diff --git a/src/web/pages/dashboard.site-observability.test.tsx b/src/web/pages/dashboard.site-observability.test.tsx index 4b4644b..3889d06 100644 --- a/src/web/pages/dashboard.site-observability.test.tsx +++ b/src/web/pages/dashboard.site-observability.test.tsx @@ -120,10 +120,13 @@ describe('Dashboard site observability panel', () => { expect(collectText(panel)).toContain('Demo Site'); expect(collectText(panel)).toContain('75%'); expect(collectText(panel)).toContain('320ms'); - expect(collectText(panel)).toContain('查看日志'); + expect(logLink.props.title).toBe('查看日志'); expect(cells).toHaveLength(24); expect(String(cells[0]?.props.title || '')).toContain('可用性 100%'); expect(String(cells[7]?.props.title || '')).toContain('可用性 0%'); + expect(String(cells[0]?.props['data-tooltip'] || '')).toContain('时间:'); + expect(String(cells[0]?.props['data-tooltip'] || '')).toContain('可用性:100%'); + expect(String(cells[0]?.props['data-tooltip'] || '')).toContain('成功/失败:1/0'); expect(String(logLink.props.href || logLink.props.to || '')).toContain('/logs?siteId=1'); } finally { root?.unmount();