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.
445 lines
15 KiB
JavaScript
445 lines
15 KiB
JavaScript
/**
|
|
* Credentials page module.
|
|
* Displays credentials organized by service with tabs, search, and CSV export.
|
|
* Endpoint: GET /list_credentials (returns HTML tables)
|
|
*/
|
|
import { ResourceTracker } from '../core/resource-tracker.js';
|
|
import { api, Poller } from '../core/api.js';
|
|
import { el, $, $$, empty } from '../core/dom.js';
|
|
import { t } from '../core/i18n.js';
|
|
|
|
const PAGE = 'credentials';
|
|
const REFRESH_INTERVAL = 30000;
|
|
|
|
/* ── state ── */
|
|
let tracker = null;
|
|
let poller = null;
|
|
let serviceData = []; // [{ service, category, credentials: { headers, rows } }]
|
|
let currentCategory = 'all';
|
|
let searchGlobal = '';
|
|
let searchTerms = {};
|
|
let collapsedCards = new Set();
|
|
|
|
/* ── localStorage ── */
|
|
const LS_CARD = 'cred:card:collapsed:';
|
|
const getCardPref = (svc) => { try { return localStorage.getItem(LS_CARD + svc); } catch { return null; } };
|
|
const setCardPref = (svc, collapsed) => { try { localStorage.setItem(LS_CARD + svc, collapsed ? '1' : '0'); } catch { } };
|
|
|
|
/* ── lifecycle ── */
|
|
export async function mount(container) {
|
|
tracker = new ResourceTracker(PAGE);
|
|
container.appendChild(buildShell());
|
|
await fetchCredentials();
|
|
poller = new Poller(fetchCredentials, REFRESH_INTERVAL);
|
|
poller.start();
|
|
}
|
|
|
|
export function unmount() {
|
|
if (poller) { poller.stop(); poller = null; }
|
|
if (tracker) { tracker.cleanupAll(); tracker = null; }
|
|
serviceData = [];
|
|
currentCategory = 'all';
|
|
searchGlobal = '';
|
|
searchTerms = {};
|
|
collapsedCards.clear();
|
|
}
|
|
|
|
/* ── shell ── */
|
|
function buildShell() {
|
|
return el('div', { class: 'credentials-container' }, [
|
|
/* stats bar */
|
|
el('div', { class: 'stats-bar' }, [
|
|
statItem('🧩', 'stat-services', t('creds.services')),
|
|
statItem('🔐', 'stat-creds', t('creds.totalCredentials')),
|
|
statItem('🖥️', 'stat-hosts', t('creds.uniqueHosts')),
|
|
]),
|
|
/* global search */
|
|
el('div', { class: 'global-search-container' }, [
|
|
el('input', {
|
|
type: 'text', id: 'cred-global-search', class: 'global-search-input',
|
|
placeholder: t('common.search'), oninput: onGlobalSearch
|
|
}),
|
|
el('button', { class: 'clear-global-button', id: 'cred-clear-global', onclick: clearGlobalSearch }, ['✖']),
|
|
]),
|
|
/* tabs */
|
|
el('div', { class: 'tabs-container', id: 'cred-tabs' }),
|
|
/* services grid */
|
|
el('div', { class: 'services-grid', id: 'credentials-grid' }),
|
|
/* toast */
|
|
el('div', { class: 'copied-feedback', id: 'cred-toast' }, ['Copied to clipboard!']),
|
|
]);
|
|
}
|
|
|
|
function statItem(icon, id, label) {
|
|
return el('div', { class: 'stat-item' }, [
|
|
el('span', { class: 'stat-icon' }, [icon]),
|
|
el('span', { class: 'stat-value', id }, ['0']),
|
|
el('span', { class: 'stat-label' }, [label]),
|
|
]);
|
|
}
|
|
|
|
/* ── fetch ── */
|
|
async function fetchCredentials() {
|
|
try {
|
|
const text = await fetch('/list_credentials').then(r => r.text());
|
|
const doc = new DOMParser().parseFromString(text, 'text/html');
|
|
const tables = doc.querySelectorAll('table');
|
|
|
|
serviceData = [];
|
|
tables.forEach(table => {
|
|
const titleEl = table.previousElementSibling;
|
|
if (titleEl && titleEl.textContent) {
|
|
const raw = titleEl.textContent.toLowerCase().replace('.csv', '').trim();
|
|
const credentials = parseTable(table);
|
|
serviceData.push({ service: raw, category: raw, credentials });
|
|
}
|
|
});
|
|
|
|
// Sort by most credentials first
|
|
serviceData.sort((a, b) => (b.credentials.rows?.length || 0) - (a.credentials.rows?.length || 0));
|
|
|
|
updateStats();
|
|
renderTabs();
|
|
renderServices();
|
|
applyPersistedCollapse();
|
|
} catch (err) {
|
|
console.error(`[${PAGE}] fetch error:`, err);
|
|
}
|
|
}
|
|
|
|
function parseTable(table) {
|
|
const headers = Array.from(table.querySelectorAll('th')).map(th => th.textContent.trim());
|
|
const rows = Array.from(table.querySelectorAll('tr')).slice(1).map(row => {
|
|
const cells = Array.from(row.querySelectorAll('td'));
|
|
return Object.fromEntries(headers.map((h, i) => [h, (cells[i]?.textContent || '').trim()]));
|
|
});
|
|
return { headers, rows };
|
|
}
|
|
|
|
/* ── stats ── */
|
|
function updateStats() {
|
|
const setVal = (id, v) => { const e = $(`#${id}`); if (e) e.textContent = v; };
|
|
setVal('stat-services', serviceData.length);
|
|
setVal('stat-creds', serviceData.reduce((a, s) => a + (s.credentials.rows?.length || 0), 0));
|
|
|
|
// Count unique MACs
|
|
const macSet = new Set();
|
|
serviceData.forEach(s => {
|
|
(s.credentials.rows || []).forEach(r => {
|
|
for (const [k, v] of Object.entries(r)) {
|
|
if (k.toLowerCase().includes('mac')) {
|
|
const norm = normalizeMac(v);
|
|
if (norm) macSet.add(norm);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
setVal('stat-hosts', macSet.size);
|
|
}
|
|
|
|
function normalizeMac(v) {
|
|
if (!v) return null;
|
|
const raw = String(v).toLowerCase().replace(/[^0-9a-f]/g, '');
|
|
if (raw.length !== 12) return null;
|
|
return raw.match(/.{2}/g).join(':');
|
|
}
|
|
|
|
/* ── tabs ── */
|
|
function getCategories() {
|
|
return [...new Set(serviceData.map(s => s.category))];
|
|
}
|
|
|
|
function computeBadgeCounts() {
|
|
const map = { all: 0 };
|
|
getCategories().forEach(cat => map[cat] = 0);
|
|
const needle = searchGlobal.toLowerCase();
|
|
|
|
serviceData.forEach(svc => {
|
|
const rows = svc.credentials.rows || [];
|
|
let count;
|
|
if (!needle) {
|
|
count = rows.length;
|
|
} else {
|
|
count = rows.reduce((acc, row) => {
|
|
const text = Object.values(row).join(' ').toLowerCase();
|
|
return acc + (text.includes(needle) ? 1 : 0);
|
|
}, 0);
|
|
}
|
|
map.all += count;
|
|
map[svc.category] = (map[svc.category] || 0) + count;
|
|
});
|
|
return map;
|
|
}
|
|
|
|
function renderTabs() {
|
|
const tabs = $('#cred-tabs');
|
|
if (!tabs) return;
|
|
const counts = computeBadgeCounts();
|
|
const cats = ['all', ...getCategories()];
|
|
empty(tabs);
|
|
|
|
cats.forEach(cat => {
|
|
const label = cat === 'all' ? 'All' : cat.toUpperCase();
|
|
const count = counts[cat] || 0;
|
|
const active = cat === currentCategory ? 'active' : '';
|
|
const tab = el('div', { class: `tab ${active}`, 'data-cat': cat }, [
|
|
label,
|
|
el('span', { class: 'tab-badge' }, [String(count)]),
|
|
]);
|
|
tab.onclick = () => {
|
|
currentCategory = cat;
|
|
tabs.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
tab.classList.add('active');
|
|
renderServices();
|
|
applyPersistedCollapse();
|
|
};
|
|
tabs.appendChild(tab);
|
|
});
|
|
}
|
|
|
|
function updateBadges() {
|
|
const counts = computeBadgeCounts();
|
|
$$('#cred-tabs .tab').forEach(tab => {
|
|
const cat = tab.dataset.cat;
|
|
const badge = tab.querySelector('.tab-badge');
|
|
if (badge) badge.textContent = counts[cat] || 0;
|
|
});
|
|
}
|
|
|
|
/* ── services rendering ── */
|
|
function renderServices() {
|
|
const grid = $('#credentials-grid');
|
|
if (!grid) return;
|
|
empty(grid);
|
|
|
|
const needle = searchGlobal.toLowerCase();
|
|
|
|
// Filter by global search
|
|
let searched = serviceData.filter(svc => {
|
|
if (!needle) return true;
|
|
const titleMatch = svc.service.includes(needle);
|
|
const rowMatch = svc.credentials.rows.some(r =>
|
|
Object.values(r).join(' ').toLowerCase().includes(needle));
|
|
return titleMatch || rowMatch;
|
|
});
|
|
|
|
// Filter by category
|
|
if (currentCategory !== 'all') {
|
|
searched = searched.filter(s => s.category === currentCategory);
|
|
}
|
|
|
|
if (searched.length === 0) {
|
|
grid.appendChild(el('div', { style: 'text-align:center;color:var(--muted);padding:40px' }, [
|
|
el('div', { style: 'font-size:3rem;margin-bottom:16px;opacity:.5' }, ['🔍']),
|
|
'No credentials',
|
|
]));
|
|
updateBadges();
|
|
return;
|
|
}
|
|
|
|
searched.forEach(s => grid.appendChild(createServiceCard(s)));
|
|
|
|
// If global search active, auto-expand and filter rows
|
|
if (needle) {
|
|
$$('.service-card', grid).forEach(card => {
|
|
card.classList.remove('collapsed');
|
|
card.querySelectorAll('.credential-item').forEach(item => {
|
|
const text = item.textContent.toLowerCase();
|
|
item.style.display = text.includes(needle) ? '' : 'none';
|
|
});
|
|
});
|
|
}
|
|
|
|
updateBadges();
|
|
}
|
|
|
|
function createServiceCard(svc) {
|
|
const count = svc.credentials.rows.length;
|
|
const isCollapsed = collapsedCards.has(svc.service);
|
|
|
|
const card = el('div', {
|
|
class: `service-card ${isCollapsed ? 'collapsed' : ''}`,
|
|
'data-service': svc.service,
|
|
'data-credentials': String(count),
|
|
}, [
|
|
/* header */
|
|
el('div', { class: 'service-header', onclick: (e) => toggleCollapse(e, svc.service) }, [
|
|
el('span', { class: 'service-title' }, [svc.service.toUpperCase()]),
|
|
el('span', { class: 'service-count' }, [`Credentials: ${count}`]),
|
|
el('div', { class: 'search-container', onclick: e => e.stopPropagation() }, [
|
|
el('input', {
|
|
type: 'text', class: 'search-input', placeholder: 'Search...',
|
|
'data-service': svc.service, oninput: (e) => filterServiceCreds(e, svc.service)
|
|
}),
|
|
el('button', { class: 'clear-button', onclick: (e) => clearServiceSearch(e, svc.service) }, ['✖']),
|
|
]),
|
|
el('button', {
|
|
class: 'download-button', title: 'Download CSV',
|
|
onclick: (e) => downloadCSV(e, svc.service, svc.credentials)
|
|
}, ['💾']),
|
|
el('span', { class: 'collapse-indicator' }, ['▼']),
|
|
]),
|
|
/* content */
|
|
el('div', { class: 'service-content' }, [
|
|
...svc.credentials.rows.map(row => createCredentialItem(row)),
|
|
]),
|
|
]);
|
|
|
|
return card;
|
|
}
|
|
|
|
function createCredentialItem(row) {
|
|
return el('div', { class: 'credential-item' }, [
|
|
...Object.entries(row).map(([key, value]) => {
|
|
const val = String(value ?? '');
|
|
const bubbleClass = getBubbleClass(key);
|
|
return el('div', { class: 'credential-field' }, [
|
|
el('span', { class: 'field-label' }, [key]),
|
|
el('div', {
|
|
class: `field-value ${val.trim() ? bubbleClass : ''}`,
|
|
'data-value': val,
|
|
onclick: (e) => copyToClipboard(e.currentTarget),
|
|
title: 'Click to copy',
|
|
}, [val]),
|
|
]);
|
|
}),
|
|
]);
|
|
}
|
|
|
|
function getBubbleClass(key) {
|
|
const k = key.toLowerCase();
|
|
if (k === 'port') return 'bubble-orange';
|
|
if (['ip address', 'ip', 'hostname', 'mac address', 'mac'].includes(k)) return 'bubble-blue';
|
|
return 'bubble-green';
|
|
}
|
|
|
|
/* ── collapse ── */
|
|
function toggleCollapse(e, service) {
|
|
if (e.target.closest('.search-container') || e.target.closest('.download-button')) return;
|
|
const card = $(`.service-card[data-service="${service}"]`);
|
|
if (!card) return;
|
|
const nowCollapsed = !card.classList.contains('collapsed');
|
|
card.classList.toggle('collapsed');
|
|
if (nowCollapsed) collapsedCards.add(service);
|
|
else collapsedCards.delete(service);
|
|
setCardPref(service, nowCollapsed);
|
|
}
|
|
|
|
function applyPersistedCollapse() {
|
|
$$('.service-card').forEach(card => {
|
|
const svc = card.dataset.service;
|
|
const pref = getCardPref(svc);
|
|
if (pref === '1') {
|
|
card.classList.add('collapsed');
|
|
collapsedCards.add(svc);
|
|
} else if (pref === '0') {
|
|
card.classList.remove('collapsed');
|
|
collapsedCards.delete(svc);
|
|
} else {
|
|
// Default: collapsed
|
|
card.classList.add('collapsed');
|
|
}
|
|
});
|
|
}
|
|
|
|
/* ── search ── */
|
|
function onGlobalSearch(e) {
|
|
searchGlobal = e.target.value;
|
|
const clearBtn = $('#cred-clear-global');
|
|
if (clearBtn) clearBtn.classList.toggle('show', searchGlobal.length > 0);
|
|
renderServices();
|
|
applyPersistedCollapse();
|
|
}
|
|
|
|
function clearGlobalSearch() {
|
|
const inp = $('#cred-global-search');
|
|
if (inp) inp.value = '';
|
|
searchGlobal = '';
|
|
const clearBtn = $('#cred-clear-global');
|
|
if (clearBtn) clearBtn.classList.remove('show');
|
|
renderServices();
|
|
applyPersistedCollapse();
|
|
$$('.service-card').forEach(c => c.classList.add('collapsed'));
|
|
}
|
|
|
|
function filterServiceCreds(e, service) {
|
|
const filter = e.target.value.toLowerCase();
|
|
searchTerms[service] = filter;
|
|
const card = $(`.service-card[data-service="${service}"]`);
|
|
if (!card) return;
|
|
|
|
if (filter.length > 0) card.classList.remove('collapsed');
|
|
|
|
card.querySelectorAll('.credential-item').forEach(item => {
|
|
const text = item.textContent.toLowerCase();
|
|
item.style.display = text.includes(filter) ? '' : 'none';
|
|
});
|
|
|
|
// Toggle clear button
|
|
const clearBtn = e.target.nextElementSibling;
|
|
if (clearBtn) clearBtn.classList.toggle('show', filter.length > 0);
|
|
}
|
|
|
|
function clearServiceSearch(e, service) {
|
|
e.stopPropagation();
|
|
const card = $(`.service-card[data-service="${service}"]`);
|
|
if (!card) return;
|
|
const inp = card.querySelector('.search-input');
|
|
if (inp) inp.value = '';
|
|
searchTerms[service] = '';
|
|
card.querySelectorAll('.credential-item').forEach(item => item.style.display = '');
|
|
const clearBtn = card.querySelector('.clear-button');
|
|
if (clearBtn) clearBtn.classList.remove('show');
|
|
}
|
|
|
|
/* ── copy ── */
|
|
function copyToClipboard(el) {
|
|
const text = el.dataset.value || '';
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
showToast();
|
|
const bg = el.style.background;
|
|
el.style.background = '#4CAF50';
|
|
setTimeout(() => el.style.background = bg, 500);
|
|
}).catch(() => {
|
|
// Fallback
|
|
const ta = document.createElement('textarea');
|
|
ta.value = text;
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(ta);
|
|
showToast();
|
|
});
|
|
}
|
|
|
|
function showToast() {
|
|
const toast = $('#cred-toast');
|
|
if (!toast) return;
|
|
toast.classList.add('show');
|
|
setTimeout(() => toast.classList.remove('show'), 1500);
|
|
}
|
|
|
|
/* ── CSV export ── */
|
|
function downloadCSV(e, service, credentials) {
|
|
e.stopPropagation();
|
|
if (!credentials.rows || credentials.rows.length === 0) return;
|
|
const headers = Object.keys(credentials.rows[0]);
|
|
let csv = headers.join(',') + '\n';
|
|
credentials.rows.forEach(row => {
|
|
const values = headers.map(h => {
|
|
const v = String(row[h] ?? '');
|
|
return v.includes(',') ? `"${v.replace(/"/g, '""')}"` : v;
|
|
});
|
|
csv += values.join(',') + '\n';
|
|
});
|
|
const blob = new Blob([csv], { type: 'text/csv' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `${service}_credentials.csv`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
}
|