Web UI polish: thumbnail-hover preview, white thumbnail selection, Themes bullet list; global Scryfall image retry (thumbs+previews) with fallbacks and cache-bust; standardized data-card-name. Deck Summary alignment overhaul (count//name/owned grid, tabular numerals, inset highlight, tooltips, starts under header). Added diagnostics (health + logs pages, error pages, request-id propagation), global HTMX error toasts, and docs updates. Update DOCKER guide and add run-web scripts. Update CHANGELOG and release notes template.
2025-08-27 11:21:46 -07:00
|
|
|
|
{% extends "base.html" %}
|
|
|
|
|
|
{% block content %}
|
|
|
|
|
|
<section>
|
|
|
|
|
|
<h2>Diagnostics</h2>
|
|
|
|
|
|
<p class="muted">Use these tools to verify error handling surfaces.</p>
|
2025-08-28 16:44:58 -07:00
|
|
|
|
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
|
Web UI polish: thumbnail-hover preview, white thumbnail selection, Themes bullet list; global Scryfall image retry (thumbs+previews) with fallbacks and cache-bust; standardized data-card-name. Deck Summary alignment overhaul (count//name/owned grid, tabular numerals, inset highlight, tooltips, starts under header). Added diagnostics (health + logs pages, error pages, request-id propagation), global HTMX error toasts, and docs updates. Update DOCKER guide and add run-web scripts. Update CHANGELOG and release notes template.
2025-08-27 11:21:46 -07:00
|
|
|
|
<h3 style="margin-top:0">System summary</h3>
|
|
|
|
|
|
<div id="sysSummary" class="muted">Loading…</div>
|
2025-10-03 10:43:24 -07:00
|
|
|
|
<div id="envFlags" style="margin-top:.5rem"></div>
|
|
|
|
|
|
<div id="themeSuppMetrics" class="muted" style="margin-top:.5rem">Loading theme metrics…</div>
|
2025-08-28 16:44:58 -07:00
|
|
|
|
<div id="themeSummary" style="margin-top:.5rem"></div>
|
2025-09-26 18:15:52 -07:00
|
|
|
|
<div id="themeTokenStats" class="muted" style="margin-top:.5rem">Loading theme stats…</div>
|
2025-08-28 16:44:58 -07:00
|
|
|
|
<div style="margin-top:.35rem">
|
|
|
|
|
|
<button class="btn" id="diag-theme-reset">Reset theme preference</button>
|
|
|
|
|
|
</div>
|
Web UI polish: thumbnail-hover preview, white thumbnail selection, Themes bullet list; global Scryfall image retry (thumbs+previews) with fallbacks and cache-bust; standardized data-card-name. Deck Summary alignment overhaul (count//name/owned grid, tabular numerals, inset highlight, tooltips, starts under header). Added diagnostics (health + logs pages, error pages, request-id propagation), global HTMX error toasts, and docs updates. Update DOCKER guide and add run-web scripts. Update CHANGELOG and release notes template.
2025-08-27 11:21:46 -07:00
|
|
|
|
</div>
|
2025-10-02 15:31:05 -07:00
|
|
|
|
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
|
|
|
|
|
|
<h3 style="margin-top:0">Multi-face merge snapshot</h3>
|
|
|
|
|
|
<div class="muted" style="margin-bottom:.35rem">Pulls from <code>logs/dfc_merge_summary.json</code> to verify merge coverage.</div>
|
2025-10-03 10:43:24 -07:00
|
|
|
|
{% set colors = (merge_summary.colors if merge_summary else {}) | default({}) %}
|
2025-10-02 15:31:05 -07:00
|
|
|
|
{% if colors %}
|
|
|
|
|
|
<div class="muted" style="margin-bottom:.35rem">Last updated: {{ merge_summary.updated_at or 'unknown' }}</div>
|
|
|
|
|
|
<div style="overflow-x:auto">
|
|
|
|
|
|
<table style="width:100%; border-collapse:collapse; font-size:13px;">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr style="border-bottom:1px solid var(--border); text-align:left;">
|
|
|
|
|
|
<th style="padding:.35rem .25rem;">Color</th>
|
|
|
|
|
|
<th style="padding:.35rem .25rem;">Groups merged</th>
|
|
|
|
|
|
<th style="padding:.35rem .25rem;">Faces dropped</th>
|
|
|
|
|
|
<th style="padding:.35rem .25rem;">Multi-face rows</th>
|
|
|
|
|
|
<th style="padding:.35rem .25rem;">Latest entries</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
2025-10-03 10:43:24 -07:00
|
|
|
|
{% for item in colors|dictsort %}
|
|
|
|
|
|
{% set color = item[0] %}
|
|
|
|
|
|
{% set payload = item[1] | default({}) %}
|
2025-10-02 15:31:05 -07:00
|
|
|
|
<tr style="border-bottom:1px solid rgba(148,163,184,0.2);">
|
|
|
|
|
|
<td style="padding:.35rem .25rem; font-weight:600;">{{ color|title }}</td>
|
|
|
|
|
|
<td style="padding:.35rem .25rem;">{{ payload.group_count or 0 }}</td>
|
|
|
|
|
|
<td style="padding:.35rem .25rem;">{{ payload.faces_dropped or 0 }}</td>
|
|
|
|
|
|
<td style="padding:.35rem .25rem;">{{ payload.multi_face_rows or 0 }}</td>
|
|
|
|
|
|
<td style="padding:.35rem .25rem;">
|
2025-10-03 10:43:24 -07:00
|
|
|
|
{% set entries = (payload.entries | default([])) %}
|
2025-10-02 15:31:05 -07:00
|
|
|
|
{% if entries %}
|
|
|
|
|
|
<details>
|
|
|
|
|
|
<summary style="cursor:pointer;">{{ entries|length }} recorded</summary>
|
2025-10-03 10:43:24 -07:00
|
|
|
|
{% set preview = entries[:5] %}
|
2025-10-02 15:31:05 -07:00
|
|
|
|
<ul style="margin:.35rem 0 0 .75rem; padding:0; list-style:disc; max-height:180px; overflow:auto;">
|
2025-10-03 10:43:24 -07:00
|
|
|
|
{% for entry in preview %}
|
|
|
|
|
|
<li style="margin-bottom:.25rem;">
|
|
|
|
|
|
<strong>{{ entry.name }}</strong> — {{ entry.total_faces }} faces (dropped {{ entry.dropped_faces }})
|
|
|
|
|
|
</li>
|
2025-10-02 15:31:05 -07:00
|
|
|
|
{% endfor %}
|
2025-10-03 10:43:24 -07:00
|
|
|
|
{% if entries|length > preview|length %}
|
|
|
|
|
|
<li style="font-size:11px; opacity:.75;">… {{ entries|length - preview|length }} more entries</li>
|
|
|
|
|
|
{% endif %}
|
2025-10-02 15:31:05 -07:00
|
|
|
|
</ul>
|
|
|
|
|
|
</details>
|
|
|
|
|
|
{% else %}
|
|
|
|
|
|
<span class="muted">No groups recorded</span>
|
|
|
|
|
|
{% endif %}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
{% endfor %}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{% else %}
|
|
|
|
|
|
<div class="muted">No merge summary has been recorded. Run the tagger with multi-face merging enabled.</div>
|
|
|
|
|
|
{% endif %}
|
|
|
|
|
|
<div id="dfcMetrics" class="muted" style="margin-top:.5rem;">Loading MDFC metrics…</div>
|
|
|
|
|
|
</div>
|
2025-08-28 16:44:58 -07:00
|
|
|
|
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
|
2025-08-28 14:57:22 -07:00
|
|
|
|
<h3 style="margin-top:0">Performance (local)</h3>
|
|
|
|
|
|
<div class="muted" style="margin-bottom:.35rem">Scroll the Step 5 list; this panel shows a rough FPS estimate and virtualization renders.</div>
|
|
|
|
|
|
<div style="display:flex; gap:1rem; flex-wrap:wrap">
|
|
|
|
|
|
<div><strong>Scroll FPS:</strong> <span id="perf-fps">–</span></div>
|
|
|
|
|
|
<div><strong>Visible tiles:</strong> <span id="perf-visible">–</span></div>
|
|
|
|
|
|
<div><strong>Render count:</strong> <span id="perf-renders">0</span></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-09-01 16:55:24 -07:00
|
|
|
|
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
|
|
|
|
|
|
<h3 style="margin-top:0">Combos & Synergies (ad-hoc)</h3>
|
|
|
|
|
|
<div class="muted" style="margin-bottom:.35rem">Paste card names (one per line) and detect two-card combos and synergies using current lists.</div>
|
|
|
|
|
|
<textarea id="diag-combos-input" rows="6" style="width:100%; resize:vertical; font-family: var(--mono);"></textarea>
|
|
|
|
|
|
<div style="margin-top:.5rem; display:flex; gap:.5rem; align-items:center">
|
|
|
|
|
|
<button class="btn" id="diag-combos-run">Detect</button>
|
|
|
|
|
|
<small class="muted">Runs in diagnostics mode only.</small>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<pre id="diag-combos-out" style="margin-top:.5rem; white-space:pre-wrap"></pre>
|
|
|
|
|
|
</div>
|
2025-08-28 16:44:58 -07:00
|
|
|
|
{% if enable_pwa %}
|
|
|
|
|
|
<div class="card" style="background:#0f1115; border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
|
|
|
|
|
|
<h3 style="margin-top:0">PWA status</h3>
|
|
|
|
|
|
<div id="pwaStatus" class="muted">Checking…</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{% endif %}
|
|
|
|
|
|
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem;">
|
Web UI polish: thumbnail-hover preview, white thumbnail selection, Themes bullet list; global Scryfall image retry (thumbs+previews) with fallbacks and cache-bust; standardized data-card-name. Deck Summary alignment overhaul (count//name/owned grid, tabular numerals, inset highlight, tooltips, starts under header). Added diagnostics (health + logs pages, error pages, request-id propagation), global HTMX error toasts, and docs updates. Update DOCKER guide and add run-web scripts. Update CHANGELOG and release notes template.
2025-08-27 11:21:46 -07:00
|
|
|
|
<h3 style="margin-top:0">Error triggers</h3>
|
|
|
|
|
|
<div class="row" style="display:flex; gap:.5rem; align-items:center">
|
|
|
|
|
|
<button class="btn" hx-get="/diagnostics/trigger-error" hx-trigger="click" hx-target="this" hx-swap="none">Trigger HTTP error (418)</button>
|
|
|
|
|
|
<button class="btn" hx-get="/diagnostics/trigger-error?kind=unhandled" hx-trigger="click" hx-target="this" hx-swap="none">Trigger unhandled error (500)</button>
|
|
|
|
|
|
<small class="muted">You should see a toast and an inline banner with Request-ID.</small>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{% if show_logs %}
|
|
|
|
|
|
<p style="margin-top:.75rem"><a class="btn" href="/logs">Open Logs</a></p>
|
|
|
|
|
|
{% endif %}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
<script>
|
|
|
|
|
|
(function(){
|
|
|
|
|
|
var el = document.getElementById('sysSummary');
|
|
|
|
|
|
function render(data){
|
|
|
|
|
|
if (!el) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
var v = (data && data.version) || 'dev';
|
|
|
|
|
|
var up = (data && data.uptime_seconds) || 0;
|
|
|
|
|
|
var st = (data && data.server_time_utc) || '';
|
|
|
|
|
|
var flags = (data && data.flags) || {};
|
|
|
|
|
|
el.innerHTML = '<div><strong>Version:</strong> '+String(v)+'</div>'+
|
|
|
|
|
|
(st ? '<div><strong>Server time (UTC):</strong> '+String(st)+'</div>' : '')+
|
|
|
|
|
|
'<div><strong>Uptime:</strong> '+String(up)+'s</div>'+
|
2025-09-17 13:23:27 -07:00
|
|
|
|
'<div><strong>Flags:</strong> '
|
|
|
|
|
|
+ 'SHOW_LOGS='+ (flags.SHOW_LOGS? '1':'0')
|
|
|
|
|
|
+ ', SHOW_DIAGNOSTICS='+ (flags.SHOW_DIAGNOSTICS? '1':'0')
|
|
|
|
|
|
+ ', SHOW_SETUP='+ (flags.SHOW_SETUP? '1':'0')
|
2025-09-30 15:49:08 -07:00
|
|
|
|
+ ', SHOW_COMMANDERS='+ (flags.SHOW_COMMANDERS? '1':'0')
|
2025-09-17 13:23:27 -07:00
|
|
|
|
+ ', RANDOM_MODES='+ (flags.RANDOM_MODES? '1':'0')
|
|
|
|
|
|
+ ', RANDOM_UI='+ (flags.RANDOM_UI? '1':'0')
|
|
|
|
|
|
+ ', RANDOM_MAX_ATTEMPTS='+ String(flags.RANDOM_MAX_ATTEMPTS ?? '')
|
|
|
|
|
|
+ ', RANDOM_TIMEOUT_MS='+ String(flags.RANDOM_TIMEOUT_MS ?? '')
|
|
|
|
|
|
+ '</div>';
|
Web UI polish: thumbnail-hover preview, white thumbnail selection, Themes bullet list; global Scryfall image retry (thumbs+previews) with fallbacks and cache-bust; standardized data-card-name. Deck Summary alignment overhaul (count//name/owned grid, tabular numerals, inset highlight, tooltips, starts under header). Added diagnostics (health + logs pages, error pages, request-id propagation), global HTMX error toasts, and docs updates. Update DOCKER guide and add run-web scripts. Update CHANGELOG and release notes template.
2025-08-27 11:21:46 -07:00
|
|
|
|
} catch(_){ el.textContent = 'Unavailable'; }
|
|
|
|
|
|
}
|
|
|
|
|
|
function load(){
|
|
|
|
|
|
try { fetch('/status/sys', { cache: 'no-store' }).then(function(r){ return r.json(); }).then(render).catch(function(){ el.textContent='Unavailable'; }); } catch(_){ el.textContent='Unavailable'; }
|
|
|
|
|
|
}
|
|
|
|
|
|
load();
|
2025-10-03 10:43:24 -07:00
|
|
|
|
// Environment flags card
|
|
|
|
|
|
(function(){
|
|
|
|
|
|
var target = document.getElementById('envFlags');
|
|
|
|
|
|
if (!target) return;
|
|
|
|
|
|
function renderEnv(data){
|
|
|
|
|
|
if (!data || !data.flags) { target.textContent = 'Flags unavailable'; return; }
|
|
|
|
|
|
var f = data.flags;
|
|
|
|
|
|
function as01(v){ return (v ? '1' : '0'); }
|
|
|
|
|
|
var lines = [];
|
|
|
|
|
|
lines.push('<div><strong>Homepage & UI:</strong> '
|
|
|
|
|
|
+ 'SHOW_SETUP=' + as01(f.SHOW_SETUP)
|
|
|
|
|
|
+ ', SHOW_LOGS=' + as01(f.SHOW_LOGS)
|
|
|
|
|
|
+ ', SHOW_DIAGNOSTICS=' + as01(f.SHOW_DIAGNOSTICS)
|
|
|
|
|
|
+ ', SHOW_COMMANDERS=' + as01(f.SHOW_COMMANDERS)
|
|
|
|
|
|
+ ', ENABLE_THEMES=' + as01(f.ENABLE_THEMES)
|
|
|
|
|
|
+ ', ENABLE_CUSTOM_THEMES=' + as01(f.ENABLE_CUSTOM_THEMES)
|
|
|
|
|
|
+ ', ALLOW_MUST_HAVES=' + as01(f.ALLOW_MUST_HAVES)
|
|
|
|
|
|
+ ', THEME=' + String(f.DEFAULT_THEME || '')
|
|
|
|
|
|
+ ', THEME_MATCH_MODE=' + String(f.THEME_MATCH_MODE || '')
|
|
|
|
|
|
+ ', USER_THEME_LIMIT=' + String(f.USER_THEME_LIMIT || '')
|
|
|
|
|
|
+ '</div>');
|
|
|
|
|
|
lines.push('<div><strong>Random:</strong> '
|
|
|
|
|
|
+ 'RANDOM_MODES=' + as01(f.RANDOM_MODES)
|
|
|
|
|
|
+ ', RANDOM_UI=' + as01(f.RANDOM_UI)
|
|
|
|
|
|
+ ', RANDOM_MAX_ATTEMPTS=' + String(f.RANDOM_MAX_ATTEMPTS || '')
|
|
|
|
|
|
+ ', RANDOM_TIMEOUT_MS=' + String(f.RANDOM_TIMEOUT_MS || '')
|
|
|
|
|
|
+ ', RANDOM_REROLL_THROTTLE_MS=' + String(f.RANDOM_REROLL_THROTTLE_MS || '')
|
|
|
|
|
|
+ ', RANDOM_TELEMETRY=' + as01(f.RANDOM_TELEMETRY)
|
|
|
|
|
|
+ ', RANDOM_STRUCTURED_LOGS=' + as01(f.RANDOM_STRUCTURED_LOGS)
|
|
|
|
|
|
+ '</div>');
|
|
|
|
|
|
lines.push('<div><strong>Rate limiting (random):</strong> '
|
|
|
|
|
|
+ 'RATE_LIMIT_ENABLED=' + as01(f.RATE_LIMIT_ENABLED)
|
|
|
|
|
|
+ ', WINDOW_S=' + String(f.RATE_LIMIT_WINDOW_S || '')
|
|
|
|
|
|
+ ', RANDOM=' + String(f.RANDOM_RATE_LIMIT_RANDOM || '')
|
|
|
|
|
|
+ ', BUILD=' + String(f.RANDOM_RATE_LIMIT_BUILD || '')
|
|
|
|
|
|
+ ', SUGGEST=' + String(f.RANDOM_RATE_LIMIT_SUGGEST || '')
|
|
|
|
|
|
+ '</div>');
|
|
|
|
|
|
target.innerHTML = lines.join('');
|
|
|
|
|
|
}
|
|
|
|
|
|
try { fetch('/status/sys', { cache: 'no-store' }).then(function(r){ return r.json(); }).then(renderEnv).catch(function(){ target.textContent='Flags unavailable'; }); } catch(_){ target.textContent='Flags unavailable'; }
|
|
|
|
|
|
})();
|
|
|
|
|
|
var themeSuppEl = document.getElementById('themeSuppMetrics');
|
|
|
|
|
|
function renderThemeSupp(payload){
|
|
|
|
|
|
if (!themeSuppEl) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (!payload || payload.ok !== true) {
|
|
|
|
|
|
themeSuppEl.textContent = 'Theme metrics unavailable';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
var metrics = payload.metrics || {};
|
|
|
|
|
|
var total = metrics.total_builds != null ? Number(metrics.total_builds) : 0;
|
|
|
|
|
|
if (!total) {
|
|
|
|
|
|
themeSuppEl.textContent = 'No deck builds recorded yet.';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
var withUser = metrics.with_user_themes != null ? Number(metrics.with_user_themes) : 0;
|
|
|
|
|
|
var share = metrics.user_theme_share != null ? Number(metrics.user_theme_share) : 0;
|
|
|
|
|
|
var sharePct = !Number.isNaN(share) ? (share * 100).toFixed(1) + '%' : '0%';
|
|
|
|
|
|
var summary = metrics.last_summary || {};
|
|
|
|
|
|
var commander = Array.isArray(summary.commanderThemes) ? summary.commanderThemes : [];
|
|
|
|
|
|
var user = Array.isArray(summary.userThemes) ? summary.userThemes : [];
|
|
|
|
|
|
var merged = Array.isArray(summary.mergedThemes) ? summary.mergedThemes : [];
|
|
|
|
|
|
var unresolvedCount = summary.unresolvedCount != null ? Number(summary.unresolvedCount) : 0;
|
|
|
|
|
|
var unresolved = Array.isArray(summary.unresolved) ? summary.unresolved : [];
|
|
|
|
|
|
var mode = summary.mode || 'AND';
|
|
|
|
|
|
var weight = summary.weight != null ? Number(summary.weight) : 1;
|
|
|
|
|
|
var updated = metrics.last_updated || '';
|
|
|
|
|
|
var topUser = Array.isArray(metrics.top_user_themes) ? metrics.top_user_themes : [];
|
|
|
|
|
|
function joinList(arr){
|
|
|
|
|
|
if (!arr || !arr.length) return '—';
|
|
|
|
|
|
return arr.join(', ');
|
|
|
|
|
|
}
|
|
|
|
|
|
var html = '';
|
|
|
|
|
|
html += '<div><strong>Total builds:</strong> ' + String(total) + ' (user themes ' + String(withUser) + '\u00a0| ' + sharePct + ')</div>';
|
|
|
|
|
|
if (updated) {
|
|
|
|
|
|
html += '<div style="font-size:11px;">Last updated: ' + String(updated) + '</div>';
|
|
|
|
|
|
}
|
|
|
|
|
|
html += '<div><strong>Commander themes:</strong> ' + joinList(commander) + '</div>';
|
|
|
|
|
|
html += '<div><strong>User themes:</strong> ' + joinList(user) + '</div>';
|
|
|
|
|
|
html += '<div><strong>Merged:</strong> ' + joinList(merged) + '</div>';
|
|
|
|
|
|
var unresolvedLabel = '0';
|
|
|
|
|
|
if (unresolvedCount > 0) {
|
|
|
|
|
|
unresolvedLabel = String(unresolvedCount) + ' (' + joinList(unresolved) + ')';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
unresolvedLabel = '0';
|
|
|
|
|
|
}
|
|
|
|
|
|
html += '<div><strong>Unresolved:</strong> ' + unresolvedLabel + '</div>';
|
|
|
|
|
|
html += '<div style="font-size:11px;">Mode ' + String(mode) + ' · Weight ' + weight.toFixed(2) + '</div>';
|
|
|
|
|
|
if (topUser.length) {
|
|
|
|
|
|
var topLine = topUser.slice(0, 5).map(function(item){
|
|
|
|
|
|
if (!item) return '';
|
|
|
|
|
|
var t = item.theme != null ? String(item.theme) : '';
|
|
|
|
|
|
var c = item.count != null ? String(item.count) : '0';
|
|
|
|
|
|
return t + ' (' + c + ')';
|
|
|
|
|
|
}).filter(Boolean);
|
|
|
|
|
|
if (topLine.length) {
|
|
|
|
|
|
html += '<div style="font-size:11px; opacity:0.75;">Top user themes: ' + topLine.join(', ') + '</div>';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
themeSuppEl.innerHTML = html;
|
|
|
|
|
|
} catch (_){
|
|
|
|
|
|
themeSuppEl.textContent = 'Theme metrics unavailable';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
function loadThemeSupp(){
|
|
|
|
|
|
if (!themeSuppEl) return;
|
|
|
|
|
|
themeSuppEl.textContent = 'Loading theme metrics…';
|
|
|
|
|
|
fetch('/status/theme_metrics', { cache: 'no-store' })
|
|
|
|
|
|
.then(function(resp){
|
|
|
|
|
|
if (resp.status === 404) {
|
|
|
|
|
|
themeSuppEl.textContent = 'Diagnostics disabled (metrics unavailable)';
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
return resp.json();
|
|
|
|
|
|
})
|
|
|
|
|
|
.then(function(data){ if (data) renderThemeSupp(data); })
|
|
|
|
|
|
.catch(function(){ themeSuppEl.textContent = 'Theme metrics unavailable'; });
|
|
|
|
|
|
}
|
|
|
|
|
|
loadThemeSupp();
|
2025-09-26 18:15:52 -07:00
|
|
|
|
var tokenEl = document.getElementById('themeTokenStats');
|
|
|
|
|
|
function renderTokens(payload){
|
|
|
|
|
|
if (!tokenEl) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (!payload || payload.ok !== true) {
|
|
|
|
|
|
tokenEl.textContent = 'Theme stats unavailable';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
var stats = payload.stats || {};
|
|
|
|
|
|
var top = Array.isArray(stats.top_tokens) ? stats.top_tokens.slice(0, 5) : [];
|
|
|
|
|
|
var html = '';
|
|
|
|
|
|
var commanders = (stats && stats.commanders != null) ? stats.commanders : '0';
|
|
|
|
|
|
var withTags = (stats && stats.with_tags != null) ? stats.with_tags : '0';
|
|
|
|
|
|
var uniqueTokens = (stats && stats.unique_tokens != null) ? stats.unique_tokens : '0';
|
|
|
|
|
|
var assignments = (stats && stats.total_assignments != null) ? stats.total_assignments : '0';
|
|
|
|
|
|
var avgTokens = (stats && stats.avg_tokens_per_commander != null) ? stats.avg_tokens_per_commander : '0';
|
|
|
|
|
|
var medianTokens = (stats && stats.median_tokens_per_commander != null) ? stats.median_tokens_per_commander : '0';
|
|
|
|
|
|
html += '<div><strong>Commanders indexed:</strong> ' + String(commanders) + ' (' + String(withTags) + ' with tags)</div>';
|
|
|
|
|
|
html += '<div><strong>Theme tokens:</strong> ' + String(uniqueTokens) + ' unique; ' + String(assignments) + ' assignments</div>';
|
|
|
|
|
|
html += '<div><strong>Tokens per commander:</strong> avg ' + String(avgTokens) + ', median ' + String(medianTokens) + '</div>';
|
|
|
|
|
|
if (top.length) {
|
|
|
|
|
|
var parts = [];
|
|
|
|
|
|
top.forEach(function(item){
|
|
|
|
|
|
parts.push(String(item.token) + ' (' + String(item.count) + ')');
|
|
|
|
|
|
});
|
|
|
|
|
|
html += '<div><strong>Top tokens:</strong> ' + parts.join(', ') + '</div>';
|
|
|
|
|
|
}
|
|
|
|
|
|
var pool = stats.random_pool || {};
|
|
|
|
|
|
if (pool && typeof pool.size !== 'undefined'){
|
|
|
|
|
|
var coveragePct = null;
|
|
|
|
|
|
if (pool.coverage_ratio != null){
|
|
|
|
|
|
var cov = Number(pool.coverage_ratio);
|
|
|
|
|
|
if (!Number.isNaN(cov)){ coveragePct = (cov * 100).toFixed(1); }
|
|
|
|
|
|
}
|
|
|
|
|
|
html += '<div style="margin-top:0.35rem;"><strong>Curated random pool:</strong> ' + String(pool.size) + ' tokens';
|
|
|
|
|
|
if (coveragePct !== null){ html += ' (' + coveragePct + '% of catalog tokens)'; }
|
|
|
|
|
|
html += '</div>';
|
|
|
|
|
|
var rules = pool.rules || {};
|
|
|
|
|
|
var threshold = rules.overrepresented_share_threshold;
|
|
|
|
|
|
if (threshold != null){
|
|
|
|
|
|
var thrPct = Number(threshold);
|
|
|
|
|
|
if (!Number.isNaN(thrPct)){ html += '<div style="font-size:11px;">Over-represented threshold: ≥ ' + (thrPct * 100).toFixed(1) + '% of commanders</div>'; }
|
|
|
|
|
|
}
|
|
|
|
|
|
var excludedCounts = pool.excluded_counts || {};
|
|
|
|
|
|
var reasonKeys = Object.keys(excludedCounts);
|
|
|
|
|
|
if (reasonKeys.length){
|
|
|
|
|
|
var badges = reasonKeys.map(function(reason){
|
|
|
|
|
|
return reason + ' (' + excludedCounts[reason] + ')';
|
|
|
|
|
|
});
|
|
|
|
|
|
html += '<div style="font-size:11px;">Exclusions: ' + badges.join(', ') + '</div>';
|
|
|
|
|
|
}
|
|
|
|
|
|
var samples = pool.excluded_samples || {};
|
|
|
|
|
|
var sampleKeys = Object.keys(samples);
|
|
|
|
|
|
if (sampleKeys.length){
|
|
|
|
|
|
var sampleLines = [];
|
|
|
|
|
|
sampleKeys.slice(0, 3).forEach(function(reason){
|
|
|
|
|
|
var tokens = samples[reason] || [];
|
|
|
|
|
|
var sampleTokens = (tokens || []).slice(0, 3);
|
|
|
|
|
|
var remainder = Math.max((tokens || []).length - sampleTokens.length, 0);
|
|
|
|
|
|
var tokenLabel = sampleTokens.join(', ');
|
|
|
|
|
|
if (remainder > 0){ tokenLabel += ' +' + remainder; }
|
|
|
|
|
|
sampleLines.push(reason + ': ' + tokenLabel);
|
|
|
|
|
|
});
|
|
|
|
|
|
html += '<div style="font-size:11px; opacity:0.75;">Samples → ' + sampleLines.join(' | ') + '</div>';
|
|
|
|
|
|
}
|
|
|
|
|
|
var manualDetail = pool.manual_exclusion_detail || {};
|
|
|
|
|
|
var manualKeys = Object.keys(manualDetail);
|
|
|
|
|
|
if (manualKeys.length){
|
|
|
|
|
|
var manualSamples = manualKeys.slice(0, 3).map(function(token){
|
|
|
|
|
|
var info = manualDetail[token] || {};
|
|
|
|
|
|
var label = info.display || token;
|
|
|
|
|
|
var cat = info.category ? (' [' + info.category + ']') : '';
|
|
|
|
|
|
return label + cat;
|
|
|
|
|
|
});
|
|
|
|
|
|
var manualRemainder = Math.max(manualKeys.length - manualSamples.length, 0);
|
|
|
|
|
|
var manualLine = manualSamples.join(', ');
|
|
|
|
|
|
if (manualRemainder > 0){ manualLine += ' +' + manualRemainder; }
|
|
|
|
|
|
html += '<div style="font-size:11px;">Manual exclusions: ' + manualLine + '</div>';
|
|
|
|
|
|
}
|
|
|
|
|
|
var manualGroups = Array.isArray(rules.manual_exclusions) ? rules.manual_exclusions : [];
|
|
|
|
|
|
if (manualGroups.length){
|
|
|
|
|
|
var categoryList = manualGroups.map(function(group){ return group.category || 'manual'; });
|
|
|
|
|
|
html += '<div style="font-size:11px; opacity:0.75;">Manual categories: ' + categoryList.join(', ') + '</div>';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
var telemetry = stats.index_telemetry || {};
|
|
|
|
|
|
if (telemetry && typeof telemetry.token_count !== 'undefined'){
|
|
|
|
|
|
var hitRate = telemetry.hit_rate != null ? Number(telemetry.hit_rate) : null;
|
|
|
|
|
|
var hitPct = (hitRate !== null && !Number.isNaN(hitRate)) ? (hitRate * 100).toFixed(1) : null;
|
|
|
|
|
|
var teleLine = '<div style="font-size:11px; margin-top:0.25rem;">Tag index: ' + String(telemetry.token_count || 0) + ' tokens · lookups ' + String(telemetry.lookups || 0);
|
|
|
|
|
|
if (hitPct !== null){ teleLine += ' · hit rate ' + hitPct + '%'; }
|
|
|
|
|
|
if (telemetry.substring_checks){ teleLine += ' · substring checks ' + String(telemetry.substring_checks || 0); }
|
|
|
|
|
|
teleLine += '</div>';
|
|
|
|
|
|
html += teleLine;
|
|
|
|
|
|
}
|
|
|
|
|
|
tokenEl.innerHTML = html;
|
|
|
|
|
|
} catch(_){
|
|
|
|
|
|
tokenEl.textContent = 'Theme stats unavailable';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
function loadTokenStats(){
|
|
|
|
|
|
if (!tokenEl) return;
|
|
|
|
|
|
tokenEl.textContent = 'Loading theme stats…';
|
|
|
|
|
|
fetch('/status/random_theme_stats', { cache: 'no-store' })
|
|
|
|
|
|
.then(function(resp){
|
|
|
|
|
|
if (resp.status === 404) {
|
|
|
|
|
|
tokenEl.textContent = 'Diagnostics disabled (stats unavailable)';
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
return resp.json();
|
|
|
|
|
|
})
|
|
|
|
|
|
.then(function(data){ if (data) renderTokens(data); })
|
|
|
|
|
|
.catch(function(){ tokenEl.textContent = 'Theme stats unavailable'; });
|
|
|
|
|
|
}
|
|
|
|
|
|
loadTokenStats();
|
2025-10-02 15:31:05 -07:00
|
|
|
|
var dfcMetricsEl = document.getElementById('dfcMetrics');
|
|
|
|
|
|
function renderDfcMetrics(payload){
|
|
|
|
|
|
if (!dfcMetricsEl) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (!payload || payload.ok !== true) {
|
|
|
|
|
|
dfcMetricsEl.textContent = 'MDFC metrics unavailable';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
var metrics = payload.metrics || {};
|
|
|
|
|
|
var html = '';
|
|
|
|
|
|
html += '<div><strong>Deck summaries observed:</strong> ' + String(metrics.total_builds || 0) + '</div>';
|
|
|
|
|
|
var withDfc = Number(metrics.builds_with_mdfc || 0);
|
|
|
|
|
|
var share = metrics.build_share != null ? Number(metrics.build_share) : null;
|
|
|
|
|
|
if (!Number.isNaN(share) && share !== null) {
|
|
|
|
|
|
share = (share * 100).toFixed(1);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
share = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
html += '<div><strong>With MDFCs:</strong> ' + String(withDfc);
|
|
|
|
|
|
if (share !== null) {
|
|
|
|
|
|
html += ' (' + share + '%)';
|
|
|
|
|
|
}
|
|
|
|
|
|
html += '</div>';
|
|
|
|
|
|
var totalLands = Number(metrics.total_mdfc_lands || 0);
|
|
|
|
|
|
var avg = metrics.avg_mdfc_lands != null ? Number(metrics.avg_mdfc_lands) : null;
|
|
|
|
|
|
html += '<div><strong>Total MDFC lands:</strong> ' + String(totalLands);
|
|
|
|
|
|
if (avg !== null && !Number.isNaN(avg)) {
|
|
|
|
|
|
html += ' (avg ' + avg.toFixed(2) + ')';
|
|
|
|
|
|
}
|
|
|
|
|
|
html += '</div>';
|
|
|
|
|
|
var top = metrics.top_cards || {};
|
|
|
|
|
|
var topKeys = Object.keys(top);
|
|
|
|
|
|
if (topKeys.length) {
|
|
|
|
|
|
var items = topKeys.slice(0, 5).map(function(name){
|
|
|
|
|
|
return name + ' (' + String(top[name]) + ')';
|
|
|
|
|
|
});
|
|
|
|
|
|
html += '<div style="font-size:11px;">Top MDFC sources: ' + items.join(', ') + '</div>';
|
|
|
|
|
|
}
|
|
|
|
|
|
var last = metrics.last_summary || {};
|
|
|
|
|
|
if (typeof last.dfc_lands !== 'undefined') {
|
|
|
|
|
|
html += '<div style="font-size:11px; margin-top:0.25rem;">Last summary: ' + String(last.dfc_lands || 0) + ' MDFC lands · total with MDFCs ' + String(last.with_dfc || 0) + '</div>';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (metrics.last_updated) {
|
|
|
|
|
|
html += '<div style="font-size:11px;">Updated: ' + String(metrics.last_updated) + '</div>';
|
|
|
|
|
|
}
|
|
|
|
|
|
dfcMetricsEl.innerHTML = html;
|
|
|
|
|
|
} catch (_){
|
|
|
|
|
|
dfcMetricsEl.textContent = 'MDFC metrics unavailable';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
function loadDfcMetrics(){
|
|
|
|
|
|
if (!dfcMetricsEl) return;
|
|
|
|
|
|
dfcMetricsEl.textContent = 'Loading MDFC metrics…';
|
|
|
|
|
|
fetch('/status/dfc_metrics', { cache: 'no-store' })
|
|
|
|
|
|
.then(function(resp){
|
|
|
|
|
|
if (resp.status === 404) {
|
|
|
|
|
|
dfcMetricsEl.textContent = 'Diagnostics disabled (metrics unavailable)';
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
return resp.json();
|
|
|
|
|
|
})
|
|
|
|
|
|
.then(function(data){ if (data) renderDfcMetrics(data); })
|
|
|
|
|
|
.catch(function(){ dfcMetricsEl.textContent = 'MDFC metrics unavailable'; });
|
|
|
|
|
|
}
|
|
|
|
|
|
loadDfcMetrics();
|
2025-08-28 16:44:58 -07:00
|
|
|
|
// Theme status and reset
|
|
|
|
|
|
try{
|
|
|
|
|
|
var tEl = document.getElementById('themeSummary');
|
|
|
|
|
|
var resetBtn = document.getElementById('diag-theme-reset');
|
|
|
|
|
|
function renderTheme(){
|
|
|
|
|
|
if (!tEl) return;
|
|
|
|
|
|
var key = 'mtg:theme';
|
|
|
|
|
|
var stored = localStorage.getItem(key);
|
|
|
|
|
|
var html = '';
|
|
|
|
|
|
var resolved = document.documentElement.getAttribute('data-theme') || '';
|
|
|
|
|
|
html += '<div><strong>Resolved theme:</strong> ' + resolved + '</div>';
|
|
|
|
|
|
html += '<div><strong>Preference:</strong> ' + (stored ? stored : '(none)') + '</div>';
|
|
|
|
|
|
tEl.innerHTML = html;
|
|
|
|
|
|
}
|
|
|
|
|
|
renderTheme();
|
|
|
|
|
|
if (resetBtn){
|
|
|
|
|
|
resetBtn.addEventListener('click', function(){
|
|
|
|
|
|
try{ localStorage.removeItem('mtg:theme'); }catch(_){ }
|
|
|
|
|
|
// Re-apply from server default via base script by simulating system apply
|
|
|
|
|
|
try{
|
|
|
|
|
|
var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
|
|
|
|
var v = prefersDark ? 'dark' : 'light-blend';
|
|
|
|
|
|
document.documentElement.setAttribute('data-theme', v);
|
|
|
|
|
|
}catch(_){ }
|
|
|
|
|
|
renderTheme();
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}catch(_){ }
|
2025-09-01 16:55:24 -07:00
|
|
|
|
// Combos & synergies ad-hoc tester
|
|
|
|
|
|
try{
|
|
|
|
|
|
var runBtn = document.getElementById('diag-combos-run');
|
|
|
|
|
|
var ta = document.getElementById('diag-combos-input');
|
|
|
|
|
|
var out = document.getElementById('diag-combos-out');
|
|
|
|
|
|
function parseLines(){
|
|
|
|
|
|
var v = (ta && ta.value) || '';
|
|
|
|
|
|
return v.split(/\r?\n/).map(function(s){ return s.trim(); }).filter(Boolean);
|
|
|
|
|
|
}
|
|
|
|
|
|
async function run(){
|
|
|
|
|
|
if (!ta || !out) return;
|
|
|
|
|
|
out.textContent = 'Running…';
|
|
|
|
|
|
try{
|
|
|
|
|
|
var resp = await fetch('/diagnostics/combos', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ names: parseLines() })});
|
|
|
|
|
|
if (!resp.ok){ out.textContent = 'Error '+resp.status; return; }
|
|
|
|
|
|
var data = await resp.json();
|
|
|
|
|
|
var lines = [];
|
|
|
|
|
|
// Versions
|
|
|
|
|
|
try{
|
|
|
|
|
|
if (data.versions){
|
|
|
|
|
|
var vLine = 'List versions: ';
|
|
|
|
|
|
if (data.versions.combos) vLine += 'combos v'+ String(data.versions.combos);
|
|
|
|
|
|
if (data.versions.synergies) vLine += (data.versions.combos? ', ' : '') + 'synergies v'+ String(data.versions.synergies);
|
|
|
|
|
|
lines.push(vLine);
|
|
|
|
|
|
}
|
|
|
|
|
|
}catch(_){ }
|
|
|
|
|
|
lines.push('Combos: '+ data.counts.combos);
|
|
|
|
|
|
(data.combos||[]).forEach(function(c){
|
|
|
|
|
|
var badges = [];
|
|
|
|
|
|
if (c.cheap_early) badges.push('cheap/early');
|
|
|
|
|
|
if (c.setup_dependent) badges.push('setup-dependent');
|
|
|
|
|
|
var tagStr = (c.tags && c.tags.length? ' ['+c.tags.join(', ')+']' : '');
|
|
|
|
|
|
var badgeStr = badges.length ? ' {'+badges.join(', ')+'}' : '';
|
|
|
|
|
|
lines.push(' - '+c.a+' + '+c.b+ tagStr + badgeStr);
|
|
|
|
|
|
});
|
|
|
|
|
|
lines.push('Synergies: '+ data.counts.synergies);
|
|
|
|
|
|
(data.synergies||[]).forEach(function(s){ lines.push(' - '+s.a+' + '+s.b+(s.tags && s.tags.length? ' ['+s.tags.join(', ')+']':'')); });
|
|
|
|
|
|
out.textContent = lines.join('\n');
|
|
|
|
|
|
}catch(e){ out.textContent = 'Failed: '+ (e && e.message? e.message : 'Unknown error'); }
|
|
|
|
|
|
}
|
|
|
|
|
|
if (runBtn){ runBtn.addEventListener('click', run); }
|
|
|
|
|
|
}catch(_){ }
|
2025-08-28 16:44:58 -07:00
|
|
|
|
try{
|
|
|
|
|
|
var p = document.getElementById('pwaStatus');
|
|
|
|
|
|
if (p){
|
|
|
|
|
|
function renderPwa(){
|
|
|
|
|
|
try{
|
|
|
|
|
|
var st = window.__pwaStatus || {};
|
|
|
|
|
|
p.innerHTML = '<div><strong>Registered:</strong> '+ (st.registered? 'Yes':'No') +'</div>' + (st.scope? '<div><strong>Scope:</strong> '+ st.scope +'</div>' : '');
|
|
|
|
|
|
}catch(_){ p.textContent = 'Unavailable'; }
|
|
|
|
|
|
}
|
|
|
|
|
|
setTimeout(renderPwa, 500);
|
|
|
|
|
|
}
|
|
|
|
|
|
}catch(_){ }
|
2025-08-28 14:57:22 -07:00
|
|
|
|
// Perf probe: listen to scroll on a card grid if present
|
|
|
|
|
|
try{
|
|
|
|
|
|
var fpsEl = document.getElementById('perf-fps');
|
|
|
|
|
|
var visEl = document.getElementById('perf-visible');
|
|
|
|
|
|
var rcEl = document.getElementById('perf-renders');
|
|
|
|
|
|
var grid = document.querySelector('.card-grid');
|
|
|
|
|
|
var last = performance.now();
|
|
|
|
|
|
var frames = 0; var renders = 0;
|
|
|
|
|
|
function tick(){
|
|
|
|
|
|
frames++;
|
|
|
|
|
|
var now = performance.now();
|
|
|
|
|
|
if (now - last >= 500){
|
|
|
|
|
|
var fps = Math.round((frames * 1000) / (now - last));
|
|
|
|
|
|
if (fpsEl) fpsEl.textContent = String(fps);
|
|
|
|
|
|
frames = 0; last = now;
|
|
|
|
|
|
}
|
|
|
|
|
|
requestAnimationFrame(tick);
|
|
|
|
|
|
}
|
|
|
|
|
|
requestAnimationFrame(tick);
|
|
|
|
|
|
function updateVisible(){
|
|
|
|
|
|
try{
|
|
|
|
|
|
if (!grid) return;
|
|
|
|
|
|
var tiles = grid.querySelectorAll('.card-tile');
|
|
|
|
|
|
var c = 0; tiles.forEach(function(t){ if (t.style.display !== 'none') c++; });
|
|
|
|
|
|
if (visEl) visEl.textContent = String(c);
|
|
|
|
|
|
}catch(_){ }
|
|
|
|
|
|
}
|
|
|
|
|
|
if (grid){
|
|
|
|
|
|
grid.addEventListener('scroll', updateVisible);
|
|
|
|
|
|
var mo = new MutationObserver(function(){ renders++; if (rcEl) rcEl.textContent = String(renders); updateVisible(); });
|
|
|
|
|
|
mo.observe(grid, { childList: true, subtree: true, attributes: false });
|
|
|
|
|
|
updateVisible();
|
|
|
|
|
|
}
|
|
|
|
|
|
}catch(_){ }
|
Web UI polish: thumbnail-hover preview, white thumbnail selection, Themes bullet list; global Scryfall image retry (thumbs+previews) with fallbacks and cache-bust; standardized data-card-name. Deck Summary alignment overhaul (count//name/owned grid, tabular numerals, inset highlight, tooltips, starts under header). Added diagnostics (health + logs pages, error pages, request-id propagation), global HTMX error toasts, and docs updates. Update DOCKER guide and add run-web scripts. Update CHANGELOG and release notes template.
2025-08-27 11:21:46 -07:00
|
|
|
|
})();
|
|
|
|
|
|
</script>
|
|
|
|
|
|
{% endblock %}
|