@dijkspicy/opencode-stats-reporter 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,241 @@
1
+ /**
2
+ * ECharts 封装:初始化、深浅主题、resize、销毁重建
3
+ * 深色用 ECharts 内置 dark 主题;主题切换时 dispose 后重新 init
4
+ */
5
+ (function () {
6
+ 'use strict';
7
+
8
+ var instances = {}; // name -> echarts instance
9
+
10
+ function isDark() {
11
+ return document.documentElement.getAttribute('data-theme') === 'dark';
12
+ }
13
+
14
+ /** 初始化(已存在则先销毁)。返回实例或 null(DOM 不存在时) */
15
+ function init(name, el) {
16
+ if (!el) return null;
17
+ dispose(name);
18
+ var chart = window.echarts.init(el, isDark() ? 'dark' : null, { renderer: 'canvas' });
19
+ instances[name] = chart;
20
+ return chart;
21
+ }
22
+
23
+ function dispose(name) {
24
+ if (instances[name]) {
25
+ instances[name].dispose();
26
+ delete instances[name];
27
+ }
28
+ }
29
+
30
+ /** 销毁全部图表(主题切换时调用) */
31
+ function disposeAll() {
32
+ Object.keys(instances).forEach(dispose);
33
+ }
34
+
35
+ function resizeAll() {
36
+ Object.keys(instances).forEach(function (k) { instances[k].resize(); });
37
+ }
38
+
39
+ // 窗口尺寸变化时统一 resize(防抖)
40
+ var resizeTimer = null;
41
+ window.addEventListener('resize', function () {
42
+ clearTimeout(resizeTimer);
43
+ resizeTimer = setTimeout(resizeAll, 150);
44
+ });
45
+
46
+ // ---- 公共配色(两套主题下都清晰可读)----
47
+ var COLOR_UNCACHED = '#4c9aff'; // 输入·缓存未命中
48
+ var COLOR_CACHED = '#9254de'; // 输入·缓存命中
49
+ var COLOR_RSP = '#36cfc9'; // 输出 tokens
50
+ var COLOR_LINE = '#f7b500'; // 请求数折线
51
+ var PALETTE = ['#4c9aff', '#36cfc9', '#f7b500', '#ff7a45', '#9254de', '#73d13d', '#ff85c0', '#40a9ff'];
52
+
53
+ function baseTooltip() {
54
+ return {
55
+ trigger: 'axis',
56
+ backgroundColor: isDark() ? '#2b2f36' : '#fff',
57
+ borderColor: isDark() ? '#444' : '#ddd',
58
+ textStyle: { color: isDark() ? '#e8e8e8' : '#333', fontSize: 12 }
59
+ };
60
+ }
61
+
62
+ /**
63
+ * token 三类堆叠柱 + 请求数折线(副轴)的通用 option
64
+ * items: [{ label, tipTitle, uncached, cached, rsp, cnt, complete }]
65
+ * 未完结桶(complete=false)柱子降透明度
66
+ */
67
+ function stackOption(items, fmt) {
68
+ var labels = [], uncached = [], cached = [], rsp = [], cnt = [];
69
+ items.forEach(function (it) {
70
+ labels.push(it.label);
71
+ var barStyle = it.complete === false ? { opacity: 0.45 } : undefined;
72
+ uncached.push({ value: it.uncached, itemStyle: barStyle });
73
+ cached.push({ value: it.cached, itemStyle: barStyle });
74
+ rsp.push({ value: it.rsp, itemStyle: barStyle });
75
+ cnt.push(it.cnt);
76
+ });
77
+ return {
78
+ color: [COLOR_UNCACHED, COLOR_CACHED, COLOR_RSP, COLOR_LINE],
79
+ tooltip: Object.assign(baseTooltip(), {
80
+ formatter: function (params) {
81
+ var idx = params[0].dataIndex;
82
+ var it = items[idx];
83
+ var html = it.tipTitle + (it.complete === false ? '(进行中)' : '') + '<br/>';
84
+ params.forEach(function (p) {
85
+ html += p.marker + p.seriesName + ':' + fmt.fullNumber(p.value) + '<br/>';
86
+ });
87
+ var totalIn = it.uncached + it.cached;
88
+ html += '输入合计:' + fmt.fullNumber(totalIn)
89
+ + '(缓存命中 ' + fmt.hitRate(totalIn > 0 ? it.cached / totalIn : null) + ')';
90
+ return html;
91
+ }
92
+ }),
93
+ legend: { data: ['输入(缓存未命中)', '输入(缓存命中)', '输出 Tokens', '请求数'], top: 0 },
94
+ grid: { left: 60, right: 56, top: 36, bottom: 28 },
95
+ xAxis: { type: 'category', data: labels, axisTick: { alignWithLabel: true } },
96
+ yAxis: [
97
+ { type: 'value', name: 'Tokens', axisLabel: { formatter: function (v) { return fmt.abbrev(v); } } },
98
+ { type: 'value', name: '请求数', splitLine: { show: false }, axisLabel: { formatter: function (v) { return fmt.abbrev(v); } } }
99
+ ],
100
+ series: [
101
+ { name: '输入(缓存未命中)', type: 'bar', stack: 'tokens', data: uncached, barMaxWidth: 32 },
102
+ { name: '输入(缓存命中)', type: 'bar', stack: 'tokens', data: cached, barMaxWidth: 32 },
103
+ { name: '输出 Tokens', type: 'bar', stack: 'tokens', data: rsp, barMaxWidth: 32 },
104
+ { name: '请求数', type: 'line', yAxisIndex: 1, data: cnt, smooth: true, symbolSize: 6, lineStyle: { width: 2 } }
105
+ ]
106
+ };
107
+ }
108
+
109
+ /**
110
+ * 每日用量趋势:输入拆分为 缓存未命中/缓存命中(OpenAI 规范 req 含命中),
111
+ * 与输出三类堆叠 + 请求数折线
112
+ */
113
+ function trendOption(rows, fmt) {
114
+ var items = rows.map(function (r) {
115
+ var cached = r.cached_tokens || 0;
116
+ return {
117
+ label: fmt.shortDate(r.date),
118
+ tipTitle: r.date,
119
+ uncached: Math.max((r.req_tokens || 0) - cached, 0),
120
+ cached: cached,
121
+ rsp: r.rsp_tokens || 0,
122
+ cnt: r.request_count || 0,
123
+ complete: r.complete
124
+ };
125
+ });
126
+ return stackOption(items, fmt);
127
+ }
128
+
129
+ /** 今日分时:整点小时桶,当前未完结小时降透明度 */
130
+ function hourlyOption(rows, fmt) {
131
+ var items = rows.map(function (r) {
132
+ var cached = r.cached_tokens || 0;
133
+ return {
134
+ label: fmt.hourLabel(r.ts),
135
+ tipTitle: fmt.hourLabel(r.ts) + ' - ' + fmt.hourLabel(r.ts + 3600),
136
+ uncached: Math.max((r.req_tokens || 0) - cached, 0),
137
+ cached: cached,
138
+ rsp: r.rsp_tokens || 0,
139
+ cnt: r.request_count || 0,
140
+ complete: r.complete
141
+ };
142
+ });
143
+ return stackOption(items, fmt);
144
+ }
145
+
146
+ /** 模型分布:横向条形图,Top 10,其余合并"其他";按 total_tokens 降序 */
147
+ function modelBarOption(list, fmt) {
148
+ var sorted = list.slice().sort(function (a, b) { return b.total_tokens - a.total_tokens; });
149
+ var top = sorted.slice(0, 10);
150
+ var rest = sorted.slice(10);
151
+ if (rest.length) {
152
+ top.push({
153
+ model: '其他(' + rest.length + ' 个模型)',
154
+ total_tokens: rest.reduce(function (s, r) { return s + r.total_tokens; }, 0),
155
+ req_tokens: rest.reduce(function (s, r) { return s + (r.req_tokens || 0); }, 0),
156
+ rsp_tokens: rest.reduce(function (s, r) { return s + (r.rsp_tokens || 0); }, 0),
157
+ cached_tokens: rest.reduce(function (s, r) { return s + (r.cached_tokens || 0); }, 0),
158
+ request_count: rest.reduce(function (s, r) { return s + r.request_count; }, 0)
159
+ });
160
+ }
161
+ var names = top.map(function (r) { return r.model; }).reverse();
162
+ var values = top.map(function (r) { return r.total_tokens; }).reverse();
163
+ return {
164
+ color: [COLOR_UNCACHED],
165
+ tooltip: {
166
+ trigger: 'item',
167
+ backgroundColor: isDark() ? '#2b2f36' : '#fff',
168
+ borderColor: isDark() ? '#444' : '#ddd',
169
+ textStyle: { color: isDark() ? '#e8e8e8' : '#333', fontSize: 12 },
170
+ formatter: function (p) {
171
+ var d = top[top.length - 1 - p.dataIndex];
172
+ var cached = d.cached_tokens || 0;
173
+ return p.name
174
+ + '<br/>输入(缓存未命中):' + fmt.fullNumber(Math.max((d.req_tokens || 0) - cached, 0))
175
+ + '<br/>输入(缓存命中):' + fmt.fullNumber(cached)
176
+ + '<br/>输出:' + fmt.fullNumber(d.rsp_tokens || 0)
177
+ + '<br/>请求数:' + fmt.fullNumber(d.request_count || 0);
178
+ }
179
+ },
180
+ grid: { left: 8, right: 60, top: 8, bottom: 8, containLabel: true },
181
+ xAxis: { type: 'value', axisLabel: { formatter: function (v) { return fmt.abbrev(v); } } },
182
+ yAxis: { type: 'category', data: names, axisLabel: { width: 130, overflow: 'truncate' } },
183
+ series: [{
184
+ type: 'bar', data: values, barMaxWidth: 18,
185
+ label: { show: true, position: 'right', formatter: function (p) { return fmt.abbrev(p.value); } },
186
+ itemStyle: { borderRadius: [0, 4, 4, 0] }
187
+ }]
188
+ };
189
+ }
190
+
191
+ /** 供应商分布:环形图,中心显示总请求数 */
192
+ function providerDonutOption(list, fmt) {
193
+ var totalReq = list.reduce(function (s, r) { return s + (r.request_count || 0); }, 0);
194
+ var data = list.map(function (r) {
195
+ return { name: r.provider, value: r.total_tokens, requestCount: r.request_count, share: r.share };
196
+ });
197
+ return {
198
+ color: PALETTE,
199
+ tooltip: {
200
+ trigger: 'item',
201
+ backgroundColor: isDark() ? '#2b2f36' : '#fff',
202
+ borderColor: isDark() ? '#444' : '#ddd',
203
+ textStyle: { color: isDark() ? '#e8e8e8' : '#333', fontSize: 12 },
204
+ formatter: function (p) {
205
+ var d = p.data;
206
+ return d.name + '<br/>Tokens:' + fmt.fullNumber(d.value)
207
+ + '<br/>请求数:' + fmt.fullNumber(d.requestCount)
208
+ + '<br/>占比:' + fmt.hitRate(d.share);
209
+ }
210
+ },
211
+ legend: { bottom: 0, type: 'scroll' },
212
+ title: {
213
+ text: fmt.fullNumber(totalReq),
214
+ subtext: '总请求数',
215
+ left: 'center', top: '38%',
216
+ textStyle: { fontSize: 20, fontWeight: 600, color: isDark() ? '#e8e8e8' : '#222' },
217
+ subtextStyle: { fontSize: 12, color: isDark() ? '#999' : '#888' }
218
+ },
219
+ series: [{
220
+ type: 'pie', radius: ['52%', '72%'], center: ['50%', '46%'],
221
+ avoidLabelOverlap: true,
222
+ label: { show: false },
223
+ emphasis: { label: { show: true, formatter: '{b}\n{d}%', fontSize: 13 } },
224
+ data: data
225
+ }]
226
+ };
227
+ }
228
+
229
+ window.BRU = window.BRU || {};
230
+ window.BRU.charts = {
231
+ init: init,
232
+ dispose: dispose,
233
+ disposeAll: disposeAll,
234
+ resizeAll: resizeAll,
235
+ trendOption: trendOption,
236
+ hourlyOption: hourlyOption,
237
+ modelBarOption: modelBarOption,
238
+ providerDonutOption: providerDonutOption,
239
+ isDark: isDark
240
+ };
241
+ })();
@@ -0,0 +1,125 @@
1
+ /**
2
+ * 配置读取 / 校验 / localStorage 覆盖
3
+ * 优先级:localStorage > config.js(window.APP_CONFIG)
4
+ */
5
+ (function () {
6
+ 'use strict';
7
+
8
+ var STORAGE_KEY = 'blueregion-usage.config.v1';
9
+
10
+ var DEFAULTS = {
11
+ trendDays: 14,
12
+ theme: 'auto',
13
+ numberStyle: 'wan',
14
+ dailyCreditLimit: null,
15
+ weeklyCreditLimit: null,
16
+ monthlyCreditLimit: null
17
+ };
18
+
19
+ /** 读取 localStorage 中的覆盖配置(无则返回 null) */
20
+ function readLocal() {
21
+ try {
22
+ var raw = window.localStorage.getItem(STORAGE_KEY);
23
+ if (!raw) return null;
24
+ var obj = JSON.parse(raw);
25
+ return (obj && typeof obj === 'object') ? obj : null;
26
+ } catch (e) {
27
+ return null; // localStorage 不可用或内容损坏时静默降级
28
+ }
29
+ }
30
+
31
+ /** 合并配置:localStorage 覆盖 config.js,再补默认值 */
32
+ function load() {
33
+ var fileCfg = (typeof window.APP_CONFIG === 'object' && window.APP_CONFIG) || {};
34
+ var localCfg = readLocal() || {};
35
+ var merged = {};
36
+ var keys = ['gatewayBaseUrl', 'apiKey', 'trendDays', 'theme', 'numberStyle',
37
+ 'dailyCreditLimit', 'weeklyCreditLimit', 'monthlyCreditLimit'];
38
+ keys.forEach(function (k) {
39
+ if (localCfg[k] !== undefined && localCfg[k] !== null && localCfg[k] !== '') merged[k] = localCfg[k];
40
+ else if (fileCfg[k] !== undefined && fileCfg[k] !== null && fileCfg[k] !== '') merged[k] = fileCfg[k];
41
+ else if (DEFAULTS[k] !== undefined) merged[k] = DEFAULTS[k];
42
+ });
43
+ return merged;
44
+ }
45
+
46
+ /**
47
+ * 校验配置,返回 { ok, errors: string[], cfg }
48
+ * gatewayBaseUrl 必须是合法 http(s) URL;apiKey 必填
49
+ */
50
+ function validate(cfg) {
51
+ var errors = [];
52
+ cfg = cfg || {};
53
+
54
+ var base = (cfg.gatewayBaseUrl || '').trim();
55
+ if (!base) {
56
+ errors.push('网关地址(gatewayBaseUrl)必填');
57
+ } else {
58
+ try {
59
+ var u = new URL(base);
60
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') {
61
+ errors.push('网关地址必须是 http(s) URL');
62
+ }
63
+ } catch (e) {
64
+ errors.push('网关地址不是合法 URL');
65
+ }
66
+ }
67
+
68
+ if (!(cfg.apiKey || '').trim()) {
69
+ errors.push('API Key(apiKey)必填');
70
+ }
71
+
72
+ var days = Number(cfg.trendDays);
73
+ if (cfg.trendDays !== undefined && cfg.trendDays !== '') {
74
+ if (!isFinite(days) || days < 1 || days > 15) errors.push('trendDays 必须在 1-15 之间');
75
+ }
76
+
77
+ if (cfg.theme && ['auto', 'light', 'dark'].indexOf(cfg.theme) < 0) {
78
+ errors.push('theme 只能是 auto / light / dark');
79
+ }
80
+ if (cfg.numberStyle && ['wan', 'intl'].indexOf(cfg.numberStyle) < 0) {
81
+ errors.push('numberStyle 只能是 wan / intl');
82
+ }
83
+
84
+ // 限额类字段:空表示不限,否则必须是非负数字
85
+ ['dailyCreditLimit', 'weeklyCreditLimit', 'monthlyCreditLimit'].forEach(function (k) {
86
+ var raw = cfg[k];
87
+ if (raw === undefined || raw === null || raw === '') return;
88
+ var n = Number(raw);
89
+ if (!isFinite(n) || n < 0) errors.push(k + ' 必须是非负数字');
90
+ });
91
+
92
+ return { ok: errors.length === 0, errors: errors, cfg: cfg };
93
+ }
94
+
95
+ /** 保存到 localStorage(记住配置) */
96
+ function saveLocal(cfg) {
97
+ try {
98
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg));
99
+ return true;
100
+ } catch (e) {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ /** 清除 localStorage 覆盖配置 */
106
+ function clearLocal() {
107
+ try { window.localStorage.removeItem(STORAGE_KEY); } catch (e) { /* ignore */ }
108
+ }
109
+
110
+ /** 是否存在 localStorage 覆盖配置 */
111
+ function hasLocal() {
112
+ return readLocal() !== null;
113
+ }
114
+
115
+ window.BRU = window.BRU || {};
116
+ window.BRU.config = {
117
+ load: load,
118
+ validate: validate,
119
+ saveLocal: saveLocal,
120
+ clearLocal: clearLocal,
121
+ hasLocal: hasLocal,
122
+ readLocal: readLocal,
123
+ DEFAULTS: DEFAULTS
124
+ };
125
+ })();
@@ -0,0 +1,156 @@
1
+ /**
2
+ * 数字 / 百分比 / 延迟 / 日期格式化工具
3
+ * - 卡片与坐标轴用缩写(wan / intl 两种风格)
4
+ * - tooltip 与表格永远显示完整千分位
5
+ */
6
+ (function () {
7
+ 'use strict';
8
+
9
+ /** 千分位完整数字,如 2,453,824;非法值返回 -- */
10
+ function fullNumber(n) {
11
+ if (n === null || n === undefined || !isFinite(Number(n))) return '--';
12
+ return Number(n).toLocaleString('en-US');
13
+ }
14
+
15
+ /**
16
+ * 大数字缩写
17
+ * wan 模式:<1万 原值千分位;>=1万 x.x 万;>=1亿 x.xx 亿
18
+ * intl 模式:K / M / B
19
+ */
20
+ function abbrev(n, style) {
21
+ if (n === null || n === undefined || !isFinite(Number(n))) return '--';
22
+ n = Number(n);
23
+ var neg = n < 0 ? '-' : '';
24
+ n = Math.abs(n);
25
+ if (style === 'intl') {
26
+ if (n >= 1e9) return neg + (n / 1e9).toFixed(2) + 'B';
27
+ if (n >= 1e6) return neg + (n / 1e6).toFixed(1) + 'M';
28
+ if (n >= 1e3) return neg + (n / 1e3).toFixed(1) + 'K';
29
+ return neg + String(Math.round(n));
30
+ }
31
+ // 默认 wan
32
+ if (n >= 1e8) return neg + (n / 1e8).toFixed(2) + ' 亿';
33
+ if (n >= 1e4) return neg + (n / 1e4).toFixed(1) + ' 万';
34
+ return neg + Number(n).toLocaleString('en-US');
35
+ }
36
+
37
+ /**
38
+ * 缓存命中率:输入比例(0-1),输出 1 位小数百分比
39
+ * 分母为 0 / 空值时显示 --
40
+ */
41
+ function hitRate(ratio) {
42
+ if (ratio === null || ratio === undefined || !isFinite(Number(ratio))) return '--';
43
+ return (Number(ratio) * 100).toFixed(1) + '%';
44
+ }
45
+
46
+ /** 由分子分母计算命中率,分母为 0 时返回 -- */
47
+ function hitRateOf(cached, req) {
48
+ if (!req || !isFinite(Number(req)) || Number(req) <= 0) return '--';
49
+ return hitRate(Number(cached || 0) / Number(req));
50
+ }
51
+
52
+ /** TTFT:<1000ms 显示 xxx ms,>=1s 显示 x.xx s */
53
+ function latency(ms) {
54
+ if (ms === null || ms === undefined || !isFinite(Number(ms))) return '--';
55
+ ms = Number(ms);
56
+ if (ms < 1000) return Math.round(ms) + ' ms';
57
+ return (ms / 1000).toFixed(2) + ' s';
58
+ }
59
+
60
+ /**
61
+ * Credit 用量(估算值):2 位小数千分位;0 < v < 0.01 显示 <0.01
62
+ * Credit = 总 Tokens ÷ 1e6 × 模型 Credit 系数
63
+ */
64
+ function credit(v) {
65
+ if (v === null || v === undefined || !isFinite(Number(v))) return '--';
66
+ v = Number(v);
67
+ if (v > 0 && v < 0.01) return '<0.01';
68
+ return v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
69
+ }
70
+
71
+ /** '2026-09-05' -> '09-05',趋势图横轴用 */
72
+ function shortDate(iso) {
73
+ if (!iso || typeof iso !== 'string') return '';
74
+ var m = iso.match(/^\d{4}-(\d{2}-\d{2})/);
75
+ return m ? m[1] : iso;
76
+ }
77
+
78
+ /** 秒级时间戳 -> 'HH:MM:SS' */
79
+ function timeOfDay(epochSec) {
80
+ if (!epochSec || !isFinite(Number(epochSec))) return '--';
81
+ var d = new Date(Number(epochSec) * 1000);
82
+ function p(x) { return (x < 10 ? '0' : '') + x; }
83
+ return p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
84
+ }
85
+
86
+ /** Date -> 'YYYY-MM-DD',CSV 文件名用 */
87
+ function todayStr(d) {
88
+ d = d || new Date();
89
+ function p(x) { return (x < 10 ? '0' : '') + x; }
90
+ return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate());
91
+ }
92
+
93
+ /** 秒级时间戳 -> 'HH:00',分时图横轴用 */
94
+ function hourLabel(epochSec) {
95
+ if (!epochSec || !isFinite(Number(epochSec))) return '';
96
+ var d = new Date(Number(epochSec) * 1000);
97
+ return (d.getHours() < 10 ? '0' : '') + d.getHours() + ':00';
98
+ }
99
+
100
+ /** 毫秒时间戳 -> 'YYYY-MM-DD HH:MM:SS'(本地时区),会话时间字段用 */
101
+ function dateTime(ms) {
102
+ if (ms === null || ms === undefined || !isFinite(Number(ms))) return '--';
103
+ var d = new Date(Number(ms));
104
+ function p(x) { return (x < 10 ? '0' : '') + x; }
105
+ return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) +
106
+ ' ' + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
107
+ }
108
+
109
+ /**
110
+ * 环比变化(比值):当前值与上一周期值比较,返回 { text, dir }
111
+ * dir: 1 升 / -1 降 / 0 持平;基准为 0 或空值时返回 null(不展示)
112
+ */
113
+ function pctChange(cur, prev) {
114
+ cur = Number(cur); prev = Number(prev);
115
+ if (!isFinite(cur) || !isFinite(prev) || prev <= 0) return null;
116
+ var d = (cur - prev) / prev;
117
+ var dir = d > 0.0005 ? 1 : (d < -0.0005 ? -1 : 0);
118
+ return { text: (d >= 0 ? '+' : '') + (d * 100).toFixed(1) + '%', dir: dir };
119
+ }
120
+
121
+ /** 比率环比(百分点差):如命中率 32.1% vs 30.0% -> '+2.1pp' */
122
+ function ppChange(cur, prev) {
123
+ cur = Number(cur); prev = Number(prev);
124
+ if (!isFinite(cur) || !isFinite(prev)) return null;
125
+ var d = (cur - prev) * 100;
126
+ var dir = d > 0.05 ? 1 : (d < -0.05 ? -1 : 0);
127
+ return { text: (d >= 0 ? '+' : '') + d.toFixed(1) + 'pp', dir: dir };
128
+ }
129
+
130
+ /** API Key 脱敏:保留前 11 位与后 2 位,如 api-key-t006••••56 */
131
+ function maskKey(key) {
132
+ if (!key) return '';
133
+ key = String(key);
134
+ if (key.length <= 6) return key.charAt(0) + '••••';
135
+ if (key.length <= 13) return key.slice(0, 4) + '••••' + key.slice(-2);
136
+ return key.slice(0, 11) + '••••' + key.slice(-2);
137
+ }
138
+
139
+ window.BRU = window.BRU || {};
140
+ window.BRU.format = {
141
+ fullNumber: fullNumber,
142
+ abbrev: abbrev,
143
+ hitRate: hitRate,
144
+ hitRateOf: hitRateOf,
145
+ latency: latency,
146
+ credit: credit,
147
+ shortDate: shortDate,
148
+ timeOfDay: timeOfDay,
149
+ todayStr: todayStr,
150
+ hourLabel: hourLabel,
151
+ dateTime: dateTime,
152
+ pctChange: pctChange,
153
+ ppChange: ppChange,
154
+ maskKey: maskKey
155
+ };
156
+ })();