Files
Bjorn/web/js/pages/credentials.js
Fabien POLLY eb20b168a6 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.
2026-02-18 22:36:10 +01:00

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);
}