diff --git a/src/web_ui/index.html b/src/web_ui/index.html
index 08a9fda..2cfd72b 100644
--- a/src/web_ui/index.html
+++ b/src/web_ui/index.html
@@ -400,6 +400,52 @@
.sx-val{font-size:12px;color:var(--muted);white-space:nowrap;font-variant-numeric:tabular-nums}
@media(max-width:760px){.sx-2col{grid-template-columns:1fr;gap:14px}.sx-hero{grid-column:span 2}}
+ /* ── Maintenance: "diagnostics console" ───────────────────────────────────
+ Shares the sx-* section/eyebrow language but reads as a systems-health
+ readout: a pulsing verdict, per-subsystem status tiles, and instrument
+ panels. Status colour flows through --mx-c off a data-status attribute,
+ scoped to .mx-body so it can't leak onto other data-status nodes. */
+ .mx-body{position:relative}
+ .mx-body [data-status=ok]{--mx-c:#4ade80}
+ .mx-body [data-status=warn]{--mx-c:#facc15}
+ .mx-body [data-status=bad]{--mx-c:#f87171}
+ .mx-body [data-status=pending]{--mx-c:var(--muted)}
+ .mx-hero{display:flex;align-items:center;gap:15px;border:1px solid var(--hair);border-radius:var(--radius);
+ padding:15px 17px;margin:9px 0 13px;position:relative;overflow:hidden;
+ background:radial-gradient(150% 130% at 0% 0%,color-mix(in srgb,var(--mx-c,var(--accent)) 16%,transparent),transparent 60%),linear-gradient(158deg,var(--card),var(--panel))}
+ .mx-pulse{flex-shrink:0;width:14px;height:14px;border-radius:50%;background:var(--mx-c,#4ade80);
+ box-shadow:0 0 10px var(--mx-c,#4ade80);animation:mxPulse 2.4s ease-out infinite}
+ @keyframes mxPulse{0%{box-shadow:0 0 0 0 color-mix(in srgb,var(--mx-c,#4ade80) 55%,transparent),0 0 10px var(--mx-c,#4ade80)}70%{box-shadow:0 0 0 13px transparent,0 0 10px var(--mx-c,#4ade80)}100%{box-shadow:0 0 0 0 transparent,0 0 10px var(--mx-c,#4ade80)}}
+ .mx-verdict{font-family:var(--font-display);font-weight:400;font-size:2.1rem;line-height:1}
+ .mx-verdict-sub{font-size:11px;color:var(--muted);letter-spacing:.04em;text-transform:uppercase;margin-top:3px}
+
+ .mx-tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;margin-bottom:4px}
+ .mx-tile{position:relative;overflow:hidden;border:1px solid var(--hair);border-radius:var(--radius);padding:11px 13px;
+ background:linear-gradient(158deg,color-mix(in srgb,var(--card) 90%,transparent),color-mix(in srgb,var(--panel) 78%,transparent))}
+ .mx-tile::after{content:'';position:absolute;left:0;top:0;height:2px;width:100%;background:var(--mx-c,var(--muted))}
+ .mx-th{display:flex;align-items:center;gap:7px;font-size:10px;letter-spacing:.13em;text-transform:uppercase;color:var(--muted);font-weight:700}
+ .mx-dot{width:8px;height:8px;border-radius:50%;background:var(--mx-c,var(--muted));flex-shrink:0;box-shadow:0 0 7px var(--mx-c,transparent)}
+ .mx-tv{font-family:var(--font-display);font-weight:400;font-size:1.95rem;line-height:1.05;margin-top:5px}
+
+ .mx-group{border:1px solid var(--hair);border-radius:var(--radius-sm);padding:10px 12px;margin-bottom:9px;
+ background:color-mix(in srgb,var(--card) 28%,transparent)}
+ .mx-group-h{font-weight:700;font-size:12.5px;margin-bottom:7px;display:flex;align-items:center;gap:8px}
+ .mx-group-h .mx-id{color:var(--muted);font-weight:400;font-variant-numeric:tabular-nums}
+ .mx-item{display:flex;align-items:center;gap:9px;font-size:12px;padding:5px 0;
+ border-bottom:1px solid color-mix(in srgb,var(--hair) 55%,transparent)}
+ .mx-group .mx-item:last-child{border-bottom:none}
+ .mx-item input[type=checkbox]{accent-color:var(--accent);flex-shrink:0}
+ .mx-item .mx-label{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+ .mx-item .mx-sub{color:var(--muted)}
+ .mx-conf{width:9px;height:9px;border-radius:50%;flex-shrink:0;box-shadow:0 0 6px currentColor}
+ .mx-pill{flex-shrink:0;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;
+ padding:2px 9px;border-radius:999px;border:1px solid}
+ .mx-pill.keep{color:#4ade80;border-color:color-mix(in srgb,#4ade80 50%,transparent);background:color-mix(in srgb,#4ade80 13%,transparent)}
+ .mx-pill.remove{color:#f87171;border-color:color-mix(in srgb,#f87171 50%,transparent);background:color-mix(in srgb,#f87171 13%,transparent)}
+ .mx-prog{height:9px;border-radius:6px;overflow:hidden;border:1px solid var(--hair);background:color-mix(in srgb,var(--card) 55%,transparent)}
+ .mx-prog>i{display:block;height:100%;border-radius:6px;transition:width .35s ease;
+ background:linear-gradient(90deg,var(--accent),color-mix(in srgb,var(--accent) 38%,transparent))}
+
@media(prefers-reduced-motion:reduce){*{animation:none!important;transition-duration:.001ms!important}}
@@ -2583,9 +2629,9 @@ function sxBoardSwitch(metric,btn){
/* ── Maintenance (library health) ───────────────────────────────── */
async function openMaintenance(){
const bg=document.createElement('div');bg.className='modal-bg';bg.onclick=e=>{if(e.target===bg)bg.remove()};
- bg.innerHTML=`
+ bg.innerHTML=`
Library health ✕
-
Scanning…
+
Scanning…
`;
document.body.appendChild(bg);
try{
@@ -2594,48 +2640,90 @@ async function openMaintenance(){
}catch(e){document.getElementById('maint-body').innerHTML=`
Scan failed: ${esc(e.message)}
`}
}
+// Live health counts for the verdict + status tiles. sim/at start null
+// (async) and are filled by their render functions.
+let _mx={dup:0,miss:0,sim:null,at:null};
+function mxTile(key,count,status){
+ const el=document.getElementById('mx-tile-'+key);if(!el)return;
+ const v=el.querySelector('.mx-tv');if(v)v.textContent=(count==null?'—':count);
+ el.dataset.status=status||(count>0?'warn':'ok');
+}
+function mxVerdict(){
+ const box=document.getElementById('mx-verdict-box'),el=document.getElementById('mx-verdict');
+ if(!box||!el)return;
+ const issues=_mx.dup+_mx.miss+(_mx.sim||0);
+ if(issues>0){el.textContent=`${issues} item${issues===1?'':'s'} to review`;box.dataset.status='warn';}
+ else{el.textContent=_mx.sim===null?'Healthy':'All clear';box.dataset.status='ok';}
+}
+
function renderMaintenance(r){
const body=document.getElementById('maint-body');if(!body)return;
const dups=r.duplicates||[],miss=r.missing||[];
- let h='';
- h+=`
Duplicates (${dups.length}) `;
- if(!dups.length){h+='
No duplicate video IDs found.
'}
+ _mx={dup:dups.length,miss:miss.length,sim:null,at:null};
+ const tile=(key,label,count,status)=>`
+
${esc(label)}
+
${count==null?'—':count}
`;
+
+ let h=`
System diagnostics
+
+
+
Scanning…
+
duplicates · missing assets · perceptual twins · filing
+
+
+ ${tile('dup','Duplicate IDs',dups.length,dups.length?'warn':'ok')}
+ ${tile('miss','Missing assets',miss.length,miss.length?'warn':'ok')}
+ ${tile('sim','Similar content',null,'pending')}
+ ${tile('at','Unfiled groups',null,'pending')}
+
`;
+
+ // ── Duplicate IDs ──
+ h+=`
Duplicate IDs ${dups.length} group${dups.length===1?'':'s'} `;
+ if(!dups.length) h+=`
No duplicate video IDs — every clip is unique.
`;
else{
for(const g of dups){
- h+=`
-
${esc(g.title)} [${esc(g.id)}]
`;
- g.copies.forEach((c,i)=>{
- const tag=c.recommended_keep?'
keep ':'
remove ';
+ h+=`
`;
}
h+=`
🗑 Remove checked copies `;
}
- h+=`
Missing assets (${miss.length}) `;
- if(!miss.length){h+='
Every video has its thumbnail, metadata, and description.
'}
+ h+=`
`;
+
+ // ── Missing assets ──
+ h+=`
Missing assets ${miss.length} video${miss.length===1?'':'s'} `;
+ if(!miss.length) h+=`
Every video has its thumbnail, metadata, and description.
`;
else{
- if(miss.length>1)h+=`
⬇ Fetch all missing (${miss.length}) `;
+ if(miss.length>1)h+=`
⬇ Fetch all missing (${miss.length}) `;
for(const m of miss){
const need=[m.missing_thumbnail?'thumbnail':null,m.missing_info?'metadata':null,m.missing_description?'description':null].filter(Boolean).join(', ');
- h+=`
-
${esc(m.title)} — missing ${need}
+ h+=`
+ ${esc(m.title)} — missing ${esc(need)}
⬇ Fetch
`;
}
}
- h+=`
Similar content (perceptual)
-
Finds the same video re-uploaded under a different ID — mirrors, re-encodes, resolution changes — by comparing sampled frames. The first scan fingerprints your library (a few minutes); after that it's cached, so re-scans are quick.
+ h+=`
`;
+
+ // ── Similar content (perceptual) ──
+ h+=`
Similar content perceptual
+
Finds the same video re-uploaded under a different ID — mirrors, re-encodes, resolution changes — by comparing sampled frames. The first scan fingerprints your library (a few minutes); after that it's cached, so re-scans are quick.
`;
- h+=`
🏷 Auto-tag suggestions
-
Groups your unfiled channels by source platform and typical video length. Review a group and apply it to drop those channels into a folder — nothing moves until you click Apply.
+
+ // ── Auto-tag suggestions ──
+ h+=`
🏷 Auto-tag suggestions
+
Groups your unfiled channels by source platform and typical video length. Review a group and apply it to drop those channels into a folder — nothing moves until you click Apply.
Analyzing…
`;
+
body.innerHTML=h;
+ mxVerdict();
initDedupArea();
initAutotagArea();
}
@@ -2650,21 +2738,21 @@ async function initAutotagArea(){
}
function renderAutotag(groups){
const area=document.getElementById('autotag-area');if(!area)return;
- if(!groups||!groups.length){area.innerHTML='
No grouping suggestions — every channel is already filed or too ambiguous to call.
';return}
+ _mx.at=(groups&&groups.length)||0;mxTile('at',_mx.at,_mx.at?'warn':'ok');
+ if(!groups||!groups.length){area.innerHTML='
No grouping suggestions — every channel is already filed or too ambiguous to call.
';return}
// Confidence → dot colour: strong (green) ≥ .85, medium (amber) ≥ .7, else grey.
- const dot=c=>{const col=c>=0.85?'#4ade80':c>=0.7?'#facc15':'#9ca3af';return `
`};
+ const dot=c=>{const col=c>=0.85?'#4ade80':c>=0.7?'#facc15':'#9ca3af';return `
`};
let h='';
groups.forEach((g,gi)=>{
- h+=`
-
-
📁 ${esc(g.group)} (${g.channels.length})
+ h+=`
+
📁 ${esc(g.group)} (${g.channels.length})
Apply → ${esc(g.group)}
`;
g.channels.forEach((c,ci)=>{
- h+=`
+ h+=`
${dot(c.confidence)}
- ${esc(c.display_name)} · ${esc(c.platform_label)} · ${esc(c.reason)}
+ ${esc(c.display_name)} · ${esc(c.platform_label)} · ${esc(c.reason)}
`;
});
h+=` `;
@@ -2732,26 +2820,26 @@ function startDedupPoll(){
}
function renderDedupProgress(s){
const a=document.getElementById('dedup-area');if(!a)return;
+ mxTile('sim',null,'pending');
const pct=s.total?Math.round(100*s.done/s.total):0;
- a.innerHTML=`
Fingerprinting ${s.done} / ${s.total||'…'} new videos…
-
`;
+ a.innerHTML=`
Fingerprinting ${s.done} / ${s.total||'…'} new videos…
+
`;
}
function renderSimilarGroups(groups){
const a=document.getElementById('dedup-area');if(!a)return;
- let h=`
+ _mx.sim=groups.length;mxTile('sim',groups.length,groups.length?'warn':'ok');mxVerdict();
+ let h=`
${groups.length} similar group${groups.length===1?'':'s'}
⟳ Re-scan
`;
- if(!groups.length){h+='
No visually-similar duplicates found across different IDs. 🎉
';a.innerHTML=h;return}
+ if(!groups.length){h+='
No visually-similar duplicates across different IDs. 🎉
';a.innerHTML=h;return}
groups.forEach((g,gi)=>{
- h+=`
-
Group ${gi+1} — ${g.videos.length} copies
`;
+ h+=`
Group ${gi+1} — ${g.videos.length} copies
`;
g.videos.forEach(v=>{
- const tag=v.recommended_keep?'
keep ':'
remove ';
const size=v.file_size?fmtSize(v.file_size):'no video';
- h+=`
+ h+=`
- ${esc(v.title)} · ${esc(v.channel)} · ${size} · [${esc(v.video_id)}]
- ${tag}
+ ${esc(v.title)} · ${esc(v.channel)} · ${size} · [${esc(v.video_id)}]
+ ${v.recommended_keep?'keep':'remove'}
`;
});
h+=` `;