mirror of
https://github.com/infinition/Bjorn.git
synced 2026-03-13 16:12:00 +00:00
Add RLUtils class for managing RL/AI dashboard endpoints
- Implemented methods for fetching AI stats, training history, and recent experiences. - Added functionality to set operation mode (MANUAL, AUTO, AI) with appropriate handling. - Included helper methods for querying the database and sending JSON responses. - Integrated model metadata extraction for visualization purposes.
This commit is contained in:
499
web/js/pages/database.js
Normal file
499
web/js/pages/database.js
Normal file
@@ -0,0 +1,499 @@
|
||||
/**
|
||||
* Database page module — Full SQLite browser.
|
||||
* Sidebar tree with tables/views, main content area with table data,
|
||||
* inline editing, search/sort/limit, CRUD, CSV/JSON export, danger zone ops.
|
||||
* All endpoints under /api/db/*.
|
||||
*/
|
||||
import { ResourceTracker } from '../core/resource-tracker.js';
|
||||
import { api, Poller } from '../core/api.js';
|
||||
import { el, $, empty, toast } from '../core/dom.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { initSharedSidebarLayout } from '../core/sidebar-layout.js';
|
||||
|
||||
const PAGE = 'database';
|
||||
|
||||
/* ── state ── */
|
||||
let tracker = null;
|
||||
let poller = null;
|
||||
let catalog = []; // [{ name, type:'table'|'view', columns:[] }]
|
||||
let activeTable = null; // name of the selected table/view
|
||||
let tableData = null; // { columns:[], rows:[], total:0 }
|
||||
let dirty = new Map(); // pk → { col: newVal, ... }
|
||||
let selected = new Set();
|
||||
let sortCol = null;
|
||||
let sortDir = 'asc';
|
||||
let searchText = '';
|
||||
let rowLimit = 100;
|
||||
let sidebarFilter = '';
|
||||
let liveRefresh = false;
|
||||
let disposeSidebarLayout = null;
|
||||
|
||||
/* ── lifecycle ── */
|
||||
export async function mount(container) {
|
||||
tracker = new ResourceTracker(PAGE);
|
||||
const shell = buildShell();
|
||||
container.appendChild(shell);
|
||||
disposeSidebarLayout = initSharedSidebarLayout(shell, {
|
||||
sidebarSelector: '.db-sidebar',
|
||||
mainSelector: '.db-main',
|
||||
storageKey: 'sidebar:database',
|
||||
mobileBreakpoint: 900,
|
||||
toggleLabel: t('common.menu'),
|
||||
mobileDefaultOpen: true,
|
||||
});
|
||||
await loadCatalog();
|
||||
}
|
||||
|
||||
export function unmount() {
|
||||
if (disposeSidebarLayout) { disposeSidebarLayout(); disposeSidebarLayout = null; }
|
||||
if (poller) { poller.stop(); poller = null; }
|
||||
if (tracker) { tracker.cleanupAll(); tracker = null; }
|
||||
catalog = []; activeTable = null; tableData = null;
|
||||
dirty = new Map(); selected = new Set();
|
||||
sortCol = null; sortDir = 'asc'; searchText = '';
|
||||
rowLimit = 100; sidebarFilter = ''; liveRefresh = false;
|
||||
}
|
||||
|
||||
/* ── shell ── */
|
||||
function buildShell() {
|
||||
const hideLabel = (() => {
|
||||
const v = t('common.hide');
|
||||
return v && v !== 'common.hide' ? v : 'Hide';
|
||||
})();
|
||||
return el('div', { class: 'db-container page-with-sidebar' }, [
|
||||
/* sidebar */
|
||||
el('aside', { class: 'db-sidebar page-sidebar', id: 'db-sidebar' }, [
|
||||
el('div', { class: 'sidehead' }, [
|
||||
el('div', { class: 'sidetitle' }, [t('nav.database')]),
|
||||
el('div', { class: 'spacer' }),
|
||||
el('button', { class: 'btn', id: 'hideSidebar', 'data-hide-sidebar': '1', type: 'button' }, [hideLabel]),
|
||||
]),
|
||||
el('div', { class: 'sidecontent' }, [
|
||||
el('div', { class: 'tree-head' }, [
|
||||
el('div', { class: 'pill' }, ['Tables']),
|
||||
el('div', { class: 'spacer' }),
|
||||
el('button', { class: 'btn', type: 'button', onclick: loadCatalog }, [t('common.refresh')]),
|
||||
]),
|
||||
el('input', {
|
||||
type: 'text', class: 'db-sidebar-filter', placeholder: t('db.filterTables'),
|
||||
oninput: onSidebarFilter
|
||||
}),
|
||||
el('div', { class: 'db-tree', id: 'db-tree' }),
|
||||
]),
|
||||
]),
|
||||
/* main */
|
||||
el('div', { class: 'db-main page-main', id: 'db-main' }, [
|
||||
el('div', { class: 'db-toolbar', id: 'db-toolbar', style: 'display:none' }, [
|
||||
/* search + sort + limit */
|
||||
el('input', {
|
||||
type: 'text', class: 'db-search', placeholder: t('db.searchRows'),
|
||||
oninput: onSearch
|
||||
}),
|
||||
el('select', { class: 'db-limit-select', onchange: onLimitChange }, [
|
||||
...[50, 100, 250, 500, 1000].map(n =>
|
||||
el('option', { value: String(n), ...(n === 100 ? { selected: '' } : {}) }, [String(n)])),
|
||||
]),
|
||||
el('label', { class: 'db-live-label' }, [
|
||||
el('input', { type: 'checkbox', id: 'db-live', onchange: onLiveToggle }),
|
||||
` ${t('db.autoRefresh')}`,
|
||||
]),
|
||||
]),
|
||||
el('div', { class: 'db-actions', id: 'db-actions', style: 'display:none' }, [
|
||||
el('button', { class: 'vuln-btn', id: 'db-btn-save', onclick: onSave }, [t('db.saveChanges')]),
|
||||
el('button', { class: 'vuln-btn', id: 'db-btn-discard', onclick: onDiscard }, [t('db.discardChanges')]),
|
||||
el('button', { class: 'vuln-btn', onclick: () => loadTable(activeTable) }, [t('common.refresh')]),
|
||||
el('button', { class: 'vuln-btn', onclick: onAddRow }, ['+Row']),
|
||||
el('button', { class: 'vuln-btn btn-danger', onclick: onDeleteSelected }, [t('db.deleteSelected')]),
|
||||
el('button', { class: 'vuln-btn', onclick: () => exportTable('csv') }, ['CSV']),
|
||||
el('button', { class: 'vuln-btn', onclick: () => exportTable('json') }, ['JSON']),
|
||||
]),
|
||||
/* table content */
|
||||
el('div', { class: 'db-table-wrap', id: 'db-table-wrap' }, [
|
||||
el('div', { style: 'text-align:center;color:var(--ink);opacity:.5;padding:60px 0' }, [
|
||||
el('div', { style: 'font-size:3rem;margin-bottom:12px;opacity:.5' }, ['\u{1F5C4}\uFE0F']),
|
||||
t('db.selectTableFromSidebar'),
|
||||
]),
|
||||
]),
|
||||
/* danger zone */
|
||||
el('div', { class: 'db-danger', id: 'db-danger', style: 'display:none' }, [
|
||||
el('span', { style: 'font-weight:700;color:var(--critical)' }, [t('db.dangerZone')]),
|
||||
el('button', { class: 'vuln-btn btn-danger btn-sm', onclick: onVacuum }, ['VACUUM']),
|
||||
el('button', { class: 'vuln-btn btn-danger btn-sm', onclick: onTruncate }, ['Truncate']),
|
||||
el('button', { class: 'vuln-btn btn-danger btn-sm', onclick: onDrop }, ['Drop']),
|
||||
]),
|
||||
/* status */
|
||||
el('div', { class: 'db-status', id: 'db-status' }),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/* ── catalog ── */
|
||||
async function loadCatalog() {
|
||||
try {
|
||||
const data = await api.get('/api/db/catalog', { timeout: 8000 });
|
||||
if (Array.isArray(data)) {
|
||||
catalog = data.map((item) => ({
|
||||
name: typeof item === 'string' ? item : (item?.name || item?.table || item?.id || ''),
|
||||
type: item?.type || 'table',
|
||||
})).filter((item) => item.name);
|
||||
} else {
|
||||
const tables = Array.isArray(data?.tables) ? data.tables : [];
|
||||
const views = Array.isArray(data?.views) ? data.views : [];
|
||||
catalog = [
|
||||
...tables.map((item) => ({
|
||||
name: typeof item === 'string' ? item : (item?.name || item?.table || item?.id || ''),
|
||||
type: item?.type || 'table',
|
||||
})),
|
||||
...views.map((item) => ({
|
||||
name: typeof item === 'string' ? item : (item?.name || item?.view || item?.id || ''),
|
||||
type: item?.type || 'view',
|
||||
})),
|
||||
].filter((item) => item.name);
|
||||
}
|
||||
renderTree();
|
||||
} catch (err) {
|
||||
console.warn(`[${PAGE}]`, err.message);
|
||||
setStatus(t('db.failedLoadCatalog'));
|
||||
}
|
||||
}
|
||||
|
||||
function renderTree() {
|
||||
const tree = $('#db-tree');
|
||||
if (!tree) return;
|
||||
empty(tree);
|
||||
|
||||
const needle = sidebarFilter.toLowerCase();
|
||||
const tables = catalog.filter((t) => (t.type || 'table') === 'table');
|
||||
const views = catalog.filter((t) => t.type === 'view');
|
||||
|
||||
const renderGroup = (label, items) => {
|
||||
const filtered = needle ? items.filter(i => i.name.toLowerCase().includes(needle)) : items;
|
||||
if (filtered.length === 0) return;
|
||||
tree.appendChild(el('div', { class: 'db-tree-group' }, [
|
||||
el('div', { class: 'db-tree-label' }, [`${label} (${filtered.length})`]),
|
||||
...filtered.map(item =>
|
||||
el('div', {
|
||||
class: `tree-item ${item.name === activeTable ? 'active' : ''}`,
|
||||
'data-name': item.name,
|
||||
onclick: () => selectTable(item.name),
|
||||
}, [
|
||||
el('span', { class: 'db-tree-icon' }, [item.type === 'view' ? '\u{1F50D}' : '\u{1F4CB}']),
|
||||
item.name,
|
||||
])
|
||||
),
|
||||
]));
|
||||
};
|
||||
|
||||
renderGroup('Tables', tables);
|
||||
renderGroup('Views', views);
|
||||
|
||||
if (catalog.length === 0) {
|
||||
tree.appendChild(el('div', { style: 'text-align:center;padding:20px;opacity:.5' }, [t('db.noTables')]));
|
||||
}
|
||||
}
|
||||
|
||||
function onSidebarFilter(e) {
|
||||
sidebarFilter = e.target.value;
|
||||
renderTree();
|
||||
}
|
||||
|
||||
/* ── select table ── */
|
||||
async function selectTable(name) {
|
||||
activeTable = name;
|
||||
sortCol = null; sortDir = 'asc';
|
||||
searchText = ''; dirty.clear(); selected.clear();
|
||||
renderTree();
|
||||
showToolbar(true);
|
||||
await loadTable(name);
|
||||
}
|
||||
|
||||
function showToolbar(show) {
|
||||
const toolbar = $('#db-toolbar');
|
||||
const actions = $('#db-actions');
|
||||
const danger = $('#db-danger');
|
||||
if (toolbar) toolbar.style.display = show ? '' : 'none';
|
||||
if (actions) actions.style.display = show ? '' : 'none';
|
||||
if (danger) danger.style.display = show ? '' : 'none';
|
||||
}
|
||||
|
||||
/* ── load table data ── */
|
||||
async function loadTable(name) {
|
||||
if (!name) return;
|
||||
setStatus(t('common.loading'));
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', String(rowLimit));
|
||||
if (sortCol) { params.set('sort', sortCol); params.set('dir', sortDir); }
|
||||
if (searchText) params.set('search', searchText);
|
||||
|
||||
const data = await api.get(`/api/db/table/${encodeURIComponent(name)}?${params}`, { timeout: 10000 });
|
||||
tableData = data;
|
||||
renderTable();
|
||||
setStatus(`${data.rows?.length || 0} of ${data.total ?? '?'} rows`);
|
||||
} catch (err) {
|
||||
console.warn(`[${PAGE}]`, err.message);
|
||||
setStatus(t('db.failedLoadTable'));
|
||||
const wrap = $('#db-table-wrap');
|
||||
if (wrap) { empty(wrap); wrap.appendChild(el('div', { style: 'padding:40px;text-align:center;opacity:.5' }, [t('db.errorLoadingData')])); }
|
||||
}
|
||||
}
|
||||
|
||||
/* ── render table ── */
|
||||
function renderTable() {
|
||||
const wrap = $('#db-table-wrap');
|
||||
if (!wrap || !tableData) return;
|
||||
empty(wrap);
|
||||
|
||||
const cols = tableData.columns || [];
|
||||
const rows = tableData.rows || [];
|
||||
|
||||
if (cols.length === 0) {
|
||||
wrap.appendChild(el('div', { style: 'padding:40px;text-align:center;opacity:.5' }, [t('db.emptyTable')]));
|
||||
return;
|
||||
}
|
||||
|
||||
const thead = el('thead', {}, [
|
||||
el('tr', {}, [
|
||||
el('th', { class: 'db-th-sel' }, [
|
||||
el('input', { type: 'checkbox', onchange: onSelectAll }),
|
||||
]),
|
||||
...cols.map((col) =>
|
||||
el('th', {
|
||||
class: sortCol === col ? 'sorted' : '',
|
||||
onclick: () => toggleSort(col),
|
||||
}, [col, sortCol === col ? (sortDir === 'asc' ? ' \u2191' : ' \u2193') : '']),
|
||||
),
|
||||
]),
|
||||
]);
|
||||
|
||||
const tbody = el('tbody');
|
||||
rows.forEach((row, idx) => {
|
||||
const pk = rowPK(row, idx);
|
||||
const isSelected = selected.has(pk);
|
||||
const isDirty = dirty.has(pk);
|
||||
const tr = el('tr', {
|
||||
class: `db-tr ${isSelected ? 'selected' : ''} ${isDirty ? 'dirty' : ''}`,
|
||||
'data-pk': pk,
|
||||
}, [
|
||||
el('td', { class: 'db-td db-td-sel' }, [
|
||||
el('input', {
|
||||
type: 'checkbox',
|
||||
...(isSelected ? { checked: '' } : {}),
|
||||
onchange: (e) => toggleRowSelection(pk, e.target.checked),
|
||||
}),
|
||||
]),
|
||||
...cols.map((col) => {
|
||||
const currentVal = dirty.get(pk)?.[col] ?? (row[col] ?? '').toString();
|
||||
const originalVal = (row[col] ?? '').toString();
|
||||
return el('td', { class: 'db-td', 'data-col': col }, [
|
||||
el('span', {
|
||||
class: 'db-cell',
|
||||
contentEditable: 'true',
|
||||
spellcheck: 'false',
|
||||
'data-pk': pk,
|
||||
'data-col': col,
|
||||
'data-orig': originalVal,
|
||||
onblur: onCellBlur,
|
||||
}, [currentVal]),
|
||||
]);
|
||||
}),
|
||||
]);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
wrap.appendChild(el('table', { class: 'db data-table' }, [thead, tbody]));
|
||||
updateDirtyUI();
|
||||
}
|
||||
|
||||
function rowPK(row, idx) {
|
||||
/* Try 'id' or 'rowid' as PK; fallback to index */
|
||||
if (row.id !== undefined) return String(row.id);
|
||||
if (row.rowid !== undefined) return String(row.rowid);
|
||||
return `_idx_${idx}`;
|
||||
}
|
||||
|
||||
/* ── sorting ── */
|
||||
function toggleSort(col) {
|
||||
if (sortCol === col) {
|
||||
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
sortCol = col;
|
||||
sortDir = 'asc';
|
||||
}
|
||||
loadTable(activeTable);
|
||||
}
|
||||
|
||||
/* ── search ── */
|
||||
function onSearch(e) {
|
||||
searchText = e.target.value;
|
||||
loadTable(activeTable);
|
||||
}
|
||||
|
||||
/* ── limit ── */
|
||||
function onLimitChange(e) {
|
||||
rowLimit = parseInt(e.target.value, 10) || 100;
|
||||
loadTable(activeTable);
|
||||
}
|
||||
|
||||
/* ── live refresh ── */
|
||||
function onLiveToggle(e) {
|
||||
liveRefresh = e.target.checked;
|
||||
if (liveRefresh) {
|
||||
poller = new Poller(() => loadTable(activeTable), 5000);
|
||||
poller.start();
|
||||
} else {
|
||||
if (poller) { poller.stop(); poller = null; }
|
||||
}
|
||||
}
|
||||
|
||||
/* ── selection ── */
|
||||
function onSelectAll(e) {
|
||||
const rows = tableData?.rows || [];
|
||||
if (e.target.checked) {
|
||||
rows.forEach((r, i) => selected.add(rowPK(r, i)));
|
||||
} else {
|
||||
selected.clear();
|
||||
}
|
||||
renderTable();
|
||||
}
|
||||
|
||||
function toggleRowSelection(pk, checked) {
|
||||
if (checked) selected.add(pk); else selected.delete(pk);
|
||||
const tr = document.querySelector(`tr.db-tr[data-pk="${pk}"]`);
|
||||
if (tr) tr.classList.toggle('selected', checked);
|
||||
}
|
||||
|
||||
/* ── inline editing ── */
|
||||
function onCellBlur(e) {
|
||||
const span = e.target;
|
||||
const pk = span.dataset.pk;
|
||||
const col = span.dataset.col;
|
||||
const orig = span.dataset.orig;
|
||||
const newVal = span.textContent;
|
||||
|
||||
if (newVal === orig) {
|
||||
/* revert — remove from dirty if no other changes */
|
||||
const changes = dirty.get(pk);
|
||||
if (changes) {
|
||||
delete changes[col];
|
||||
if (Object.keys(changes).length === 0) dirty.delete(pk);
|
||||
}
|
||||
} else {
|
||||
if (!dirty.has(pk)) dirty.set(pk, {});
|
||||
dirty.get(pk)[col] = newVal;
|
||||
}
|
||||
updateDirtyUI();
|
||||
}
|
||||
|
||||
function updateDirtyUI() {
|
||||
const saveBtn = $('#db-btn-save');
|
||||
const discardBtn = $('#db-btn-discard');
|
||||
const hasDirty = dirty.size > 0;
|
||||
if (saveBtn) saveBtn.classList.toggle('btn-primary', hasDirty);
|
||||
if (discardBtn) discardBtn.style.opacity = hasDirty ? '1' : '0.4';
|
||||
}
|
||||
|
||||
/* ── save ── */
|
||||
async function onSave() {
|
||||
if (dirty.size === 0) return;
|
||||
setStatus(t('common.saving'));
|
||||
try {
|
||||
const updates = [];
|
||||
dirty.forEach((changes, pk) => {
|
||||
updates.push({ pk, changes });
|
||||
});
|
||||
await api.post('/api/db/update', { table: activeTable, updates });
|
||||
dirty.clear();
|
||||
toast(t('db.changesSaved'), 2000, 'success');
|
||||
await loadTable(activeTable);
|
||||
} catch (err) {
|
||||
toast(`${t('db.saveFailed')}: ${err.message}`, 3000, 'error');
|
||||
setStatus(t('db.saveFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
function onDiscard() {
|
||||
dirty.clear();
|
||||
renderTable();
|
||||
toast(t('db.changesDiscarded'), 1500);
|
||||
}
|
||||
|
||||
/* ── add row ── */
|
||||
async function onAddRow() {
|
||||
setStatus(t('db.insertingRow'));
|
||||
try {
|
||||
await api.post('/api/db/insert', { table: activeTable });
|
||||
toast(t('db.rowInserted'), 2000, 'success');
|
||||
await loadTable(activeTable);
|
||||
} catch (err) {
|
||||
toast(`${t('db.insertFailed')}: ${err.message}`, 3000, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/* ── delete selected ── */
|
||||
async function onDeleteSelected() {
|
||||
if (selected.size === 0) { toast(t('db.noRowsSelected'), 1500); return; }
|
||||
setStatus(t('db.deletingRowsCount', { count: selected.size }));
|
||||
try {
|
||||
await api.post('/api/db/delete', { table: activeTable, pks: [...selected] });
|
||||
selected.clear();
|
||||
toast(t('db.rowsDeleted'), 2000, 'success');
|
||||
await loadTable(activeTable);
|
||||
} catch (err) {
|
||||
toast(`${t('common.deleteFailed')}: ${err.message}`, 3000, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/* ── export ── */
|
||||
function exportTable(format) {
|
||||
if (!activeTable) return;
|
||||
window.location.href = `/api/db/export/${encodeURIComponent(activeTable)}?format=${format}`;
|
||||
}
|
||||
|
||||
/* ── danger zone ── */
|
||||
async function onVacuum() {
|
||||
setStatus(t('db.runningVacuum'));
|
||||
try {
|
||||
await api.post('/api/db/vacuum', {});
|
||||
toast(t('db.vacuumComplete'), 2000, 'success');
|
||||
setStatus(t('db.vacuumDone'));
|
||||
} catch (err) {
|
||||
toast(`${t('db.vacuumFailed')}: ${err.message}`, 3000, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function onTruncate() {
|
||||
if (!activeTable) return;
|
||||
if (!confirm(t('db.confirmTruncate', { table: activeTable }))) return;
|
||||
setStatus(t('db.truncating'));
|
||||
try {
|
||||
await api.post(`/api/db/truncate/${encodeURIComponent(activeTable)}`, {});
|
||||
toast(t('db.tableTruncated'), 2000, 'success');
|
||||
await loadTable(activeTable);
|
||||
} catch (err) {
|
||||
toast(`${t('db.truncateFailed')}: ${err.message}`, 3000, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function onDrop() {
|
||||
if (!activeTable) return;
|
||||
if (!confirm(t('db.confirmDrop', { table: activeTable }))) return;
|
||||
setStatus(t('db.dropping'));
|
||||
try {
|
||||
await api.post(`/api/db/drop/${encodeURIComponent(activeTable)}`, {});
|
||||
toast(t('db.droppedTable', { table: activeTable }), 2000, 'success');
|
||||
activeTable = null;
|
||||
showToolbar(false);
|
||||
const wrap = $('#db-table-wrap');
|
||||
if (wrap) { empty(wrap); wrap.appendChild(el('div', { style: 'padding:40px;text-align:center;opacity:.5' }, [t('db.tableDropped')])); }
|
||||
await loadCatalog();
|
||||
} catch (err) {
|
||||
toast(`${t('db.dropFailed')}: ${err.message}`, 3000, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/* ── status bar ── */
|
||||
function setStatus(msg) {
|
||||
const el2 = $('#db-status');
|
||||
if (el2) el2.textContent = msg || '';
|
||||
}
|
||||
Reference in New Issue
Block a user