json-object-editor
Version:
JOE the Json Object Editor | Platform Edition
334 lines (302 loc) • 13.3 kB
HTML
<html>
<head>
<meta charset="utf-8">
<title>JOE Plugins — Inventory</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}
.chip.app{background:#e0ecff;border-color:#b3c7ff}
.chip.async{background:#dcfce7;border-color:#16a34a}
.chip.protected{background:#fee2e2;border-color:#dc2626}
.badge{display:inline-block;padding:2px 6px;border-radius:10px;font-size:12px;background:#eef;border:1px solid #ccd}
.badge.warn{background:#f0c040;color:#000;border-color:#e5b93b}
.badge.ok{background:#0a7d00;color:#fff;border-color:#0a7d00}
.badge.info{background:#e5e7eb;color:#111827;border-color:#d1d5db}
.t-center{text-align:center}
th{background:#f6f8fa}
.status-ok{color:#0a7d00;font-size:22px}
.status-bad{color:#b00020;font-size:22px}
.row{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin:8px 0}
a[target]{text-decoration:none}
code{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px}
</style>
</head>
<body>
<div id="mcp-nav"></div>
<script src="/JsonObjectEditor/_www/mcp-nav.js"></script>
<h1>Plugin Inventory</h1>
<div class="small">
Overview of active JOE plugins and their async methods, with app usage.
Useful for verifying that methods like <code>chatgpt.autofill</code> and
<code>chatgpt.widgetStart</code> are present on this instance.
</div>
<div class="row" style="margin-top:12px;">
<label for="base" style="margin:0">Base URL</label>
<input id="base" value="" placeholder="http://localhost:2025" style="min-width:260px" />
<button id="refresh">Refresh</button>
<span id="status" class="small"></span>
</div>
<div class="row" style="margin-top:4px;">
<label for="appFilter" style="margin:0">Filter by App</label>
<select id="appFilter">
<option value="">All</option>
</select>
<label for="nameFilter" style="margin:0">Filter by Plugin</label>
<input id="nameFilter" placeholder="plugin name contains..." style="min-width:200px" />
</div>
<table>
<thead>
<tr>
<th class="sortable" data-key="name">Plugin <span class="sort-ind"></span></th>
<th>Path</th>
<th class="sortable" data-key="methodCount">Async Methods <span class="sort-ind"></span></th>
<th>Methods</th>
<th>Apps Using</th>
<th class="sortable" data-key="protectedCount">Protected <span class="sort-ind"></span></th>
<th>Notes</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 = $('appFilter');
var nameFilter = $('nameFilter');
base.value = base.value || location.origin;
function setStatus(msg, ok){
status.textContent = msg||'';
status.className = 'small ' + (ok===true?'status-ok': ok===false?'status-bad':'');
}
async function fetchJSON(url, opts){
const res = await fetch(url, opts);
if(!res.ok){
let body;
try{ body = await res.text(); }catch(_e){ body=''; }
throw new Error('HTTP '+res.status+' '+body);
}
return res.json();
}
async function callMCP(baseUrl, 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)) || {};
}
async function load(){
try{
setStatus('Loading...', null);
rows.innerHTML = '';
var baseUrl = base.value.replace(/\/$/,'');
// 1) Load plugin list via plugin-utils
var plugData = await fetchJSON(baseUrl + '/API/plugin/plugin-utils');
var plugins = (plugData && plugData.plugins) || {};
// 2) Load apps via MCP listApps so we can see which apps reference which plugins
var appMap = {};
try{
var la = await callMCP(baseUrl, 'listApps', {});
appMap = (la && la.apps) || {};
}catch(_e){ appMap = {}; }
// Build app -> plugins map and plugin -> apps usage map
var pluginApps = {}; // { pluginName: [appName,...] }
Object.keys(appMap || {}).forEach(function(appName){
var app = appMap[appName] || {};
var appPlugins = Array.isArray(app.plugins) ? app.plugins : [];
appPlugins.forEach(function(p){
if(!p) return;
pluginApps[p] = pluginApps[p] || [];
if (pluginApps[p].indexOf(appName) === -1){
pluginApps[p].push(appName);
}
});
});
// Populate app filter options
if (appFilterSel){
// Clear existing (keep "All")
while(appFilterSel.options.length > 1){
appFilterSel.remove(1);
}
Object.keys(appMap || {}).sort().forEach(function(a){
var opt=document.createElement('option');
opt.value=a;
opt.textContent=a;
appFilterSel.appendChild(opt);
});
}
// Normalize plugin rows
var data = Object.keys(plugins || {}).sort().map(function(name){
var p = plugins[name] || {};
var asyncMethods = Array.isArray(p.async) ? p.async.slice().sort() : [];
var topLevelMethods = Array.isArray(p.methods) ? p.methods.slice().sort() : [];
var usedBy = (pluginApps[name] || []).slice().sort();
var protectedList = Array.isArray(p.protected) ? p.protected : [];
// Union of async/top-level/protected so we show everything.
var methodSet = {};
asyncMethods.forEach(function(m){ if(m){ methodSet[m] = true; }});
topLevelMethods.forEach(function(m){ if(m){ methodSet[m] = true; }});
protectedList.forEach(function(m){ if(m){ methodSet[m] = true; }});
var allMethods = Object.keys(methodSet).sort();
return {
name: name,
path: p._pathname || '',
override: !!p._override,
methodsAll: allMethods,
asyncMethods: asyncMethods,
methodCount: asyncMethods.length,
usedBy: usedBy,
protected: protectedList,
protectedCount: protectedList.length
};
});
function getFilteredData(){
var appSel = (appFilterSel && appFilterSel.value) || '';
var nameSel = (nameFilter && nameFilter.value || '').toLowerCase().trim();
return data.filter(function(d){
if (appSel && (!d.usedBy || d.usedBy.indexOf(appSel) === -1)){
return false;
}
if (nameSel && d.name.toLowerCase().indexOf(nameSel) === -1){
return false;
}
return true;
});
}
var sortState = { key:'name', dir:'asc' };
function sortData(key){
if (sortState.key === key){ sortState.dir = (sortState.dir === 'asc') ? 'desc' : 'asc'; }
else {
sortState.key = key;
sortState.dir = (key === 'methodCount' || key === 'protectedCount') ? 'desc' : '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 (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' ? '▲' : '▼';
}
});
}
function renderTable(list){
rows.innerHTML = '';
(list||[]).forEach(function(item){
var tr = document.createElement('tr');
function td(html, cls){
var c=document.createElement('td');
if(cls){c.className=cls;}
c.innerHTML = html||'';
return c;
}
// Plugin name
tr.appendChild(td('<strong>'+item.name+'</strong>'));
// Path
tr.appendChild(td(item.path ? ('<code>'+item.path+'</code>') : '<span class="small">n/a</span>'));
// Async method count
tr.appendChild(td(String(item.methodCount), 't-center'));
// Methods list (all top-level + async). Color-code:
// - async methods: green chip
// - protected methods: red chip
var methodsHTML;
if (item.methodsAll && item.methodsAll.length){
methodsHTML = '<div class="chips">'+item.methodsAll.map(function(m){
var classes = ['chip'];
if (item.asyncMethods && item.asyncMethods.indexOf(m) !== -1){
classes.push('async');
}
if (item.protected && item.protected.indexOf(m) !== -1){
classes.push('protected');
}
return '<span class="'+classes.join(' ')+'">'+m+'</span>';
}).join(' ') + '</div>';
} else {
methodsHTML = '<span class="small">None</span>';
}
tr.appendChild(td(methodsHTML));
// Apps using
var appsHTML = item.usedBy.length
? '<div class="chips">'+item.usedBy.map(function(app){
var cls = 'chip app';
var link = '/JOE/'+app;
return '<a class="'+cls+'" href="'+link+'" target="_app_'+app+'">'+app+'</a>';
}).join(' ')+'</div>'
: '<span class="small">None</span>';
tr.appendChild(td(appsHTML));
// Protected list
var protHTML = item.protectedCount
? '<div class="chips">'+item.protected.map(function(m){ return '<span class="chip">'+m+'</span>'; }).join(' ')+'</div>'
: '<span class="small">None</span>';
tr.appendChild(td(protHTML, 't-center'));
// Notes
var notes = [];
if (item.override){
notes.push('<span class="badge warn">override</span>');
}
if (item.name === 'chatgpt'){
notes.push('<span class="badge info">AI / Responses+MCP</span>');
}
tr.appendChild(td(notes.join(' ') || '<span class="small">—</span>'));
rows.appendChild(tr);
});
}
// Attach header sort handlers once
Array.prototype.forEach.call(document.querySelectorAll('th.sortable'), function(th){
th.onclick = function(){ sortData(th.getAttribute('data-key')); };
});
if (appFilterSel){
appFilterSel.onchange = function(){ sortData(sortState.key || 'name'); };
}
if (nameFilter){
nameFilter.oninput = function(){ sortData(sortState.key || 'name'); };
}
sortData('name');
setStatus('Loaded '+Object.keys(plugins||{}).length+' plugins', true);
}catch(e){
setStatus(e.message||String(e), false);
}
}
refresh.onclick = load;
setTimeout(load, 50);
})();
</script>
</body>
</html>