mirror of
https://github.com/zhu1090093659/dsh-web.git
synced 2026-09-29 17:08:39 +08:00
feat(telemetry-view): chart daily active instances above the site trend
The dashboard only showed the active-instance count as a KPI number, with no series behind it. Render the heartbeat UV series as its own trend panel above the site PV/UV chart, sharing one SVG line renderer whose tooltip, crosshair and hit zone are now per chart box.
This commit is contained in:
+2
-2
@@ -1,5 +1,5 @@
|
||||
# Bilingual-pair consistency record (docs/i18n.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with git hash-object.
|
||||
2026-08-26-telemetry-dashboard-pagination.md: 5f6a9f1741c6e47d2d132aec95013a9f6fb91ed7
|
||||
2026-08-26-telemetry-dashboard-pagination.zh.md: 8212e239f83a0901497545d6db2a21dda5a5e7a2
|
||||
2026-08-26-telemetry-dashboard-pagination.md: ce2146ee192f9cbaca235adae9e344fdf46989bd
|
||||
2026-08-26-telemetry-dashboard-pagination.zh.md: 31d21e194c16b4406857ed3ed5853e2b5d87a5f8
|
||||
|
||||
@@ -11,7 +11,7 @@ The private telemetry dashboard rendered the summary aggregate as static server-
|
||||
The summary API paginates server-side and the dashboard became a self-rendering dark client:
|
||||
|
||||
- `GET /api/telemetry/summary` accepts `paths_limit`/`paths_offset` (default 20, max 100) and `items_limit`/`items_offset` (default 200, max 200); responses carry `site.paths_total` and `plugins.totals.items` (now an exact distinct-subject count rather than a capped page length) plus echoed `*_page` windows. Defaults reproduce the pre-pagination response, so existing readers are unaffected. The per-item channel/version breakdowns stay full-cardinality scans (their size is bounded by the plugin catalog, not traffic) and are joined onto the returned page in memory, keeping one D1 batch of nine statements.
|
||||
- `market/telemetry-view` splits into `src/index.js` (Access JWT verification, routing, `/app.js` and `/data` endpoints) and `src/page.js` (the document plus the client source). Boot data rides in an inert `<script type="application/json">` block and the client loads as a same-origin external script: the zone's edge injects a CSP nonce, which neutralizes `unsafe-inline` per spec, so inline scripts are blocked no matter what the worker sends — `script-src 'self'` with an external file is immune, and JSON blocks are not governed by script-src at all. The client renders KPI cards with day-over-day deltas, a hand-rolled SVG PV/UV trend chart with hover crosshair, and hot-path / plugin tables with pagers (page numbers plus 10/20/50 size selector). Range switches and pagination refetch through a same-origin `/data` proxy that applies the same Access verification and forwards the pagination window, so interactions never reload the page.
|
||||
- `market/telemetry-view` splits into `src/index.js` (Access JWT verification, routing, `/app.js` and `/data` endpoints) and `src/page.js` (the document plus the client source). Boot data rides in an inert `<script type="application/json">` block and the client loads as a same-origin external script: the zone's edge injects a CSP nonce, which neutralizes `unsafe-inline` per spec, so inline scripts are blocked no matter what the worker sends — `script-src 'self'` with an external file is immune, and JSON blocks are not governed by script-src at all. The client renders KPI cards with day-over-day deltas, two hand-rolled SVG trend charts — daily active instances (heartbeat UV, above) and site PV/UV — from one shared line renderer with a per-chart hover crosshair, and hot-path / plugin tables with pagers (page numbers plus 10/20/50 size selector). Range switches and pagination refetch through a same-origin `/data` proxy that applies the same Access verification and forwards the pagination window, so interactions never reload the page.
|
||||
- CSP moved from no-scripts to `script-src 'self'; connect-src 'self'`: all JavaScript is one same-origin file (no CDN), and the embedded boot JSON escapes `<` as \\u003c so data cannot terminate its block.
|
||||
- Both workers deploy with plain `wrangler deploy`; no D1 migration is involved.
|
||||
|
||||
@@ -30,8 +30,8 @@ This note extends [Anonymous install telemetry via the market edge API](2026-08-
|
||||
- `plugins.totals.items` semantics improved from "page length (<= 200)" to "exact distinct heartbeat subjects in range"; consumers that treated it as the items array length see the same value until catalog size exceeds 200.
|
||||
- The summary batch grew from 7 to 9 D1 statements (two COUNT DISTINCT totals); each remains an indexed aggregate over the same range.
|
||||
- tv.dsh-market.com now executes one same-origin JavaScript file under `script-src 'self'`; the page still loads no third-party assets and the Access JWT gate is unchanged for `/`, `/app.js`, and `/data`. A static boot hint stays visible and an on-page error banner reports the message if the client script ever fails to run again.
|
||||
- Test coverage: `scripts/market-worker.test.mjs` asserts the pagination bindings, clamps, and totals; `scripts/telemetry-view.test.mjs` covers the Access gate and boot-JSON escaping.
|
||||
- Test coverage: `scripts/market-worker.test.mjs` asserts the pagination bindings, clamps, and totals; `scripts/telemetry-view.test.mjs` covers the Access gate, boot-JSON escaping, and the active-instance panel rendering above the site trend panel.
|
||||
|
||||
## Testing
|
||||
|
||||
`node --test scripts/market-worker.test.mjs scripts/telemetry-view.test.mjs` (28 tests) plus a local HTTP harness driving the rendered page with Playwright: pager clicks issue the expected `/data` windows, range switching resets both offsets, the chart tooltip appears on hover, and no console errors occur. Desktop and mobile screenshots verified.
|
||||
`node --test scripts/market-worker.test.mjs scripts/telemetry-view.test.mjs` (28 tests) plus a local HTTP harness driving the rendered page with Playwright: pager clicks issue the expected `/data` windows, range switching resets both offsets, each chart's tooltip appears on hover, and no console errors occur. Desktop and mobile screenshots verified.
|
||||
|
||||
@@ -11,7 +11,7 @@ Status: implemented
|
||||
汇总 API 改为服务端分页,看板改为自渲染的深色客户端:
|
||||
|
||||
- `GET /api/telemetry/summary` 接受 `paths_limit`/`paths_offset`(默认 20,上限 100)与 `items_limit`/`items_offset`(默认 200,上限 200);响应携带 `site.paths_total` 与 `plugins.totals.items`(后者从「截断后的页长度」变为精确的去重条目总数),并回显 `*_page` 分页窗口。默认值复现分页前的响应,既有调用方不受影响。条目的渠道/版本分布保持全基数扫描(其行数由插件目录规模决定,与流量无关)并在内存中关联到返回页,保持单个 D1 批次九条语句。
|
||||
- `market/telemetry-view` 拆为 `src/index.js`(Access JWT 校验、路由、`/app.js` 与 `/data` 端点)与 `src/page.js`(页面文档与客户端源码)。boot 数据放在不可执行的 `<script type="application/json">` 块中,客户端以同源外部脚本加载:该 zone 的边缘会向 CSP 注入 nonce,按规范 nonce 会使 `unsafe-inline` 失效,内联脚本无论 worker 发什么都会被拦——`script-src 'self'` 加外部文件对此免疫,而 JSON 数据块根本不受 script-src 管辖。客户端渲染含环比昨日的 KPI 卡片、手绘 SVG PV/UV 趋势图(悬停十字线与提示框)、带分页器(页码 + 10/20/50 每页选择)的热门路径与插件表格。切换时间范围与翻页经由同源 `/data` 代理(同样校验 Access JWT 并转发分页窗口),不再整页刷新。
|
||||
- `market/telemetry-view` 拆为 `src/index.js`(Access JWT 校验、路由、`/app.js` 与 `/data` 端点)与 `src/page.js`(页面文档与客户端源码)。boot 数据放在不可执行的 `<script type="application/json">` 块中,客户端以同源外部脚本加载:该 zone 的边缘会向 CSP 注入 nonce,按规范 nonce 会使 `unsafe-inline` 失效,内联脚本无论 worker 发什么都会被拦——`script-src 'self'` 加外部文件对此免疫,而 JSON 数据块根本不受 script-src 管辖。客户端渲染含环比昨日的 KPI 卡片、两张手绘 SVG 趋势图(上方为每日活跃实例即心跳 UV,下方为站点 PV/UV,共用同一折线渲染器、各自带悬停十字线)、带分页器(页码 + 10/20/50 每页选择)的热门路径与插件表格。切换时间范围与翻页经由同源 `/data` 代理(同样校验 Access JWT 并转发分页窗口),不再整页刷新。
|
||||
- CSP 从禁脚本调整为 `script-src 'self'; connect-src 'self'`:全部脚本为同一个同源文件(不引 CDN),内嵌 boot JSON 将 `<` 转义为 \\u003c,数据无法提前终结所在块。
|
||||
- 两个 worker 均以 `wrangler deploy` 直接部署,不涉及 D1 迁移。
|
||||
|
||||
@@ -30,8 +30,8 @@ Status: implemented
|
||||
- `plugins.totals.items` 语义从「页长度(≤200)」变为「区间内精确去重条目数」;把它当 items 数组长度用的消费方在目录规模超过 200 前读数一致。
|
||||
- 汇总批次从 7 条增至 9 条 D1 语句(两个 COUNT DISTINCT 总量),均为同区间的索引聚合。
|
||||
- tv.dsh-market.com 现在在 `script-src 'self'` 下执行一个同源 JavaScript 文件;页面仍不加载任何第三方资源,`/`、`/app.js` 与 `/data` 的 Access JWT 门禁不变。若客户端脚本再次无法运行,静态加载提示会保留、页面错误横幅会给出具体报错。
|
||||
- 测试覆盖:`scripts/market-worker.test.mjs` 断言分页绑定、钳制与总量;`scripts/telemetry-view.test.mjs` 覆盖 Access 门禁与 boot JSON 转义。
|
||||
- 测试覆盖:`scripts/market-worker.test.mjs` 断言分页绑定、钳制与总量;`scripts/telemetry-view.test.mjs` 覆盖 Access 门禁、boot JSON 转义,以及活跃实例面板渲染在站点趋势面板之上。
|
||||
|
||||
## Testing
|
||||
|
||||
`node --test scripts/market-worker.test.mjs scripts/telemetry-view.test.mjs`(28 个用例),另用本地 HTTP harness 以 Playwright 驱动渲染页:翻页发出预期的 `/data` 窗口请求、切换范围重置两个偏移、悬停出现图表提示框、无控制台错误。桌面与移动端截图均已核验。
|
||||
`node --test scripts/market-worker.test.mjs scripts/telemetry-view.test.mjs`(28 个用例),另用本地 HTTP harness 以 Playwright 驱动渲染页:翻页发出预期的 `/data` 窗口请求、切换范围重置两个偏移、两图悬停各自出现提示框、无控制台错误。桌面与移动端截图均已核验。
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ GitHub README 展示用两个无需密钥的 shields 端点徽章(只返回聚
|
||||
|
||||
### 私有实时视图
|
||||
|
||||
`market/telemetry-view`(部署为 worker `dsh-market-telemetry-view`,地址 `tv.dsh-market.com`)是只读仪表盘:读取汇总接口的滚存缓存并渲染 KPI 卡片、日 PV/UV 趋势图、分页的热门路径与各包/皮肤安装量(含当日活跃、渠道分布与版本分布),自身不存任何数据。仪表盘页内切换时间范围与翻页经由同源 `/data` JSON 代理(同样校验 Access JWT)调用汇总接口的分页参数,不刷新整页。访问保护双层:路由应挂 Cloudflare Access 自托管应用(邮箱验证),worker 内部同时校验 Access JWT 签名(`ACCESS_TEAM` + `ACCESS_AUD` secret,未配置前默认拒绝服务)。路由上 `tv.dsh-market.com` 落在主 worker 的 `*.dsh-market.com` 通配 zone 路由内,由主 worker 在 fetch 入口把整个主机名经 `TELEMETRY_VIEW` 服务绑定转发给本 worker(Access JWT 头随请求透传;`/app.js` 与 `/data` 相应列入主 worker 的 `run_worker_first`,主站自己的 `/app.js` 资源由 worker 显式回退到 ASSETS 提供)。看板取数相应经反向的 `MARKET` 服务绑定直调主 worker:本 worker 运行在主 worker 的调用链内,公开 fetch 回 `dsh-market.com` 会在同一请求上下文里二次进入主 worker,触发 Cloudflare 环路保护并回落占位源站(522)。
|
||||
`market/telemetry-view`(部署为 worker `dsh-market-telemetry-view`,地址 `tv.dsh-market.com`)是只读仪表盘:读取汇总接口的滚存缓存并渲染 KPI 卡片、日活跃实例趋势图(心跳 UV)与站点日 PV/UV 趋势图、分页的热门路径与各包/皮肤安装量(含当日活跃、渠道分布与版本分布),自身不存任何数据。仪表盘页内切换时间范围与翻页经由同源 `/data` JSON 代理(同样校验 Access JWT)调用汇总接口的分页参数,不刷新整页。访问保护双层:路由应挂 Cloudflare Access 自托管应用(邮箱验证),worker 内部同时校验 Access JWT 签名(`ACCESS_TEAM` + `ACCESS_AUD` secret,未配置前默认拒绝服务)。路由上 `tv.dsh-market.com` 落在主 worker 的 `*.dsh-market.com` 通配 zone 路由内,由主 worker 在 fetch 入口把整个主机名经 `TELEMETRY_VIEW` 服务绑定转发给本 worker(Access JWT 头随请求透传;`/app.js` 与 `/data` 相应列入主 worker 的 `run_worker_first`,主站自己的 `/app.js` 资源由 worker 显式回退到 ASSETS 提供)。看板取数相应经反向的 `MARKET` 服务绑定直调主 worker:本 worker 运行在主 worker 的调用链内,公开 fetch 回 `dsh-market.com` 会在同一请求上下文里二次进入主 worker,触发 Cloudflare 环路保护并回落占位源站(522)。
|
||||
|
||||
## 接入新包
|
||||
|
||||
|
||||
@@ -171,21 +171,33 @@ function renderCards() {
|
||||
}).join('')
|
||||
}
|
||||
|
||||
/* ---------- trend chart (hand-rolled SVG: PV area + UV line) ---------- */
|
||||
function renderChart() {
|
||||
var daily = (data.site && data.site.daily) || []
|
||||
var box = $('chart')
|
||||
if (!daily.length) { box.innerHTML = '<div class="empty">暂无数据</div>'; return }
|
||||
/* ---------- hand-rolled SVG line chart, shared by both trend panels ----------
|
||||
cfg = { rows, series: [{ key, color, width, opacity, label }], aria, empty,
|
||||
fill: { id, key, color }, tip(row) -> html }
|
||||
Each box owns its own tooltip/crosshair so two charts can coexist. */
|
||||
function drawChart(boxId, cfg) {
|
||||
var box = $(boxId)
|
||||
var rows = cfg.rows || []
|
||||
if (!rows.length) { box.innerHTML = '<div class="empty">' + (cfg.empty || '暂无数据') + '</div>'; return }
|
||||
var W = 1000, H = 300, pl = 46, pr = 18, pt = 16, pb = 34
|
||||
var iw = W - pl - pr, ih = H - pt - pb
|
||||
var top = Math.max(1, Math.max.apply(null, daily.map(function (r) { return Math.max(Number(r.pv) || 0, Number(r.uv) || 0) })))
|
||||
var step = daily.length > 1 ? iw / (daily.length - 1) : 0
|
||||
function X(i) { return pl + (daily.length > 1 ? i * step : iw / 2) }
|
||||
var peak = 0
|
||||
rows.forEach(function (r) {
|
||||
cfg.series.forEach(function (s) { peak = Math.max(peak, Number(r[s.key]) || 0) })
|
||||
})
|
||||
var top = Math.max(1, peak)
|
||||
var step = rows.length > 1 ? iw / (rows.length - 1) : 0
|
||||
function X(i) { return pl + (rows.length > 1 ? i * step : iw / 2) }
|
||||
function Y(v) { return pt + ih - (Number(v) || 0) / top * ih }
|
||||
function line(key) {
|
||||
return daily.map(function (r, i) { return (i ? 'L' : 'M') + X(i).toFixed(1) + ' ' + Y(r[key]).toFixed(1) }).join('')
|
||||
return rows.map(function (r, i) { return (i ? 'L' : 'M') + X(i).toFixed(1) + ' ' + Y(r[key]).toFixed(1) }).join('')
|
||||
}
|
||||
var area = line('pv') + 'L' + X(daily.length - 1).toFixed(1) + ' ' + (pt + ih) + 'L' + X(0).toFixed(1) + ' ' + (pt + ih) + 'Z'
|
||||
var fill = cfg.fill
|
||||
? '<defs><linearGradient id="' + cfg.fill.id + '" x1="0" y1="0" x2="0" y2="1">'
|
||||
+ '<stop offset="0" stop-color="' + cfg.fill.color + '" stop-opacity=".32"/><stop offset="1" stop-color="' + cfg.fill.color + '" stop-opacity="0"/>'
|
||||
+ '</linearGradient></defs>'
|
||||
+ '<path d="' + line(cfg.fill.key) + 'L' + X(rows.length - 1).toFixed(1) + ' ' + (pt + ih) + 'L' + X(0).toFixed(1) + ' ' + (pt + ih) + 'Z" fill="url(#' + cfg.fill.id + ')"/>'
|
||||
: ''
|
||||
var grid = ''
|
||||
for (var g = 0; g <= 4; g++) {
|
||||
var gy = pt + ih * g / 4
|
||||
@@ -193,41 +205,67 @@ function renderChart() {
|
||||
+ '<text x="' + (pl - 8) + '" y="' + (gy + 4) + '" text-anchor="end" fill="#5d6580" font-size="11">' + fmt(Math.round(top * (4 - g) / 4)) + '</text>'
|
||||
}
|
||||
var ticks = ''
|
||||
var every = Math.max(1, Math.ceil(daily.length / 8))
|
||||
for (var t = 0; t < daily.length; t += every) {
|
||||
ticks += '<text x="' + X(t).toFixed(1) + '" y="' + (H - 12) + '" text-anchor="middle" fill="#5d6580" font-size="11">' + esc(daily[t].day.slice(5)) + '</text>'
|
||||
var every = Math.max(1, Math.ceil(rows.length / 8))
|
||||
for (var t = 0; t < rows.length; t += every) {
|
||||
ticks += '<text x="' + X(t).toFixed(1) + '" y="' + (H - 12) + '" text-anchor="middle" fill="#5d6580" font-size="11">' + esc(rows[t].day.slice(5)) + '</text>'
|
||||
}
|
||||
box.innerHTML =
|
||||
'<div class="legend" style="margin-bottom:10px"><span><i style="background:#8ea6ff"></i>PV</span><span><i style="background:#45c4f5"></i>UV</span></div>'
|
||||
+ '<svg viewBox="0 0 ' + W + ' ' + H + '" role="img" aria-label="站点访问趋势">'
|
||||
+ '<defs><linearGradient id="pvfill" x1="0" y1="0" x2="0" y2="1">'
|
||||
+ '<stop offset="0" stop-color="#6f8cff" stop-opacity=".32"/><stop offset="1" stop-color="#6f8cff" stop-opacity="0"/>'
|
||||
+ '</linearGradient></defs>'
|
||||
+ grid
|
||||
+ '<path d="' + area + '" fill="url(#pvfill)"/>'
|
||||
+ '<path d="' + line('pv') + '" fill="none" stroke="#8ea6ff" stroke-width="2.2" stroke-linejoin="round" stroke-linecap="round"/>'
|
||||
+ '<path d="' + line('uv') + '" fill="none" stroke="#45c4f5" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" opacity=".9"/>'
|
||||
+ ticks
|
||||
+ '<line id="cross" y1="' + pt + '" y2="' + (pt + ih) + '" stroke="rgba(230,234,246,.35)" stroke-width="1" opacity="0"/>'
|
||||
+ '<rect id="hitzone" x="' + pl + '" y="' + pt + '" width="' + iw + '" height="' + ih + '" fill="transparent"/>'
|
||||
+ '</svg><div class="tip" id="tip"></div>'
|
||||
var svg = box.querySelector('svg'), tip = $('tip'), cross = box.querySelector('#cross')
|
||||
box.querySelector('#hitzone').addEventListener('mousemove', function (ev) {
|
||||
var legend = cfg.series.length > 1
|
||||
? '<div class="legend" style="margin-bottom:10px">' + cfg.series.map(function (s) {
|
||||
return '<span><i style="background:' + s.color + '"></i>' + esc(s.label) + '</span>'
|
||||
}).join('') + '</div>'
|
||||
: ''
|
||||
var strokes = cfg.series.map(function (s) {
|
||||
return '<path d="' + line(s.key) + '" fill="none" stroke="' + s.color + '" stroke-width="' + s.width + '" stroke-linejoin="round" stroke-linecap="round"'
|
||||
+ (s.opacity ? ' opacity="' + s.opacity + '"' : '') + '/>'
|
||||
}).join('')
|
||||
box.innerHTML = legend
|
||||
+ '<svg viewBox="0 0 ' + W + ' ' + H + '" role="img" aria-label="' + esc(cfg.aria) + '">'
|
||||
+ fill + grid + strokes + ticks
|
||||
+ '<line class="cross" y1="' + pt + '" y2="' + (pt + ih) + '" stroke="rgba(230,234,246,.35)" stroke-width="1" opacity="0"/>'
|
||||
+ '<rect class="hit" x="' + pl + '" y="' + pt + '" width="' + iw + '" height="' + ih + '" fill="transparent"/>'
|
||||
+ '</svg><div class="tip"></div>'
|
||||
var svg = box.querySelector('svg'), tip = box.querySelector('.tip'), cross = box.querySelector('.cross'), hit = box.querySelector('.hit')
|
||||
hit.addEventListener('mousemove', function (ev) {
|
||||
var rect = svg.getBoundingClientRect()
|
||||
var mx = (ev.clientX - rect.left) / rect.width * W
|
||||
var i = Math.max(0, Math.min(daily.length - 1, Math.round((mx - pl) / (step || 1))))
|
||||
var r = daily[i]
|
||||
var i = Math.max(0, Math.min(rows.length - 1, Math.round((mx - pl) / (step || 1))))
|
||||
var r = rows[i]
|
||||
cross.setAttribute('x1', X(i)); cross.setAttribute('x2', X(i)); cross.setAttribute('opacity', '1')
|
||||
tip.innerHTML = '<div class="d">' + esc(r.day) + '</div>PV <b>' + full(r.pv) + '</b> · UV <b>' + full(r.uv) + '</b>'
|
||||
tip.innerHTML = '<div class="d">' + esc(r.day) + '</div>' + cfg.tip(r)
|
||||
tip.style.left = Math.max(0, Math.min(rect.width - 150, X(i) / W * rect.width - 60)) + 'px'
|
||||
tip.style.top = '34px'
|
||||
tip.classList.add('show')
|
||||
})
|
||||
box.querySelector('#hitzone').addEventListener('mouseleave', function () {
|
||||
hit.addEventListener('mouseleave', function () {
|
||||
tip.classList.remove('show'); cross.setAttribute('opacity', '0')
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------- active instances per day (heartbeat UV) ---------- */
|
||||
function renderActiveChart() {
|
||||
drawChart('active-chart', {
|
||||
rows: (data.plugins && data.plugins.daily) || [],
|
||||
aria: '每日活跃实例趋势',
|
||||
empty: '暂无心跳数据——插件心跳要等含遥测的版本发布、用户更新后才会出现',
|
||||
series: [{ key: 'uv', color: '#45c4f5', width: 2.2 }],
|
||||
tip: function (r) { return '活跃实例 <b>' + full(r.uv) + '</b> · 心跳上报 <b>' + full(r.beats) + '</b>' },
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------- site traffic trend (PV area + UV line) ---------- */
|
||||
function renderChart() {
|
||||
drawChart('chart', {
|
||||
rows: (data.site && data.site.daily) || [],
|
||||
aria: '站点访问趋势',
|
||||
fill: { id: 'pvfill', key: 'pv', color: '#6f8cff' },
|
||||
series: [
|
||||
{ key: 'pv', color: '#8ea6ff', width: 2.2, label: 'PV' },
|
||||
{ key: 'uv', color: '#45c4f5', width: 2, opacity: '.9', label: 'UV' },
|
||||
],
|
||||
tip: function (r) { return 'PV <b>' + full(r.pv) + '</b> · UV <b>' + full(r.uv) + '</b>' },
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------- shared pager ---------- */
|
||||
function pager(el, total, offset, size, onGo, onSize) {
|
||||
var pages = Math.max(1, Math.ceil(total / size))
|
||||
@@ -318,7 +356,7 @@ function dataUrl() {
|
||||
+ '&items_limit=' + state.itemsSize + '&items_offset=' + state.itemsOffset
|
||||
}
|
||||
function renderAll() {
|
||||
renderCards(); renderChart(); renderPaths(); renderItems()
|
||||
renderCards(); renderActiveChart(); renderChart(); renderPaths(); renderItems()
|
||||
$('updated').textContent = '更新于 ' + new Date().toLocaleTimeString('zh-CN', { hour12: false })
|
||||
}
|
||||
function refresh(section) {
|
||||
@@ -383,6 +421,10 @@ const SHELL = [
|
||||
'<div class="err" id="err" role="alert"></div>',
|
||||
'<p class="meta" id="boot-hint">正在渲染数据……若此提示不消失,说明页面脚本被拦截(请检查浏览器控制台)。</p>',
|
||||
'<section class="cards rise" id="cards"></section>',
|
||||
'<section class="panel rise" id="panel-active">',
|
||||
'<div class="panel-h"><h2>活跃实例趋势</h2><span class="note">按日去重的实例数(当日上报插件心跳的浏览器),最新一天即 KPI 卡的「今日活跃实例」</span></div>',
|
||||
'<div class="chart-box" id="active-chart"></div>',
|
||||
'</section>',
|
||||
'<section class="panel rise" id="panel-chart">',
|
||||
'<div class="panel-h"><h2>站点访问趋势</h2><span class="note">仅统计浏览器端上报的页面访问</span></div>',
|
||||
'<div class="chart-box" id="chart"></div>',
|
||||
|
||||
@@ -50,6 +50,9 @@ test('dashboard document inlines CSP-safe boot data and the paginated shell', ()
|
||||
assert.ok(html.includes('<script src="/app.js"'), 'external client script referenced')
|
||||
assert.ok(html.includes('id="paths-pager"'))
|
||||
assert.ok(html.includes('id="items-pager"'))
|
||||
// The active-instance trend renders above the site traffic trend.
|
||||
assert.ok(html.includes('id="active-chart"'), 'active-instance trend panel present')
|
||||
assert.ok(html.indexOf('id="active-chart"') < html.indexOf('id="panel-chart"'), 'active-instance trend sits above the site trend')
|
||||
assert.match(PAGE_CSP, /script-src 'self'/)
|
||||
assert.ok(!/script-src[^;]*unsafe-inline/.test(PAGE_CSP), 'script-src must not rely on unsafe-inline')
|
||||
assert.match(PAGE_CSP, /connect-src 'self'/)
|
||||
|
||||
Reference in New Issue
Block a user