mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-22 04:41:23 +00:00
* Chart-transform plugin capability * PR comments * Cleanup * Fix markdown * CodeRabbit feedback Signed-off-by: Joe <jphinspace@gmail.com> --------- Signed-off-by: Joe <jphinspace@gmail.com> Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
1754 lines
112 KiB
JavaScript
1754 lines
112 KiB
JavaScript
(function () {
|
|
'use strict';
|
|
|
|
const state = window.__feedBackCapabilityInspector || (window.__feedBackCapabilityInspector = {});
|
|
state.render = render;
|
|
if (state.installed) return;
|
|
state.installed = true;
|
|
const DOCUMENTATION_ONLY_DOMAINS = new Set([
|
|
'backend.routes',
|
|
'ui.player-controls',
|
|
'ui.player-panels',
|
|
'ui.player-overlays',
|
|
'plugins',
|
|
'jobs',
|
|
'midi-control',
|
|
'tempo-clock',
|
|
]);
|
|
const DOMAIN_GROUPS = Object.freeze([
|
|
Object.freeze({
|
|
id: 'app-library',
|
|
label: 'Application and Library',
|
|
summary: 'Navigation, plugin screens, settings, library sources, and tunings.',
|
|
domains: Object.freeze(['ui.navigation', 'ui.plugin-screens', 'settings', 'library', 'tuning']),
|
|
}),
|
|
Object.freeze({
|
|
id: 'player-audio',
|
|
label: 'Player and Audio Runtime',
|
|
summary: 'Playback, renderer, chart-transform, mixer, monitoring, effects, and note-detection surfaces.',
|
|
domains: Object.freeze(['playback', 'visualization', 'chart-transform', 'audio-mix', 'audio-input', 'audio-monitoring', 'audio-effects', 'stems', 'note-detection']),
|
|
}),
|
|
Object.freeze({
|
|
id: 'plugin-defined',
|
|
label: 'Plugin-defined Domains',
|
|
summary: 'Domains declared by installed plugins outside the current core surface list.',
|
|
domains: Object.freeze([]),
|
|
}),
|
|
Object.freeze({
|
|
id: 'capability-runtime',
|
|
label: 'Capability Runtime',
|
|
summary: 'Diagnostics snapshots plus capability graph operations used by the inspector.',
|
|
domains: Object.freeze(['diagnostics', 'pipeline']),
|
|
}),
|
|
]);
|
|
const DOMAIN_ICONS = Object.freeze({
|
|
library: 'bookOpen',
|
|
playback: 'playCircle',
|
|
visualization: 'monitor',
|
|
'audio-mix': 'sliders',
|
|
'audio-effects': 'box',
|
|
'audio-monitoring': 'headphones',
|
|
stems: 'sliders',
|
|
'note-detection': 'activity',
|
|
'chart-transform': 'box',
|
|
diagnostics: 'fileSearch',
|
|
pipeline: 'activity',
|
|
'ui.navigation': 'list',
|
|
'ui.plugin-screens': 'monitor',
|
|
settings: 'sliders',
|
|
'backend.routes': 'network',
|
|
'ui.player-controls': 'sliders',
|
|
'ui.player-panels': 'monitor',
|
|
'ui.player-overlays': 'monitor',
|
|
plugins: 'puzzle',
|
|
jobs: 'history',
|
|
'midi-control': 'sliders',
|
|
'audio-input': 'plug',
|
|
'tempo-clock': 'history',
|
|
});
|
|
const ICON_PATHS = Object.freeze({
|
|
activity: '<path d="M22 12h-4l-3 7L9 5l-3 7H2"/>',
|
|
alert: '<path d="M12 3 2 21h20L12 3z"/><path d="M12 9v5"/><path d="M12 17h.01"/>',
|
|
bookOpen: '<path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/>',
|
|
box: '<path d="m21 8-9-5-9 5 9 5 9-5z"/><path d="M3 8v8l9 5 9-5V8"/><path d="M12 13v8"/>',
|
|
chevronDown: '<path d="m6 9 6 6 6-6"/>',
|
|
chevronRight: '<path d="m9 6 6 6-6 6"/>',
|
|
circleOff: '<circle cx="12" cy="12" r="9"/><path d="m5 5 14 14"/>',
|
|
coreCube: '<path d="M12 2.8 21 8.1v8.8L12 22l-9-5.1V8.1L12 2.8z"/><path d="M3 8.1 9 11.7"/><path d="M15 11.7 21 8.1"/><path d="M12 15.6V22"/><circle cx="12" cy="12.2" r="3.4"/>',
|
|
crown: '<path d="M2 6l5 5 5-8 5 8 5-5-2 13H4L2 6z"/><path d="M4 19h16"/>',
|
|
eye: '<path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/>',
|
|
fileSearch: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><circle cx="11.5" cy="14.5" r="2.5"/><path d="m13.3 16.3 2.2 2.2"/>',
|
|
flag: '<path d="M5 22V4"/><path d="M5 4h11l-1 5 1 5H5"/>',
|
|
headphones: '<path d="M3 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a9 9 0 0 1 18 0v7a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3"/>',
|
|
history: '<path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 3v6h6"/><path d="M12 7v5l3 2"/>',
|
|
info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v6"/><path d="M12 7h.01"/>',
|
|
list: '<path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/>',
|
|
lock: '<rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/>',
|
|
monitor: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/>',
|
|
network: '<circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><circle cx="12" cy="18" r="3"/><path d="m8.5 8.5 2 6"/><path d="m15.5 8.5-2 6"/>',
|
|
playCircle: '<circle cx="12" cy="12" r="10"/><path d="m10 8 6 4-6 4V8z"/>',
|
|
plug: '<path d="M12 22v-5"/><path d="M9 8V2"/><path d="M15 8V2"/><path d="M6 8h12v4a6 6 0 0 1-12 0V8z"/>',
|
|
puzzle: '<path d="M8 3h3a2 2 0 0 1 2 2v1.2a1.4 1.4 0 1 0 2 0V5a2 2 0 0 1 2-2h1a2 2 0 0 1 2 2v4h-1.2a1.4 1.4 0 1 0 0 2H20v4a2 2 0 0 1-2 2h-3v1.2a1.4 1.4 0 1 1-2 0V17H8a2 2 0 0 1-2-2v-3H4.8a1.4 1.4 0 1 1 0-2H6V5a2 2 0 0 1 2-2z"/>',
|
|
send: '<path d="m22 2-7 20-4-9-9-4 20-7z"/><path d="M22 2 11 13"/>',
|
|
shield: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 12 2 2 4-4"/>',
|
|
shieldAlert: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M12 8v5"/><path d="M12 16h.01"/>',
|
|
sliders: '<path d="M4 6h10"/><path d="M18 6h2"/><path d="M4 12h4"/><path d="M12 12h8"/><path d="M4 18h12"/><path d="M5 6a2 2 0 1 0 4 0 2 2 0 0 0-4 0z"/><path d="M8 12a2 2 0 1 0 4 0 2 2 0 0 0-4 0z"/><path d="M16 18a2 2 0 1 0 4 0 2 2 0 0 0-4 0z"/>',
|
|
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
|
});
|
|
const COMMAND_ICON = '<span data-legend-icon="command" class="inline-block h-3 w-3 rounded bg-orange-400 shadow-[0_0_12px_rgba(251,146,60,0.55)]" aria-hidden="true"></span>';
|
|
const OPERATION_ICON = '<span data-legend-icon="operation" class="inline-block h-3 w-3 rounded bg-purple-400 shadow-[0_0_12px_rgba(192,132,252,0.55)]" aria-hidden="true"></span>';
|
|
const EVENT_ICON = '<span data-legend-icon="event" class="inline-block h-3 w-3 rotate-45 rounded-sm bg-blue-400 shadow-[0_0_12px_rgba(96,165,250,0.55)]" aria-hidden="true"></span>';
|
|
const PROVIDER_COMMAND_LINK = '<span data-legend-line="provider-command" class="inline-flex h-px w-8 bg-purple-400"></span>';
|
|
const PROVIDER_OPERATION_LINK = '<span data-legend-line="provider-operation" class="inline-flex h-px w-8 bg-purple-300"></span>';
|
|
const PROVIDER_EVENT_LINK = '<span data-legend-line="provider-event" class="inline-flex h-px w-8 bg-violet-200"></span>';
|
|
const GRAPH_FILTERS = new Set(['all', 'commands', 'operations', 'events', 'shimmed']);
|
|
const LEGACY_SURFACE_ENDPOINTS = Object.freeze({
|
|
library: Object.freeze({
|
|
refresh: Object.freeze({ type: 'command', label: 'refresh' }),
|
|
select: Object.freeze({ type: 'command', label: 'select' }),
|
|
'sync-song': Object.freeze({ type: 'command', label: 'sync-song' }),
|
|
}),
|
|
});
|
|
const ATTRIBUTION_ONLY_SHIMS = Object.freeze({
|
|
library: new Set(['register_library_provider']),
|
|
});
|
|
const CYTOSCAPE_URL = '/static/vendor/cytoscape/cytoscape.min.js';
|
|
let cytoscapePromise = null;
|
|
let activeGraph = null;
|
|
let activeGraphs = new Map();
|
|
let renderScheduled = false;
|
|
|
|
function text(value) {
|
|
return String(value == null ? '' : value).replace(/[<>&]/g, ch => ({ '<': '<', '>': '>', '&': '&' }[ch]));
|
|
}
|
|
|
|
function attr(value) {
|
|
return text(value).replace(/["']/g, ch => ({ '"': '"', "'": ''' }[ch]));
|
|
}
|
|
|
|
function registry() {
|
|
return window.feedBack && window.feedBack.capabilities;
|
|
}
|
|
|
|
function snapshot() {
|
|
const api = registry();
|
|
if (!api || typeof api.snapshotDiagnostics !== 'function') return null;
|
|
try { return api.snapshotDiagnostics(); }
|
|
catch (err) { return { error: err && err.message ? err.message : String(err) }; }
|
|
}
|
|
|
|
function chips(values, className) {
|
|
const list = Array.isArray(values) ? values : [];
|
|
return list.map(item => `<span class="${className || 'bg-dark-700 text-gray-300'} px-2 py-1 rounded text-xs leading-none">${text(item)}</span>`).join('');
|
|
}
|
|
|
|
function pill(label, tone = 'muted') {
|
|
const classes = {
|
|
clean: 'bg-emerald-500/10 text-emerald-300 border-emerald-700/60',
|
|
observed: 'bg-teal-500/10 text-teal-300 border-teal-700/60',
|
|
used: 'bg-gold/10 text-gold border-gold/50',
|
|
warning: 'bg-amber-500/10 text-amber-300 border-amber-700/60',
|
|
conflict: 'bg-red-500/10 text-red-300 border-red-800/70',
|
|
muted: 'bg-dark-700 text-gray-400 border-gray-800',
|
|
info: 'bg-accent/10 text-accent-light border-accent/40',
|
|
};
|
|
return `<span class="inline-flex items-center rounded border px-2 py-1 text-xs leading-none ${classes[tone] || classes.muted}">${text(label)}</span>`;
|
|
}
|
|
|
|
function iconSvg(name, className = 'h-4 w-4') {
|
|
const path = ICON_PATHS[name] || ICON_PATHS.info;
|
|
return `<svg class="${attr(className)}" aria-hidden="true" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24">${path}</svg>`;
|
|
}
|
|
|
|
function iconBadge(iconName, label, tooltip, tone = 'muted') {
|
|
const classes = {
|
|
clean: 'bg-emerald-500/10 text-emerald-300 border-emerald-700/60',
|
|
observed: 'bg-teal-500/10 text-teal-300 border-teal-700/60',
|
|
used: 'bg-gold/10 text-gold border-gold/50',
|
|
warning: 'bg-amber-500/10 text-amber-300 border-amber-700/60',
|
|
conflict: 'bg-red-500/10 text-red-300 border-red-800/70',
|
|
muted: 'bg-dark-700 text-gray-400 border-gray-800',
|
|
info: 'bg-accent/10 text-accent-light border-accent/40',
|
|
};
|
|
const title = tooltip || label;
|
|
return `<span class="inline-flex items-center gap-1 rounded border px-2 py-1 text-xs leading-none ${classes[tone] || classes.muted}" title="${attr(title)}" aria-label="${attr(title)}" role="img">${iconSvg(iconName)}<span>${text(label)}</span></span>`;
|
|
}
|
|
|
|
function compactIconBadge(iconName, value, tooltip, tone = 'muted') {
|
|
const classes = {
|
|
clean: 'bg-emerald-500/10 text-emerald-300 border-emerald-700/60',
|
|
observed: 'bg-teal-500/10 text-teal-300 border-teal-700/60',
|
|
used: 'bg-gold/10 text-gold border-gold/50',
|
|
warning: 'bg-amber-500/10 text-amber-300 border-amber-700/60',
|
|
conflict: 'bg-red-500/10 text-red-300 border-red-800/70',
|
|
muted: 'bg-dark-700 text-gray-400 border-gray-800',
|
|
info: 'bg-accent/10 text-accent-light border-accent/40',
|
|
};
|
|
const hasValue = value !== '' && value != null;
|
|
return `<span class="inline-flex items-center gap-1 rounded border px-2 py-1 text-xs leading-none ${classes[tone] || classes.muted}" title="${attr(tooltip)}" aria-label="${attr(tooltip)}" role="img">${iconSvg(iconName)}${hasValue ? `<span>${text(value)}</span>` : ''}</span>`;
|
|
}
|
|
|
|
function availabilityBadge(availability) {
|
|
const value = availability || 'available';
|
|
const tone = value === 'available' ? 'clean' : (value === 'disabled' ? 'muted' : 'warning');
|
|
const icon = value === 'available' ? 'shield' : (value === 'disabled' ? 'circleOff' : 'shieldAlert');
|
|
const classes = {
|
|
clean: 'bg-emerald-500/10 text-emerald-300 border-emerald-700/60',
|
|
warning: 'bg-amber-500/10 text-amber-300 border-amber-700/60',
|
|
muted: 'bg-dark-700 text-gray-400 border-gray-800',
|
|
};
|
|
const title = `Availability: ${value}`;
|
|
return `<span class="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded border ${classes[tone] || classes.muted}" title="${attr(title)}" aria-label="${attr(title)}" role="img" data-availability-icon="${attr(value)}">${iconSvg(icon, 'h-4 w-4')}</span>`;
|
|
}
|
|
|
|
function roleIconBadge(iconName, title, tone = 'muted', role = '') {
|
|
const classes = {
|
|
owner: 'bg-gold/10 text-gold border-gold/50',
|
|
provider: 'bg-accent/10 text-accent-light border-accent/40',
|
|
observer: 'bg-teal-500/10 text-teal-300 border-teal-700/60',
|
|
requester: 'bg-gold/10 text-gold border-gold/50',
|
|
muted: 'bg-dark-700 text-gray-400 border-gray-800',
|
|
};
|
|
return `<span data-role-icon="${attr(role || title)}" class="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded border ${classes[tone] || classes.muted}" title="${attr(title)}" aria-label="${attr(title)}" role="img">${iconSvg(iconName, 'h-4 w-4')}</span>`;
|
|
}
|
|
|
|
function originIconBadge(iconName, title, tone = 'muted', origin = '') {
|
|
const classes = {
|
|
core: 'bg-blue-500/10 text-blue-300 border-blue-700/60',
|
|
external: 'bg-dark-700 text-gray-400 border-gray-800',
|
|
muted: 'bg-dark-700 text-gray-400 border-gray-800',
|
|
};
|
|
return `<span data-origin-icon="${attr(origin || title)}" class="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded border ${classes[tone] || classes.muted}" title="${attr(title)}" aria-label="${attr(title)}" role="img">${iconSvg(iconName, 'h-4 w-4')}</span>`;
|
|
}
|
|
|
|
function participantOriginIcon(participant) {
|
|
const pluginId = participant && participant.pluginId;
|
|
if (!pluginId || pluginId === 'No owner/provider') return '';
|
|
const providerId = participantProviderId(participant);
|
|
const coreOrigin = pluginId === 'core' || String(pluginId).startsWith('core.') || providerId === 'local';
|
|
const roleLabel = hasRole(participant, 'owner')
|
|
? 'owner'
|
|
: ((providerId || hasRole(participant, 'provider')) ? 'provider' : 'participant');
|
|
const label = `${coreOrigin ? 'Core' : 'Non-core'} ${roleLabel}`;
|
|
return coreOrigin
|
|
? originIconBadge('coreCube', label, 'core', 'core')
|
|
: originIconBadge('puzzle', label, 'external', 'non-core');
|
|
}
|
|
|
|
function participantProviderId(participant) {
|
|
const policy = participant && participant.providerPolicy && typeof participant.providerPolicy === 'object' ? participant.providerPolicy : {};
|
|
return String(policy.providerId || '').trim();
|
|
}
|
|
|
|
function providerRoleIcon(participant) {
|
|
const providerId = participantProviderId(participant);
|
|
if (providerId || hasRole(participant, 'provider')) return roleIconBadge('plug', providerId ? `Provider: ${providerId}` : 'Provider participant', 'provider', 'provider');
|
|
return '';
|
|
}
|
|
|
|
function ownerHeaderIcons(participant) {
|
|
if (!participant) return '';
|
|
if (hasRole(participant, 'owner')) {
|
|
const kind = String(participant.kind || '').trim();
|
|
const ownership = String(participant.ownership || '').trim();
|
|
const secondaryIcon = kind === 'provider-coordinator' || ownership === 'multi-provider'
|
|
? roleIconBadge('network', 'Provider coordinator', 'provider', 'provider-coordinator')
|
|
: roleIconBadge('plug', 'Capability provider', 'provider', 'provider');
|
|
return `${roleIconBadge('crown', 'Owner', 'owner', 'owner')}${participantOriginIcon(participant)}${secondaryIcon}`;
|
|
}
|
|
return `${providerRoleIcon(participant)}${participantOriginIcon(participant)}`;
|
|
}
|
|
|
|
function participantTooltip(participant) {
|
|
const pluginId = participant && participant.pluginId ? participant.pluginId : 'unknown';
|
|
const roles = Array.isArray(participant && participant.roles) && participant.roles.length ? participant.roles.join(', ') : 'participant';
|
|
const parts = [participant && participant._shimOnly ? `Shim: ${pluginId}` : `Plugin: ${pluginId}`, `Roles: ${roles}`];
|
|
const providerId = participantProviderId(participant);
|
|
if (providerId) parts.push(`Provider: ${providerId}`);
|
|
parts.push(`Registration: ${participant && participant.runtime ? 'runtime' : 'manifest'}`);
|
|
parts.push(`Availability: ${participant && participant.availability ? participant.availability : 'available'}`);
|
|
if (participant && participant.safety) parts.push(`Safety: ${participant.safety}`);
|
|
if (participant && hasRole(participant, 'owner') && participant.kind) parts.push(`Kind: ${participant.kind}`);
|
|
if (participant && hasRole(participant, 'owner') && participant.ownership && !participant.kind) parts.push(`Ownership: ${participant.ownership}`);
|
|
if (participant && participant._shimOnly) parts.push('Source: legacy compatibility shim');
|
|
return parts.join(' | ');
|
|
}
|
|
|
|
function copyButton(value, label = 'Copy') {
|
|
if (!value) return '';
|
|
return `<button type="button" data-copy-surface="${attr(value)}" class="rounded border border-gray-800 bg-dark-700 px-2 py-1 text-[11px] leading-none text-gray-400 hover:text-white hover:border-gray-600 transition">${text(label)}</button>`;
|
|
}
|
|
|
|
function metricCard(label, value, tone = 'muted') {
|
|
const toneClass = {
|
|
clean: 'border-emerald-800/60 bg-emerald-500/5',
|
|
warning: 'border-amber-800/60 bg-amber-500/5',
|
|
conflict: 'border-red-800/70 bg-red-500/5',
|
|
used: 'border-gold/40 bg-gold/5',
|
|
muted: 'border-gray-800 bg-dark-800/70',
|
|
}[tone] || 'border-gray-800 bg-dark-800/70';
|
|
return `
|
|
<div class="rounded-lg border ${toneClass} px-3 py-2" data-summary-card="metric" data-tone="${attr(tone)}">
|
|
<div data-summary-row>
|
|
<span class="text-[11px] uppercase tracking-wide text-gray-500" data-summary-label>${text(label)}</span>
|
|
<span class="text-lg font-semibold text-white" data-summary-value>${text(value)}</span>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function isAttributionOnlyShim(shim) {
|
|
const attributionOnly = ATTRIBUTION_ONLY_SHIMS[String(shim && shim.capability || '')];
|
|
return !!(attributionOnly && attributionOnly.has(String(shim && shim.legacySurface || '')));
|
|
}
|
|
|
|
function totalShimHits(shims) {
|
|
return (Array.isArray(shims) ? shims : []).reduce((sum, shim) => (
|
|
isAttributionOnlyShim(shim) ? sum : sum + Number(shim && shim.hitCount || 0)
|
|
), 0);
|
|
}
|
|
|
|
function compatibilityState(conflicts, warningCount = 0) {
|
|
const count = Array.isArray(conflicts) ? conflicts.length : 0;
|
|
if (count) return { label: 'Conflicts', tone: 'conflict' };
|
|
if (warningCount) return { label: 'Warnings', tone: 'warning' };
|
|
return { label: 'Clean', tone: 'clean' };
|
|
}
|
|
|
|
function reviewInfo(pipeline) {
|
|
const review = pipeline && pipeline.review && typeof pipeline.review === 'object' ? pipeline.review : {};
|
|
return {
|
|
lifecycle: review.lifecycle || 'plugin-defined',
|
|
label: review.label || 'Plugin-defined',
|
|
tone: review.tone || 'info',
|
|
summary: review.summary || 'Declared by a plugin or test fixture rather than registered as a core FeedBack domain.',
|
|
};
|
|
}
|
|
|
|
function uniqueValues(items, field, fallback) {
|
|
const values = [];
|
|
for (const item of Array.isArray(items) ? items : []) {
|
|
const value = String(item && item[field] || fallback || '').trim();
|
|
if (value && !values.includes(value)) values.push(value);
|
|
}
|
|
if (!values.length && fallback) values.push(fallback);
|
|
return values;
|
|
}
|
|
|
|
function hasRole(participant, role) {
|
|
return Array.isArray(participant && participant.roles) && participant.roles.includes(role);
|
|
}
|
|
|
|
function isCoreOwner(participant) {
|
|
return participant && participant.pluginId === 'core' && hasRole(participant, 'owner');
|
|
}
|
|
|
|
function coreOwners(participants) {
|
|
return (Array.isArray(participants) ? participants : []).filter(isCoreOwner);
|
|
}
|
|
|
|
function nonCoreParticipants(participants) {
|
|
return (Array.isArray(participants) ? participants : []).filter(participant => participant && participant.pluginId !== 'core');
|
|
}
|
|
|
|
function ownerNames(participants) {
|
|
const owners = (Array.isArray(participants) ? participants : []).filter(participant => hasRole(participant, 'owner'));
|
|
return uniqueValues(owners, 'pluginId', '');
|
|
}
|
|
|
|
function ownerParticipants(participants) {
|
|
return (Array.isArray(participants) ? participants : []).filter(participant => hasRole(participant, 'owner'));
|
|
}
|
|
|
|
function uniqueList(values) {
|
|
const out = [];
|
|
for (const value of Array.isArray(values) ? values : []) {
|
|
const item = String(value || '').trim();
|
|
if (item && !out.includes(item)) out.push(item);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function capabilityItems(participants, field) {
|
|
const items = (Array.isArray(participants) ? participants : []).flatMap(participant => {
|
|
if (!participant) return [];
|
|
const values = Array.isArray(participant[field]) ? participant[field] : [];
|
|
if (field === 'events') return [...values, ...(Array.isArray(participant.observes) ? participant.observes : []), ...(Array.isArray(participant.emits) ? participant.emits : [])];
|
|
return values;
|
|
});
|
|
return uniqueList(items);
|
|
}
|
|
|
|
function shimEndpoint(surface, capability = '') {
|
|
const attributionOnly = ATTRIBUTION_ONLY_SHIMS[String(capability || '')];
|
|
if (attributionOnly && attributionOnly.has(String(surface || ''))) return null;
|
|
const mapped = (LEGACY_SURFACE_ENDPOINTS[String(capability || '')] || {})[String(surface || '')];
|
|
if (mapped) return mapped;
|
|
return { type: 'command', label: String(surface || '').trim() };
|
|
}
|
|
|
|
function shimEndpoints(shims, type, onlyHits) {
|
|
return uniqueList((Array.isArray(shims) ? shims : [])
|
|
.filter(shim => !onlyHits || Number(shim && shim.hitCount || 0) > 0)
|
|
.map(shim => shimEndpoint(shim && shim.legacySurface, shim && shim.capability))
|
|
.filter(endpoint => endpoint && endpoint.type === type)
|
|
.map(endpoint => endpoint.label)
|
|
.filter(Boolean));
|
|
}
|
|
|
|
function observedShimCommands(shims) {
|
|
return shimEndpoints(shims, 'command', true);
|
|
}
|
|
|
|
function observedShimEvents(shims) {
|
|
return shimEndpoints(shims, 'event', true);
|
|
}
|
|
|
|
function expectedShimCommands(expectedShims) {
|
|
return shimEndpoints(expectedShims, 'command', false);
|
|
}
|
|
|
|
function expectedShimEvents(expectedShims) {
|
|
return shimEndpoints(expectedShims, 'event', false);
|
|
}
|
|
|
|
function shimmedEndpointsBySource(shims) {
|
|
const bySource = new Map();
|
|
for (const shim of Array.isArray(shims) ? shims : []) {
|
|
if (!shim || Number(shim.hitCount || 0) <= 0) continue;
|
|
const source = String(shim.source || '').trim();
|
|
if (!source) continue;
|
|
const endpoint = shimEndpoint(shim.legacySurface, shim.capability);
|
|
if (!endpoint || !endpoint.label) continue;
|
|
const key = endpointKey(endpoint.type, endpoint.label);
|
|
const endpoints = bySource.get(source) || new Set();
|
|
endpoints.add(key);
|
|
bySource.set(source, endpoints);
|
|
}
|
|
return bySource;
|
|
}
|
|
|
|
function participantUses(participant, shims, expectedShims) {
|
|
const source = participant && participant.pluginId;
|
|
const participantShims = (Array.isArray(shims) ? shims : []).filter(shim => shim && shim.source === source);
|
|
const commands = uniqueList([...(participant && participant.commands || []), ...(participant && participant.requests || []), ...observedShimCommands(participantShims)]);
|
|
const operations = uniqueList([...(participant && participant.operations || [])]);
|
|
let events = uniqueList([...(participant && participant.events || []), ...(participant && participant.observes || []), ...(participant && participant.emits || []), ...observedShimEvents(participantShims)]);
|
|
if (!events.length && participant && hasRole(participant, 'observer')) events = expectedShimEvents(expectedShims);
|
|
return { commands, operations, events };
|
|
}
|
|
|
|
function currentGraphFilter() {
|
|
const value = String(state.domainGraphFilter || 'all');
|
|
return GRAPH_FILTERS.has(value) ? value : 'all';
|
|
}
|
|
|
|
function filteredCapabilities(commands, operations, events, filter) {
|
|
if (filter === 'commands') return { commands, operations: [], events: [] };
|
|
if (filter === 'operations') return { commands: [], operations, events: [] };
|
|
if (filter === 'events') return { commands: [], operations: [], events };
|
|
return { commands, operations, events };
|
|
}
|
|
|
|
function filteredGraphLinks(linkData, filter) {
|
|
if (filter === 'shimmed') return { ...linkData, links: linkData.links.filter(link => link.type === 'shimmed') };
|
|
if (filter === 'commands') return { ...linkData, links: linkData.links.filter(link => link.endpointType === 'command') };
|
|
if (filter === 'operations') return { ...linkData, links: linkData.links.filter(link => link.endpointType === 'operation') };
|
|
if (filter === 'events') return { ...linkData, links: linkData.links.filter(link => link.endpointType === 'event') };
|
|
return linkData;
|
|
}
|
|
|
|
function graphFilterButton(value, label) {
|
|
const active = currentGraphFilter() === value;
|
|
const classes = active ? 'bg-purple-500/40 text-white border-purple-400/40' : 'border-gray-800 text-gray-400 hover:text-white hover:border-gray-600';
|
|
return `<button type="button" data-domain-graph-filter="${attr(value)}" aria-pressed="${active ? 'true' : 'false'}" class="rounded border px-2 py-1 transition ${classes}">${text(label)}</button>`;
|
|
}
|
|
|
|
function endpointKey(type, value) {
|
|
return `${type}:${String(value || '')}`;
|
|
}
|
|
|
|
function participantEndpointKey(index, type, value) {
|
|
return `${index}:${endpointKey(type, value)}`;
|
|
}
|
|
|
|
function graphCollapsedGroups() {
|
|
state.graphCollapsedGroups = state.graphCollapsedGroups || {};
|
|
return state.graphCollapsedGroups;
|
|
}
|
|
|
|
function graphGroupStateKey(domain, side, index, type) {
|
|
return [domain || 'unknown', side || 'provider', index == null ? 'all' : String(index), type || 'event'].join('|');
|
|
}
|
|
|
|
function isGraphGroupCollapsed(domain, side, index, type) {
|
|
return !!graphCollapsedGroups()[graphGroupStateKey(domain, side, index, type)];
|
|
}
|
|
|
|
function graphCapabilityGroupPortKey(type) {
|
|
return `group:${type}`;
|
|
}
|
|
|
|
function participantGroupPortKey(index, type) {
|
|
return `${index}:group:${type}`;
|
|
}
|
|
|
|
function endpointIcon(type, graphKey = '', linkKind = '', flow = '') {
|
|
const flowKey = flow || type;
|
|
const colorClass = flowKey === 'provider-command'
|
|
? 'bg-purple-400 border-purple-200 shadow-[0_0_12px_rgba(192,132,252,0.65)]'
|
|
: flowKey === 'provider-operation'
|
|
? 'bg-purple-300 border-purple-100 shadow-[0_0_12px_rgba(216,180,254,0.65)]'
|
|
: flowKey === 'provider-event'
|
|
? 'bg-violet-200 border-violet-100 shadow-[0_0_12px_rgba(221,214,254,0.6)]'
|
|
: type === 'command'
|
|
? 'bg-orange-400 border-orange-200 shadow-[0_0_12px_rgba(251,146,60,0.65)]'
|
|
: type === 'operation'
|
|
? 'bg-purple-400 border-purple-200 shadow-[0_0_12px_rgba(192,132,252,0.65)]'
|
|
: 'bg-blue-400 border-blue-200 shadow-[0_0_12px_rgba(96,165,250,0.65)]';
|
|
const shapeClass = type === 'event' ? 'rotate-45 rounded-sm' : 'rounded';
|
|
const dataAttr = graphKey ? ` data-graph-${linkKind ? 'participant' : 'capability'}-port="${attr(graphKey)}"` : '';
|
|
return `<span class="inline-block h-3 w-3 shrink-0 border ${shapeClass} ${colorClass}" data-endpoint-icon="${attr(type)}" data-endpoint-flow="${attr(flowKey)}"${dataAttr} aria-hidden="true"></span>`;
|
|
}
|
|
|
|
function participantEndpointKind(participant, type, item, shims) {
|
|
if (participant && participant._shimOnly) return 'shimmed';
|
|
const shimmed = shimmedEndpointsBySource(shims).get(participant && participant.pluginId);
|
|
return shimmed && shimmed.has(endpointKey(type, item)) ? 'shimmed' : 'observed';
|
|
}
|
|
|
|
function graphLinkFlow(participant, endpointType) {
|
|
if (endpointType === 'command' && hasRole(participant, 'provider')) return 'provider-command';
|
|
if (endpointType === 'operation' && hasRole(participant, 'provider')) return 'provider-operation';
|
|
if (endpointType === 'event' && hasRole(participant, 'provider')) return 'provider-event';
|
|
return endpointType;
|
|
}
|
|
|
|
function graphLinkColor(flow) {
|
|
if (flow === 'provider-command') return '#c084fc';
|
|
if (flow === 'provider-operation') return '#d8b4fe';
|
|
if (flow === 'operation') return '#c084fc';
|
|
if (flow === 'provider-event') return '#ddd6fe';
|
|
if (flow === 'event') return '#6ea8ff';
|
|
return '#fb923c';
|
|
}
|
|
|
|
function graphLinkTitle(link) {
|
|
if (link && link.flow === 'provider-command') return 'Provider command link';
|
|
if (link && link.flow === 'provider-operation') return 'Provider operation link';
|
|
if (link && link.endpointType === 'operation') return 'Operation link';
|
|
if (link && link.flow === 'provider-event') return 'Provider event link';
|
|
if (link && link.endpointType === 'event') return 'Event link';
|
|
return 'Command link';
|
|
}
|
|
|
|
function loadCytoscape() {
|
|
if (window.cytoscape) return Promise.resolve(window.cytoscape);
|
|
if (cytoscapePromise) return cytoscapePromise;
|
|
cytoscapePromise = new Promise((resolve, reject) => {
|
|
const script = document.createElement('script');
|
|
script.src = CYTOSCAPE_URL;
|
|
script.async = true;
|
|
script.onload = () => window.cytoscape ? resolve(window.cytoscape) : reject(new Error('Cytoscape did not initialize'));
|
|
script.onerror = () => reject(new Error('Unable to load Cytoscape'));
|
|
(document.head || document.body || document.documentElement).appendChild(script);
|
|
});
|
|
return cytoscapePromise;
|
|
}
|
|
|
|
function destroyActiveGraph(domain) {
|
|
const graph = activeGraphs.get(domain);
|
|
if (graph && typeof graph.destroy === 'function') graph.destroy();
|
|
activeGraphs.delete(domain);
|
|
if (activeGraph === graph) activeGraph = null;
|
|
if (!activeGraph && activeGraphs.size) activeGraph = activeGraphs.values().next().value || null;
|
|
state.activeGraph = activeGraph;
|
|
state.activeGraphs = activeGraphs;
|
|
}
|
|
|
|
function destroyActiveGraphs() {
|
|
activeGraphs.forEach(graph => {
|
|
if (graph && typeof graph.destroy === 'function') graph.destroy();
|
|
});
|
|
activeGraphs = new Map();
|
|
activeGraph = null;
|
|
state.activeGraph = null;
|
|
state.activeGraphs = activeGraphs;
|
|
}
|
|
|
|
function rememberActiveGraph(domain, graph) {
|
|
destroyActiveGraph(domain);
|
|
activeGraphs.set(domain, graph);
|
|
activeGraph = graph;
|
|
state.activeGraph = graph;
|
|
state.activeGraphs = activeGraphs;
|
|
}
|
|
|
|
function graphModel(pipeline, shims, expectedShims, filter = currentGraphFilter()) {
|
|
const participants = Array.isArray(pipeline.participants) ? pipeline.participants : [];
|
|
const providers = ownerParticipants(participants);
|
|
const users = graphParticipants(participants, shims, hasMultiProviderOwner(participants));
|
|
const allCommands = uniqueList([...capabilityItems(providers.length ? providers : participants, 'commands'), ...expectedShimCommands(expectedShims), ...observedShimCommands(shims)]);
|
|
const allOperations = uniqueList([...capabilityItems(providers.length ? providers : participants, 'operations')]);
|
|
const allEvents = uniqueList([...capabilityItems(providers.length ? providers : participants, 'events'), ...expectedShimEvents(expectedShims), ...observedShimEvents(shims)]);
|
|
const activeFilter = GRAPH_FILTERS.has(filter) ? filter : 'all';
|
|
const { commands, operations, events } = filteredCapabilities(allCommands, allOperations, allEvents, activeFilter);
|
|
const linkData = filteredGraphLinks(domainGraphLinks({ commands, operations, events }, users, shims, expectedShims), activeFilter);
|
|
const allLinkData = domainGraphLinks({ commands: allCommands, operations: allOperations, events: allEvents }, users, shims, expectedShims);
|
|
return { participants, providers, users, allCommands, allOperations, allEvents, commands, operations, events, linkData, allLinkData };
|
|
}
|
|
|
|
function mountDomainGraph(pipeline, shims, expectedShims) {
|
|
const ids = graphDomIds(pipeline && pipeline.name);
|
|
const container = document.getElementById(ids.cyId);
|
|
const frame = document.getElementById(ids.frameId);
|
|
if (!container || !frame || !container.getBoundingClientRect || !frame.querySelectorAll) return;
|
|
const portAnchor = (element) => {
|
|
const frameRect = frame.getBoundingClientRect();
|
|
const rect = element.getBoundingClientRect();
|
|
const x = rect.left - frameRect.left + (rect.width / 2);
|
|
return { x, y: rect.top - frameRect.top + (rect.height / 2) };
|
|
};
|
|
const portByKey = (selector, datasetKey, key) => Array.from(frame.querySelectorAll(selector)).find(element => element.dataset && element.dataset[datasetKey] === key);
|
|
loadCytoscape().then(cytoscape => {
|
|
if (!container.isConnected) return;
|
|
const schedule = window.requestAnimationFrame ? window.requestAnimationFrame.bind(window) : callback => setTimeout(callback, 0);
|
|
schedule(() => {
|
|
if (!container.isConnected) return;
|
|
const model = graphModel(pipeline, shims, expectedShims);
|
|
const width = Math.max(760, Math.round(frame.getBoundingClientRect().width || container.clientWidth || 0));
|
|
const height = Math.max(360, Math.round(frame.getBoundingClientRect().height || container.clientHeight || 0));
|
|
container.style.width = `${width}px`;
|
|
container.style.height = `${height}px`;
|
|
container.innerHTML = '';
|
|
const elements = [];
|
|
for (let index = 0; index < model.linkData.links.length; index += 1) {
|
|
const link = model.linkData.links[index];
|
|
const sourceKey = isGraphGroupCollapsed(pipeline.name, 'provider', 'all', link.endpointType)
|
|
? graphCapabilityGroupPortKey(link.endpointType)
|
|
: endpointKey(link.endpointType, link.sourceLabel || link.label);
|
|
const targetKey = isGraphGroupCollapsed(pipeline.name, 'participant', link.participantIndex, link.endpointType)
|
|
? participantGroupPortKey(link.participantIndex, link.endpointType)
|
|
: participantEndpointKey(link.participantIndex, link.endpointType, link.label);
|
|
const source = portByKey('[data-graph-capability-port]', 'graphCapabilityPort', sourceKey);
|
|
const target = portByKey('[data-graph-participant-port]', 'graphParticipantPort', targetKey);
|
|
if (!source || !target) continue;
|
|
const sourcePoint = portAnchor(source);
|
|
const targetPoint = portAnchor(target);
|
|
const sourceId = `link-${index}-source`;
|
|
const targetId = `link-${index}-target`;
|
|
elements.push({ data: { id: sourceId }, position: sourcePoint });
|
|
elements.push({ data: { id: targetId }, position: targetPoint });
|
|
elements.push({ data: { id: `link-${index}`, source: sourceId, target: targetId, kind: link.type, endpointType: link.endpointType, flow: link.flow || link.endpointType, participantIndex: link.participantIndex, participantId: link.participantId || '', label: link.label, sourceLabel: link.sourceLabel || link.label } });
|
|
}
|
|
const graph = cytoscape({
|
|
container,
|
|
elements,
|
|
layout: { name: 'preset', fit: false },
|
|
minZoom: 1,
|
|
maxZoom: 1,
|
|
userZoomingEnabled: false,
|
|
userPanningEnabled: false,
|
|
boxSelectionEnabled: false,
|
|
autoungrabify: true,
|
|
style: [
|
|
{ selector: 'node', style: { width: 1, height: 1, opacity: 0, label: '' } },
|
|
{ selector: 'edge', style: { width: 1.55, 'curve-style': 'unbundled-bezier', 'control-point-distances': '96 -96', 'control-point-weights': '0.35 0.65', 'target-arrow-shape': 'none', opacity: 0.86, 'line-cap': 'round' } },
|
|
{ selector: 'edge[endpointType = "command"]', style: { 'line-color': '#fb923c' } },
|
|
{ selector: 'edge[flow = "provider-command"]', style: { 'line-color': '#c084fc' } },
|
|
{ selector: 'edge[endpointType = "operation"]', style: { 'line-color': '#c084fc' } },
|
|
{ selector: 'edge[flow = "provider-operation"]', style: { 'line-color': '#d8b4fe' } },
|
|
{ selector: 'edge[endpointType = "event"]', style: { 'line-color': '#6ea8ff' } },
|
|
{ selector: 'edge[flow = "provider-event"]', style: { 'line-color': '#ddd6fe' } },
|
|
{ selector: 'edge[kind = "shimmed"]', style: { 'line-style': 'dashed', 'line-dash-pattern': [9, 7] } },
|
|
{ selector: 'edge.dimmed', style: { opacity: 0.12, width: 1 } },
|
|
{ selector: 'edge.highlighted', style: { opacity: 1, width: 3.25, 'z-index': 10 } },
|
|
],
|
|
});
|
|
rememberActiveGraph(pipeline.name, graph);
|
|
graph.zoom(1);
|
|
graph.pan({ x: 0, y: 0 });
|
|
graph.resize();
|
|
applyGraphHover(state.graphHover || null);
|
|
});
|
|
}).catch(() => {
|
|
container.innerHTML = '<div class="flex h-full min-h-[18rem] items-center justify-center rounded-lg border border-gray-800 bg-dark-900/30 p-4 text-sm text-gray-500">Graph renderer unavailable. Showing fallback lanes below.</div>';
|
|
});
|
|
}
|
|
|
|
function graphEdgeMatches(edge, hover) {
|
|
if (!hover) return false;
|
|
const participantIndex = String(edge.data('participantIndex'));
|
|
const endpointType = String(edge.data('endpointType') || '');
|
|
const label = String(edge.data('label') || '');
|
|
const sourceLabel = String(edge.data('sourceLabel') || label);
|
|
if (hover.kind === 'participant') return participantIndex === String(hover.participantIndex);
|
|
if (hover.kind === 'provider-group') return endpointType === hover.endpointType;
|
|
if (hover.kind === 'provider-endpoint') return endpointType === hover.endpointType && sourceLabel === hover.label;
|
|
if (hover.kind === 'participant-group') return participantIndex === String(hover.participantIndex) && endpointType === hover.endpointType;
|
|
if (hover.kind === 'participant-endpoint') return participantIndex === String(hover.participantIndex) && endpointType === hover.endpointType && label === hover.label;
|
|
return false;
|
|
}
|
|
|
|
function graphProviderFocusKey(type, label) {
|
|
return `${type || ''}:${label || ''}`;
|
|
}
|
|
|
|
function setGraphFocusStyle(element, mode) {
|
|
if (!element || !element.style) return;
|
|
if (!mode) {
|
|
element.style.opacity = '';
|
|
element.style.filter = '';
|
|
element.style.fontWeight = '';
|
|
element.style.color = '';
|
|
return;
|
|
}
|
|
if (mode === 'active') {
|
|
element.style.opacity = '1';
|
|
element.style.filter = '';
|
|
element.style.fontWeight = '700';
|
|
element.style.color = '#f9fafb';
|
|
return;
|
|
}
|
|
element.style.opacity = '0.24';
|
|
element.style.filter = 'saturate(0.6)';
|
|
element.style.fontWeight = '';
|
|
element.style.color = '';
|
|
}
|
|
|
|
function graphFrames() {
|
|
if (!document.querySelectorAll) return [];
|
|
return Array.from(document.querySelectorAll('[data-domain-graph-frame]'));
|
|
}
|
|
|
|
function frameForDomain(domain) {
|
|
const ids = graphDomIds(domain);
|
|
return document.getElementById(ids.frameId);
|
|
}
|
|
|
|
function applyProviderHoverStyles(hover, matchingEdges, frame) {
|
|
const frames = frame ? [frame] : graphFrames();
|
|
if (!frames.length) return;
|
|
if (!hover) {
|
|
frames.forEach(frameElement => {
|
|
if (!frameElement || !frameElement.querySelectorAll) return;
|
|
Array.from(frameElement.querySelectorAll('[data-graph-provider-focus]'))
|
|
.forEach(target => setGraphFocusStyle(target, null));
|
|
});
|
|
return;
|
|
}
|
|
const activeTypes = new Set();
|
|
const activeLabels = new Set();
|
|
matchingEdges.forEach(edge => {
|
|
const type = String(edge.data('endpointType') || '');
|
|
const label = String(edge.data('sourceLabel') || edge.data('label') || '');
|
|
if (type) activeTypes.add(type);
|
|
if (type && label) activeLabels.add(graphProviderFocusKey(type, label));
|
|
});
|
|
if (hover.kind === 'provider-group' && hover.endpointType) activeTypes.add(hover.endpointType);
|
|
if (hover.kind === 'provider-endpoint' && hover.endpointType && hover.label) {
|
|
activeTypes.add(hover.endpointType);
|
|
activeLabels.add(graphProviderFocusKey(hover.endpointType, hover.label));
|
|
}
|
|
frames.forEach(frameElement => {
|
|
if (!frameElement || !frameElement.querySelectorAll) return;
|
|
const targets = Array.from(frameElement.querySelectorAll('[data-graph-provider-focus]'));
|
|
targets.forEach(target => {
|
|
const type = target.dataset.graphFocusType || '';
|
|
const label = target.dataset.graphFocusLabel || '';
|
|
const active = label
|
|
? activeLabels.has(graphProviderFocusKey(type, label))
|
|
: activeTypes.has(type);
|
|
setGraphFocusStyle(target, active ? 'active' : 'inactive');
|
|
});
|
|
});
|
|
}
|
|
|
|
function applyGraphHover(hover) {
|
|
state.graphHover = hover || null;
|
|
if (!activeGraphs.size) {
|
|
applyProviderHoverStyles(null, []);
|
|
return;
|
|
}
|
|
activeGraphs.forEach((graph, domain) => {
|
|
if (!graph || typeof graph.edges !== 'function') return;
|
|
const frame = frameForDomain(domain);
|
|
const edges = graph.edges();
|
|
if (!edges || typeof edges.removeClass !== 'function') return;
|
|
edges.removeClass('highlighted');
|
|
edges.removeClass('dimmed');
|
|
if (!hover || hover.domain !== domain) {
|
|
applyProviderHoverStyles(null, [], frame);
|
|
return;
|
|
}
|
|
const matchingEdges = [];
|
|
edges.forEach(edge => {
|
|
const matches = graphEdgeMatches(edge, hover);
|
|
if (matches) matchingEdges.push(edge);
|
|
edge.addClass(matches ? 'highlighted' : 'dimmed');
|
|
});
|
|
applyProviderHoverStyles(hover, matchingEdges, frame);
|
|
});
|
|
}
|
|
|
|
function graphHoverFromElement(element) {
|
|
if (!element || !element.dataset) return null;
|
|
const root = element.closest && element.closest('[data-domain-graph]');
|
|
const domain = root && root.dataset ? root.dataset.domainGraph : '';
|
|
const kind = element.dataset.graphFocusKind;
|
|
if (kind) {
|
|
return {
|
|
domain,
|
|
kind,
|
|
participantIndex: element.dataset.graphFocusParticipantIndex,
|
|
endpointType: element.dataset.graphFocusType || '',
|
|
label: element.dataset.graphFocusLabel || '',
|
|
};
|
|
}
|
|
if (element.dataset.graphHoverParticipant != null) {
|
|
return { domain, kind: 'participant', participantIndex: element.dataset.graphHoverParticipant };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function hasMultiProviderOwner(participants) {
|
|
return (Array.isArray(participants) ? participants : []).some(participant => hasRole(participant, 'owner') && participant && (participant.kind === 'provider-coordinator' || participant.ownership === 'multi-provider'));
|
|
}
|
|
|
|
function graphParticipants(participants, shims, includeProviders = false) {
|
|
const users = nonCoreParticipants(participants)
|
|
.filter(participant => !hasRole(participant, 'owner'))
|
|
.filter(participant => includeProviders || !hasRole(participant, 'provider'))
|
|
.slice();
|
|
const known = new Set(users.map(participant => participant.pluginId));
|
|
for (const shim of Array.isArray(shims) ? shims : []) {
|
|
const source = String(shim && shim.source || '').trim();
|
|
if (!source || source === 'core' || known.has(source)) continue;
|
|
users.push({
|
|
pluginId: source,
|
|
roles: ['participant'],
|
|
commands: [],
|
|
events: [],
|
|
runtime: true,
|
|
availability: 'available',
|
|
ownership: 'observer-only',
|
|
safety: 'safe',
|
|
_shimOnly: true,
|
|
});
|
|
known.add(source);
|
|
}
|
|
return users;
|
|
}
|
|
|
|
function graphParticipantCount(participants, users) {
|
|
const ids = new Set();
|
|
for (const participant of [...(Array.isArray(participants) ? participants : []), ...(Array.isArray(users) ? users : [])]) {
|
|
const id = String(participant && participant.pluginId || '').trim();
|
|
if (id) ids.add(id);
|
|
}
|
|
return ids.size;
|
|
}
|
|
|
|
function domainIconName(name, review) {
|
|
const icon = DOMAIN_ICONS[String(name || '')];
|
|
if (icon) return icon;
|
|
const lifecycle = review && review.lifecycle;
|
|
if (lifecycle === 'future-expansion') return 'history';
|
|
if (lifecycle === 'plugin-defined') return 'puzzle';
|
|
if (lifecycle === 'diagnostic') return 'fileSearch';
|
|
return 'box';
|
|
}
|
|
|
|
function expandedDomains() {
|
|
state.expandedDomains = state.expandedDomains || {};
|
|
return state.expandedDomains;
|
|
}
|
|
|
|
function isDomainExpanded(name, defaultExpanded) {
|
|
const expanded = expandedDomains();
|
|
return Object.prototype.hasOwnProperty.call(expanded, name) ? !!expanded[name] : !!defaultExpanded;
|
|
}
|
|
|
|
function panelIdForDomain(name) {
|
|
return `capability-domain-${String(name || 'unknown').replace(/[^A-Za-z0-9_-]+/g, '-')}`;
|
|
}
|
|
|
|
function graphDomIds(name) {
|
|
const base = `${panelIdForDomain(name)}-graph`;
|
|
return {
|
|
panelId: `${base}-panel`,
|
|
frameId: `${base}-frame`,
|
|
cyId: `${base}-cy`,
|
|
fallbackId: `${base}-fallback`,
|
|
};
|
|
}
|
|
|
|
function domainGroup(name) {
|
|
for (let index = 0; index < DOMAIN_GROUPS.length; index += 1) {
|
|
const group = DOMAIN_GROUPS[index];
|
|
const domainIndex = group.domains.indexOf(name);
|
|
if (domainIndex >= 0) return { ...group, groupIndex: index, domainIndex };
|
|
}
|
|
const fallbackIndex = DOMAIN_GROUPS.findIndex(group => group.id === 'plugin-defined');
|
|
const fallback = DOMAIN_GROUPS[fallbackIndex >= 0 ? fallbackIndex : DOMAIN_GROUPS.length - 1];
|
|
return { ...fallback, groupIndex: fallbackIndex >= 0 ? fallbackIndex : DOMAIN_GROUPS.length - 1, domainIndex: Number.MAX_SAFE_INTEGER };
|
|
}
|
|
|
|
function sortPipelines(pipelines) {
|
|
return (Array.isArray(pipelines) ? pipelines : []).slice().sort((a, b) => {
|
|
const ag = domainGroup(a && a.name);
|
|
const bg = domainGroup(b && b.name);
|
|
if (ag.groupIndex !== bg.groupIndex) return ag.groupIndex - bg.groupIndex;
|
|
if (ag.domainIndex !== bg.domainIndex) return ag.domainIndex - bg.domainIndex;
|
|
return String(a && a.name || '').localeCompare(String(b && b.name || ''));
|
|
});
|
|
}
|
|
|
|
function groupedPipelines(pipelines) {
|
|
const byGroup = new Map();
|
|
for (const pipeline of sortPipelines(pipelines)) {
|
|
const group = domainGroup(pipeline.name);
|
|
const entry = byGroup.get(group.id) || { group, pipelines: [] };
|
|
entry.pipelines.push(pipeline);
|
|
byGroup.set(group.id, entry);
|
|
}
|
|
return DOMAIN_GROUPS.map(group => byGroup.get(group.id)).filter(Boolean);
|
|
}
|
|
|
|
function participantRow(participant) {
|
|
const availability = participant.availability || (participant.enabled ? 'available' : 'disabled');
|
|
const availabilityTone = availability === 'available' ? 'clean' : (availability === 'disabled' ? 'muted' : 'warning');
|
|
return `
|
|
<div class="border border-gray-800 rounded-lg p-3 bg-dark-900/35">
|
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
<span class="font-semibold text-white">${text(participant.pluginId)}</span>
|
|
<div class="flex flex-wrap gap-1">
|
|
${pill(availability, availabilityTone)}
|
|
${pill(participant.runtime ? 'runtime' : 'manifest', participant.runtime ? 'info' : 'muted')}
|
|
${participant.incompatible ? pill('incompatible', 'conflict') : ''}
|
|
</div>
|
|
</div>
|
|
<div class="flex flex-wrap gap-1 mt-3">${chips(participant.roles, 'bg-dark-700 text-gray-300 border border-gray-800')}</div>
|
|
<div class="mt-3 grid gap-1 text-xs text-gray-500">
|
|
<div><span class="text-gray-400">Commands:</span> ${text((participant.commands || []).join(', ') || 'none')}</div>
|
|
<div><span class="text-gray-400">Operations:</span> ${text((participant.operations || []).join(', ') || 'none')}</div>
|
|
<div><span class="text-gray-400">Requests:</span> ${text((participant.requests || []).join(', ') || 'none')}</div>
|
|
<div><span class="text-gray-400">Observes:</span> ${text((participant.observes || []).join(', ') || 'none')}</div>
|
|
<div><span class="text-gray-400">Safety:</span> ${text(participant.safety || 'safe')} ${participant.kind ? `<span class="text-gray-700">/</span> <span class="text-gray-400">Kind:</span> ${text(participant.kind)}` : `<span class="text-gray-700">/</span> <span class="text-gray-400">Ownership:</span> ${text(participant.ownership || 'exclusive-owner')}`}</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function participantLabel(participant) {
|
|
const roles = Array.isArray(participant && participant.roles) ? participant.roles : [];
|
|
return `${participant && participant.pluginId ? participant.pluginId : 'unknown'}${roles.length ? ` (${roles.join(', ')})` : ''}`;
|
|
}
|
|
|
|
function participantChips(participants, emptyLabel, tone = 'muted') {
|
|
const list = Array.isArray(participants) ? participants : [];
|
|
if (!list.length) return `<span class="rounded border border-gray-800 bg-dark-900/35 px-2 py-1 text-xs text-gray-500">${text(emptyLabel)}</span>`;
|
|
const className = tone === 'observed'
|
|
? 'rounded border border-teal-700/60 bg-teal-500/10 px-2 py-1 text-xs text-teal-300'
|
|
: 'rounded border border-gray-800 bg-dark-700 px-2 py-1 text-xs text-gray-300';
|
|
return list.map(participant => `<span class="${className}" title="${attr(participantLabel(participant))}">${text(participantLabel(participant))}</span>`).join('');
|
|
}
|
|
|
|
function capabilityRelationshipRows(pipelines) {
|
|
return sortPipelines(pipelines).map(pipeline => {
|
|
const participants = Array.isArray(pipeline && pipeline.participants) ? pipeline.participants : [];
|
|
return {
|
|
capability: pipeline.name,
|
|
providers: ownerProviderParticipants(participants),
|
|
users: nonCoreParticipants(participants),
|
|
};
|
|
});
|
|
}
|
|
|
|
function capabilityRelationshipMap(pipelines) {
|
|
const rows = capabilityRelationshipRows(pipelines);
|
|
if (!rows.length) return '';
|
|
return `
|
|
<section class="rounded-lg border border-gray-800 bg-dark-800/45 p-4">
|
|
<div class="mb-3 flex flex-wrap items-start justify-between gap-3">
|
|
<div>
|
|
<h3 class="text-sm font-semibold text-gray-100">Capability relationship map</h3>
|
|
<p class="mt-1 text-xs text-gray-500">Owner/provider capabilities connect to non-core participants using the same capability.</p>
|
|
</div>
|
|
${pill(`${rows.reduce((sum, row) => sum + row.users.length, 0)} external`, rows.some(row => row.users.length) ? 'observed' : 'muted')}
|
|
</div>
|
|
<div class="hidden gap-2 text-[11px] uppercase tracking-wide text-gray-500 lg:grid lg:grid-cols-[minmax(9rem,0.9fr)_minmax(12rem,1fr)_4rem_minmax(12rem,1fr)]">
|
|
<div>Owner/provider</div>
|
|
<div class="text-right">Capability</div>
|
|
<div></div>
|
|
<div>Non-core usage</div>
|
|
</div>
|
|
<div class="mt-2 grid gap-2">
|
|
${rows.map(row => `
|
|
<div class="grid gap-2 rounded border border-gray-800 bg-dark-900/30 p-2 lg:grid-cols-[minmax(9rem,0.9fr)_minmax(12rem,1fr)_4rem_minmax(12rem,1fr)] lg:items-center" data-capability-link="${attr(row.capability)}">
|
|
<div class="flex flex-wrap gap-1">${participantChips(row.providers, 'No owner/provider')}</div>
|
|
<div class="flex justify-start lg:justify-end">
|
|
<span class="inline-flex items-center gap-1 rounded border border-accent/40 bg-accent/10 px-2 py-1 text-xs font-medium text-accent-light" title="Capability: ${attr(row.capability)}">${iconSvg('list')}<span>${text(row.capability)}</span></span>
|
|
</div>
|
|
<div class="hidden items-center lg:flex" aria-hidden="true">
|
|
<span class="h-px flex-1 bg-gray-700"></span>
|
|
<span class="h-2 w-2 rotate-45 border-r border-t border-gray-700"></span>
|
|
</div>
|
|
<div class="flex flex-wrap gap-1">${participantChips(row.users, 'No usage', row.users.length ? 'observed' : 'muted')}</div>
|
|
</div>`).join('')}
|
|
</div>
|
|
</section>`;
|
|
}
|
|
|
|
function shimTitle(shim) {
|
|
const surface = String(shim && shim.legacySurface || '');
|
|
if (surface === 'register_library_provider') return 'Legacy library provider detected';
|
|
if (surface === 'refresh') return 'Legacy library refresh detected';
|
|
if (surface === 'select') return 'Legacy library provider selection detected';
|
|
if (surface === 'sync-song') return 'Legacy library song sync detected';
|
|
return 'Legacy compatibility surface detected';
|
|
}
|
|
|
|
function formatTime(value) {
|
|
if (!value) return '';
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return String(value);
|
|
return `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`;
|
|
}
|
|
|
|
function matchingExpectedShim(shim, expectedShims) {
|
|
const surface = String(shim && shim.legacySurface || '');
|
|
return (Array.isArray(expectedShims) ? expectedShims : []).find(expected => {
|
|
const expectedSurface = String(expected && expected.legacySurface || '');
|
|
if (!expectedSurface) return false;
|
|
return expectedSurface.endsWith('*') ? surface.startsWith(expectedSurface.slice(0, -1)) : surface === expectedSurface;
|
|
}) || null;
|
|
}
|
|
|
|
function shimRow(shim, expectedShims) {
|
|
const hitCount = Number(shim.hitCount || 0);
|
|
const expected = matchingExpectedShim(shim, expectedShims);
|
|
const status = hitCount > 0 ? pill('Used', 'used') : pill(shim.status || 'active', shim.status === 'active' ? 'info' : 'muted');
|
|
const lastSeen = formatTime(shim.lastHitAt);
|
|
return `
|
|
<div class="rounded-lg border ${hitCount > 0 ? 'border-gold/40 bg-gold/5' : 'border-gray-800 bg-dark-900/35'} p-3">
|
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
|
<div>
|
|
<div class="text-sm font-semibold text-white">${text(shimTitle(shim))}</div>
|
|
<div class="mt-1 flex flex-wrap items-center gap-2">
|
|
<code class="rounded bg-dark-700 px-2 py-1 text-xs text-gray-200">${text(shim.legacySurface || 'legacy')}</code>
|
|
${copyButton(shim.legacySurface)}
|
|
</div>
|
|
</div>
|
|
<div class="flex flex-wrap items-center gap-2">${status}</div>
|
|
</div>
|
|
<div class="mt-3 grid gap-1 text-xs text-gray-500">
|
|
<div>${hitCount} hit${hitCount === 1 ? '' : 's'}${lastSeen ? ` / last seen ${text(lastSeen)}` : ''}</div>
|
|
<div>Source: ${text(shim.source || 'unknown')}${shim.providerId ? ` / Provider: ${text(shim.providerId)}` : ''}</div>
|
|
<div>${text(shim.reason || expected?.reason || `Maps to ${shim.capability || 'unknown'} compatibility bridge`)}</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function shimSection(shims, expectedShims) {
|
|
const list = Array.isArray(shims) ? shims : [];
|
|
const hits = totalShimHits(list);
|
|
return `
|
|
<div class="rounded-lg border border-gray-800 bg-dark-800/45 p-4">
|
|
<div class="mb-3">
|
|
<h4 class="text-sm font-semibold text-gray-100">Compatibility shims</h4>
|
|
<div class="mt-1 text-xs text-gray-500">${list.length} shim${list.length === 1 ? '' : 's'} / ${hits} hit${hits === 1 ? '' : 's'}</div>
|
|
</div>
|
|
${list.length ? `<div class="grid gap-3">${list.map(shim => shimRow(shim, expectedShims)).join('')}</div>` : '<div class="rounded border border-gray-800 bg-dark-900/35 p-3 text-sm text-gray-500">No compatibility shims observed for this domain.</div>'}
|
|
</div>`;
|
|
}
|
|
|
|
function expectedShimObserved(expected, shims) {
|
|
const surface = String(expected && expected.legacySurface || '');
|
|
if (!surface) return false;
|
|
const prefix = surface.endsWith('*') ? surface.slice(0, -1) : '';
|
|
return (Array.isArray(shims) ? shims : []).some(shim => {
|
|
const observed = String(shim && shim.legacySurface || '');
|
|
return prefix ? observed.startsWith(prefix) : observed === surface;
|
|
});
|
|
}
|
|
|
|
function expectedShimCell(expected, observed) {
|
|
if (!expected) return '<span class="text-xs text-gray-700">Not expected</span>';
|
|
return `
|
|
<div class="flex flex-col gap-2">
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
${pill(observed ? 'Observed' : 'Not observed', observed ? 'observed' : 'muted')}
|
|
${copyButton(expected.legacySurface)}
|
|
</div>
|
|
<code class="break-all rounded bg-dark-900 px-2 py-1 text-xs text-gray-300">${text(expected.legacySurface || 'legacy')}</code>
|
|
<div class="text-xs text-gray-500">${text(expected.reason || '')}</div>
|
|
</div>`;
|
|
}
|
|
|
|
function expectedShimGroup(expectedShims, observedShims) {
|
|
const groups = new Map();
|
|
for (const expected of Array.isArray(expectedShims) ? expectedShims : []) {
|
|
const surface = String(expected && expected.legacySurface || '');
|
|
const eventMatch = surface.match(/^window\.feedBack\.(emit|on):(.+)$/);
|
|
const key = eventMatch ? eventMatch[2] : surface;
|
|
const type = eventMatch ? (eventMatch[1] === 'emit' ? 'emit' : 'listener') : 'surface';
|
|
const entry = groups.get(key) || { group: key, emit: null, listener: null, surfaces: [] };
|
|
const withState = { ...expected, observed: expectedShimObserved(expected, observedShims) };
|
|
if (type === 'emit') entry.emit = withState;
|
|
else if (type === 'listener') entry.listener = withState;
|
|
else entry.surfaces.push(withState);
|
|
groups.set(key, entry);
|
|
}
|
|
return Array.from(groups.values());
|
|
}
|
|
|
|
function expectedSurfaceRow(group) {
|
|
return `
|
|
<tr class="border-t border-gray-800 align-top">
|
|
<td class="px-3 py-3"><code class="text-xs text-gray-200">${text(group.group)}</code></td>
|
|
<td class="px-3 py-3">${expectedShimCell(group.emit, group.emit && group.emit.observed)}</td>
|
|
<td class="px-3 py-3">${expectedShimCell(group.listener, group.listener && group.listener.observed)}</td>
|
|
<td class="px-3 py-3">
|
|
${group.surfaces.length ? group.surfaces.map(item => expectedShimCell(item, item.observed)).join('<div class="my-2 border-t border-gray-800"></div>') : '<span class="text-xs text-gray-700">None</span>'}
|
|
</td>
|
|
</tr>`;
|
|
}
|
|
|
|
function expectedShimSection(domain, expectedShims, observedShims) {
|
|
const list = Array.isArray(expectedShims) ? expectedShims : [];
|
|
if (!list.length) {
|
|
return `
|
|
<div class="rounded-lg border border-gray-800 bg-dark-800/45 p-4 lg:col-span-2">
|
|
<h4 class="text-sm font-semibold text-gray-100">Expected legacy surfaces</h4>
|
|
<div class="mt-2 text-sm text-gray-500">No legacy compatibility shims are expected for this domain.</div>
|
|
</div>`;
|
|
}
|
|
const filter = (state.surfaceFilters && state.surfaceFilters[domain]) || 'all';
|
|
const groups = expectedShimGroup(list, observedShims).filter(group => {
|
|
const entries = [group.emit, group.listener, ...group.surfaces].filter(Boolean);
|
|
if (filter === 'observed') return entries.some(entry => entry.observed);
|
|
if (filter === 'problems') return entries.some(entry => !entry.observed);
|
|
return true;
|
|
});
|
|
return `
|
|
<div class="rounded-lg border border-gray-800 bg-dark-800/45 p-4 lg:col-span-2">
|
|
<div class="flex flex-wrap items-center justify-between gap-3 mb-3">
|
|
<div>
|
|
<h4 class="text-sm font-semibold text-gray-100">Expected legacy surfaces</h4>
|
|
<div class="mt-1 text-xs text-gray-500">${list.length} expected / grouped by event or API surface</div>
|
|
</div>
|
|
<select data-surface-filter="${attr(domain)}" class="bg-dark-700 border border-gray-800 rounded px-2 py-1 text-xs text-gray-300 outline-none">
|
|
<option value="all" ${filter === 'all' ? 'selected' : ''}>Show all</option>
|
|
<option value="observed" ${filter === 'observed' ? 'selected' : ''}>Show observed</option>
|
|
<option value="problems" ${filter === 'problems' ? 'selected' : ''}>Show not observed</option>
|
|
</select>
|
|
</div>
|
|
<div class="overflow-x-auto rounded-lg border border-gray-800">
|
|
<table class="min-w-full text-left">
|
|
<thead class="bg-dark-900/70 text-[11px] uppercase tracking-wide text-gray-500">
|
|
<tr>
|
|
<th class="px-3 py-2 font-semibold">Group</th>
|
|
<th class="px-3 py-2 font-semibold">Emit surface</th>
|
|
<th class="px-3 py-2 font-semibold">Listener surface</th>
|
|
<th class="px-3 py-2 font-semibold">Other surface</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="bg-dark-900/25">${groups.map(expectedSurfaceRow).join('') || '<tr><td colspan="4" class="px-3 py-4 text-sm text-gray-500">No surfaces match this filter.</td></tr>'}</tbody>
|
|
</table>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function participantSection(pipeline) {
|
|
const participants = Array.isArray(pipeline.participants) ? pipeline.participants : [];
|
|
const owners = coreOwners(participants);
|
|
const extras = nonCoreParticipants(participants);
|
|
return `
|
|
<div class="rounded-lg border border-gray-800 bg-dark-800/45 p-4 lg:col-span-2">
|
|
<div class="mb-3 flex flex-wrap items-start justify-between gap-3">
|
|
<div>
|
|
<h4 class="text-sm font-semibold text-gray-100">Capability usage</h4>
|
|
<div class="mt-1 text-xs text-gray-500">Core ownership separated from non-core participants.</div>
|
|
</div>
|
|
${pill(`${extras.length} non-core`, extras.length ? 'observed' : 'muted')}
|
|
</div>
|
|
<div class="grid gap-3 lg:grid-cols-2">
|
|
<div>
|
|
<div class="mb-2 text-xs uppercase tracking-wide text-gray-500">Core owner</div>
|
|
<div class="grid gap-3">${owners.map(participantRow).join('') || '<div class="rounded border border-gray-800 bg-dark-900/35 p-3 text-sm text-gray-500">No core owner is registered for this domain.</div>'}</div>
|
|
</div>
|
|
<div>
|
|
<div class="mb-2 text-xs uppercase tracking-wide text-gray-500">Non-core participants</div>
|
|
<div class="grid gap-3">${extras.map(participantRow).join('') || '<div class="rounded border border-gray-800 bg-dark-900/35 p-3 text-sm text-gray-500">No participants are using this capability.</div>'}</div>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function participantUsesByLinkData(users, linkData) {
|
|
const uses = (Array.isArray(users) ? users : []).map(() => ({ commands: [], operations: [], events: [] }));
|
|
for (const link of Array.isArray(linkData && linkData.links) ? linkData.links : []) {
|
|
const entry = uses[link.participantIndex];
|
|
if (!entry) continue;
|
|
const list = link.endpointType === 'operation' ? entry.operations : (link.endpointType === 'command' ? entry.commands : entry.events);
|
|
if (!list.includes(link.label)) list.push(link.label);
|
|
}
|
|
return uses;
|
|
}
|
|
|
|
function visibleGraphParticipants(users, usesByIndex) {
|
|
return (Array.isArray(users) ? users : []).map((participant, index) => {
|
|
const uses = (Array.isArray(usesByIndex) && usesByIndex[index]) || { commands: [], operations: [], events: [] };
|
|
return { participant, index, uses };
|
|
}).filter(entry => entry.uses.commands.length || entry.uses.operations.length || entry.uses.events.length);
|
|
}
|
|
|
|
function graphHeaderSummary(model) {
|
|
const summaryUses = participantUsesByLinkData(model.users, model.allLinkData);
|
|
const summaryParticipants = visibleGraphParticipants(model.users, summaryUses);
|
|
const shimmed = model.allLinkData.links.filter(link => link.type === 'shimmed').length;
|
|
const observed = model.allLinkData.links.filter(link => link.type === 'observed').length;
|
|
return {
|
|
participantTotal: graphParticipantCount([], summaryParticipants.map(entry => entry.participant)),
|
|
capabilityTotal: model.allCommands.length + model.allOperations.length + model.allEvents.length,
|
|
observed,
|
|
shimmed,
|
|
};
|
|
}
|
|
|
|
function graphGroupButton(domain, side, index, title, type, count, collapsed, flow = '') {
|
|
const key = graphGroupStateKey(domain, side, index, type);
|
|
const isProvider = side === 'provider';
|
|
const port = collapsed
|
|
? (isProvider
|
|
? `<span class="absolute -right-[1.4375rem] top-1/2 -translate-y-1/2">${endpointIcon(type, graphCapabilityGroupPortKey(type))}</span>`
|
|
: `<span class="absolute -left-[1.4375rem] top-1/2 -translate-y-1/2">${endpointIcon(type, participantGroupPortKey(index, type), 'group', flow)}</span>`)
|
|
: '';
|
|
const align = isProvider ? 'justify-end text-right' : 'justify-start text-left';
|
|
const countPill = pill(String(count), 'muted');
|
|
const focusAttrs = isProvider
|
|
? ` data-graph-focus-kind="provider-group" data-graph-focus-type="${attr(type)}" data-graph-provider-focus="group"`
|
|
: ` data-graph-focus-kind="participant-group" data-graph-focus-participant-index="${attr(index)}" data-graph-focus-type="${attr(type)}"`;
|
|
return `
|
|
<button type="button" data-toggle-graph-group="${attr(key)}" data-graph-group-collapsed="${collapsed ? 'true' : 'false'}"${focusAttrs} aria-expanded="${collapsed ? 'false' : 'true'}" title="${attr(collapsed ? `Expand ${title}` : `Collapse ${title}`)}" class="relative flex min-h-6 w-full min-w-0 items-center gap-2 ${align} text-xs font-semibold uppercase tracking-wide text-gray-500 transition hover:text-gray-200">
|
|
${isProvider ? `${countPill}<span class="min-w-0 truncate">${text(title)}</span>` : `${port}<span class="min-w-0 truncate">${text(title)}</span>${countPill}`}
|
|
${isProvider ? port : ''}
|
|
</button>`;
|
|
}
|
|
|
|
function providerCapabilityGroup(domain, title, items, type) {
|
|
const collapsed = isGraphGroupCollapsed(domain, 'provider', 'all', type);
|
|
return `
|
|
<div class="grid gap-2 border-t border-gray-800/70 pt-3" data-graph-provider-group="${attr(type)}" data-graph-group-collapsed="${collapsed ? 'true' : 'false'}" data-graph-focus-kind="provider-group" data-graph-focus-type="${attr(type)}">
|
|
${graphGroupButton(domain, 'provider', 'all', title, type, items.length, collapsed)}
|
|
${collapsed ? '' : `<div class="grid gap-2">${items.map(item => `<div class="relative flex min-h-5 min-w-0 items-center justify-end pr-0 text-right text-sm text-gray-200" data-capability-node="${attr(type)}:${attr(item)}" data-graph-provider-endpoint-row="true"><span class="min-w-0 truncate" data-graph-focus-kind="provider-endpoint" data-graph-focus-type="${attr(type)}" data-graph-focus-label="${attr(item)}" data-graph-provider-focus="endpoint">${text(item)}</span><span class="absolute -right-[1.4375rem] top-1/2 -translate-y-1/2" data-graph-focus-kind="provider-endpoint" data-graph-focus-type="${attr(type)}" data-graph-focus-label="${attr(item)}" data-graph-provider-focus="endpoint">${endpointIcon(type, endpointKey(type, item))}</span></div>`).join('') || '<div class="text-right text-sm text-gray-500">None declared</div>'}</div>`}
|
|
</div>`;
|
|
}
|
|
|
|
function ownerDescription(domain, participant, review) {
|
|
const explicit = String(participant && (participant.description || participant.summary) || '').replace(/\s+/g, ' ').trim();
|
|
if (explicit) return explicit;
|
|
const participantId = participant && participant.pluginId ? participant.pluginId : '';
|
|
if (!participantId || participantId === 'No owner/provider') return `No owner is currently registered for the ${domain} domain.`;
|
|
if (participantId === 'core' && review && review.summary) return review.summary;
|
|
if (hasRole(participant, 'owner')) return `Owns the ${domain} capability contract and exposes its provider endpoints.`;
|
|
if (hasRole(participant, 'provider')) return `Provides endpoints for the ${domain} capability domain.`;
|
|
return `Participates in the ${domain} capability domain.`;
|
|
}
|
|
|
|
function providerPanel(domain, participants, commands, operations, events, review) {
|
|
const ownerEntries = participants.length ? participants : [{ pluginId: 'No owner/provider', roles: [] }];
|
|
return `
|
|
<div class="relative flex h-full flex-col gap-3 rounded-lg border border-gray-800 bg-dark-900/40 p-4 lg:w-96 lg:max-w-96 lg:flex-none" data-domain-provider-card="true">
|
|
${ownerEntries.map(participant => `
|
|
<div data-domain-owner-name="${attr(participant.pluginId || 'unknown')}">
|
|
<div class="flex items-center justify-between gap-3">
|
|
<div class="min-w-0 truncate text-lg font-semibold text-white">${text(participant.pluginId || 'unknown')}</div>
|
|
<div class="flex shrink-0 items-center justify-end gap-1">${ownerHeaderIcons(participant)}</div>
|
|
</div>
|
|
</div>`).join('')}
|
|
${operations.length ? providerCapabilityGroup(domain, 'Operations', operations, 'operation') : ''}
|
|
${commands.length ? providerCapabilityGroup(domain, 'Commands', commands, 'command') : ''}
|
|
${events.length ? providerCapabilityGroup(domain, 'Events', events, 'event') : ''}
|
|
${commands.length || operations.length || events.length ? '' : '<div class="border-t border-gray-800/70 pt-3 text-right text-sm text-gray-500">No endpoints declared.</div>'}
|
|
<div class="mt-auto border-t border-gray-800/70 pt-3 text-right" data-domain-owner-footer="true">
|
|
${ownerEntries.map(participant => `<div data-domain-owner-description="${attr(participant.pluginId || 'unknown')}" class="text-sm leading-5 text-gray-400">${text(ownerDescription(domain, participant, review))}</div>`).join('')}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function participantEndpointGroup(domain, participant, uses, index, shims, title, type) {
|
|
const items = type === 'operation' ? uses.operations : (type === 'command' ? uses.commands : uses.events);
|
|
if (!items.length) return '';
|
|
const collapsed = isGraphGroupCollapsed(domain, 'participant', index, type);
|
|
const flow = graphLinkFlow(participant, type);
|
|
return `
|
|
<div class="grid gap-2 border-t border-gray-800/70 pt-3 first:border-t-0 first:pt-0" data-graph-participant-group="${index}:${attr(type)}" data-graph-group-collapsed="${collapsed ? 'true' : 'false'}" data-graph-focus-kind="participant-group" data-graph-focus-participant-index="${attr(index)}" data-graph-focus-type="${attr(type)}">
|
|
${graphGroupButton(domain, 'participant', index, title, type, items.length, collapsed, flow)}
|
|
${collapsed ? '' : `<div class="grid gap-2">${items.map(item => `<div class="relative flex min-h-5 min-w-0 items-center pl-0 text-sm text-gray-300" data-graph-participant-endpoint-row="true"><span class="absolute -left-[1.4375rem] top-1/2 -translate-y-1/2" data-graph-focus-kind="participant-endpoint" data-graph-focus-participant-index="${attr(index)}" data-graph-focus-type="${attr(type)}" data-graph-focus-label="${attr(item)}">${endpointIcon(type, participantEndpointKey(index, type, item), participantEndpointKind(participant, type, item, shims), flow)}</span><span class="min-w-0 truncate" data-graph-focus-kind="participant-endpoint" data-graph-focus-participant-index="${attr(index)}" data-graph-focus-type="${attr(type)}" data-graph-focus-label="${attr(item)}">${text(item)}</span></div>`).join('')}</div>`}
|
|
</div>`;
|
|
}
|
|
|
|
function participantGraphCard(domain, participant, uses, index, shims) {
|
|
const groupMarkup = [
|
|
participantEndpointGroup(domain, participant, uses, index, shims, 'Operations', 'operation'),
|
|
participantEndpointGroup(domain, participant, uses, index, shims, 'Commands', 'command'),
|
|
participantEndpointGroup(domain, participant, uses, index, shims, 'Events', 'event'),
|
|
].filter(Boolean).join('');
|
|
const primaryRoleIcon = providerRoleIcon(participant);
|
|
return `
|
|
<div class="relative rounded-lg border border-gray-800 bg-dark-900/40 p-4 transition hover:border-accent/50" data-domain-participant-card="${attr(participant.pluginId)}" data-participant-index="${index}" data-graph-hover-participant="${index}" title="${attr(participantTooltip(participant))}">
|
|
<div class="flex items-center gap-3">
|
|
${primaryRoleIcon}
|
|
<div class="min-w-0 flex-1 truncate text-lg font-semibold text-white">${text(participant.pluginId)}</div>
|
|
<div class="flex shrink-0 items-center justify-end gap-1">${participantOriginIcon(participant)}${availabilityBadge(participant.availability)}</div>
|
|
</div>
|
|
<div class="mt-3 grid gap-3 border-t border-gray-800/70 pt-3">
|
|
${groupMarkup || '<div class="text-sm text-gray-500">No command, operation, or event usage declared.</div>'}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function domainGraphLinks(capabilities, participants, shims, expectedShims) {
|
|
const commandOffset = capabilities.operations.length;
|
|
const eventOffset = capabilities.operations.length + capabilities.commands.length;
|
|
const operationIndex = new Map(capabilities.operations.map((item, index) => [item, index]));
|
|
const commandIndex = new Map(capabilities.commands.map((item, index) => [item, commandOffset + index]));
|
|
const eventIndex = new Map(capabilities.events.map((item, index) => [item, eventOffset + index]));
|
|
const nodeCount = Math.max(1, capabilities.commands.length + capabilities.operations.length + capabilities.events.length);
|
|
const participantCount = Math.max(1, participants.length);
|
|
const shimmedBySource = shimmedEndpointsBySource(shims);
|
|
const links = [];
|
|
participants.forEach((participant, participantIndex) => {
|
|
const uses = participantUses(participant, shims, expectedShims);
|
|
const shimmedEndpoints = shimmedBySource.get(participant && participant.pluginId) || new Set();
|
|
for (const item of uses.commands) {
|
|
const linkType = participant && (participant._shimOnly || shimmedEndpoints.has(endpointKey('command', item))) ? 'shimmed' : 'observed';
|
|
if (commandIndex.has(item)) links.push({ type: linkType, flow: graphLinkFlow(participant, 'command'), capabilityIndex: commandIndex.get(item), participantIndex, participantId: participant && participant.pluginId || '', label: item, sourceLabel: item, endpointType: 'command' });
|
|
}
|
|
for (const item of uses.operations) {
|
|
const linkType = participant && (participant._shimOnly || shimmedEndpoints.has(endpointKey('operation', item))) ? 'shimmed' : 'observed';
|
|
if (operationIndex.has(item)) links.push({ type: linkType, flow: graphLinkFlow(participant, 'operation'), capabilityIndex: operationIndex.get(item), participantIndex, participantId: participant && participant.pluginId || '', label: item, sourceLabel: item, endpointType: 'operation' });
|
|
}
|
|
for (const item of uses.events) {
|
|
const exact = eventIndex.has(item) ? item : Array.from(eventIndex.keys()).find(event => event.endsWith('*') && item.startsWith(event.slice(0, -1)));
|
|
const linkType = participant && (participant._shimOnly || shimmedEndpoints.has(endpointKey('event', item))) ? 'shimmed' : 'observed';
|
|
if (exact) links.push({ type: linkType, flow: graphLinkFlow(participant, 'event'), capabilityIndex: eventIndex.get(exact), participantIndex, participantId: participant && participant.pluginId || '', label: item, sourceLabel: exact, endpointType: 'event' });
|
|
}
|
|
});
|
|
return { links, nodeCount, participantCount };
|
|
}
|
|
|
|
function domainGraphSvg(linkData) {
|
|
const yForCapability = index => 12 + (index + 0.5) * (76 / linkData.nodeCount);
|
|
const yForParticipant = index => 14 + (index + 0.5) * (72 / linkData.participantCount);
|
|
const paths = linkData.links.map(link => {
|
|
const y1 = yForCapability(link.capabilityIndex);
|
|
const y2 = yForParticipant(link.participantIndex);
|
|
const color = graphLinkColor(link.flow || link.endpointType);
|
|
const dash = link.type === 'shimmed' ? ' stroke-dasharray="4 4"' : '';
|
|
return `<path data-link-kind="${attr(link.type)}" data-link-flow="${attr(link.flow || link.endpointType)}" d="M 42 ${y1.toFixed(2)} C 52 ${y1.toFixed(2)}, 58 ${y2.toFixed(2)}, 68 ${y2.toFixed(2)}" fill="none" stroke="${color}" stroke-width="1.4" opacity="0.82"${dash}><title>${text(graphLinkTitle(link))}: ${text(link.label)}</title></path>`;
|
|
}).join('');
|
|
return `<svg class="pointer-events-none absolute inset-0 hidden h-full w-full lg:block" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">${paths}</svg>`;
|
|
}
|
|
|
|
function domainGraphView(pipeline, shims, expectedShims, options = {}) {
|
|
const model = graphModel(pipeline, shims, expectedShims);
|
|
const { providers, users, commands, operations, events, linkData } = model;
|
|
const visibleUses = participantUsesByLinkData(users, linkData);
|
|
const visibleParticipants = visibleGraphParticipants(users, visibleUses);
|
|
const { participantTotal, capabilityTotal, observed, shimmed } = graphHeaderSummary(model);
|
|
const status = compatibilityState(pipeline.conflicts || []);
|
|
const review = reviewInfo(pipeline);
|
|
const linkMetadata = linkData.links.map(link => `<span data-link-kind="${attr(link.type)}" data-link-flow="${attr(link.flow || link.endpointType)}" data-link-participant="${attr(link.participantId || '')}">${text(link.label)}</span>`).join('');
|
|
const collapsible = !!options.collapsible;
|
|
const expanded = collapsible ? isDomainExpanded(pipeline.name, !!options.defaultExpanded) : true;
|
|
const ids = graphDomIds(pipeline.name);
|
|
const actionLabel = `${expanded ? 'Collapse' : 'Expand'} ${pipeline.name} domain graph`;
|
|
const statusIcon = status.tone === 'conflict' ? 'alert' : (status.tone === 'warning' ? 'shieldAlert' : 'shield');
|
|
const domainIcon = domainIconName(pipeline.name, review);
|
|
const titleMarkup = collapsible
|
|
? `<h3 class="text-lg font-semibold text-white"><button type="button" data-toggle-domain="${attr(pipeline.name)}" aria-expanded="${expanded ? 'true' : 'false'}" aria-controls="${attr(ids.panelId)}" aria-label="${attr(actionLabel)}" title="${attr(actionLabel)}" class="group inline-flex max-w-full items-center gap-2 rounded text-left transition hover:text-accent-light focus:outline-none focus:ring-2 focus:ring-accent/60"><span class="min-w-0 break-all font-bold">${text(pipeline.name)}</span><span class="shrink-0 text-gray-500 transition group-hover:text-accent-light">${iconSvg(expanded ? 'chevronDown' : 'chevronRight', 'h-4 w-4')}</span></button></h3>`
|
|
: `<h3 class="text-lg font-semibold text-white"><span class="font-bold">${text(pipeline.name)}</span></h3>`;
|
|
return `
|
|
<section class="rounded-lg border border-gray-800 bg-dark-800/55 p-4" data-domain-graph="${attr(pipeline.name)}" data-domain-graph-expanded="${expanded ? 'true' : 'false'}">
|
|
<div class="${expanded ? 'mb-4 ' : ''}flex flex-wrap items-center justify-between gap-4">
|
|
<div class="flex items-center gap-3">
|
|
<span class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-purple-500/20 text-purple-200" title="${attr(pipeline.name)} domain" aria-label="${attr(pipeline.name)} domain" role="img">${iconSvg(domainIcon, 'h-5 w-5')}</span>
|
|
${titleMarkup}
|
|
</div>
|
|
<div class="flex flex-wrap items-center justify-end gap-2">
|
|
${compactIconBadge('users', participantTotal, `${participantTotal} participant${participantTotal === 1 ? '' : 's'}`, 'muted')}
|
|
${compactIconBadge('box', capabilityTotal, `${capabilityTotal} capability endpoint${capabilityTotal === 1 ? '' : 's'}`, 'muted')}
|
|
${compactIconBadge('eye', observed, `${observed} observed link${observed === 1 ? '' : 's'}`, observed ? 'observed' : 'muted')}
|
|
${compactIconBadge('plug', shimmed, `${shimmed} shimmed link${shimmed === 1 ? '' : 's'}`, shimmed ? 'used' : 'muted')}
|
|
${compactIconBadge(statusIcon, '', `Domain status: ${status.label}`, status.tone)}
|
|
</div>
|
|
</div>
|
|
${expanded ? `<div id="${attr(ids.panelId)}" data-domain-graph-panel="${attr(pipeline.name)}">
|
|
<div id="${attr(ids.frameId)}" data-domain-graph-frame="${attr(pipeline.name)}" class="relative overflow-hidden rounded-lg border border-gray-800 bg-dark-900/25 p-4">
|
|
<div id="${attr(ids.cyId)}" data-domain-graph-cy="${attr(pipeline.name)}" class="pointer-events-none absolute inset-0 z-20 hidden lg:block" aria-hidden="true"></div>
|
|
<div id="${attr(ids.fallbackId)}" data-domain-graph-fallback="${attr(pipeline.name)}" class="relative z-10 grid gap-10 lg:flex lg:items-stretch lg:justify-between lg:gap-16 xl:gap-24" data-graph-lanes="${attr(pipeline.name)}">
|
|
<div class="hidden" data-graph-link-metadata>${linkMetadata}</div>
|
|
${providerPanel(pipeline.name, providers, commands, operations, events, review)}
|
|
<div class="grid gap-3 lg:w-96 lg:max-w-96 lg:flex-none" data-domain-participant-lane="true">
|
|
${visibleParticipants.map(entry => participantGraphCard(pipeline.name, entry.participant, entry.uses, entry.index, shims)).join('') || '<div class="rounded-lg border border-gray-800 bg-dark-900/40 p-4 text-sm text-gray-500">No participants are using this capability.</div>'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="mt-3 grid gap-2 rounded-lg border border-gray-800 bg-dark-900/30 px-3 py-2 text-xs text-gray-400">
|
|
<div class="flex flex-wrap items-center justify-end gap-2" data-domain-graph-filter-row="${attr(pipeline.name)}">
|
|
<span class="text-gray-500">Show:</span>${graphFilterButton('all', 'All')}${graphFilterButton('operations', 'Operations')}${graphFilterButton('commands', 'Commands')}${graphFilterButton('events', 'Events')}${graphFilterButton('shimmed', 'Shimmed')}
|
|
</div>
|
|
<div class="flex flex-wrap items-center gap-4 border-t border-gray-800 pt-2">
|
|
<span class="font-semibold text-gray-300">Legend</span>
|
|
<span class="flex items-center gap-2">${OPERATION_ICON}Operation</span>
|
|
<span class="flex items-center gap-2">${COMMAND_ICON}Command</span>
|
|
<span class="flex items-center gap-2">${EVENT_ICON}Event</span>
|
|
<span data-legend-line="operation" class="inline-flex h-px w-8 bg-purple-400"></span><span>Operation link</span>
|
|
${PROVIDER_OPERATION_LINK}<span>Provider operation link</span>
|
|
<span data-legend-line="command" class="inline-flex h-px w-8 bg-orange-400"></span><span>Command link</span>
|
|
${PROVIDER_COMMAND_LINK}<span>Provider command link</span>
|
|
<span data-legend-line="event" class="inline-flex h-px w-8 bg-blue-400"></span><span>Event link</span>
|
|
${PROVIDER_EVENT_LINK}<span>Provider event link</span>
|
|
<span data-legend-line="shimmed" class="inline-flex h-px w-8 border-t border-dashed border-gray-400"></span><span>Shimmed link</span>
|
|
</div>
|
|
</div>
|
|
</div>` : ''}
|
|
</section>`;
|
|
}
|
|
|
|
function pipelineCard(pipeline, shimsByCapability, expectedShimsByCapability, options = {}) {
|
|
const shims = (shimsByCapability && shimsByCapability.get(pipeline.name)) || [];
|
|
const expectedShims = (expectedShimsByCapability && expectedShimsByCapability.get(pipeline.name)) || [];
|
|
return domainGraphView(pipeline, shims, expectedShims, { collapsible: true, defaultExpanded: !!options.defaultExpanded });
|
|
}
|
|
|
|
function pipelineGroupSection(entry, shimsByCapability, expectedShimsByCapability, options = {}) {
|
|
const pipelines = entry.pipelines || [];
|
|
const domainCount = pipelines.length;
|
|
return `
|
|
<section class="grid gap-3">
|
|
<div class="flex flex-wrap items-end justify-between gap-3 border-b border-gray-800 pb-2">
|
|
<div>
|
|
<div class="h-4" aria-hidden="true"></div>
|
|
<h3 class="mt-1 text-lg font-semibold text-white">${text(entry.group.label)}</h3>
|
|
<p class="mt-1 text-xs text-gray-500">${text(entry.group.summary)}</p>
|
|
</div>
|
|
${pill(`${domainCount} domain${domainCount === 1 ? '' : 's'}`, 'muted')}
|
|
</div>
|
|
${pipelines.map(pipeline => pipelineCard(pipeline, shimsByCapability, expectedShimsByCapability, options)).join('')}
|
|
</section>`;
|
|
}
|
|
|
|
function summaryDashboard(data, pipelines, compatibilityShims) {
|
|
const participants = Array.isArray(data.participants) ? data.participants : [];
|
|
const conflicts = pipelines.reduce((sum, pipeline) => sum + ((pipeline.conflicts || []).length), 0);
|
|
const warnings = (Array.isArray(data.missingProviders) ? data.missingProviders.length : 0)
|
|
+ (Array.isArray(data.unsupportedVersions) ? data.unsupportedVersions.length : 0);
|
|
const shimHits = totalShimHits(compatibilityShims);
|
|
const status = conflicts ? { label: 'Conflicts', tone: 'conflict' } : (warnings ? { label: 'Warnings', tone: 'warning' } : { label: 'Clean', tone: 'clean' });
|
|
return `
|
|
<div class="mt-4 grid grid-cols-2 gap-3 md:grid-cols-4" data-summary-dashboard>
|
|
${metricCard('Domains', pipelines.length, 'muted')}
|
|
${metricCard('Participants', participants.length, 'muted')}
|
|
${metricCard(shimHits === 1 ? 'Shim hit' : 'Shim hits', shimHits, shimHits > 0 ? 'used' : 'muted')}
|
|
<div class="rounded-lg border ${status.tone === 'clean' ? 'border-emerald-800/60 bg-emerald-500/5' : (status.tone === 'warning' ? 'border-amber-800/60 bg-amber-500/5' : 'border-red-800/70 bg-red-500/5')} px-3 py-2" data-summary-card="status" data-tone="${attr(status.tone)}">
|
|
<div data-summary-row>
|
|
<span class="text-[11px] uppercase tracking-wide text-gray-500" data-summary-label>Status</span>
|
|
<span data-summary-status-value data-tone="${attr(status.tone)}">${text(status.label)}</span>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function audioSessionSnapshot() {
|
|
const api = window.feedBack && window.feedBack.audioSession;
|
|
if (!api || typeof api.snapshot !== 'function') return null;
|
|
try { return api.snapshot(); }
|
|
catch (_) { return null; }
|
|
}
|
|
|
|
function playbackSnapshot() {
|
|
const api = window.feedBack && window.feedBack.playback;
|
|
if (!api || typeof api.snapshot !== 'function') return null;
|
|
try { return api.snapshot({ exportMode: 'local-inspector' }); }
|
|
catch (_) { return null; }
|
|
}
|
|
|
|
function playbackSupportPanel(playbackData) {
|
|
if (!playbackData || !playbackData.state) return '';
|
|
const state = playbackData.state || {};
|
|
const session = state.sessionId || 'unknown';
|
|
const target = state.target || {};
|
|
const display = target.localDisplay || {};
|
|
const media = state.media || {};
|
|
const route = state.route || media.route || {};
|
|
const loop = state.loop || media.loop || {};
|
|
const participants = Array.isArray(playbackData.participants) ? playbackData.participants : [];
|
|
const bridges = Array.isArray(playbackData.bridges) ? playbackData.bridges : [];
|
|
const recentOutcomes = playbackData.history && playbackData.history.current && Array.isArray(playbackData.history.current.recentOutcomes)
|
|
? playbackData.history.current.recentOutcomes.slice(-6) : [];
|
|
const recentEvents = playbackData.history && playbackData.history.current && Array.isArray(playbackData.history.current.lifecycleEvents)
|
|
? playbackData.history.current.lifecycleEvents.slice(-6) : [];
|
|
const bridgeHits = bridges.reduce((sum, bridge) => sum + Number(bridge.hitCount || 0), 0);
|
|
const label = [display.title, display.artist].filter(Boolean).join(' - ') || target.targetId || 'none';
|
|
const timeLabel = media.currentTime == null ? 'unknown' : `${Number(media.currentTime).toFixed(2)}s`;
|
|
const durationLabel = media.duration == null ? 'unknown' : `${Number(media.duration).toFixed(2)}s`;
|
|
return `<section class="mb-4 rounded-lg border border-gray-800 bg-dark-900/40 p-4" data-playback-support>
|
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<h3 class="text-sm font-semibold text-white">Playback</h3>
|
|
<p class="mt-1 text-xs text-gray-500">Transport session, route, loop, requester, bridge, and recent outcome state from the playback host.</p>
|
|
</div>
|
|
<div class="flex flex-wrap gap-2">
|
|
${pill(`state: ${state.state || 'unknown'}`, state.state === 'playing' || state.state === 'ready' ? 'clean' : (state.state === 'failed' || state.state === 'unavailable' ? 'conflict' : 'muted'))}
|
|
${pill(`route: ${route.routeKind || 'unknown'}`, route.state === 'active' ? 'clean' : (route.state === 'degraded' ? 'warning' : 'muted'))}
|
|
${pill(loop && loop.enabled ? 'loop: active' : 'loop: inactive', loop && loop.enabled ? 'used' : 'muted')}
|
|
${pill(`${participants.length} participant${participants.length === 1 ? '' : 's'}`, participants.length ? 'info' : 'muted')}
|
|
${pill(`${bridgeHits} bridge hit${bridgeHits === 1 ? '' : 's'}`, bridgeHits ? 'used' : 'muted')}
|
|
</div>
|
|
</div>
|
|
<div class="mt-3 grid gap-2 text-xs text-gray-400 md:grid-cols-2">
|
|
<div data-playback-session>Session: ${text(session)}</div>
|
|
<div data-playback-target>Target: ${text(label)}</div>
|
|
<div data-playback-time>Time: ${text(timeLabel)} / ${text(durationLabel)}</div>
|
|
<div data-playback-loop>Loop: ${loop && loop.enabled ? `${text(loop.startTime)}-${text(loop.endTime)} (${text(loop.state || 'active')})` : 'none'}</div>
|
|
<div data-playback-route>Route: ${text(route.routeKind || 'unknown')} (${text(route.state || 'unknown')})${route.safeReason ? ` - ${text(route.safeReason)}` : ''}</div>
|
|
<div data-playback-participants>Participants: ${participants.map(participant => text(participant.requesterId || participant.observerId || participant.role || 'unknown')).join(', ') || 'none'}</div>
|
|
<div data-playback-bridges>Bridges: ${bridges.map(bridge => `${text(bridge.bridgeId)}:${text(bridge.hitCount || 0)}`).join(', ') || 'none'}</div>
|
|
<div data-playback-outcomes>Outcomes: ${recentOutcomes.map(outcome => `${text(outcome.operation)}:${text(outcome.status || outcome.outcome)}`).join(', ') || 'none'}</div>
|
|
<div data-playback-events class="md:col-span-2">Events: ${recentEvents.map(event => `${text(event.event)}:${text(event.state)}`).join(', ') || 'none'}</div>
|
|
</div>
|
|
</section>`;
|
|
}
|
|
|
|
function audioDomainSupportPanel(audioData) {
|
|
if (!audioData || !audioData.domains) return '';
|
|
const mix = audioData.domains['audio-mix'] || {};
|
|
const route = mix.route || (audioData.session && audioData.session.route) || {};
|
|
const analyser = mix.analyser || (audioData.session && audioData.session.analyser) || {};
|
|
const input = audioData.domains['audio-input'] || {};
|
|
const monitoring = audioData.domains['audio-monitoring'] || {};
|
|
const stems = audioData.domains['stems'] || {};
|
|
const participants = Array.isArray(mix.participants) ? mix.participants : [];
|
|
const sources = Array.isArray(input.sources) ? input.sources : [];
|
|
const selectedInput = input.selected || null;
|
|
const openInputSessions = Array.isArray(input.openSessions) ? input.openSessions : [];
|
|
const monitoringProviders = Array.isArray(monitoring.providers) ? monitoring.providers : [];
|
|
const selectedMonitoringProvider = monitoring.selectedProvider || null;
|
|
const monitoringSessions = Array.isArray(monitoring.sessions) ? monitoring.sessions : [];
|
|
const monitoringBridges = Array.isArray(monitoring.bridges) ? monitoring.bridges : [];
|
|
const inputBridges = Array.isArray(input.bridges) ? input.bridges : [];
|
|
const bridges = [
|
|
...(Array.isArray(mix.bridges) ? mix.bridges : []),
|
|
...inputBridges,
|
|
...monitoringBridges,
|
|
...(Array.isArray(stems.bridges) ? stems.bridges : []),
|
|
];
|
|
const bridgeHits = bridges.reduce((sum, bridge) => sum + Number(bridge.hitCount || 0), 0);
|
|
const faders = Array.isArray(mix.faders) ? mix.faders : [];
|
|
const failedOutcomes = (Array.isArray(audioData.recentOutcomes) ? audioData.recentOutcomes : [])
|
|
.filter(outcome => outcome && (outcome.domain === 'audio-mix' || outcome.domain === 'audio-input' || outcome.domain === 'audio-monitoring') && (outcome.outcome === 'failed' || outcome.outcome === 'denied' || outcome.outcome === 'degraded' || outcome.outcome === 'unavailable' || outcome.outcome === 'provider-selection-required' || outcome.status === 'timeout'))
|
|
.slice(-5);
|
|
const claims = Array.isArray(stems.claims) ? stems.claims : [];
|
|
const owner = stems.owner || null;
|
|
return `<section class="mb-4 rounded-lg border border-gray-800 bg-dark-900/40 p-4" data-audio-session-support>
|
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<h3 class="text-sm font-semibold text-white">Audio session</h3>
|
|
<p class="mt-1 text-xs text-gray-500">Route, mix participants, stem owner, claims, and bridge hits from the active session host.</p>
|
|
</div>
|
|
<div class="flex flex-wrap gap-2">
|
|
${pill(`route: ${route.routeKind || 'unknown'}`, route.availability === 'available' ? 'clean' : 'warning')}
|
|
${pill(`${participants.length} mix participant${participants.length === 1 ? '' : 's'}`, participants.length ? 'info' : 'muted')}
|
|
${pill(`${faders.length} fader${faders.length === 1 ? '' : 's'}`, faders.length ? 'info' : 'muted')}
|
|
${pill(`${input.totalSources ?? sources.length} input source${(input.totalSources ?? sources.length) === 1 ? '' : 's'}`, sources.length ? 'info' : 'muted')}
|
|
${pill(`${input.totalOpenSessions ?? openInputSessions.length} open input${(input.totalOpenSessions ?? openInputSessions.length) === 1 ? '' : 's'}`, openInputSessions.length ? 'used' : 'muted')}
|
|
${pill(`${monitoring.totalProviders ?? monitoringProviders.length} monitor provider${(monitoring.totalProviders ?? monitoringProviders.length) === 1 ? '' : 's'}`, monitoringProviders.length ? 'info' : 'muted')}
|
|
${pill(`${monitoring.totalSessions ?? monitoringSessions.length} monitor${(monitoring.totalSessions ?? monitoringSessions.length) === 1 ? '' : 's'}`, monitoringSessions.length ? 'info' : 'muted')}
|
|
${pill(owner ? `stems: ${owner.ownerId}` : 'stems: no owner', owner ? 'clean' : 'muted')}
|
|
${pill(`${claims.length} claim${claims.length === 1 ? '' : 's'}`, claims.length ? 'used' : 'muted')}
|
|
${pill(`${bridgeHits} bridge hit${bridgeHits === 1 ? '' : 's'}`, bridges.length ? 'used' : 'muted')}
|
|
</div>
|
|
</div>
|
|
<div class="mt-3 grid gap-2 text-xs text-gray-400 md:grid-cols-2">
|
|
<div data-audio-session-route>Route: ${text(route.routeKind || 'unknown')} (${text(route.availability || 'unknown')})</div>
|
|
<div data-audio-session-analyser>Analyser: ${text(analyser.source || 'unavailable')} (${text(analyser.availability || 'unavailable')})</div>
|
|
<div data-audio-session-faders>Faders: ${faders.map(fader => `${text(fader.label || fader.faderLabel || fader.participantId)}:${text(fader.availability || 'unknown')}:${text(fader.sourceMode || 'native')}${fader.lastRejectedValue != null ? ':failed' : ''}`).join(', ') || 'none'}</div>
|
|
<div data-audio-session-input>Input: ${sources.map(s => `${text(s.label || s.sourceId || s.kind)}:${text(s.availability || 'unknown')}:${text(s.channelSummary && s.channelSummary.channelShape ? s.channelSummary.channelShape : s.channelShape || 'unknown')}:${text(s.sourceMode || 'native')}${s.supersededBy ? ':superseded' : ''}`).join(', ') || 'none'}</div>
|
|
<div data-audio-session-selected-input>Selected input: ${selectedInput ? `${text(selectedInput.logicalSourceKey || selectedInput.sourceId || 'selected')}:${text(selectedInput.availability || 'unknown')}:${text(selectedInput.restoreStatus || 'unknown')}` : 'none'}</div>
|
|
<div data-audio-session-open-input>Open input: ${openInputSessions.map(s => `${text(s.openSessionId)}:${text(s.channelShape || 'unknown')}:${text(s.state || 'unknown')}:${text((s.requesters || []).map(r => r.requesterId).join('+'))}`).join(', ') || 'none'}</div>
|
|
<div data-audio-session-monitoring-providers>Monitoring providers: ${monitoringProviders.map(p => `${text(p.label || p.providerId)}:${text(p.availability || 'unknown')}:${text(p.sourceMode || 'native')}${p.supersededBy ? ':superseded' : ''}`).join(', ') || 'none'}</div>
|
|
<div data-audio-session-selected-monitoring>Selected monitoring: ${selectedMonitoringProvider ? `${text(selectedMonitoringProvider.logicalMonitoringKey || selectedMonitoringProvider.providerId || 'selected')}:${text(selectedMonitoringProvider.availability || 'unknown')}:${text(selectedMonitoringProvider.providerId || 'unknown')}` : 'none'}</div>
|
|
<div data-audio-session-monitoring>Monitoring: ${monitoringSessions.map(s => `${text(s.monitoringId)}:${text(s.state)}:${text((s.requesters || []).map(r => r.requesterId).join('+'))}:${text((s.sourceRef && (s.sourceRef.logicalSourceKey || s.sourceRef.sourceId)) || 'unknown')}`).join(', ') || 'none'}</div>
|
|
<div data-audio-session-direct-monitor>Direct monitor: ${monitoring.directMonitor ? `${text(monitoring.directMonitor.state || monitoring.directMonitor.preference || 'unknown')}:${text(monitoring.directMonitor.control || 'unknown')}:${text(monitoring.directMonitor.applied === true ? 'applied' : (monitoring.directMonitor.applied === false ? 'not-applied' : 'unknown'))}` : 'unknown'}</div>
|
|
<div data-audio-session-stems>Stem owner: ${text(owner && owner.ownerId ? owner.ownerId : 'none')}</div>
|
|
<div data-audio-session-participants>Participants: ${participants.map(p => text(p.label || p.participantId)).join(', ') || 'none'}</div>
|
|
<div data-audio-session-claims>Claims: ${claims.map(c => `${text(c.claimId)}:${text(c.state)}`).join(', ') || 'none'}</div>
|
|
<div data-audio-session-bridges>Bridges: ${bridges.map(b => `${text(b.bridgeId)}:${text(b.outcome || 'handled')}${b.reason ? ` (${text(b.reason)})` : ''}`).join(', ') || 'none'}</div>
|
|
<div data-audio-session-input-bridges>Input bridges: ${inputBridges.map(b => `${text(b.bridgeId)}:${text(b.status || b.outcome || 'handled')}`).join(', ') || 'none'}</div>
|
|
<div data-audio-session-monitoring-bridges>Monitoring bridges: ${monitoringBridges.map(b => `${text(b.bridgeId)}:${text(b.status || b.outcome || 'handled')}`).join(', ') || 'none'}</div>
|
|
<div data-audio-session-failures>Failures: ${failedOutcomes.map(outcome => `${text(outcome.domain)}:${text(outcome.operation)}:${text(outcome.faderId || outcome.monitoringId || outcome.sourceId || outcome.logicalSourceKey || outcome.participantId || outcome.requesterId)}:${text(outcome.status || outcome.outcome)}`).join(', ') || 'none'}</div>
|
|
</div>
|
|
</section>`;
|
|
}
|
|
|
|
function syncFilterOptions(filter, pipelines) {
|
|
if (!filter) return '';
|
|
const names = sortPipelines(pipelines).map(pipeline => pipeline && pipeline.name).filter(Boolean);
|
|
const current = names.includes(filter.value) ? filter.value : '';
|
|
const options = groupedPipelines(pipelines).map(entry => (
|
|
`<optgroup label="${attr(entry.group.label)}">${entry.pipelines.map(pipeline => `<option value="${attr(pipeline.name)}" ${pipeline.name === current ? 'selected' : ''}>${text(pipeline.name)}</option>`).join('')}</optgroup>`
|
|
)).join('');
|
|
filter.innerHTML = `<option value="">All domains</option>${options}`;
|
|
filter.value = current;
|
|
return current;
|
|
}
|
|
|
|
function render() {
|
|
const filter = document.getElementById('capability-inspector-filter');
|
|
const content = document.getElementById('capability-inspector-content');
|
|
const empty = document.getElementById('capability-inspector-empty');
|
|
const summary = document.getElementById('capability-inspector-summary');
|
|
if (!content || !empty) return;
|
|
const data = snapshot();
|
|
if (!data || data.error) {
|
|
content.innerHTML = '';
|
|
empty.classList.remove('hidden');
|
|
empty.textContent = data && data.error ? data.error : 'Capability runtime is loading...';
|
|
if (summary) summary.textContent = '';
|
|
return;
|
|
}
|
|
empty.classList.add('hidden');
|
|
const pipelines = sortPipelines((Array.isArray(data.pipelines) ? data.pipelines : [])
|
|
.filter(pipeline => pipeline && !DOCUMENTATION_ONLY_DOMAINS.has(pipeline.name)));
|
|
const selected = syncFilterOptions(filter, pipelines);
|
|
const visible = selected ? pipelines.filter(pipeline => pipeline.name === selected) : pipelines;
|
|
const shimsByCapability = new Map();
|
|
for (const shim of Array.isArray(data.compatibilityShims) ? data.compatibilityShims : []) {
|
|
const capability = shim && shim.capability;
|
|
if (!capability) continue;
|
|
const list = shimsByCapability.get(capability) || [];
|
|
list.push(shim);
|
|
shimsByCapability.set(capability, list);
|
|
}
|
|
const expectedShimsByCapability = new Map();
|
|
for (const shim of Array.isArray(data.expectedCompatibilityShims) ? data.expectedCompatibilityShims : []) {
|
|
const capability = shim && shim.capability;
|
|
if (!capability) continue;
|
|
const list = expectedShimsByCapability.get(capability) || [];
|
|
list.push(shim);
|
|
expectedShimsByCapability.set(capability, list);
|
|
}
|
|
const compatibilityShims = Array.isArray(data.compatibilityShims) ? data.compatibilityShims : [];
|
|
if (summary) summary.innerHTML = summaryDashboard(data, pipelines, compatibilityShims);
|
|
destroyActiveGraphs();
|
|
const playbackPanel = playbackSupportPanel(playbackSnapshot());
|
|
const audioPanel = audioDomainSupportPanel(audioSessionSnapshot());
|
|
content.innerHTML = playbackPanel + audioPanel + (selected
|
|
? visible.map(pipeline => domainGraphView(pipeline, shimsByCapability.get(pipeline.name) || [], expectedShimsByCapability.get(pipeline.name) || [])).join('')
|
|
: groupedPipelines(visible).map(entry => pipelineGroupSection(entry, shimsByCapability, expectedShimsByCapability, { defaultExpanded: false })).join(''))
|
|
|| '<div class="text-gray-500 text-sm">No capability domains registered.</div>';
|
|
const graphPipelines = selected ? visible : visible.filter(pipeline => isDomainExpanded(pipeline.name, false));
|
|
graphPipelines.forEach(pipeline => {
|
|
mountDomainGraph(pipeline, shimsByCapability.get(pipeline.name) || [], expectedShimsByCapability.get(pipeline.name) || []);
|
|
});
|
|
}
|
|
|
|
function scheduleRender() {
|
|
if (renderScheduled) return;
|
|
renderScheduled = true;
|
|
const schedule = window.requestAnimationFrame ? window.requestAnimationFrame.bind(window) : callback => setTimeout(callback, 0);
|
|
schedule(() => {
|
|
renderScheduled = false;
|
|
render();
|
|
});
|
|
}
|
|
|
|
function install() {
|
|
const refresh = document.getElementById('capability-inspector-refresh');
|
|
const filter = document.getElementById('capability-inspector-filter');
|
|
if (refresh && !refresh.dataset.bound) {
|
|
refresh.dataset.bound = '1';
|
|
refresh.addEventListener('click', render);
|
|
}
|
|
if (filter && !filter.dataset.bound) {
|
|
filter.dataset.bound = '1';
|
|
filter.addEventListener('change', render);
|
|
}
|
|
const content = document.getElementById('capability-inspector-content');
|
|
if (content && !content.dataset.bound) {
|
|
content.dataset.bound = '1';
|
|
content.addEventListener('change', (event) => {
|
|
const target = event.target;
|
|
if (!target || !target.dataset || !target.dataset.surfaceFilter) return;
|
|
state.surfaceFilters = state.surfaceFilters || {};
|
|
state.surfaceFilters[target.dataset.surfaceFilter] = target.value || 'all';
|
|
render();
|
|
});
|
|
content.addEventListener('click', (event) => {
|
|
const graphFilter = event.target && event.target.closest && event.target.closest('[data-domain-graph-filter]');
|
|
if (graphFilter) {
|
|
const value = graphFilter.dataset.domainGraphFilter || 'all';
|
|
state.domainGraphFilter = GRAPH_FILTERS.has(value) ? value : 'all';
|
|
render();
|
|
return;
|
|
}
|
|
const graphGroup = event.target && event.target.closest && event.target.closest('[data-toggle-graph-group]');
|
|
if (graphGroup) {
|
|
const key = graphGroup.dataset.toggleGraphGroup || '';
|
|
if (key) {
|
|
const groups = graphCollapsedGroups();
|
|
if (graphGroup.dataset.graphGroupCollapsed === 'true') delete groups[key];
|
|
else groups[key] = true;
|
|
render();
|
|
}
|
|
return;
|
|
}
|
|
const toggle = event.target && event.target.closest && event.target.closest('[data-toggle-domain]');
|
|
if (toggle) {
|
|
const domain = toggle.dataset.toggleDomain || '';
|
|
const expanded = expandedDomains();
|
|
const isOpen = toggle.getAttribute && toggle.getAttribute('aria-expanded') === 'true';
|
|
expanded[domain] = !isOpen;
|
|
render();
|
|
return;
|
|
}
|
|
const button = event.target && event.target.closest && event.target.closest('[data-copy-surface]');
|
|
if (!button) return;
|
|
const value = button.dataset.copySurface || '';
|
|
const original = button.textContent;
|
|
const done = () => {
|
|
button.textContent = 'Copied';
|
|
setTimeout(() => { button.textContent = original || 'Copy'; }, 1000);
|
|
};
|
|
const clipboard = window.navigator && window.navigator.clipboard;
|
|
if (clipboard && typeof clipboard.writeText === 'function') {
|
|
clipboard.writeText(value).then(done, done);
|
|
} else {
|
|
done();
|
|
}
|
|
});
|
|
content.addEventListener('mouseover', (event) => {
|
|
const target = event.target && event.target.closest && (event.target.closest('[data-graph-focus-kind]') || event.target.closest('[data-graph-hover-participant]'));
|
|
if (!target) return;
|
|
if (event.relatedTarget && target.contains && target.contains(event.relatedTarget)) return;
|
|
applyGraphHover(graphHoverFromElement(target));
|
|
});
|
|
content.addEventListener('mouseout', (event) => {
|
|
const target = event.target && event.target.closest && (event.target.closest('[data-graph-focus-kind]') || event.target.closest('[data-graph-hover-participant]'));
|
|
if (!target) return;
|
|
if (event.relatedTarget && target.contains && target.contains(event.relatedTarget)) return;
|
|
const relatedTarget = event.relatedTarget && event.relatedTarget.closest && (event.relatedTarget.closest('[data-graph-focus-kind]') || event.relatedTarget.closest('[data-graph-hover-participant]'));
|
|
applyGraphHover(relatedTarget ? graphHoverFromElement(relatedTarget) : null);
|
|
});
|
|
}
|
|
render();
|
|
}
|
|
|
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', install);
|
|
else install();
|
|
window.addEventListener('feedBack:capabilities:ready', render);
|
|
window.addEventListener('feedBack:capabilities:changed', scheduleRender);
|
|
})();
|