tina4ruby 3.10.42 → 3.10.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -281,12 +281,19 @@ module Tina4
281
281
  end
282
282
 
283
283
  def execute_many(sql, params_list = [])
284
- total_affected = 0
285
- params_list.each do |params|
286
- current_driver.execute(sql, params)
287
- total_affected += 1
284
+ results = []
285
+ drv = current_driver
286
+ drv.begin_transaction
287
+ begin
288
+ params_list.each do |params|
289
+ results << drv.execute(sql, params)
290
+ end
291
+ drv.commit
292
+ rescue => e
293
+ drv.rollback
294
+ raise e
288
295
  end
289
- { success: true, affected_rows: total_affected }
296
+ results
290
297
  end
291
298
 
292
299
  def transaction
@@ -498,6 +498,14 @@ module Tina4
498
498
  end
499
499
 
500
500
  def status_payload
501
+ db_table_count = 0
502
+ begin
503
+ db = Tina4.database
504
+ db_table_count = db.tables.size if db
505
+ rescue
506
+ # ignore
507
+ end
508
+
501
509
  {
502
510
  framework: "tina4-ruby",
503
511
  version: Tina4::VERSION,
@@ -505,6 +513,8 @@ module Tina4
505
513
  platform: RUBY_PLATFORM,
506
514
  debug: ENV["TINA4_DEBUG"] || "false",
507
515
  log_level: ENV["TINA4_LOG_LEVEL"] || "ERROR",
516
+ database: ENV["DATABASE_URL"] || "not configured",
517
+ db_tables: db_table_count,
508
518
  uptime: (Time.now - (defined?(@boot_time) && @boot_time ? @boot_time : (@boot_time = Time.now))).round(1),
509
519
  route_count: Tina4::Router.routes.size,
510
520
  request_stats: request_inspector.stats,
@@ -574,7 +584,9 @@ module Tina4
574
584
  },
575
585
  pid: Process.pid,
576
586
  thread_count: Thread.list.size,
577
- env: ENV["TINA4_ENV"] || ENV["RACK_ENV"] || ENV["RUBY_ENV"] || "development"
587
+ env: ENV["TINA4_ENV"] || ENV["RACK_ENV"] || ENV["RUBY_ENV"] || "development",
588
+ db_tables: (begin; db = Tina4.database; db ? db.tables.size : 0; rescue; 0; end),
589
+ db_connected: (begin; db = Tina4.database; !db.nil?; rescue; false; end)
578
590
  }
579
591
  end
580
592
 
@@ -599,19 +611,38 @@ module Tina4
599
611
  sql = sql.to_s.strip
600
612
  return { error: "No SQL provided" } if sql.empty?
601
613
 
602
- first_word = sql.split(/[\s\t\n\r]+/, 2).first.to_s.upcase
603
- unless %w[SELECT PRAGMA EXPLAIN SHOW DESCRIBE].include?(first_word)
604
- return { error: "Only SELECT queries are allowed in the dev dashboard" }
605
- end
606
-
607
614
  db = Tina4.database
608
615
  return { error: "No database configured" } unless db
609
616
 
617
+ # Split multiple statements on semicolons
618
+ statements = sql.split(";").map(&:strip).reject(&:empty?)
619
+
610
620
  begin
611
- result = db.fetch(sql)
612
- rows = result.respond_to?(:to_a) ? result.to_a : (result.is_a?(Array) ? result : [])
613
- columns = rows.first.is_a?(Hash) ? rows.first.keys.map(&:to_s) : []
614
- { columns: columns, rows: rows, count: rows.size }
621
+ if statements.size == 1
622
+ first_word = statements[0].split(/[\s\t\n\r]+/, 2).first.to_s.upcase
623
+ if %w[SELECT PRAGMA EXPLAIN SHOW DESCRIBE].include?(first_word)
624
+ result = db.fetch(statements[0])
625
+ rows = result.respond_to?(:to_a) ? result.to_a : (result.is_a?(Array) ? result : [])
626
+ columns = rows.first.is_a?(Hash) ? rows.first.keys.map(&:to_s) : []
627
+ return { columns: columns, rows: rows, count: rows.size }
628
+ end
629
+ end
630
+
631
+ # Execute all statements (single write or multi-statement batch)
632
+ total_affected = 0
633
+ db.start_transaction if db.respond_to?(:start_transaction)
634
+ begin
635
+ statements.each do |stmt|
636
+ result = db.execute(stmt)
637
+ total_affected += (result.respond_to?(:affected_rows) ? result.affected_rows : 0)
638
+ end
639
+ db.commit if db.respond_to?(:commit)
640
+ rescue => e
641
+ db.rollback if db.respond_to?(:rollback)
642
+ return { error: e.message }
643
+ end
644
+
645
+ { affected: total_affected, success: true }
615
646
  rescue => e
