@@ -1,12 +1,13 @@
'use strict' ;
/* =====================================================================
小白复盘 · 数据中枢 admin/app.js — HEL-529 第十二版
三页真实数据中枢:运行总览 / 数据源配置 / 数据血缘。
视觉系统逐行照抄已通过打样 hub-kimi.html + SPEC-V11.md( v12 血缘压缩增补),
小白复盘 · 数据中枢 admin/app.js — HEL-529 返工:按定稿 100% 还原
视觉与 DOM 结构逐行照抄已确认打样 hub-kimi.html( v12 血缘压缩增补),
但本文件不含任何随机/模拟数据——全部状态、数字、事件均来自真实接口:
既有 /admin/api/{overview,sources,jobs,batches,datasets, audit,...}
与 HEL-543 新增只读旁路 /admin/api/{providers/status,source-catalog,
lineage,lineage/affected}。
/admin/api/{overview,sources,jobs,batches,audit,...} 与 HEL-543 只读旁路
/admin/api/{providers/status,source-catalog,lineage,lineage/affected}。
动效遵循 SPEC-V11: marquee 46s 线性 / flowline 1.4s / radar 4.2s /
blink 1.1s steps(2) / ledPing 1.6s / sparkBlink 1.4s / 时钟冒号 blink /
延迟仅在真实值变化时闪烁(禁止随机伪造数值)/ 缓存年龄每秒真实增长。
===================================================================== */
/* ================= 基础:DOM helper / API / CSRF ================= */
@@ -136,27 +137,23 @@ function kpiHtml(label, value, sub, tone, live) {
const tc = { txt : '' , mint : 'glow-mint' , amb : 'glow-amb' , rd : 'glow-rd' , cy : 'glow-cy' } [ tone || 'txt' ] ;
return ` <div class="kpi"><span class="lab"> ${ label } </span><span class="num kv ${ tc } "> ${ value } ${ live ? '<span class="blink" style="color:#22d3ee;margin-left:2px">_</span>' : '' } </span> ${ sub ? ` <span class="ks"> ${ sub } </span> ` : '' } </div> ` ;
}
function radarBlipsHtml ( pulseOk ) {
return pulseOk
? ` <div class="blip pulse-dot" style="left:64%;top:30%;background:#34d399"></div><div class="blip pulse-dot" style="left:30%;top:62%;background:#f87171;animation-delay:.6s"></div> `
: '' ;
}
/* 延迟:真实值 + 变化时才闪(打样 jit 的真实数据版:不随机抖动数字,
仅当新一轮真实调用改写该值时给 .flash 青色闪烁)。 */
function latHtml ( ms , warn ) {
if ( ms == null ) return '<span style="color:#54637e">—</span>' ;
const color = ms > 1000 ? '#fbbf24' : ( warn ? '#fbbf24' : '#d7e1f0' ) ;
return ` <span class="num" style="color: ${ color } "> ${ Number ( ms ) . toLocaleString ( 'en-US' ) } ms</span> ` ;
}
function ageFromIso ( iso ) {
if ( ! iso ) return null ;
const t = Date . parse ( iso . includes ( 'T' ) ? iso : iso . replace ( ' ' , 'T' ) ) ;
if ( Number . isNaN ( t ) ) return null ;
return Math . max ( 0 , Math . floor ( ( Date . now ( ) - t ) / 1000 ) ) ;
return ` <span class="num jit" data-lat=" ${ Number ( ms ) } " style="color: ${ color } "> ${ Number ( ms ) . toLocaleString ( 'en-US' ) } ms</span> ` ;
}
/* 缓存年龄:记录真实时间戳,由 updateDynamics 每秒重算(真实增长)。 */
function ageHtml ( iso ) {
const s = ageFromIso ( iso ) ;
if ( s == null ) return '<span style="color:#54637e">—</span>' ;
const txt = s < 90 ? ` ${ s } s ` : s < 3600 ? ` ${ Math . floor ( s / 60 ) } m ` : ` ${ Math . floor ( s / 3600 ) } h ` ;
return ` <span class="num" style="color: ${ s > 90 ? '#fbbf24' : '#8b9bb4' } "> ${ txt } </span> ` ;
const ms = Date . parse ( String ( iso || '' ) . includes ( 'T' ) ? iso : String ( iso || '' ) . replace ( ' ' , 'T' ) ) ;
if ( ! iso || Number . isNaN ( ms ) ) return '<span style="color:#54637e">—</span>' ;
return ` <span class="num" data-age-epoch=" ${ ms } " style="color:#8b9bb4">—</span> ` ;
}
function fmtAgeSec ( s ) {
if ( s == null ) return '—' ;
// 打样口径:缓存年龄一律按秒展示、每秒增长(>30s 变琥珀)
return ` ${ Number ( s ) . toLocaleString ( 'en-US' ) } s ` ;
}
function hm ( iso ) {
if ( ! iso ) return '—' ;
@@ -167,13 +164,66 @@ function hm(iso) {
}
const p2 = ( x ) => String ( x ) . padStart ( 2 , '0' ) ;
/* ================= sparkline(真实历史:来自 provider_call_log) ================= */
const SPARK _COLORS = { cy : '#22d3ee' , mint : '#34d399' , amb : '#fbbf24' , rd : '#f87171' } ;
function sparkHtml ( jid , tone , w , h ) {
return ` <svg width=" ${ w } " height=" ${ h } " class="spark" data-jid=" ${ esc ( jid ) } " data-w=" ${ w } " data-h=" ${ h } " style="display:block;opacity:.9"><polyline points="" fill="none" stroke=" ${ SPARK _COLORS [ tone ] } " stroke-width="1.2" stroke-opacity=".8"/><circle class="spark-end" r="2" fill=" ${ SPARK _COLORS [ tone ] } " cx="0" cy="0"/></svg> ` ;
}
function sparkSeries ( jid ) {
const hist = LatHist . get ( jid ) || [ ] ;
if ( hist . length < 2 ) return null ;
const max = Math . max ( ... hist , 1 ) ;
return hist . map ( ( v ) => v / max ) ;
}
function sparkPaint ( svg ) {
const jid = svg . dataset . jid , w = + svg . dataset . w , h = + svg . dataset . h ;
const data = sparkSeries ( jid ) ;
const poly = svg . querySelector ( 'polyline' ) ;
const dot = svg . querySelector ( 'circle' ) ;
if ( ! data ) {
const y = ( h / 2 ) . toFixed ( 2 ) ;
poly . setAttribute ( 'points' , ` 0, ${ y } ${ w } , ${ y } ` ) ;
poly . setAttribute ( 'stroke-opacity' , '.25' ) ;
dot . style . display = 'none' ;
return ;
}
poly . setAttribute ( 'stroke-opacity' , '.8' ) ;
dot . style . display = '' ;
const pts = data . map ( ( v , i ) => ` ${ ( ( i / ( data . length - 1 ) ) * w ) . toFixed ( 2 ) } , ${ ( h - v * ( h - 3 ) - 1 ) . toFixed ( 2 ) } ` ) . join ( ' ' ) ;
poly . setAttribute ( 'points' , pts ) ;
dot . setAttribute ( 'cx' , ( w - 1 ) . toFixed ( 2 ) ) ;
dot . setAttribute ( 'cy' , ( h - data [ data . length - 1 ] * ( h - 3 ) - 1 ) . toFixed ( 2 ) ) ;
}
/* 每个“观测对象”(接口或数据集)的真实延迟历史(旧→新),来自
provider_call_log 最近 200 条。 */
const LatHist = new Map ( ) ;
const CALL _MATCHERS = { } ;
function buildLatHist ( ) {
const calls = ( RAW . providers && RAW . providers . recent _calls ) || [ ] ;
const buckets = new Map ( ) ;
[ ... calls ] . reverse ( ) . forEach ( ( c ) => {
if ( c . latency _ms == null ) return ;
const key = ` ${ c . provider } : ${ c . interface } ` ;
( buckets . get ( key ) || buckets . set ( key , [ ] ) . get ( key ) ) . push ( c . latency _ms ) ;
} ) ;
LatHist . clear ( ) ;
buckets . forEach ( ( arr , key ) => LatHist . set ( key , arr . slice ( - 24 ) ) ) ;
// 数据集维度的历史:tushare 观测行写的是数据集名,直接按数据集聚合
const dsBuckets = new Map ( ) ;
[ ... calls ] . reverse ( ) . forEach ( ( c ) => {
if ( c . provider !== 'tushare' || c . latency _ms == null ) return ;
( dsBuckets . get ( c . interface ) || dsBuckets . set ( c . interface , [ ] ) . get ( c . interface ) ) . push ( c . latency _ms ) ;
} ) ;
dsBuckets . forEach ( ( arr , ds ) => LatHist . set ( ` ds: ${ ds } ` , arr . slice ( - 24 ) ) ) ;
}
/* ================= 数据层:拉取真实接口并汇总为一份快照 ================= */
const RAW = {
overview : null , sources : null , providers : null , catalog : null ,
lineage : null , jobs : null , batches : null , datasets : null , audit : null ,
lineage : null , jobs : null , batches : null , audit : null ,
} ;
async function fetchAll ( ) {
const [ overview , sources , providers , catalog , lineage , jobs , batches , datasets , audit ] = await Promise . all ( [
const [ overview , sources , providers , catalog , lineage , jobs , batches , audit ] = await Promise . all ( [
api ( '/admin/api/overview' ) ,
api ( '/admin/api/sources' ) ,
api ( '/admin/api/providers/status?limit=200' ) ,
@@ -181,31 +231,14 @@ async function fetchAll() {
api ( '/admin/api/lineage' ) ,
api ( '/admin/api/jobs' ) ,
api ( ` /admin/api/batches?date= ${ today ( ) } ` ) ,
api ( ` /admin/api/datasets?date= ${ today ( ) } ` ) ,
api ( '/admin/api/audit' ) ,
] ) ;
RAW . overview = overview ; RAW . sources = sources ; RAW . providers = providers ; RAW . catalog = catalog ;
RAW . lineage = lineage ; RAW . jobs = jobs ; RAW . batches = batches ; RAW . datasets = datasets ; RAW . audit = audit ;
RAW . lineage = lineage ; RAW . jobs = jobs ; RAW . batches = batches ; RAW . audit = audit ;
buildLatHist ( ) ;
}
function today ( ) { return RAW . overview ? RAW . overview . trade _date : '' ; }
/* 按 provider(+interface) 索引 provider_health,供各页复用 */
function healthIndex ( ) {
const idx = { } ;
const rows = ( RAW . providers && RAW . providers . health ) || [ ] ;
for ( const row of rows ) {
idx [ row . provider ] = idx [ row . provider ] || [ ] ;
idx [ row . provider ] . push ( row ) ;
}
return idx ;
}
function callsIndex ( ) {
const idx = { } ;
const rows = ( RAW . providers && RAW . providers . recent _calls ) || [ ] ;
for ( const row of rows ) { ( idx [ row . provider ] = idx [ row . provider ] || [ ] ) . push ( row ) ; }
return idx ;
}
const OFFICIAL _DATASET _IDS = [ 'daily' , 'valuation' , 'moneyflow' , 'auction' , 'index_daily' , 'limit_events' , 'popularity' , 'dragon_tiger' , 'sector_daily' , 'stocks' ] ;
const DATASET _NAME = {
daily : '日K' , valuation : '估值' , moneyflow : '资金流' , auction : '竞价' , index _daily : '指数日K' ,
@@ -213,7 +246,7 @@ const DATASET_NAME = {
} ;
/* =====================================================================
总览页
总览页(结构照抄 hub-kimi.html,数据全部真实)
===================================================================== */
function buildOverviewVM ( ) {
const ov = RAW . overview || { } ;
@@ -223,7 +256,6 @@ function buildOverviewVM() {
( ov . publications || [ ] ) . forEach ( ( p ) => { pubsByDs [ p . dataset ] = p ; } ) ;
const batchByDs = { } ;
( ( RAW . batches && RAW . batches . batches ) || [ ] ) . forEach ( ( b ) => { if ( ! batchByDs [ b . dataset ] || b . started _at > batchByDs [ b . dataset ] . started _at ) batchByDs [ b . dataset ] = b ; } ) ;
const hidx = healthIndex ( ) ;
const totalOfficial = OFFICIAL _DATASET _IDS . length ;
const publishedCount = OFFICIAL _DATASET _IDS . filter ( ( id ) => pubsByDs [ id ] && pubsByDs [ id ] . state === 'published' ) . length ;
const anomalyCount = ( ov . anomalies || [ ] ) . length ;
@@ -231,7 +263,7 @@ function buildOverviewVM() {
const successRate = recentCalls . length ? ( recentCalls . filter ( ( c ) => c . ok ) . length / recentCalls . length * 100 ) : null ;
// 主站回退页面:主源异常且备源健康 → 判定为“回退中”的 provisional 数据集
let fallbackPages = 0 ;
const fallbackPages = [ ] ;
( RAW . lineage && RAW . lineage . items || [ ] ) . forEach ( ( it ) => {
if ( it . tier !== 'provisional' || ! it . backup _source ) return ;
const rows = it . live _provider _health || [ ] ;
@@ -239,7 +271,9 @@ function buildOverviewVM() {
const backupProv = ( it . providers || [ ] ) [ 1 ] ;
const primaryState = worstBucket ( rows . filter ( ( r ) => r . provider === primaryProv ) . map ( ( r ) => bucketOf ( r . state ) ) ) ;
const backupState = worstBucket ( rows . filter ( ( r ) => r . provider === backupProv ) . map ( ( r ) => bucketOf ( r . state ) ) ) ;
if ( ( primaryState === 'fail' || primaryState === 'degrade' || primaryState === 'slow' ) && backupState === 'ok' ) fallbackPages += 1 ;
if ( ( primaryState === 'fail' || primaryState === 'degrade' || primaryState === 'slow' ) && backupState === 'ok' ) {
( it . known _consumers || [ ] ) . forEach ( ( c ) => fallbackPages . push ( c ) ) ;
}
} ) ;
const datasetCards = OFFICIAL _DATASET _IDS . map ( ( id ) => {
@@ -257,37 +291,31 @@ function buildOverviewVM() {
return { id , name : DATASET _NAME [ id ] || id , bucket , time : note && ! batch ? . error ? note : '' , rows , err : batch && batch . error ? batch . error : '' } ;
} ) ;
// 实时观察层:eastmoney / tencent 的真实 provider_health 行
// 实时观察层:eastmoney / tencent 目录接口的真实观测(observed=true)
const catalogByProv = { } ;
( RAW . catalog && RAW . catalog . items || [ ] ) . forEach ( ( it ) => { catalogByProv [ it . provider ] = it ; } ) ;
const observers = [ ] ;
[ 'eastmoney' , 'tencent' ] . forEach ( ( prov ) => {
const rows = hidx [ prov ] || [ ] ;
const cat = catalogByProv [ prov ] ;
rows . forEach ( ( row ) => {
const iface = ( cat && cat . interfaces . find ( ( i ) => i . interface === row . interface ) ) || { } ;
if ( ! cat ) return ;
cat . interfaces . forEach ( ( iface ) => {
if ( ! iface . observed ) return ;
observers . push ( {
name : iface . capability || row . interface ,
provider : prov ,
interface : row . interface ,
lastOk : row . last _ok _at ,
lat : row . last _latency _ms ,
bucket : bucketOf ( row . state ) ,
note : row . last _error || row . last _fallback _reason || '' ,
jid : ` ${ prov } : ${ iface . interface } ` ,
name : iface . capability , provider : prov , interface : iface . interface ,
lastOk : iface . observed _at , lat : iface . observed _latency _ms ,
bucket : bucketOf ( iface . observed _state ) ,
note : iface . observed _note || '' ,
} ) ;
} ) ;
} ) ;
// 主站影响:lineage() 条目按 known_consumers 展开
// 主站影响:lineage() 条目按 known_consumers 展开(接口级状态)
const siteImpact = [ ] ;
( RAW . lineage && RAW . lineage . items || [ ] ) . forEach ( ( it ) => {
const rows = it . live _provider _health || [ ] ;
const bucket = it . tier === 'official'
? ( it . publication ? ( it . publication . state === 'published' ? 'ok' : ( it . publication . state === 'degraded' ? 'degrade' : 'fail' ) ) : ( rows . length ? worstBucket ( rows . map ( ( r ) => bucketOf ( r . state ) ) ) : 'plan' ) )
: ( rows . length ? worstBucket ( rows . map ( ( r ) => bucketOf ( r . state ) ) ) : 'off' ) ;
const note = rows . find ( ( r ) => r . last _error ) ? . last _error || rows . find ( ( r ) => r . last _fallback _reason ) ? . last _fallback _reason || '' ;
const bucket = lineageItemBucket ( it ) ;
( it . known _consumers || [ ] ) . forEach ( ( c ) => {
siteImpact . push ( { page : c , dataset : it . dataset , bucket , note } ) ;
siteImpact . push ( { page : c , short : pageNameOf ( c ) , dataset : it . dataset , bucket , note : lineageItemNote ( it ) } ) ;
} ) ;
} ) ;
const okPages = siteImpact . filter ( ( s ) => s . bucket === 'ok' ) . length ;
@@ -304,14 +332,16 @@ function buildOverviewVM() {
return { t : ( j . at . split ( '-' ) [ 0 ] || j . at ) . slice ( 0 , 5 ) , label : j . title , state : st , jobId : j . id } ;
} ) ;
// 待处理异常:批次失败/停滞 + 主源连续失败 + 未配置授权源
// 待处理异常:overview.anomalies 已在后端收敛为“仍未恢复的最新异常”
const incidents = [ ] ;
( ov . anomalies || [ ] ) . forEach ( ( b ) => {
incidents . push ( { sev : 'fail' , tag : b . state , title : ` ${ DATASET _NAME [ b . dataset ] || b . dataset } ${ b . state === 'failed' ? '失败' : '停滞' } ` , meta : ` ${ b . error ? esc ( b . error ) : '无错误详情' } · 批次 ${ b . batch _id } ` , dataset : b . dataset } ) ;
} ) ;
if ( eod . state === 'waiting_upstream' || eod . state === 'cutoff_failed' ) {
incidents . push ( { sev : eod . state === 'cutoff_failed' ? 'fail' : 'slow' , tag : eod . state , title : eod . state === 'cutoff_failed' ? '盘后批已截止失败' : '等待上游重试 ' , meta : ` 已试 ${ eod . attempts || 0 } 次 ${ ( eod . missing _datasets || [ ] ) . length ? ' · 缺 ' + eod . missing _datasets . join ( ',' ) : '' } ` , dataset : ( eod . missing _datasets || [ ] ) [ 0 ] || '' } ) ;
incidents . push ( { sev : eod . state === 'cutoff_failed' ? 'fail' : 'slow' , tag : eod . state , title : eod . state === 'cutoff_failed' ? '盘后批已截止失败' : '自动重试窗口 ' , meta : ` 15:15– 23:30 · 每 30 分钟 · 成功即停 · 已试 ${ eod . attempts || 0 } 次 ${ ( eod . missing _datasets || [ ] ) . length ? ' · 缺 ' + eod . missing _datasets . join ( ',' ) : '' } ` , dataset : ( eod . missing _datasets || [ ] ) [ 0 ] || '' , cd : eod . next _retry _at } ) ;
}
const hidx = { } ;
( ( RAW . providers && RAW . providers . health ) || [ ] ) . forEach ( ( r ) => { ( hidx [ r . provider ] = hidx [ r . provider ] || [ ] ) . push ( r ) ; } ) ;
Object . keys ( hidx ) . forEach ( ( prov ) => {
const bad = ( hidx [ prov ] || [ ] ) . filter ( ( r ) => ( r . consec _failures || 0 ) > 0 && bucketOf ( r . state ) === 'fail' ) ;
if ( bad . length ) incidents . push ( { sev : 'fail' , tag : prov , title : ` ${ prov } 部分接口连续失败 ` , meta : bad . map ( ( r ) => ` ${ r . interface } × ${ r . consec _failures } ` ) . join ( ' · ' ) , provider : prov } ) ;
@@ -325,6 +355,7 @@ function buildOverviewVM() {
trade _date : ov . trade _date , session _phase : ov . session _phase , eod , rev ,
publishedCount , totalOfficial , anomalyCount , successRate ,
fallbackPages , datasetCards , observers , siteImpact , okPages , timeline , incidents ,
unpublished : OFFICIAL _DATASET _IDS . filter ( ( id ) => ! pubsByDs [ id ] || pubsByDs [ id ] . state !== 'published' ) ,
} ;
}
@@ -335,7 +366,7 @@ function heroHtml(vm) {
if ( vm . rev . state === 'waiting_review' ) tags . push ( tagHtml ( 'cy' , '复核 · 待发布' ) ) ;
const subParts = [ ] ;
if ( vm . eod . attempts ) subParts . push ( ` 已试 <span style="color:#fbbf24"> ${ vm . eod . attempts } </span> 次 ` ) ;
if ( vm . eod . next _retry _at ) subParts . push ( ` 下次重试 <span style="color:#fbbf24"> ${ hm ( vm . eod . next _retry _at ) } </span> ` ) ;
if ( vm . eod . next _retry _at ) subParts . push ( ` 下次重试 <span class="num" data-cd-epoch=" ${ Date . parse ( String ( vm . eod . next _retry _at ) . replace ( ' ' , 'T' ) ) } " data-cdfmt="mmss" style="color:#fbbf24">--:-- </span>` ) ;
if ( ( vm . eod . missing _datasets || [ ] ) . length ) subParts . push ( ` 缺 <span style="color:#f87171"> ${ esc ( vm . eod . missing _datasets . join ( ',' ) ) } </span> ` ) ;
if ( vm . rev . window ) subParts . push ( ` 复核窗口 <span style="color:#22d3ee"> ${ esc ( vm . rev . window ) } </span> ` ) ;
return ` <div class="panel hero"> ` +
@@ -345,23 +376,25 @@ function heroHtml(vm) {
( subParts . length ? ` <div class="hero-sub"> ${ subParts . join ( '<span class="sep">|</span>' ) } </div> ` : '' ) +
` </div><span class="flex1"></span> ` +
` <div class="kpis"> ` +
kpiHtml ( '已发布数据集' , ` <span class="glow-mint"> ${ vm . publishedCount } </span><span style="color:#54637e;font-size:18px">/ ${ vm . totalOfficial } </span> ` , null ) +
kpiHtml ( '已发布数据集' , ` <span class="glow-mint"> ${ vm . publishedCount } </span><span style="color:#54637e;font-size:18px">/ ${ vm . totalOfficial } </span> ` , vm . unpublished . length ? ` ${ vm . unpublished . map ( ( d ) => DATASET _NAME [ d ] || d ) . join ( ' · ' ) } 未发布 ` : '全部已发布' ) +
kpiHtml ( '今日调用成功率' , vm . successRate == null ? '<span style="color:#54637e">—</span>' : ` <span> ${ vm . successRate . toFixed ( 1 ) } %</span> ` , null , 'mint' , vm . successRate != null ) +
kpiHtml ( '主站回退页面' , ` ${ vm . fallbackPages } 页 ` , null , vm . fallbackPages ? 'amb' : 'txt' ) +
kpiHtml ( '主站回退页面' , ` ${ vm . fallbackPages . length } 页 ` , vm . fallbackPages . length ? esc ( vm . fallbackPages [ 0 ] ) : '实时层主源正常' , vm . fallbackPages . length ? 'amb' : 'txt' ) +
kpiHtml ( 'EOD 尝试' , ` ${ vm . eod . attempts || 0 } 次 ` , '上限 5 · 23:30 止' , vm . eod . attempts ? 'rd' : 'txt' ) +
` </div></div> ` ;
}
function datasetStripHtml ( vm ) {
return ` <div class="ds-grid"> ${ vm . datasetCards . map ( ( d ) => {
const ring = d . bucket === 'fail' ? 'fail' : d . bucket === 'degrade' ? 'slow' : d . bucket === 'review' ? 'review' : '' ;
const sparkTone = d . bucket === 'fail' ? 'rd' : d . bucket === 'degrade' ? 'amb' : d . bucket === 'review' ? 'cy' : 'mint' ;
let note = '' ;
if ( d . time ) note += ` <span class="num" style="color:#8b9bb4"> ${ esc ( d . time ) } · </span> ` ;
if ( d . rows ) note += ` <span class="num"> ${ Number ( d . rows ) . toLocaleString ( 'en-US' ) } 行</span> ` ;
if ( d . err ) note += ` <span style="color:#f87171"> ${ esc ( d . err ) } </span> ` ;
if ( d . err ) { const brief = String ( d . err ) . length > 22 ? String ( d . err ) . slice ( 0 , 22 ) + '…' : d . err ; note += ` <span style="color:#f87171" title=" ${ esc ( d . err ) } "> ${ esc ( brief ) } </span> ` ; }
if (!d.time && !d.rows && !d.err) note = '<span style="color:#54637e">今日尚未发布</span>';
return ` < div class = "panel dsc ${ring}" data - dataset = "${d.id}" > ` +
` < div class = "dsc-top" > < span class = "dsc-name" > $ { esc ( d . name ) } < / s p a n > $ { p i l l H t m l ( d . b u c k e t ) } < / d i v > ` +
` <div class="dsc-note"> ${ note } </div> ` +
sparkHtml ( ` ds: ${ d . id } ` , sparkTone , 96 , 16 ) +
` </div> ` ;
} ) . join ( '' ) } < / d i v > ` ;
}
@@ -373,20 +406,30 @@ function observatoryHtml(vm) {
` <td style="color:#8b9bb4"> ${ o . provider === 'eastmoney' ? '东方财富' : '腾讯行情' } </td> ` +
` <td> ${ ageHtml ( o . lastOk ) } </td> ` +
` <td> ${ latHtml ( o . lat , o . bucket !== 'ok' ) } </td> ` +
` <td> ${ sparkHtml ( o . jid , o . bucket === 'slow' ? 'amb' : 'mint' , 64 , 16 ) } </td> ` +
` <td><span style="display:flex;align-items:center;gap:8px"> ${ pillHtml ( o . bucket ) } ${ o . note ? ` <span class="obs-note"> ${ esc ( o . note ) } </span> ` : '' } </span></td> ` +
` </tr> ` ) . join ( '' ) : ` <tr><td colspan="5 " class="empty-hint">尚无真实观测记录(等待首次探测/调用)</td></tr> ` ;
const body = ` <table class="dtable"><thead><tr><th>观察项</th><th>当前来源</th><th>最近成功 </th><th>延迟</th><th>状态 · 备注</th></tr></thead><tbody> ${ rows } </tbody></table> ` ;
` </tr> ` ) . join ( '' ) : ` <tr><td colspan="6 " class="empty-hint">尚无真实观测记录(等待首次探测/调用)</td></tr> ` ;
const body = ` <table class="dtable"><thead><tr><th>观察项</th><th>当前来源</th><th>缓存年龄 </th><th>延迟</th><th>趋势</th><th> 状态 · 备注</th></tr></thead><tbody> ${ rows } </tbody></table> ` ;
return panelHtml ( '实时观察层' , right , body , false ) ;
}
function siteImpactHtml ( vm ) {
const bad = vm . siteImpact . filter ( ( s ) => s . bucket !== 'ok' ) ;
const right = ` <span style="font-size:10px"><span style="color:#34d399"> ${ vm . okPages } 项正常</span><span style="color:#54637e"> · </span><span style="color:#fbbf24"> ${ vm . siteImpact . length - vm . okPages } 项待关注</span></span> ` ;
const rows = vm . siteImpact . length ? vm . siteImpact . map ( ( s ) => ` <tr> ` +
` <td style="color:#e8f1ff;width:36 %"> ${ esc ( s . page ) } </td> ` +
` <td style="width:18 %"> ${ pillHtml ( s . bucket ) } </td> ` +
const rows = bad . length ? bad . slice ( 0 , 5 ) . map ( ( s ) => ` <tr> ` +
` <td style="color:#e8f1ff;width:40 %"> ${ esc ( s . page ) } </td> ` +
` <td style="width:20 %"> ${ pillHtml ( s . bucket ) } </td> ` +
` <td style="font-size:11px;color:#54637e"> ${ s . note ? esc ( s . note ) : '—' } </td> ` +
` <td style="text-align:right"><button class="tbtn mini" data-goto-lineage=" ${ esc ( s . dataset ) } ">查看血缘</button></td> ` +
` </tr> ` ) . join ( '' ) : ` <tr><td colspan="4" class="empty-hint">尚无血缘记录 </td></tr> ` ;
return panelHtml ( '主站影响' , right , ` <table class="dtable"><tbody> ${ rows } </tbody></table> ` , false ) ;
` </tr> ` ) . join ( '' ) : ` <tr><td style="color:#e8f1ff;width:40%">全部页面正常</td><td> ${ pillHtml ( 'ok' ) } </td><td style="font-size:11px;color:#54637e">—</td><td> </td></tr>` ;
const footPages = vm . siteImpact . map ( ( s ) => ( { name : s . short , bucket : s . bucket } ) ) ;
const byPage = new Map ( ) ;
footPages . forEach ( ( p ) => { const cur = byPage . get ( p . name ) ; if ( ! cur || SEV [ p . bucket ] > SEV [ cur ] ) byPage . set ( p . name , p . bucket ) ; } ) ;
const foot = ` <div class="site-foot"><span style="color:#34d399;margin-right:4px">正常 ${ vm . okPages } </span> ` +
[ ... byPage . entries ( ) ] . map ( ( [ name , bucket ] ) => bucket === 'ok'
? ` <span style="display:inline-flex;align-items:center;gap:6px"><span> ${ esc ( name ) } </span><span style="color:#243152">·</span></span> `
: ` <span style="display:inline-flex;align-items:center;gap:6px;color: ${ HEALTH [ bucket ] . color } "> ${ esc ( name ) } ( ${ HEALTH [ bucket ] . label } )</span><span style="color:#243152">·</span></span> ` ) . join ( '' ) +
` </div> ` ;
return panelHtml ( '主站影响' , right , ` <table class="dtable"><tbody> ${ rows } </tbody></table> ${ foot } ` , false ) ;
}
function timelineHtml ( vm ) {
const nodes = vm . timeline ;
@@ -422,17 +465,21 @@ function timelineHtml(vm) {
s += ` <g><line x1=" ${ nowX } " y1=" ${ y - 44 } " x2=" ${ nowX } " y2=" ${ y + 44 } " stroke="#22d3ee" stroke-width="1" stroke-dasharray="2 3" opacity=".7"/> ` +
` <circle cx=" ${ nowX } " cy=" ${ y } " r="3.6" fill="#22d3ee" class="now-dot now-r"/> ` +
` <text x=" ${ nowX } " y=" ${ y - 50 } " text-anchor="middle" font-size="9" fill="#22d3ee" font-weight="600" class="num">NOW</text></g></svg> ` ;
const right = ` <span style="font-size:10px;color:#54637e">现在 <span class="num" style="color:#22d3ee"> ${ p2 ( now . getHours ( ) ) } : ${ p2 ( now . getMinutes ( ) ) } </span> · <span style="color:#8b9bb4">SCHEDULER.PY</span></span> ` ;
const range = nodes . length ? ` ${ nodes [ 0 ] . t } → ${ nodes [ nodes . length - 1 ] . t } ` : '' ;
const right = ` <span style="font-size:10px;color:#54637e">现在 <span class="num" style="color:#22d3ee"> ${ p2 ( now . getHours ( ) ) } : ${ p2 ( now . getMinutes ( ) ) } </span> · ${ range } · <span style="color:#8b9bb4">SCHEDULER.PY</span></span> ` ;
return panelHtml ( '今晚时间线' , right , s , false ) ;
}
function incidentsHtml ( vm ) {
if ( ! vm . incidents . length ) return panelHtml ( '待处理异常' , tagHtml ( 'mint' , '0 OPEN' ) , '<div class="empty-hint">当前无待处理异常</div>' , true ) ;
const cards = vm . incidents . map ( ( it ) => {
const tag = it . sev === 'fail' ? tagHtml ( 'rd' , esc ( it . tag ) ) : it . sev === 'slow' ? tagHtml ( 'amb' , esc ( it . tag ) ) : tagHtml ( 'dashed' , esc ( it . tag ) ) ;
const meta = it . cd
? ` ${ it . meta } <span class="num" style="color:#fbbf24"> · 下次重试 <span data-cd-epoch=" ${ Date . parse ( String ( it . cd ) . replace ( ' ' , 'T' ) ) } " data-cdfmt="ms">--m--s</span></span> `
: it . meta ;
return ` <div class="inc ${ it . sev } "> ${ tag } ` +
` <div style="flex:1;min-width:0"> ` +
` <div class="inc-title" ${ it . sev === 'off' ? ' style="color:#8b9bb4"' : '' } > ${ esc ( it . title ) } </div> ` +
` <div class="inc-meta"> ${ esc ( it . meta ) } </div> ` +
` <div class="inc-meta"> ${ meta } </div> ` +
` </div> ` +
` <button class="tbtn" style="flex:none" data-incident-action data-dataset=" ${ esc ( it . dataset || '' ) } " data-provider=" ${ esc ( it . provider || '' ) } ">去处理 →</button> ` +
` </div> ` ;
@@ -447,44 +494,62 @@ function overviewHtml() {
}
/* =====================================================================
数据源配置页
数据源配置页(分组接口表照抄打样 mx-grid 结构,数据全部真实)
===================================================================== */
const srcUiState = { } ;
const PROV _ROLE _TEXT = {
tushare : 'official · 盘后正式数据唯一源' ,
eastmoney : 'free · 盘中观察主源' ,
tencent : 'free · 盘中观察备源' ,
ifind : 'licensed · 图表 / 问财 / 实时快照' ,
} ;
function buildSourcesVM ( ) {
const catalog = ( RAW . catalog && RAW . catalog . items ) || [ ] ;
const legacy = { } ;
( RAW . sources && RAW . sources . items || [ ] ) . forEach ( ( it ) => { legacy [ it . provider ] = it ; } ) ;
const hidx = healthIndex ( ) ;
const cidx = callsIndex ( ) ;
const cidx = { } ;
( ( RAW . providers && RAW . providers . recent _calls ) || [ ] ) . forEach ( ( c ) => { if ( ! cidx [ c . provider ] ) cidx [ c . provider ] = c ; } ) ;
const active = catalog . filter ( ( c ) => c . role !== 'reserved' ) ;
const reserved = catalog . filter ( ( c ) => c . role === 'reserved' ) ;
const totalIfaces = active . reduce ( ( n , c ) => n + c . interfaces . length , 0 ) ;
const usedIfaces = active . reduce ( ( n , c ) => n + c . interfaces . filter ( ( i ) => ( hidx [ c . provider ] || [ ] ) . some ( ( h ) => h . interface === i . interface ) ) . length , 0 ) ;
// “在用”= 已登记且已观察到调用(接口名或数据集名匹配,后端 join 已标注)
const usedIfaces = active . reduce ( ( n , c ) => n + c . interfaces . filter ( ( i ) => i . observed ) . length , 0 ) ;
const cards = active . map ( ( c ) => {
const leg = legacy [ c . provider ] || { } ;
const legState = ( leg . health && ( leg . health . state || leg . health . status ) ) || null ;
const rows = hidx [ c . provider ] || [ ] ;
const worst = rows . length ? worstBucket ( rows . map ( ( r ) => bucketOf ( r . state ) ) ) : ( legState ? bucketOf ( legState ) : 'off' ) ;
const cred = c . credential || { } ;
const lastCall = ( cidx [ c . provider ] || [ ] ) [ 0 ] ;
const observedRows = c . interfaces . filter ( ( i ) => i . observed ) ;
const worst = observedRows . length ? worstBucket ( observedRows . map ( ( i ) => bucketOf ( i . observed _state ) ) ) : null ;
const configured = cred . configured ;
let bucket ;
if ( worst ) bucket = worst ;
else if ( configured ) bucket = 'plan' ;
else bucket = 'off' ;
const lastCall = cidx [ c . provider ] ;
if ( ! ( c . provider in srcUiState ) ) srcUiState [ c . provider ] = { open : true , probing : false , probeResult : null } ;
const ifaceRows = c . interfaces . map ( ( i ) => {
const h = rows . find ( ( r ) => r . interface === i . interface ) ;
return {
api : i . interface , use : i . capability ,
ok : h ? ( h . last _ok _at ? hm ( h . last _ok _at ) : '—' ) : '—' ,
lat : h ? h . last _latency _ms : null ,
st : h ? bucketOf ( h . state ) : 'off' ,
err : h && h . state !== 'ok' ? ( h . last _error || h . last _fallback _reason || '' ) : '' ,
} ;
const groups = [ ] ;
const groupMap = new Map ( ) ;
c . interfaces . forEach ( ( i ) => {
const g = i . group || '其他' ;
if ( ! groupMap . has ( g ) ) groupMap . set ( g , [ ] ) ;
groupMap . get ( g ) . push ( i ) ;
} ) ;
groupMap . forEach ( ( items , name ) => groups . push ( { name , items } ) ) ;
const ifaceRows = ( group ) => group . items . map ( ( i ) => ( {
api : i . interface , use : i . capability ,
ok : i . observed ? ( i . observed _at ? hm ( i . observed _at ) : '—' ) : '—' ,
lat : i . observed ? i . observed _latency _ms : null ,
st : i . observed ? bucketOf ( i . observed _state ) : ( configured ? 'plan' : 'off' ) ,
obs : i . observed , basis : i . observed _basis ,
err : i . observed && i . observed _state !== 'ok' ? ( i . observed _note || '' ) : '' ,
} ) ) ;
return {
id : c . provider , name : c . label , role : c . role , tag : c . credential _key ? 'licensed' : 'free' ,
bucket : worst , credential : cred , usedCount : ifaceRows . filter ( ( r ) => r . st !== 'off' ) . length , totalCount : ifaceRows . length ,
lastProbe : lastCall ? ` ${ hm ( lastCall . created _at ) } · ${ lastCall . ok ? ` ✓ ${ lastCall . latency _ms } ms ` : ` × ${ lastCall . error || 'ERROR' } ` } ` : '—' ,
latency : rows . length ? ( rows . map ( ( r ) => r . last _latency _ms ) . filter ( ( v ) => v != null ) [ 0 ] || null ) : null ,
error : rows . find ( ( r ) => r . last _error ) ? . last _error || ' ',
ifaceRows ,
id : c . provider , name : c . label , roleText : PROV _ROLE _TEXT [ c . provider ] || c . role ,
tag : c . credential _key ? 'licensed' : 'free' ,
bucket , credential : cred ,
usedCount : c . interfaces . filter ( ( i ) => i . observed ) . length , totalCount : c . interfaces . length ,
lastProbe : lastCall ? ` ${ hm ( lastCall . fetched _at ) } ${ lastCall . status === 'ok' ? ` ✓ ${ lastCall . latency _ms ? ? '' } ms ` : ` × ${ lastCall . error || 'ERROR' } ` } ` : '— ',
latency : observedRows . length ? ( observedRows . map ( ( r ) => r . observed _latency _ms ) . filter ( ( v ) => v != null ) [ 0 ] || null ) : null ,
error : observedRows . find ( ( r ) => r . observed _state !== 'ok' && r . observed _note ) ? . observed _note || '' ,
groups : groups . map ( ( g ) => ( { name : g . name , rows : ifaceRows ( g ) } ) ) ,
} ;
} ) ;
return { cards , reserved , totalIfaces , usedIfaces } ;
@@ -501,36 +566,48 @@ function sourceCardHtml(card) {
// 靠 note 字段区分,这里必须原样展示 note,不能一律折叠成"已配置"。
const credBadge = card . credential . note ? card . credential . note :
card . credential . configured ? ` 已配置 ${ card . credential . last4 ? ' · ' + card . credential . last4 : '' } ` : '未配置' ;
const badges = [ credBadge , card . role ] ;
const badges = [ credBadge ] ;
if ( card . credential . configured && card . credential . updated _at ) badges . push ( ` ${ String ( card . credential . updated _at ) . slice ( 5 , 10 ) . replace ( '-' , '/' ) } 更新 ` ) ;
badges . push ( card . id === 'tushare' ? '主(无备)' : ( card . id === 'ifind' ? '图表主源(未启用)' : ( card . id === 'tencent' ? '实时层备' : '实时层主' ) ) ) ;
let h = ` <section class="panel" style=" ${ ringStyle } "> ` ;
h += ` <header class="src-head"> ` +
ledHtml ( ledSt , card . bucket === 'fail' ) +
` <span class="src-name"> ${ esc ( card . name ) } </span> ` +
` <span class="src-role"> ${ esc ( card . tag ) } </span> ` +
pillHtml ( card . bucket ) +
` <span class="src-role"> ${ esc ( card . roleText ) } </span> ` +
pillHtml ( card . bucket , card . bucket === 'plan' ? '已配置 · 待观测' : '' ) +
` <span style="display:flex;gap:6px"> ${ badges . map ( ( b ) => tagHtml ( card . bucket === 'off' ? 'dashed' : 'dim' , esc ( b ) ) ) . join ( '' ) } </span> ` +
` <span class="flex1"></span> ` +
` <button class="tbtn" data-probe=" ${ card . id } " ${ ui . probing ? ' disabled' : '' } > ${ ui . probing ? '<span class="animate-spin" style="margin-right:4px">◌</span>探测中…' : '探测一次' } </button> ` +
` <button class="tbtn" data-toggle=" ${ card . id } "> ${ ui . open ? '收起接口清单' : '展开接口清单' } </button> ` +
` <button class="tbtn" data-probe=" ${ card . id } " ${ ui . probing || card . bucket === 'off' ? ' disabled' : '' } > ${ ui . probing ? '<span class="animate-spin" style="margin-right:4px">◌</span>探测中…' : '探测一次' } </button> ` +
( card . bucket === 'off'
? ` <button class="tbtn" data-cred-hint=" ${ card . id } ">更新凭证</button> `
: ` <button class="tbtn" data-toggle=" ${ card . id } "> ${ ui . open ? '收起接口清单' : '展开接口清单' } </button> ` ) +
` </header> ` ;
const probeCell = ui . probeResult
? ` <span class="num" style="color: ${ ui . probeResult . ok ? '#34d399' : '#f87171' } "> ${ ui . probeResult . t } ${ ui . probeResult . ok ? ` ✓ ${ ui . probeResult . ms } ms ` : '× TIMEOUT' } </span> `
: ` <span class="num" ${ card . lastProbe . includes ( '✓' ) ? ' style="color:#34d399"' : card . lastProbe . includes ( '× ' ) ? ' style="color:#f87171"' : '' } > ${ esc ( card . lastProbe ) } </span> ` ;
h += ` <div class="src-meta"> ` +
` <span>接口 <span class="num" style="color:#22d3ee"> ${ card . usedCount } / ${ card . totalCount } </span> 在用</span> ` +
` <span class="msep">|</span><span>最后探测 <span class="num" style="color: ${ card . lastProbe . includes ( '× ' ) ? '#f87171' : '#8b9bb4' } "> ${ esc ( card . lastProbe ) } </span> </span>` +
` <span>接口 <span class="num" style="color: ${ card . bucket === 'off' ? '#54637e' : ' #22d3ee' } "> ${ card . usedCount } / ${ card . totalCount } </span> 在用</span> ` +
` <span class="msep">|</span><span>最后探测 ${ probeCell } </span> ` +
` <span class="msep">|</span><span>延迟 ${ latHtml ( card . latency ) } </span> ` +
( card . error ? ` <span class="msep">|</span><span style="color:#f87171">最近错误 ${ esc ( card . error ) } </span> ` : ` <span class="msep">|</span><span style="color:#54637e">无最近错误</span> ` ) +
( card . error ? ` <span class="msep">|</span><span style="color:#f87171">最近错误 ${ esc ( card . error ) } </span> ` : ( card . bucket !== 'off' ? ` <span class="msep">|</span><span style="color:#54637e">无最近错误</span> ` : '' ) ) +
` </div> ` ;
if ( ui . open && card . ifaceRows . length ) {
h += ` <div class="mx-grid"><div style="min-width:0;grid-column:1/-1 "> ` +
if ( ui . open && card . bucket !== 'off' ) {
h += ` <div class="mx-grid"> ` + card . groups . map ( ( g ) => ` <div style="min-width:0"> ` +
` <div class="lab" style="margin-bottom:6px"> ${ esc ( g . name ) } </div> ` +
` <table class="dtable"><thead><tr><th>接口</th><th>用途</th><th>最近成功</th><th>延迟</th><th>状态</th></tr></thead><tbody> ` +
card . ifaceRows . map ( ( it ) => ` <tr> ` +
g . rows . map ( ( it ) => ` <tr> ` +
` <td style="color:#22d3ee;font-size:11px;word-break:break-all;min-width:90px"> ${ esc ( it . api ) } </td> ` +
` <td style="color:#8b9bb4;font-size:11px;white-space:nowrap"> ${ esc ( it . use ) } </td> ` +
` <td class="num" style="font-size:11px;color: ${ it . st === 'fail ' ? '#f87171' : '#8b9bb4' } "> ${ esc ( it . ok ) } </td> ` +
` <td class="num" style="font-size:11px;color: ${ it . ok . includes ( '× ' ) || it . ok === '昨日 ' ? '#f87171' : '#8b9bb4' } "> ${ esc ( it . ok ) } </td> ` +
` <td style="font-size:11px"> ${ latHtml ( it . lat ) } </td> ` +
` <td style="white-space:nowrap"><span style="display:flex;align-items:center;gap:6px"> ${ pillHtml ( it . st ) } ${ it . err ? ` <span style="font-size:10px;color:#f87171"> ${ esc ( it . err ) } </span> ` : '' } </span></td> ` +
` </tr> ` ) . join ( '' ) + ` </tbody></table></div></div> ` ;
} else if ( ! card . ifaceRows . length ) {
h += ` <div style="padding:16px"><div class="offbox">该来源尚无已登记接口</div></div> ` ;
` <td style="white-space:nowrap"><span style="display:flex;align-items:center;gap:6px"> ${ pillHtml ( it . st , it . st === 'plan' && it . obs === false ? '已配置 · 待观测' : '' ) } ${ it . err ? ` <span style="font-size:10px;color:#f87171"> ${ esc ( it . err ) } </span> ` : '' } </span></td> ` +
` </tr> ` ) . join ( '' ) +
` </tbody></table></div> ` ) . join ( '' ) + ` </div> ` ;
}
if ( card . bucket === 'off' ) {
h += ` <div style="padding:16px"><div class="offbox">凭证未配置 · ${ card . totalCount } 个预留接口处于停用状态 · 配置后将成为图表 / 问财 / 实时快照主源 ` +
` <div style="margin-top:8px;display:flex;justify-content:center;gap:6px;flex-wrap:wrap"> ${ card . groups [ 0 ] . rows . map ( ( i ) => tagHtml ( 'dashed' , esc ( i . api ) ) ) . join ( '' ) } </div> ` +
` </div></div> ` ;
}
return h + ` </section> ` ;
}
@@ -555,17 +632,57 @@ function sourcesHtml() {
function renderSourceCards ( ) {
const el = $ ( 'srcCards' ) ;
if ( ! el ) return ;
const vm = buildSourcesVM ( ) ;
el . innerHTML = vm . cards . map ( sourceCardHtml ) . join ( '' ) ;
el . innerHTML = buildSourcesVM ( ) . cards . map ( sourceCardHtml ) . join ( '' ) ;
paintSparks ( ) ;
}
/* =====================================================================
数据血缘页
数据血缘页(接口级状态 + 已配置/待观测 + 真实频率 + 事件脉冲)
===================================================================== */
function pageNameOf ( consumer ) {
const idx = consumer . indexOf ( '(' ) ;
return ( idx > 0 ? consumer . slice ( 0 , idx ) : consumer ) . trim ( ) ;
}
/* 一条血缘项的服务接口集合(primary/backup 冒号后的真实接口名) */
function servingInterfaces ( item ) {
const out = new Set ( [ item . dataset ] ) ;
[ item . primary _source , item . backup _source ] . forEach ( ( src ) => {
if ( ! src ) return ;
src . split ( ':' ) . slice ( 1 ) . join ( ':' ) . split ( '+' ) . forEach ( ( api ) => {
const name = api . trim ( ) ;
if ( name ) out . add ( name ) ;
} ) ;
} ) ;
return out ;
}
/* 接口级状态:只看本数据集真正走的那几条接口,不用提供商级汇总顶替。 */
function lineageItemRowsHealth ( item ) {
const apis = servingInterfaces ( item ) ;
return ( item . live _provider _health || [ ] ) . filter ( ( r ) => apis . has ( r . interface ) ) ;
}
function lineageItemBucket ( item ) {
const rows = lineageItemRowsHealth ( item ) ;
if ( item . tier === 'official' ) {
if ( item . publication ) return item . publication . state === 'published' ? 'ok' : ( item . publication . state === 'degraded' ? 'degrade' : 'fail' ) ;
return rows . length ? worstBucket ( rows . map ( ( r ) => bucketOf ( r . state ) ) ) : 'plan' ;
}
if ( item . tier === 'licensed' ) {
const cred = ( ( RAW . catalog && RAW . catalog . items ) || [ ] ) . find ( ( c ) => c . provider === 'ifind' ) ;
if ( cred && ! ( cred . credential && cred . credential . configured ) ) return 'off' ;
return rows . length ? worstBucket ( rows . map ( ( r ) => bucketOf ( r . state ) ) ) : 'off' ;
}
// provisional:已有数据来源;有观测行看接口级状态,暂无观测行=已配置/待观测
return rows . length ? worstBucket ( rows . map ( ( r ) => bucketOf ( r . state ) ) ) : 'plan' ;
}
function lineageItemNote ( item ) {
const rows = lineageItemRowsHealth ( item ) ;
const note = rows . find ( ( r ) => r . last _error ) ? . last _error || rows . find ( ( r ) => r . last _fallback _reason ) ? . last _fallback _reason || '' ;
if ( note ) return note ;
if ( item . tier === 'licensed' ) return 'iFinD 未配置 · 功能隐藏' ;
if ( item . tier === 'provisional' && ! rows . length ) return '已配置 · 暂无观测记录' ;
return '' ;
}
const linFilter = { onlyBad : false , src : '全部' , q : '' } ;
let linHover = null ;
let GW = 1500 , GH = 286 ;
@@ -579,18 +696,12 @@ function buildLineageVM() {
const srcState = { } ;
const dsState = { } ;
const pageState = { } ;
const pageDatasets = { } ;
const edgesSD = [ ] ;
const edgesDP = [ ] ;
const rows = [ ] ;
items . forEach ( ( it ) => {
const rowsHealth = it . live _provider _health || [ ] ;
let bucket ;
if ( it . tier === 'official' ) {
bucket = it . publication ? ( it . publication . state === 'published' ? 'ok' : ( it . publication . state === 'degraded' ? 'degrade' : 'fail' ) ) : ( rowsHealth . length ? worstBucket ( rowsHealth . map ( ( r ) => bucketOf ( r . state ) ) ) : 'plan' ) ;
} else {
bucket = rowsHealth . length ? worstBucket ( rowsHealth . map ( ( r ) => bucketOf ( r . state ) ) ) : 'off' ;
}
const rowsHealth = lineageItemRowsHealth ( it ) ;
const bucket = lineageItemBucket ( it ) ;
dsState [ it . dataset ] = bucket ;
( it . providers || [ ] ) . forEach ( ( p ) => {
srcState [ p ] = worstBucket ( [ srcState [ p ] || 'ok' , ... rowsHealth . filter ( ( r ) => r . provider === p ) . map ( ( r ) => bucketOf ( r . state ) ) ] ) ;
@@ -601,16 +712,16 @@ function buildLineageVM() {
pages . forEach ( ( pg ) => {
pageState [ pg ] = worstBucket ( [ pageState [ pg ] || 'ok' , bucket ] ) ;
edgesDP . push ( [ it . dataset , pg ] ) ;
( pageDatasets [ pg ] = pageDatasets [ pg ] || [ ] ) . push ( it ) ;
} ) ;
const lat = rowsHealth . map ( ( r ) => r . last _latency _ms ) . find ( ( v ) => v != null ) ;
const ok = it . tier === 'official' && it . publication ? it . publication . published _at : ( rowsHealth . find ( ( r ) => r . last _ok _at ) || { } ) . last _ok _at ;
rows . push ( {
page : pages . join ( ' / ' ) || '—' , item : consumers . join ( ' / ' ) , dataset : it . dataset ,
via : ` ${ it . primary _source } ${ it . backup _source ? ' → ' + it . backup _source : '' } ` ,
role : it . backup _source ? '主/备' : ( it . tier === 'licensed' ? '主(授权)' : '主(无备)' ) ,
ok : ok ? hm ( ok ) : '—' , lat , st : bucket ,
note : rowsHealth . find ( ( r ) => r . last _error ) ? . last _error || rowsHealth . find ( ( r ) => r . last _fallback _reason ) ? . last _fallback _reason || ( it . publication && it . publication . state !== 'published' ? '未正式发布' : '' ) ,
freq : it . update _freq || '—' ,
ok : it . tier === 'official' && it . publication ? it . publication . published _at : ( rowsHealth . find ( ( r ) => r . last _ok _at ) || { } ) . last _ok _at ,
lat : rowsHealth . map ( ( r ) => r . last _latency _ms ) . find ( ( v ) => v != null ) ,
st : bucket ,
note : lineageItemNote ( it ) ,
} ) ;
} ) ;
const sources = Object . keys ( srcState ) . map ( ( id ) => ( { id , name : PROV _LABEL [ id ] || id , st : srcState [ id ] } ) ) ;
@@ -641,6 +752,7 @@ function lineageGraphHtml(vm) {
if ( vm . datasets . length ) h += ` <text x=" ${ gpos [ 'd:' + vm . datasets [ 0 ] . id ] . x } " y="12" text-anchor="middle" ${ colStyle } >数据集 · ${ vm . datasets . length } </text> ` ;
if ( vm . pages . length ) h += ` <text x=" ${ gpos [ 'p:' + vm . pages [ 0 ] . id ] . x } " y="12" text-anchor="middle" ${ colStyle } >主站页面 · ${ vm . pages . length } </text> ` ;
const dsBy = { } ; vm . datasets . forEach ( ( d ) => { dsBy [ d . id ] = d . st ; } ) ;
// 已连接线路全部有可辨识的定向流动(flowline),颜色按数据集真实状态
vm . edgesSD . forEach ( ( [ s , d ] ) => {
if ( ! gpos [ 's:' + s ] || ! gpos [ 'd:' + d ] ) return ;
h += ` <path data-link data-kind="sd" data-a=" ${ esc ( s ) } " data-b=" ${ esc ( d ) } " d=" ${ linkPath ( gpos [ 's:' + s ] , gpos [ 'd:' + d ] ) } " fill="none" stroke=" ${ stColorOf ( dsBy [ d ] ) } " stroke-width="1" stroke-opacity="0.28" class=" ${ dsBy [ d ] === 'off' ? '' : 'flowline' } " style="transition:stroke-opacity .18s ease,stroke-width .18s ease"/> ` ;
@@ -716,35 +828,45 @@ function renderLinRows(vm) {
const rows = linRowsData ( vm ) ;
if ( cnt ) cnt . textContent = ` ${ rows . length } / ${ vm . rows . length } 行 ` ;
const cls = ( st ) => st === 'fail' ? 'row-fail' : st === 'degrade' || st === 'slow' ? 'row-slow' : st === 'off' ? 'row-off' : '' ;
const stLabel = ( r ) => ( r . st === 'plan' && r . note . includes ( '待观测' ) ) ? '已配置 · 待观测' : '' ;
body . innerHTML = rows . map ( ( r ) => ` <tr class=" ${ cls ( r . st ) } "> ` +
` <td style="color:#e8f1ff;white-space:nowrap"> ${ esc ( r . page ) } </td> ` +
` <td style="color:#8b9bb4;font-size:11px;white-space:nowrap"> ${ esc ( r . item ) } </td> ` +
` <td style="color:#22d3ee;font-size:11px"> ${ esc ( r . dataset ) } </td> ` +
` <td style="color:#8b9bb4;font-size:11px;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title=" ${ esc ( r . via ) } "> ${ esc ( r . via ) } </td> ` +
` <td style="color:#54637e;font-size:11px;white-space:nowrap"> ${ esc ( r . role ) } </td> ` +
` <td class="num" style="font-size:11px;white-space:nowrap;color:#8b9bb4 "> ${ esc ( r . ok ) } </td> ` +
` <td> ${ latHtml ( r . lat ) } </td> ` +
` <td><span style="display:flex;align-items:center;gap:8px;white-space:nowrap"> ${ pillHtml ( r . st ) } ${ r . note ? ` <span style="font-size:10px;color:#54637e"> ${ esc ( r . note ) } </span> ` : '' } </span></ td> ` +
` </tr> ` ) . join ( '' ) || ` <tr><td colspan="8" class="empty-hint">无匹配记录 · 调整筛选条件</td ></tr > ` ;
` <td class="num" style="color:#8b9bb4; font-size:11px;white-space:nowrap"> ${ esc ( r . freq ) } </td> ` +
` <td class="num" style="font-size:11px;white-space:nowrap;color:#8b9bb4"> ${ r . ok ? esc ( hm ( r . ok ) ) : '—' } </td> ` +
` <td class="num" style="font-size:11px;color: ${ r . lat != null && r . lat > 1000 ? '#fbbf24' : '#8b9bb4' } "> ${ r . lat != null ? Number ( r . lat ) . toLocaleString ( 'en-US' ) + 'ms' : '— ' } </td> ` +
` <td><span style="display:flex;align-items:center;gap:8px;white-space:nowrap"> ${ pillHtml ( r . st , stLabel ( r ) ) } ${ r . note && ! stLabel ( r ) ? ` <span style="font-size:10px;color:#54637e"> ${ esc ( r . note ) } </span> ` : '' } </span ></td >` +
` </tr> ` ) . join ( '' ) || ` <tr><td colspan="9" class="empty-hint">无匹配记录 · 调整筛选条件</td></tr> ` ;
if ( bodyN ) bodyN . innerHTML = rows . map ( ( r ) => ` <tr class=" ${ cls ( r . st ) } "> ` +
` <td style="color:#e8f1ff;white-space:nowrap"> ${ esc ( r . page ) } <span class="sub" style="color:#8b9bb4"> ${ esc ( r . item ) } </span></td> ` +
` <td style="color:#22d3ee;font-size:11px"> ${ esc ( r . dataset ) } </td> ` +
` <td style="color:#8b9bb4;font-size:11px;word-break:break-all"> ${ esc ( r . via ) } </td> ` +
` <td class="num" style="font-size:11px;white-space:nowrap;color:#8b9bb4 "> ${ esc ( r . ok ) } <span class="sub"> ${ r . lat != null ? r . lat + 'ms' : '—' } </span></td> ` +
` <td> ${ pillHtml ( r . st ) } ${ r . note ? ` <span class="sub"> ${ esc ( r . note ) } </span> ` : '' } </td> ` +
` </tr> ` ) . join ( '' ) || ` <tr><td colspan="5" class="empty-hint">无匹配记录 · 调整筛选条件</td></tr> ` ;
` <td class="num" style="color:#8b9bb4; font-size:11px;white-space:nowrap"> ${ esc ( r . freq ) } <span class="sub"> ${ esc ( r . role ) } </span></td> ` +
` <td class="num" style="font-size:11px;white-space:nowrap;color:#8b9bb4"> ${ r . ok ? esc ( hm ( r . ok ) ) : '—' } <span class="sub"> ${ r . lat != null ? r . lat + 'ms' : '— ' } </span></ td> ` +
` <td> ${ pillHtml ( r . st , stLabel ( r ) ) } ${ r . note && ! stLabel ( r ) ? ` <span class="sub"> ${ esc ( r . note ) } </span> ` : '' } </td> ` +
` </tr> ` ) . join ( '' ) || ` <tr><td colspan="6" class="empty-hint">无匹配记录 · 调整筛选条件</td></tr> ` ;
}
function lineageHtml ( ) {
const vm = buildLineageVM ( ) ;
const anomalyPages = new Set ( ) ;
vm . rows . forEach ( ( r ) => { if ( r . st === 'fail' ) ( r . page || '' ) . split ( ' / ' ) . forEach ( ( p ) => anomalyPages . add ( p ) ) ; } ) ;
const failDs = vm . datasets . filter ( ( d ) => d . st === 'fail' ) ;
let impactTag ;
if ( failDs . length ) {
const pages = new Set ( ) ;
failDs . forEach ( ( d ) => { ( adj . dp . get ( d . id ) || new Set ( ) ) . forEach ( ( p ) => pages . add ( p ) ) ; } ) ;
impactTag = tagHtml ( 'rd' , ` ${ failDs . map ( ( d ) => d . id ) . join ( '/' ) } 断链影响 ${ pages . size } 页 ` ) ;
} else {
impactTag = tagHtml ( 'mint' , '暂无断链影响' ) ;
}
const html = ` <div class="col"> ` +
` <div class="panel strip" style="gap:8px 20px"> ` +
` <span style="font-size:13px;color:#e8f1ff">血缘地图</span> ` +
` <span class="num" style="font-size:11px;color:#8b9bb4"><span style="color:#22d3ee"> ${ vm . sources . length } </span> 源 → <span style="color:#22d3ee"> ${ vm . datasets . length } </span> 数据集 → <span style="color:#22d3ee"> ${ vm . pages . length } </span> 页面</span> ` +
` <span class="legend" style="color:#54637e;letter-spacing:0"><span> ${ ledHtml ( 'ok' ) } 健康</span><span> ${ ledHtml ( 'slow' ) } 慢/降级</span><span> ${ ledHtml ( 'fail' ) } 失败</span><span> ${ ledHtml ( 'plan' ) } 计划</span><span> ${ ledHtml ( 'off' ) } 未配置</span></span> ` +
` <span class="flex1"></span> ` +
( anomalyPages . size ? tagHtml ( 'rd' , ` ${ [ ... anomalyPages ] [ 0 ] } 断链影响 ${ anomalyPages . size } 页 ` ) : tagHtml ( 'mint' , '暂无断链影响' ) ) +
impactTag +
` </div> ` +
` <section class="panel"><div style="position:relative"> ${ lineageGraphHtml ( vm ) } </div></section> ` +
` <section class="panel"> ` +
@@ -755,11 +877,10 @@ function lineageHtml() {
` <span class="num" style="font-size:10px;color:#54637e" id="linCount"></span> ` +
` <input class="lin-q" id="linQ" placeholder="搜索页面 / 接口 / 数据集" value=" ${ esc ( linFilter . q ) } "> ` +
` </div> ` +
` <div class="lin-scroll"><table class="dtable lin-table"><thead><tr><th>主站页面</th><th>数据项</th><th>数据集</th><th>源</th><th>主/备</th><th>最近成功</th><th>延迟</th><th>状态</th></tr></thead><tbody id="linBody"></tbody></table> ` +
` <table class="dtable lin-table-n"><thead><tr><th>主站页面</th><th>数据集</th><th>源</th><th>最近成功</th><th>状态</th></tr></thead><tbody id="linBodyN"></tbody></table></div> ` +
` <div class="lin-scroll"><table class="dtable lin-table"><thead><tr><th>主站页面</th><th>数据项</th><th>数据集</th><th>源 : 接口 </th><th>主/备</th><th>更新频率</th><th> 最近成功</th><th>延迟</th><th>状态</th></tr></thead><tbody id="linBody"></tbody></table> ` +
` <table class="dtable lin-table-n"><thead><tr><th>主站页面</th><th>数据集</th><th>源 : 接口</th><th>更新频率 </th><th>最近成功</th><th>状态</th></tr></thead><tbody id="linBodyN"></tbody></table></div> ` +
` </section> ` +
` </div> ` ;
window . _ _linVM = vm ;
return html ;
}
@@ -775,16 +896,19 @@ function renderPage(p) {
state . page = p ;
mainEl . dataset . page = p ;
document . querySelectorAll ( '[data-nav]' ) . forEach ( ( b ) => b . classList . toggle ( 'act' , b . dataset . nav === p ) ) ;
if ( p === 'overview' ) mainEl . innerHTML = overviewHtml ( ) ;
else if ( p === 'sources' ) mainEl . innerHTML = sourcesHtml ( ) ;
if ( p === 'overview' ) { mainEl . innerHTML = overviewHtml ( ) ; paintSparks ( ) ; flashChangedLatencies ( ) ; }
else if ( p === 'sources' ) { mainEl . innerHTML = sourcesHtml ( ) ; paintSparks ( ) ; }
else {
mainEl . innerHTML = lineageHtml ( ) ;
renderLinRows ( window . _ _linVM ) ;
applyLinHover ( window . _ _linVM ) ;
const vm = buildLineageVM ( ) ;
window . _ _linVM = vm ;
renderLinRows ( vm ) ;
applyLinHover ( vm ) ;
const svg = $ ( 'linSvg' ) ;
if ( svg ) svg . addEventListener ( 'mouseleave' , ( ) => setLinHover ( null , window . _ _linVM ) ) ;
fitLineageTable ( ) ;
}
window . scrollTo ( 0 , 0 ) ;
updatePhaseTag ( ) ;
}
window . addEventListener ( 'hashchange' , ( ) => renderPage ( pageFromHash ( ) ) ) ;
@@ -795,11 +919,10 @@ window.addEventListener('resize', () => {
resizeT = setTimeout ( ( ) => { if ( state . page === 'lineage' ) renderPage ( 'lineage' ) ; } , 180 ) ;
} ) ;
/* HEL-529: 血缘详情表随真实数据集数量(当前 17 条,非打样假设的 13 条)
动态撑高;宽档(>1100px,覆盖 1920× 920/1440× 900 验收位)给表格容器一个
基于剩余可视高度计算的 max-height + 内部滚动,保证整页 scrollHeight
始终 <= clientHeight(硬性验收项),同时全部行仍可在容器内滚动查看。
窄档(<=1100px)保持原有整页纵向滚动,不设上限。 */
/* HEL-529: 血缘详情表随真实数据集数量动态撑高;宽档(>1100px,覆盖
1920× 920/1440× 900 验收位)给表格容器一个基于剩余可视高度计算的
max-height + 内部滚动,保证整页 scrollHeight 始终 <= clientHeight,
同时全部行仍可在容器内滚动查看。窄档(<=1100px)整页纵滚。 */
function fitLineageTable ( ) {
const wrap = document . querySelector ( '.lin-scroll' ) ;
const tape = document . querySelector ( '.tape' ) ;
@@ -816,6 +939,8 @@ function fitLineageTable() {
function updatePhaseTag ( ) {
const ov = RAW . overview ;
if ( ! ov ) return ;
const bad = ( ov . anomalies || [ ] ) . length > 0 ;
$ ( 'radarBlipBad' ) . style . display = bad ? '' : 'none' ;
$ ( 'phaseTag' ) . innerHTML = ` ${ ledHtml ( 'rev' , true ) } ${ esc ( ov . session _phase || '' ) } ` ;
}
@@ -824,6 +949,11 @@ mainEl.addEventListener('click', (e) => {
if ( pb ) { doProbe ( pb . dataset . probe ) ; return ; }
const tg = e . target . closest ( '[data-toggle]' ) ;
if ( tg ) { const ui = srcUiState [ tg . dataset . toggle ] ; if ( ui ) { ui . open = ! ui . open ; renderSourceCards ( ) ; } return ; }
const ch = e . target . closest ( '[data-cred-hint]' ) ;
if ( ch ) {
toast ( 'iFinD 凭证需在部署侧配置(DATAHUB_IFIND_* 环境变量或 /v1/credentials/ifind),后台暂未开放在线写入' , 'err' ) ;
return ;
}
const goto = e . target . closest ( '[data-goto-lineage]' ) ;
if ( goto ) { linFilter . q = '' ; location . hash = 'lineage' ; return ; }
const inc = e . target . closest ( '[data-incident-action]' ) ;
@@ -846,14 +976,35 @@ async function doProbe(id) {
if ( ! ui || ui . probing ) return ;
ui . probing = true ;
renderSourceCards ( ) ;
try { await api ( ` /admin/api/sources/ ${ id } /probe ` , { method : 'POST' , body : '{}' } ) ; } catch ( err ) { toast ( err . message , 'err' ) ; }
try {
await api ( ` /admin/api/sources/ ${ id } /probe ` , { method : 'POST' , body : '{}' } ) ;
ui . probeResult = { t : ` ${ p2 ( new Date ( ) . getHours ( ) ) } : ${ p2 ( new Date ( ) . getMinutes ( ) ) } ` , ok : true , ms : null } ;
} catch ( err ) {
ui . probeResult = { t : ` ${ p2 ( new Date ( ) . getHours ( ) ) } : ${ p2 ( new Date ( ) . getMinutes ( ) ) } ` , ok : false , ms : null } ;
toast ( err . message , 'err' ) ;
}
ui . probing = false ;
await refreshQuiet ( ) ;
renderSourceCards ( ) ;
}
/* ---------- EVENT TAPE: 真实事件差量渲染 ---------- */
const tapeSeen = { call : 0 , provCall : 0 , audit : 0 , job : 0 , first : true } ;
/* ---------- EVENT TAPE: 预装最近真实事件 + 持续滚动 + 新事件闪动 ---------- */
const tapeSeen = { call : 0 , provCall : 0 , audit : 0 , job : 0 } ;
let tapeBuf = [ ] ;
let tapeSeeded = false ;
function tapeEventHtml ( e , fresh ) {
return ` <span class="tape-item ${ fresh ? ' flash' : '' } "><span class="tt num"> ${ esc ( hm ( e . ts ) ) } </span><span class="ts"> ${ esc ( e . src ) } </span><span class="tm"> ${ esc ( e . msg ) } </span> ` +
( e . ok ? ` <span class="tok num">✓ ${ e . ms ? ` ${ e . ms } ms ` : '' } </span> ` : '<span class="tbad">× 失败</span>' ) +
` <span class="tsep">///</span></span> ` ;
}
function renderTape ( freshIds ) {
const items = tapeBuf . slice ( - 14 ) ;
const html = items . length
? items . map ( ( e ) => tapeEventHtml ( e , freshIds && freshIds . has ( e . sortId ) ) ) . join ( '' )
: ` <span class="tape-item"><span class="tm" style="color:#54637e">暂无真实事件 · 等待首次调用/任务</span><span class="tsep">///</span></span> ` ;
$ ( 'tapeA' ) . innerHTML = html ;
$ ( 'tapeB' ) . innerHTML = html ;
}
function tapeEventsFromRaw ( ) {
const evs = [ ] ;
( ( RAW . overview && RAW . overview . recent _calls ) || [ ] ) . forEach ( ( c ) => { if ( c . id > tapeSeen . call ) evs . push ( { ts : c . created _at , src : 'tushare' , msg : c . endpoint , ok : c . ok , ms : c . latency _ms , sortId : 'c' + c . id } ) ; } ) ;
@@ -867,16 +1018,25 @@ function tapeEventsFromRaw() {
tapeSeen . job = Math . max ( tapeSeen . job , maxOf ( ( RAW . jobs && RAW . jobs . runs ) || [ ] , 'id' ) ) ;
return evs ;
}
let tapeBuf = [ ] ;
function seedTape ( ) {
// 首次加载:预装最近的真实事件(打样 EVENT TAPE 开场即有内容滚动)
const evs = [ ] ;
( ( RAW . providers && RAW . providers . recent _calls ) || [ ] ) . forEach ( ( c ) => evs . push ( { ts : c . fetched _at , src : c . provider , msg : c . interface , ok : c . status === 'ok' , ms : c . latency _ms , sortId : 'p' + c . id } ) ) ;
( ( RAW . overview && RAW . overview . recent _calls ) || [ ] ) . forEach ( ( c ) => evs . push ( { ts : c . created _at , src : 'tushare' , msg : c . endpoint , ok : c . ok , ms : c . latency _ms , sortId : 'c' + c . id } ) ) ;
( ( RAW . jobs && RAW . jobs . runs ) || [ ] ) . forEach ( ( r ) => evs . push ( { ts : r . finished _at || r . started _at , src : 'scheduler' , msg : ` ${ r . job _id } ${ r . state } ` , ok : r . state !== 'failed' , sortId : 'j' + r . id } ) ) ;
( ( RAW . audit && RAW . audit . items ) || [ ] ) . forEach ( ( a ) => evs . push ( { ts : a . created _at , src : 'hub_admin' , msg : ` ${ a . action } ${ a . target || '' } ` . trim ( ) , ok : true , sortId : 'a' + a . id } ) ) ;
evs . sort ( ( x , y ) => String ( x . ts ) . localeCompare ( String ( y . ts ) ) ) ;
tapeBuf = evs . slice ( - 14 ) ;
tapeSeeded = true ;
renderTape ( null ) ;
}
function updateTape ( ) {
if ( ! tapeSeeded ) { seedTape ( ) ; return ; }
const evs = tapeEventsFromRaw ( ) ;
if ( tapeSeen . first ) { tapeSeen . first = false ; return ; } // 首次加载只建基线,不补播历史事件
if ( ! evs . length ) return ;
const fresh = new Set ( evs . map ( ( e ) => e . sortId ) ) ;
tapeBuf = tapeBuf . concat ( evs ) . slice ( - 16 ) ;
const html = tapeBuf . map ( ( e ) => ` <span class="tape-item"><span class="tt num"> ${ esc ( hm ( e . ts ) ) } </span><span class="ts"> ${ esc ( e . src ) } </span><span class="tm"> ${ esc ( e . msg ) } </span> ` +
( e . ok ? ` <span class="tok num">✓ ${ e . ms ? ` ${ e . ms } ms ` : '' } </span> ` : '<span class="tbad">× 失败</span>' ) +
` <span class="tsep">///</span></span> ` ) . join ( '' ) ;
$ ( 'tapeA' ) . innerHTML = html ; $ ( 'tapeB' ) . innerHTML = html ;
renderTape ( fresh ) ;
}
/* ---------- 抽屉:调度任务 / 盘后发布 / 审计 ---------- */
@@ -1011,18 +1171,22 @@ function toast(msg, kind) {
toastT = setTimeout ( ( ) => { el . classList . remove ( 'show' ) ; } , 3200 ) ;
}
/* ================= 顶栏:时钟 / 减少动态 / 主题 ================= */
/* ================= 顶栏:时钟 / 减少动态 ================= */
let clockEls = null ;
function updateClock ( ) {
if ( ! clockEls ) clockEls = { d : $ ( 'ckD' ) , h : $ ( 'ckH' ) , m : $ ( 'ckM' ) , s : $ ( 'ckS' ) } ;
const now = new Date ( ) ;
$ ( 'ckD' ) . textContent = ` ${ now . getFullYear ( ) } - ${ p2 ( now . getMonth ( ) + 1 ) } - ${ p2 ( now . getDate ( ) ) } ` ;
$ ( 'ckH' ) . textContent = p2 ( now . getHours ( ) ) ;
$ ( 'ckM' ) . textContent = p2 ( now . getMinutes ( ) ) ;
$ ( 'ckS' ) . textContent = p2 ( now . getSeconds ( ) ) ;
const ds = ` ${ now . getFullYear ( ) } - ${ p2 ( now . getMonth ( ) + 1 ) } - ${ p2 ( now . getDate ( ) ) } ` ;
if ( clockEls . d . textContent != = ds ) clockEls . d . textContent = ds ;
const hs = p2 ( now . getHours ( ) ) , ms = p2 ( now . getMinutes ( ) ) , ss = p2 ( now . getSeconds ( ) ) ;
if ( clockEls . h . textContent != = hs ) clockEls . h . textContent = hs ;
if ( clockEls . m . textContent !== ms ) clockEls . m . textContent = ms ;
if ( clockEls . s . textContent !== ss ) clockEls . s . textContent = ss ;
}
function applyCalm ( on ) {
state . calm = on ;
// 挂在 #app(覆盖 appRoot 及其兄弟 drawer/modal/toast),不能只挂 #appRoot:
// 否则 .reduce-motion * 与 .reduce-motion .drawer/.toast 选不中旁路层过渡。
// 挂在 body + #app(覆盖 appRoot 及其兄弟 drawer/modal/toast),否则
// .reduce-motion * 选不中旁路层过渡。
document . body . classList . toggle ( 'reduce-motion' , on ) ;
const app = $ ( 'app' ) ;
if ( app ) app . classList . toggle ( 'reduce-motion' , on ) ;
@@ -1037,7 +1201,62 @@ if (window.matchMedia) {
mq . addEventListener ? mq . addEventListener ( 'change' , syncMq ) : mq . addListener ( syncMq ) ;
}
/* ================= 动态更新:200ms tick(打样节奏) =================
- 时钟每秒翻字(冒号 blink 由 CSS 承担)
- 缓存年龄依据真实时间戳每秒增长(>30s 变琥珀)
- 倒计时按真实 next_retry_at 每秒递减
- sparkline 端点闪烁由 CSS 承担,折线只在真实历史变化时重画
- 延迟数字只在真实值变化时闪一下(flash),绝不随机伪造数值 */
function paintSparks ( ) {
document . querySelectorAll ( 'svg.spark' ) . forEach ( ( svg ) => sparkPaint ( svg ) ) ;
}
const prevLat = new Map ( ) ;
function flashChangedLatencies ( ) {
document . querySelectorAll ( '[data-lat]' ) . forEach ( ( el ) => {
const jid = el . closest ( '[data-jid]' ) ? el . closest ( '[data-jid]' ) . dataset . jid : ( el . closest ( 'tr' ) ? 'row:' + [ ... el . closest ( 'tr' ) . children ] . indexOf ( el ) : '' ) ;
const v = el . dataset . lat ;
const key = state . page + ':' + ( el . closest ( '[data-dataset]' ) ? . dataset . dataset || jid ) + ':' + v ;
const prev = prevLat . get ( el ) ;
if ( prev !== undefined && prev !== v ) {
el . classList . remove ( 'flash' ) ;
void el . offsetWidth ;
el . classList . add ( 'flash' ) ;
}
prevLat . set ( el , v ) ;
} ) ;
}
function updateDynamics ( ) {
const now = Date . now ( ) ;
document . querySelectorAll ( '[data-age-epoch]' ) . forEach ( ( el ) => {
const ms = + el . dataset . ageEpoch ;
if ( ! ms || Number . isNaN ( ms ) ) return ;
const s = Math . max ( 0 , Math . floor ( ( now - ms ) / 1000 ) ) ;
const txt = fmtAgeSec ( s ) ;
if ( el . textContent !== txt ) el . textContent = txt ;
const color = s > 30 ? '#fbbf24' : '#8b9bb4' ;
if ( el . style . color !== color ) el . style . color = color ;
} ) ;
document . querySelectorAll ( '[data-cd-epoch]' ) . forEach ( ( el ) => {
const target = + el . dataset . cdEpoch ;
if ( ! target || Number . isNaN ( target ) ) return ;
const left = Math . floor ( ( target - now ) / 1000 ) ;
if ( left > 0 ) {
const m = Math . floor ( left / 60 ) , s = left % 60 ;
const txt = el . dataset . cdfmt === 'ms' ? ` ${ m } m ${ p2 ( s ) } s ` : ` ${ p2 ( m ) } : ${ p2 ( s ) } ` ;
if ( el . textContent !== txt ) el . textContent = txt ;
} else if ( el . textContent !== '已到点' ) {
el . textContent = '已到点' ;
}
} ) ;
}
/* ================= 轮询:拉取真实数据、驱动渲染、可见性/网络门禁 ================= */
const dataVersion = { overview : '' , sources : '' , lineage : '' } ;
function snapshotVersion ( part ) {
if ( part === 'overview' ) return JSON . stringify ( [ RAW . overview , RAW . batches && RAW . batches . batches , RAW . providers && RAW . providers . health , RAW . catalog ] ) ;
if ( part === 'sources' ) return JSON . stringify ( [ RAW . catalog , RAW . providers && RAW . providers . recent _calls , RAW . sources ] ) ;
return JSON . stringify ( RAW . lineage ) ;
}
const Poller = ( ( ) => {
let timer = null ;
let running = false ;
@@ -1047,9 +1266,32 @@ const Poller = (() => {
try {
await fetchAll ( ) ;
updateTape ( ) ;
if ( state . page === 'overview' ) mainEl . innerHTML = overviewHtml ( ) ;
else if ( state . page === 'sources' ) renderSourceCards ( ) ;
else if ( state . page === 'lineage' ) { /* 血缘图交互态复杂,仅在用户操作或下次轮询后整体重绘一次 */ mainEl . innerHTML = lineageHtml ( ) ; renderLinRows ( window . _ _linVM ) ; applyLinHover ( window . _ _linVM ) ; fitLineageTable ( ) ; }
const vOv = snapshotVersion ( 'overview' ) , vSrc = snapshotVersion ( 'sources' ) , vLin = snapshotVersion ( 'lineage' ) ;
if ( state . page === 'overview' && vOv !== dataVersion . overview ) {
dataVersion . overview = vOv ;
mainEl . innerHTML = overviewHtml ( ) ;
paintSparks ( ) ;
flashChangedLatencies ( ) ;
} else if ( state . page === 'overview' ) {
flashChangedLatencies ( ) ;
}
if ( state . page === 'sources' && vSrc !== dataVersion . sources ) {
dataVersion . sources = vSrc ;
renderSourceCards ( ) ;
}
if ( state . page === 'lineage' && vLin !== dataVersion . lineage ) {
dataVersion . lineage = vLin ;
mainEl . innerHTML = lineageHtml ( ) ;
const vm = buildLineageVM ( ) ;
window . _ _linVM = vm ;
renderLinRows ( vm ) ;
applyLinHover ( vm ) ;
const svg = $ ( 'linSvg' ) ;
if ( svg ) svg . addEventListener ( 'mouseleave' , ( ) => setLinHover ( null , window . _ _linVM ) ) ;
fitLineageTable ( ) ;
}
// 真实调用事件脉冲:血缘页上对应来源节点亮一圈 ledPing 节奏的光环
pulseSourcesOnNewCalls ( ) ;
updatePhaseTag ( ) ;
setNetOk ( true ) ;
} catch ( err ) {
@@ -1072,10 +1314,39 @@ const Poller = (() => {
} ;
} ) ( ) ;
/* 真实调用脉冲:provider_call_log 出现新调用时,给血缘图对应来源节点
叠一圈与 ledPing 同节奏(1.6s ease-out)的扩散光环;这是事件动效,
与 gnode-pulse 的静态呼吸不同,一次即消散。 */
let pulseSeenCall = 0 ;
function pulseSourcesOnNewCalls ( ) {
if ( state . page !== 'lineage' ) {
pulseSeenCall = ( ( RAW . providers && RAW . providers . recent _calls ) || [ ] ) . reduce ( ( m , c ) => Math . max ( m , c . id || 0 ) , pulseSeenCall ) ;
return ;
}
const calls = ( RAW . providers && RAW . providers . recent _calls ) || [ ] ;
const fresh = calls . filter ( ( c ) => ( c . id || 0 ) > pulseSeenCall ) ;
pulseSeenCall = calls . reduce ( ( m , c ) => Math . max ( m , c . id || 0 ) , pulseSeenCall ) ;
fresh . forEach ( ( c ) => {
const g = document . querySelector ( ` #linSvg g[data-nid=" ${ CSS . escape ( c . provider ) } "] ` ) ;
if ( ! g ) return ;
const circle = g . querySelector ( 'circle' ) ;
if ( ! circle ) return ;
const ring = document . createElementNS ( 'http://www.w3.org/2000/svg' , 'circle' ) ;
ring . setAttribute ( 'cx' , circle . getAttribute ( 'cx' ) ) ;
ring . setAttribute ( 'cy' , circle . getAttribute ( 'cy' ) ) ;
ring . setAttribute ( 'r' , '5' ) ;
ring . setAttribute ( 'fill' , 'none' ) ;
ring . setAttribute ( 'stroke' , '#22d3ee' ) ;
ring . setAttribute ( 'stroke-width' , '1.2' ) ;
ring . setAttribute ( 'class' , 'node-ping' ) ;
g . appendChild ( ring ) ;
setTimeout ( ( ) => ring . remove ( ) , 1700 ) ;
} ) ;
}
function resetTapeBaselines ( ) {
// 恢复轮询时静默重建基线,不补播暂停期间错过的事件。
// 恢复轮询时静默重建基线,不补播暂停期间错过的事件(但保留已上带内容) 。
tapeSeen . call = tapeSeen . provCall = tapeSeen . audit = tapeSeen . job = Number . MAX _SAFE _INTEGER ;
tapeSeen . first = true ;
}
document . addEventListener ( 'visibilitychange' , ( ) => {
if ( document . hidden ) { Poller . stop ( ) ; }
@@ -1090,11 +1361,15 @@ async function refreshQuiet() {
/* ================= 启动 ================= */
async function startApp ( ) {
setInterval ( updateClock , 1000 ) ;
updateClock ( ) ;
await fetchAll ( ) ;
tapeSeen . first = true ;
dataVersion . overview = snapshotVersion ( 'overview' ) ;
dataVersion . sources = snapshotVersion ( 'sources' ) ;
dataVersion . lineage = snapshotVersion ( 'lineage' ) ;
pulseSeenCall = ( ( RAW . providers && RAW . providers . recent _calls ) || [ ] ) . reduce ( ( m , c ) => Math . max ( m , c . id || 0 ) , 0 ) ;
renderPage ( pageFromHash ( ) ) ;
updateTape ( ) ; // 预装最近真实事件并开始滚动
updateClock ( ) ;
setInterval ( ( ) => { updateClock ( ) ; updateDynamics ( ) ; } , 200 ) ; // 打样 tick 节奏
await Poller . start ( ) ;
}