json-object-editor
Version:
JOE the Json Object Editor | Platform Edition
325 lines (296 loc) • 13.5 kB
HTML
<html>
<head>
<meta charset="utf-8">
<title>JOE MCP — Schemas Health</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif;margin:20px}
h1{margin:8px 0}
.small{font-size:12px;color:#666}
table{width:100%;border-collapse:collapse;margin-top:10px}
th,td{border:1px solid #e1e4e8;padding:8px;text-align:left;font-size:14px}
th.sortable{cursor:pointer}
th.sorted-asc, th.sorted-desc{background:#eef}
th .sort-ind{font-size:12px;opacity:.6;margin-left:6px}
.chips{display:flex;gap:6px;flex-wrap:wrap}
.chip{display:inline-block;padding:2px 8px;border-radius:12px;background:#eef;border:1px solid #ccd;font-size:12px}
.chip.joe{background:#11bcd6;color:#fff;border-color:#0fa5bb}
.icon-cell{width:72px}
.icon64{width:64px;height:64px;display:flex;align-items:center;justify-content:center}
.icon64 svg{width:64px;height:64px;display:block}
.health-badge{display:inline-block;padding:2px 8px;border-radius:12px;font-size:12px;border:1px solid transparent}
.health-good{background:#0a7d00;color:#fff;border-color:#0a7d00}
.health-warn{background:#f0c040;color:#000;border-color:#e5b93b}
.health-bad{background:#d93025;color:#fff;border-color:#d93025}
.t-center{text-align:center}
th{background:#f6f8fa}
.status-ok{color:#0a7d00;font-size:22px}
.status-bad{color:#b00020;font-size:22px}
.badge{display:inline-block;padding:2px 6px;border-radius:10px;font-size:12px;background:#eef;border:1px solid #ccd}
a[target]{text-decoration:none}
</style>
</head>
<body>
<div id="mcp-nav"></div>
<script src="/JsonObjectEditor/_www/mcp-nav.js"></script>
<h1>Schema Health</h1>
<div class="small">Summary of all schemas available in this JOE instance.</div>
<div class="row" style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin:8px 0 16px;">
<label for="base" style="margin:0">Base URL</label>
<input id="base" value="" placeholder="http://localhost:2025" style="min-width:280px" />
<button id="refresh">Refresh</button>
<span id="status" class="small"></span>
</div>
<div class="row" style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin:4px 0 10px;">
<label for="appFilter" style="margin:0">Filter by App</label>
<select id="appFilter"><option value="">All</option></select>
</div>
<table>
<thead>
<tr>
<th>Icon</th>
<th class="sortable" data-key="name">Schema <span class="sort-ind"></span></th>
<th class="sortable" data-key="count">Count <span class="sort-ind"></span></th>
<th class="sortable" data-key="default">Default <span class="sort-ind"></span></th>
<th class="sortable" data-key="summary">Summary <span class="sort-ind"></span></th>
<th class="sortable" data-key="source">Source <span class="sort-ind"></span></th>
<th class="sortable" data-key="fieldcheck">Field Check <span class="sort-ind"></span></th>
<th>Outbound</th>
<th>Apps</th>
<th class="sortable" data-key="health">Synthesis Health <span class="sort-ind"></span></th>
<th>API (summary)</th>
</tr>
</thead>
<tbody id="rows"></tbody>
</table>
<script>
(function(){
var $ = function(id){ return document.getElementById(id); };
var base = $('base');
var refresh = $('refresh');
var status = $('status');
var rows = $('rows');
var appFilterSel = null;
base.value = base.value || location.origin;
var defaultSchemasFallback = ['user','group','goal','initiative','event','report','tag','status','workflow','list','notification','note','include','instance','setting'];
function setStatus(msg, ok){
status.textContent = msg||'';
status.className = 'small ' + (ok===true?'status-ok': ok===false?'status-bad':'');
}
async function fetchJSON(url){
const res = await fetch(url);
if(!res.ok){ throw new Error('HTTP '+res.status+' '+(await res.text())); }
return res.json();
}
function chunk(arr, size){
var out=[], i=0; for(i=0;i<arr.length;i+=size){ out.push(arr.slice(i,i+size)); } return out;
}
function check(mark){
return mark ? '<span class="status-ok">✓</span>' : '<span class="status-bad">✗</span>';
}
async function load(){
try{
setStatus('Loading...', null);
rows.innerHTML = '';
const baseUrl = base.value.replace(/\/$/,'');
const list = await fetchJSON(baseUrl + '/API/list/schemas');
// Fetch full schemas in chunks (includes curated summary when present)
var full = {};
var counts = {};
var chunks = chunk(list, 25);
for (var i=0;i<chunks.length;i++){
var names = chunks[i].join(',');
var got = await fetchJSON(baseUrl + '/API/schemas/' + encodeURIComponent(names));
var ds = await fetchJSON(baseUrl + '/API/datasets/' + encodeURIComponent(names));
Object.assign(full, got.schemas || {});
Object.assign(counts, (ds && ds.counts) || {});
}
// Fetch apps via MCP listApps
async function callMCP(method, params){
const url = baseUrl + '/mcp';
const body = { jsonrpc:'2.0', id:String(Date.now()), method:method, params:params||{} };
const resp = await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)});
if(!resp.ok){ throw new Error('HTTP '+resp.status+' '+(await resp.text())); }
const j = await resp.json();
return j && (j.result||j) || {};
}
var appMap = {};
try{
var la = await callMCP('listApps', {});
appMap = (la && la.apps) || {};
}catch(_e){ appMap = {}; }
var data = list.map(function(name){
var fs = full[name] || {};
var usedBy = [];
for (var appName in appMap){
var a = appMap[appName] || {};
var cols = Array.isArray(a.collections) ? a.collections : [];
if (cols.indexOf(name) !== -1){ usedBy.push(appName); }
}
// If schema is a default core schema, show only the JOE app chip to avoid noise
if ((!!fs.default_schema)){
usedBy = ['joe'];
}
var outbound = [];
try{
var rel = (fs.summary && fs.summary.relationships && fs.summary.relationships.outbound) || [];
outbound = rel.filter(function(r){ return r && r.field && r.targetSchema; })
.map(function(r){ return { field: r.field, schema: r.targetSchema }; });
}catch(_e){ outbound = []; }
var fieldCount = (fs.summary && typeof fs.summary.fieldCount === 'number') ? fs.summary.fieldCount : ((fs.summary && fs.summary.fields && fs.summary.fields.length) || 0);
var fieldCountBase = (fs.summary && typeof fs.summary.fieldCountBase === 'number') ? fs.summary.fieldCountBase : fieldCount;
var fieldCoverage = fieldCountBase ? Math.round((fieldCount/fieldCountBase)*100) : 0;
return {
name: name,
count: counts[name] || 0,
default: !!fs.default_schema || false,
summary: !!fs.summary,
source: (fs.summary && fs.summary.source) || (fs.__origin === 'core' ? 'core' : 'instance'),
apiUrl: baseUrl + '/API/schema/' + encodeURIComponent(name) + '?summaryOnly=true',
apps: usedBy.sort(),
outbound: outbound,
fieldcheck: fieldCoverage,
fieldcheckText: fieldCount + '/' + fieldCountBase + ' ('+fieldCoverage+'%)',
menuicon: fs.menuicon || ''
};
});
function computeHealth(item){
var signals = 4; // Update when adding more health signals
var step = 100/signals;
var score = 0;
if ((item.count||0) > 0) score += step;
if (item.menuicon) score += step;
if (!!item.summary) score += step;
if ((item.apps||[]).length > 0) score += step;
return Math.round(score);
}
// Read app parameter from URL
var urlParams = new URLSearchParams(window.location.search);
var urlApp = urlParams.get('app');
// Populate app filter options
appFilterSel = document.getElementById('appFilter');
if (appFilterSel){
var appNames = Object.keys(appMap||{}).sort();
appNames.forEach(function(a){
var opt=document.createElement('option'); opt.value=a; opt.textContent=a; appFilterSel.appendChild(opt);
});
// Set filter from URL parameter if present (before initial render)
if (urlApp) {
// Check if the app exists in the options
for (var i = 0; i < appFilterSel.options.length; i++) {
if (appFilterSel.options[i].value === urlApp) {
appFilterSel.value = urlApp;
break;
}
}
}
}
function getFilteredData(){
var selected = appFilterSel && appFilterSel.value || '';
if (!selected) return data;
return data.filter(function(d){ return (d.apps||[]).indexOf(selected) !== -1; });
}
function renderTable(sorted){
rows.innerHTML = '';
(sorted||[]).forEach(function(item){
var health = computeHealth(item);
var hClass = health === 100 ? 'health-good' : (health >= 75 ? 'health-warn' : 'health-bad');
var tr = document.createElement('tr');
function td(html, cls){ var c=document.createElement('td'); if(cls){c.className=cls;} c.innerHTML=html||''; return c; }
function safeIcon(svg){
try{
if(!svg || svg.indexOf('<svg') === -1) return '';
var m = svg.match(/<path[^>]*d="([^"]+)"/i);
if (m && m[1] && !/^[Mm]/.test(m[1])) return '';
return svg;
}catch(_e){ return ''; }
}
var iconHTML = safeIcon(item.menuicon);
tr.appendChild(td(iconHTML ? ('<div class="icon64">'+iconHTML+'</div>') : '', 'icon-cell'));
tr.appendChild(td('<strong>'+item.name+'</strong>'));
tr.appendChild(td(String(item.count), 't-center'));
tr.appendChild(td(check(item.default), 't-center'));
tr.appendChild(td(check(item.summary)));
tr.appendChild(td(item.source === 'core' ? 'Core' : 'Instance', 't-center'));
tr.appendChild(td(item.fieldcheckText||'', 't-center'));
tr.appendChild(td('<div class="chips">'+ (item.outbound||[]).map(function(ln){ return '<span class="chip">'+ln.field+' → '+ln.schema+'</span>'; }).join(' ') +'</div>'));
tr.appendChild(td('<div class="chips">'+ (item.apps||[]).map(function(app){ var cls = (app==='joe'?'chip joe':'chip'); var link='/JOE/'+app+'#'+item.name; return '<a class="'+cls+'" href="'+link+'" target="_app_'+app+'">'+app+'</a>'; }).join(' ') +'</div>'));
tr.appendChild(td('<span class="health-badge '+hClass+'">'+health+'%</span>', 't-center'));
tr.appendChild(td('<a href="'+item.apiUrl+'" target="_schemas_api_'+item.name+'">/API/schema/'+item.name+'?summaryOnly=true</a>'));
rows.appendChild(tr);
});
}
var sortState = { key:null, dir:'asc' };
function sortData(key){
if (sortState.key === key){ sortState.dir = (sortState.dir === 'asc') ? 'desc' : 'asc'; }
else {
sortState.key = key;
// For boolean/numeric columns, default to true-first or highest-first
if (key === 'default' || key === 'summary' || key === 'count' || key === 'health') { sortState.dir = 'desc'; }
else { sortState.dir = 'asc'; }
}
var dir = sortState.dir === 'asc' ? 1 : -1;
var baseList = getFilteredData();
var sorted = baseList.slice().sort(function(a,b){
var av = a[key], bv = b[key];
if (key === 'health'){ av = computeHealth(a); bv = computeHealth(b); }
// Normalize booleans for deterministic sort
if (typeof av === 'boolean') { av = av ? 1 : 0; }
if (typeof bv === 'boolean') { bv = bv ? 1 : 0; }
if (typeof av === 'string' && typeof bv === 'string'){
av = av.toLowerCase(); bv = bv.toLowerCase();
}
if (av > bv) return 1*dir;
if (av < bv) return -1*dir;
return 0;
});
renderTable(sorted);
updateHeaderIndicators();
}
function updateHeaderIndicators(){
var ths = document.querySelectorAll('th.sortable');
Array.prototype.forEach.call(ths, function(th){
th.classList.remove('sorted-asc','sorted-desc');
var ind = th.querySelector('.sort-ind');
if (ind) ind.textContent = '';
if (th.getAttribute('data-key') === sortState.key){
th.classList.add(sortState.dir === 'asc' ? 'sorted-asc' : 'sorted-desc');
if (ind) ind.textContent = sortState.dir === 'asc' ? '▲' : '▼';
}
});
}
// Default sort by name asc (filter will be applied automatically if appFilterSel.value is set)
sortData('name');
updateHeaderIndicators();
// Attach header sort handlers
Array.prototype.forEach.call(document.querySelectorAll('th.sortable'), function(th){
th.onclick = function(){ sortData(th.getAttribute('data-key')); };
});
// Filter handler
if (appFilterSel){
appFilterSel.onchange = function(){
// Update URL parameter
var selectedApp = appFilterSel.value || '';
var url = new URL(window.location);
if (selectedApp) {
url.searchParams.set('app', selectedApp);
} else {
url.searchParams.delete('app');
}
window.history.replaceState({}, '', url);
// Apply filter
sortData(sortState.key || 'name');
};
}
setStatus('Loaded '+list.length+' schemas', true);
}catch(e){
setStatus(e.message||String(e), false);
}
}
refresh.onclick = load;
setTimeout(load, 50);
})();
</script>
</body>
</html>