616
647
  { error: e.message }
617
648
  end
@@ -1069,32 +1100,45 @@ module Tina4
1069
1100
  <h2>Database</h2>
1070
1101
  <button class="btn btn-sm" onclick="loadTables()">Refresh</button>
1071
1102
  </div>
1072
- <div class="flex gap-md p-md">
1073
- <div class="flex-1">
1074
- <div class="flex gap-sm items-center mb-sm">
1075
- <select id="query-type" class="input">
1103
+ <div style="display:flex;height:calc(100vh - 140px);overflow:hidden">
1104
+ <!-- Left: Tables navigation -->
1105
+ <div style="width:200px;min-width:200px;border-right:1px solid var(--border);padding:0.5rem;overflow-y:auto;display:flex;flex-direction:column;gap:0.5rem">
1106
+ <div class="text-sm text-muted" style="font-weight:600">Tables</div>
1107
+ <div id="table-list" class="text-sm"></div>
1108
+ <div style="border-top:1px solid var(--border);padding-top:0.5rem;margin-top:auto">
1109
+ <div class="text-sm text-muted" style="font-weight:600;margin-bottom:0.25rem">Seed Data</div>
1110
+ <select id="seed-table" class="input" style="width:100%;margin-bottom:0.25rem;font-size:0.75rem"><option value="">Pick table...</option></select>
1111
+ <div class="flex gap-sm items-center">
1112
+ <input type="number" id="seed-count" class="input" value="10" min="1" max="1000" style="width:60px;font-size:0.75rem">
1113
+ <button class="btn btn-sm btn-success" onclick="seedTable()">Seed</button>
1114
+ </div>
1115
+ </div>
1116
+ </div>
1117
+ <!-- Right: Query + Results -->
1118
+ <div style="flex:1;display:flex;flex-direction:column;overflow:hidden;padding:0.5rem">
1119
+ <div style="display:flex;gap:0.5rem;align-items:center;margin-bottom:0.25rem">
1120
+ <select id="query-type" class="input" style="width:auto;font-size:0.75rem">
1076
1121
  <option value="sql">SQL</option>
1077
1122
  </select>
1123
+ <span class="text-sm text-muted">Limit</span>
1124
+ <select id="query-limit" class="input" style="width:70px;font-size:0.75rem">
1125
+ <option value="20">20</option>
1126
+ <option value="50">50</option>
1127
+ <option value="100">100</option>
1128
+ <option value="500">500</option>
1129
+ <option value="0">All</option>
1130
+ </select>
1078
1131
  <button class="btn btn-sm btn-primary" onclick="runQuery()">Run</button>
1132
+ <button class="btn btn-sm" id="btn-csv" onclick="copyResults('csv',this)" title="Copy results as CSV">Copy CSV</button>
1133
+ <button class="btn btn-sm" id="btn-json" onclick="copyResults('json',this)" title="Copy results as JSON">Copy JSON</button>
1134
+ <button class="btn btn-sm" onclick="pasteData()" title="Paste tab-separated data as INSERTs">Paste</button>
1079
1135
  <span class="text-sm text-muted">Ctrl+Enter</span>
1080
1136
  </div>
1081
- <textarea id="query-input" rows="4" placeholder="SELECT * FROM users LIMIT 20" class="input input-mono" style="width:100%"></textarea>
1137
+ <textarea id="query-input" rows="3" placeholder="SELECT * FROM users LIMIT 20" class="input input-mono" style="width:100%;font-size:0.75rem;resize:vertical"></textarea>
1082
1138
  <div id="query-error" class="hidden" style="color:var(--danger);font-size:0.75rem;margin-top:0.25rem"></div>
