index.html 81 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062
  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>TsDB2 Analyzer v3</title>
  7. <style>
  8. *{box-sizing:border-box;margin:0;padding:0}
  9. body{font-family:'Segoe UI',system-ui,-apple-system,sans-serif;background:#0b0b14;color:#c8ccd4;min-height:100vh;overflow-x:hidden}
  10. ::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:#12121f}::-webkit-scrollbar-thumb{background:#2a2a4a;border-radius:3px}
  11. a{color:#5b9bd5;text-decoration:none}
  12. .hdr{background:linear-gradient(90deg,#0d0d1a,#141428);border-bottom:1px solid #1e1e3a;padding:0 24px;height:52px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:100}
  13. .hdr .logo{display:flex;align-items:center;gap:10px;font-size:16px;font-weight:700;color:#5b9bd5}
  14. .hdr .meta{display:flex;align-items:center;gap:16px;font-size:12px;color:#666}
  15. .hdr .meta .db-name{color:#8890a0;max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  16. .hdr .btn{padding:5px 14px;background:#1a1a30;border:1px solid #2a2a4a;color:#8890a0;border-radius:4px;cursor:pointer;font-size:12px;transition:all .2s}
  17. .hdr .btn:hover{background:#222240;color:#aab;border-color:#3a3a5a}
  18. .main{display:flex;height:calc(100vh - 52px)}
  19. .sidebar{width:240px;min-width:240px;background:#0e0e1a;border-right:1px solid #1a1a2e;overflow-y:auto;display:flex;flex-direction:column}
  20. .sidebar .sec-title{padding:12px 16px 6px;font-size:10px;text-transform:uppercase;letter-spacing:1.5px;color:#444;font-weight:600}
  21. .sidebar .tbl-item{padding:9px 16px;cursor:pointer;display:flex;align-items:center;gap:8px;font-size:13px;color:#778;transition:all .15s;border-left:3px solid transparent}
  22. .sidebar .tbl-item:hover{background:#141428;color:#aab}
  23. .sidebar .tbl-item.active{background:#141428;color:#5b9bd5;border-left-color:#5b9bd5}
  24. .sidebar .tbl-item .ico{font-size:14px;opacity:.6}
  25. .sidebar .tbl-item .cnt{margin-left:auto;font-size:10px;color:#445;background:#1a1a2e;padding:1px 6px;border-radius:8px}
  26. .content{flex:1;overflow-y:auto;padding:20px 28px}
  27. .bc{display:flex;align-items:center;gap:6px;padding:0 0 16px;font-size:12px;color:#556}
  28. .bc .item{cursor:pointer;color:#5b9bd5;transition:color .15s}.bc .item:hover{color:#7bb}
  29. .bc .sep{color:#334}.bc .cur{color:#8890a0}
  30. .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:14px;margin-bottom:20px}
  31. .card{background:#111122;border:1px solid #1a1a2e;border-radius:8px;padding:16px 18px;position:relative;overflow:hidden}
  32. .card::before{content:'';position:absolute;top:0;left:0;right:0;height:2px}
  33. .card.c1::before{background:linear-gradient(90deg,#5b9bd5,#4a8bc4)}.card.c2::before{background:linear-gradient(90deg,#4ecdc4,#3dbdb5)}
  34. .card.c3::before{background:linear-gradient(90deg,#f7b731,#e6a620)}.card.c4::before{background:linear-gradient(90deg,#a78bfa,#8b5cf6)}
  35. .card.c5::before{background:linear-gradient(90deg,#e74c3c,#c0392b)}
  36. .card .lb{font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#556;margin-bottom:6px}
  37. .card .val{font-size:28px;font-weight:700;color:#e0e4ec}.card .sub{font-size:11px;color:#556;margin-top:4px}
  38. .sec{background:#0f0f1e;border:1px solid #1a1a2e;border-radius:8px;margin-bottom:16px;overflow:hidden}
  39. .sec-hd{padding:12px 16px;border-bottom:1px solid #1a1a2e;display:flex;align-items:center;gap:8px;font-size:13px;font-weight:600;color:#aab}
  40. .sec-hd .dot{width:8px;height:8px;border-radius:50%;background:#5b9bd5}
  41. .sec-bd{padding:0}
  42. .dtbl{width:100%;border-collapse:collapse;font-size:12px}
  43. .dtbl th{background:#0d0d1a;color:#5b9bd5;font-weight:600;padding:8px 12px;text-align:left;border-bottom:1px solid #1a1a2e;position:sticky;top:0;font-size:11px;text-transform:uppercase;letter-spacing:.5px}
  44. .dtbl td{padding:7px 12px;border-bottom:1px solid #141428;color:#8890a0;vertical-align:middle}
  45. .dtbl tr:hover td{background:#111125}
  46. .dtbl .mono{font-family:'Cascadia Code','Fira Code',Consolas,monospace;font-size:11px}
  47. .dtbl .clickable{cursor:pointer;color:#5b9bd5;transition:color .15s}.dtbl .clickable:hover{color:#7bb;text-decoration:underline}
  48. .badge{display:inline-block;padding:2px 8px;border-radius:3px;font-size:10px;font-weight:600;letter-spacing:.5px}
  49. .badge.primary{background:rgba(91,155,213,.15);color:#5b9bd5}.badge.time{background:rgba(78,205,196,.15);color:#4ecdc4}
  50. .badge.string{background:rgba(247,183,49,.15);color:#f7b731}.badge.compose{background:rgba(167,139,250,.15);color:#a78bfa}
  51. .badge.btree{background:rgba(116,185,255,.15);color:#74b9ff}.badge.leaf{background:rgba(78,205,196,.15);color:#4ecdc4}
  52. .badge.internal{background:rgba(247,183,49,.15);color:#f7b731}.badge.data{background:rgba(91,155,213,.12);color:#5b9bd5}
  53. .badge.dropped{background:rgba(231,76,60,.15);color:#e74c3c}.badge.active{background:rgba(78,205,196,.15);color:#4ecdc4}
  54. .idx-list{padding:12px}
  55. .idx-card{background:#111122;border:1px solid #1a1a2e;border-radius:6px;padding:12px 16px;margin-bottom:8px;cursor:pointer;transition:all .2s;display:flex;align-items:center;gap:12px}
  56. .idx-card:hover{border-color:#2a2a4a;background:#141428}
  57. .idx-card .idx-ico{width:32px;height:32px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:16px;background:#1a1a30}
  58. .idx-card .idx-info{flex:1}.idx-card .idx-name{font-size:13px;font-weight:600;color:#c8ccd4}
  59. .idx-card .idx-meta{font-size:11px;color:#556;margin-top:3px;display:flex;gap:12px}
  60. .idx-card .idx-arrow{color:#334;font-size:14px}
  61. .node-bar{display:flex;flex-wrap:wrap;gap:16px;padding:12px 16px;background:#0d0d1a;border-bottom:1px solid #1a1a2e;font-size:12px}
  62. .node-bar .ni{display:flex;align-items:center;gap:4px}.node-bar .ni-lb{color:#556}.node-bar .ni-val{color:#c8ccd4;font-weight:600}
  63. .nav-btns{display:flex;gap:8px;padding:12px 16px;justify-content:center}
  64. .nav-btn{padding:6px 16px;background:#1a1a30;border:1px solid #2a2a4a;color:#8890a0;border-radius:4px;cursor:pointer;font-size:12px;transition:all .2s}
  65. .nav-btn:hover{background:#222240;color:#aab;border-color:#3a3a5a}
  66. .empty{text-align:center;padding:40px 20px;color:#445}.empty .ico{font-size:36px;margin-bottom:10px;opacity:.4}.empty .msg{font-size:13px}
  67. .fb-overlay{position:fixed;inset:0;background:#0b0b14;z-index:200;display:flex;align-items:center;justify-content:center}
  68. .fb-box{width:560px;max-height:80vh;background:#111122;border:1px solid #1e1e3a;border-radius:10px;box-shadow:0 20px 60px rgba(0,0,0,.5);overflow:hidden;display:flex;flex-direction:column}
  69. .fb-hd{padding:16px 20px;border-bottom:1px solid #1a1a2e;font-size:15px;font-weight:700;color:#5b9bd5;display:flex;align-items:center;gap:8px}
  70. .fb-path{padding:8px 16px;background:#0d0d1a;font-size:11px;color:#556;border-bottom:1px solid #1a1a2e;font-family:monospace;word-break:break-all}
  71. .fb-list{flex:1;overflow-y:auto;max-height:400px}
  72. .fb-item{padding:8px 16px;cursor:pointer;display:flex;align-items:center;gap:10px;font-size:13px;transition:background .1s;border-bottom:1px solid #0d0d1a}
  73. .fb-item:hover{background:#141428}.fb-item.sel{background:#141428;border-left:3px solid #5b9bd5}
  74. .fb-item .fi-ico{width:20px;text-align:center;font-size:14px}.fb-item .fi-sz{margin-left:auto;font-size:10px;color:#445}
  75. .fb-ft{padding:12px 16px;border-top:1px solid #1a1a2e;display:flex;gap:8px}
  76. .fb-ft button{flex:1;padding:8px;border:none;border-radius:5px;cursor:pointer;font-size:12px;font-weight:600;transition:all .2s}
  77. .fb-ft .b-up{background:#1a1a30;color:#8890a0}.fb-ft .b-up:hover{background:#222240}
  78. .fb-ft .b-open{background:#5b9bd5;color:#fff}.fb-ft .b-open:hover{background:#4a8bc4}
  79. .fb-ft .b-open:disabled{background:#1a1a30;color:#445;cursor:not-allowed}
  80. .hex-data{font-family:'Cascadia Code','Fira Code',Consolas,monospace;font-size:10px;color:#667;word-break:break-all;max-width:400px;line-height:1.5}
  81. .ubar{width:60px;height:4px;background:#1a1a2e;border-radius:2px;overflow:hidden;display:inline-block;vertical-align:middle;margin-left:6px}
  82. .ubar .fill{height:100%;border-radius:2px;background:#4ecdc4}
  83. .ver-card{background:#0d0d18;border:1px solid #1a1a2e;border-radius:6px;padding:10px 14px;margin-bottom:8px}
  84. .ver-card .ver-hd{display:flex;align-items:center;gap:8px;margin-bottom:6px}
  85. .ver-card .ver-num{font-size:14px;font-weight:700;color:#5b9bd5}
  86. .ver-card .ver-fields{display:flex;flex-wrap:wrap;gap:4px}
  87. .ver-card .ver-field{padding:2px 8px;border-radius:3px;font-size:11px;font-family:monospace}
  88. .ver-card .ver-field.active{background:rgba(78,205,196,.1);color:#4ecdc4;border:1px solid rgba(78,205,196,.2)}
  89. .ver-card .ver-field.dropped{background:rgba(231,76,60,.08);color:#e74c3c;border:1px solid rgba(231,76,60,.15);text-decoration:line-through}
  90. .row-decoded{font-family:'Cascadia Code','Fira Code',Consolas,monospace;font-size:11px;color:#4ecdc4;line-height:1.6;max-width:600px}
  91. .row-decoded .fld-name{color:#5b9bd5}.row-decoded .fld-val{color:#c8ccd4}
  92. .pmgrid{position:relative;height:440px;overflow:auto;background:#0d0d1a;border:1px solid #1a1a2e;border-radius:6px}
  93. .pmspacer{position:relative;width:100%}
  94. .pmcv{position:absolute;left:0;top:0;display:block}
  95. .pmtip{position:fixed;z-index:400;display:none;pointer-events:none;background:#161628;border:1px solid #2a2a4a;border-radius:5px;padding:8px 10px;font-size:11px;line-height:1.55;color:#c8ccd4;white-space:pre;box-shadow:0 8px 24px rgba(0,0,0,.55);font-family:'Cascadia Code','Fira Code',Consolas,monospace}
  96. .pmbar{height:3px;background:#1a1a2e;border-radius:2px;overflow:hidden;margin-top:8px;max-width:340px}
  97. .pmbar>div{height:100%;background:linear-gradient(90deg,#5b9bd5,#4ecdc4);width:0;transition:width .2s}
  98. @media(max-width:900px){.sidebar{width:200px;min-width:200px}}
  99. </style>
  100. </head>
  101. <body>
  102. <div id="app"></div>
  103. <script>
  104. const $=id=>document.getElementById(id);
  105. const esc=s=>s?s.replace(/</g,'&lt;').replace(/>/g,'&gt;'):'';
  106. function fB(n){n=+n;if(n<1024)return n+' B';if(n<1048576)return(n/1024).toFixed(1)+' KB';if(n<1073741824)return(n/1048576).toFixed(2)+' MB';return(n/1073741824).toFixed(2)+' GB'}
  107. // escAttr 放进 HTML 属性的文本: esc 只挡 < >, 而属性里还得挡住引号 ——
  108. // 截断后的键 / 原始十六进制全文要放 title, 值里带一个 " 就会把属性提前截断。
  109. const escAttr=s=>esc(s).replace(/"/g,'&quot;').replace(/'/g,'&#39;');
  110. // clip 长文本截断显示: 宽字符串列会把键与原始十六进制推到几千字符 (1024 字节的键 =
  111. // 2048 个十六进制字符), 原样塞进单元格会把表格拉成一条长带。截断但保留全文 (调用方放
  112. // title), 并注明原始长度 —— 截断只该省地方, 不该让人以为数据本来就短。
  113. function clip(s,n){s=s==null?'':String(s);if(s.length<=n)return esc(s);return esc(s.slice(0,n))+'<span style="color:#556"> …共 '+s.length+' 字符</span>'}
  114. function ni(lb,val){return '<div class="ni"><span class="ni-lb">'+lb+'</span><span class="ni-val">'+val+'</span></div>'}
  115. function bigCard(cls,lb,val,sub){return '<div class="card '+cls+'"><div class="lb">'+lb+'</div><div class="val">'+val+'</div>'+(sub?'<div class="sub">'+sub+'</div>':'')+'</div>'}
  116. const ITYPE={1:'Time',2:'Primary',3:'String',4:'Compose',5:'BTree',6:'Hash'};
  117. const ICOLOR={1:'time',2:'primary',3:'string',4:'compose',5:'btree',6:'btree'};
  118. let S={view:'fb',db:'',tables:[],cur:null,bc:[],idxType:0,idxPath:[],curIdxName:''};
  119. let FB={path:'',sel:''};
  120. let ovTok=0; // Overview 渲染令牌: 审计轮询/重绘时用于判定视图是否已切换
  121. let auTok=0; // Audit 渲染令牌: 索引内容增量补全期间轮询重绘时用于判定视图是否已切换
  122. // ===== Page Map: 分块懒加载 + canvas 虚拟化渲染 =====
  123. // 旧实现是 fetch('/api/pages') 等后端把 19.5 万页全算完, 再拼 19.5 万个 <div>
  124. // 塞进 innerHTML —— 光 HTML 字符串就上百 MB, 页面必卡死甚至崩。
  125. // 现在:
  126. // 1) 后端 /api/pagemap 按块返回 (每块 2 万页; 类型 1 字节 + 使用率 1 字节, base64);
  127. // 2) 前端分块拉取, 拉到哪块画哪块, 顶部进度条实时推进 (先有多少显示多少);
  128. // 3) 只画视口内的格子 —— 全库 19.5 万页也只有一块画布, DOM 里永远是 1 个 canvas。
  129. const PMT={0:'Free',1:'Table',2:'Data',3:'Heap',4:'IdxMeta',5:'IdxData',6:'Overflow(deprecated)',7:'WAL',8:'SchemaExt',[-1]:'Never used',[-2]:'Reserved',[-3]:'Orphan (leaked)',[-4]:'Unknown',[-9]:'\u52a0\u8f7d\u4e2d\u2026'};
  130. const PMC={0:'#1a1a2e',1:'#f7b731',2:'#5b9bd5',3:'#4ecdc4',4:'#a78bfa',5:'#74b9ff',6:'#b8a04a',7:'#ff9f43',8:'#e879f9',[-1]:'#101018',[-2]:'#40305a',[-3]:'#7a2626',[-4]:'#555555',[-9]:'#151520'};
  131. const KT={free:0,table:1,data:2,heap:3,imeta:4,idata:5,overflow:6,wal:7,sext:8,never:-1,reserved:-2,orphan:-3,other:-4};
  132. const PMCELL=14,PMPITCH=PMCELL+2,PMCHUNK=20000;
  133. let PM=null,pmTok=0;
  134. const PMINFO=new Map(); // 单页归属缓存: 悬停时按需拉一页, 不再随页图整体传 19.5 万条
  135. function pmB64(s){const b=atob(s),a=new Uint8Array(b.length);for(let i=0;i<b.length;i++)a[i]=b.charCodeAt(i);return a}
  136. // pageGrid 在 mountId 里挂一个虚拟化页图。
  137. // opts: {info:信息栏元素id, legend:图例元素id, hl:Set<页号>, hlLabel, hlColor}
  138. function pageGrid(mountId,opts){
  139. const mount=$(mountId);if(!mount)return;
  140. opts=opts||{};
  141. const tok=++pmTok;
  142. PM={tok:tok,total:0,typ:null,usg:null,cols:1,loaded:0,
  143. hl:opts.hl||null,hlLabel:opts.hlLabel||'',hlColor:opts.hlColor||'#4ecdc4',hover:0};
  144. mount.innerHTML='<div class="pmgrid"><div class="pmspacer"><canvas class="pmcv"></canvas></div></div><div class="pmbar"><div></div></div>';
  145. PM.wrap=mount.querySelector('.pmgrid');
  146. PM.sp=mount.querySelector('.pmspacer');
  147. PM.cv=mount.querySelector('.pmcv');
  148. PM.bar=mount.querySelector('.pmbar>div');
  149. PM.ctx=PM.cv.getContext('2d');
  150. PM.info=opts.info?$(opts.info):null;
  151. PM.legend=opts.legend?$(opts.legend):null;
  152. PM.tip=document.createElement('div');PM.tip.className='pmtip';document.body.appendChild(PM.tip);
  153. PM.wrap.addEventListener('scroll',()=>{pmMove();pmDraw()},{passive:true});
  154. PM.cv.addEventListener('mousemove',pmHover);
  155. PM.cv.addEventListener('mouseleave',()=>{if(PM){PM.hover=0;PM.tip.style.display='none'}});
  156. PM.cv.addEventListener('click',e=>{const id=pmHit(e);if(id)showPageInfo(id)});
  157. pmFetch(1);
  158. }
  159. window.addEventListener('resize',()=>{if(PM)pmLayout()});
  160. function pmFetch(start){
  161. if(!PM)return;
  162. const st=PM;
  163. fetch('/api/pagemap?start='+start+'&count='+PMCHUNK).then(r=>r.json()).then(d=>{
  164. if(!st.wrap.isConnected||st.tok!==pmTok){st.tip.remove();return}
  165. if(d.e){if(st.info)st.info.textContent='Error: '+d.e;return}
  166. if(!d.total){if(st.info)st.info.textContent='No pages';return}
  167. if(!st.typ){
  168. st.total=d.total;
  169. st.typ=new Int8Array(st.total+1);st.typ.fill(-9);
  170. st.usg=new Uint8Array(st.total+1);
  171. pmLegend(d.counts);pmLayout();
  172. }
  173. if(d.count){
  174. const t=pmB64(d.t),u=pmB64(d.u);
  175. for(let i=0;i<d.count;i++){st.typ[d.start+i]=t[i]-8;st.usg[d.start+i]=u[i]}
  176. st.loaded+=d.count;
  177. }
  178. if(st.info)st.info.textContent=d.done
  179. ? (st.total+' pages \u00d7 16 KB \u00b7 \u6bcf\u683c\u53ef\u70b9\u51fb\u67e5\u770b\u5f52\u5c5e')
  180. : ('\u5df2\u52a0\u8f7d '+st.loaded+' / '+st.total+' \u9875 ('+Math.round(st.loaded/st.total*100)+'%) \u00b7 \u8fb9\u62c9\u8fb9\u753b\u2026');
  181. if(st.bar)st.bar.style.width=Math.round(st.loaded/st.total*100)+'%';
  182. pmDraw();
  183. if(d.done){if(st.bar)st.bar.parentNode.style.display='none'}
  184. else pmFetch(d.start+d.count);
  185. }).catch(e=>{if(st.wrap.isConnected&&st.info)st.info.textContent='Error: '+e.message});
  186. }
  187. function pmLegend(counts){
  188. if(!PM||!PM.legend||!counts)return;
  189. let lg='';
  190. for(const k in counts)if(counts[k]>0)lg+='<span><span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:'+(PMC[KT[k]]||'#333')+';margin-right:3px;vertical-align:middle"></span>'+PMT[KT[k]]+': '+counts[k]+'</span>';
  191. PM.legend.innerHTML=lg;
  192. }
  193. // pmLayout 按容器宽度算列数, 用 spacer 撑出总高度, 画布固定成视口大小。
  194. function pmLayout(){
  195. if(!PM||!PM.total)return;
  196. const st=PM,w=Math.max(240,st.wrap.clientWidth-2);
  197. st.cols=Math.max(1,Math.floor((w-2)/PMPITCH));
  198. st.sp.style.height=(Math.ceil(st.total/st.cols)*PMPITCH+2)+'px';
  199. const h=Math.max(80,st.wrap.clientHeight);
  200. st.cv.width=w;st.cv.height=h;
  201. st.cv.style.width=w+'px';st.cv.style.height=h+'px';
  202. pmMove();pmDraw();
  203. }
  204. function pmMove(){if(PM)PM.cv.style.top=PM.wrap.scrollTop+'px'}
  205. // pmDraw 只画视口内的行 —— 这是"不卡死"的关键: 每帧最多几百个格子。
  206. function pmDraw(){
  207. if(!PM||!PM.typ)return;
  208. const st=PM,ctx=st.ctx,cv=st.cv,top=st.wrap.scrollTop,h=cv.height;
  209. ctx.clearRect(0,0,cv.width,h);
  210. const first=Math.max(0,Math.floor(top/PMPITCH)),last=Math.floor((top+h)/PMPITCH);
  211. for(let row=first;row<=last;row++){
  212. const base=row*st.cols,y=row*PMPITCH-top;
  213. for(let col=0;col<st.cols;col++){
  214. const pid=base+col+1;
  215. if(pid>st.total)break;
  216. const t=st.typ[pid];
  217. const c=(st.hl&&st.hl.has(pid))?st.hlColor:(PMC[t]||'#151520');
  218. ctx.globalAlpha=t<0?1:(st.usg[pid]>50?1:(st.usg[pid]>20?.6:.3));
  219. ctx.fillStyle=c;
  220. ctx.fillRect(col*PMPITCH,y,PMCELL,PMCELL);
  221. }
  222. }
  223. ctx.globalAlpha=1;
  224. }
  225. function pmHit(e){
  226. if(!PM||!PM.total)return 0;
  227. const r=PM.cv.getBoundingClientRect(),x=e.clientX-r.left,y=e.clientY-r.top;
  228. if(x<0||y<0)return 0;
  229. const col=Math.floor(x/PMPITCH);
  230. if(col<0||col>=PM.cols)return 0;
  231. const pid=Math.floor((PM.wrap.scrollTop+y)/PMPITCH)*PM.cols+col+1;
  232. return pid<=PM.total?pid:0;
  233. }
  234. function pmHover(e){
  235. if(!PM)return;
  236. const id=pmHit(e);
  237. if(!id){PM.hover=0;PM.tip.style.display='none';return}
  238. PM.hover=id;
  239. const info=PMINFO.get(id);
  240. PM.tip.textContent=pmTipText(id,info);
  241. PM.tip.style.display='block';
  242. PM.tip.style.left=Math.min(e.clientX+14,innerWidth-240)+'px';
  243. PM.tip.style.top=Math.min(e.clientY+14,innerHeight-110)+'px';
  244. if(info===undefined){
  245. PMINFO.set(id,null); // 占位, 防止同一格的悬停风暴打出一串重复请求
  246. fetch('/api/page-info?id='+id).then(r=>r.json()).then(d=>{
  247. PMINFO.set(id,d||{});
  248. if(PM&&PM.hover===id)PM.tip.textContent=pmTipText(id,d||{});
  249. }).catch(()=>{PMINFO.set(id,{})});
  250. }
  251. }
  252. function pmTipText(id,info){
  253. const t=PM.typ?PM.typ[id]:-9;
  254. let s='Page #'+id+'\nType: '+(PMT[t]!==undefined?PMT[t]:'?'+t)+'\nUsage: '+(PM.usg?PM.usg[id]:0)+'%';
  255. if(PM.hl&&PM.hl.has(id))s+='\n\u2605 '+PM.hlLabel;
  256. s+=info===undefined?('\nRole: \u2026\nOwner: \u2026'):('\nRole: '+((info&&info.role)||'-')+'\nOwner: '+((info&&info.owner)||'-'));
  257. return s;
  258. }
  259. // pmLimited 长列表分批渲染: 先画前 500 行, 其余交给"显示更多"按钮。
  260. // 空闲链动辄 3 万页, 一次性 innerHTML 同样会卡住页面。
  261. function pmLimited(tbodyId,moreId,rows,render){
  262. const body=$(tbodyId),btn=$(moreId);if(!body)return;
  263. const CH=500;let shown=0;
  264. const step=()=>{
  265. body.insertAdjacentHTML('beforeend',rows.slice(shown,shown+CH).map((r,i)=>render(r,shown+i)).join(''));
  266. shown=Math.min(shown+CH,rows.length);
  267. if(!btn)return;
  268. if(shown<rows.length){btn.textContent='\u663e\u793a\u66f4\u591a ('+shown+' / '+rows.length+')';btn.style.display=''}
  269. else btn.style.display='none';
  270. };
  271. if(btn)btn.onclick=step;
  272. step();
  273. }
  274. function renderFB(){
  275. document.getElementById('app').innerHTML=
  276. '<div class="fb-overlay"><div class="fb-box"><div class="fb-hd"><span style="font-size:20px">&#x1F4CA;</span> TsDB2 Analyzer v3</div>'+
  277. '<div class="fb-path" id="fbp">Loading...</div><div class="fb-list" id="fbl"></div>'+
  278. '<div class="fb-ft"><button class="b-up" onclick="fbUp()">&#x2190; Up</button>'+
  279. '<button class="b-open" id="fbOpen" onclick="fbOpen()" disabled>Open Database</button></div></div></div>';
  280. fbLoad('');
  281. }
  282. function fbLoad(p){
  283. fetch(p?'/api/list-dir?path='+encodeURIComponent(p):'/api/list-dir').then(r=>r.json()).then(d=>{
  284. if(d.err){alert(d.err);return}
  285. FB.path=d.p;FB.sel='';$('fbOpen').disabled=true;$('fbp').textContent=d.p;
  286. let h='';
  287. if(d.d)d.d.forEach(x=>{h+='<div class="fb-item" ondblclick="fbLoad(\''+esc(d.p+'/'+x.n).replace(/\\/g,'/')+'\')">'+
  288. '<span class="fi-ico">&#x1F4C1;</span>'+esc(x.n)+'</div>'});
  289. if(d.f)d.f.forEach(x=>{const fp=d.p+'/'+x.n;
  290. h+='<div class="fb-item" onclick="fbSel(this,\''+esc(fp).replace(/\\/g,'/')+'\')">'+
  291. '<span class="fi-ico">&#x1F4C4;</span>'+esc(x.n)+'<span class="fi-sz">'+fB(x.s)+'</span></div>'});
  292. $('fbl').innerHTML=h||'<div class="empty"><div class="ico">&#x1F4ED;</div><div class="msg">Empty</div></div>';
  293. });
  294. }
  295. function fbSel(el,p){document.querySelectorAll('.fb-item').forEach(e=>e.classList.remove('sel'));el.classList.add('sel');FB.sel=p;$('fbOpen').disabled=false}
  296. function fbUp(){fetch('/api/list-dir?path='+encodeURIComponent(FB.path)).then(r=>r.json()).then(d=>{if(d.pp)fbLoad(d.pp)})}
  297. function fbOpen(){
  298. if(!FB.sel)return;
  299. const btn=$('fbOpen');btn.disabled=true;btn.textContent='Opening...';
  300. fetch('/api/open-db',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({p:FB.sel})})
  301. .then(r=>r.json()).then(d=>{
  302. if(d.ok){S.db=d.s.f;S.view='dash';loadDash(d.s)}
  303. else{alert('Error: '+d.e);btn.disabled=false;btn.textContent='Open Database'}
  304. }).catch(e=>{alert('Error: '+e);btn.disabled=false;btn.textContent='Open Database'});
  305. }
  306. function loadDash(s){
  307. let h='<div class="hdr"><div class="logo"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 3v18"/></svg>TsDB2 Analyzer v3</div>'+
  308. '<div class="meta"><span class="db-name">'+esc(s.f)+'</span><span>'+s.fs+'</span><span>'+s.pg+' pages</span>'+
  309. '<button class="btn" onclick="showTutorial()">&#x1F4D6; Tutorial</button>'+
  310. '<button class="btn" onclick="S.view=\'fb\';renderFB()">Change DB</button></div></div>';
  311. h+='<div class="main"><div class="sidebar" id="sidebar"><div class="sec-title">Explorer</div>'+
  312. '<div class="tbl-item" onclick="showOverview()" style="color:#5b9bd5"><span class="ico">&#x1F4CA;</span>Overview</div>'+
  313. '<div class="tbl-item" onclick="showHeader()"><span class="ico">&#x1F4D1;</span>HeaderPage</div>'+
  314. '<div class="tbl-item" onclick="showFreePages()"><span class="ico">&#x267B;</span>Free Pages</div>'+
  315. '<div class="sec-title">Audit</div>'+
  316. '<div class="tbl-item" onclick="showAudit()"><span class="ico">&#x1F50D;</span>Full Audit</div>'+
  317. '<div class="tbl-item" onclick="showHeapChains()"><span class="ico">&#x1F4E6;</span>Heap Chains</div>'+
  318. '<div class="tbl-item" onclick="showOrphans()"><span class="ico">&#x1F9F9;</span>Orphan Pages</div>'+
  319. '<div class="sec-title">Tables</div><div id="sideTabs"></div></div>';
  320. h+='<div class="content" id="content"></div></div>';
  321. document.getElementById('app').innerHTML=h;loadTables();showOverview(s);
  322. }
  323. function loadTables(){
  324. fetch('/api/tables').then(r=>r.json()).then(ts=>{
  325. S.tables=ts||[];let h='';
  326. S.tables.forEach(t=>{
  327. let mc=t.masterFields?t.masterFields.filter(f=>!f.dropped).length:0;
  328. h+='<div class="tbl-item" onclick="showTable(\''+esc(t.name)+'\')"><span class="ico">&#x1F4CB;</span>'+esc(t.name)+
  329. '<span class="cnt">v'+(t.activeVer||1)+'</span></div>';
  330. });
  331. if($('sideTabs'))$('sideTabs').innerHTML=h;
  332. });
  333. }
  334. function showOverview(s){
  335. if(!s){fetch('/api/stats').then(r=>r.json()).then(d=>showOverview(d));return}
  336. const tok=++ovTok;
  337. const failed=s.auditFailed===true;
  338. const ready=s.auditReady!==false&&!failed;
  339. S.curIdxName='';S.idxPath=[];
  340. document.querySelectorAll('.tbl-item').forEach(e=>e.classList.remove('active'));
  341. const busy=failed?'<span style="color:#e05a5a">审计失败</span>':'<span style="color:#8a94a6">审计中…</span>';
  342. let h='<div class="bc"><span class="cur">Overview</span></div>';
  343. h+='<div class="stats">'+
  344. '<div class="card c1"><div class="lb">Database Size</div><div class="val">'+s.fs+'</div><div class="sub">'+s.pg+' pages</div></div>'+
  345. '<div class="card c2"><div class="lb">Tables</div><div class="val">'+s.tc+'</div><div class="sub">'+(ready?(s.rows||0)+' rows':busy)+'</div></div>'+
  346. '<div class="card c3"><div class="lb">Free Pages</div><div class="val">'+(ready?(s.free||0):'&#8212;')+'</div><div class="sub">'+(ready?fB((s.free||0)*16384)+' recyclable':busy)+'</div></div>'+
  347. '<div class="card c4"><div class="lb">Heap Pages</div><div class="val">'+(ready?(s.heap||0):'&#8212;')+'</div><div class="sub">field overflow</div></div>'+
  348. '<div class="card '+(ready&&s.verdict==='OK'?'c2':'c5')+'"><div class="lb">Audit Verdict</div><div class="val" style="font-size:17px;padding-top:6px">'+(ready?(s.verdict||'-'):(failed?'ERROR':'AUDITING'))+'</div><div class="sub">'+(ready?(s.unacc||0)+' unaccounted pages':busy)+'</div></div>'+
  349. '<div class="card c3"><div class="lb">Page Layout</div><div class="val" style="font-size:17px;padding-top:6px">4096+16K</div><div class="sub">'+(ready?(s.reserved||0)+' reserved + '+(s.never||0)+' never used':busy)+'</div></div></div>';
  350. h+='<div class="sec"><div class="sec-hd"><div class="dot"></div>All Tables</div><div class="sec-bd">';
  351. if(!S.tables||!S.tables.length)h+='<div class="empty"><div class="msg">No tables</div></div>';
  352. else{
  353. h+='<table class="dtbl"><thead><tr><th>Table</th><th>Rows</th><th>Active Ver</th><th>Master Fields</th><th>Versions</th><th>Indexes</th><th>Heap Pages</th><th>Row Size</th></tr></thead><tbody>';
  354. S.tables.forEach(t=>{
  355. let mc=t.masterFields?t.masterFields.filter(f=>!f.dropped).length:0;
  356. let dc=t.masterFields?t.masterFields.filter(f=>f.dropped).length:0;
  357. h+='<tr style="cursor:pointer" onclick="showTable(\''+esc(t.name)+'\')">'+
  358. '<td style="color:#5b9bd5;font-weight:600">'+esc(t.name)+'</td><td>'+t.rowN+'</td>'+
  359. '<td><span class="badge active">v'+t.activeVer+'</span></td>'+
  360. '<td>'+mc+(dc>0?' <span class="badge dropped">'+dc+' dropped</span>':'')+'</td>'+
  361. '<td>'+(t.versions?t.versions.length:0)+'</td>'+
  362. '<td>'+(t.idxs?t.idxs.length:0)+'</td><td>'+t.hpc+'</td><td>'+t.fsz+' B</td></tr>';
  363. });
  364. h+='</tbody></table>';
  365. }
  366. h+='</div></div>';
  367. // Page visualization
  368. h+='<div class="sec"><div class="sec-hd"><div class="dot" style="background:#74b9ff"></div>Page Map <span id="pgMapInfo" style="font-weight:400;font-size:11px;color:#556;margin-left:8px">'+(ready?'Loading...':(failed?'审计失败':'审计中…'))+'</span><span id="ovAudit" style="font-weight:400;font-size:11px;color:#f7b731;margin-left:10px"></span></div><div class="sec-bd" style="padding:12px"><div id="pgMap"></div><div id="pgLegend" style="margin-top:10px;display:flex;gap:12px;flex-wrap:wrap;font-size:11px;color:#667"></div></div></div>';
  369. $('content').innerHTML=h;
  370. // 页图只依赖头部 + 空闲链 (见 analyzer/pagemap.go), 几秒就能出图, 不必陪全库审计
  371. // 等上一两分钟 —— 所以先挂上开始分块加载; 审计就绪后本函数会整体重绘一次
  372. // (届时服务端页索引已缓存, 重拉只是十来个毫秒级的分块请求)。
  373. pageGrid('pgMap',{info:'pgMapInfo',legend:'pgLegend'});
  374. // 审计状态单独一行, 免得把页图的加载进度覆盖掉。
  375. if(failed&&$('ovAudit'))$('ovAudit').textContent='审计失败: '+((s.auditError||'').split('\n')[0]||'unknown');
  376. // 全库审计尚未就绪: 轮询 /api/stats, 就绪后整体重绘 (含汇总卡片)。
  377. // 这样打开库不再被审计阻塞, 又能自动补齐汇总值; 审计失败则停止轮询并提示。
  378. // 大表审计要一两分钟, 所以预算给到 15 分钟, 不再 2 分钟就误报超时。
  379. if(!ready&&!failed){
  380. let n=0;
  381. const tm=setInterval(()=>{
  382. if(tok!==ovTok){clearInterval(tm);return}
  383. if(++n>1800){clearInterval(tm);if($('ovAudit'))$('ovAudit').textContent='审计超时';return}
  384. fetch('/api/stats').then(r=>r.json()).then(d=>{
  385. if(tok!==ovTok){clearInterval(tm);return}
  386. if(d&&(d.auditReady||d.auditFailed)){clearInterval(tm);showOverview(d)}
  387. }).catch(()=>{});
  388. },500);
  389. return;
  390. }
  391. }
  392. function showTable(nm){
  393. const t=S.tables.find(x=>x.name===nm);if(!t)return;
  394. S.cur=t;S.idxType=0;S.curIdxName='';S.idxPath=[];S.bc=[{l:nm,a:null}];
  395. document.querySelectorAll('.tbl-item').forEach(e=>{e.classList.remove('active');if(e.textContent.includes(nm))e.classList.add('active')});
  396. let h='<div class="bc"><span class="item" onclick="showOverview()">Overview</span><span class="sep">&#x203A;</span><span class="cur">'+esc(nm)+'</span></div>';
  397. let mc=t.masterFields?t.masterFields.filter(f=>!f.dropped).length:0;
  398. let dc=t.masterFields?t.masterFields.filter(f=>f.dropped).length:0;
  399. h+='<div class="stats">'+
  400. '<div class="card c1"><div class="lb">Total Rows</div><div class="val">'+t.rowN+'</div></div>'+
  401. '<div class="card c2"><div class="lb">Active Version</div><div class="val">v'+t.activeVer+'</div></div>'+
  402. '<div class="card c3"><div class="lb">Active Fields</div><div class="val">'+mc+'</div>'+(dc>0?'<div class="sub">'+dc+' dropped</div>':'')+'</div>'+
  403. '<div class="card c4"><div class="lb">Schema Versions</div><div class="val">'+(t.versions?t.versions.length:0)+'</div></div>'+
  404. '<div class="card c5"><div class="lb">Indexes</div><div class="val">'+(t.idxs?t.idxs.length:0)+'</div></div></div>';
  405. // Master Registry
  406. h+='<div class="sec"><div class="sec-hd"><div class="dot" style="background:#f7b731"></div>Master Registry (Field History)</div><div class="sec-bd">'+
  407. '<table class="dtbl"><thead><tr><th>#</th><th>Name</th><th>Type</th><th>Size</th><th>Status</th></tr></thead><tbody>';
  408. if(t.masterFields)t.masterFields.forEach((f,i)=>{
  409. let st=f.dropped?'<span class="badge dropped">DROPPED</span>':'<span class="badge active">ACTIVE</span>';
  410. let nmStyle=f.dropped?'color:#e74c3c;text-decoration:line-through':'color:#5b9bd5;font-weight:600';
  411. // NULL / UNIQUE 由后端把 flag 字节拆好 (nullable / unique), 前端不再自己碰位掩码
  412. let fl=(f.nullable?' <span class="badge btree" title="可为 NULL (行内 NULL 位图据此置位)">NULL</span>':'')+
  413. (f.unique?' <span class="badge btree" title="列级唯一声明; 约束由唯一索引执行">UNIQUE</span>':'');
  414. // 徽章文本 (label) 与悬浮说明 (tip) 都由后端算好: Flag 低 5 位是【按类型重载】的一格
  415. // (decimal 是 scale, 字符串家族是宽度码), 前端若能拿到它, 迟早有人把宽度码当字节数显示 ——
  416. // "256byte(20)" 会被读成 20 字节, 而那一列真实宽度是 1024。字符串列的 label 因此直接
  417. // 带出行内宽度 (string(1024B)), 与右边 Size 列同源, 不再有"类型名与真实宽度打架"的情况。
  418. h+='<tr><td class="mono">'+i+'</td><td style="'+nmStyle+'">'+esc(f.name)+'</td>'+
  419. '<td><span class="badge primary" title="'+escAttr(f.tip||'')+'">'+esc(f.label||f.typeName)+'</span>'+fl+'</td>'+
  420. '<td title="行内宽度: 本列在每一行里固定占 '+f.sz+' 字节 (定长, 不足补零)">'+f.sz+' bytes</td><td>'+st+'</td></tr>';
  421. });
  422. h+='</tbody></table></div></div>';
  423. // Schema Versions
  424. h+='<div class="sec"><div class="sec-hd"><div class="dot" style="background:#a78bfa"></div>Schema Versions</div><div class="sec-bd" style="padding:12px">';
  425. if(t.versions)t.versions.forEach(v=>{
  426. let isAct=(v.ver===t.activeVer);
  427. h+='<div class="ver-card" style="'+(isAct?'border-color:#4ecdc4':'')+'">';
  428. h+='<div class="ver-hd"><span class="ver-num">Version '+v.ver+'</span>';
  429. if(isAct)h+='<span class="badge active">CURRENT</span>';
  430. h+='</div><div class="ver-fields">';
  431. if(v.fields)v.fields.forEach((fn,fi)=>{
  432. let isDrop=fn.includes('(dropped)');
  433. // 字段片只放名字 (一版几十列时再塞类型会糊成一片), 但把"类型 + 行内宽度"挂进 title:
  434. // v.indices 是本版字段在 masterFields 里的【数字下标数组】(后端保证, 见 VInfo.Indices) ——
  435. // 别当盘上字节用: 后端若退回 []byte, JSON 会变成 base64 字符串, v.indices[i] 取到的是
  436. // 第 i 个字符, masterFields['A'] 恒为 undefined, 整块说明静默消失 (只是不显示, 不报错)。
  437. let mi=(t.masterFields&&v.indices&&v.indices[fi]!=null&&v.indices[fi]<t.masterFields.length)?t.masterFields[v.indices[fi]]:null;
  438. let tip=mi?(mi.name+' · '+(mi.label||mi.typeName)+' · 行内 '+mi.sz+' 字节'+(mi.tip?'\n'+mi.tip:'')):'';
  439. h+='<span class="ver-field '+(isDrop?'dropped':'active')+'" title="'+escAttr(tip)+'">'+esc(fn)+'</span>';
  440. });
  441. h+='</div></div>';
  442. });
  443. h+='</div></div>';
  444. // Indexes
  445. h+='<div class="sec"><div class="sec-hd"><div class="dot" style="background:#5b9bd5"></div>Indexes</div><div class="sec-bd"><div class="idx-list">';
  446. if(!t.idxs||!t.idxs.length)h+='<div class="empty"><div class="msg">No indexes</div></div>';
  447. else t.idxs.forEach(idx=>{
  448. // 分量后缀上宽度: 索引键是各分量按【真实行内宽度】拼起来的, 宽字符串分量会让键变得很长
  449. // (B+ 树每页能放的条目数直接塌下去), 只看列名看不出这件事。
  450. const mf=fi=>t.masterFields&&t.masterFields[fi]?t.masterFields[fi]:null;
  451. const flds=(idx.flds||[]).map(fi=>{const m=mf(fi);return m?m.name+'('+m.sz+'B)':'?'}).join(', ');
  452. // 二级索引条目 = [各分量宽度之和][pkID 8B]; 行存储索引 (Time/Primary) 的键就是 pkID,
  453. // 行数据跟在键后面 —— 两种布局不同, 一律按分量求和会给出错的键长, 所以分开算。
  454. const secondary=(idx.type>=3&&idx.type<=6);
  455. const keyLen=secondary?(idx.flds||[]).reduce((a,fi)=>{const m=mf(fi);return a+(m?m.sz:0)},0)+8:0;
  456. let ic=ICOLOR[idx.type]||'primary';
  457. h+='<div class="idx-card" onclick="showIndex(\''+esc(idx.name)+'\','+idx.ep+','+idx.type+')">'+
  458. '<div class="idx-ico" style="background:rgba(91,155,213,.15)">'+(idx.type===2?'&#x1F511;':idx.type===1?'&#x23F0;':idx.type===3?'&#x1F524;':idx.type===5?'&#x1F333;':'&#x1F517;')+'</div>'+
  459. '<div class="idx-info"><div class="idx-name">'+esc(idx.name)+'</div>'+
  460. '<div class="idx-meta"><span class="badge '+ic+'">'+idx.ts+'</span>'+(idx.uniq?'<span class="badge btree" title="唯一索引: 同一索引键只允许一行 (任一分量为 NULL 的行不进索引)">UNIQUE</span>':'')+'<span>Fields: '+esc(flds)+'</span>'+
  461. (secondary?'<span title="条目布局: 键(各分量按真实行内宽度拼接) + pkID(8B)。宽字符串分量会把键拉长, 每页条目数与树高都随之上升">Key: '+keyLen+' B</span>':'')+
  462. '<span>Root: '+idx.ep+'</span></div></div>'+
  463. '<div class="idx-arrow">&#x203A;</div></div>';
  464. });
  465. h+='</div></div></div>';
  466. // Storage
  467. h+='<div class="sec"><div class="sec-hd"><div class="dot" style="background:#4ecdc4"></div>Storage</div><div class="sec-bd">'+
  468. '<div class="idx-card" onclick="showHeapPages(\''+esc(nm)+'\')"><div class="idx-ico" style="background:rgba(78,205,196,.15)">&#x1F4E6;</div>'+
  469. '<div class="idx-info"><div class="idx-name">Heap Pages (Field Overflow)</div><div class="idx-meta"><span>First: '+t.hp0+'</span><span>Declared: '+t.hpc+' pages</span><span>点击核对实际链长</span></div></div>'+
  470. '<div class="idx-arrow">&#x203A;</div></div></div></div>';
  471. $('content').innerHTML=h;
  472. }
  473. function showIndex(nm,ep,it){S.idxType=it;S.curIdxName=nm;S.idxPath=[ep];openIdxPath()}
  474. // 重建整个索引视图外壳 (面包屑 + 内容区), 并加载当前路径末端页
  475. function openIdxPath(){
  476. let idxLike=(S.idxType===3||S.idxType===4);
  477. let h='<div class="bc" id="idxBC">'+idxBCHtml()+'</div>';
  478. h+='<div class="sec"><div class="sec-hd"><div class="dot" style="background:#'+(S.idxType===2?'5b9bd5':S.idxType===1?'4ecdc4':S.idxType===3?'f7b731':'a78bfa')+'"></div>'+
  479. (idxLike?'Index':'B+ Tree')+' - '+esc(S.curIdxName)+'</div><div id="idxContent" style="padding:16px;color:#5b9bd5">Loading...</div></div>';
  480. $('content').innerHTML=h;
  481. loadIdxPage(S.idxPath[S.idxPath.length-1],S.idxType);
  482. }
  483. // 索引下钻路径面包屑: Overview > Table > Index > #root > #child ...
  484. function idxBCHtml(){
  485. let h='<span class="item" onclick="showOverview()">Overview</span><span class="sep">&#x203A;</span>';
  486. if(S.cur)h+='<span class="item" onclick="showTable(\''+esc(S.cur.name)+'\')">'+esc(S.cur.name)+'</span><span class="sep">&#x203A;</span>';
  487. h+='<span class="item" onclick="jumpIdxPath(0)">'+esc(S.curIdxName)+'</span>';
  488. const last=S.idxPath.length-1;
  489. S.idxPath.forEach((pid,i)=>{
  490. h+='<span class="sep">&#x203A;</span>';
  491. if(i<last)h+='<span class="item" onclick="jumpIdxPath('+i+')">#'+pid+'</span>';
  492. else h+='<span class="cur">#'+pid+(i===0?' (root)':'')+'</span>';
  493. });
  494. return h;
  495. }
  496. // 回退到路径中的第 i 层 (0 = 根页)
  497. function jumpIdxPath(i){S.idxPath=S.idxPath.slice(0,i+1);openIdxPath()}
  498. // 深入子页: 压入路径
  499. function drillIdx(pid){S.idxPath.push(pid);openIdxPath()}
  500. // 同层前后翻页: 替换路径末端
  501. function gotoIdxSibling(pid){if(S.idxPath.length)S.idxPath[S.idxPath.length-1]=pid;openIdxPath()}
  502. function loadIdxPage(pid,it){
  503. fetch('/api/btree?id='+pid+'&t='+it).then(r=>r.json()).then(d=>{
  504. if(d.e){$('idxContent').innerHTML='<div class="empty"><div class="msg">Error: '+d.e+'</div></div>';return}
  505. let isBtree=(it===1||it===2);
  506. let isSBtree=(it===5);
  507. let h='<div class="node-bar"><div class="ni"><span class="ni-lb">Page</span><span class="ni-val">'+d.id+'</span></div>'+
  508. '<div class="ni"><span class="ni-lb">Type</span><span class="badge '+(d.lf?'leaf':'internal')+'">'+(d.lf?'Leaf':'Internal')+'</span></div>'+
  509. '<div class="ni"><span class="ni-lb">Entries</span><span class="ni-val">'+d.cn+'</span></div>'+
  510. '<div class="ni"><span class="ni-lb">Usage</span><span class="ni-val">'+d.us.toFixed(1)+'%</span>'+
  511. '<div class="ubar"><div class="fill" style="width:'+Math.min(d.us,100)+'%"></div></div></div></div>';
  512. if(!d.lf)h+='<div style="padding:8px 16px;font-size:11px;color:#556;background:#0a0a16;border-bottom:1px solid #1a1a2e">B+ Tree Internal Node</div>';
  513. if(d.lf&&isSBtree)h+='<div style="padding:8px 16px;font-size:11px;color:#f7b731;background:#0a0a16;border-bottom:1px solid #1a1a2e">Secondary B+ Tree Leaf (values are PKIDs, not DataPage IDs)</div>';
  514. h+='<table class="dtbl"><thead><tr><th style="width:40px">#</th><th>Key</th>';
  515. if(isBtree){
  516. h+='<th style="width:120px">'+(d.lf?'Data Page':'Child Page')+'</th>';
  517. }else if(isSBtree){
  518. h+='<th style="width:120px">'+(d.lf?'PKID':'Child Page')+'</th>';
  519. }else{
  520. h+='<th style="width:120px">PKID</th>';
  521. }
  522. h+='</tr></thead><tbody>';
  523. (d.ents||[]).forEach((e,i)=>{
  524. // 键按真实宽度拼, 宽字符串分量的 B+ 树叶条目键可达上千字节 → 十六进制上千字符;
  525. // 截断显示 (全文在 title 里), 否则一页几十条会把表格撑成一条竖带。
  526. h+='<tr><td class="mono" style="color:#556">'+i+'</td><td class="mono" style="color:#c8ccd4" title="'+escAttr(e.k)+'">'+clip(e.k,96)+'</td>';
  527. if(isBtree){
  528. if(d.lf)h+='<td><span class="badge data clickable" onclick="showDP('+e.p+')">'+e.p+'</span></td>';
  529. else h+='<td><span class="badge primary clickable" onclick="drillIdx('+e.p+')">'+e.p+'</span></td>';
  530. }else if(isSBtree){
  531. if(d.lf)h+='<td class="mono" style="color:#5b9bd5;cursor:pointer" onclick="showPK('+e.dp+')" title="点击查看 DataPage">'+e.dp+'</td>';
  532. else h+='<td><span class="badge primary clickable" onclick="drillIdx('+e.p+')">'+e.p+'</span></td>';
  533. }else h+='<td class="mono" style="color:#667">'+e.dp+'</td>';
  534. h+='</tr>';
  535. });
  536. h+='</tbody></table>';
  537. let nav='';
  538. if(S.idxPath.length>1)nav+='<button class="nav-btn" onclick="jumpIdxPath('+(S.idxPath.length-2)+')">&#x21A9; Back</button>';
  539. if(d.nx)nav+='<button class="nav-btn" onclick="gotoIdxSibling('+d.nx+')">Next &#x2192; #'+d.nx+'</button>';
  540. if(d.pv)nav+='<button class="nav-btn" onclick="gotoIdxSibling('+d.pv+')">&#x2190; Prev #'+d.pv+'</button>';
  541. if(nav)h+='<div class="nav-btns">'+nav+'</div>';
  542. $('idxContent').innerHTML=h;
  543. });
  544. }
  545. function showDP(pid){
  546. let h='<div class="bc">';
  547. if(S.curIdxName&&S.idxPath.length)h+=idxBCHtml()+'<span class="sep">&#x203A;</span><span class="cur">Data #'+pid+'</span>';
  548. else h+='<span class="cur">Data #'+pid+'</span>';
  549. h+='</div><div class="sec"><div class="sec-hd"><div class="dot" style="background:#4ecdc4"></div>Data Page #'+pid+'</div>'+
  550. '<div id="dpContent" style="padding:16px;color:#5b9bd5">Loading...</div></div>';
  551. $('content').innerHTML=h;
  552. fetch('/api/data-page?id='+pid).then(r=>r.json()).then(d=>{
  553. if(d.e){$('dpContent').innerHTML='<div class="empty"><div class="msg">Error</div></div>';return}
  554. let info='<div class="node-bar"><div class="ni"><span class="ni-lb">Page</span><span class="ni-val">'+d.id+'</span></div>'+
  555. '<div class="ni"><span class="ni-lb">Rows</span><span class="ni-val">'+d.cn+'</span></div>'+
  556. '<div class="ni"><span class="ni-lb">Min</span><span class="ni-val" style="color:#4ecdc4">'+d.mn+'</span></div>'+
  557. '<div class="ni"><span class="ni-lb">Max</span><span class="ni-val" style="color:#f7b731">'+d.mx+'</span></div>'+
  558. '<div class="ni"><span class="ni-lb">Usage</span><span class="ni-val">'+d.us.toFixed(1)+'%</span>'+
  559. '<div class="ubar"><div class="fill" style="width:'+Math.min(d.us,100)+'%"></div></div></div></div>';
  560. let rows='<table class="dtbl"><thead><tr><th style="width:30px">#</th><th style="width:60px">PK</th><th style="width:50px">Ver</th><th>Decoded Fields</th><th>Raw Hex</th></tr></thead><tbody>';
  561. (d.rs||[]).forEach(r=>{
  562. rows+='<tr><td class="mono" style="color:#556">'+r.i+'</td>'+
  563. '<td class="mono" style="color:#f7b731;font-size:12px">'+r.pk+'</td>'+
  564. '<td><span class="badge active">v'+r.sv+'</span></td>'+
  565. '<td class="row-decoded">'+esc(r.decoded)+'</td>'+
  566. // 原始十六进制按整行长度算: 一行里有 1024/2048 字节的字符串列时是几千个字符,
  567. // 截断显示 (全文在 title 里); 解码列不截 —— 那正是要看的字符串内容本身。
  568. '<td class="hex-data" title="'+escAttr(r.h)+'">'+clip(r.h,240)+'</td></tr>';
  569. });
  570. rows+='</tbody></table>';
  571. let nav='';
  572. if(d.pv)nav+='<button class="nav-btn" onclick="showDP('+d.pv+')">&#x2190; Prev #'+d.pv+'</button>';
  573. if(d.nx)nav+='<button class="nav-btn" onclick="showDP('+d.nx+')">Next &#x2192; #'+d.nx+'</button>';
  574. $('dpContent').innerHTML=info+rows+(nav?'<div class="nav-btns">'+nav+'</div>':'');
  575. });
  576. }
  577. function showPK(pkid){
  578. // 找到当前表的主键索引根页
  579. let pkRoot=0;
  580. if(S.cur&&S.cur.idxs){
  581. S.cur.idxs.forEach(idx=>{if(idx.type===2)pkRoot=idx.ep});
  582. }
  583. if(!pkRoot){alert('No primary index found');return}
  584. let h='<div class="bc">';
  585. if(S.curIdxName&&S.idxPath.length)h+=idxBCHtml()+'<span class="sep">&#x203A;</span>';
  586. h+='<span class="cur">PK #'+pkid+'</span></div>';
  587. h+='<div class="sec"><div class="sec-hd"><div class="dot" style="background:#5b9bd5"></div>Locating PK #'+pkid+'...</div>';
  588. h+='<div id="dpContent" style="padding:16px;color:#5b9bd5">Searching primary B+ tree...</div></div>';
  589. $('content').innerHTML=h;
  590. fetch('/api/find-pk?pk='+pkid+'&root='+pkRoot).then(r=>r.json()).then(d=>{
  591. if(d.e){$('dpContent').innerHTML='<div class="empty"><div class="msg">'+d.e+'</div></div>';return}
  592. showDP(d.dp);
  593. });
  594. }
  595. function showHeader(){
  596. document.querySelectorAll('.tbl-item').forEach(e=>e.classList.remove('active'));
  597. S.bc=[{l:'HeaderPage',a:null}];
  598. let h='<div class="bc"><span class="item" onclick="showOverview()">Overview</span><span class="sep">&#x203A;</span><span class="cur">HeaderPage</span></div>';
  599. h+='<div class="sec"><div class="sec-hd"><div class="dot" style="background:#4ecdc4"></div>Database Header</div>';
  600. h+='<div id="hdrContent" style="padding:16px;color:#5b9bd5">Loading...</div></div>';
  601. $('content').innerHTML=h;
  602. fetch('/api/header').then(r=>r.json()).then(d=>{
  603. if(d.e){$('hdrContent').innerHTML='<div class="empty"><div class="msg">Error</div></div>';return}
  604. let rows=[['Magic',d.magic],['Version','v'+d.ver],['Page Size',d.pgsz+' bytes ('+(d.pgsz/1024)+' KB)'],
  605. ['Data Pages (actual)',d.pg],['Next Free Page',d.nf],['Allocated Pages',d.ap+' (2 .. '+(d.nf-1)+')'],
  606. ['Never Used Pages',d.nu],
  607. ['Table Page (root)',d.tp],['Table Count',d.tc],
  608. ['Create Time',d.ct],['Modify Time',d.mt],
  609. ['Free Chain Start',d.fs],['Free Chain End',d.fe],['Free Count (declared)',d.fc],['Free Walked (actual)',d.fw],
  610. ['Global Flags',d.gf],['WAL First Page',d.wal0],['WAL Last Page',d.wal1],['Next Txn ID',d.txn],
  611. ['Header CRC',(d.crc?('0x'+d.crc.toString(16).toUpperCase().padStart(8,'0')):'(none)')+' — '+d.cv],
  612. ['Comment',d.cm||'(empty)']];
  613. let t='<table class="dtbl"><thead><tr><th style="width:200px">Field</th><th>Value</th></tr></thead><tbody>';
  614. rows.forEach(r=>{t+='<tr><td style="color:#5b9bd5;font-weight:600">'+r[0]+'</td><td class="mono" style="color:#c8ccd4">'+r[1]+'</td></tr>'});
  615. t+='</tbody></table>';
  616. let fcOk=d.fv==='OK';
  617. t+='<div class="ver-card" style="margin:14px 0 0;border-color:'+(fcOk?'#4ecdc4':'#e74c3c')+'">'+
  618. '<div class="ver-hd"><span style="font-size:13px;font-weight:700;color:'+(fcOk?'#4ecdc4':'#e74c3c')+'">Free Chain Self-Check</span>'+
  619. '<span class="badge '+(fcOk?'active':'dropped')+'" style="margin-left:6px">'+esc(d.fv||'-')+'</span></div>'+
  620. '<div style="font-size:12px;color:#8890a0;line-height:1.6">'+esc(d.fd||'')+'</div>'+
  621. '<div class="node-bar" style="background:transparent;border:none;padding:10px 0 0">'+
  622. ni('Declared',d.fc)+ni('Walked',d.fw)+ni('Chain Start',d.fs)+ni('Chain End',d.fe)+'</div></div>';
  623. $('hdrContent').innerHTML=t;
  624. });
  625. }
  626. function showHeapPages(nm){
  627. const t=S.tables.find(x=>x.name===nm);if(!t)return;S.cur=t;
  628. S.bc=[{l:nm,a:()=>showTable(nm)},{l:'Heap Pages',a:null}];
  629. let h='<div class="bc"><span class="item" onclick="showOverview()">Overview</span><span class="sep">&#x203A;</span>'+
  630. '<span class="item" onclick="showTable(\''+esc(nm)+'\')">'+esc(nm)+'</span><span class="sep">&#x203A;</span><span class="cur">Heap Pages</span></div>';
  631. h+='<div class="sec"><div class="sec-hd"><div class="dot" style="background:#f7b731"></div>Heap Chain - '+esc(nm)+' <span id="heapInfo" style="font-weight:400;font-size:11px;color:#556;margin-left:8px">Loading...</span></div>';
  632. h+='<div style="padding:12px 12px 0"><div id="heapMap"></div></div>';
  633. h+='<div id="heapContent" style="padding:0 12px 12px"></div></div>';
  634. $('content').innerHTML=h;
  635. fetch('/api/heap-chain?id='+t.hp0).then(r=>r.json()).then(hd=>{
  636. let actual=hd.pages?hd.pages.length:0;
  637. let declared=t.hpc||0;
  638. let mis=declared!==actual;
  639. // 交叉校验: TableInfo.HeapPageCount (声明值) vs 实走堆链得到的长度 (实际值)。
  640. // 三态: OK 一致;
  641. // STALE 声明为 0 而链非空 —— 该表建于计数器引入之前, 引擎会退化为逐页回收, 数据安全;
  642. // MISMATCH 非零却对不上 —— 计数器真失真 (偏高=页泄漏, 偏低=页被提前回收)。
  643. let badge=mis
  644. ? (declared===0
  645. ? '<span class="badge btree" title="HeapPageCount=0: 该表建于本次改动之前, 计数器从未初始化。引擎会退化为逐页回收, 数据安全。">STALE</span>'
  646. : '<span class="badge dropped" title="HeapPageCount 与实走链长不一致, 计数器已失真">MISMATCH</span>')
  647. : '<span class="badge active">OK</span>';
  648. let color=mis?(declared===0?'#f7b731':'#e74c3c'):'#4ecdc4';
  649. $('heapInfo').innerHTML=actual+' pages \u00d7 16 KB &nbsp;|&nbsp; declared: <b style="color:'+color+'">'+declared+'</b> '+badge;
  650. if(!actual){$('heapContent').innerHTML='<div class="empty"><div class="ico">&#x1F4E6;</div><div class="msg">No heap pages</div></div>';return}
  651. // 页图改用 canvas 虚拟化 + 分块懒加载: 只画视口内的格子, 堆页青色高亮。
  652. pageGrid('heapMap',{hl:new Set(hd.pages.map(p=>p.id)),hlLabel:'Heap Page',hlColor:'#4ecdc4'});
  653. $('heapContent').innerHTML='<table class="dtbl" style="margin-top:12px"><thead><tr><th>Page ID</th><th>Next</th><th>Prev</th><th>Used</th><th>Usage</th><th title="页头 RefCount — 指向本页的活引用数, 归零即可整页回收">RefCount</th></tr></thead><tbody id="heapBody"></tbody></table><div style="padding:10px 0"><button class="nav-btn" id="heapMore" style="display:none">显示更多</button></div>';
  654. pmLimited('heapBody','heapMore',hd.pages,p=>{
  655. let us=(p.use/16384*100).toFixed(1);
  656. let rc=p.ref||0;
  657. return '<tr><td class="mono" style="color:#4ecdc4;font-weight:600">'+p.id+'</td><td class="mono">'+p.nx+'</td><td class="mono">'+p.pv+'</td><td class="mono">'+fB(p.use)+'</td><td>'+us+'%<div class="ubar"><div class="fill" style="width:'+Math.min(us,100)+'%"></div></div></td>'+
  658. '<td><span class="badge '+(rc>0?'active':'dropped')+'">'+rc+'</span></td></tr>';
  659. });
  660. }).catch(e=>{$('heapContent').innerHTML='<div style="color:#e74c3c">Error: '+e.message+'</div>'});
  661. }
  662. function showFreePages(){
  663. document.querySelectorAll('.tbl-item').forEach(e=>e.classList.remove('active'));
  664. S.bc=[{l:'Free Pages',a:null}];
  665. let h='<div class="bc"><span class="item" onclick="showOverview()">Overview</span><span class="sep">&#x203A;</span><span class="cur">Free Pages</span></div>';
  666. h+='<div class="sec"><div class="sec-hd"><div class="dot" style="background:#a78bfa"></div>Recycled Pages <span id="freeInfo" style="font-weight:400;font-size:11px;color:#556;margin-left:8px">Loading...</span></div>';
  667. h+='<div style="padding:12px 12px 0"><div id="freeMap"></div></div>';
  668. h+='<div id="freeContent" style="padding:0 12px 12px"></div></div>';
  669. $('content').innerHTML=h;
  670. fetch('/api/free-pages').then(r=>r.json()).then(fd=>{
  671. if(!fd.pages||!fd.pages.length){$('freeContent').innerHTML='<div class="empty"><div class="ico">&#x267B;</div><div class="msg">No recycled pages</div></div>';return}
  672. const all=fd.pages;
  673. $('freeInfo').textContent=fd.total+' free pages \u00d7 16 KB = '+fB(fd.total*16384);
  674. // 页图改用 canvas 虚拟化 + 分块懒加载, 空闲页紫色高亮。
  675. pageGrid('freeMap',{hl:new Set(all),hlLabel:'Free Page',hlColor:'#a78bfa'});
  676. $('freeContent').innerHTML='<table class="dtbl" style="margin-top:12px"><thead><tr><th>#</th><th>Page ID</th><th>Next Free</th></tr></thead><tbody id="freeBody"></tbody></table><div style="padding:10px 0"><button class="nav-btn" id="freeMore" style="display:none">显示更多</button></div>';
  677. pmLimited('freeBody','freeMore',all,(p,i)=>'<tr><td class="mono" style="color:#556">'+i+'</td><td class="mono" style="color:#a78bfa;font-weight:600">'+p+'</td><td class="mono">'+(all[i+1]||'0 (end)')+'</td></tr>');
  678. }).catch(e=>{$('freeContent').innerHTML='<div style="color:#e74c3c">Error: '+e.message+'</div>'});
  679. }
  680. function navBC(i){S.bc=S.bc.slice(0,i+1);S.bc[i].a()}
  681. // ===== Audit / Heap Chains / Orphan Pages =====
  682. // renderOrphanRows 把一批页号渲染成表格行 (每个页号查一次 /api/page-info)。
  683. // 之后可以在表尾追加一行占位说明 (见 loadOrphanRows 的截断提示 / 分页脚)。
  684. function renderOrphanRows(ids,targetId,foot){
  685. if(!ids||!ids.length){let el=$(targetId);if(el)el.innerHTML='<tr><td colspan="7" style="color:#4ecdc4">None \u2014 所有页都归属明确</td></tr>';return}
  686. Promise.all(ids.map(id=>fetch('/api/page-info?id='+id).then(x=>x.json()).catch(()=>({id:id,e:'?'})))).then(list=>{
  687. let tb='';
  688. list.forEach(p=>{
  689. tb+='<tr><td class="mono" style="color:#e74c3c;font-weight:600">'+p.id+'</td>'+
  690. '<td>'+(p.e?'<span style="color:#667">'+esc(p.e)+'</span>':'<span class="badge data">'+esc(p.ts||'?')+'</span> <span class="mono" style="color:#556">#'+p.t+'</span>')+'</td>'+
  691. '<td>'+(p.us!=null?p.us.toFixed(1)+'%':'-')+'</td>'+
  692. '<td class="mono">'+esc(p.role||'-')+'</td><td>'+esc(p.owner||'-')+'</td>'+
  693. '<td>'+(p.isAlloc?'<span class="badge active">yes</span>':'<span class="badge dropped">no</span>')+'</td>'+
  694. '<td><span class="clickable" onclick="showPageInfo('+p.id+')">inspect</span></td></tr>';
  695. });
  696. if(foot)tb+=foot;
  697. let el=$(targetId);if(el)el.innerHTML=tb||'<tr><td colspan="7">-</td></tr>';
  698. });
  699. }
  700. // loadOrphanRows 批量查未归属页的页头, 填进指定 tbody。
  701. function loadOrphanRows(ids,targetId){
  702. // 每个孤儿页都要单独发一次 /api/page-info: 一次并发几千个请求同样会把浏览器拖垮,
  703. // 所以只查前 300 个, 剩余的在表尾注明 (整页浏览走分页接口, 见 loadUnaccPage)。
  704. const CAP=300,head=(ids||[]).slice(0,CAP);
  705. if(!head.length){let el=$(targetId);if(el)el.innerHTML='<tr><td colspan="7" style="color:#4ecdc4">None \u2014 所有页都归属明确</td></tr>';return}
  706. let foot='';
  707. if(ids.length>CAP)foot='<tr><td colspan="7" style="color:#f7b731">\u2026 共 '+ids.length+' 页, 为避免拖垮浏览器仅展示前 '+CAP+' 页</td></tr>';
  708. renderOrphanRows(head,targetId,foot);
  709. }
  710. // orphanPageArg 把一次分页请求的 offset/limit 存下来, 供翻页按钮复用。
  711. var orphanPageArg={off:0,lim:50,total:0};
  712. // loadUnaccPage 从落盘缓存库分页取孤儿页 (不必把明细整份塞进浏览器), 支持上一页/下一页。
  713. function loadUnaccPage(total,targetId,off){
  714. const lim=50;if(off<0)off=0;
  715. fetch('/api/audit/unacc?offset='+off+'&limit='+lim).then(x=>x.json()).then(d=>{
  716. if(d.e||!d.rows){loadOrphanRows(null,targetId);return} // 缓存未就绪时兜底
  717. orphanPageArg={off:off,lim:lim,total:d.total||0};
  718. const from=d.total?off+1:0,to=Math.min(off+lim,d.total);
  719. let foot='';
  720. if(d.total>lim){
  721. const prev=off>0?'<span class="clickable" style="color:#5b9bd5" onclick="loadUnaccPage('+d.total+',\''+targetId+'\','+(off-lim)+')">\u2039 Prev</span>':'<span style="color:#445">\u2039 Prev</span>';
  722. const next=(off+lim<d.total)?'<span class="clickable" style="color:#5b9bd5" onclick="loadUnaccPage('+d.total+',\''+targetId+'\','+(off+lim)+')">Next \u203A</span>':'<span style="color:#445">Next \u203A</span>';
  723. foot='<tr><td colspan="7" style="color:#8890a0">'+prev+' &nbsp;·&nbsp; '+(from+'-'+to)+' / '+d.total+' &nbsp;·&nbsp; '+next+'</td></tr>';
  724. }
  725. renderOrphanRows(d.rows,targetId,foot);
  726. });
  727. }
  728. // renderIssueRows 渲染索引内容问题明细 (内存兜底版与分页版共用)。
  729. function renderIssueRows(rows,targetId,foot){
  730. let tb='';
  731. (rows||[]).forEach(x=>{var at=(x.at<0)?'-':x.at;var pg=(x.page>0)?'<td class="clickable mono" onclick="showPageInfo('+x.page+')">'+x.page+'</td>':'<td class="mono">-</td>';tb+='<tr><td class="mono" style="color:#5b9bd5">'+esc(x.table+'.'+x.index)+'</td><td>'+esc(x.itype)+'</td><td style="color:#e74c3c">'+esc(x.kind)+'</td>'+pg+'<td class="mono">'+at+'</td><td style="font-size:12px;color:#c8ccd4">'+esc(x.detail)+'</td></tr>'});
  732. if(!tb)tb='<tr><td colspan="6" style="color:#556">-</td></tr>';
  733. if(foot)tb+=foot;
  734. let el=$(targetId);if(el)el.innerHTML=tb;
  735. }
  736. // loadIssuePage 从落盘缓存库分页取索引问题明细; 缓存未就绪时回退到内存里的前若干条。
  737. function loadIssuePage(total,fallback,targetId,off){
  738. const lim=50;if(off<0)off=0;
  739. fetch('/api/audit/issues?offset='+off+'&limit='+lim).then(x=>x.json()).then(d=>{
  740. if(d.e||!d.rows){
  741. const fb=(fallback||[]).slice(0,60);
  742. renderIssueRows(fb,targetId,(total>60)?'<tr><td colspan="6" style="color:#556">\u2026 明细已截断 (以后端计数为准)</td></tr>':'');
  743. return;
  744. }
  745. let foot='';
  746. if(d.total>lim){
  747. const from=d.total?off+1:0,to=Math.min(off+lim,d.total);
  748. const prev=off>0?'<span class="clickable" style="color:#5b9bd5" onclick="loadIssuePage('+d.total+',null,\''+targetId+'\','+(off-lim)+')">\u2039 Prev</span>':'<span style="color:#445">\u2039 Prev</span>';
  749. const next=(off+lim<d.total)?'<span class="clickable" style="color:#5b9bd5" onclick="loadIssuePage('+d.total+',null,\''+targetId+'\','+(off+lim)+')">Next \u203A</span>':'<span style="color:#445">Next \u203A</span>';
  750. foot='<tr><td colspan="6" style="color:#8890a0">'+prev+' &nbsp;·&nbsp; '+(from+'-'+to)+' / '+d.total+' &nbsp;·&nbsp; '+next+'</td></tr>';
  751. }
  752. renderIssueRows(d.rows,targetId,foot);
  753. });
  754. }
  755. function showAudit(){
  756. document.querySelectorAll('.tbl-item').forEach(e=>e.classList.remove('active'));
  757. const tok=++auTok;
  758. let h='<div class="bc"><span class="item" onclick="showOverview()">Overview</span><span class="sep">&#x203A;</span><span class="cur">Full Audit</span></div>';
  759. h+='<div class="sec"><div class="sec-hd"><div class="dot" style="background:#5b9bd5"></div>Full Database Audit <span id="auInfo" style="font-weight:400;font-size:11px;color:#556;margin-left:8px">Loading...</span></div><div id="auContent" style="padding:12px;color:#5b9bd5">Running full audit...</div></div>';
  760. $('content').innerHTML=h;
  761. loadAudit(tok);
  762. }
  763. // loadAudit 拉取审计结果并渲染。索引内容一致性是后台增量补全的 (idxPending),
  764. // 未就绪时先渲染"校验中"并轮询 (其它结论: 页账 / 空闲链 / 交叉引用 已就绪)。
  765. function loadAudit(tok){
  766. if(tok!==auTok||!$('auContent'))return;
  767. fetch('/api/audit').then(r=>r.json()).then(a=>{
  768. if(tok!==auTok||!$('auContent'))return;
  769. if(a.e){$('auContent').innerHTML='<div class="empty"><div class="msg">'+esc(a.e)+'</div></div>';return}
  770. let vc=a.verdict==='OK'?'#4ecdc4':((a.verdict==='UNACCOUNTED'||a.verdict==='CORRUPT')?'#e74c3c':'#f7b731');
  771. $('auInfo').innerHTML='<span style="color:'+vc+';font-weight:700">'+a.verdict+'</span>';
  772. let r=a.reach||{},fr=a.free||{};
  773. let o='<div class="ver-card" style="border-color:'+vc+'"><div class="ver-hd"><span style="font-size:16px;font-weight:700;color:'+vc+'">'+a.verdict+'</span>'+
  774. '<span style="font-size:11px;color:#556">'+esc(a.file||'')+'</span>'+
  775. '<span style="font-size:11px;color:#556;margin-left:auto">'+fB(a.bytes||0)+'</span></div>';
  776. if(a.notes&&a.notes.length)o+='<div style="margin-top:6px;color:#aab;font-size:12px;line-height:1.7">'+a.notes.map(n=>'&bull; '+esc(n)).join('<br>')+'</div>';
  777. o+='</div>';
  778. o+='<div class="stats">'+
  779. bigCard('c1','Total Pages',a.totalPages,'16 KB \u00d7 N')+
  780. bigCard('c2','Allocated',a.allocated,'page 2 .. '+(a.firstFree-1))+
  781. bigCard('c2','Reachable',r.reachable,'被表/索引引用')+
  782. bigCard('c5','Unaccounted',r.unaccounted,'孤儿页')+
  783. bigCard('c4','Free',fr.total,'空闲链')+
  784. bigCard('c3','Reserved',a.reserved,'页 1 引擎保留')+
  785. bigCard('c3','Never Used',a.neverUsed,'未分配尾页')+
  786. '</div>';
  787. let fv=fr.verdict==='OK'?'active':'dropped';
  788. o+='<div class="sec"><div class="sec-hd"><div class="dot" style="background:#a78bfa"></div>Free Chain Self-Check <span class="badge '+fv+'" style="margin-left:6px">'+esc(fr.verdict||'-')+'</span></div><div class="sec-bd" style="padding:12px">';
  789. o+='<div class="node-bar" style="background:transparent;border:none;padding:0 0 10px">'+
  790. ni('Head',fr.head)+ni('Tail',fr.tail)+ni('Declared',fr.declared)+ni('Decl End',fr.declEnd)+ni('Walked',fr.total)+ni('Bad Ptr',fr.badPtr)+'</div>';
  791. o+='<div style="font-size:12px;color:#8890a0">'+esc(fr.detail||'')+'</div></div></div>';
  792. // ---- 完整性交叉校验 (对照 SQLite integrity_check / PG amcheck) ----
  793. let cl=a.crossLinks||[],fl=a.freeLive||[],brs=a.badRefs||[];
  794. let cCount=a.multiRef||0,bCount=a.badRefCount||0,bad=(cCount||fl.length||bCount);
  795. o+='<div class="sec"><div class="sec-hd"><div class="dot" style="background:'+(bad?'#e74c3c':'#4ecdc4')+'"></div>Integrity Cross-Check <span class="badge '+(bad?'dropped':'active')+'" style="margin-left:6px">'+(bad?'FAIL':'PASS')+'</span></div><div class="sec-bd" style="padding:12px">';
  796. o+='<div class="node-bar" style="background:transparent;border:none;padding:0 0 10px">'+ni('Cross-Linked',cCount)+ni('Free-But-Live',fl.length)+ni('Bad Refs',bCount)+'</div>';
  797. if(cCount){
  798. o+='<div style="font-size:12px;color:#aab;margin:4px 0">同一页被两个不同主人认领 (已排除同表内 数据页+索引叶 的正常 fan-in):</div>';
  799. o+='<table class="dtbl"><thead><tr><th>Page</th><th>Type</th><th>Claims (role@owner)</th></tr></thead><tbody>';
  800. cl.slice(0,50).forEach(x=>{o+='<tr><td class="clickable mono" onclick="showPageInfo('+x.page+')">'+x.page+'</td><td>'+esc(x.type)+'</td><td class="mono" style="color:#c8ccd4">'+(x.claims||[]).map(function(c){return esc(c)}).join(' &nbsp;|&nbsp; ')+'</td></tr>'});
  801. o+='</tbody></table>'+(cCount>50?'<div style="font-size:11px;color:#556;margin-top:4px">... 仅显示前 50 / 共 '+cCount+'</div>':'');
  802. }
  803. if(fl.length){
  804. o+='<div style="font-size:12px;color:#aab;margin:10px 0 4px">既在空闲链、又被引用 (下次分配即被覆写):</div>';
  805. o+='<div class="mono" style="font-size:12px;line-height:1.9">'+fl.slice(0,100).map(function(p){return '<span class="clickable" onclick="showPageInfo('+p+')" style="color:#e74c3c">'+p+'</span>'}).join(' ')+(fl.length>100?' ...':'')+'</div>';
  806. }
  807. if(bCount){
  808. o+='<div style="font-size:12px;color:#aab;margin:10px 0 4px">非法页指针 (指向文件外 / 保留页 1):</div>';
  809. o+='<table class="dtbl"><thead><tr><th>From</th><th>To</th><th>Kind</th><th>Role</th><th>Owner</th></tr></thead><tbody>';
  810. brs.slice(0,50).forEach(x=>{o+='<tr><td class="mono">'+x.from+'</td><td class="mono" style="color:#e74c3c;font-weight:600">'+x.to+'</td><td>'+esc(x.kind)+'</td><td class="mono">'+esc(x.role)+'</td><td>'+esc(x.owner)+'</td></tr>'});
  811. o+='</tbody></table>'+(bCount>50?'<div style="font-size:11px;color:#556;margin-top:4px">... 仅显示前 50 / 共 '+bCount+'</div>':'');
  812. }
  813. if(!bad)o+='<div style="font-size:12px;color:#4ecdc4">通过 —— 无交叉引用 / 无空闲页被引用 / 无非法指针。</div>';
  814. o+='</div></div>';
  815. // ---- 索引内容一致性 (对照 PRAGMA integrity_check / amcheck) ----
  816. let iEnt=a.idxEntries||0,iBad=a.idxBadOrder||0,iOrph=a.idxOrphanPK||0,iNDP=a.idxNotDataPage||0;
  817. let iMR=a.idxMissingRow||0,iME=a.idxMissingEntry||0;
  818. let iIss=a.idxIssues||[],iBad2=(iBad||iOrph||iNDP||iMR||iME),iSkip=a.idxSkipped||'',iNote=a.idxNotes||[],iPend=a.idxPending===true;
  819. o+='<div class="sec"><div class="sec-hd"><div class="dot" style="background:'+(iPend?'#5b9bd5':(iSkip?'#f7b731':(iBad2?'#e74c3c':'#4ecdc4')))+'"></div>Index Consistency'
  820. +(iPend?' <span class="badge" style="margin-left:6px;background:#5b9bd5">CHECKING</span>':(iSkip?' <span class="badge dropped" style="margin-left:6px;background:#f7b731">SKIPPED</span>':(iBad2?' <span class="badge dropped" style="margin-left:6px">FAIL</span>':' <span class="badge active" style="margin-left:6px">PASS</span>')))
  821. +'<span style="font-size:11px;color:#8890a0;margin-left:8px;font-weight:400">'+(iPend?'—':iEnt+' entries')+'</span></div><div class="sec-bd" style="padding:12px">';
  822. if(iPend){o+='<div style="font-size:12px;color:#5b9bd5">正在逐条比对索引内容 (键序 / 悬空索引项 / 反向缺失) —— 大表需要十几秒, 完成后本页自动刷新。上方页账 / 空闲链 / 交叉引用结论已就绪。</div>';}
  823. else if(iSkip){o+='<div style="font-size:12px;color:#f7b731">'+esc(iSkip)+'</div>';}
  824. else{
  825. o+='<div class="node-bar" style="background:transparent;border:none;padding:0 0 10px">'+ni('Key Order',iBad)+ni('Orphan Index Entry',iOrph)+ni('Not-Data-Page',iNDP)+ni('Missing Row',iMR)+ni('Missing Entry',iME)+'</div>';
  826. o+='<div style="font-size:11px;color:#8890a0;margin:-4px 0 8px">范围: B+树(Primary/BTree/Str) 查页内+跨页键序; Time/Compose 只查页内 (链是"填满一页再下一页", 跨页不保证全局有序); Hash 桶内无序, 仅查桶内条目。<br>方向: 前三项是 索引项→行 (索引里有没有废条目); Missing Row / Missing Entry 是 行→索引项 (该有的条目在不在), 键按引擎同款算法从行字节复算。</div>';
  827. if(!iBad2)o+='<div style="font-size:12px;color:#4ecdc4">通过 —— 索引键序单调; 每个二级索引条目都能回表到存活行; 且每个存活行在各索引里都留有条目。</div>';
  828. if(iNote.length){o+='<div style="font-size:11px;color:#f7b731;margin-bottom:6px">跳过: '+iNote.map(esc).join('; ')+'</div>';}
  829. if(iIss.length||iBad2){
  830. o+='<table class="dtbl"><thead><tr><th>Table.Index</th><th>Type</th><th>Kind</th><th>Page</th><th>#</th><th>Detail</th></tr></thead><tbody id="auIssBody"><tr><td colspan="6" style="color:#556">Loading...</td></tr></tbody></table>';
  831. }
  832. }
  833. o+='</div></div>';
  834. o+='<div class="sec"><div class="sec-hd"><div class="dot" style="background:#4ecdc4"></div>Reachability by Role</div><div class="sec-bd"><table class="dtbl"><thead><tr><th>Role</th><th>Pages</th><th>Share</th></tr></thead><tbody>';
  835. let br=r.byRole||{},tot=a.totalPages||1;
  836. Object.keys(br).sort((x,y)=>br[y]-br[x]).forEach(k=>{o+='<tr><td class="mono" style="color:#5b9bd5">'+esc(k)+'</td><td>'+br[k]+'</td><td>'+(br[k]/tot*100).toFixed(1)+'%</td></tr>'});
  837. o+='<tr><td style="color:#4ecdc4;font-weight:600">free</td><td>'+fr.total+'</td><td>'+(fr.total/tot*100).toFixed(1)+'%</td></tr>';
  838. o+='<tr><td style="color:#a78bfa;font-weight:600">reserved</td><td>'+a.reserved+'</td><td>'+(a.reserved/tot*100).toFixed(1)+'%</td></tr>';
  839. o+='<tr><td style="color:#445;font-weight:600">never-used</td><td>'+a.neverUsed+'</td><td>'+(a.neverUsed/tot*100).toFixed(1)+'%</td></tr>';
  840. o+='</tbody></table></div></div>';
  841. o+='<div class="sec"><div class="sec-hd"><div class="dot" style="background:#f7b731"></div>Per-Table Audit <span style="font-size:11px;color:#556;margin-left:8px">'+(a.tables?a.tables.length:0)+' tables</span></div><div class="sec-bd"><table class="dtbl"><thead><tr>'+
  842. '<th>Table</th><th>Rows</th><th>Data</th><th>Heap Decl</th><th>Heap Walked</th><th>Heap Refs</th><th>Live/Empty</th><th>Heap Bytes</th><th>Idx Pages</th><th>Idx\u2192Data</th><th>Verdict</th></tr></thead><tbody>';
  843. if(!a.tables||!a.tables.length)o+='<tr><td colspan="11" class="empty">No tables</td></tr>';
  844. else a.tables.forEach(t=>{
  845. let bv=t.verdict==='OK'?'active':(t.verdict==='STALE'?'btree':'dropped');
  846. o+='<tr><td><span class="clickable" onclick="showTable(\''+esc(t.name)+'\')">'+esc(t.name)+'</span></td>'+
  847. '<td>'+t.rows+'</td><td>'+t.dataPages+'</td><td>'+t.hpc+'</td><td>'+t.heapActual+'</td><td>'+t.heapRefs+'</td>'+
  848. '<td><span style="color:#4ecdc4">'+t.heapLive+'</span> / <span style="color:#f7b731">'+t.heapEmpty+'</span></td>'+
  849. '<td>'+fB(t.heapBytes)+'</td><td>'+t.idxPages+'</td><td>'+t.idxDps+'</td>'+
  850. '<td><span class="badge '+bv+'" title="'+esc(t.detail)+'">'+esc(t.verdict)+'</span></td></tr>';
  851. });
  852. o+='</tbody></table></div></div>';
  853. o+='<div class="sec"><div class="sec-hd"><div class="dot" style="background:#e74c3c"></div>Orphan / Unaccounted Pages <span style="font-size:11px;color:#e74c3c;margin-left:6px">'+r.unaccounted+'</span>'+(r.truncated?'<span style="font-size:11px;color:#556;margin-left:6px">(list truncated)</span>':'')+'</div><div class="sec-bd"><table class="dtbl"><thead><tr><th>Page</th><th>Type</th><th>Usage</th><th>Role</th><th>Owner</th><th>Allocated</th><th></th></tr></thead><tbody id="orphBody"><tr><td colspan="7" style="color:#556">Loading...</td></tr></tbody></table>'+
  854. ((r.unaccountedByType||r.walLeftover)?('<div style="padding:10px 14px;font-size:11px;color:#556">按页类型: '+(r.unaccountedByType?Object.keys(r.unaccountedByType).map(k=>esc(k)+' \u00d7 '+r.unaccountedByType[k]).join(', '):'-')+(r.walLeftover?' · <span style="color:#f7b731">其中 '+r.walLeftover+' 页为 WAL 残留 (历史已提交 WAL 链未被回收)</span>':'')+'</div>'):'')+
  855. '</div></div>';
  856. $('auContent').innerHTML=o;
  857. loadOrphanRows(r.unaccountedIds,'orphBody');
  858. // 索引问题明细: 走分页接口按页取 (问题很多时不把整份塞进页面)。
  859. if($('auIssBody'))loadIssuePage(iBad+iOrph+iNDP+iMR+iME,iIss,'auIssBody',0);
  860. // 增量审计: 索引内容还在后台补全 —— 标记"校验中"并轮询, 就绪后自动重绘一次。
  861. if(a.idxPending){
  862. if($('auInfo'))$('auInfo').innerHTML='<span style="color:#f7b731;font-weight:700">'+a.verdict+'</span> <span style="font-size:11px;color:#f7b731">索引校验中…</span>';
  863. setTimeout(()=>loadAudit(tok),700);
  864. }
  865. }).catch(e=>{if(tok===auTok&&$('auContent'))$('auContent').innerHTML='<div style="color:#e74c3c">Error: '+e.message+'</div>'});
  866. }
  867. function showHeapChains(){
  868. document.querySelectorAll('.tbl-item').forEach(e=>e.classList.remove('active'));
  869. let h='<div class="bc"><span class="item" onclick="showOverview()">Overview</span><span class="sep">&#x203A;</span><span class="cur">Heap Chains</span></div>';
  870. h+='<div class="sec"><div class="sec-hd"><div class="dot" style="background:#4ecdc4"></div>Heap Chains \u2014 all tables <span id="hcInfo" style="font-weight:400;font-size:11px;color:#556;margin-left:8px">Loading...</span></div><div id="hcContent" style="padding:12px;color:#5b9bd5">Loading...</div></div>';
  871. $('content').innerHTML=h;
  872. fetch('/api/heap-chains').then(r=>r.json()).then(d=>{
  873. if(d.e){$('hcContent').innerHTML='<div class="empty"><div class="msg">'+esc(d.e)+'</div></div>';return}
  874. let ts=d.tables||[],fr=d.free||{},rch=d.reach||{};
  875. let decl=0,act=0,refs=0,bytes=0,live=0,empty=0,withHeap=0,mismatch=0,stale=0;
  876. ts.forEach(t=>{decl+=t.hpc;act+=t.heapActual;refs+=t.heapRefs;bytes+=t.heapBytes;live+=t.heapLive;empty+=t.heapEmpty;
  877. if(t.heapActual>0)withHeap++;if(t.verdict==='MISMATCH')mismatch++;if(t.verdict==='STALE')stale++;});
  878. $('hcInfo').innerHTML=withHeap+' tables with heap &nbsp;|&nbsp; '+act+' heap pages \u00d7 16 KB';
  879. let o='<div class="stats">'+
  880. bigCard('c1','Tables With Heap',withHeap,'有堆链的表')+
  881. bigCard('c2','Heap Pages',act,'实走链长')+
  882. bigCard('c3','Declared',decl,'HeapPageCount 之和')+
  883. bigCard('c4','Bucket Refs',refs,'RefCount 之和')+
  884. bigCard('c2','Live / Empty',live+' / '+empty,'RefCount>0 / =0')+
  885. bigCard('c1','Heap Bytes',fB(bytes),'已用字节')+
  886. bigCard(mismatch?'c5':'c2','Mismatches',mismatch,(stale?'plus '+stale+' STALE':'no counter drift'))+
  887. '</div>';
  888. o+='<div class="sec"><div class="sec-hd"><div class="dot" style="background:#f7b731"></div>Per-Table Heap Accounting</div><div class="sec-bd"><table class="dtbl"><thead><tr>'+
  889. '<th>Table</th><th>First Page</th><th>Declared</th><th>Walked</th><th>Diff</th><th>Refs</th><th>Live/Empty</th><th>Bytes</th><th>Verdict</th><th></th></tr></thead><tbody>';
  890. if(!ts.length)o+='<tr><td colspan="10" class="empty">No tables</td></tr>';
  891. ts.slice().sort((x,y)=>(y.heapActual-x.heapActual)||(y.hpc-x.hpc)).forEach(t=>{
  892. let bv=t.verdict==='OK'?'active':(t.verdict==='STALE'?'btree':'dropped');
  893. let diff=t.heapActual-t.hpc;
  894. let dcol=diff===0?'#4ecdc4':(t.hpc===0?'#f7b731':'#e74c3c');
  895. o+='<tr><td><span class="clickable" onclick="showTable(\''+esc(t.name)+'\')">'+esc(t.name)+'</span></td>'+
  896. '<td class="mono">'+(t.hp0||0)+'</td><td>'+t.hpc+'</td><td>'+t.heapActual+'</td>'+
  897. '<td style="color:'+dcol+';font-weight:600">'+(diff>0?'+':'')+diff+'</td>'+
  898. '<td>'+t.heapRefs+'</td><td><span style="color:#4ecdc4">'+t.heapLive+'</span> / <span style="color:#f7b731">'+t.heapEmpty+'</span></td>'+
  899. '<td>'+fB(t.heapBytes)+'</td>'+
  900. '<td><span class="badge '+bv+'" title="'+esc(t.detail)+'">'+esc(t.verdict)+'</span></td>'+
  901. '<td>'+(t.heapActual>0?'<span class="clickable" onclick="showHeapPages(\''+esc(t.name)+'\')">view chain</span>':'<span style="color:#445">-</span>')+'</td></tr>';
  902. });
  903. o+='</tbody></table></div></div>';
  904. o+='<div class="sec"><div class="sec-hd"><div class="dot" style="background:#e74c3c"></div>Unowned Pages (no table) <span style="font-size:11px;color:#e74c3c;margin-left:6px">'+(rch.unaccounted||0)+'</span></div><div class="sec-bd"><table class="dtbl"><thead><tr><th>Page</th><th>Type</th><th>Usage</th><th>Role</th><th>Owner</th><th>Allocated</th><th></th></tr></thead><tbody id="hcOrphBody"><tr><td colspan="7" style="color:#556">Loading...</td></tr></tbody></table>'+
  905. '<div style="padding:10px 14px;font-size:11px;color:#556">这些页既不属于任何表/索引, 也不在空闲链上 —— DropTable / 重建索引漏收的候选。</div></div></div>';
  906. $('hcContent').innerHTML=o;
  907. loadOrphanRows(rch.unaccountedIds,'hcOrphBody');
  908. }).catch(e=>{$('hcContent').innerHTML='<div style="color:#e74c3c">Error: '+e.message+'</div>'});
  909. }
  910. function showOrphans(){
  911. document.querySelectorAll('.tbl-item').forEach(e=>e.classList.remove('active'));
  912. let h='<div class="bc"><span class="item" onclick="showOverview()">Overview</span><span class="sep">&#x203A;</span><span class="cur">Orphan Pages</span></div>';
  913. h+='<div class="sec"><div class="sec-hd"><div class="dot" style="background:#e74c3c"></div>Orphan / Unaccounted Pages <span id="opInfo" style="font-weight:400;font-size:11px;color:#556;margin-left:8px">Loading...</span></div><div id="opContent" style="padding:12px;color:#5b9bd5">Scanning...</div></div>';
  914. $('content').innerHTML=h;
  915. fetch('/api/audit').then(r=>r.json()).then(a=>{
  916. if(a.e){$('opContent').innerHTML='<div class="empty"><div class="msg">'+esc(a.e)+'</div></div>';return}
  917. let r=a.reach||{};
  918. $('opInfo').innerHTML=r.unaccounted+' pages unaccounted';
  919. let o='';
  920. if(!r.unaccounted){
  921. o='<div class="empty"><div class="ico">&#x2705;</div><div class="msg">All '+a.totalPages+' pages are accounted for \u2014 每个页要么归属明确, 要么在空闲链上。</div></div>';
  922. }else{
  923. let bt=r.unaccountedByType?Object.keys(r.unaccountedByType).map(k=>esc(k)+' \u00d7 '+r.unaccountedByType[k]).join(', '):'';
  924. o='<div class="ver-card" style="border-color:#e74c3c"><div class="ver-hd"><span style="font-size:14px;font-weight:700;color:#e74c3c">'+r.unaccounted+' orphan pages</span></div>'+
  925. '<div style="font-size:12px;color:#aab;line-height:1.7">已下发区间内, 既不可达 (不属于任何表/索引) 也不在空闲链上的页。'+
  926. (bt?('按页类型: '+bt+'。<br>'):'')+
  927. (r.walLeftover?('<b style="color:#f7b731">其中 '+r.walLeftover+' 页为 WAL 残留</b> —— 页类型为 WAL, 但页头 FirstWALPage=0 (无待恢复 WAL) 且不在空闲链上, 即历史已提交事务的 WAL 链页在提交时未被归还空闲链 (引擎漏回收)。这类页不会再被分配, 属于空间泄漏, 需引擎侧修复。<br>'):'')+
  928. '其它常见来源: DropTable / 重建索引漏收, 或本工具未覆盖的结构。点击 <b>inspect</b> 查看页头与归属。</div></div>';
  929. o+='<div class="sec"><div class="sec-hd"><div class="dot" style="background:#e74c3c"></div>Orphan Page List'+(r.truncated?' <span style="font-size:11px;color:#556">(truncated)</span>':'')+'</div><div class="sec-bd">'+
  930. '<table class="dtbl"><thead><tr><th>Page</th><th>Type</th><th>Usage</th><th>Role</th><th>Owner</th><th>Allocated</th><th></th></tr></thead><tbody id="opBody"><tr><td colspan="7" style="color:#556">Loading...</td></tr></tbody></table></div></div>';
  931. }
  932. $('opContent').innerHTML=o;
  933. // 孤儿页可能很多: 走分页接口按页取, 不把整份明细塞进浏览器。
  934. loadUnaccPage(r.unaccounted,'opBody',0);
  935. }).catch(e=>{$('opContent').innerHTML='<div style="color:#e74c3c">Error: '+e.message+'</div>'});
  936. }
  937. // showPageInfo 单页检查弹窗 —— 页头原始字段 + 归属 + 快捷跳转。
  938. function showPageInfo(pid){
  939. fetch('/api/page-info?id='+pid).then(r=>r.json()).then(p=>{
  940. if(!p||p.e){alert('Page '+pid+': '+((p&&p.e)||'read error'));return}
  941. let ov=document.createElement('div');ov.id='piOverlay';
  942. ov.style.cssText='position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.72);z-index:9999;display:flex;align-items:center;justify-content:center';
  943. ov.onclick=function(e){if(e.target===ov)piClose()};
  944. let box=document.createElement('div');
  945. box.style.cssText='background:#111122;border:1px solid #1e1e3a;border-radius:10px;width:560px;max-width:92%;max-height:86vh;overflow:auto;box-shadow:0 20px 60px rgba(0,0,0,.6)';
  946. let roleBadge=p.role?('<span class="badge primary">'+esc(p.role)+'</span>'):'<span class="badge">unowned</span>';
  947. let h='<div style="padding:14px 18px;border-bottom:1px solid #1a1a2e;display:flex;align-items:center;gap:8px">'+
  948. '<span style="font-size:15px;font-weight:700;color:#5b9bd5">Page #'+p.id+'</span>'+
  949. '<span class="badge data">'+esc(p.ts)+'</span>'+roleBadge+
  950. '<span style="margin-left:auto;cursor:pointer;color:#667;font-size:20px;line-height:1" onclick="piClose()">&times;</span></div>';
  951. h+='<div class="node-bar" style="border-bottom:1px solid #1a1a2e">'+ni('Type',p.t)+ni('Flags','0x'+(p.flags||0).toString(16))+ni('RefCount',p.ref)+ni('Usage',p.us.toFixed(1)+'%')+'</div>';
  952. let rows=[['Prev Page',p.pv],['Next Page',p.nx],['UseSize',p.use+' B'],['Usage',p.us.toFixed(1)+'%'],
  953. ['Role',esc(p.role||'-')],['Owner',esc(p.owner||'-')],
  954. ['In Free Chain',p.inFree?'<span class="badge active">yes</span>':'<span class="badge dropped">no</span>'],
  955. ['Allocated',p.isAlloc?'<span class="badge active">yes</span>':'<span class="badge dropped">no</span>']];
  956. h+='<table class="dtbl"><tbody>';
  957. rows.forEach(r=>{h+='<tr><td style="width:160px;color:#5b9bd5;font-weight:600">'+r[0]+'</td><td class="mono" style="color:#c8ccd4">'+r[1]+'</td></tr>'});
  958. h+='<tr><td style="color:#5b9bd5;font-weight:600">Raw Hex (first 64B)</td><td class="hex-data">'+esc(p.hex||'')+'</td></tr>';
  959. h+='</tbody></table>';
  960. let acts='';
  961. if(p.t===2)acts+='<button class="nav-btn" onclick="piClose();showDP('+p.id+')">Inspect DataPage &#x2192;</button>';
  962. if(p.t===3){
  963. let own=p.owner||'';
  964. if(S.tables.find(x=>x.name===own))acts+='<button class="nav-btn" onclick="piClose();showHeapPages(\''+esc(own)+'\')">Open Heap Chain &#x2192;</button>';
  965. }
  966. h+='<div class="nav-btns">'+acts+'<button class="nav-btn" onclick="piClose()">Close</button></div>';
  967. box.innerHTML=h;ov.appendChild(box);document.body.appendChild(ov);
  968. }).catch(e=>{alert('Page '+pid+': '+e.message)});
  969. }
  970. function piClose(){let o=document.getElementById('piOverlay');if(o)o.remove()}
  971. // ===== Tutorial System =====
  972. let TUT={step:0,total:0,steps:[]};
  973. function showTutorial(){
  974. TUT.step=0;
  975. TUT.steps=[
  976. {title:'Welcome to TsDB2 Analyzer',content:'This interactive tutorial will guide you through all features of the Analyzer. Click "Next" to begin.',action:null},
  977. {title:'Step 1: Overview Dashboard',content:'The Overview page shows database statistics: size, table count, page size, and a visual Page Map grid.',action:()=>showOverview(),highlight:'#pgMap'},
  978. {title:'Page Map Visualization',content:'Each small square represents one 16 KB page. Colors indicate type (Data/Table/Index/Heap/Free). Opacity shows usage rate. Hover over squares for details.',action:null,highlight:'#pgMap'},
  979. {title:'Step 2: HeaderPage',content:'View database header metadata: magic number, version, page size, free page chain, timestamps, and global flags.',action:()=>showHeader(),highlight:null},
  980. {title:'Step 3: Free Pages',content:'See all recycled pages in the free list. The grid highlights free pages in purple. Below is the linked list showing next pointers.',action:()=>showFreePages(),highlight:'#freeContent'},
  981. {title:'Step 4: Table Explorer',content:'Click any table in the sidebar to view its schema, indexes, and storage details.',action:()=>{if(S.tables.length>0)showTable(S.tables[0].name)},highlight:null},
  982. {title:'Master Registry & Schema Versions',content:'View field definitions (active/dropped), schema evolution history, and version-specific field layouts.',action:null,highlight:null},
  983. {title:'Index Navigation',content:'Click on an index card to explore its B+ tree structure. Primary indexes map keys to DataPages. Secondary indexes map keys to PKIDs.',action:null,highlight:null},
  984. {title:'B+ Tree Internal Nodes',content:'Internal nodes contain separator keys and child page pointers. Click child page links to navigate deeper into the tree.',action:null,highlight:null},
  985. {title:'B+ Tree Leaf Nodes',content:'Leaf nodes store actual data: Primary indexes have [Key → DataPage], Secondary indexes have [Key → PKID].',action:null,highlight:null},
  986. {title:'PKID to DataPage Jump',content:'In secondary index leaves, click the blue PKID value. It searches the primary B+ tree and jumps directly to the corresponding DataPage.',action:null,highlight:null},
  987. {title:'DataPage Details',content:'DataPages store row data with schema-aware decoding. Each row shows PK, schema version, decoded fields, and raw hex dump.',action:null,highlight:null},
  988. {title:'Heap Pages (Overflow)',content:'Large fields that overflow from DataPages are stored in Heap chains. View heap usage and overflow patterns.',action:()=>{if(S.tables.length>0&&S.tables[0].hp0)showHeapPages(S.tables[0].name)},highlight:null},
  989. {title:'Breadcrumb Navigation',content:'Use the breadcrumb trail at the top to navigate back through your exploration path. Click any previous step to jump back.',action:null,highlight:null},
  990. {title:'Congratulations!',content:'You have completed the TsDB2 Analyzer tutorial! You can now explore databases like a pro. Click "Change DB" to analyze another database file.',action:null,highlight:null}
  991. ];
  992. TUT.total=TUT.steps.length;
  993. renderTutStep();
  994. }
  995. function renderTutStep(){
  996. if(TUT.step>=TUT.total){return}
  997. const s=TUT.steps[TUT.step];
  998. if(s.action)s.action();
  999. setTimeout(()=>{
  1000. let overlay=document.createElement('div');
  1001. overlay.id='tutOverlay';
  1002. overlay.style.cssText='position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.7);z-index:9999;display:flex;align-items:center;justify-content:center';
  1003. let box=document.createElement('div');
  1004. box.style.cssText='background:#1a1a2e;border:2px solid #5b9bd5;border-radius:12px;padding:24px;max-width:600px;width:90%;box-shadow:0 10px 40px rgba(0,0,0,0.8)';
  1005. let h='<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">';
  1006. h+='<span style="font-size:18px;font-weight:700;color:#5b9bd5">'+esc(s.title)+'</span>';
  1007. h+='<span style="font-size:12px;color:#667">'+(TUT.step+1)+'/'+TUT.total+'</span></div>';
  1008. h+='<div style="color:#aab;line-height:1.6;font-size:14px;margin-bottom:20px">'+esc(s.content)+'</div>';
  1009. h+='<div style="display:flex;gap:10px;justify-content:flex-end">';
  1010. if(TUT.step>0)h+='<button onclick="tutPrev()" style="padding:8px 16px;background:#1a1a30;border:1px solid #2a2a4a;color:#8890a0;border-radius:6px;cursor:pointer;font-size:13px">&#x2190; Prev</button>';
  1011. if(TUT.step<TUT.total-1)h+='<button onclick="tutNext()" style="padding:8px 16px;background:#5b9bd5;border:none;color:#fff;border-radius:6px;cursor:pointer;font-size:13px;font-weight:600">Next &#x2192;</button>';
  1012. else h+='<button onclick="tutClose()" style="padding:8px 16px;background:#4ecdc4;border:none;color:#fff;border-radius:6px;cursor:pointer;font-size:13px;font-weight:600">Finish &#x2714;</button>';
  1013. h+='</div>';
  1014. box.innerHTML=h;
  1015. overlay.appendChild(box);
  1016. document.body.appendChild(overlay);
  1017. if(s.highlight){
  1018. const el=document.querySelector(s.highlight);
  1019. if(el){el.scrollIntoView({behavior:'smooth',block:'center'});el.style.boxShadow='0 0 0 3px #f7b731,0 0 20px rgba(247,183,49,0.5)';setTimeout(()=>{el.style.boxShadow=''},3000)}
  1020. }
  1021. },100);
  1022. }
  1023. function tutNext(){const o=document.getElementById('tutOverlay');if(o)o.remove();TUT.step++;renderTutStep()}
  1024. function tutPrev(){const o=document.getElementById('tutOverlay');if(o)o.remove();TUT.step--;renderTutStep()}
  1025. function tutClose(){const o=document.getElementById('tutOverlay');if(o)o.remove()}
  1026. renderFB();
  1027. </script>
  1028. </body>
  1029. </html>