Management UI: corpus delete, process panel, activity feed, diagnostics

- DELETE /api/training/corpus/{id} + delete button on training page,
  with confirmation dialog and recompute hint
- /api/system/tasks + floating process panel (bottom-left) showing
  active background tasks with live 3s polling
- /api/system/recent-activity derives a feed from cases, style_corpus,
  and last style_patterns run; sidebar on home page renders with
  relative timestamps
- /api/system/diagnostics + /#/diagnostics page showing DB health,
  row counts per table, active tasks, stuck documents (>10 min),
  failed extractions
- Cosmetic: signature phrase headline now prefers clean phrases over
  bracket-heavy templates for display

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 12:04:13 +00:00
parent fcb2e1a325
commit 3e0221ccec
3 changed files with 689 additions and 23 deletions

View File

@@ -396,6 +396,71 @@ header nav a.active::after {
font-style: italic;
}
/* Home layout: main + activity sidebar */
.home-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
gap: var(--space-6);
}
.home-main { min-width: 0; }
.home-sidebar {
background: var(--color-parchment);
border: 1px solid var(--color-rule);
border-radius: var(--radius-lg);
padding: var(--space-5);
height: fit-content;
position: sticky;
top: var(--space-5);
}
.home-aside-title {
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--color-gold-deep);
font-weight: 700;
margin-bottom: var(--space-4);
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--color-gold);
}
/* Activity feed */
.activity-feed { display: flex; flex-direction: column; gap: var(--space-3); }
.activity-item {
padding: var(--space-3);
background: var(--color-surface);
border: 1px solid var(--color-rule-soft);
border-radius: var(--radius);
font-size: 0.82em;
transition: all var(--t);
cursor: pointer;
border-right: 3px solid var(--color-gold);
}
.activity-item:hover {
box-shadow: var(--shadow-sm);
border-right-color: var(--color-gold-deep);
}
.activity-label {
color: var(--color-navy);
font-weight: 600;
line-height: 1.4;
margin-bottom: 4px;
}
.activity-detail {
color: var(--color-ink-muted);
font-size: 0.9em;
line-height: 1.4;
margin-bottom: 4px;
}
.activity-time {
color: var(--color-ink-light);
font-size: 0.78em;
}
.activity-icon { font-size: 0.9em; margin-left: 4px; }
@media (max-width: 1000px) {
.home-grid { grid-template-columns: 1fr; }
.home-sidebar { position: static; }
}
@media (max-width: 800px) {
.kpi-row { grid-template-columns: repeat(2, 1fr); }
.home-hero { flex-direction: column; align-items: flex-start; gap: var(--space-4); }
@@ -556,6 +621,140 @@ header nav a.active::after {
border-color: var(--color-success);
}
/* Processing visibility panel (floating bottom-right) */
.process-panel {
position: fixed;
bottom: var(--space-5);
left: var(--space-5);
width: 320px;
max-height: 60vh;
background: var(--color-surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
border: 1px solid var(--color-gold);
overflow: hidden;
z-index: 900;
animation: fadeSlideUp 0.35s var(--ease-out);
}
@keyframes fadeSlideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.process-panel.collapsed .process-panel-body { display: none; }
.process-panel-header {
padding: 12px 16px;
background: var(--color-navy);
color: var(--color-parchment);
display: flex;
align-items: center;
gap: 10px;
border-bottom: 2px solid var(--color-gold);
}
.process-panel-title {
font-weight: 600;
font-size: 0.9em;
flex: 1;
}
.process-panel-count {
background: var(--color-gold);
color: var(--color-navy);
padding: 2px 10px;
border-radius: var(--radius-pill);
font-size: 0.78em;
font-weight: 700;
min-width: 24px;
text-align: center;
}
.process-panel .btn-icon {
color: var(--color-parchment);
font-size: 1.3em;
line-height: 1;
padding: 0 8px;
}
.process-panel .btn-icon:hover {
color: var(--color-gold-soft);
background: transparent;
}
.process-panel-body {
padding: 10px 14px;
max-height: calc(60vh - 50px);
overflow-y: auto;
}
.process-item {
padding: 10px 12px;
background: var(--color-cream);
border-radius: var(--radius);
margin-bottom: 6px;
font-size: 0.82em;
border-right: 3px solid var(--color-gold);
}
.process-item:last-child { margin-bottom: 0; }
.process-item-name {
font-weight: 500;
color: var(--color-navy);
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.process-item-status {
display: flex;
align-items: center;
gap: 6px;
color: var(--color-ink-muted);
font-size: 0.92em;
}
/* Diagnostics page */
.diag-pill {
display: inline-block;
padding: 3px 12px;
border-radius: var(--radius-pill);
font-size: 0.78em;
font-weight: 600;
margin-right: auto;
}
.diag-pill.ok { background: var(--color-success-bg); color: var(--color-success); }
.diag-pill.error { background: var(--color-danger-bg); color: var(--color-danger); }
.diag-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: var(--space-4);
}
.diag-stat {
padding: var(--space-4);
background: var(--color-cream);
border-radius: var(--radius);
border-right: 3px solid var(--color-gold);
}
.diag-stat-label {
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-ink-muted);
font-weight: 600;
margin-bottom: 4px;
}
.diag-stat-value {
font-size: var(--text-2xl);
font-weight: 800;
color: var(--color-navy);
line-height: 1;
}
.diag-row {
padding: 10px 14px;
background: var(--color-cream);
border-radius: var(--radius);
margin-bottom: 6px;
font-size: 0.86em;
border-right: 3px solid var(--color-rule);
}
.diag-row:last-child { margin-bottom: 0; }
.diag-row-error { border-right-color: var(--color-danger); background: var(--color-danger-bg); }
.diag-row-warn { border-right-color: var(--color-warn); background: var(--color-warn-bg); }
.diag-row-title { font-weight: 600; color: var(--color-navy); margin-bottom: 2px; }
.diag-row-meta { font-size: 0.88em; color: var(--color-ink-muted); }
/* ── Local files (research, drafts, proofread) ───────── */
.local-file-group { margin-bottom: 12px; }
.local-file-group-header { font-size: 0.82em; font-weight: 600; color: #666; margin-bottom: 6px; padding-bottom: 4px; border-bottom: 1px solid #eee; }
@@ -696,7 +895,15 @@ header nav a.active::after {
background: #f7f7f7; font-weight: 600; color: #555;
font-size: 0.78em; text-transform: uppercase;
}
.corpus-table tr:hover td { background: #fafafa; }
.corpus-table tr:hover td { background: var(--color-cream); }
.btn-icon {
background: transparent; border: none; cursor: pointer;
color: var(--color-ink-light); font-size: 1.05em;
padding: 6px 8px; border-radius: var(--radius);
transition: all var(--t);
}
.btn-icon:hover { color: var(--color-navy); background: var(--color-cream-deep); }
.btn-icon-danger:hover { color: var(--color-danger); background: var(--color-danger-bg); }
.cat-tag {
display: inline-block; padding: 2px 8px; margin: 0 2px;
background: #e3f2fd; color: #1565c0; border-radius: 10px;
@@ -1019,6 +1226,7 @@ header nav a.active::after {
<a href="#/training" id="navTraining">אימון סגנון</a>
<a href="#/style-report" id="navStyleReport">הסגנון שלי</a>
<a href="#/skills" id="navSkills">Skills</a>
<a href="#/diagnostics" id="navDiagnostics">מצב מערכת</a>
</nav>
</header>
@@ -1058,13 +1266,21 @@ header nav a.active::after {
</div>
</div>
<div class="divider-gold"></div>
<div class="page-header">
<h2>תיקים פעילים</h2>
</div>
<div class="case-grid" id="caseGrid">
<div class="empty">טוען תיקים...</div>
<div class="home-grid">
<div class="home-main">
<div class="page-header">
<h2>תיקים פעילים</h2>
</div>
<div class="case-grid" id="caseGrid">
<div class="empty">טוען תיקים...</div>
</div>
</div>
<aside class="home-sidebar">
<h3 class="home-aside-title">פעילות אחרונה</h3>
<div class="activity-feed" id="activityFeed">
<div class="empty" style="font-size:0.82em;padding:20px">טוען...</div>
</div>
</aside>
</div>
</div>
@@ -1434,6 +1650,17 @@ header nav a.active::after {
</div>
</div>
<!-- ══ Page: Diagnostics ══ -->
<div class="page" id="page-diagnostics">
<div class="page-header">
<h2>מצב מערכת</h2>
<button class="btn btn-ghost btn-sm" onclick="loadDiagnostics()">רענן</button>
</div>
<div id="diagnosticsContent">
<div class="empty">טוען...</div>
</div>
</div>
</div>
<!-- Modal for pattern examples -->
@@ -1456,6 +1683,16 @@ header nav a.active::after {
<div class="toast" id="toast"></div>
<!-- Processing visibility panel (floating) -->
<div class="process-panel" id="processPanel" style="display:none">
<div class="process-panel-header">
<span class="process-panel-title">עיבוד פעיל</span>
<span class="process-panel-count" id="processPanelCount">0</span>
<button class="btn-icon" onclick="toggleProcessPanel()" title="הסתר"></button>
</div>
<div class="process-panel-body" id="processPanelBody"></div>
</div>
<script>
const API = '/api';
let currentCaseNumber = '';
@@ -1518,13 +1755,22 @@ function handleRoute() {
document.getElementById('navStyleReport').classList.add('active');
subtitle = 'פורטרט הסגנון שלי';
loadStyleReport();
} else if (hash === '#/diagnostics') {
document.getElementById('page-diagnostics').classList.add('active');
document.getElementById('navDiagnostics').classList.add('active');
subtitle = 'מצב מערכת';
loadDiagnostics();
}
document.getElementById('pageSubtitle').textContent = subtitle;
}
window.addEventListener('hashchange', handleRoute);
window.addEventListener('load', () => { handleRoute(); loadStatus(); });
window.addEventListener('load', () => {
handleRoute();
loadStatus();
startProcessPanelPolling();
});
// ── Case List ────────────────────────────────────────────
async function loadCaseList() {
@@ -2268,31 +2514,215 @@ async function loadStatus() {
} catch (e) {}
}
// ── Diagnostics Page ─────────────────────────────────
async function loadDiagnostics() {
const container = document.getElementById('diagnosticsContent');
if (!container) return;
container.innerHTML = '<div class="empty">טוען...</div>';
try {
const res = await fetch(API + '/system/diagnostics');
const data = await res.json();
const dbBadge = data.db_ok
? '<span class="diag-pill ok">✓ מחובר</span>'
: '<span class="diag-pill error">✕ תקלה</span>';
const tablesHtml = Object.entries(data.tables).map(([name, count]) => {
const label = {
cases: 'תיקים',
documents: 'מסמכים',
document_chunks: 'קטעים',
style_corpus: 'קורפוס סגנון',
style_patterns: 'דפוסים',
}[name] || name;
const val = count === null ? '⚠️' : count.toLocaleString('he-IL');
return `<div class="diag-stat"><div class="diag-stat-label">${esc(label)}</div><div class="diag-stat-value">${val}</div></div>`;
}).join('');
const failedHtml = (data.failed_documents || []).map(d => `
<div class="diag-row diag-row-error">
<div class="diag-row-title">${esc(d.title || '(ללא שם)')}</div>
<div class="diag-row-meta">
${d.case_number ? `תיק ${esc(d.case_number)} · ` : ''}
סטטוס: <strong>${esc(d.status)}</strong>
</div>
</div>
`).join('') || '<div class="empty" style="padding:16px">אין כישלונות</div>';
const stuckHtml = (data.stuck_documents || []).map(d => `
<div class="diag-row diag-row-warn">
<div class="diag-row-title">${esc(d.title || '(ללא שם)')}</div>
<div class="diag-row-meta">
${d.case_number ? `תיק ${esc(d.case_number)} · ` : ''}
${esc(d.status)} מאז ${formatRelativeTime(d.created_at)}
</div>
</div>
`).join('') || '<div class="empty" style="padding:16px">אין מסמכים תקועים</div>';
const activeHtml = (data.active_tasks || []).map(t => `
<div class="diag-row">
<div class="diag-row-title">${esc(t.filename || t.task_id)}</div>
<div class="diag-row-meta">${esc(STEP_LABELS[t.step] || STEP_LABELS[t.status] || t.status)}</div>
</div>
`).join('') || '<div class="empty" style="padding:16px">אין משימות פעילות</div>';
container.innerHTML = `
<div class="card">
<div class="card-header">בריאות בסיס נתונים ${dbBadge}</div>
<div class="card-body">
<div class="diag-stats-grid">${tablesHtml}</div>
</div>
</div>
<div class="card">
<div class="card-header">משימות פעילות ברקע</div>
<div class="card-body">${activeHtml}</div>
</div>
<div class="card">
<div class="card-header">מסמכים תקועים (יותר מ-10 דקות)</div>
<div class="card-body">${stuckHtml}</div>
</div>
<div class="card">
<div class="card-header">מסמכים שכשלו</div>
<div class="card-body">${failedHtml}</div>
</div>
`;
} catch (e) {
container.innerHTML = `<div class="empty">שגיאה בטעינה: ${esc(e.message)}</div>`;
}
}
// ── Processing Visibility Panel ──────────────────────
let _processPollTimer = null;
function startProcessPanelPolling() {
if (_processPollTimer) return;
pollProcessPanel();
_processPollTimer = setInterval(pollProcessPanel, 3000);
}
async function pollProcessPanel() {
try {
const res = await fetch(API + '/system/tasks');
const data = await res.json();
renderProcessPanel(data.active || []);
} catch (e) {}
}
const STEP_LABELS = {
queued: 'בתור',
processing: 'מעבד',
proofreading: 'הגהה',
saving: 'שומר',
corpus: 'קורפוס',
chunking: 'פיצול',
embedding: 'embeddings',
validating: 'מאמת',
copying: 'מעתיק',
registering: 'רושם',
extracting: 'חילוץ טקסט',
};
function renderProcessPanel(items) {
const panel = document.getElementById('processPanel');
const body = document.getElementById('processPanelBody');
const count = document.getElementById('processPanelCount');
if (!panel) return;
if (!items.length) {
panel.style.display = 'none';
return;
}
panel.style.display = '';
count.textContent = items.length;
body.innerHTML = items.map(t => {
const label = STEP_LABELS[t.step] || STEP_LABELS[t.status] || t.status;
const name = t.filename || t.task_id;
return `
<div class="process-item">
<div class="process-item-name">${esc(name)}</div>
<div class="process-item-status">
<span class="mini-spinner"></span>
<span>${esc(label)}</span>
</div>
</div>
`;
}).join('');
}
function toggleProcessPanel() {
const panel = document.getElementById('processPanel');
panel.classList.toggle('collapsed');
}
async function loadKPIs() {
// Home dashboard KPI tiles
// Home dashboard KPI tiles + activity feed
const casesEl = document.getElementById('kpiCases');
const corpusEl = document.getElementById('kpiCorpus');
const patternsEl = document.getElementById('kpiPatterns');
const procEl = document.getElementById('kpiProcessing');
if (!casesEl) return;
try {
const [statusRes, corpusRes, patternsRes] = await Promise.all([
const [statusRes, corpusRes, patternsRes, activityRes] = await Promise.all([
fetch(API + '/processing-status').then(r => r.json()).catch(() => ({})),
fetch(API + '/training/corpus').then(r => r.json()).catch(() => []),
fetch(API + '/training/patterns').then(r => r.json()).catch(() => ({total: 0})),
fetch(API + '/system/recent-activity').then(r => r.json()).catch(() => ({events: []})),
]);
casesEl.textContent = statusRes.cases ?? '0';
corpusEl.textContent = corpusRes.length || '0';
patternsEl.textContent = patternsRes.total || '0';
// Count decisions currently being processed
document.getElementById('kpiCases').textContent = statusRes.cases ?? '0';
document.getElementById('kpiCorpus').textContent = corpusRes.length || '0';
document.getElementById('kpiPatterns').textContent = patternsRes.total || '0';
const procCount = (statusRes.processing_documents ?? statusRes.processing ?? 0);
procEl.textContent = procCount || '0';
document.getElementById('kpiProcessing').textContent = procCount || '0';
renderActivityFeed(activityRes.events || []);
} catch (e) {
console.error('KPI load failed', e);
}
}
const ACTIVITY_ICONS = {
case_created: '📁',
corpus_added: '📚',
analysis_run: '✨',
};
function formatRelativeTime(iso) {
if (!iso) return '';
const then = new Date(iso);
const diffMs = Date.now() - then.getTime();
const min = Math.floor(diffMs / 60000);
if (min < 1) return 'עכשיו';
if (min < 60) return `לפני ${min} דקות`;
const hr = Math.floor(min / 60);
if (hr < 24) return `לפני ${hr} שעות`;
const days = Math.floor(hr / 24);
if (days < 30) return `לפני ${days} ימים`;
return then.toLocaleDateString('he-IL');
}
function renderActivityFeed(events) {
const feed = document.getElementById('activityFeed');
if (!feed) return;
if (!events.length) {
feed.innerHTML = '<div class="empty" style="font-size:0.82em;padding:20px">עדיין אין פעילות</div>';
return;
}
feed.innerHTML = events.map(e => {
const icon = ACTIVITY_ICONS[e.type] || '•';
const target = e.target || '#/';
return `
<div class="activity-item" onclick="location.hash='${esc(target.replace(/^\/?#/, ''))}'">
<div class="activity-label"><span class="activity-icon">${icon}</span>${esc(e.label)}</div>
${e.detail ? `<div class="activity-detail">${esc(e.detail)}</div>` : ''}
<div class="activity-time">${formatRelativeTime(e.timestamp)}</div>
</div>
`;
}).join('');
}
// ── Helpers ──────────────────────────────────────────────
function esc(s) {
if (!s) return '';
@@ -3108,6 +3538,24 @@ function renderContribution(contrib) {
}).join('');
}
async function deleteCorpusItem(id, decisionNumber) {
if (!confirm(`להסיר את החלטה ${decisionNumber || '(ללא מספר)'} מהקורפוס?\n\nפעולה זו בלתי הפיכה — הדפוסים הקשורים להחלטה עצמה יישארו, אבל עקומת הלמידה תחושב מחדש בריצה הבאה של "נתח קורפוס".`)) {
return;
}
try {
const res = await fetch(API + '/training/corpus/' + encodeURIComponent(id), { method: 'DELETE' });
if (!res.ok) {
const err = await res.text();
throw new Error(err);
}
toast('ההחלטה הוסרה מהקורפוס', 'success');
loadCorpusList();
loadKPIs();
} catch (e) {
toast('שגיאה במחיקה: ' + e.message, 'error');
}
}
async function loadCorpusList() {
const container = document.getElementById('corpusList');
const count = document.getElementById('corpusCount');
@@ -3122,7 +3570,7 @@ async function loadCorpusList() {
container.innerHTML = `
<table class="corpus-table">
<thead>
<tr><th>מספר</th><th>תאריך</th><th>קטגוריות</th><th>תווים</th><th>נוצר</th></tr>
<tr><th>מספר</th><th>תאריך</th><th>קטגוריות</th><th>תווים</th><th>נוצר</th><th></th></tr>
</thead>
<tbody>
${rows.map(r => `
@@ -3132,6 +3580,7 @@ async function loadCorpusList() {
<td>${(r.subject_categories || []).map(c => `<span class="cat-tag">${esc(c)}</span>`).join('')}</td>
<td>${r.chars.toLocaleString('he-IL')}</td>
<td>${esc(r.created_at ? r.created_at.substring(0, 10) : '—')}</td>
<td><button class="btn-icon btn-icon-danger" title="הסר מהקורפוס" onclick="deleteCorpusItem('${esc(r.id)}', '${esc(r.decision_number || '')}')">🗑</button></td>
</tr>
`).join('')}
</tbody>