2e2b0b45fa
Rebuild the dashboard as a Drive-style document management system on top of the existing CaveauAI hybrid RAG pipeline. Backend: - 5 migrations (versions, trash soft-delete, saved searches, categories, audit) - DMS helpers (folder ACL walker, disk storage, audit, version snapshot, XLSX/PPTX/HTML/CSV/MD extractors) - New APIs: folders, document-versions, trash, bulk, preview, saved-searches, categories, diagnostics - Extended APIs: documents (folder_id, soft-delete, ACL filter, sort), upload (9 file types, version-collision detection with replace/new/keep-both, disk persistence), chat-stream (folder scoping + graph related-documents) - 30-day trash purge cron with Qdrant + disk + graph cleanup Frontend: - Drive-style two-pane browser with folder tree, drag-drop, bulk-action bar, right-click context menu, multi-select - New pages: folders (tree + per-folder ACL editor), trash (restore/purge) - Extended pages: upload (folder picker, version-collision modal, 9 file type chips), document (Preview/Versions/Permissions tabs with PDF.js + mammoth.js + audio), index (DMS KPIs + activity feed), settings (live diagnostics ping MariaDB/Qdrant/LiteLLM/FalkorDB/disk), chat (folder scope chips + related-authorities chips) - New CSS (dms.css) + JS bundle (dms.js) exposing window.DBN_DMS - Sidebar nav adds Folders + Trash items All routes return HTTP 200 in local smoke test; all 32 files lint clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
215 lines
12 KiB
PHP
215 lines
12 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
require_once __DIR__ . '/../includes/bootstrap.php';
|
|
$dashboardPage = 'index';
|
|
$dashboardTitle = dbnToolsT('dash_title_overview', dbnToolsCurrentLanguage());
|
|
$dashboardLead = dbnToolsT('dash_lead_overview', dbnToolsCurrentLanguage());
|
|
require_once __DIR__ . '/../includes/layout_dashboard.php';
|
|
?>
|
|
<section class="dash-kpis" id="dashKpis" aria-label="Corpus statistics">
|
|
<div class="dash-kpi">
|
|
<p class="dash-kpi__label" id="kpiLabelDocs"></p>
|
|
<p class="dash-kpi__value" data-kpi="documents">—</p>
|
|
<p class="dash-kpi__hint" id="kpiHintQuota"></p>
|
|
</div>
|
|
<div class="dash-kpi">
|
|
<p class="dash-kpi__label" id="kpiLabelChunks"></p>
|
|
<p class="dash-kpi__value" data-kpi="chunks">—</p>
|
|
<p class="dash-kpi__hint" id="kpiHintSearchable"></p>
|
|
</div>
|
|
<div class="dash-kpi">
|
|
<p class="dash-kpi__label" id="kpiLabelReady"></p>
|
|
<p class="dash-kpi__value" data-kpi="ready">—</p>
|
|
<p class="dash-kpi__hint" id="kpiHintTotal"></p>
|
|
</div>
|
|
<div class="dash-kpi">
|
|
<p class="dash-kpi__label" id="kpiLabelLast"></p>
|
|
<p class="dash-kpi__value" data-kpi="last_upload" style="font-size: 1.05rem;">—</p>
|
|
<p class="dash-kpi__hint" id="kpiHintDate"></p>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="dash-card">
|
|
<div class="dash-card__head">
|
|
<h2 id="getStartedTitle"></h2>
|
|
</div>
|
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem;">
|
|
<a class="dash-btn dash-btn--primary" href="/dashboard/upload.php" id="btnUpload"></a>
|
|
<a class="dash-btn" href="/dashboard/chat.php" id="btnAsk"></a>
|
|
<a class="dash-btn" href="/dashboard/documents.php" id="btnBrowse"></a>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="dms-kpis" id="dmsExtraKpis" aria-label="DMS overview">
|
|
<div class="dms-kpi"><p class="dms-kpi__label">Storage used</p><p class="dms-kpi__value" id="dmsStorage">—</p><p class="dms-kpi__hint">across all documents</p></div>
|
|
<div class="dms-kpi"><p class="dms-kpi__label">Folders</p><p class="dms-kpi__value" id="dmsFolders">—</p><p class="dms-kpi__hint">organising your library</p></div>
|
|
<div class="dms-kpi"><p class="dms-kpi__label">In trash</p><p class="dms-kpi__value" id="dmsTrash">—</p><p class="dms-kpi__hint">auto-purges after 30d</p></div>
|
|
<div class="dms-kpi"><p class="dms-kpi__label">Smart folders</p><p class="dms-kpi__value" id="dmsSmart">—</p><p class="dms-kpi__hint">saved views</p></div>
|
|
</section>
|
|
|
|
<section class="dash-card">
|
|
<div class="dash-card__head">
|
|
<h2 id="recentTitle"></h2>
|
|
<div class="dash-card__actions">
|
|
<a href="/dashboard/documents.php" class="dash-btn" id="btnSeeAll"></a>
|
|
</div>
|
|
</div>
|
|
<div id="dashRecent" class="dash-loading"></div>
|
|
</section>
|
|
|
|
<section class="dash-card">
|
|
<div class="dash-card__head"><h2>Recent activity</h2></div>
|
|
<div id="dmsActivity" class="dms-activity"><div class="dms-loading"></div></div>
|
|
</section>
|
|
|
|
<script>
|
|
(function () {
|
|
'use strict';
|
|
const I18N = window.DBN_I18N || {};
|
|
const api = window.DBN_DASHBOARD.apiBase;
|
|
const loc = I18N.locale || 'en-GB';
|
|
|
|
document.getElementById('kpiLabelDocs').textContent = I18N.kpi_docs || 'Documents';
|
|
document.getElementById('kpiHintQuota').textContent = I18N.kpi_of_quota || 'of quota';
|
|
document.getElementById('kpiLabelChunks').textContent = I18N.kpi_chunks || 'Passages indexed';
|
|
document.getElementById('kpiHintSearchable').textContent = I18N.kpi_searchable || 'searchable pieces';
|
|
document.getElementById('kpiLabelReady').textContent = I18N.kpi_ready || 'Ready';
|
|
document.getElementById('kpiHintTotal').textContent = I18N.kpi_of_total || 'of total';
|
|
document.getElementById('kpiLabelLast').textContent = I18N.kpi_last || 'Last upload';
|
|
document.getElementById('kpiHintDate').textContent = I18N.kpi_date_label || 'date';
|
|
document.getElementById('getStartedTitle').textContent = I18N.get_started || 'Get started';
|
|
document.getElementById('recentTitle').textContent = I18N.recent_activity || 'Recent activity';
|
|
document.getElementById('btnUpload').textContent = I18N.upload_docs_btn || '📥 Upload documents';
|
|
document.getElementById('btnAsk').textContent = I18N.ask_btn || '💬 Ask a legal question';
|
|
document.getElementById('btnBrowse').textContent = I18N.browse_btn || '📚 Browse corpus';
|
|
document.getElementById('btnSeeAll').textContent = I18N.see_all || 'See all →';
|
|
document.getElementById('dashRecent').textContent = I18N.loading || 'Loading…';
|
|
|
|
function fmtDate(s) {
|
|
if (!s) return '—';
|
|
try {
|
|
return new Date(s.replace(' ', 'T') + 'Z').toLocaleDateString(loc,
|
|
{ day: 'numeric', month: 'short', year: 'numeric' });
|
|
} catch (_) { return s; }
|
|
}
|
|
function fmtNum(n) { return n == null ? '—' : Number(n).toLocaleString(loc); }
|
|
|
|
function statusPill(status) {
|
|
const cls = {
|
|
ready: 'dash-status--ready', pending: 'dash-status--pending',
|
|
processing: 'dash-status--processing', error: 'dash-status--error',
|
|
}[status] || 'dash-status--pending';
|
|
const label = {
|
|
ready: I18N.status_ready || 'Ready', pending: I18N.status_pending || 'Pending',
|
|
processing: I18N.status_processing || 'Processing', error: I18N.status_error || 'Error',
|
|
}[status] || status;
|
|
return '<span class="dash-status ' + cls + '">' + label + '</span>';
|
|
}
|
|
|
|
fetch(api + '/documents.php?action=list&limit=100', { credentials: 'same-origin' })
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (!data.ok) throw new Error(data.error?.message || 'Failed');
|
|
const docs = data.documents || [];
|
|
const total = data.total;
|
|
const chunks = docs.reduce((s, d) => s + (d.chunk_count || 0), 0);
|
|
const ready = docs.filter(d => d.status === 'ready').length;
|
|
const last = docs[0] ? docs[0].created_at : null;
|
|
|
|
document.querySelector('[data-kpi="documents"]').textContent = fmtNum(total);
|
|
document.querySelector('[data-kpi="chunks"]').textContent = fmtNum(chunks);
|
|
document.querySelector('[data-kpi="ready"]').textContent = ready + ' / ' + docs.length;
|
|
document.querySelector('[data-kpi="last_upload"]').textContent = fmtDate(last);
|
|
|
|
const recent = document.getElementById('dashRecent');
|
|
if (!docs.length) {
|
|
recent.className = 'dash-empty';
|
|
recent.innerHTML = '<span class="dash-empty__icon">📭</span>'
|
|
+ (I18N.empty_docs || 'No documents yet.') + ' '
|
|
+ '<a href="/dashboard/upload.php">' + (I18N.empty_docs_link || 'Upload your first') + '</a>.';
|
|
return;
|
|
}
|
|
recent.className = '';
|
|
recent.innerHTML = '';
|
|
const table = document.createElement('table');
|
|
table.className = 'dash-doctable';
|
|
table.innerHTML = '<thead><tr>'
|
|
+ '<th>' + (I18N.th_title || 'Title') + '</th>'
|
|
+ '<th>' + (I18N.th_status || 'Status') + '</th>'
|
|
+ '<th>' + (I18N.th_chunks || 'Passages') + '</th>'
|
|
+ '<th>' + (I18N.th_added || 'Added') + '</th>'
|
|
+ '</tr></thead>';
|
|
const tbody = document.createElement('tbody');
|
|
docs.slice(0, 8).forEach(doc => {
|
|
const tr = document.createElement('tr');
|
|
tr.addEventListener('click', () => location.href = '/dashboard/document.php?id=' + doc.id);
|
|
const safe = (s) => String(s ?? '').replace(/[&<>"]/g, c => ({ '&':'&','<':'<','>':'>','"':'"' }[c]));
|
|
tr.innerHTML =
|
|
'<td><span class="dash-doctable__title">' + safe(doc.title) + '</span>'
|
|
+ (doc.source_tool ? '<div class="dash-doctable__meta">via ' + safe(doc.source_tool) + '</div>' : '') + '</td>'
|
|
+ '<td>' + statusPill(doc.status) + '</td>'
|
|
+ '<td>' + fmtNum(doc.chunk_count) + '</td>'
|
|
+ '<td>' + fmtDate(doc.created_at) + '</td>';
|
|
tbody.appendChild(tr);
|
|
});
|
|
table.appendChild(tbody);
|
|
recent.appendChild(table);
|
|
})
|
|
.catch(err => {
|
|
const recent = document.getElementById('dashRecent');
|
|
recent.className = 'dash-error';
|
|
recent.textContent = (I18N.error_loading || 'Could not load: ') + err.message;
|
|
});
|
|
|
|
// DMS overview tiles
|
|
(async function loadDmsKpis() {
|
|
try {
|
|
const tree = await fetch(api + '/folders.php?action=list_tree', { credentials: 'same-origin' }).then(r => r.json());
|
|
const allDocs = await fetch(api + '/documents.php?action=list&limit=500', { credentials: 'same-origin' }).then(r => r.json());
|
|
const storage = (allDocs.documents || []).reduce((s, d) => s + (d.file_size_bytes || 0), 0);
|
|
document.getElementById('dmsStorage').textContent =
|
|
storage < 1024*1024 ? Math.round(storage/1024) + ' KB'
|
|
: storage < 1024*1024*1024 ? (storage/1024/1024).toFixed(1) + ' MB'
|
|
: (storage/1024/1024/1024).toFixed(2) + ' GB';
|
|
const countFolders = (nodes) => (nodes || []).reduce((n, x) => n + 1 + countFolders(x.children), 0);
|
|
document.getElementById('dmsFolders').textContent = countFolders(tree.tree || []);
|
|
document.getElementById('dmsTrash').textContent = tree.trash_count || 0;
|
|
} catch (_) { /* ignored */ }
|
|
try {
|
|
const ss = await fetch(api + '/saved-searches.php?action=list', { credentials: 'same-origin' }).then(r => r.json());
|
|
document.getElementById('dmsSmart').textContent = (ss.items || []).length;
|
|
} catch (_) { /* ignored */ }
|
|
})();
|
|
|
|
// Activity feed — gracefully degrades if endpoint absent
|
|
(async function loadActivity() {
|
|
const wrap = document.getElementById('dmsActivity');
|
|
try {
|
|
// We don't have a dedicated activity endpoint; fall back to /documents?action=list sorted by updated.
|
|
const data = await fetch(api + '/documents.php?action=list&limit=15&sort=updated_at&dir=desc', { credentials: 'same-origin' }).then(r => r.json());
|
|
const items = data.documents || [];
|
|
if (!items.length) {
|
|
wrap.innerHTML = '<div class="dms-list__empty"><strong>No activity yet</strong><span>Upload a document to get started.</span></div>';
|
|
return;
|
|
}
|
|
const safe = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&':'&','<':'<','>':'>','"':'"' }[c]));
|
|
const rel = s => { if (!s) return '—'; const d = new Date(s.replace(' ', 'T') + 'Z'); const diff = (Date.now()-d)/1000;
|
|
if (diff<60) return 'just now'; if (diff<3600) return Math.floor(diff/60)+'m'; if (diff<86400) return Math.floor(diff/3600)+'h';
|
|
if (diff<86400*7) return Math.floor(diff/86400)+'d'; return d.toLocaleDateString(loc); };
|
|
const iconForStatus = { ready:'✓', pending:'⏳', processing:'⚙', error:'⚠' };
|
|
wrap.innerHTML = items.slice(0, 15).map(d =>
|
|
'<div class="dms-activity__row">' +
|
|
'<span class="dms-activity__icon">' + (iconForStatus[d.status] || '📄') + '</span>' +
|
|
'<div><a href="/dashboard/document.php?id=' + d.id + '">' + safe(d.title) + '</a>' +
|
|
(d.category ? ' <span class="dms-chip dms-chip--cat">' + safe(d.category) + '</span>' : '') + '</div>' +
|
|
'<span class="dms-activity__time">' + rel(d.updated_at || d.created_at) + '</span></div>'
|
|
).join('');
|
|
} catch (e) {
|
|
wrap.innerHTML = '<div class="dms-list__empty"><strong>Could not load</strong><span>' + e.message + '</span></div>';
|
|
}
|
|
})();
|
|
})();
|
|
</script>
|
|
|
|
<?php require_once __DIR__ . '/../includes/layout_dashboard_footer.php'; ?>
|