Files

686 lines
37 KiB
JavaScript

/**
* ComfyUI-Doctor Stats Tab Component
* Provides statistics dashboard within the Doctor sidebar.
* Uses Island Registry for standardized mount/unmount.
*/
import { app } from "../../../../scripts/app.js";
import { register, mount } from "../island_registry.js";
import { renderStatisticsIsland, unmountStatisticsIsland } from "../statistics-island.js";
import { isPreactEnabled } from "../preact-loader.js";
import { DoctorAPI } from "../doctor_api.js";
let isPreactMode = false;
function escapeRegexLiteral(text) {
return String(text || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function deriveSuggestionText(ctx) {
const raw = ctx?.suggestion;
if (!raw) return '';
if (typeof raw === 'string') return raw;
if (typeof raw === 'object') return raw.message || raw.summary || '';
return '';
}
// ═══════════════════════════════════════════════════════════════
// ISLAND REGISTRATION (5C.1)
// ═══════════════════════════════════════════════════════════════
register({
id: 'stats',
isEnabled: () => isPreactEnabled(),
render: async (container, props) => {
const success = await renderStatisticsIsland(container, props, { replace: true });
if (!success) throw new Error('StatisticsIsland render returned false');
},
unmount: (container) => {
unmountStatisticsIsland();
},
fallbackRender: (container, error) => {
renderVanilla(container);
},
onError: (error) => {
console.warn('[StatsTab] Island error:', error?.message);
}
});
// ═══════════════════════════════════════════════════════════════
// TAB RENDER & ACTIVATE
// ═══════════════════════════════════════════════════════════════
export async function render(container) {
const doctorUI = app.Doctor;
// 5C.1: Use registry for mount - it handles Preact and fallback automatically
isPreactMode = false;
// Try Preact Island via Registry
const success = await mount('stats', container, { uiText: doctorUI.uiText });
if (success) {
isPreactMode = true;
// 5B.1: Set island active flag to gate DoctorUI DOM updates
doctorUI.statsIslandActive = true;
doctorUI.sidebarStatsPanel = null;
} else {
// 5B.1: Ensure flag is false when Preact fails
// Note: fallbackRender was already called by registry
doctorUI.statsIslandActive = false;
}
return () => {
doctorUI.statsIslandActive = false;
doctorUI.sidebarStatsPanel = null;
if (isPreactMode) {
unmountStatisticsIsland();
isPreactMode = false;
}
};
}
export function onActivate() {
const doctorUI = app.Doctor;
// Refresh for Vanilla only when running in vanilla mode.
if (!isPreactMode && typeof doctorUI.renderStatistics === 'function') {
doctorUI.renderStatistics();
}
// Refresh for Preact
// Dispatch event to trigger refresh in island
if (isPreactMode) {
window.dispatchEvent(new CustomEvent('doctor-refresh-stats'));
}
}
// ═══════════════════════════════════════════════════════════════
// VANILLA RENDERER (Legacy F13 Logic)
// ═══════════════════════════════════════════════════════════════
function renderVanilla(container) {
const doctorUI = app.Doctor;
const statsPanel = document.createElement('details');
statsPanel.id = 'doctor-statistics-panel';
statsPanel.className = 'stats-panel doctor-sidebar-content';
statsPanel.open = true;
statsPanel.style.cssText = `
background: transparent;
padding: 10px;
margin: 0;
max-height: none;
height: 100%;
border-radius: 0;
`;
// Align empty-state copy with E2E expectations and UX.
const topPatternsEmptyText = doctorUI.getUIText('stats_no_data') || 'No data yet';
statsPanel.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<summary style="pointer-events: none; opacity: 0.7; list-style: none;">📊 ${doctorUI.getUIText('statistics_title') || 'Error Statistics'}</summary>
<div style="display: flex; gap: 6px;">
<button id="doctor-stats-reset-btn" title="${doctorUI.getUIText('stats_reset_btn') || 'Reset'}" style="background: none; border: none; color: #888; cursor: pointer; font-size: 14px;">🗑️</button>
<button id="doctor-stats-refresh-btn" title="Refresh" style="background: none; border: none; color: #888; cursor: pointer; font-size: 14px;">🔄</button>
</div>
</div>
<div id="doctor-stats-reset-message" style="display: none; margin-bottom: 10px; padding: 8px; border-radius: 4px; font-size: 12px;"></div>
<div id="doctor-stats-content">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value" id="stats-total">-</div>
<div class="stat-label">${doctorUI.getUIText('stats_total_errors') || 'Total (30d)'}</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stats-24h">-</div>
<div class="stat-label">${doctorUI.getUIText('stats_last_24h') || 'Last 24h'}</div>
</div>
</div>
<div class="top-patterns" id="doctor-top-patterns" style="margin-top: 20px;">
<h5>🔥 ${doctorUI.getUIText('stats_top_patterns') || 'Top Error Patterns'}</h5>
<div class="stats-empty">${topPatternsEmptyText}</div>
</div>
<div class="category-breakdown" id="doctor-category-breakdown" style="margin-top: 20px;">
<h5>📁 ${doctorUI.getUIText('stats_categories') || 'Categories'}</h5>
</div>
<details id="doctor-feedback-panel" style="margin-top: 20px; border-top: 1px solid #444; padding-top: 12px;">
<summary style="cursor: pointer; color: #ccc; font-size: 13px; user-select: none;">
📨 ${doctorUI.getUIText('feedback_quick_title') || 'Quick Community Feedback (GitHub PR)'}
</summary>
<div style="margin-top: 10px; display: flex; flex-direction: column; gap: 8px;">
<div style="font-size: 11px; color: #888;">
${doctorUI.getUIText('feedback_quick_hint') || 'Create a sanitized feedback PR with pattern candidate + verified suggestion. Server-side GitHub token required for submit.'}
</div>
<div style="display: grid; grid-template-columns: 1fr 110px; gap: 8px;">
<input id="doctor-feedback-pattern-id" placeholder="${doctorUI.getUIText('feedback_pattern_id_placeholder') || 'pattern id'}" style="padding:6px;background:#111;border:1px solid #444;border-radius:4px;color:#eee;font-size:12px;" />
<select id="doctor-feedback-category" style="padding:6px;background:#111;border:1px solid #444;border-radius:4px;color:#eee;font-size:12px;">
<option value="generic">generic</option>
<option value="workflow">workflow</option>
<option value="memory">memory</option>
<option value="model_loading">model_loading</option>
<option value="framework">framework</option>
<option value="validation">validation</option>
<option value="type">type</option>
<option value="execution">execution</option>
</select>
</div>
<div style="display: grid; grid-template-columns: 1fr 80px; gap: 8px;">
<textarea id="doctor-feedback-pattern-regex" rows="2" placeholder="${doctorUI.getUIText('feedback_regex_placeholder') || 'Regex pattern'}" style="padding:6px;background:#111;border:1px solid #444;border-radius:4px;color:#eee;font-size:12px;resize:vertical;"></textarea>
<input id="doctor-feedback-priority" type="number" min="1" max="100" value="60" style="padding:6px;background:#111;border:1px solid #444;border-radius:4px;color:#eee;font-size:12px;" />
</div>
<textarea id="doctor-feedback-suggestion" rows="3" placeholder="${doctorUI.getUIText('feedback_suggestion_placeholder') || 'Verified suggestion / fix notes'}" style="padding:6px;background:#111;border:1px solid #444;border-radius:4px;color:#eee;font-size:12px;resize:vertical;"></textarea>
<div style="display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap;">
<label style="font-size:11px;color:#aaa;display:flex;align-items:center;gap:6px;">
<input id="doctor-feedback-include-stats" type="checkbox" checked />
${doctorUI.getUIText('feedback_include_stats_label') || 'Include statistics snapshot (30d)'}
</label>
<button id="doctor-feedback-autofill-btn" type="button" style="padding:5px 8px;background:#333;border:1px solid #444;border-radius:4px;color:#eee;font-size:11px;cursor:pointer;">${doctorUI.getUIText('feedback_autofill_btn') || '↺ Autofill from latest error'}</button>
</div>
<input id="doctor-feedback-admin-token" type="password" placeholder="${doctorUI.getUIText('feedback_admin_token_placeholder') || 'Admin token (optional if loopback/no DOCTOR_ADMIN_TOKEN)'}" style="padding:6px;background:#111;border:1px solid #444;border-radius:4px;color:#eee;font-size:12px;" />
<div style="display:flex; gap:8px;">
<button id="doctor-feedback-preview-btn" style="flex:1;padding:8px;background:#2d6cdf;border:none;border-radius:4px;color:white;font-size:12px;cursor:pointer;">${doctorUI.getUIText('feedback_preview_btn') || 'Preview Sanitized Payload'}</button>
<button id="doctor-feedback-submit-btn" style="flex:1;padding:8px;background:#2e7d32;border:none;border-radius:4px;color:white;font-size:12px;cursor:pointer;">${doctorUI.getUIText('feedback_submit_btn') || 'Create GitHub PR'}</button>
</div>
<div id="doctor-feedback-status" style="font-size:11px;color:#8bc34a;"></div>
<pre id="doctor-feedback-preview-output" style="margin:0;max-height:220px;overflow:auto;white-space:pre-wrap;word-break:break-word;background:#161616;border:1px solid #333;border-radius:4px;padding:8px;color:#bbb;font-size:11px;">${doctorUI.getUIText('feedback_preview_empty') || 'Preview output will appear here.'}</pre>
</div>
</details>
</div>
<!-- Trust & Health Section -->
<div id="doctor-trust-health-panel" style="border-top: 1px solid #444; padding-top: 15px; margin-top: 20px;">
<div style="display: flex; align-items: center; justify-content: space-between; gap: 10px;">
<div style="display: flex; align-items: center; gap: 6px; font-size: 13px; color: #aaa;">
🛡️ <span>${doctorUI.getUIText('trust_health_title') || 'Trust & Health'}</span>
</div>
<button id="doctor-trust-health-refresh-btn" style="padding: 6px 10px; background: #333; border: 1px solid #444; border-radius: 4px; color: #eee; cursor: pointer; font-size: 12px;" title="${doctorUI.getUIText('refresh_btn') || 'Refresh'}">🔄</button>
</div>
<div id="doctor-health-output" style="margin-top: 10px; font-size: 12px; color: #bbb; line-height: 1.4;">
${doctorUI.getUIText('trust_health_hint') || 'Fetch /doctor/health and plugin trust report (scan-only).'}
</div>
<div id="doctor-plugins-output" style="margin-top: 10px; font-size: 12px; color: #bbb; line-height: 1.4;"></div>
</div>
<!-- Anonymous Telemetry Section -->
<div id="doctor-telemetry-panel" style="border-top: 1px solid #444; padding-top: 15px; margin-top: 15px;">
<div style="display: flex; align-items: center; justify-content: space-between; gap: 10px;">
<div style="display: flex; align-items: center; gap: 6px; font-size: 13px; color: #aaa;">
📊 <span>${doctorUI.getUIText('telemetry_label') || 'Anonymous Telemetry'}</span>
</div>
<label class="doctor-toggle" style="position: relative; display: inline-block; width: 40px; height: 22px;">
<input type="checkbox" id="doctor-telemetry-toggle" style="opacity: 0; width: 0; height: 0;">
<span class="doctor-toggle-slider" style="position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #444; transition: .3s; border-radius: 22px;"></span>
</label>
</div>
<div style="font-size: 12px; color: #888; margin-top: 5px;">${doctorUI.getUIText('telemetry_description') || 'Send anonymous usage data to help improve Doctor'}</div>
<div id="doctor-telemetry-stats" style="font-size: 11px; color: #666; margin-top: 5px;"></div>
<div style="display: flex; gap: 8px; margin-top: 10px;">
<button id="doctor-telemetry-view-btn" style="flex: 1; padding: 6px 10px; background: #333; border: 1px solid #444; border-radius: 4px; color: #eee; cursor: pointer; font-size: 11px;">${doctorUI.getUIText('telemetry_view_buffer') || 'View Buffer'}</button>
<button id="doctor-telemetry-clear-btn" style="flex: 1; padding: 6px 10px; background: #333; border: 1px solid #444; border-radius: 4px; color: #eee; cursor: pointer; font-size: 11px;">${doctorUI.getUIText('telemetry_clear_all') || 'Clear All'}</button>
<button id="doctor-telemetry-export-btn" style="flex: 1; padding: 6px 10px; background: #333; border: 1px solid #444; border-radius: 4px; color: #eee; cursor: pointer; font-size: 11px;">${doctorUI.getUIText('telemetry_export') || 'Export'}</button>
</div>
<div style="font-size: 11px; color: #666; margin-top: 5px;">${doctorUI.getUIText('telemetry_upload_none') || 'Upload destination: None (local only)'}</div>
</div>
`;
container.appendChild(statsPanel);
doctorUI.sidebarStatsPanel = statsPanel;
// Wire up Stats Reset button
setupStatsResetHandler(statsPanel, doctorUI);
// Wire up F16 community feedback panel
setupCommunityFeedbackHandlers(statsPanel, doctorUI);
// Wire up Trust & Health button
setupTrustHealthHandlers(statsPanel, doctorUI);
// Wire up Telemetry controls
setupTelemetryHandlers(statsPanel, doctorUI);
if (typeof doctorUI.renderStatistics === 'function') {
doctorUI.renderStatistics();
}
}
// ═══════════════════════════════════════════════════════════════
// STATS RESET HANDLER (R16)
// ═══════════════════════════════════════════════════════════════
function setupStatsResetHandler(container, doctorUI) {
const resetBtn = container.querySelector('#doctor-stats-reset-btn');
const refreshBtn = container.querySelector('#doctor-stats-refresh-btn');
const messageDiv = container.querySelector('#doctor-stats-reset-message');
// Wire up refresh button (in vanilla mode)
if (refreshBtn) {
refreshBtn.addEventListener('click', () => {
if (typeof doctorUI.renderStatistics === 'function') {
doctorUI.renderStatistics();
}
});
}
if (!resetBtn) return;
resetBtn.addEventListener('click', async () => {
const confirmText = doctorUI.getUIText('stats_reset_confirm') || 'Reset statistics? This will clear all error history.';
if (!confirm(confirmText)) return;
resetBtn.disabled = true;
const original = resetBtn.textContent;
resetBtn.textContent = '⏳';
try {
const result = await DoctorAPI.resetStatistics();
if (result.success) {
messageDiv.style.display = 'block';
messageDiv.style.background = 'rgba(76, 175, 80, 0.2)';
messageDiv.style.color = '#4caf50';
messageDiv.textContent = doctorUI.getUIText('stats_reset_success') || 'Statistics reset successfully';
// Refresh stats
if (typeof doctorUI.renderStatistics === 'function') {
doctorUI.renderStatistics();
}
} else {
messageDiv.style.display = 'block';
messageDiv.style.background = 'rgba(244, 67, 54, 0.2)';
messageDiv.style.color = '#f44336';
messageDiv.textContent = result.message || doctorUI.getUIText('stats_reset_failed') || 'Failed to reset statistics';
}
} catch (e) {
messageDiv.style.display = 'block';
messageDiv.style.background = 'rgba(244, 67, 54, 0.2)';
messageDiv.style.color = '#f44336';
messageDiv.textContent = doctorUI.getUIText('stats_reset_failed') || 'Failed to reset statistics';
} finally {
resetBtn.disabled = false;
resetBtn.textContent = original;
setTimeout(() => { messageDiv.style.display = 'none'; }, 3000);
}
});
}
function setupCommunityFeedbackHandlers(container, doctorUI) {
const patternIdInput = container.querySelector('#doctor-feedback-pattern-id');
const categorySelect = container.querySelector('#doctor-feedback-category');
const regexInput = container.querySelector('#doctor-feedback-pattern-regex');
const priorityInput = container.querySelector('#doctor-feedback-priority');
const suggestionInput = container.querySelector('#doctor-feedback-suggestion');
const includeStatsInput = container.querySelector('#doctor-feedback-include-stats');
const adminTokenInput = container.querySelector('#doctor-feedback-admin-token');
const autofillBtn = container.querySelector('#doctor-feedback-autofill-btn');
const previewBtn = container.querySelector('#doctor-feedback-preview-btn');
const submitBtn = container.querySelector('#doctor-feedback-submit-btn');
const statusDiv = container.querySelector('#doctor-feedback-status');
const previewOutput = container.querySelector('#doctor-feedback-preview-output');
if (!patternIdInput || !regexInput || !suggestionInput || !previewBtn || !submitBtn) return;
const getSeed = () => {
const ctx = doctorUI.lastErrorData || {};
const summary = (ctx.error_summary || ctx.last_error || ctx.error || '').trim();
const seedLine = (summary.split('\n').find(Boolean) || 'RuntimeError').slice(0, 180);
return {
patternId: 'community_user_feedback',
category: 'generic',
regex: escapeRegexLiteral(seedLine || 'RuntimeError'),
suggestion: deriveSuggestionText(ctx),
errorContext: ctx,
};
};
const setStatus = (text, type = 'success') => {
if (!statusDiv) return;
statusDiv.style.color = type === 'error' ? '#ff6b6b' : '#8bc34a';
statusDiv.innerHTML = text || '';
};
const buildPayload = async () => {
const includeStats = !!includeStatsInput?.checked;
let statsSnapshot = null;
if (includeStats) {
const statsResult = await DoctorAPI.getStatistics(30);
if (statsResult?.success) {
statsSnapshot = { ...(statsResult.statistics || {}), time_range_days: 30 };
}
}
return {
pattern_candidate: {
id: patternIdInput.value,
regex: regexInput.value,
category: categorySelect?.value || 'generic',
priority: Number(priorityInput?.value || 60),
notes: 'Submitted via Doctor F16 Quick Community Feedback',
},
suggestion_candidate: {
language: 'en',
message: suggestionInput.value,
},
error_context: (doctorUI.lastErrorData || {}),
include_stats: includeStats,
stats_snapshot: statsSnapshot,
};
};
const fillFromLatest = () => {
const seed = getSeed();
if (!patternIdInput.value) patternIdInput.value = seed.patternId;
patternIdInput.value = seed.patternId;
categorySelect.value = seed.category;
regexInput.value = seed.regex;
if (seed.suggestion) suggestionInput.value = seed.suggestion;
if (previewOutput) previewOutput.textContent = doctorUI.getUIText('feedback_preview_empty') || 'Preview output will appear here.';
setStatus('');
};
autofillBtn?.addEventListener('click', fillFromLatest);
fillFromLatest();
previewBtn.addEventListener('click', async () => {
previewBtn.disabled = true;
submitBtn.disabled = true;
previewBtn.textContent = doctorUI.getUIText('feedback_previewing_btn') || '⏳ Previewing...';
setStatus('');
try {
const payload = await buildPayload();
const result = await DoctorAPI.previewCommunityFeedback(payload);
if (result?.success) {
if (previewOutput) previewOutput.textContent = JSON.stringify(result.preview, null, 2);
const warnCount = (result.warnings || []).length;
if (warnCount > 0) {
const warnText = doctorUI.getUIText('feedback_preview_ready_warnings')
? doctorUI.getUIText('feedback_preview_ready_warnings').replace('{0}', warnCount)
: `Preview ready (${warnCount} warning${warnCount > 1 ? 's' : ''})`;
setStatus(warnText);
} else {
setStatus(doctorUI.getUIText('feedback_preview_ready') || 'Preview ready');
}
} else {
if (previewOutput) previewOutput.textContent = JSON.stringify(result?.field_errors || result || {}, null, 2);
setStatus(result?.error || doctorUI.getUIText('feedback_preview_failed') || 'Preview failed', 'error');
}
} catch (e) {
setStatus(e?.message || doctorUI.getUIText('feedback_preview_failed') || 'Preview failed', 'error');
} finally {
previewBtn.disabled = false;
submitBtn.disabled = false;
previewBtn.textContent = doctorUI.getUIText('feedback_preview_btn') || 'Preview Sanitized Payload';
}
});
submitBtn.addEventListener('click', async () => {
previewBtn.disabled = true;
submitBtn.disabled = true;
submitBtn.textContent = doctorUI.getUIText('feedback_submitting_btn') || '⏳ Creating PR...';
setStatus('');
try {
const payload = await buildPayload();
const result = await DoctorAPI.submitCommunityFeedback(payload, adminTokenInput?.value || '');
if (result?.success) {
const prUrl = result?.github?.pr_url;
const successText = doctorUI.getUIText('feedback_submit_success') || 'GitHub PR created';
if (prUrl) {
setStatus(`${successText}: <a href="${prUrl}" target="_blank" rel="noopener noreferrer" style="color:#7db7ff;">${prUrl}</a>`);
} else {
setStatus(successText);
}
if (previewOutput && result.preview) previewOutput.textContent = JSON.stringify(result.preview, null, 2);
} else {
if (previewOutput) previewOutput.textContent = JSON.stringify(result?.field_errors || result || {}, null, 2);
setStatus(result?.message || result?.error || doctorUI.getUIText('feedback_submit_failed') || 'Submit failed', 'error');
}
} catch (e) {
setStatus(e?.message || doctorUI.getUIText('feedback_submit_failed') || 'Submit failed', 'error');
} finally {
previewBtn.disabled = false;
submitBtn.disabled = false;
submitBtn.textContent = doctorUI.getUIText('feedback_submit_btn') || 'Create GitHub PR';
}
});
}
// ═══════════════════════════════════════════════════════════════
// TRUST & HEALTH HANDLERS (Moved from Settings tab)
// ═══════════════════════════════════════════════════════════════
function setupTrustHealthHandlers(container, doctorUI) {
const trustHealthBtn = container.querySelector('#doctor-trust-health-refresh-btn');
const healthOutput = container.querySelector('#doctor-health-output');
const pluginsOutput = container.querySelector('#doctor-plugins-output');
const renderTrustBadge = (trust) => {
const map = {
trusted: { fg: '#c8f7c5', bg: 'rgba(76, 175, 80, 0.18)', border: 'rgba(76, 175, 80, 0.35)' },
unsigned: { fg: '#ffe5b4', bg: 'rgba(255, 193, 7, 0.14)', border: 'rgba(255, 193, 7, 0.35)' },
untrusted: { fg: '#ffd6d6', bg: 'rgba(244, 67, 54, 0.14)', border: 'rgba(244, 67, 54, 0.35)' },
blocked: { fg: '#ffbdbd', bg: 'rgba(244, 67, 54, 0.20)', border: 'rgba(244, 67, 54, 0.45)' },
};
const s = map[trust] || { fg: '#ddd', bg: 'rgba(158,158,158,0.10)', border: 'rgba(158,158,158,0.25)' };
const el = document.createElement('span');
el.textContent = trust || 'unknown';
el.style.cssText = `display:inline-block; padding:2px 6px; border-radius:999px; font-size:11px; border:1px solid ${s.border}; background:${s.bg}; color:${s.fg};`;
return el;
};
const refreshTrustHealth = async () => {
if (!trustHealthBtn) return;
trustHealthBtn.disabled = true;
const original = trustHealthBtn.textContent;
trustHealthBtn.textContent = '⏳';
try {
// Health
const healthRes = await DoctorAPI.getHealth();
if (healthRes?.success && healthRes.health) {
const h = healthRes.health;
const pipelineStatus = h.last_analysis?.pipeline_status || 'unknown';
const ssrfBlocked = h.ssrf?.blocked_total ?? h.ssrf?.blocked ?? 0;
const dropped = h.logger?.dropped_messages ?? 0;
healthOutput.textContent = `Health: pipeline_status=${pipelineStatus}, ssrf_blocked=${ssrfBlocked}, dropped_logs=${dropped}`;
} else {
const msg = healthRes?.error || 'Failed to load /doctor/health';
healthOutput.textContent = `Health: ${msg}`;
}
// Plugins
const pluginsRes = await DoctorAPI.getPluginsReport();
pluginsOutput.innerHTML = '';
if (pluginsRes?.success && pluginsRes.plugins) {
const payload = pluginsRes.plugins;
const header = document.createElement('div');
header.style.cssText = 'color:#aaa; font-size:12px; margin-bottom:6px;';
const trustCounts = payload.trust_counts || {};
const enabled = payload.config?.enabled ? 'enabled' : 'disabled';
const allowlistCount = payload.config?.allowlist_count ?? 0;
const sigRequired = payload.config?.signature_required ? 'required' : 'optional';
header.textContent = `Plugins: ${enabled}, allowlist=${allowlistCount}, signature=${sigRequired}`;
pluginsOutput.appendChild(header);
const list = document.createElement('div');
list.style.cssText = 'display:flex; flex-direction:column; gap:6px;';
const items = payload.plugins || [];
if (!items.length) {
const empty = document.createElement('div');
empty.style.cssText = 'color:#888; font-size:12px;';
empty.textContent = doctorUI.getUIText('plugins_none_found') || 'No plugins found.';
list.appendChild(empty);
} else {
items.slice(0, 10).forEach((p) => {
const row = document.createElement('div');
row.style.cssText = 'display:flex; align-items:center; gap:8px; padding:6px 8px; border:1px solid #333; border-radius:6px; background:#161616;';
const name = document.createElement('div');
name.style.cssText = 'flex:1; min-width:0;';
const title = document.createElement('div');
title.style.cssText = 'font-size:12px; color:#ddd; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;';
title.textContent = p.plugin_id || p.file || 'unknown';
const sub = document.createElement('div');
sub.style.cssText = 'font-size:11px; color:#888; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;';
sub.textContent = `${p.file || ''}${p.reason ? ` • ${p.reason}` : ''}`;
name.appendChild(title);
name.appendChild(sub);
row.appendChild(renderTrustBadge(p.trust));
row.appendChild(name);
list.appendChild(row);
});
}
pluginsOutput.appendChild(list);
const summary = document.createElement('div');
summary.style.cssText = 'margin-top:8px; font-size:11px; color:#888;';
summary.textContent = `Trust counts: ${Object.entries(trustCounts).map(([k, v]) => `${k}=${v}`).join(', ') || 'none'}`;
pluginsOutput.appendChild(summary);
} else {
const msg = pluginsRes?.error || 'Failed to load /doctor/plugins';
pluginsOutput.textContent = `Plugins: ${msg}`;
}
} catch (e) {
healthOutput.textContent = `Health: ${e?.message || 'error'}`;
pluginsOutput.textContent = '';
} finally {
trustHealthBtn.textContent = original;
trustHealthBtn.disabled = false;
}
};
if (trustHealthBtn) {
trustHealthBtn.onclick = () => refreshTrustHealth();
}
}
// ═══════════════════════════════════════════════════════════════
// TELEMETRY HANDLERS (Moved from Settings tab)
// ═══════════════════════════════════════════════════════════════
function setupTelemetryHandlers(container, doctorUI) {
const telemetryToggle = container.querySelector('#doctor-telemetry-toggle');
const telemetryStats = container.querySelector('#doctor-telemetry-stats');
const telemetryViewBtn = container.querySelector('#doctor-telemetry-view-btn');
const telemetryClearBtn = container.querySelector('#doctor-telemetry-clear-btn');
const telemetryExportBtn = container.querySelector('#doctor-telemetry-export-btn');
const updateToggleSlider = (toggle) => {
const slider = toggle?.nextElementSibling;
if (slider) {
if (toggle.checked) {
slider.style.background = '#4caf50';
slider.innerHTML = '<span style="position:absolute;left:4px;top:3px;width:16px;height:16px;background:#fff;border-radius:50%;transition:.3s;transform:translateX(18px);"></span>';
} else {
slider.style.background = '#444';
slider.innerHTML = '<span style="position:absolute;left:4px;top:3px;width:16px;height:16px;background:#fff;border-radius:50%;transition:.3s;"></span>';
}
}
};
const updateTelemetryStats = async () => {
if (!telemetryStats) return;
try {
const res = await fetch('/doctor/telemetry/status');
const data = await res.json();
if (data.success) {
if (telemetryToggle) {
telemetryToggle.checked = data.enabled;
updateToggleSlider(telemetryToggle);
}
const count = data.stats?.count || 0;
telemetryStats.textContent = `${doctorUI.getUIText('telemetry_buffer_count')?.replace('{n}', count) || `Currently buffered: ${count} events`}`;
}
} catch (e) {
telemetryStats.textContent = 'Status unavailable';
}
};
if (telemetryToggle) {
telemetryToggle.onchange = async () => {
try {
const res = await fetch('/doctor/telemetry/toggle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: telemetryToggle.checked })
});
const data = await res.json();
telemetryToggle.checked = data.enabled;
updateToggleSlider(telemetryToggle);
updateTelemetryStats();
} catch (e) {
console.error('[ComfyUI-Doctor] Telemetry toggle error:', e);
}
};
updateToggleSlider(telemetryToggle);
}
if (telemetryViewBtn) {
telemetryViewBtn.onclick = async () => {
try {
const res = await fetch('/doctor/telemetry/buffer');
const data = await res.json();
if (data.success) {
const events = data.events || [];
const content = events.length > 0
? JSON.stringify(events.slice(-20), null, 2)
: 'No events buffered';
alert(`Telemetry Buffer (${events.length} events):\n\n${content.slice(0, 2000)}`);
}
} catch (e) {
alert('Failed to load buffer');
}
};
}
if (telemetryClearBtn) {
telemetryClearBtn.onclick = async () => {
if (!confirm(doctorUI.getUIText('telemetry_confirm_clear') || 'Clear all telemetry data?')) {
return;
}
try {
const res = await fetch('/doctor/telemetry/clear', { method: 'POST' });
const data = await res.json();
if (data.success) {
telemetryClearBtn.textContent = '✅';
setTimeout(() => {
telemetryClearBtn.textContent = doctorUI.getUIText('telemetry_clear_all') || 'Clear All';
}, 1500);
updateTelemetryStats();
}
} catch (e) {
telemetryClearBtn.textContent = '❌';
setTimeout(() => {
telemetryClearBtn.textContent = doctorUI.getUIText('telemetry_clear_all') || 'Clear All';
}, 1500);
}
};
}
if (telemetryExportBtn) {
telemetryExportBtn.onclick = async () => {
try {
const res = await fetch('/doctor/telemetry/export');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'telemetry_export.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
telemetryExportBtn.textContent = '✅';
setTimeout(() => {
telemetryExportBtn.textContent = doctorUI.getUIText('telemetry_export') || 'Export';
}, 1500);
} catch (e) {
telemetryExportBtn.textContent = '❌';
setTimeout(() => {
telemetryExportBtn.textContent = doctorUI.getUIText('telemetry_export') || 'Export';
}, 1500);
}
};
}
// Load telemetry status on init
updateTelemetryStats();
}