1083
- </div>
1084
- <div style="width:180px">
1085
- <div class="text-sm text-muted" style="font-weight:600;margin-bottom:0.5rem">Tables</div>
1086
- <div id="table-list" class="text-sm"></div>
1087
- <div style="margin-top:0.75rem;border-top:1px solid var(--border);padding-top:0.75rem">
1088
- <div class="text-sm text-muted" style="font-weight:600;margin-bottom:0.5rem">Seed Data</div>
1089
- <select id="seed-table" class="input" style="width:100%;margin-bottom:0.25rem"><option value="">Pick table...</option></select>
1090
- <div class="flex gap-sm items-center">
1091
- <input type="number" id="seed-count" class="input" value="10" min="1" max="1000" style="width:60px">
1092
- <button class="btn btn-sm btn-success" onclick="seedTable()">Seed</button>
1093
- </div>
1094
- </div>
1139
+ <div id="query-results" style="flex:1;overflow:auto;margin-top:0.25rem;font-size:0.75rem"></div>
1095
1140
  </div>
1096
1141
  </div>
1097
- <div id="query-results" style="overflow-x:auto"></div>
1098
1142
  </div>
1099
1143
 
1100
1144
  <!-- Requests Panel -->
@@ -1385,20 +1429,38 @@ function miColor(mi){
1385
1429
  if(mi>=30) return 'rgb('+(Math.round(220+((60-mi)/30)*19))+','+(Math.round(180-((60-mi)/30)*112))+',0)';
1386
1430
  return 'rgb(239,'+(Math.round(68-mi*2))+',0)';
1387
1431
  }
