fix(ui): eliminate KPI card height jump on load — all five cards fixed at 206px, chart area flex:1

Previously the skeleton was a fixed 186px but the real cards had different natural heights (status pie 110 + legend, tokens two-line sub + legend, etc.), so replacing the skeleton caused a size jump. Now every .kpi is a fixed 206px flex column; .k-chart absorbs the remaining space (flex:1) and the canvas absolutely fills it (draw fns read container height, not a hardcoded 86/110). Skeleton .ksk is exactly 206px with the same layout, so first paint and real cards are identical in size. Deployed (bak .bak.20260811w), server active.
This commit is contained in:
root
2026-08-11 18:02:28 +08:00
parent dbee5188f8
commit bce3f1b23d

View File

@ -102,9 +102,9 @@ pre.configbox { background:var(--card2); border:1px solid var(--line); border-ra
/* ---------- dashboard / usage metrics ---------- */
.kpi-skeletons { grid-column:1 / -1; display:grid; grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); gap:12px; }
.ksk { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:14px 16px; min-height:186px; display:flex; flex-direction:column; gap:10px; }
.ksk { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:14px 16px; height:206px; display:flex; flex-direction:column; gap:10px; }
.ksk i { display:block; height:11px; border-radius:6px; background:linear-gradient(90deg,var(--card2) 25%,var(--line) 37%,var(--card2) 63%); background-size:400% 100%; animation:shimmer 1.2s infinite; }
.ksk i:nth-child(1){ width:38%; align-self:flex-start; } .ksk i:nth-child(2){ width:68%; height:24px; } .ksk i:nth-child(3){ width:100%; height:86px; margin-top:4px; }
.ksk i:nth-child(1){ width:38%; align-self:flex-start; } .ksk i:nth-child(2){ width:68%; height:24px; } .ksk i:nth-child(3){ width:100%; flex:1 1 auto; margin:6px 0 0; }
@keyframes shimmer { 0%{background-position:100% 0} 100%{background-position:0 0} }
/* entry / transition animations */
@keyframes fadeUp { from{opacity:0;transform:translateY(8px)} to{opacity:1;transform:none} }
@ -114,17 +114,15 @@ pre.configbox { background:var(--card2); border:1px solid var(--line); border-ra
.tab-pane { animation:tabIn .25s ease; }
.chat-log .chat-empty { animation:fadeIn .3s ease; }
.kpis { display:grid; grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); gap:12px; margin-bottom:18px; }
.kpi { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:14px 16px; box-shadow:var(--shadow); }
.kpi .k-hdr { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:6px; }
.kpi { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:14px 16px; box-shadow:var(--shadow); display:flex; flex-direction:column; height:206px; }
.kpi .k-hdr { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:6px; flex:0 0 auto; }
.kpi .k-lab { font-size:12px; color:var(--muted); font-weight:600; white-space:nowrap; }
.kpi .k-val { font-size:26px; font-weight:800; margin-top:4px; font-variant-numeric:tabular-nums; letter-spacing:.3px; }
.kpi .k-val { font-size:26px; font-weight:800; margin-top:4px; font-variant-numeric:tabular-nums; letter-spacing:.3px; flex:0 0 auto; }
.kpi .k-val .u { font-size:13px; font-weight:600; color:var(--muted); margin-left:3px; }
.kpi .k-sub { font-size:11.5px; color:var(--muted); margin-top:4px; }
.kpi .k-sub { font-size:11.5px; color:var(--muted); margin-top:4px; flex:0 0 auto; }
.kpi .k-sub.k2 { display:flex; flex-direction:column; align-items:flex-start; line-height:1.5; }
.kpi .k-chart { margin-top:8px; position:relative; height:86px; }
.kpi canvas { width:100%; height:86px; display:block; }
.kpi.kpi-status .k-chart-pie { height:110px; }
.kpi.kpi-status canvas { height:110px; }
.kpi .k-chart { position:relative; flex:1 1 auto; min-height:40px; margin-top:8px; }
.kpi canvas { position:absolute; inset:0; width:100%; height:100%; display:block; }
.status-legend { display:flex; flex-wrap:wrap; gap:5px 9px; margin-top:6px; justify-content:center; }
.status-legend .lg { display:inline-flex; align-items:center; gap:5px; font-size:11px; color:var(--muted); white-space:nowrap; }
.status-legend .lg i { width:9px; height:9px; border-radius:2px; flex:0 0 auto; }
@ -915,7 +913,7 @@ function drawActiveChart(st) {
const ctx = cv.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const parent = cv.parentElement, rect = parent.getBoundingClientRect();
const H = 86;
const H = rect.height || 86;
cv.width = rect.width * dpr; cv.height = H * dpr; ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, cv.width, cv.height);
// sample: requests started in the last 3s (approx QPS). Records carry unix-ms time.
@ -945,14 +943,15 @@ function drawReqsChart(st) {
const ctx = cv.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const parent = cv.parentElement, rect = parent.getBoundingClientRect();
cv.width = rect.width * dpr; cv.height = 86 * dpr; ctx.scale(dpr, dpr);
const H = rect.height || 86;
cv.width = rect.width * dpr; cv.height = H * dpr; ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, cv.width, cv.height);
const recs = st.records || [];
// bucket by UTC day -> request counts (last 7 non-empty days)
const byDay = new Map();
recs.forEach(r => { const d = new Date(r.time).toISOString().slice(5,10); byDay.set(d, (byDay.get(d)||0)+1); });
const counts = Array.from(byDay.values()).slice(-7);
dailyBars(ctx, counts, rect.width, 86, '#3fb27f');
dailyBars(ctx, counts, rect.width, H, '#3fb27f');
}
const tokPalette = ['#3fb27f','#4fa3d8','#f0a45c','#b06ce0'];
function drawTokensChart(models) {
@ -960,7 +959,8 @@ function drawTokensChart(models) {
const ctx = cv.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const parent = cv.parentElement, rect = parent.getBoundingClientRect();
cv.width = rect.width * dpr; cv.height = 86 * dpr; ctx.scale(dpr, dpr);
const H = rect.height || 86;
cv.width = rect.width * dpr; cv.height = H * dpr; ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, cv.width, cv.height);
const rows = (models||[]).slice().sort((a,b)=>b.tokens-a.tokens).slice(0,4);
const tot = rows.reduce((a,x)=>a+x.tokens,0) || 1;
@ -968,7 +968,7 @@ function drawTokensChart(models) {
rows.forEach((m,i) => {
const w = Math.max(2, m.tokens / tot * rect.width);
ctx.fillStyle = tokPalette[i%tokPalette.length];
ctx.fillRect(x, 0, w, 86);
ctx.fillRect(x, 0, w, H);
x += w;
});
// color-swatch legend instead of on-chart text
@ -982,12 +982,13 @@ function drawLatencyChart(st) {
const ctx = cv.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const parent = cv.parentElement, rect = parent.getBoundingClientRect();
cv.width = rect.width * dpr; cv.height = 86 * dpr; ctx.scale(dpr, dpr);
const H = rect.height || 86;
cv.width = rect.width * dpr; cv.height = H * dpr; ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, cv.width, cv.height);
const recs = st.records || [];
// per-record latency sparkline (last 40)
const lat = recs.slice(-40).map(r => r.latency_ms || 0);
sparkline(ctx, lat, rect.width, 86, '#e06c6c');
sparkline(ctx, lat, rect.width, H, '#e06c6c');
}
function drawStatusChart(rows) {
const cv = document.getElementById('cv-status'); if (!cv) return;