Unified Agent + reusable Go agent core.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: refine console stats view

Lyric ea7c15af d58b794f

+364 -137
+6 -6
web/console/src/i18n/index.js
··· 777 777 stats_total_tokens: "总 Tokens", 778 778 stats_cached_input_tokens: "缓存命中 Tokens", 779 779 stats_cache_creation_input_tokens: "缓存写入 Tokens", 780 - stats_total_cost: "总成本", 781 - stats_costs: "成本", 782 - stats_input_cost: "输入成本", 783 - stats_output_cost: "输出成本", 784 - stats_cached_input_cost: "缓存命中成本", 785 - stats_cache_creation_input_cost: "缓存写入成本", 780 + stats_total_cost: "总费用", 781 + stats_costs: "费用", 782 + stats_input_cost: "输入费用", 783 + stats_output_cost: "输出费用", 784 + stats_cached_input_cost: "缓存命中费用", 785 + stats_cache_creation_input_cost: "缓存写入费用", 786 786 stats_tokens: "Tokens", 787 787 stats_api_host: "API Host", 788 788 stats_model: "模型",
+191 -56
web/console/src/views/StatsView.css
··· 13 13 .stats-hero { 14 14 position: relative; 15 15 display: grid; 16 - grid-template-columns: minmax(0, 0.88fr) minmax(340px, 1.12fr); 17 - gap: clamp(16px, 2.3vw, 34px); 16 + grid-template-columns: minmax(0, 1.08fr) minmax(320px, 0.92fr); 17 + grid-template-areas: 18 + "copy side" 19 + "spotlight side"; 20 + gap: clamp(18px, 2.4vw, 34px); 18 21 align-items: start; 19 - padding-top: 14px; 22 + } 23 + 24 + .stats-hero.block-default { 25 + padding: clamp(18px, 2vw, 24px); 26 + background: #fff; 27 + border: 1px solid var(--stats-line); 28 + } 29 + 30 + .stats-hero.block-default::before, 31 + .stats-hero.block-default::after { 32 + content: ""; 33 + position: absolute; 34 + width: 12px; 35 + height: 12px; 36 + border: 2px solid color-mix(in srgb, var(--stats-accent) 72%, transparent); 37 + pointer-events: none; 20 38 } 21 39 22 - .stats-hero::before, 40 + .stats-hero.block-default::before { 41 + top: -1px; 42 + left: -1px; 43 + border-right: none; 44 + border-bottom: none; 45 + } 46 + 47 + .stats-hero.block-default::after { 48 + right: -1px; 49 + bottom: -1px; 50 + border-left: none; 51 + border-top: none; 52 + } 53 + 23 54 .stats-host-block::before { 24 55 content: ""; 25 56 position: absolute; ··· 37 68 pointer-events: none; 38 69 } 39 70 71 + .stats-host-block:first-child::before { 72 + display: none; 73 + } 74 + 40 75 .stats-hero-copy, 76 + .stats-hero-side, 41 77 .stats-host-block, 42 78 .stats-section, 43 79 .stats-section-panel, ··· 46 82 min-width: 0; 47 83 } 48 84 85 + .stats-hero-side, 49 86 .stats-hero-copy { 50 87 display: grid; 51 - gap: 14px; 88 + } 89 + 90 + .stats-hero-copy { 91 + grid-area: copy; 92 + gap: 10px; 93 + align-content: start; 94 + } 95 + 96 + .stats-hero-side { 97 + position: relative; 98 + grid-area: side; 99 + gap: 16px; 100 + align-content: start; 101 + padding-left: clamp(18px, 2vw, 24px); 102 + padding-top: 2px; 52 103 } 53 104 54 - .stats-hero-head, 105 + .stats-hero-side::before { 106 + content: ""; 107 + position: absolute; 108 + top: 2px; 109 + bottom: 4px; 110 + left: 0; 111 + width: 1px; 112 + background: repeating-linear-gradient( 113 + 180deg, 114 + color-mix(in srgb, var(--stats-accent) 20%, transparent) 0 3px, 115 + transparent 6px 9px 116 + ); 117 + mask-image: linear-gradient( 118 + 180deg, 119 + transparent 0%, 120 + rgba(0, 0, 0, 0.92) 8%, 121 + rgba(0, 0, 0, 0.92) 84%, 122 + transparent 100% 123 + ); 124 + -webkit-mask-image: linear-gradient( 125 + 180deg, 126 + transparent 0%, 127 + rgba(0, 0, 0, 0.92) 8%, 128 + rgba(0, 0, 0, 0.92) 84%, 129 + transparent 100% 130 + ); 131 + opacity: 0.72; 132 + pointer-events: none; 133 + } 134 + 135 + .stats-hero-head { 136 + display: grid; 137 + gap: 8px; 138 + align-content: start; 139 + } 140 + 55 141 .stats-host-head { 56 142 display: flex; 57 143 align-items: flex-start; ··· 63 149 margin: 0; 64 150 display: flex; 65 151 flex-wrap: wrap; 66 - justify-content: flex-end; 67 - gap: 8px 14px; 152 + gap: 6px 14px; 68 153 font-family: var(--font-mono); 69 154 font-size: 11px; 70 155 line-height: 1.5; 71 156 color: var(--stats-muted); 157 + overflow-wrap: anywhere; 72 158 } 73 159 74 160 .stats-hero-meta-item { 75 - white-space: nowrap; 161 + white-space: normal; 76 162 } 77 163 78 - .stats-lead-block { 79 - display: grid; 80 - gap: 8px; 81 - } 82 - 83 - .stats-lead-label, 84 - .stats-glance-label, 164 + .stats-hero-primary-label, 165 + .stats-hero-secondary-label, 85 166 .stats-host-eyebrow, 86 167 .stats-ledger-label, 87 168 .stats-model-head-cell, 88 - .stats-inline-meta-label, 89 169 .stats-empty { 90 170 font-family: var(--font-mono); 91 171 font-size: 10px; ··· 95 175 color: var(--stats-muted); 96 176 } 97 177 98 - .stats-lead-value { 99 - display: block; 100 - font-family: var(--q-card-title-font-family); 101 - font-size: clamp(3rem, 5.7vw, 4.8rem); 102 - font-weight: 600; 103 - font-variant-numeric: tabular-nums; 104 - line-height: 0.94; 105 - letter-spacing: -0.045em; 106 - color: var(--stats-ink); 107 - } 108 - 109 - .stats-glance-grid { 178 + .stats-hero-spotlight { 179 + grid-area: spotlight; 110 180 display: grid; 111 - grid-template-columns: repeat(3, minmax(0, 1fr)); 112 - min-width: 0; 113 - border-top: 1px solid var(--stats-line-soft); 114 - border-left: 1px solid var(--stats-line-soft); 115 - } 116 - 117 - .stats-glance-item { 118 - display: grid; 181 + align-content: end; 119 182 gap: 6px; 120 183 min-width: 0; 121 - padding: 14px 14px 13px; 122 - border-right: 1px solid var(--stats-line-soft); 123 - border-bottom: 1px solid var(--stats-line-soft); 184 + min-height: clamp(76px, 9vw, 108px); 124 185 } 125 186 126 - .stats-glance-value, 187 + .stats-hero-primary-value, 188 + .stats-hero-secondary-value, 127 189 .stats-ledger-value { 128 190 display: block; 129 191 min-width: 0; ··· 136 198 overflow-wrap: anywhere; 137 199 } 138 200 201 + .stats-hero-primary-value { 202 + max-width: 11ch; 203 + font-size: clamp(2.8rem, 2.15rem + 2.4vw, 4.9rem); 204 + line-height: 0.9; 205 + letter-spacing: -0.055em; 206 + text-wrap: balance; 207 + } 208 + 209 + .stats-hero-primary-value-unavailable, 210 + .stats-hero-secondary-value-unavailable { 211 + color: var(--stats-muted); 212 + font-weight: 500; 213 + } 214 + 139 215 .stats-inline-meta { 140 216 display: flex; 141 217 flex-wrap: wrap; ··· 143 219 align-items: baseline; 144 220 } 145 221 146 - .stats-inline-meta-summary { 147 - padding-top: 2px; 222 + .stats-hero-detail-groups { 223 + display: grid; 224 + gap: 10px; 148 225 } 149 226 150 - .stats-inline-meta-host, 151 - .stats-inline-meta-model { 227 + .stats-hero-detail-groups .stats-inline-meta-summary { 228 + display: grid; 229 + grid-template-columns: repeat(4, minmax(0, 1fr)); 230 + gap: 10px 18px; 231 + align-items: start; 232 + } 233 + 234 + .stats-hero-detail-groups .stats-inline-meta-summary .stats-inline-meta-item { 235 + display: grid; 236 + gap: 4px; 237 + align-content: start; 238 + } 239 + 240 + .stats-inline-meta-summary { 152 241 padding-top: 2px; 153 242 } 154 243 ··· 159 248 min-width: 0; 160 249 } 161 250 251 + .stats-inline-meta-label { 252 + font-family: var(--font-mono); 253 + font-size: 10px; 254 + font-weight: 500; 255 + letter-spacing: 0.12em; 256 + text-transform: uppercase; 257 + color: var(--stats-muted); 258 + } 259 + 162 260 .stats-inline-meta-value { 163 261 font-size: 12px; 164 262 line-height: 1.45; 165 263 color: var(--stats-copy); 166 264 font-variant-numeric: tabular-nums; 167 265 overflow-wrap: anywhere; 266 + } 267 + 268 + .stats-hero-secondary-grid { 269 + display: grid; 270 + grid-template-columns: repeat(2, minmax(0, 1fr)); 271 + gap: 12px; 272 + min-width: 0; 273 + } 274 + 275 + .stats-hero-secondary-item { 276 + display: grid; 277 + gap: 6px; 278 + min-width: 0; 279 + min-height: 72px; 280 + align-content: end; 281 + padding: 14px 0 0; 282 + text-align: left; 283 + } 284 + 285 + .stats-hero-secondary-value { 286 + font-size: clamp(1.18rem, 1.08rem + 0.32vw, 1.4rem); 287 + line-height: 1.04; 168 288 } 169 289 170 290 .stats-section { ··· 429 549 @media (max-width: 1080px) { 430 550 .stats-hero { 431 551 grid-template-columns: 1fr; 552 + grid-template-areas: 553 + "copy" 554 + "spotlight" 555 + "side"; 556 + } 557 + 558 + .stats-hero-side { 559 + padding-left: 0; 560 + border-top: 1px solid var(--stats-line-soft); 561 + padding-top: 16px; 562 + } 563 + 564 + .stats-hero-side::before { 565 + display: none; 566 + } 567 + 568 + .stats-hero-detail-groups .stats-inline-meta-summary { 569 + grid-template-columns: repeat(2, minmax(0, 1fr)); 432 570 } 433 571 } 434 572 435 573 @media (max-width: 860px) { 436 - .stats-glance-grid { 437 - grid-template-columns: repeat(2, minmax(0, 1fr)); 574 + .stats-hero-secondary-grid { 575 + grid-template-columns: 1fr; 438 576 } 439 577 440 578 .stats-request-pill { ··· 443 581 } 444 582 445 583 @media (max-width: 720px) { 446 - .stats-hero-head, 447 584 .stats-host-head { 448 585 flex-direction: column; 449 586 align-items: flex-start; 450 587 } 451 - 452 - .stats-hero-meta { 453 - justify-content: flex-start; 454 - } 455 588 } 456 589 457 590 @media (max-width: 560px) { 458 - .stats-glance-grid, 459 591 .stats-band-grid { 460 - grid-template-columns: 1fr; 592 + grid-template-columns: repeat(2, minmax(0, 1fr)); 461 593 } 462 594 463 595 .stats-band-cell { 596 + border-top: 1px solid var(--stats-line-soft); 597 + } 598 + 599 + .stats-band-cell:nth-child(odd) { 464 600 border-left: 0; 465 - border-top: 1px solid var(--stats-line-soft); 466 601 } 467 602 468 - .stats-band-cell:first-child { 603 + .stats-band-cell:nth-child(-n + 2) { 469 604 border-top: 0; 470 605 } 471 606
+167 -75
web/console/src/views/StatsView.js
··· 51 51 } 52 52 } 53 53 54 - function summaryLeadMetric(t, totals) { 54 + function summaryHeroMetric(t, totals, key) { 55 55 const costCurrency = typeof totals?.cost_currency === "string" ? totals.cost_currency : "USD"; 56 - if (hasMetricValue(totals, "total_cost")) { 57 - return { 58 - key: "total_cost", 59 - label: t("stats_total_cost"), 60 - value: formatCost(totals.total_cost, costCurrency), 61 - }; 56 + switch (key) { 57 + case "total_cost": 58 + return { 59 + key, 60 + label: t("stats_total_cost"), 61 + value: hasMetricValue(totals, key) ? formatCost(totals[key], costCurrency) : "-", 62 + unavailable: !hasMetricValue(totals, key), 63 + }; 64 + case "total_tokens": 65 + return { 66 + key, 67 + label: t("stats_total_tokens"), 68 + value: hasMetricValue(totals, key) ? formatNumber(totals[key]) : "-", 69 + unavailable: !hasMetricValue(totals, key), 70 + }; 71 + case "requests": 72 + return { 73 + key, 74 + label: t("stats_requests"), 75 + value: hasMetricValue(totals, key) ? formatNumber(totals[key]) : "-", 76 + unavailable: !hasMetricValue(totals, key), 77 + }; 78 + default: 79 + return { 80 + key, 81 + label: key, 82 + value: "-", 83 + unavailable: true, 84 + }; 62 85 } 86 + } 87 + 88 + function summaryHeroMetrics(t, totals) { 89 + const orderedKeys = ["total_cost", "total_tokens", "requests"]; 90 + const primaryKey = orderedKeys.find((key) => hasMetricValue(totals, key)) || orderedKeys[0]; 63 91 return { 64 - key: "total_tokens", 65 - label: t("stats_total_tokens"), 66 - value: formatNumber(totals.total_tokens), 92 + primary: summaryHeroMetric(t, totals, primaryKey), 93 + secondary: orderedKeys.filter((key) => key !== primaryKey).map((key) => summaryHeroMetric(t, totals, key)), 67 94 }; 68 95 } 69 96 70 - function summaryPrimaryMetrics(t, totals) { 71 - const leadKey = summaryLeadMetric(t, totals).key; 97 + function summaryCostMetrics(t, totals, includeTotal = true) { 98 + const costCurrency = typeof totals?.cost_currency === "string" ? totals.cost_currency : "USD"; 72 99 return [ 73 - { key: "total_tokens", label: t("stats_total_tokens"), value: formatNumber(totals.total_tokens) }, 74 - { key: "input_tokens", label: t("stats_input_tokens"), value: formatNumber(totals.input_tokens) }, 75 - { key: "output_tokens", label: t("stats_output_tokens"), value: formatNumber(totals.output_tokens) }, 76 - { key: "requests", label: t("stats_requests"), value: formatNumber(totals.requests) }, 77 - { key: "cached_input_tokens", label: t("stats_cached_input_tokens"), value: formatNumber(totals.cached_input_tokens) }, 78 100 { 79 - key: "cache_creation_input_tokens", 80 - label: t("stats_cache_creation_input_tokens"), 81 - value: formatNumber(totals.cache_creation_input_tokens), 101 + key: "total_cost", 102 + label: t("stats_total_cost"), 103 + value: hasMetricValue(totals, "total_cost") ? formatCost(totals?.total_cost, costCurrency) : "-", 104 + unavailable: !hasMetricValue(totals, "total_cost"), 82 105 }, 83 - ].filter((item) => item.key !== leadKey); 84 - } 85 - 86 - function summaryCostMetrics(t, totals) { 87 - const costCurrency = typeof totals?.cost_currency === "string" ? totals.cost_currency : "USD"; 88 - return [ 89 106 { 90 107 key: "input_cost", 91 108 label: t("stats_input_cost"), 92 109 value: formatCost(totals?.input_cost, costCurrency), 93 - available: hasMetricValue(totals, "input_cost"), 110 + unavailable: !hasMetricValue(totals, "input_cost"), 94 111 }, 95 112 { 96 113 key: "output_cost", 97 114 label: t("stats_output_cost"), 98 115 value: formatCost(totals?.output_cost, costCurrency), 99 - available: hasMetricValue(totals, "output_cost"), 116 + unavailable: !hasMetricValue(totals, "output_cost"), 100 117 }, 101 118 { 102 119 key: "cached_input_cost", 103 120 label: t("stats_cached_input_cost"), 104 121 value: formatCost(totals?.cached_input_cost, costCurrency), 105 - available: hasMetricValue(totals, "cached_input_cost"), 122 + unavailable: !hasMetricValue(totals, "cached_input_cost"), 106 123 }, 107 124 { 108 125 key: "cache_creation_input_cost", 109 126 label: t("stats_cache_creation_input_cost"), 110 127 value: formatCost(totals?.cache_creation_input_cost, costCurrency), 111 - available: hasMetricValue(totals, "cache_creation_input_cost"), 128 + unavailable: !hasMetricValue(totals, "cache_creation_input_cost"), 112 129 }, 113 - ].filter((item) => item.available); 130 + ].filter((item) => (includeTotal || item.key !== "total_cost") && !item.unavailable); 131 + } 132 + 133 + function summaryTokenMetrics(t, totals, includeTotal = true) { 134 + return [ 135 + { 136 + key: "total_tokens", 137 + label: t("stats_total_tokens"), 138 + value: hasMetricValue(totals, "total_tokens") ? formatNumber(totals?.total_tokens) : "-", 139 + unavailable: !hasMetricValue(totals, "total_tokens"), 140 + }, 141 + { 142 + key: "input_tokens", 143 + label: t("stats_input_tokens"), 144 + value: hasMetricValue(totals, "input_tokens") ? formatNumber(totals?.input_tokens) : "-", 145 + unavailable: !hasMetricValue(totals, "input_tokens"), 146 + }, 147 + { 148 + key: "output_tokens", 149 + label: t("stats_output_tokens"), 150 + value: hasMetricValue(totals, "output_tokens") ? formatNumber(totals?.output_tokens) : "-", 151 + unavailable: !hasMetricValue(totals, "output_tokens"), 152 + }, 153 + { 154 + key: "cached_input_tokens", 155 + label: t("stats_cached_input_tokens"), 156 + value: hasMetricValue(totals, "cached_input_tokens") ? formatNumber(totals?.cached_input_tokens) : "-", 157 + unavailable: !hasMetricValue(totals, "cached_input_tokens"), 158 + }, 159 + { 160 + key: "cache_creation_input_tokens", 161 + label: t("stats_cache_creation_input_tokens"), 162 + value: hasMetricValue(totals, "cache_creation_input_tokens") 163 + ? formatNumber(totals?.cache_creation_input_tokens) 164 + : "-", 165 + unavailable: !hasMetricValue(totals, "cache_creation_input_tokens"), 166 + }, 167 + ].filter((item) => (includeTotal || item.key !== "total_tokens") && !item.unavailable); 114 168 } 115 169 116 170 function costMetrics(t, totals) { ··· 148 202 : "-", 149 203 unavailable: !hasMetricValue(totals, "cache_creation_input_cost"), 150 204 }, 151 - ].filter((item) => item.key === "total_cost" || !item.unavailable); 205 + ]; 152 206 } 153 207 154 208 function tokenMetrics(t, totals) { 155 209 return [ 156 - { key: "total_tokens", label: t("stats_total_tokens"), value: formatNumber(totals.total_tokens) }, 157 - { key: "input_tokens", label: t("stats_input_tokens"), value: formatNumber(totals.input_tokens) }, 158 - { key: "output_tokens", label: t("stats_output_tokens"), value: formatNumber(totals.output_tokens) }, 159 - { key: "cached_input_tokens", label: t("stats_cached_input_tokens"), value: formatNumber(totals.cached_input_tokens) }, 210 + { 211 + key: "total_tokens", 212 + label: t("stats_total_tokens"), 213 + value: hasMetricValue(totals, "total_tokens") ? formatNumber(totals.total_tokens) : "-", 214 + unavailable: !hasMetricValue(totals, "total_tokens"), 215 + }, 216 + { 217 + key: "input_tokens", 218 + label: t("stats_input_tokens"), 219 + value: hasMetricValue(totals, "input_tokens") ? formatNumber(totals.input_tokens) : "-", 220 + unavailable: !hasMetricValue(totals, "input_tokens"), 221 + }, 222 + { 223 + key: "output_tokens", 224 + label: t("stats_output_tokens"), 225 + value: hasMetricValue(totals, "output_tokens") ? formatNumber(totals.output_tokens) : "-", 226 + unavailable: !hasMetricValue(totals, "output_tokens"), 227 + }, 228 + { 229 + key: "cached_input_tokens", 230 + label: t("stats_cached_input_tokens"), 231 + value: hasMetricValue(totals, "cached_input_tokens") ? formatNumber(totals.cached_input_tokens) : "-", 232 + unavailable: !hasMetricValue(totals, "cached_input_tokens"), 233 + }, 160 234 { 161 235 key: "cache_creation_input_tokens", 162 236 label: t("stats_cache_creation_input_tokens"), 163 - value: formatNumber(totals.cache_creation_input_tokens), 237 + value: hasMetricValue(totals, "cache_creation_input_tokens") 238 + ? formatNumber(totals.cache_creation_input_tokens) 239 + : "-", 240 + unavailable: !hasMetricValue(totals, "cache_creation_input_tokens"), 164 241 }, 165 242 ]; 166 243 } 167 244 168 - function visibleModelCostColumns(t, rows) { 245 + function visibleModelCostColumns(t) { 169 246 const columns = [ 170 247 { key: "total_cost", label: t("stats_total_cost"), kind: "cost" }, 171 248 { key: "input_cost", label: t("stats_input_cost"), kind: "cost" }, ··· 173 250 { key: "cached_input_cost", label: t("stats_cached_input_cost"), kind: "cost" }, 174 251 { key: "cache_creation_input_cost", label: t("stats_cache_creation_input_cost"), kind: "cost" }, 175 252 ]; 176 - if (!rows.some((row) => columns.some((column) => hasMetricValue(row, column.key)))) { 177 - return []; 178 - } 179 - return columns.filter((column) => rows.some((row) => hasMetricValue(row, column.key))); 253 + return columns; 180 254 } 181 255 182 - function visibleModelTokenColumns(t, rows) { 256 + function visibleModelTokenColumns(t) { 183 257 const columns = [ 184 - { key: "total_tokens", label: t("stats_total_tokens"), kind: "token", always: true }, 185 - { key: "input_tokens", label: t("stats_input_tokens"), kind: "token", always: true }, 186 - { key: "output_tokens", label: t("stats_output_tokens"), kind: "token", always: true }, 258 + { key: "total_tokens", label: t("stats_total_tokens"), kind: "token" }, 259 + { key: "input_tokens", label: t("stats_input_tokens"), kind: "token" }, 260 + { key: "output_tokens", label: t("stats_output_tokens"), kind: "token" }, 187 261 { key: "cached_input_tokens", label: t("stats_cached_input_tokens"), kind: "token" }, 188 262 { key: "cache_creation_input_tokens", label: t("stats_cache_creation_input_tokens"), kind: "token" }, 189 263 ]; 190 - return columns.filter( 191 - (column) => column.always || rows.some((row) => hasMetricValue(row, column.key) || Number(row?.[column.key] || 0) > 0) 192 - ); 264 + return columns; 193 265 } 194 266 195 267 function formatModelLedgerValue(row, column) { ··· 197 269 const currency = typeof row?.cost_currency === "string" ? row.cost_currency : "USD"; 198 270 return hasMetricValue(row, column.key) ? formatFixedCost(row[column.key], currency) : "-"; 199 271 } 200 - return formatNumber(row?.[column.key]); 272 + return hasMetricValue(row, column.key) ? formatNumber(row[column.key]) : "-"; 201 273 } 202 274 203 275 function isModelLedgerValueUnavailable(row, column) { 204 - return column.kind === "cost" && !hasMetricValue(row, column.key); 276 + return !hasMetricValue(row, column.key); 205 277 } 206 278 207 279 const StatsView = { ··· 231 303 232 304 const visibleHosts = computed(() => (Array.isArray(payload.value.api_hosts) ? payload.value.api_hosts : [])); 233 305 const visibleModels = computed(() => (Array.isArray(payload.value.models) ? payload.value.models : [])); 234 - const primarySummaryMetric = computed(() => summaryLeadMetric(t, payload.value.summary || {})); 235 - const secondarySummaryMetrics = computed(() => summaryPrimaryMetrics(t, payload.value.summary || {})); 236 - const secondarySummaryCosts = computed(() => summaryCostMetrics(t, payload.value.summary || {})); 306 + const heroSummaryMetrics = computed(() => summaryHeroMetrics(t, payload.value.summary || {})); 307 + const summaryCosts = computed(() => summaryCostMetrics(t, payload.value.summary || {}, false)); 308 + const summaryTokens = computed(() => summaryTokenMetrics(t, payload.value.summary || {}, false)); 237 309 const summaryMetaItems = computed(() => { 238 310 const items = []; 239 311 if (payload.value.updated_at) { ··· 303 375 selectedStatsTab, 304 376 visibleHosts, 305 377 visibleModels, 306 - primarySummaryMetric, 307 - secondarySummaryMetrics, 308 - secondarySummaryCosts, 378 + heroSummaryMetrics, 379 + summaryCosts, 380 + summaryTokens, 309 381 summaryMetaItems, 310 382 onTabChange, 311 383 hostCostMetrics, ··· 323 395 <QFence v-if="err" type="danger" icon="QIconCloseCircle" :text="err" /> 324 396 325 397 <section class="stats-page"> 326 - <header class="stats-hero"> 398 + <header class="stats-hero block-default"> 327 399 <div class="stats-hero-copy"> 328 400 <div class="stats-hero-head"> 329 401 <AppKicker as="h3" left="LLM" right="Usage" /> 330 - <p v-if="summaryMetaItems.length > 0" class="stats-hero-meta"> 331 - <span v-for="item in summaryMetaItems" :key="item" class="stats-hero-meta-item">{{ item }}</span> 332 - </p> 333 402 </div> 334 - <div class="stats-lead-block"> 335 - <span class="stats-lead-label">{{ primarySummaryMetric.label }}</span> 336 - <span class="stats-lead-value">{{ primarySummaryMetric.value }}</span> 403 + <p v-if="summaryMetaItems.length > 0" class="stats-hero-meta"> 404 + <span v-for="item in summaryMetaItems" :key="item" class="stats-hero-meta-item">{{ item }}</span> 405 + </p> 406 + </div> 407 + 408 + <section class="stats-hero-spotlight"> 409 + <span class="stats-hero-primary-label">{{ heroSummaryMetrics.primary.label }}</span> 410 + <span class="stats-hero-primary-value" :class="{ 'stats-hero-primary-value-unavailable': heroSummaryMetrics.primary.unavailable }"> 411 + {{ heroSummaryMetrics.primary.value }} 412 + </span> 413 + </section> 414 + 415 + <div class="stats-hero-side"> 416 + <div class="stats-hero-secondary-grid"> 417 + <article v-for="item in heroSummaryMetrics.secondary" :key="item.key" class="stats-hero-secondary-item"> 418 + <span class="stats-hero-secondary-label">{{ item.label }}</span> 419 + <span class="stats-hero-secondary-value" :class="{ 'stats-hero-secondary-value-unavailable': item.unavailable }"> 420 + {{ item.value }} 421 + </span> 422 + </article> 337 423 </div> 338 - <div v-if="secondarySummaryCosts.length > 0" class="stats-inline-meta stats-inline-meta-summary"> 339 - <div v-for="item in secondarySummaryCosts" :key="item.key" class="stats-inline-meta-item"> 340 - <span class="stats-inline-meta-label">{{ item.label }}</span> 341 - <span class="stats-inline-meta-value">{{ item.value }}</span> 424 + 425 + <div v-if="summaryCosts.length > 0 || summaryTokens.length > 0" class="stats-hero-detail-groups"> 426 + <div v-if="summaryCosts.length > 0" class="stats-inline-meta stats-inline-meta-summary"> 427 + <div v-for="item in summaryCosts" :key="'summary:cost:' + item.key" class="stats-inline-meta-item"> 428 + <span class="stats-inline-meta-label">{{ item.label }}</span> 429 + <span class="stats-inline-meta-value">{{ item.value }}</span> 430 + </div> 342 431 </div> 343 - </div> 344 - </div> 345 - <div class="stats-glance-grid"> 346 - <div v-for="item in secondarySummaryMetrics" :key="item.key" class="stats-glance-item"> 347 - <span class="stats-glance-label">{{ item.label }}</span> 348 - <span class="stats-glance-value">{{ item.value }}</span> 432 + 433 + <div v-if="summaryTokens.length > 0" class="stats-inline-meta stats-inline-meta-summary"> 434 + <div v-for="item in summaryTokens" :key="'summary:token:' + item.key" class="stats-inline-meta-item"> 435 + <span class="stats-inline-meta-label">{{ item.label }}</span> 436 + <span class="stats-inline-meta-value">{{ item.value }}</span> 437 + </div> 438 + </div> 349 439 </div> 350 440 </div> 351 441 </header> ··· 395 485 <div class="stats-band-grid"> 396 486 <div v-for="item in hostTokenMetrics(host)" :key="host.api_host + ':token:' + item.key" class="stats-band-cell"> 397 487 <span class="stats-ledger-label">{{ item.label }}</span> 398 - <span class="stats-ledger-value">{{ item.value }}</span> 488 + <span class="stats-ledger-value" :class="{ 'stats-ledger-value-unavailable': item.unavailable }">{{ item.value }}</span> 399 489 </div> 400 490 </div> 401 491 </section> ··· 459 549 v-for="column in modelLedgerTokenColumns(host.models)" 460 550 :key="host.api_host + ':' + model.model + ':token:' + column.key" 461 551 class="stats-model-ledger-value-cell" 552 + :class="{ 'stats-model-ledger-value-cell-unavailable': isModelLedgerValueUnavailable(model, column) }" 462 553 > 463 554 {{ formatModelLedgerValue(model, column) }} 464 555 </td> ··· 541 632 v-for="column in modelLedgerTokenColumns(visibleModels)" 542 633 :key="model.model + ':token:' + column.key" 543 634 class="stats-model-ledger-value-cell" 635 + :class="{ 'stats-model-ledger-value-cell-unavailable': isModelLedgerValueUnavailable(model, column) }" 544 636 > 545 637 {{ formatModelLedgerValue(model, column) }} 546 638 </td>