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