mirror of
https://github.com/infinition/Bjorn.git
synced 2026-03-10 06:31:59 +00:00
- 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.
500 lines
16 KiB
JavaScript
500 lines
16 KiB
JavaScript
/**
|
|
* 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 || '';
|
|
}
|