| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062 |
- <!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>TsDB2 Analyzer v3</title>
- <style>
- *{box-sizing:border-box;margin:0;padding:0}
- body{font-family:'Segoe UI',system-ui,-apple-system,sans-serif;background:#0b0b14;color:#c8ccd4;min-height:100vh;overflow-x:hidden}
- ::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:#12121f}::-webkit-scrollbar-thumb{background:#2a2a4a;border-radius:3px}
- a{color:#5b9bd5;text-decoration:none}
- .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}
- .hdr .logo{display:flex;align-items:center;gap:10px;font-size:16px;font-weight:700;color:#5b9bd5}
- .hdr .meta{display:flex;align-items:center;gap:16px;font-size:12px;color:#666}
- .hdr .meta .db-name{color:#8890a0;max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
- .hdr .btn{padding:5px 14px;background:#1a1a30;border:1px solid #2a2a4a;color:#8890a0;border-radius:4px;cursor:pointer;font-size:12px;transition:all .2s}
- .hdr .btn:hover{background:#222240;color:#aab;border-color:#3a3a5a}
- .main{display:flex;height:calc(100vh - 52px)}
- .sidebar{width:240px;min-width:240px;background:#0e0e1a;border-right:1px solid #1a1a2e;overflow-y:auto;display:flex;flex-direction:column}
- .sidebar .sec-title{padding:12px 16px 6px;font-size:10px;text-transform:uppercase;letter-spacing:1.5px;color:#444;font-weight:600}
- .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}
- .sidebar .tbl-item:hover{background:#141428;color:#aab}
- .sidebar .tbl-item.active{background:#141428;color:#5b9bd5;border-left-color:#5b9bd5}
- .sidebar .tbl-item .ico{font-size:14px;opacity:.6}
- .sidebar .tbl-item .cnt{margin-left:auto;font-size:10px;color:#445;background:#1a1a2e;padding:1px 6px;border-radius:8px}
- .content{flex:1;overflow-y:auto;padding:20px 28px}
- .bc{display:flex;align-items:center;gap:6px;padding:0 0 16px;font-size:12px;color:#556}
- .bc .item{cursor:pointer;color:#5b9bd5;transition:color .15s}.bc .item:hover{color:#7bb}
- .bc .sep{color:#334}.bc .cur{color:#8890a0}
- .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:14px;margin-bottom:20px}
- .card{background:#111122;border:1px solid #1a1a2e;border-radius:8px;padding:16px 18px;position:relative;overflow:hidden}
- .card::before{content:'';position:absolute;top:0;left:0;right:0;height:2px}
- .card.c1::before{background:linear-gradient(90deg,#5b9bd5,#4a8bc4)}.card.c2::before{background:linear-gradient(90deg,#4ecdc4,#3dbdb5)}
- .card.c3::before{background:linear-gradient(90deg,#f7b731,#e6a620)}.card.c4::before{background:linear-gradient(90deg,#a78bfa,#8b5cf6)}
- .card.c5::before{background:linear-gradient(90deg,#e74c3c,#c0392b)}
- .card .lb{font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#556;margin-bottom:6px}
- .card .val{font-size:28px;font-weight:700;color:#e0e4ec}.card .sub{font-size:11px;color:#556;margin-top:4px}
- .sec{background:#0f0f1e;border:1px solid #1a1a2e;border-radius:8px;margin-bottom:16px;overflow:hidden}
- .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}
- .sec-hd .dot{width:8px;height:8px;border-radius:50%;background:#5b9bd5}
- .sec-bd{padding:0}
- .dtbl{width:100%;border-collapse:collapse;font-size:12px}
- .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}
- .dtbl td{padding:7px 12px;border-bottom:1px solid #141428;color:#8890a0;vertical-align:middle}
- .dtbl tr:hover td{background:#111125}
- .dtbl .mono{font-family:'Cascadia Code','Fira Code',Consolas,monospace;font-size:11px}
- .dtbl .clickable{cursor:pointer;color:#5b9bd5;transition:color .15s}.dtbl .clickable:hover{color:#7bb;text-decoration:underline}
- .badge{display:inline-block;padding:2px 8px;border-radius:3px;font-size:10px;font-weight:600;letter-spacing:.5px}
- .badge.primary{background:rgba(91,155,213,.15);color:#5b9bd5}.badge.time{background:rgba(78,205,196,.15);color:#4ecdc4}
- .badge.string{background:rgba(247,183,49,.15);color:#f7b731}.badge.compose{background:rgba(167,139,250,.15);color:#a78bfa}
- .badge.btree{background:rgba(116,185,255,.15);color:#74b9ff}.badge.leaf{background:rgba(78,205,196,.15);color:#4ecdc4}
- .badge.internal{background:rgba(247,183,49,.15);color:#f7b731}.badge.data{background:rgba(91,155,213,.12);color:#5b9bd5}
- .badge.dropped{background:rgba(231,76,60,.15);color:#e74c3c}.badge.active{background:rgba(78,205,196,.15);color:#4ecdc4}
- .idx-list{padding:12px}
- .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}
- .idx-card:hover{border-color:#2a2a4a;background:#141428}
- .idx-card .idx-ico{width:32px;height:32px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:16px;background:#1a1a30}
- .idx-card .idx-info{flex:1}.idx-card .idx-name{font-size:13px;font-weight:600;color:#c8ccd4}
- .idx-card .idx-meta{font-size:11px;color:#556;margin-top:3px;display:flex;gap:12px}
- .idx-card .idx-arrow{color:#334;font-size:14px}
- .node-bar{display:flex;flex-wrap:wrap;gap:16px;padding:12px 16px;background:#0d0d1a;border-bottom:1px solid #1a1a2e;font-size:12px}
- .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}
- .nav-btns{display:flex;gap:8px;padding:12px 16px;justify-content:center}
- .nav-btn{padding:6px 16px;background:#1a1a30;border:1px solid #2a2a4a;color:#8890a0;border-radius:4px;cursor:pointer;font-size:12px;transition:all .2s}
- .nav-btn:hover{background:#222240;color:#aab;border-color:#3a3a5a}
- .empty{text-align:center;padding:40px 20px;color:#445}.empty .ico{font-size:36px;margin-bottom:10px;opacity:.4}.empty .msg{font-size:13px}
- .fb-overlay{position:fixed;inset:0;background:#0b0b14;z-index:200;display:flex;align-items:center;justify-content:center}
- .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}
- .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}
- .fb-path{padding:8px 16px;background:#0d0d1a;font-size:11px;color:#556;border-bottom:1px solid #1a1a2e;font-family:monospace;word-break:break-all}
- .fb-list{flex:1;overflow-y:auto;max-height:400px}
- .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}
- .fb-item:hover{background:#141428}.fb-item.sel{background:#141428;border-left:3px solid #5b9bd5}
- .fb-item .fi-ico{width:20px;text-align:center;font-size:14px}.fb-item .fi-sz{margin-left:auto;font-size:10px;color:#445}
- .fb-ft{padding:12px 16px;border-top:1px solid #1a1a2e;display:flex;gap:8px}
- .fb-ft button{flex:1;padding:8px;border:none;border-radius:5px;cursor:pointer;font-size:12px;font-weight:600;transition:all .2s}
- .fb-ft .b-up{background:#1a1a30;color:#8890a0}.fb-ft .b-up:hover{background:#222240}
- .fb-ft .b-open{background:#5b9bd5;color:#fff}.fb-ft .b-open:hover{background:#4a8bc4}
- .fb-ft .b-open:disabled{background:#1a1a30;color:#445;cursor:not-allowed}
- .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}
- .ubar{width:60px;height:4px;background:#1a1a2e;border-radius:2px;overflow:hidden;display:inline-block;vertical-align:middle;margin-left:6px}
- .ubar .fill{height:100%;border-radius:2px;background:#4ecdc4}
- .ver-card{background:#0d0d18;border:1px solid #1a1a2e;border-radius:6px;padding:10px 14px;margin-bottom:8px}
- .ver-card .ver-hd{display:flex;align-items:center;gap:8px;margin-bottom:6px}
- .ver-card .ver-num{font-size:14px;font-weight:700;color:#5b9bd5}
- .ver-card .ver-fields{display:flex;flex-wrap:wrap;gap:4px}
- .ver-card .ver-field{padding:2px 8px;border-radius:3px;font-size:11px;font-family:monospace}
- .ver-card .ver-field.active{background:rgba(78,205,196,.1);color:#4ecdc4;border:1px solid rgba(78,205,196,.2)}
- .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}
- .row-decoded{font-family:'Cascadia Code','Fira Code',Consolas,monospace;font-size:11px;color:#4ecdc4;line-height:1.6;max-width:600px}
- .row-decoded .fld-name{color:#5b9bd5}.row-decoded .fld-val{color:#c8ccd4}
- .pmgrid{position:relative;height:440px;overflow:auto;background:#0d0d1a;border:1px solid #1a1a2e;border-radius:6px}
- .pmspacer{position:relative;width:100%}
- .pmcv{position:absolute;left:0;top:0;display:block}
- .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}
- .pmbar{height:3px;background:#1a1a2e;border-radius:2px;overflow:hidden;margin-top:8px;max-width:340px}
- .pmbar>div{height:100%;background:linear-gradient(90deg,#5b9bd5,#4ecdc4);width:0;transition:width .2s}
- @media(max-width:900px){.sidebar{width:200px;min-width:200px}}
- </style>
- </head>
- <body>
- <div id="app"></div>
- <script>
- const $=id=>document.getElementById(id);
- const esc=s=>s?s.replace(/</g,'<').replace(/>/g,'>'):'';
- 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'}
- // escAttr 放进 HTML 属性的文本: esc 只挡 < >, 而属性里还得挡住引号 ——
- // 截断后的键 / 原始十六进制全文要放 title, 值里带一个 " 就会把属性提前截断。
- const escAttr=s=>esc(s).replace(/"/g,'"').replace(/'/g,''');
- // clip 长文本截断显示: 宽字符串列会把键与原始十六进制推到几千字符 (1024 字节的键 =
- // 2048 个十六进制字符), 原样塞进单元格会把表格拉成一条长带。截断但保留全文 (调用方放
- // title), 并注明原始长度 —— 截断只该省地方, 不该让人以为数据本来就短。
- 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>'}
- function ni(lb,val){return '<div class="ni"><span class="ni-lb">'+lb+'</span><span class="ni-val">'+val+'</span></div>'}
- 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>'}
- const ITYPE={1:'Time',2:'Primary',3:'String',4:'Compose',5:'BTree',6:'Hash'};
- const ICOLOR={1:'time',2:'primary',3:'string',4:'compose',5:'btree',6:'btree'};
- let S={view:'fb',db:'',tables:[],cur:null,bc:[],idxType:0,idxPath:[],curIdxName:''};
- let FB={path:'',sel:''};
- let ovTok=0; // Overview 渲染令牌: 审计轮询/重绘时用于判定视图是否已切换
- let auTok=0; // Audit 渲染令牌: 索引内容增量补全期间轮询重绘时用于判定视图是否已切换
- // ===== Page Map: 分块懒加载 + canvas 虚拟化渲染 =====
- // 旧实现是 fetch('/api/pages') 等后端把 19.5 万页全算完, 再拼 19.5 万个 <div>
- // 塞进 innerHTML —— 光 HTML 字符串就上百 MB, 页面必卡死甚至崩。
- // 现在:
- // 1) 后端 /api/pagemap 按块返回 (每块 2 万页; 类型 1 字节 + 使用率 1 字节, base64);
- // 2) 前端分块拉取, 拉到哪块画哪块, 顶部进度条实时推进 (先有多少显示多少);
- // 3) 只画视口内的格子 —— 全库 19.5 万页也只有一块画布, DOM 里永远是 1 个 canvas。
- const PMT={0:'Free',1:'Table',2:'Data',3:'Heap',4:'IdxMeta',5:'IdxData',6:'Overflow(deprecated)',7:'WAL',8:'SchemaExt',9:'Blob',[-1]:'Never used',[-2]:'Reserved',[-3]:'Orphan (leaked)',[-4]:'Unknown',[-9]:'\u52a0\u8f7d\u4e2d\u2026'};
- const PMC={0:'#1a1a2e',1:'#f7b731',2:'#5b9bd5',3:'#4ecdc4',4:'#a78bfa',5:'#74b9ff',6:'#b8a04a',7:'#ff9f43',8:'#e879f9',9:'#2dd4bf',[-1]:'#101018',[-2]:'#40305a',[-3]:'#7a2626',[-4]:'#555555',[-9]:'#151520'};
- const KT={free:0,table:1,data:2,heap:3,imeta:4,idata:5,overflow:6,wal:7,sext:8,blob:9,never:-1,reserved:-2,orphan:-3,other:-4};
- const PMCELL=14,PMPITCH=PMCELL+2,PMCHUNK=20000;
- let PM=null,pmTok=0;
- const PMINFO=new Map(); // 单页归属缓存: 悬停时按需拉一页, 不再随页图整体传 19.5 万条
- 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}
- // pageGrid 在 mountId 里挂一个虚拟化页图。
- // opts: {info:信息栏元素id, legend:图例元素id, hl:Set<页号>, hlLabel, hlColor}
- function pageGrid(mountId,opts){
- const mount=$(mountId);if(!mount)return;
- opts=opts||{};
- const tok=++pmTok;
- PM={tok:tok,total:0,typ:null,usg:null,cols:1,loaded:0,
- hl:opts.hl||null,hlLabel:opts.hlLabel||'',hlColor:opts.hlColor||'#4ecdc4',hover:0};
- mount.innerHTML='<div class="pmgrid"><div class="pmspacer"><canvas class="pmcv"></canvas></div></div><div class="pmbar"><div></div></div>';
- PM.wrap=mount.querySelector('.pmgrid');
- PM.sp=mount.querySelector('.pmspacer');
- PM.cv=mount.querySelector('.pmcv');
- PM.bar=mount.querySelector('.pmbar>div');
- PM.ctx=PM.cv.getContext('2d');
- PM.info=opts.info?$(opts.info):null;
- PM.legend=opts.legend?$(opts.legend):null;
- PM.tip=document.createElement('div');PM.tip.className='pmtip';document.body.appendChild(PM.tip);
- PM.wrap.addEventListener('scroll',()=>{pmMove();pmDraw()},{passive:true});
- PM.cv.addEventListener('mousemove',pmHover);
- PM.cv.addEventListener('mouseleave',()=>{if(PM){PM.hover=0;PM.tip.style.display='none'}});
- PM.cv.addEventListener('click',e=>{const id=pmHit(e);if(id)showPageInfo(id)});
- pmFetch(1);
- }
- window.addEventListener('resize',()=>{if(PM)pmLayout()});
- function pmFetch(start){
- if(!PM)return;
- const st=PM;
- fetch('/api/pagemap?start='+start+'&count='+PMCHUNK).then(r=>r.json()).then(d=>{
- if(!st.wrap.isConnected||st.tok!==pmTok){st.tip.remove();return}
- if(d.e){if(st.info)st.info.textContent='Error: '+d.e;return}
- if(!d.total){if(st.info)st.info.textContent='No pages';return}
- if(!st.typ){
- st.total=d.total;
- st.typ=new Int8Array(st.total+1);st.typ.fill(-9);
- st.usg=new Uint8Array(st.total+1);
- pmLegend(d.counts);pmLayout();
- }
- if(d.count){
- const t=pmB64(d.t),u=pmB64(d.u);
- for(let i=0;i<d.count;i++){st.typ[d.start+i]=t[i]-8;st.usg[d.start+i]=u[i]}
- st.loaded+=d.count;
- }
- if(st.info)st.info.textContent=d.done
- ? (st.total+' pages \u00d7 16 KB \u00b7 \u6bcf\u683c\u53ef\u70b9\u51fb\u67e5\u770b\u5f52\u5c5e')
- : ('\u5df2\u52a0\u8f7d '+st.loaded+' / '+st.total+' \u9875 ('+Math.round(st.loaded/st.total*100)+'%) \u00b7 \u8fb9\u62c9\u8fb9\u753b\u2026');
- if(st.bar)st.bar.style.width=Math.round(st.loaded/st.total*100)+'%';
- pmDraw();
- if(d.done){if(st.bar)st.bar.parentNode.style.display='none'}
- else pmFetch(d.start+d.count);
- }).catch(e=>{if(st.wrap.isConnected&&st.info)st.info.textContent='Error: '+e.message});
- }
- function pmLegend(counts){
- if(!PM||!PM.legend||!counts)return;
- let lg='';
- 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>';
- PM.legend.innerHTML=lg;
- }
- // pmLayout 按容器宽度算列数, 用 spacer 撑出总高度, 画布固定成视口大小。
- function pmLayout(){
- if(!PM||!PM.total)return;
- const st=PM,w=Math.max(240,st.wrap.clientWidth-2);
- st.cols=Math.max(1,Math.floor((w-2)/PMPITCH));
- st.sp.style.height=(Math.ceil(st.total/st.cols)*PMPITCH+2)+'px';
- const h=Math.max(80,st.wrap.clientHeight);
- st.cv.width=w;st.cv.height=h;
- st.cv.style.width=w+'px';st.cv.style.height=h+'px';
- pmMove();pmDraw();
- }
- function pmMove(){if(PM)PM.cv.style.top=PM.wrap.scrollTop+'px'}
- // pmDraw 只画视口内的行 —— 这是"不卡死"的关键: 每帧最多几百个格子。
- function pmDraw(){
- if(!PM||!PM.typ)return;
- const st=PM,ctx=st.ctx,cv=st.cv,top=st.wrap.scrollTop,h=cv.height;
- ctx.clearRect(0,0,cv.width,h);
- const first=Math.max(0,Math.floor(top/PMPITCH)),last=Math.floor((top+h)/PMPITCH);
- for(let row=first;row<=last;row++){
- const base=row*st.cols,y=row*PMPITCH-top;
- for(let col=0;col<st.cols;col++){
- const pid=base+col+1;
- if(pid>st.total)break;
- const t=st.typ[pid];
- const c=(st.hl&&st.hl.has(pid))?st.hlColor:(PMC[t]||'#151520');
- ctx.globalAlpha=t<0?1:(st.usg[pid]>50?1:(st.usg[pid]>20?.6:.3));
- ctx.fillStyle=c;
- ctx.fillRect(col*PMPITCH,y,PMCELL,PMCELL);
- }
- }
- ctx.globalAlpha=1;
- }
- function pmHit(e){
- if(!PM||!PM.total)return 0;
- const r=PM.cv.getBoundingClientRect(),x=e.clientX-r.left,y=e.clientY-r.top;
- if(x<0||y<0)return 0;
- const col=Math.floor(x/PMPITCH);
- if(col<0||col>=PM.cols)return 0;
- const pid=Math.floor((PM.wrap.scrollTop+y)/PMPITCH)*PM.cols+col+1;
- return pid<=PM.total?pid:0;
- }
- function pmHover(e){
- if(!PM)return;
- const id=pmHit(e);
- if(!id){PM.hover=0;PM.tip.style.display='none';return}
- PM.hover=id;
- const info=PMINFO.get(id);
- PM.tip.textContent=pmTipText(id,info);
- PM.tip.style.display='block';
- PM.tip.style.left=Math.min(e.clientX+14,innerWidth-240)+'px';
- PM.tip.style.top=Math.min(e.clientY+14,innerHeight-110)+'px';
- if(info===undefined){
- PMINFO.set(id,null); // 占位, 防止同一格的悬停风暴打出一串重复请求
- fetch('/api/page-info?id='+id).then(r=>r.json()).then(d=>{
- PMINFO.set(id,d||{});
- if(PM&&PM.hover===id)PM.tip.textContent=pmTipText(id,d||{});
- }).catch(()=>{PMINFO.set(id,{})});
- }
- }
- function pmTipText(id,info){
- const t=PM.typ?PM.typ[id]:-9;
- let s='Page #'+id+'\nType: '+(PMT[t]!==undefined?PMT[t]:'?'+t)+'\nUsage: '+(PM.usg?PM.usg[id]:0)+'%';
- if(PM.hl&&PM.hl.has(id))s+='\n\u2605 '+PM.hlLabel;
- s+=info===undefined?('\nRole: \u2026\nOwner: \u2026'):('\nRole: '+((info&&info.role)||'-')+'\nOwner: '+((info&&info.owner)||'-'));
- return s;
- }
- // pmLimited 长列表分批渲染: 先画前 500 行, 其余交给"显示更多"按钮。
- // 空闲链动辄 3 万页, 一次性 innerHTML 同样会卡住页面。
- function pmLimited(tbodyId,moreId,rows,render){
- const body=$(tbodyId),btn=$(moreId);if(!body)return;
- const CH=500;let shown=0;
- const step=()=>{
- body.insertAdjacentHTML('beforeend',rows.slice(shown,shown+CH).map((r,i)=>render(r,shown+i)).join(''));
- shown=Math.min(shown+CH,rows.length);
- if(!btn)return;
- if(shown<rows.length){btn.textContent='\u663e\u793a\u66f4\u591a ('+shown+' / '+rows.length+')';btn.style.display=''}
- else btn.style.display='none';
- };
- if(btn)btn.onclick=step;
- step();
- }
- function renderFB(){
- document.getElementById('app').innerHTML=
- '<div class="fb-overlay"><div class="fb-box"><div class="fb-hd"><span style="font-size:20px">📊</span> TsDB2 Analyzer v3</div>'+
- '<div class="fb-path" id="fbp">Loading...</div><div class="fb-list" id="fbl"></div>'+
- '<div class="fb-ft"><button class="b-up" onclick="fbUp()">← Up</button>'+
- '<button class="b-open" id="fbOpen" onclick="fbOpen()" disabled>Open Database</button></div></div></div>';
- fbLoad('');
- }
- function fbLoad(p){
- fetch(p?'/api/list-dir?path='+encodeURIComponent(p):'/api/list-dir').then(r=>r.json()).then(d=>{
- if(d.err){alert(d.err);return}
- FB.path=d.p;FB.sel='';$('fbOpen').disabled=true;$('fbp').textContent=d.p;
- let h='';
- if(d.d)d.d.forEach(x=>{h+='<div class="fb-item" ondblclick="fbLoad(\''+esc(d.p+'/'+x.n).replace(/\\/g,'/')+'\')">'+
- '<span class="fi-ico">📁</span>'+esc(x.n)+'</div>'});
- if(d.f)d.f.forEach(x=>{const fp=d.p+'/'+x.n;
- h+='<div class="fb-item" onclick="fbSel(this,\''+esc(fp).replace(/\\/g,'/')+'\')">'+
- '<span class="fi-ico">📄</span>'+esc(x.n)+'<span class="fi-sz">'+fB(x.s)+'</span></div>'});
- $('fbl').innerHTML=h||'<div class="empty"><div class="ico">📭</div><div class="msg">Empty</div></div>';
- });
- }
- function fbSel(el,p){document.querySelectorAll('.fb-item').forEach(e=>e.classList.remove('sel'));el.classList.add('sel');FB.sel=p;$('fbOpen').disabled=false}
- function fbUp(){fetch('/api/list-dir?path='+encodeURIComponent(FB.path)).then(r=>r.json()).then(d=>{if(d.pp)fbLoad(d.pp)})}
- function fbOpen(){
- if(!FB.sel)return;
- const btn=$('fbOpen');btn.disabled=true;btn.textContent='Opening...';
- fetch('/api/open-db',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({p:FB.sel})})
- .then(r=>r.json()).then(d=>{
- if(d.ok){S.db=d.s.f;S.view='dash';loadDash(d.s)}
- else{alert('Error: '+d.e);btn.disabled=false;btn.textContent='Open Database'}
- }).catch(e=>{alert('Error: '+e);btn.disabled=false;btn.textContent='Open Database'});
- }
- function loadDash(s){
- 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>'+
- '<div class="meta"><span class="db-name">'+esc(s.f)+'</span><span>'+s.fs+'</span><span>'+s.pg+' pages</span>'+
- '<button class="btn" onclick="showTutorial()">📖 Tutorial</button>'+
- '<button class="btn" onclick="S.view=\'fb\';renderFB()">Change DB</button></div></div>';
- h+='<div class="main"><div class="sidebar" id="sidebar"><div class="sec-title">Explorer</div>'+
- '<div class="tbl-item" onclick="showOverview()" style="color:#5b9bd5"><span class="ico">📊</span>Overview</div>'+
- '<div class="tbl-item" onclick="showHeader()"><span class="ico">📑</span>HeaderPage</div>'+
- '<div class="tbl-item" onclick="showFreePages()"><span class="ico">♻</span>Free Pages</div>'+
- '<div class="sec-title">Audit</div>'+
- '<div class="tbl-item" onclick="showAudit()"><span class="ico">🔍</span>Full Audit</div>'+
- '<div class="tbl-item" onclick="showHeapChains()"><span class="ico">📦</span>Heap Chains</div>'+
- '<div class="tbl-item" onclick="showOrphans()"><span class="ico">🧹</span>Orphan Pages</div>'+
- '<div class="sec-title">Tables</div><div id="sideTabs"></div></div>';
- h+='<div class="content" id="content"></div></div>';
- document.getElementById('app').innerHTML=h;loadTables();showOverview(s);
- }
- function loadTables(){
- fetch('/api/tables').then(r=>r.json()).then(ts=>{
- S.tables=ts||[];let h='';
- S.tables.forEach(t=>{
- let mc=t.masterFields?t.masterFields.filter(f=>!f.dropped).length:0;
- h+='<div class="tbl-item" onclick="showTable(\''+esc(t.name)+'\')"><span class="ico">📋</span>'+esc(t.name)+
- '<span class="cnt">v'+(t.activeVer||1)+'</span></div>';
- });
- if($('sideTabs'))$('sideTabs').innerHTML=h;
- });
- }
- function showOverview(s){
- if(!s){fetch('/api/stats').then(r=>r.json()).then(d=>showOverview(d));return}
- const tok=++ovTok;
- const failed=s.auditFailed===true;
- const ready=s.auditReady!==false&&!failed;
- S.curIdxName='';S.idxPath=[];
- document.querySelectorAll('.tbl-item').forEach(e=>e.classList.remove('active'));
- const busy=failed?'<span style="color:#e05a5a">审计失败</span>':'<span style="color:#8a94a6">审计中…</span>';
- let h='<div class="bc"><span class="cur">Overview</span></div>';
- h+='<div class="stats">'+
- '<div class="card c1"><div class="lb">Database Size</div><div class="val">'+s.fs+'</div><div class="sub">'+s.pg+' pages</div></div>'+
- '<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>'+
- '<div class="card c3"><div class="lb">Free Pages</div><div class="val">'+(ready?(s.free||0):'—')+'</div><div class="sub">'+(ready?fB((s.free||0)*16384)+' recyclable':busy)+'</div></div>'+
- '<div class="card c4"><div class="lb">Heap Pages</div><div class="val">'+(ready?(s.heap||0):'—')+'</div><div class="sub">field overflow</div></div>'+
- '<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>'+
- '<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>';
- h+='<div class="sec"><div class="sec-hd"><div class="dot"></div>All Tables</div><div class="sec-bd">';
- if(!S.tables||!S.tables.length)h+='<div class="empty"><div class="msg">No tables</div></div>';
- else{
- 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>';
- S.tables.forEach(t=>{
- let mc=t.masterFields?t.masterFields.filter(f=>!f.dropped).length:0;
- let dc=t.masterFields?t.masterFields.filter(f=>f.dropped).length:0;
- h+='<tr style="cursor:pointer" onclick="showTable(\''+esc(t.name)+'\')">'+
- '<td style="color:#5b9bd5;font-weight:600">'+esc(t.name)+'</td><td>'+t.rowN+'</td>'+
- '<td><span class="badge active">v'+t.activeVer+'</span></td>'+
- '<td>'+mc+(dc>0?' <span class="badge dropped">'+dc+' dropped</span>':'')+'</td>'+
- '<td>'+(t.versions?t.versions.length:0)+'</td>'+
- '<td>'+(t.idxs?t.idxs.length:0)+'</td><td>'+t.hpc+'</td><td>'+t.fsz+' B</td></tr>';
- });
- h+='</tbody></table>';
- }
- h+='</div></div>';
- // Page visualization
- 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>';
- $('content').innerHTML=h;
- // 页图只依赖头部 + 空闲链 (见 analyzer/pagemap.go), 几秒就能出图, 不必陪全库审计
- // 等上一两分钟 —— 所以先挂上开始分块加载; 审计就绪后本函数会整体重绘一次
- // (届时服务端页索引已缓存, 重拉只是十来个毫秒级的分块请求)。
- pageGrid('pgMap',{info:'pgMapInfo',legend:'pgLegend'});
- // 审计状态单独一行, 免得把页图的加载进度覆盖掉。
- if(failed&&$('ovAudit'))$('ovAudit').textContent='审计失败: '+((s.auditError||'').split('\n')[0]||'unknown');
- // 全库审计尚未就绪: 轮询 /api/stats, 就绪后整体重绘 (含汇总卡片)。
- // 这样打开库不再被审计阻塞, 又能自动补齐汇总值; 审计失败则停止轮询并提示。
- // 大表审计要一两分钟, 所以预算给到 15 分钟, 不再 2 分钟就误报超时。
- if(!ready&&!failed){
- let n=0;
- const tm=setInterval(()=>{
- if(tok!==ovTok){clearInterval(tm);return}
- if(++n>1800){clearInterval(tm);if($('ovAudit'))$('ovAudit').textContent='审计超时';return}
- fetch('/api/stats').then(r=>r.json()).then(d=>{
- if(tok!==ovTok){clearInterval(tm);return}
- if(d&&(d.auditReady||d.auditFailed)){clearInterval(tm);showOverview(d)}
- }).catch(()=>{});
- },500);
- return;
- }
- }
- function showTable(nm){
- const t=S.tables.find(x=>x.name===nm);if(!t)return;
- S.cur=t;S.idxType=0;S.curIdxName='';S.idxPath=[];S.bc=[{l:nm,a:null}];
- document.querySelectorAll('.tbl-item').forEach(e=>{e.classList.remove('active');if(e.textContent.includes(nm))e.classList.add('active')});
- let h='<div class="bc"><span class="item" onclick="showOverview()">Overview</span><span class="sep">›</span><span class="cur">'+esc(nm)+'</span></div>';
- let mc=t.masterFields?t.masterFields.filter(f=>!f.dropped).length:0;
- let dc=t.masterFields?t.masterFields.filter(f=>f.dropped).length:0;
- h+='<div class="stats">'+
- '<div class="card c1"><div class="lb">Total Rows</div><div class="val">'+t.rowN+'</div></div>'+
- '<div class="card c2"><div class="lb">Active Version</div><div class="val">v'+t.activeVer+'</div></div>'+
- '<div class="card c3"><div class="lb">Active Fields</div><div class="val">'+mc+'</div>'+(dc>0?'<div class="sub">'+dc+' dropped</div>':'')+'</div>'+
- '<div class="card c4"><div class="lb">Schema Versions</div><div class="val">'+(t.versions?t.versions.length:0)+'</div></div>'+
- '<div class="card c5"><div class="lb">Indexes</div><div class="val">'+(t.idxs?t.idxs.length:0)+'</div></div></div>';
- // Master Registry
- h+='<div class="sec"><div class="sec-hd"><div class="dot" style="background:#f7b731"></div>Master Registry (Field History)</div><div class="sec-bd">'+
- '<table class="dtbl"><thead><tr><th>#</th><th>Name</th><th>Type</th><th>Size</th><th>Status</th></tr></thead><tbody>';
- if(t.masterFields)t.masterFields.forEach((f,i)=>{
- let st=f.dropped?'<span class="badge dropped">DROPPED</span>':'<span class="badge active">ACTIVE</span>';
- let nmStyle=f.dropped?'color:#e74c3c;text-decoration:line-through':'color:#5b9bd5;font-weight:600';
- // NULL / UNIQUE 由后端把 flag 字节拆好 (nullable / unique), 前端不再自己碰位掩码
- let fl=(f.nullable?' <span class="badge btree" title="可为 NULL (行内 NULL 位图据此置位)">NULL</span>':'')+
- (f.unique?' <span class="badge btree" title="列级唯一声明; 约束由唯一索引执行">UNIQUE</span>':'');
- // 徽章文本 (label) 与悬浮说明 (tip) 都由后端算好: Flag 低 5 位是【按类型重载】的一格
- // (decimal 是 scale, 字符串家族是宽度码), 前端若能拿到它, 迟早有人把宽度码当字节数显示 ——
- // "256byte(20)" 会被读成 20 字节, 而那一列真实宽度是 1024。字符串列的 label 因此直接
- // 带出行内宽度 (string(1024B)), 与右边 Size 列同源, 不再有"类型名与真实宽度打架"的情况。
- h+='<tr><td class="mono">'+i+'</td><td style="'+nmStyle+'">'+esc(f.name)+'</td>'+
- '<td><span class="badge primary" title="'+escAttr(f.tip||'')+'">'+esc(f.label||f.typeName)+'</span>'+fl+'</td>'+
- '<td title="行内宽度: 本列在每一行里固定占 '+f.sz+' 字节 (定长, 不足补零)">'+f.sz+' bytes</td><td>'+st+'</td></tr>';
- });
- h+='</tbody></table></div></div>';
- // Schema Versions
- 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">';
- if(t.versions)t.versions.forEach(v=>{
- let isAct=(v.ver===t.activeVer);
- h+='<div class="ver-card" style="'+(isAct?'border-color:#4ecdc4':'')+'">';
- h+='<div class="ver-hd"><span class="ver-num">Version '+v.ver+'</span>';
- if(isAct)h+='<span class="badge active">CURRENT</span>';
- h+='</div><div class="ver-fields">';
- if(v.fields)v.fields.forEach((fn,fi)=>{
- let isDrop=fn.includes('(dropped)');
- // 字段片只放名字 (一版几十列时再塞类型会糊成一片), 但把"类型 + 行内宽度"挂进 title:
- // v.indices 是本版字段在 masterFields 里的【数字下标数组】(后端保证, 见 VInfo.Indices) ——
- // 别当盘上字节用: 后端若退回 []byte, JSON 会变成 base64 字符串, v.indices[i] 取到的是
- // 第 i 个字符, masterFields['A'] 恒为 undefined, 整块说明静默消失 (只是不显示, 不报错)。
- let mi=(t.masterFields&&v.indices&&v.indices[fi]!=null&&v.indices[fi]<t.masterFields.length)?t.masterFields[v.indices[fi]]:null;
- let tip=mi?(mi.name+' · '+(mi.label||mi.typeName)+' · 行内 '+mi.sz+' 字节'+(mi.tip?'\n'+mi.tip:'')):'';
- h+='<span class="ver-field '+(isDrop?'dropped':'active')+'" title="'+escAttr(tip)+'">'+esc(fn)+'</span>';
- });
- h+='</div></div>';
- });
- h+='</div></div>';
- // Indexes
- 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">';
- if(!t.idxs||!t.idxs.length)h+='<div class="empty"><div class="msg">No indexes</div></div>';
- else t.idxs.forEach(idx=>{
- // 分量后缀上宽度: 索引键是各分量按【真实行内宽度】拼起来的, 宽字符串分量会让键变得很长
- // (B+ 树每页能放的条目数直接塌下去), 只看列名看不出这件事。
- const mf=fi=>t.masterFields&&t.masterFields[fi]?t.masterFields[fi]:null;
- const flds=(idx.flds||[]).map(fi=>{const m=mf(fi);return m?m.name+'('+m.sz+'B)':'?'}).join(', ');
- // 二级索引条目 = [各分量宽度之和][pkID 8B]; 行存储索引 (Time/Primary) 的键就是 pkID,
- // 行数据跟在键后面 —— 两种布局不同, 一律按分量求和会给出错的键长, 所以分开算。
- const secondary=(idx.type>=3&&idx.type<=6);
- const keyLen=secondary?(idx.flds||[]).reduce((a,fi)=>{const m=mf(fi);return a+(m?m.sz:0)},0)+8:0;
- let ic=ICOLOR[idx.type]||'primary';
- h+='<div class="idx-card" onclick="showIndex(\''+esc(idx.name)+'\','+idx.ep+','+idx.type+')">'+
- '<div class="idx-ico" style="background:rgba(91,155,213,.15)">'+(idx.type===2?'🔑':idx.type===1?'⏰':idx.type===3?'🔤':idx.type===5?'🌳':'🔗')+'</div>'+
- '<div class="idx-info"><div class="idx-name">'+esc(idx.name)+'</div>'+
- '<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>'+
- (secondary?'<span title="条目布局: 键(各分量按真实行内宽度拼接) + pkID(8B)。宽字符串分量会把键拉长, 每页条目数与树高都随之上升">Key: '+keyLen+' B</span>':'')+
- '<span>Root: '+idx.ep+'</span></div></div>'+
- '<div class="idx-arrow">›</div></div>';
- });
- h+='</div></div></div>';
- // Storage
- h+='<div class="sec"><div class="sec-hd"><div class="dot" style="background:#4ecdc4"></div>Storage</div><div class="sec-bd">'+
- '<div class="idx-card" onclick="showHeapPages(\''+esc(nm)+'\')"><div class="idx-ico" style="background:rgba(78,205,196,.15)">📦</div>'+
- '<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>'+
- '<div class="idx-arrow">›</div></div></div></div>';
- $('content').innerHTML=h;
- }
- function showIndex(nm,ep,it){S.idxType=it;S.curIdxName=nm;S.idxPath=[ep];openIdxPath()}
- // 重建整个索引视图外壳 (面包屑 + 内容区), 并加载当前路径末端页
- function openIdxPath(){
- let idxLike=(S.idxType===3||S.idxType===4);
- let h='<div class="bc" id="idxBC">'+idxBCHtml()+'</div>';
- 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>'+
- (idxLike?'Index':'B+ Tree')+' - '+esc(S.curIdxName)+'</div><div id="idxContent" style="padding:16px;color:#5b9bd5">Loading...</div></div>';
- $('content').innerHTML=h;
- loadIdxPage(S.idxPath[S.idxPath.length-1],S.idxType);
- }
- // 索引下钻路径面包屑: Overview > Table > Index > #root > #child ...
- function idxBCHtml(){
- let h='<span class="item" onclick="showOverview()">Overview</span><span class="sep">›</span>';
- if(S.cur)h+='<span class="item" onclick="showTable(\''+esc(S.cur.name)+'\')">'+esc(S.cur.name)+'</span><span class="sep">›</span>';
- h+='<span class="item" onclick="jumpIdxPath(0)">'+esc(S.curIdxName)+'</span>';
- const last=S.idxPath.length-1;
- S.idxPath.forEach((pid,i)=>{
- h+='<span class="sep">›</span>';
- if(i<last)h+='<span class="item" onclick="jumpIdxPath('+i+')">#'+pid+'</span>';
- else h+='<span class="cur">#'+pid+(i===0?' (root)':'')+'</span>';
- });
- return h;
- }
- // 回退到路径中的第 i 层 (0 = 根页)
- function jumpIdxPath(i){S.idxPath=S.idxPath.slice(0,i+1);openIdxPath()}
- // 深入子页: 压入路径
- function drillIdx(pid){S.idxPath.push(pid);openIdxPath()}
- // 同层前后翻页: 替换路径末端
- function gotoIdxSibling(pid){if(S.idxPath.length)S.idxPath[S.idxPath.length-1]=pid;openIdxPath()}
- function loadIdxPage(pid,it){
- fetch('/api/btree?id='+pid+'&t='+it).then(r=>r.json()).then(d=>{
- if(d.e){$('idxContent').innerHTML='<div class="empty"><div class="msg">Error: '+d.e+'</div></div>';return}
- let isBtree=(it===1||it===2);
- let isSBtree=(it===5);
- let h='<div class="node-bar"><div class="ni"><span class="ni-lb">Page</span><span class="ni-val">'+d.id+'</span></div>'+
- '<div class="ni"><span class="ni-lb">Type</span><span class="badge '+(d.lf?'leaf':'internal')+'">'+(d.lf?'Leaf':'Internal')+'</span></div>'+
- '<div class="ni"><span class="ni-lb">Entries</span><span class="ni-val">'+d.cn+'</span></div>'+
- '<div class="ni"><span class="ni-lb">Usage</span><span class="ni-val">'+d.us.toFixed(1)+'%</span>'+
- '<div class="ubar"><div class="fill" style="width:'+Math.min(d.us,100)+'%"></div></div></div></div>';
- 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>';
- 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>';
- h+='<table class="dtbl"><thead><tr><th style="width:40px">#</th><th>Key</th>';
- if(isBtree){
- h+='<th style="width:120px">'+(d.lf?'Data Page':'Child Page')+'</th>';
- }else if(isSBtree){
- h+='<th style="width:120px">'+(d.lf?'PKID':'Child Page')+'</th>';
- }else{
- h+='<th style="width:120px">PKID</th>';
- }
- h+='</tr></thead><tbody>';
- (d.ents||[]).forEach((e,i)=>{
- // 键按真实宽度拼, 宽字符串分量的 B+ 树叶条目键可达上千字节 → 十六进制上千字符;
- // 截断显示 (全文在 title 里), 否则一页几十条会把表格撑成一条竖带。
- 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>';
- if(isBtree){
- if(d.lf)h+='<td><span class="badge data clickable" onclick="showDP('+e.p+')">'+e.p+'</span></td>';
- else h+='<td><span class="badge primary clickable" onclick="drillIdx('+e.p+')">'+e.p+'</span></td>';
- }else if(isSBtree){
- if(d.lf)h+='<td class="mono" style="color:#5b9bd5;cursor:pointer" onclick="showPK('+e.dp+')" title="点击查看 DataPage">'+e.dp+'</td>';
- else h+='<td><span class="badge primary clickable" onclick="drillIdx('+e.p+')">'+e.p+'</span></td>';
- }else h+='<td class="mono" style="color:#667">'+e.dp+'</td>';
- h+='</tr>';
- });
- h+='</tbody></table>';
- let nav='';
- if(S.idxPath.length>1)nav+='<button class="nav-btn" onclick="jumpIdxPath('+(S.idxPath.length-2)+')">↩ Back</button>';
- if(d.nx)nav+='<button class="nav-btn" onclick="gotoIdxSibling('+d.nx+')">Next → #'+d.nx+'</button>';
- if(d.pv)nav+='<button class="nav-btn" onclick="gotoIdxSibling('+d.pv+')">← Prev #'+d.pv+'</button>';
- if(nav)h+='<div class="nav-btns">'+nav+'</div>';
- $('idxContent').innerHTML=h;
- });
- }
- function showDP(pid){
- let h='<div class="bc">';
- if(S.curIdxName&&S.idxPath.length)h+=idxBCHtml()+'<span class="sep">›</span><span class="cur">Data #'+pid+'</span>';
- else h+='<span class="cur">Data #'+pid+'</span>';
- h+='</div><div class="sec"><div class="sec-hd"><div class="dot" style="background:#4ecdc4"></div>Data Page #'+pid+'</div>'+
- '<div id="dpContent" style="padding:16px;color:#5b9bd5">Loading...</div></div>';
- $('content').innerHTML=h;
- fetch('/api/data-page?id='+pid).then(r=>r.json()).then(d=>{
- if(d.e){$('dpContent').innerHTML='<div class="empty"><div class="msg">Error</div></div>';return}
- let info='<div class="node-bar"><div class="ni"><span class="ni-lb">Page</span><span class="ni-val">'+d.id+'</span></div>'+
- '<div class="ni"><span class="ni-lb">Rows</span><span class="ni-val">'+d.cn+'</span></div>'+
- '<div class="ni"><span class="ni-lb">Min</span><span class="ni-val" style="color:#4ecdc4">'+d.mn+'</span></div>'+
- '<div class="ni"><span class="ni-lb">Max</span><span class="ni-val" style="color:#f7b731">'+d.mx+'</span></div>'+
- '<div class="ni"><span class="ni-lb">Usage</span><span class="ni-val">'+d.us.toFixed(1)+'%</span>'+
- '<div class="ubar"><div class="fill" style="width:'+Math.min(d.us,100)+'%"></div></div></div></div>';
- 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>';
- (d.rs||[]).forEach(r=>{
- rows+='<tr><td class="mono" style="color:#556">'+r.i+'</td>'+
- '<td class="mono" style="color:#f7b731;font-size:12px">'+r.pk+'</td>'+
- '<td><span class="badge active">v'+r.sv+'</span></td>'+
- '<td class="row-decoded">'+esc(r.decoded)+'</td>'+
- // 原始十六进制按整行长度算: 一行里有 1024/2048 字节的字符串列时是几千个字符,
- // 截断显示 (全文在 title 里); 解码列不截 —— 那正是要看的字符串内容本身。
- '<td class="hex-data" title="'+escAttr(r.h)+'">'+clip(r.h,240)+'</td></tr>';
- });
- rows+='</tbody></table>';
- let nav='';
- if(d.pv)nav+='<button class="nav-btn" onclick="showDP('+d.pv+')">← Prev #'+d.pv+'</button>';
- if(d.nx)nav+='<button class="nav-btn" onclick="showDP('+d.nx+')">Next → #'+d.nx+'</button>';
- $('dpContent').innerHTML=info+rows+(nav?'<div class="nav-btns">'+nav+'</div>':'');
- });
- }
- function showPK(pkid){
- // 找到当前表的主键索引根页
- let pkRoot=0;
- if(S.cur&&S.cur.idxs){
- S.cur.idxs.forEach(idx=>{if(idx.type===2)pkRoot=idx.ep});
- }
- if(!pkRoot){alert('No primary index found');return}
- let h='<div class="bc">';
- if(S.curIdxName&&S.idxPath.length)h+=idxBCHtml()+'<span class="sep">›</span>';
- h+='<span class="cur">PK #'+pkid+'</span></div>';
- h+='<div class="sec"><div class="sec-hd"><div class="dot" style="background:#5b9bd5"></div>Locating PK #'+pkid+'...</div>';
- h+='<div id="dpContent" style="padding:16px;color:#5b9bd5">Searching primary B+ tree...</div></div>';
- $('content').innerHTML=h;
- fetch('/api/find-pk?pk='+pkid+'&root='+pkRoot).then(r=>r.json()).then(d=>{
- if(d.e){$('dpContent').innerHTML='<div class="empty"><div class="msg">'+d.e+'</div></div>';return}
- showDP(d.dp);
- });
- }
- function showHeader(){
- document.querySelectorAll('.tbl-item').forEach(e=>e.classList.remove('active'));
- S.bc=[{l:'HeaderPage',a:null}];
- let h='<div class="bc"><span class="item" onclick="showOverview()">Overview</span><span class="sep">›</span><span class="cur">HeaderPage</span></div>';
- h+='<div class="sec"><div class="sec-hd"><div class="dot" style="background:#4ecdc4"></div>Database Header</div>';
- h+='<div id="hdrContent" style="padding:16px;color:#5b9bd5">Loading...</div></div>';
- $('content').innerHTML=h;
- fetch('/api/header').then(r=>r.json()).then(d=>{
- if(d.e){$('hdrContent').innerHTML='<div class="empty"><div class="msg">Error</div></div>';return}
- let rows=[['Magic',d.magic],['Version','v'+d.ver],['Page Size',d.pgsz+' bytes ('+(d.pgsz/1024)+' KB)'],
- ['Data Pages (actual)',d.pg],['Next Free Page',d.nf],['Allocated Pages',d.ap+' (2 .. '+(d.nf-1)+')'],
- ['Never Used Pages',d.nu],
- ['Table Page (root)',d.tp],['Table Count',d.tc],
- ['Create Time',d.ct],['Modify Time',d.mt],
- ['Free Chain Start',d.fs],['Free Chain End',d.fe],['Free Count (declared)',d.fc],['Free Walked (actual)',d.fw],
- ['Global Flags',d.gf],['WAL First Page',d.wal0],['WAL Last Page',d.wal1],['Next Txn ID',d.txn],
- ['Header CRC',(d.crc?('0x'+d.crc.toString(16).toUpperCase().padStart(8,'0')):'(none)')+' — '+d.cv],
- ['Comment',d.cm||'(empty)']];
- let t='<table class="dtbl"><thead><tr><th style="width:200px">Field</th><th>Value</th></tr></thead><tbody>';
- 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>'});
- t+='</tbody></table>';
- let fcOk=d.fv==='OK';
- t+='<div class="ver-card" style="margin:14px 0 0;border-color:'+(fcOk?'#4ecdc4':'#e74c3c')+'">'+
- '<div class="ver-hd"><span style="font-size:13px;font-weight:700;color:'+(fcOk?'#4ecdc4':'#e74c3c')+'">Free Chain Self-Check</span>'+
- '<span class="badge '+(fcOk?'active':'dropped')+'" style="margin-left:6px">'+esc(d.fv||'-')+'</span></div>'+
- '<div style="font-size:12px;color:#8890a0;line-height:1.6">'+esc(d.fd||'')+'</div>'+
- '<div class="node-bar" style="background:transparent;border:none;padding:10px 0 0">'+
- ni('Declared',d.fc)+ni('Walked',d.fw)+ni('Chain Start',d.fs)+ni('Chain End',d.fe)+'</div></div>';
- $('hdrContent').innerHTML=t;
- });
- }
- function showHeapPages(nm){
- const t=S.tables.find(x=>x.name===nm);if(!t)return;S.cur=t;
- S.bc=[{l:nm,a:()=>showTable(nm)},{l:'Heap Pages',a:null}];
- let h='<div class="bc"><span class="item" onclick="showOverview()">Overview</span><span class="sep">›</span>'+
- '<span class="item" onclick="showTable(\''+esc(nm)+'\')">'+esc(nm)+'</span><span class="sep">›</span><span class="cur">Heap Pages</span></div>';
- 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>';
- h+='<div style="padding:12px 12px 0"><div id="heapMap"></div></div>';
- h+='<div id="heapContent" style="padding:0 12px 12px"></div></div>';
- $('content').innerHTML=h;
- fetch('/api/heap-chain?id='+t.hp0).then(r=>r.json()).then(hd=>{
- let actual=hd.pages?hd.pages.length:0;
- let declared=t.hpc||0;
- let mis=declared!==actual;
- // 交叉校验: TableInfo.HeapPageCount (声明值) vs 实走堆链得到的长度 (实际值)。
- // 三态: OK 一致;
- // STALE 声明为 0 而链非空 —— 该表建于计数器引入之前, 引擎会退化为逐页回收, 数据安全;
- // MISMATCH 非零却对不上 —— 计数器真失真 (偏高=页泄漏, 偏低=页被提前回收)。
- let badge=mis
- ? (declared===0
- ? '<span class="badge btree" title="HeapPageCount=0: 该表建于本次改动之前, 计数器从未初始化。引擎会退化为逐页回收, 数据安全。">STALE</span>'
- : '<span class="badge dropped" title="HeapPageCount 与实走链长不一致, 计数器已失真">MISMATCH</span>')
- : '<span class="badge active">OK</span>';
- let color=mis?(declared===0?'#f7b731':'#e74c3c'):'#4ecdc4';
- $('heapInfo').innerHTML=actual+' pages \u00d7 16 KB | declared: <b style="color:'+color+'">'+declared+'</b> '+badge;
- if(!actual){$('heapContent').innerHTML='<div class="empty"><div class="ico">📦</div><div class="msg">No heap pages</div></div>';return}
- // 页图改用 canvas 虚拟化 + 分块懒加载: 只画视口内的格子, 堆页青色高亮。
- pageGrid('heapMap',{hl:new Set(hd.pages.map(p=>p.id)),hlLabel:'Heap Page',hlColor:'#4ecdc4'});
- $('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>';
- pmLimited('heapBody','heapMore',hd.pages,p=>{
- let us=(p.use/16384*100).toFixed(1);
- let rc=p.ref||0;
- 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>'+
- '<td><span class="badge '+(rc>0?'active':'dropped')+'">'+rc+'</span></td></tr>';
- });
- }).catch(e=>{$('heapContent').innerHTML='<div style="color:#e74c3c">Error: '+e.message+'</div>'});
- }
- function showFreePages(){
- document.querySelectorAll('.tbl-item').forEach(e=>e.classList.remove('active'));
- S.bc=[{l:'Free Pages',a:null}];
- let h='<div class="bc"><span class="item" onclick="showOverview()">Overview</span><span class="sep">›</span><span class="cur">Free Pages</span></div>';
- 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>';
- h+='<div style="padding:12px 12px 0"><div id="freeMap"></div></div>';
- h+='<div id="freeContent" style="padding:0 12px 12px"></div></div>';
- $('content').innerHTML=h;
- fetch('/api/free-pages').then(r=>r.json()).then(fd=>{
- if(!fd.pages||!fd.pages.length){$('freeContent').innerHTML='<div class="empty"><div class="ico">♻</div><div class="msg">No recycled pages</div></div>';return}
- const all=fd.pages;
- $('freeInfo').textContent=fd.total+' free pages \u00d7 16 KB = '+fB(fd.total*16384);
- // 页图改用 canvas 虚拟化 + 分块懒加载, 空闲页紫色高亮。
- pageGrid('freeMap',{hl:new Set(all),hlLabel:'Free Page',hlColor:'#a78bfa'});
- $('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>';
- 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>');
- }).catch(e=>{$('freeContent').innerHTML='<div style="color:#e74c3c">Error: '+e.message+'</div>'});
- }
- function navBC(i){S.bc=S.bc.slice(0,i+1);S.bc[i].a()}
- // ===== Audit / Heap Chains / Orphan Pages =====
- // renderOrphanRows 把一批页号渲染成表格行 (每个页号查一次 /api/page-info)。
- // 之后可以在表尾追加一行占位说明 (见 loadOrphanRows 的截断提示 / 分页脚)。
- function renderOrphanRows(ids,targetId,foot){
- if(!ids||!ids.length){let el=$(targetId);if(el)el.innerHTML='<tr><td colspan="7" style="color:#4ecdc4">None \u2014 所有页都归属明确</td></tr>';return}
- Promise.all(ids.map(id=>fetch('/api/page-info?id='+id).then(x=>x.json()).catch(()=>({id:id,e:'?'})))).then(list=>{
- let tb='';
- list.forEach(p=>{
- tb+='<tr><td class="mono" style="color:#e74c3c;font-weight:600">'+p.id+'</td>'+
- '<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>'+
- '<td>'+(p.us!=null?p.us.toFixed(1)+'%':'-')+'</td>'+
- '<td class="mono">'+esc(p.role||'-')+'</td><td>'+esc(p.owner||'-')+'</td>'+
- '<td>'+(p.isAlloc?'<span class="badge active">yes</span>':'<span class="badge dropped">no</span>')+'</td>'+
- '<td><span class="clickable" onclick="showPageInfo('+p.id+')">inspect</span></td></tr>';
- });
- if(foot)tb+=foot;
- let el=$(targetId);if(el)el.innerHTML=tb||'<tr><td colspan="7">-</td></tr>';
- });
- }
- // loadOrphanRows 批量查未归属页的页头, 填进指定 tbody。
- function loadOrphanRows(ids,targetId){
- // 每个孤儿页都要单独发一次 /api/page-info: 一次并发几千个请求同样会把浏览器拖垮,
- // 所以只查前 300 个, 剩余的在表尾注明 (整页浏览走分页接口, 见 loadUnaccPage)。
- const CAP=300,head=(ids||[]).slice(0,CAP);
- if(!head.length){let el=$(targetId);if(el)el.innerHTML='<tr><td colspan="7" style="color:#4ecdc4">None \u2014 所有页都归属明确</td></tr>';return}
- let foot='';
- if(ids.length>CAP)foot='<tr><td colspan="7" style="color:#f7b731">\u2026 共 '+ids.length+' 页, 为避免拖垮浏览器仅展示前 '+CAP+' 页</td></tr>';
- renderOrphanRows(head,targetId,foot);
- }
- // orphanPageArg 把一次分页请求的 offset/limit 存下来, 供翻页按钮复用。
- var orphanPageArg={off:0,lim:50,total:0};
- // loadUnaccPage 从落盘缓存库分页取孤儿页 (不必把明细整份塞进浏览器), 支持上一页/下一页。
- function loadUnaccPage(total,targetId,off){
- const lim=50;if(off<0)off=0;
- fetch('/api/audit/unacc?offset='+off+'&limit='+lim).then(x=>x.json()).then(d=>{
- if(d.e||!d.rows){loadOrphanRows(null,targetId);return} // 缓存未就绪时兜底
- orphanPageArg={off:off,lim:lim,total:d.total||0};
- const from=d.total?off+1:0,to=Math.min(off+lim,d.total);
- let foot='';
- if(d.total>lim){
- 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>';
- 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>';
- foot='<tr><td colspan="7" style="color:#8890a0">'+prev+' · '+(from+'-'+to)+' / '+d.total+' · '+next+'</td></tr>';
- }
- renderOrphanRows(d.rows,targetId,foot);
- });
- }
- // renderIssueRows 渲染索引内容问题明细 (内存兜底版与分页版共用)。
- function renderIssueRows(rows,targetId,foot){
- let tb='';
- (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>'});
- if(!tb)tb='<tr><td colspan="6" style="color:#556">-</td></tr>';
- if(foot)tb+=foot;
- let el=$(targetId);if(el)el.innerHTML=tb;
- }
- // loadIssuePage 从落盘缓存库分页取索引问题明细; 缓存未就绪时回退到内存里的前若干条。
- function loadIssuePage(total,fallback,targetId,off){
- const lim=50;if(off<0)off=0;
- fetch('/api/audit/issues?offset='+off+'&limit='+lim).then(x=>x.json()).then(d=>{
- if(d.e||!d.rows){
- const fb=(fallback||[]).slice(0,60);
- renderIssueRows(fb,targetId,(total>60)?'<tr><td colspan="6" style="color:#556">\u2026 明细已截断 (以后端计数为准)</td></tr>':'');
- return;
- }
- let foot='';
- if(d.total>lim){
- const from=d.total?off+1:0,to=Math.min(off+lim,d.total);
- 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>';
- 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>';
- foot='<tr><td colspan="6" style="color:#8890a0">'+prev+' · '+(from+'-'+to)+' / '+d.total+' · '+next+'</td></tr>';
- }
- renderIssueRows(d.rows,targetId,foot);
- });
- }
- function showAudit(){
- document.querySelectorAll('.tbl-item').forEach(e=>e.classList.remove('active'));
- const tok=++auTok;
- let h='<div class="bc"><span class="item" onclick="showOverview()">Overview</span><span class="sep">›</span><span class="cur">Full Audit</span></div>';
- 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>';
- $('content').innerHTML=h;
- loadAudit(tok);
- }
- // loadAudit 拉取审计结果并渲染。索引内容一致性是后台增量补全的 (idxPending),
- // 未就绪时先渲染"校验中"并轮询 (其它结论: 页账 / 空闲链 / 交叉引用 已就绪)。
- function loadAudit(tok){
- if(tok!==auTok||!$('auContent'))return;
- fetch('/api/audit').then(r=>r.json()).then(a=>{
- if(tok!==auTok||!$('auContent'))return;
- if(a.e){$('auContent').innerHTML='<div class="empty"><div class="msg">'+esc(a.e)+'</div></div>';return}
- let vc=a.verdict==='OK'?'#4ecdc4':((a.verdict==='UNACCOUNTED'||a.verdict==='CORRUPT')?'#e74c3c':'#f7b731');
- $('auInfo').innerHTML='<span style="color:'+vc+';font-weight:700">'+a.verdict+'</span>';
- let r=a.reach||{},fr=a.free||{};
- 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>'+
- '<span style="font-size:11px;color:#556">'+esc(a.file||'')+'</span>'+
- '<span style="font-size:11px;color:#556;margin-left:auto">'+fB(a.bytes||0)+'</span></div>';
- 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=>'• '+esc(n)).join('<br>')+'</div>';
- o+='</div>';
- o+='<div class="stats">'+
- bigCard('c1','Total Pages',a.totalPages,'16 KB \u00d7 N')+
- bigCard('c2','Allocated',a.allocated,'page 2 .. '+(a.firstFree-1))+
- bigCard('c2','Reachable',r.reachable,'被表/索引引用')+
- bigCard('c5','Unaccounted',r.unaccounted,'孤儿页')+
- bigCard('c4','Free',fr.total,'空闲链')+
- bigCard('c3','Reserved',a.reserved,'页 1 引擎保留')+
- bigCard('c3','Never Used',a.neverUsed,'未分配尾页')+
- '</div>';
- let fv=fr.verdict==='OK'?'active':'dropped';
- 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">';
- o+='<div class="node-bar" style="background:transparent;border:none;padding:0 0 10px">'+
- 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>';
- o+='<div style="font-size:12px;color:#8890a0">'+esc(fr.detail||'')+'</div></div></div>';
- // ---- 完整性交叉校验 (对照 SQLite integrity_check / PG amcheck) ----
- let cl=a.crossLinks||[],fl=a.freeLive||[],brs=a.badRefs||[];
- let cCount=a.multiRef||0,bCount=a.badRefCount||0,bad=(cCount||fl.length||bCount);
- 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">';
- 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>';
- if(cCount){
- o+='<div style="font-size:12px;color:#aab;margin:4px 0">同一页被两个不同主人认领 (已排除同表内 数据页+索引叶 的正常 fan-in):</div>';
- o+='<table class="dtbl"><thead><tr><th>Page</th><th>Type</th><th>Claims (role@owner)</th></tr></thead><tbody>';
- 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(' | ')+'</td></tr>'});
- o+='</tbody></table>'+(cCount>50?'<div style="font-size:11px;color:#556;margin-top:4px">... 仅显示前 50 / 共 '+cCount+'</div>':'');
- }
- if(fl.length){
- o+='<div style="font-size:12px;color:#aab;margin:10px 0 4px">既在空闲链、又被引用 (下次分配即被覆写):</div>';
- 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>';
- }
- if(bCount){
- o+='<div style="font-size:12px;color:#aab;margin:10px 0 4px">非法页指针 (指向文件外 / 保留页 1):</div>';
- o+='<table class="dtbl"><thead><tr><th>From</th><th>To</th><th>Kind</th><th>Role</th><th>Owner</th></tr></thead><tbody>';
- 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>'});
- o+='</tbody></table>'+(bCount>50?'<div style="font-size:11px;color:#556;margin-top:4px">... 仅显示前 50 / 共 '+bCount+'</div>':'');
- }
- if(!bad)o+='<div style="font-size:12px;color:#4ecdc4">通过 —— 无交叉引用 / 无空闲页被引用 / 无非法指针。</div>';
- o+='</div></div>';
- // ---- 索引内容一致性 (对照 PRAGMA integrity_check / amcheck) ----
- let iEnt=a.idxEntries||0,iBad=a.idxBadOrder||0,iOrph=a.idxOrphanPK||0,iNDP=a.idxNotDataPage||0;
- let iMR=a.idxMissingRow||0,iME=a.idxMissingEntry||0;
- let iIss=a.idxIssues||[],iBad2=(iBad||iOrph||iNDP||iMR||iME),iSkip=a.idxSkipped||'',iNote=a.idxNotes||[],iPend=a.idxPending===true;
- o+='<div class="sec"><div class="sec-hd"><div class="dot" style="background:'+(iPend?'#5b9bd5':(iSkip?'#f7b731':(iBad2?'#e74c3c':'#4ecdc4')))+'"></div>Index Consistency'
- +(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>')))
- +'<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">';
- if(iPend){o+='<div style="font-size:12px;color:#5b9bd5">正在逐条比对索引内容 (键序 / 悬空索引项 / 反向缺失) —— 大表需要十几秒, 完成后本页自动刷新。上方页账 / 空闲链 / 交叉引用结论已就绪。</div>';}
- else if(iSkip){o+='<div style="font-size:12px;color:#f7b731">'+esc(iSkip)+'</div>';}
- else{
- 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>';
- 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>';
- if(!iBad2)o+='<div style="font-size:12px;color:#4ecdc4">通过 —— 索引键序单调; 每个二级索引条目都能回表到存活行; 且每个存活行在各索引里都留有条目。</div>';
- if(iNote.length){o+='<div style="font-size:11px;color:#f7b731;margin-bottom:6px">跳过: '+iNote.map(esc).join('; ')+'</div>';}
- if(iIss.length||iBad2){
- 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>';
- }
- }
- o+='</div></div>';
- 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>';
- let br=r.byRole||{},tot=a.totalPages||1;
- 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>'});
- o+='<tr><td style="color:#4ecdc4;font-weight:600">free</td><td>'+fr.total+'</td><td>'+(fr.total/tot*100).toFixed(1)+'%</td></tr>';
- o+='<tr><td style="color:#a78bfa;font-weight:600">reserved</td><td>'+a.reserved+'</td><td>'+(a.reserved/tot*100).toFixed(1)+'%</td></tr>';
- 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>';
- o+='</tbody></table></div></div>';
- 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>'+
- '<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>';
- if(!a.tables||!a.tables.length)o+='<tr><td colspan="11" class="empty">No tables</td></tr>';
- else a.tables.forEach(t=>{
- let bv=t.verdict==='OK'?'active':(t.verdict==='STALE'?'btree':'dropped');
- o+='<tr><td><span class="clickable" onclick="showTable(\''+esc(t.name)+'\')">'+esc(t.name)+'</span></td>'+
- '<td>'+t.rows+'</td><td>'+t.dataPages+'</td><td>'+t.hpc+'</td><td>'+t.heapActual+'</td><td>'+t.heapRefs+'</td>'+
- '<td><span style="color:#4ecdc4">'+t.heapLive+'</span> / <span style="color:#f7b731">'+t.heapEmpty+'</span></td>'+
- '<td>'+fB(t.heapBytes)+'</td><td>'+t.idxPages+'</td><td>'+t.idxDps+'</td>'+
- '<td><span class="badge '+bv+'" title="'+esc(t.detail)+'">'+esc(t.verdict)+'</span></td></tr>';
- });
- o+='</tbody></table></div></div>';
- 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>'+
- ((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>'):'')+
- '</div></div>';
- $('auContent').innerHTML=o;
- loadOrphanRows(r.unaccountedIds,'orphBody');
- // 索引问题明细: 走分页接口按页取 (问题很多时不把整份塞进页面)。
- if($('auIssBody'))loadIssuePage(iBad+iOrph+iNDP+iMR+iME,iIss,'auIssBody',0);
- // 增量审计: 索引内容还在后台补全 —— 标记"校验中"并轮询, 就绪后自动重绘一次。
- if(a.idxPending){
- if($('auInfo'))$('auInfo').innerHTML='<span style="color:#f7b731;font-weight:700">'+a.verdict+'</span> <span style="font-size:11px;color:#f7b731">索引校验中…</span>';
- setTimeout(()=>loadAudit(tok),700);
- }
- }).catch(e=>{if(tok===auTok&&$('auContent'))$('auContent').innerHTML='<div style="color:#e74c3c">Error: '+e.message+'</div>'});
- }
- function showHeapChains(){
- document.querySelectorAll('.tbl-item').forEach(e=>e.classList.remove('active'));
- let h='<div class="bc"><span class="item" onclick="showOverview()">Overview</span><span class="sep">›</span><span class="cur">Heap Chains</span></div>';
- 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>';
- $('content').innerHTML=h;
- fetch('/api/heap-chains').then(r=>r.json()).then(d=>{
- if(d.e){$('hcContent').innerHTML='<div class="empty"><div class="msg">'+esc(d.e)+'</div></div>';return}
- let ts=d.tables||[],fr=d.free||{},rch=d.reach||{};
- let decl=0,act=0,refs=0,bytes=0,live=0,empty=0,withHeap=0,mismatch=0,stale=0;
- ts.forEach(t=>{decl+=t.hpc;act+=t.heapActual;refs+=t.heapRefs;bytes+=t.heapBytes;live+=t.heapLive;empty+=t.heapEmpty;
- if(t.heapActual>0)withHeap++;if(t.verdict==='MISMATCH')mismatch++;if(t.verdict==='STALE')stale++;});
- $('hcInfo').innerHTML=withHeap+' tables with heap | '+act+' heap pages \u00d7 16 KB';
- let o='<div class="stats">'+
- bigCard('c1','Tables With Heap',withHeap,'有堆链的表')+
- bigCard('c2','Heap Pages',act,'实走链长')+
- bigCard('c3','Declared',decl,'HeapPageCount 之和')+
- bigCard('c4','Bucket Refs',refs,'RefCount 之和')+
- bigCard('c2','Live / Empty',live+' / '+empty,'RefCount>0 / =0')+
- bigCard('c1','Heap Bytes',fB(bytes),'已用字节')+
- bigCard(mismatch?'c5':'c2','Mismatches',mismatch,(stale?'plus '+stale+' STALE':'no counter drift'))+
- '</div>';
- 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>'+
- '<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>';
- if(!ts.length)o+='<tr><td colspan="10" class="empty">No tables</td></tr>';
- ts.slice().sort((x,y)=>(y.heapActual-x.heapActual)||(y.hpc-x.hpc)).forEach(t=>{
- let bv=t.verdict==='OK'?'active':(t.verdict==='STALE'?'btree':'dropped');
- let diff=t.heapActual-t.hpc;
- let dcol=diff===0?'#4ecdc4':(t.hpc===0?'#f7b731':'#e74c3c');
- o+='<tr><td><span class="clickable" onclick="showTable(\''+esc(t.name)+'\')">'+esc(t.name)+'</span></td>'+
- '<td class="mono">'+(t.hp0||0)+'</td><td>'+t.hpc+'</td><td>'+t.heapActual+'</td>'+
- '<td style="color:'+dcol+';font-weight:600">'+(diff>0?'+':'')+diff+'</td>'+
- '<td>'+t.heapRefs+'</td><td><span style="color:#4ecdc4">'+t.heapLive+'</span> / <span style="color:#f7b731">'+t.heapEmpty+'</span></td>'+
- '<td>'+fB(t.heapBytes)+'</td>'+
- '<td><span class="badge '+bv+'" title="'+esc(t.detail)+'">'+esc(t.verdict)+'</span></td>'+
- '<td>'+(t.heapActual>0?'<span class="clickable" onclick="showHeapPages(\''+esc(t.name)+'\')">view chain</span>':'<span style="color:#445">-</span>')+'</td></tr>';
- });
- o+='</tbody></table></div></div>';
- 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>'+
- '<div style="padding:10px 14px;font-size:11px;color:#556">这些页既不属于任何表/索引, 也不在空闲链上 —— DropTable / 重建索引漏收的候选。</div></div></div>';
- $('hcContent').innerHTML=o;
- loadOrphanRows(rch.unaccountedIds,'hcOrphBody');
- }).catch(e=>{$('hcContent').innerHTML='<div style="color:#e74c3c">Error: '+e.message+'</div>'});
- }
- function showOrphans(){
- document.querySelectorAll('.tbl-item').forEach(e=>e.classList.remove('active'));
- let h='<div class="bc"><span class="item" onclick="showOverview()">Overview</span><span class="sep">›</span><span class="cur">Orphan Pages</span></div>';
- 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>';
- $('content').innerHTML=h;
- fetch('/api/audit').then(r=>r.json()).then(a=>{
- if(a.e){$('opContent').innerHTML='<div class="empty"><div class="msg">'+esc(a.e)+'</div></div>';return}
- let r=a.reach||{};
- $('opInfo').innerHTML=r.unaccounted+' pages unaccounted';
- let o='';
- if(!r.unaccounted){
- o='<div class="empty"><div class="ico">✅</div><div class="msg">All '+a.totalPages+' pages are accounted for \u2014 每个页要么归属明确, 要么在空闲链上。</div></div>';
- }else{
- let bt=r.unaccountedByType?Object.keys(r.unaccountedByType).map(k=>esc(k)+' \u00d7 '+r.unaccountedByType[k]).join(', '):'';
- 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>'+
- '<div style="font-size:12px;color:#aab;line-height:1.7">已下发区间内, 既不可达 (不属于任何表/索引) 也不在空闲链上的页。'+
- (bt?('按页类型: '+bt+'。<br>'):'')+
- (r.walLeftover?('<b style="color:#f7b731">其中 '+r.walLeftover+' 页为 WAL 残留</b> —— 页类型为 WAL, 但页头 FirstWALPage=0 (无待恢复 WAL) 且不在空闲链上, 即历史已提交事务的 WAL 链页在提交时未被归还空闲链 (引擎漏回收)。这类页不会再被分配, 属于空间泄漏, 需引擎侧修复。<br>'):'')+
- '其它常见来源: DropTable / 重建索引漏收, 或本工具未覆盖的结构。点击 <b>inspect</b> 查看页头与归属。</div></div>';
- 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">'+
- '<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>';
- }
- $('opContent').innerHTML=o;
- // 孤儿页可能很多: 走分页接口按页取, 不把整份明细塞进浏览器。
- loadUnaccPage(r.unaccounted,'opBody',0);
- }).catch(e=>{$('opContent').innerHTML='<div style="color:#e74c3c">Error: '+e.message+'</div>'});
- }
- // showPageInfo 单页检查弹窗 —— 页头原始字段 + 归属 + 快捷跳转。
- function showPageInfo(pid){
- fetch('/api/page-info?id='+pid).then(r=>r.json()).then(p=>{
- if(!p||p.e){alert('Page '+pid+': '+((p&&p.e)||'read error'));return}
- let ov=document.createElement('div');ov.id='piOverlay';
- 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';
- ov.onclick=function(e){if(e.target===ov)piClose()};
- let box=document.createElement('div');
- 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)';
- let roleBadge=p.role?('<span class="badge primary">'+esc(p.role)+'</span>'):'<span class="badge">unowned</span>';
- let h='<div style="padding:14px 18px;border-bottom:1px solid #1a1a2e;display:flex;align-items:center;gap:8px">'+
- '<span style="font-size:15px;font-weight:700;color:#5b9bd5">Page #'+p.id+'</span>'+
- '<span class="badge data">'+esc(p.ts)+'</span>'+roleBadge+
- '<span style="margin-left:auto;cursor:pointer;color:#667;font-size:20px;line-height:1" onclick="piClose()">×</span></div>';
- 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>';
- let rows=[['Prev Page',p.pv],['Next Page',p.nx],['UseSize',p.use+' B'],['Usage',p.us.toFixed(1)+'%'],
- ['Role',esc(p.role||'-')],['Owner',esc(p.owner||'-')],
- ['In Free Chain',p.inFree?'<span class="badge active">yes</span>':'<span class="badge dropped">no</span>'],
- ['Allocated',p.isAlloc?'<span class="badge active">yes</span>':'<span class="badge dropped">no</span>']];
- h+='<table class="dtbl"><tbody>';
- 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>'});
- h+='<tr><td style="color:#5b9bd5;font-weight:600">Raw Hex (first 64B)</td><td class="hex-data">'+esc(p.hex||'')+'</td></tr>';
- h+='</tbody></table>';
- let acts='';
- if(p.t===2)acts+='<button class="nav-btn" onclick="piClose();showDP('+p.id+')">Inspect DataPage →</button>';
- if(p.t===3){
- let own=p.owner||'';
- if(S.tables.find(x=>x.name===own))acts+='<button class="nav-btn" onclick="piClose();showHeapPages(\''+esc(own)+'\')">Open Heap Chain →</button>';
- }
- h+='<div class="nav-btns">'+acts+'<button class="nav-btn" onclick="piClose()">Close</button></div>';
- box.innerHTML=h;ov.appendChild(box);document.body.appendChild(ov);
- }).catch(e=>{alert('Page '+pid+': '+e.message)});
- }
- function piClose(){let o=document.getElementById('piOverlay');if(o)o.remove()}
- // ===== Tutorial System =====
- let TUT={step:0,total:0,steps:[]};
- function showTutorial(){
- TUT.step=0;
- TUT.steps=[
- {title:'Welcome to TsDB2 Analyzer',content:'This interactive tutorial will guide you through all features of the Analyzer. Click "Next" to begin.',action:null},
- {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'},
- {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'},
- {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},
- {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'},
- {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},
- {title:'Master Registry & Schema Versions',content:'View field definitions (active/dropped), schema evolution history, and version-specific field layouts.',action:null,highlight:null},
- {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},
- {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},
- {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},
- {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},
- {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},
- {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},
- {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},
- {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}
- ];
- TUT.total=TUT.steps.length;
- renderTutStep();
- }
- function renderTutStep(){
- if(TUT.step>=TUT.total){return}
- const s=TUT.steps[TUT.step];
- if(s.action)s.action();
- setTimeout(()=>{
- let overlay=document.createElement('div');
- overlay.id='tutOverlay';
- 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';
- let box=document.createElement('div');
- 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)';
- let h='<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">';
- h+='<span style="font-size:18px;font-weight:700;color:#5b9bd5">'+esc(s.title)+'</span>';
- h+='<span style="font-size:12px;color:#667">'+(TUT.step+1)+'/'+TUT.total+'</span></div>';
- h+='<div style="color:#aab;line-height:1.6;font-size:14px;margin-bottom:20px">'+esc(s.content)+'</div>';
- h+='<div style="display:flex;gap:10px;justify-content:flex-end">';
- 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">← Prev</button>';
- 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 →</button>';
- 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 ✔</button>';
- h+='</div>';
- box.innerHTML=h;
- overlay.appendChild(box);
- document.body.appendChild(overlay);
- if(s.highlight){
- const el=document.querySelector(s.highlight);
- 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)}
- }
- },100);
- }
- function tutNext(){const o=document.getElementById('tutOverlay');if(o)o.remove();TUT.step++;renderTutStep()}
- function tutPrev(){const o=document.getElementById('tutOverlay');if(o)o.remove();TUT.step--;renderTutStep()}
- function tutClose(){const o=document.getElementById('tutOverlay');if(o)o.remove()}
- renderFB();
- </script>
- </body>
- </html>
|