1388
- function renderBubbleChart(files){
1432
+ function renderBubbleChart(files,depGraph,scanMode){
1389
1433
  var container=document.getElementById('metrics-bubble');
1390
1434
  if(!files||!files.length){container.innerHTML='<p style="color:var(--muted);padding:1rem">No files to analyze</p>';return;}
1435
+ depGraph=depGraph||{};
1436
+ scanMode=scanMode||'project';
1391
1437
  var W=container.offsetWidth||900,H=Math.max(450,Math.min(650,W*0.45));
1392
1438
  var maxLoc=Math.max.apply(null,files.map(function(f){return f.loc}))||1;
1439
+ var maxDeps=Math.max.apply(null,files.map(function(f){return f.dep_count||0}))||1;
1440
+ var maxCC=Math.max.apply(null,files.map(function(f){return f.complexity||0}))||1;
1393
1441
  var minR=14,maxR=Math.min(70,W/10);
1394
- var sorted=files.slice().sort(function(a,b){return a.loc-b.loc});
1442
+ function healthColor(f){
1443
+ var cc=Math.min((f.avg_complexity||0)/10,1);
1444
+ var untested=f.has_tests?0:1;
1445
+ var deps=Math.min((f.dep_count||0)/5,1);
1446
+ var score=cc*0.4+untested*0.4+deps*0.2;
1447
+ score=Math.max(0,Math.min(1,score));
1448
+ var hue=Math.round(120*(1-score));
1449
+ var sat=Math.round(70+score*30);
1450
+ var lit=Math.round(42+18*(1-score));
1451
+ return 'hsl('+hue+','+sat+'%,'+lit+'%)';
1452
+ }
1453
+ var pathIdx={};
1454
+ files.forEach(function(f,i){pathIdx[f.path]=i;});
1455
+ function sizeScore(f){return (f.loc/maxLoc)*0.4+((f.avg_complexity||0)/10)*0.4+((f.dep_count||0)/maxDeps)*0.2;}
1456
+ var sorted=files.slice().sort(function(a,b){return sizeScore(a)-sizeScore(b)});
1395
1457
  var cx=W/2,cy=H/2;
1396
1458
  var bubbles=[];
1397
1459
  var angle=0,spiralR=0;
1398
1460
  for(var i=0;i<sorted.length;i++){
1399
1461
  var f=sorted[i];
1400
- var r=minR+Math.sqrt(f.loc/maxLoc)*(maxR-minR);
1401
- var color=miColor(f.maintainability||0);
1462
+ var r=minR+Math.sqrt(sizeScore(f))*(maxR-minR);
1463
+ var color=healthColor(f);
1402
1464
  var placed=false;
1403
1465
  for(var attempt=0;attempt<800;attempt++){
1404
1466
  var px=cx+spiralR*Math.cos(angle);
@@ -1409,79 +1471,217 @@ function renderBubbleChart(files){
1409
1471
  if(Math.sqrt(dx*dx+dy*dy)<r+bubbles[j].r+2){collides=true;break;}
1410
1472
  }
1411
1473
  if(!collides&&px>r+2&&px<W-r-2&&py>r+25&&py<H-r-2){
1412
- bubbles.push({x:px,y:py,r:r,color:color,f:f,angle:Math.random()*Math.PI*2,speed:0.3+Math.random()*0.5,drift:2+Math.random()*3});
1474
+ bubbles.push({x:px,y:py,vx:0,vy:0,r:r,color:color,f:f});
1413
1475
  placed=true;break;
1414
1476
  }
1415
1477
  angle+=0.2;spiralR+=0.04;
1416
1478
  }
1417
- if(!placed){bubbles.push({x:cx+(Math.random()-0.5)*W*0.3,y:cy+(Math.random()-0.5)*H*0.3,r:r,color:color,f:f,angle:Math.random()*Math.PI*2,speed:0.3+Math.random()*0.5,drift:2+Math.random()*3});}
1479
+ if(!placed){bubbles.push({x:cx+(Math.random()-0.5)*W*0.3,y:cy+(Math.random()-0.5)*H*0.3,vx:0,vy:0,r:r,color:color,f:f});}
1418
1480
  }
1481
+ var edges=[];
1482
+ function basename(p){var n=p.split('/').pop();var d=n.lastIndexOf('.');return(d>0?n.substring(0,d):n).toLowerCase();}
1483
+ var nameIdx={};
1484
+ bubbles.forEach(function(b,i){nameIdx[basename(b.f.path)]=i;});
1485
+ Object.keys(depGraph).forEach(function(src){
1486
+ var srcIdx=null;
1487
+ bubbles.forEach(function(b,i){if(b.f.path===src)srcIdx=i;});
1488
+ if(srcIdx===null)return;
1489
+ (depGraph[src]||[]).forEach(function(tgt){
1490
+ var tgtName=tgt.split('.').pop().toLowerCase();
1491
+ var tgtIdx=nameIdx[tgtName];
1492
+ if(tgtIdx!==undefined&&srcIdx!==tgtIdx)edges.push([srcIdx,tgtIdx]);
1493
+ });
1494
+ });
1419
1495
  var canvas=document.createElement('canvas');
1420
1496
  canvas.width=W;canvas.height=H;
1421
1497
  canvas.style.cssText='display:block;border:1px solid var(--border);border-radius:8px;cursor:pointer;background:#0f172a';
1422
- container.innerHTML='<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.5rem"><h3 style="margin:0;color:var(--primary)">Code Landscape</h3><span style="font-size:0.7rem;color:var(--muted)">Click a bubble to drill down | Size=LOC | <span style="color:#22c55e">Green</span>=maintainable <span style="color:#eab308">Yellow</span>=moderate <span style="color:#ef4444">Red</span>=needs work</span></div>';
1498
+ var modeLabel=scanMode==='framework'?'<span style="color:#cba6f7;font-weight:600"> (Framework)</span> Add code to src/ to see your project':'';
1499
+ container.innerHTML='<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.5rem"><h3 style="margin:0;color:var(--primary)">Code Landscape'+modeLabel+'</h3><span style="font-size:0.7rem;color:var(--muted)">Drag bubbles | Click to drill down | Size=LOC | Colour=health | T=tested | D=deps</span></div>';
1423
1500
  container.appendChild(canvas);
1424
1501
  var ctx=canvas.getContext('2d');
1425
- var hoveredIdx=-1;
1426
- var t=0;
1502
+ var hoveredIdx=-1,dragIdx=-1,dragOX=0,dragOY=0;
1503
+ function simulate(){
1504
+ var damping=0.65,springK=0.002,repulse=40,gravity=0.008;
1505
+ var cx=W/2,cy=H/2;
1506
+ bubbles.forEach(function(b,idx){
1507
+ if(idx===dragIdx)return;
1508
+ var dx=cx-b.x,dy=cy-b.y;
1509
+ var sizeFactor=0.3+(b.r/maxR)*0.7;
1510
+ var pull=gravity*sizeFactor*sizeFactor;
1511
+ b.vx+=dx*pull;b.vy+=dy*pull;
1512
+ });
1513
+ edges.forEach(function(e){
1514
+ var a=bubbles[e[0]],b=bubbles[e[1]];
1515
+ var dx=b.x-a.x,dy=b.y-a.y;
1516
+ var dist=Math.sqrt(dx*dx+dy*dy)||1;
1517
+ var rest=a.r+b.r+20;
1518
+ var force=(dist-rest)*springK;
1519
+ var fx=dx/dist*force,fy=dy/dist*force;
1520
+ if(e[0]!==dragIdx){a.vx+=fx;a.vy+=fy;}
1521
+ if(e[1]!==dragIdx){b.vx-=fx;b.vy-=fy;}
1522
+ });
1523
+ for(var i=0;i<bubbles.length;i++){
1524
+ for(var j=i+1;j<bubbles.length;j++){
1525
+ var a=bubbles[i],b=bubbles[j];
1526
+ var dx=b.x-a.x,dy=b.y-a.y;
1527
+ var dist=Math.sqrt(dx*dx+dy*dy)||1;
1528
+ var minDist=a.r+b.r+20;
1529
+ if(dist<minDist){
1530
+ var force=repulse*(minDist-dist)/minDist;
1531
+ var fx=dx/dist*force,fy=dy/dist*force;
1532
+ if(i!==dragIdx){a.vx-=fx;a.vy-=fy;}
1533
+ if(j!==dragIdx){b.vx+=fx;b.vy+=fy;}
1534
+ }
1535
+ }
1536
+ }
1537
+ bubbles.forEach(function(b,idx){
1538
+ if(idx===dragIdx)return;
1539
+ b.vx*=damping;b.vy*=damping;
1540
+ var maxV=2;
1541
+ if(b.vx>maxV)b.vx=maxV;if(b.vx<-maxV)b.vx=-maxV;
1542
+ if(b.vy>maxV)b.vy=maxV;if(b.vy<-maxV)b.vy=-maxV;
1543
+ b.x+=b.vx;b.y+=b.vy;
1544
+ b.x=Math.max(b.r+2,Math.min(W-b.r-2,b.x));
1545
+ b.y=Math.max(b.r+25,Math.min(H-b.r-2,b.y));
1546
+ });
1547
+ }
1427
1548
  function draw(){
1428
- t+=0.016;
1549
+ simulate();
1429
1550
  ctx.clearRect(0,0,W,H);
1430
- ctx.strokeStyle='rgba(255,255,255,0.03)';ctx.lineWidth=1;
1431
- for(var gx=0;gx<W;gx+=50){ctx.beginPath();ctx.moveTo(gx,0);ctx.lineTo(gx,H);ctx.stroke();}
1432
- for(var gy=0;gy<H;gy+=50){ctx.beginPath();ctx.moveTo(0,gy);ctx.lineTo(W,gy);ctx.stroke();}
1551
+ ctx.save();ctx.translate(panX,panY);ctx.scale(zoom,zoom);
1552
+ ctx.strokeStyle='rgba(255,255,255,0.03)';ctx.lineWidth=1/zoom;
1553
+ for(var gx=0;gx<W/zoom;gx+=50){ctx.beginPath();ctx.moveTo(gx,0);ctx.lineTo(gx,H/zoom);ctx.stroke();}
1554
+ for(var gy=0;gy<H/zoom;gy+=50){ctx.beginPath();ctx.moveTo(0,gy);ctx.lineTo(W/zoom,gy);ctx.stroke();}
1555
+ edges.forEach(function(e){
1556
+ var a=bubbles[e[0]],b=bubbles[e[1]];
1557
+ var dx=b.x-a.x,dy=b.y-a.y;
1558
+ var dist=Math.sqrt(dx*dx+dy*dy)||1;
1559
+ var highlighted=(hoveredIdx===e[0]||hoveredIdx===e[1]);
1560
+ ctx.beginPath();
1561
+ ctx.moveTo(a.x+dx/dist*a.r,a.y+dy/dist*a.r);
1562
+ var ex=b.x-dx/dist*b.r,ey=b.y-dy/dist*b.r;
1563
+ ctx.lineTo(ex,ey);
1564
+ ctx.strokeStyle=highlighted?'rgba(139,180,250,0.9)':'rgba(255,255,255,0.3)';
1565
+ ctx.lineWidth=highlighted?3:1.5;ctx.stroke();
1566
+ var aLen=highlighted?14:8;
1567
+ var aAngle=Math.atan2(dy,dx);
1568
+ ctx.beginPath();
1569
+ ctx.moveTo(ex,ey);
1570
+ ctx.lineTo(ex-aLen*Math.cos(aAngle-0.4),ey-aLen*Math.sin(aAngle-0.4));
1571
+ ctx.lineTo(ex-aLen*Math.cos(aAngle+0.4),ey-aLen*Math.sin(aAngle+0.4));
1572
+ ctx.closePath();ctx.fillStyle=ctx.strokeStyle;ctx.fill();
1573
+ });
1433
1574
  bubbles.forEach(function(b,idx){
1434
- var ox=Math.sin(t*b.speed+b.angle)*b.drift;
1435
- var oy=Math.cos(t*b.speed*0.7+b.angle+1)*b.drift*0.6;
1436
- var bx=b.x+ox,by=b.y+oy;
1437
1575
  var isHovered=(idx===hoveredIdx);
1438
1576
  var drawR=isHovered?b.r+4:b.r;
1439
- if(isHovered){
1440
- ctx.beginPath();ctx.arc(bx,by,drawR+8,0,Math.PI*2);
1441
- ctx.fillStyle='rgba(255,255,255,0.08)';ctx.fill();
1442
- }
1443
- ctx.beginPath();ctx.arc(bx,by,drawR,0,Math.PI*2);
1444
- ctx.fillStyle=b.color;ctx.globalAlpha=isHovered?0.95:0.7;ctx.fill();
1445
- ctx.globalAlpha=1;ctx.strokeStyle=b.color;ctx.lineWidth=isHovered?2.5:1.5;ctx.stroke();
1577
+ if(isHovered){ctx.beginPath();ctx.arc(b.x,b.y,drawR+8,0,Math.PI*2);ctx.fillStyle='rgba(255,255,255,0.08)';ctx.fill();}
1578
+ ctx.beginPath();ctx.arc(b.x,b.y,drawR,0,Math.PI*2);
1579
+ ctx.fillStyle=b.color;ctx.globalAlpha=isHovered?1.0:0.85;ctx.fill();
1580
+ ctx.globalAlpha=1;ctx.strokeStyle=isHovered?'rgba(255,255,255,0.6)':'rgba(255,255,255,0.25)';ctx.lineWidth=isHovered?2.5:1.5;ctx.stroke();
1446
1581
  var name=b.f.path.split('/').pop().replace('.rb','');
1447
1582
  if(drawR>16){
1448
1583
  var fs=Math.max(8,Math.min(13,drawR*0.38));
1449
1584
  ctx.fillStyle='#fff';ctx.font='600 '+fs+'px monospace';ctx.textAlign='center';
1450
- ctx.fillText(name,bx,by-2);
1585
+ ctx.fillText(name,b.x,b.y-2);
1451
1586
  ctx.fillStyle='rgba(255,255,255,0.65)';ctx.font=(fs-1)+'px monospace';
1452
- ctx.fillText(b.f.loc+' LOC',bx,by+fs);
1587
+ ctx.fillText(b.f.loc+' LOC',b.x,b.y+fs);
1453
1588
  if(isHovered&&drawR>25){
1454
1589
  ctx.fillStyle='rgba(255,255,255,0.5)';ctx.font=(fs-2)+'px monospace';
1455
- ctx.fillText('CC:'+b.f.complexity+' MI:'+b.f.maintainability,bx,by+fs*2);
1590
+ ctx.fillText('CC:'+b.f.complexity+' MI:'+b.f.maintainability,b.x,b.y+fs*2);
1456
1591
  }
1457
1592
  }
1458
- b._drawX=bx;b._drawY=by;b._drawR=drawR;
1593
+ var mfs=Math.max(9,drawR*0.3);
1594
+ var mrad=mfs*0.7;
1595
+ var mpad=mrad*2.4;
1596
+ var my=b.y-drawR+mrad+3;
1597
+ if(drawR>14&&b.f.has_tests){
1598
+ var mx=b.x-(b.f.dep_count>0?mpad*0.5:0);
1599
+ ctx.beginPath();ctx.arc(mx,my,mrad,0,Math.PI*2);
1600
+ ctx.fillStyle='#16a34a';ctx.fill();
1601
+ ctx.fillStyle='#fff';ctx.font='bold '+mfs+'px sans-serif';ctx.textAlign='center';
1602
+ ctx.fillText('T',mx,my+mfs*0.35);
1603
+ }
1604
+ if(drawR>14&&b.f.dep_count>0){
1605
+ var mx2=b.x+(b.f.has_tests?mpad*0.5:0);
1606
+ ctx.beginPath();ctx.arc(mx2,my,mrad,0,Math.PI*2);
1607
+ ctx.fillStyle='#ea580c';ctx.fill();
1608
+ ctx.fillStyle='#fff';ctx.font='bold '+mfs+'px sans-serif';ctx.textAlign='center';
1609
+ ctx.fillText('D',mx2,my+mfs*0.35);
1610
+ }
1611
+ b._drawX=b.x;b._drawY=b.y;b._drawR=drawR;
1459
1612
  });
1460
- var totalLoc=0,totalFiles=bubbles.length;
1461
- bubbles.forEach(function(b){totalLoc+=b.f.loc});
1613
+ var totalLoc=0,totalFiles=bubbles.length,testedCount=0;
1614
+ bubbles.forEach(function(b){totalLoc+=b.f.loc;if(b.f.has_tests)testedCount++;});
1462
1615
  var avgMI=bubbles.reduce(function(s,b){return s+b.f.maintainability},0)/totalFiles;
1463
1616
  ctx.fillStyle='rgba(255,255,255,0.35)';ctx.font='11px monospace';ctx.textAlign='right';
1464
- ctx.fillText(totalFiles+' files | '+totalLoc.toLocaleString()+' LOC | Avg MI: '+avgMI.toFixed(1),W-12,H-10);
1617
+ ctx.restore();
1618
+ ctx.fillStyle='rgba(255,255,255,0.35)';ctx.font='11px monospace';ctx.textAlign='right';
1619
+ ctx.fillText(totalFiles+' files | '+totalLoc.toLocaleString()+' LOC | MI:'+avgMI.toFixed(1)+' | Tested:'+testedCount+'/'+totalFiles,W-12,H-10);
1465
1620
  window._metricsAnimFrame=requestAnimationFrame(draw);
1466
1621
  }
1467
1622
  draw();
1623
+ var panning=false,panStartX=0,panStartY=0;
1624
+ canvas.addEventListener('contextmenu',function(e){e.preventDefault();});
1468
1625
  canvas.addEventListener('mousemove',function(e){
1469
1626
  var rect=canvas.getBoundingClientRect();
1470
1627
  var mx=e.clientX-rect.left,my=e.clientY-rect.top;
1628
+ if(panning){
1629
+ panX+=(mx-panStartX);panY+=(my-panStartY);
1630
+ panStartX=mx;panStartY=my;return;
1631
+ }
1632
+ if(dragIdx>=0){
1633
+ var wmx=(mx-panX)/zoom,wmy=(my-panY)/zoom;
1634
+ bubbles[dragIdx].x=wmx-dragOX;bubbles[dragIdx].y=wmy-dragOY;
1635
+ bubbles[dragIdx].vx=0;bubbles[dragIdx].vy=0;return;
1636
+ }
1637
+ var wmx2=(mx-panX)/zoom,wmy2=(my-panY)/zoom;
1471
1638
  hoveredIdx=-1;
1472
1639
  for(var i=bubbles.length-1;i>=0;i--){
1473
1640
  var b=bubbles[i];
1474
- var dx=mx-b._drawX,dy=my-b._drawY;
1475
- if(Math.sqrt(dx*dx+dy*dy)<=b._drawR){hoveredIdx=i;break;}
1641
+ var dx=wmx2-b.x,dy=wmy2-b.y;
1642
+ if(Math.sqrt(dx*dx+dy*dy)<=b.r){hoveredIdx=i;break;}
1476
1643
  }
1477
- canvas.style.cursor=hoveredIdx>=0?'pointer':'default';
1644
+ canvas.style.cursor=panning?'move':hoveredIdx>=0?'grab':'default';
1478
1645
  });
1479
- canvas.addEventListener('mouseleave',function(){hoveredIdx=-1;});
1480
- canvas.addEventListener('click',function(e){
1646
+ canvas.addEventListener('mousedown',function(e){
1647
+ var rect=canvas.getBoundingClientRect();
1648
+ var mx=e.clientX-rect.left,my=e.clientY-rect.top;
1649
+ if(e.button===2){
1650
+ panning=true;panStartX=mx;panStartY=my;
1651
+ canvas.style.cursor='move';return;
1652
+ }
1653
+ if(hoveredIdx>=0){
1654
+ dragIdx=hoveredIdx;
1655
+ var wmx=(mx-panX)/zoom;
1656
+ var wmy=(my-panY)/zoom;
1657
+ dragOX=wmx-bubbles[dragIdx].x;
1658
+ dragOY=wmy-bubbles[dragIdx].y;
1659
+ canvas.style.cursor='grabbing';
1660
+ }
1661
+ });
1662
+ canvas.addEventListener('mouseup',function(){
1663
+ if(panning){panning=false;canvas.style.cursor='default';}
1664
+ if(dragIdx>=0){canvas.style.cursor='grab';dragIdx=-1;}
1665
+ });
1666
+ canvas.addEventListener('mouseleave',function(){hoveredIdx=-1;dragIdx=-1;panning=false;});
1667
+ canvas.addEventListener('dblclick',function(e){
1481
1668
  if(hoveredIdx<0)return;
1482
- var f=bubbles[hoveredIdx].f;
1483
- drillDownFile(f.path);
1669
+ drillDownFile(bubbles[hoveredIdx].f.path);
1484
1670
  });
1671
+ var zoom=1.0,panX=0,panY=0;
1672
+ canvas.addEventListener('wheel',function(e){
1673
+ e.preventDefault();
1674
+ var rect=canvas.getBoundingClientRect();
1675
+ var mx=(e.clientX-rect.left-panX)/zoom;
1676
+ var my=(e.clientY-rect.top-panY)/zoom;
1677
+ var oldZoom=zoom;
1678
+ zoom*=e.deltaY<0?1.08:0.93;
1679
+ zoom=Math.max(0.5,Math.min(2.5,zoom));
1680
+ panX+=(mx*oldZoom-mx*zoom);
1681
+ panY+=(my*oldZoom-my*zoom);
1682
+ bubbles.forEach(function(b){});
1683
+ },{passive:false});
1684
+ bubbles.forEach(function(b){b._baseR=b.r;});
1485
1685
  }
1486
1686
  function drillDownFile(path){
1487
1687
  var dd=document.getElementById('metrics-drilldown');
@@ -1560,7 +1760,7 @@ function loadAllMetrics(){
1560
1760
  fetch('/__dev/api/metrics/full').then(function(r){return r.json()}).then(function(d){
1561
1761
  _metricsFullData=d;
1562
1762
  if(d.error){document.getElementById('metrics-bubble').innerHTML='<p style="color:var(--danger);padding:1rem">'+d.error+'</p>';return;}
1563
- renderBubbleChart(d.file_metrics);
1763
+ renderBubbleChart(d.file_metrics,d.dependency_graph,d.scan_mode);
1564
1764
  var hm=document.getElementById('metrics-heatmap');
1565
1765
  var rows=d.file_metrics.map(function(f){
1566
1766
  var color=miColor(f.maintainability);
data/lib/tina4/events.rb CHANGED
@@ -81,6 +81,25 @@ module Tina4
81
81
  @listeners.keys
82
82
  end
83
83
 
84
+ # Fire an event asynchronously. Each listener runs in its own thread.
85
+ # Errors in listeners are silently caught.
86
+ #
87
+ # Tina4::Events.emit_async("user.created", user_data)
88
+ #
89
+ def emit_async(event, *args)
90
+ return unless @listeners&.key?(event)
91
+
92
+ @listeners[event].sort_by { |l| -(l[:priority] || 0) }.each do |listener|
93
+ Thread.new do
94
+ begin
95
+ listener[:callback].call(*args)
96
+ rescue => e
97
+ # Async emit silently catches errors
98
+ end
99
+ end
100
+ end
101
+ end
102
+
84
103
  # Remove all listeners for all events.
85
104
  def clear
86
105
  @listeners.clear
@@ -19,7 +19,12 @@ module Tina4
19
19
  if name
20
20
  @table_name = name
21
21
  else
22
- @table_name || self.name.split("::").last.downcase + "s"
22
+ base = self.name.split("::").last.downcase
23
+ # Pluralize by default (add "s") unless ORM_PLURAL_TABLE_NAMES is explicitly disabled
24
+ unless ENV.fetch("ORM_PLURAL_TABLE_NAMES", "").match?(/\A(false|0|no)\z/i)
25
+ base += "s" unless base.end_with?("s")
26
+ end
27
+ @table_name || base
23
28
  end
24
29
  end
25
30