[a-dash] una dashboard para iiab-oa

This commit is contained in:
Luis Guzmán 2026-03-26 16:58:16 -06:00
parent ab609ae6eb
commit 2fa54d273b
14 changed files with 2531 additions and 0 deletions

14
android/dashboard/.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
# Dependencias
node_modules/
# Archivos de entorno y logs
.env
npm-debug.log*
# Archivos compilados (si en el futuro decides compilar el TS)
dist/
build/
# Archivos del sistema operativo (Mac/Linux)
.DS_Store
Thumbs.db

1273
android/dashboard/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,23 @@
{
"name": "dashboard-console",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@types/express": "^5.0.6",
"@types/node": "^25.5.0",
"ts-node": "^10.9.2",
"typescript": "^6.0.2"
},
"dependencies": {
"ejs": "^5.0.1",
"express": "^5.2.1",
"socket.io": "^4.8.3"
}
}

View File

@ -0,0 +1,121 @@
/* =========================================
BASE STYLES
========================================= */
body {
background-color: #121212;
color: #ffffff;
overflow-x: hidden;
}
.hidden-section {
display: none !important;
}
/* =========================================
RESPONSIVE NAVIGATION (SIDEBAR / BOTTOM NAV)
========================================= */
.nav-btn { cursor: pointer; border-radius: 8px; transition: background 0.2s; }
.nav-btn:hover, .nav-btn.active { background-color: rgba(255,255,255,0.1); }
.nav-icon { display: inline-block; margin-right: 10px; }
/* Desktop (PC) */
@media (min-width: 768px) {
.app-sidebar {
min-height: 100vh;
background-color: #1e1e1e;
border-right: 1px solid #333;
}
}
/* Mobile (Phone) */
@media (max-width: 767.98px) {
.app-sidebar {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
z-index: 1050;
background-color: #121212 !important;
border-top: 1px solid #333;
padding: 10px 0 !important;
}
.sidebar-title { display: none; }
.app-sidebar .nav {
flex-direction: row !important;
justify-content: space-evenly;
}
.nav-btn {
display: flex;
flex-direction: column;
align-items: center;
padding: 5px;
font-size: 0.75rem;
}
.nav-icon { margin-right: 0; margin-bottom: 2px; }
body { padding-bottom: 80px; } /* Prevents the menu from overlapping the content */
.col-md-9 { padding: 15px !important; } /* Less lateral padding on mobile */
}
/* =========================================
APP: HOME (DASHBOARD)
========================================= */
.disk-bar-container { background: #333; height: 25px; border-radius: 15px; overflow: hidden; display: flex; }
.disk-segment-used { background: #5dbca5; transition: width 1s ease-in-out; }
.disk-segment-free { background: rgba(255,255,255,0.1); flex-grow: 1; }
.stat-card { background: #1e1e1e; border: 1px solid #333; border-radius: 10px; padding: 20px; transition: border-color 0.2s; }
.stat-card:hover { border-color: #5dbca5; }
.icon-box { width: 50px; height: 50px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 24px; }
/* =========================================
APP: MAPS
========================================= */
.map-card { background-color: #1e1e1e; border: 1px solid #444; color: #ffffff; transition: 0.2s;}
.map-card:hover { border-color: #dc3545; }
.map-coords { color: #888; font-size: 0.85em; font-family: monospace; }
/* =========================================
APP: KIWIX
========================================= */
.collapse-btn-container { opacity: 0; transition: opacity 0.3s ease-in-out; text-align: center; margin-top: -10px; margin-bottom: 20px;}
#terminal-container:hover .collapse-btn-container { opacity: 1; }
.filter-active { background-color: #198754 !important; color: white !important; border-color: #198754 !important;}
.filter-inactive { background-color: transparent !important; color: #198754 !important; border-color: #198754 !important;}
/* =========================================
FLOATING ADJUSTMENTS (Z-INDEX AND POSITION)
========================================= */
.floating-filter {
bottom: 0;
}
/* On phones, move the button above the bottom menu */
@media (max-width: 767.98px) {
.floating-filter {
bottom: 80px !important;
}
/* =========================================
NEW: MOBILE SPACING (CARD EXPERIENCE)
========================================= */
/* 1. Add margins to the main container to separate it from the phone edges */
.app-panel {
padding-left: 15px !important;
padding-right: 15px !important;
}
/* 2. Separate the terminal from the search box with a small top margin */
#terminal, #kiwix-terminal {
margin-top: 15px !important;
}
/* 3. The key: Force a bottom margin on EACH card when stacked (col-12) */
.zim-card, .map-card {
margin-bottom: 15px !important;
}
/* 4. Increase the general bottom padding to ensure the last card and floating button have space before the tab menu. */
.app-panel.pb-5 {
padding-bottom: 120px !important;
}
}

View File

@ -0,0 +1,44 @@
import express from 'express';
import http from 'http';
import { Server, Socket } from 'socket.io';
import path from 'path';
// Import our modules (event controllers)
import { handleMapsEvents } from './sockets/maps.socket';
import { handleKiwixEvents } from './sockets/kiwix.socket';
import { handleHomeEvents } from './sockets/home.socket';
const app = express();
const server = http.createServer(app);
const io = new Server(server);
// EJS views and static files configuration
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
app.use(express.static(path.join(__dirname, 'public')));
// Main route
app.get('/', (req, res) => {
res.render('index');
});
// Main Socket Connection Handler
io.on('connection', (socket: Socket) => {
console.log(`A client has connected (ID: ${socket.id}).`);
// Connect the wires to the modules
handleMapsEvents(socket);
handleKiwixEvents(socket);
handleHomeEvents(socket);
socket.on('disconnect', () => {
console.log(`Client disconnected (ID: ${socket.id}).`);
});
});
const PORT = 4000;
server.listen(PORT, () => {
console.log(`=========================================`);
console.log(`🚀 BobOS Dashboard active on port ${PORT}`);
console.log(`=========================================`);
});

View File

@ -0,0 +1,126 @@
import { Socket } from 'socket.io';
import os from 'os';
import { exec } from 'child_process';
import util from 'util';
const execPromise = util.promisify(exec);
async function getSystemStats() {
let stats = {
hostname: os.hostname(),
ip: 'Unknown',
uptime: os.uptime(),
disk: { total: 0, used: 0, free: 0, percent: 0 },
ram: { total: 0, used: 0, percent: 0 },
swap: { total: 0, used: 0, percent: 0 }
};
// 1. GET IP (Plan A: Native | Plan B: raw ifconfig)
try {
const nets = os.networkInterfaces();
for (const name of Object.keys(nets)) {
if (nets[name]) {
for (const net of nets[name]) {
if (net.family === 'IPv4' && !net.internal) {
stats.ip = net.address; break;
}
}
}
if (stats.ip !== 'Unknown') break;
}
} catch (e) {}
// Architect's Plan B: Your ifconfig pipeline silencing the proot error
if (stats.ip === 'Unknown') {
try {
// The 2>/dev/null sends the "Warning: Permission denied" to the abyss
const { stdout } = await execPromise("ifconfig 2>/dev/null | grep inet | grep -v 127.0.0.1 | awk '{print $2}'");
const ips = stdout.trim().split('\n');
if (ips.length > 0 && ips[0]) {
stats.ip = ips[0]; // We take the first valid IP it spits out
}
} catch (e) {
console.log('[Home] Plan B (ifconfig) failed or yielded no results.');
}
}
// 2. GET DISK (df -k /)
try {
const { stdout } = await execPromise('df -k /');
const lines = stdout.trim().split('\n');
if (lines.length > 1) {
const parts = lines[1].trim().split(/\s+/);
const total = parseInt(parts[1]) * 1024;
const used = parseInt(parts[2]) * 1024;
stats.disk = {
total, used, free: total - used,
percent: Math.round((used / total) * 100)
};
}
} catch (e) {}
// 3. GET RAM AND SWAP (free -b)
try {
const { stdout } = await execPromise('free -b');
const lines = stdout.trim().split('\n');
// Parse RAM (Line 2) -> Mem: total used free ...
if (lines.length > 1) {
const ramParts = lines[1].trim().split(/\s+/);
const rTotal = parseInt(ramParts[1]);
const rUsed = parseInt(ramParts[2]);
stats.ram = { total: rTotal, used: rUsed, percent: Math.round((rUsed / rTotal) * 100) };
}
// Parse Swap (Line 3) -> Swap: total used free ...
if (lines.length > 2) {
const swapParts = lines[2].trim().split(/\s+/);
const sTotal = parseInt(swapParts[1]);
const sUsed = parseInt(swapParts[2]);
// Avoid division by zero if there is no Swap configured
stats.swap = { total: sTotal, used: sUsed, percent: sTotal > 0 ? Math.round((sUsed / sTotal) * 100) : 0 };
}
} catch (e) {
console.log('[Home] Error reading memory (free -b). Using limited native data.');
// Ultra-safe Node native fallback (Only gives RAM, no Swap)
stats.ram.total = os.totalmem();
const free = os.freemem();
stats.ram.used = stats.ram.total - free;
stats.ram.percent = Math.round((stats.ram.used / stats.ram.total) * 100);
}
return stats;
}
function formatBytes(bytes: number) {
if (bytes === 0 || isNaN(bytes)) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
export const handleHomeEvents = (socket: Socket) => {
socket.on('request_home_stats', async () => {
const stats = await getSystemStats();
const uiData = {
hostname: stats.hostname,
ip: stats.ip,
uptime: Math.floor(stats.uptime / 60) + ' min',
diskTotal: formatBytes(stats.disk.total),
diskUsed: formatBytes(stats.disk.used),
diskFree: formatBytes(stats.disk.free),
diskPercent: stats.disk.percent,
ramUsed: formatBytes(stats.ram.used),
ramTotal: formatBytes(stats.ram.total),
ramPercent: stats.ram.percent,
swapUsed: formatBytes(stats.swap.used),
swapTotal: formatBytes(stats.swap.total),
swapPercent: stats.swap.percent
};
socket.emit('home_stats_ready', uiData);
});
};

View File

@ -0,0 +1,168 @@
import { Socket } from 'socket.io';
import { spawn, ChildProcess } from 'child_process';
import fs from 'fs';
import path from 'path';
const ZIMS_DIR = '/library/zims/content/';
async function getKiwixCatalog() {
try {
console.log('[Kiwix] Querying server and cross-referencing with local disk...');
const response = await fetch('https://download.kiwix.org/zim/wikipedia/');
const html = await response.text();
let localFiles = new Set<string>();
if (fs.existsSync(ZIMS_DIR)) {
localFiles = new Set(fs.readdirSync(ZIMS_DIR));
}
const regex = /<a href="([^"]+\.zim)">.*?<\/a>\s+([\d-]+ \d{2}:\d{2})\s+([0-9.]+[KMG]?)/g;
const results = [];
let match;
while ((match = regex.exec(html)) !== null) {
const file = match[1];
const fullDate = match[2];
const size = match[3];
const cleanTitle = file.replace('.zim', '').split(/[-_]/).map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(' ');
const isDownloaded = localFiles.has(file);
results.push({
id: file,
title: cleanTitle,
date: fullDate.split(' ')[0],
size: size,
isDownloaded: isDownloaded
});
}
return results;
} catch (error) {
console.error('[Kiwix] Error querying the catalog:', error);
return [];
}
}
export const handleKiwixEvents = (socket: Socket) => {
let downloadProcess: ChildProcess | null = null;
let indexProcess: ChildProcess | null = null;
let currentDownloads: string[] = []; // <-- NEW: Memory of what we are currently downloading
socket.on('request_kiwix_catalog', async () => {
socket.emit('kiwix_status', { message: 'Synchronizing catalog with local disk...' });
const catalog = await getKiwixCatalog();
socket.emit('kiwix_catalog_ready', catalog);
});
socket.on('check_kiwix_tools', () => {
const hasAria2 = fs.existsSync('/usr/bin/aria2c');
const hasIndexer = fs.existsSync('/usr/bin/iiab-make-kiwix-lib');
socket.emit('kiwix_tools_status', { hasAria2, hasIndexer });
});
socket.on('start_kiwix_download', (zims: string[]) => {
if (downloadProcess || indexProcess) {
socket.emit('kiwix_terminal_output', '\n[System] A process is already running.\n');
return;
}
if (zims.length === 0) return;
currentDownloads = zims; // Save what we are downloading
const baseUrl = 'https://download.kiwix.org/zim/wikipedia/';
const urls = zims.map(zim => baseUrl + zim);
socket.emit('kiwix_terminal_output', `\n[System] Starting download...\n`);
socket.emit('kiwix_process_status', { isRunning: true });
const args = ['-d', ZIMS_DIR, '-c', '-Z', '-x', '4', '-s', '4', '-j', '5', '--async-dns=false', ...urls];
downloadProcess = spawn('/usr/bin/aria2c', args);
downloadProcess.stdout?.on('data', (data) => socket.emit('kiwix_terminal_output', data.toString()));
downloadProcess.stderr?.on('data', (data) => socket.emit('kiwix_terminal_output', data.toString()));
// Handle natural process exit
downloadProcess.on('exit', (code, signal) => {
downloadProcess = null;
// If killed manually (Cancellation), ignore this block
if (signal === 'SIGKILL') return;
if (code === 0) {
currentDownloads = []; // Clear memory
socket.emit('kiwix_terminal_output', `\n[System] 🟢 Downloads finished. Starting indexing...\n`);
if (fs.existsSync('/usr/bin/iiab-make-kiwix-lib')) {
indexProcess = spawn('/usr/bin/iiab-make-kiwix-lib');
indexProcess.stdout?.on('data', (data) => socket.emit('kiwix_terminal_output', data.toString()));
indexProcess.on('exit', (idxCode) => {
socket.emit('kiwix_terminal_output', `\n[System] 🏁 Indexing complete.\n`);
indexProcess = null;
socket.emit('kiwix_process_status', { isRunning: false });
socket.emit('refresh_kiwix_catalog');
});
} else {
socket.emit('kiwix_process_status', { isRunning: false });
socket.emit('refresh_kiwix_catalog');
}
} else {
currentDownloads = [];
socket.emit('kiwix_process_status', { isRunning: false });
}
});
});
// NEW: PANIC BUTTON (Cancel Download)
socket.on('cancel_kiwix_download', () => {
if (downloadProcess) {
socket.emit('kiwix_terminal_output', '\n[System] 🛑 ABORTING: Killing Aria2c process...\n');
// 1. Kill the process (Ctrl+C equivalent)
downloadProcess.kill('SIGKILL');
downloadProcess = null;
// 2. Sweep the trash (Incomplete files)
socket.emit('kiwix_terminal_output', '[System] 🧹 Cleaning temporary and incomplete files...\n');
currentDownloads.forEach(zim => {
const filePath = path.join(ZIMS_DIR, zim);
const ariaPath = filePath + '.aria2';
if (fs.existsSync(filePath)) fs.unlinkSync(filePath); // Delete the .zim
if (fs.existsSync(ariaPath)) fs.unlinkSync(ariaPath); // Delete the .aria2 map
});
currentDownloads = []; // Clear memory
socket.emit('kiwix_terminal_output', '[System] ✔️ Cancellation complete. System ready.\n');
socket.emit('kiwix_process_status', { isRunning: false });
socket.emit('refresh_kiwix_catalog');
}
});
// Delete ZIMs from UI
socket.on('delete_zim', (zimId: string) => {
const filePath = path.join(ZIMS_DIR, zimId);
if (fs.existsSync(filePath)) {
socket.emit('kiwix_terminal_output', `\n[System] 🗑️ Deleting file ${zimId}...\n`);
fs.unlinkSync(filePath);
if (fs.existsSync('/usr/bin/iiab-make-kiwix-lib')) {
const idx = spawn('/usr/bin/iiab-make-kiwix-lib');
idx.stdout?.on('data', (data) => socket.emit('kiwix_terminal_output', data.toString()));
idx.stderr?.on('data', (data) => socket.emit('kiwix_terminal_output', data.toString()));
idx.on('exit', () => {
socket.emit('kiwix_terminal_output', `\n[System] 🏁 Index updated after deletion. Reloading interface...\n`);
socket.emit('refresh_kiwix_catalog');
});
} else {
socket.emit('kiwix_terminal_output', `\n[System] ✅ File deleted (no indexer). Reloading interface...\n`);
socket.emit('refresh_kiwix_catalog');
}
}
});
socket.on('disconnect', () => {
if (downloadProcess) downloadProcess.kill('SIGKILL');
if (indexProcess) indexProcess.kill('SIGKILL');
});
};

View File

@ -0,0 +1,164 @@
import { Socket } from 'socket.io';
import { spawn, ChildProcess } from 'child_process';
import fs from 'fs';
import path from 'path';
// Critical paths in the backend
const SCRIPTS_DIR = '/opt/iiab/maps/tile-extract/';
const EXTRACT_SCRIPT = path.join(SCRIPTS_DIR, 'tile-extract.py');
const CATALOG_JSON = '/library/www/osm/maps/extracts.json';
// --- FUNCTION: Read existing regions from JSON ---
function getMapsCatalog() {
try {
if (!fs.existsSync(CATALOG_JSON)) return [];
console.log('[Maps] Reading extracts.json catalog...');
const fileContent = fs.readFileSync(CATALOG_JSON, 'utf8');
const data = JSON.parse(fileContent);
// Transform JSON format {"regions": {"name": [bbox...]}}
// to an array manageable by the web: [{name, bbox}]
const regions = [];
if (data && data.regions) {
for (const name in data.regions) {
// Format coordinates to 4 decimals so they look clean
const bbox = data.regions[name].map((coord: number) => coord.toFixed(4));
regions.push({
name: name,
bbox: bbox.join(', ') // "min_lon, min_lat, max_lon, max_lat"
});
}
}
return regions;
} catch (error) {
console.error('[Maps] Error reading maps catalog:', error);
return [];
}
}
// --- FUNCTION: The Security Regex Shield (Stricter) ---
// The region name MUST be lowercase letters, numbers, or underscore ONLY.
const regionNameRegex = /^[a-z0-9_]+$/;
function validateSecureCommand(rawCommand: string): { type: string, region: string, bbox?: string, error?: string } {
const tokens = rawCommand.trim().split(/\s+/);
// 1. Must start with sudo and point to the correct script
if (tokens[0] !== 'sudo' || tokens[1] !== EXTRACT_SCRIPT) {
return { type: '', region: '', error: 'The command does not start with sudo /opt/iiab/maps/tile-extract/tile-extract.py' };
}
const action = tokens[2]; // 'extract' or 'delete'
const regionName = tokens[3];
// 2. Validate the format of the region name
if (!regionName || !regionNameRegex.test(regionName)) {
return { type: '', region: '', error: 'SECURITY ERROR: The region name MUST contain ONLY lowercase letters, numbers, and underscores (_).' };
}
if (action === 'delete' && tokens.length === 4) {
// Variant 1: DELETE (sudo tile-extract.py delete desert1)
return { type: 'delete', region: regionName };
}
if (action === 'extract' && tokens.length === 5) {
// Variant 2: DOWNLOAD (sudo tile-extract.py extract desert1 bbox)
const bbox = tokens[4];
if (!/^[-0-9.,]+$/.test(bbox)) {
return { type: '', region: '', error: 'ERROR: The coordinates (Bounding Box) have an invalid format.' };
}
return { type: 'extract', region: regionName, bbox: bbox };
}
// 3. Anything else is an error
return { type: '', region: '', error: 'Invalid command format. Only sudo [script] extract {region} {bbox} OR sudo [script] delete {region} are allowed.' };
}
export const handleMapsEvents = (socket: Socket) => {
let scriptProcess: ChildProcess | null = null;
// A. Send catalog to the web
socket.on('request_maps_catalog', () => {
const catalog = getMapsCatalog();
socket.emit('maps_catalog_ready', catalog);
});
// B. Process raw order (copy-paste) with strict validation
socket.on('start_command', (config: { rawCommand: string }) => {
if (scriptProcess) {
socket.emit('terminal_output', '\n[System] A process is already running.\n');
return;
}
const validation = validateSecureCommand(config.rawCommand);
if (validation.error) {
socket.emit('terminal_output', `\n[System] SECURITY ERROR: ${validation.error}\n`);
return;
}
let args = [EXTRACT_SCRIPT, validation.type, validation.region];
if (validation.type === 'extract' && validation.bbox) args.push(validation.bbox);
socket.emit('terminal_output', `[System] Valid command. Starting action [${validation.type}] for region: ${validation.region}...\n`);
scriptProcess = spawn('sudo', args, { env: { ...process.env, PYTHONUNBUFFERED: '1' } });
socket.emit('process_status', { isRunning: true });
scriptProcess.stdout?.on('data', (data) => socket.emit('terminal_output', data.toString()));
scriptProcess.stderr?.on('data', (data) => socket.emit('terminal_output', data.toString()));
scriptProcess.on('exit', (code) => {
scriptProcess = null;
socket.emit('terminal_output', `\n[System] Process [${validation.type}] finished with code ${code}\n`);
socket.emit('process_status', { isRunning: false });
// Reload catalog if successful
if (code === 0) {
const catalog = getMapsCatalog();
socket.emit('maps_catalog_ready', catalog);
}
});
});
// C. Process direct card deletion (Intelligent UI)
socket.on('delete_map_region', (regionName: string) => {
if (scriptProcess) return;
// Double security check just in case
if (!regionNameRegex.test(regionName)) {
socket.emit('terminal_output', `\n[System] ERROR: Invalid region name for deletion: ${regionName}\n`);
return;
}
socket.emit('terminal_output', `[System] Starting direct deletion for region: ${regionName}...\n`);
scriptProcess = spawn('sudo', [EXTRACT_SCRIPT, 'delete', regionName], { env: { ...process.env, PYTHONUNBUFFERED: '1' } });
socket.emit('process_status', { isRunning: true });
scriptProcess.stdout?.on('data', (data) => socket.emit('terminal_output', data.toString()));
scriptProcess.on('exit', (code) => {
scriptProcess = null;
if (code === 0) {
socket.emit('terminal_output', `\n[System] 🗑️ Region ${regionName} successfully deleted from JSON.\n`);
// Send updated catalog
const catalog = getMapsCatalog();
socket.emit('maps_catalog_ready', catalog);
} else {
socket.emit('terminal_output', `\n[System] Error deleting region ${regionName}. Code ${code}\n`);
}
socket.emit('process_status', { isRunning: false });
});
});
socket.on('send_input', (input: string) => {
if (scriptProcess && scriptProcess.stdin) {
scriptProcess.stdin.write(`${input}\n`);
}
});
socket.on('disconnect', () => {
if (scriptProcess) scriptProcess.kill();
});
};

View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "es2022",
"module": "commonjs",
"moduleResolution": "node16",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}

View File

@ -0,0 +1,102 @@
<div id="panel-home" class="app-panel pb-5">
<div class="d-flex justify-content-between align-items-end mb-4 border-bottom border-secondary pb-3">
<div>
<h2 class="mb-0 text-light fw-bold">My Digital Library</h2>
<div class="text-secondary" style="font-size: 1.1em;">
<span id="sys-hostname" class="text-info fw-bold">Loading...</span> &nbsp;·&nbsp;
IP: <span id="sys-ip">...</span> &nbsp;·&nbsp;
Uptime: <span id="sys-uptime">...</span>
</div>
</div>
<div class="text-end text-secondary">
<span class="badge bg-success">Online</span>
</div>
</div>
<div class="mb-5">
<div class="mb-4">
<div class="d-flex justify-content-between mb-1">
<span class="fw-bold text-light">💾 Main Storage</span>
<span class="text-secondary small"><span id="disk-used-text">...</span> / <span id="disk-total-text">...</span></span>
</div>
<div class="disk-bar-container mb-1" style="height: 18px;">
<div id="disk-used-bar" class="disk-segment-used" style="width: 0%;"></div>
<div class="disk-segment-free"></div>
</div>
</div>
<div class="row g-3">
<div class="col-6">
<div class="d-flex justify-content-between mb-1">
<span class="text-light small">⚡ RAM Memory</span>
<span class="text-secondary small" style="font-size: 0.8em;" id="ram-text">...</span>
</div>
<div class="disk-bar-container" style="height: 6px;">
<div id="ram-used-bar" style="background: #e8a020; transition: width 1s; width: 0%;"></div>
<div class="disk-segment-free"></div>
</div>
</div>
<div class="col-6">
<div class="d-flex justify-content-between mb-1">
<span class="text-light small">💽 Swap (Virtual)</span>
<span class="text-secondary small" style="font-size: 0.8em;" id="swap-text">...</span>
</div>
<div class="disk-bar-container" style="height: 6px;">
<div id="swap-used-bar" style="background: #7b8dd4; transition: width 1s; width: 0%;"></div>
<div class="disk-segment-free"></div>
</div>
</div>
</div>
</div>
<h4 class="mb-3 text-secondary">Installed Modules</h4>
<div class="row g-4">
<div class="col-md-6">
<div class="stat-card d-flex align-items-center cursor-pointer" onclick="switchTab('kiwix')" style="cursor: pointer;">
<div class="icon-box" style="background: rgba(232, 160, 32, 0.2); color: #e8a020;">📚</div>
<div class="ms-3">
<h5 class="mb-0 text-light">Kiwix Manager</h5>
<div class="text-secondary small">Browse and download Wikipedia offline</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="stat-card d-flex align-items-center cursor-pointer" onclick="switchTab('maps')" style="cursor: pointer;">
<div class="icon-box" style="background: rgba(93, 188, 165, 0.2); color: #5dbca5;">🗺️</div>
<div class="ms-3">
<h5 class="mb-0 text-light">Map Extractor</h5>
<div class="text-secondary small">Manage OpenStreetMap tiles</div>
</div>
</div>
</div>
</div>
</div>
<script>
// Request data on load
socket.emit('request_home_stats');
// Receive and render data
socket.on('home_stats_ready', (stats) => {
document.getElementById('sys-hostname').textContent = stats.hostname;
document.getElementById('sys-ip').textContent = stats.ip;
document.getElementById('sys-uptime').textContent = stats.uptime;
// Disk
document.getElementById('disk-used-text').textContent = stats.diskUsed;
document.getElementById('disk-total-text').textContent = stats.diskTotal;
document.getElementById('disk-used-bar').style.width = stats.diskPercent + '%';
// RAM
document.getElementById('ram-text').textContent = `${stats.ramUsed} / ${stats.ramTotal}`;
document.getElementById('ram-used-bar').style.width = stats.ramPercent + '%';
// Swap
document.getElementById('swap-text').textContent = `${stats.swapUsed} / ${stats.swapTotal}`;
document.getElementById('swap-used-bar').style.width = stats.swapPercent + '%';
});
</script>

View File

@ -0,0 +1,277 @@
<div id="panel-kiwix" class="app-panel hidden-section position-relative pb-5">
<h2 class="mb-4">Kiwix Repository (Wikipedia)</h2>
<div id="kiwix-status" class="alert alert-info d-flex align-items-center">
<span class="spinner-border spinner-border-sm me-3" role="status" aria-hidden="true"></span>
<span id="kiwix-status-text">Connecting to local server...</span>
</div>
<div class="mb-3">
<input type="text" id="zim-search" class="form-control bg-dark text-light border-secondary form-control-lg" placeholder="🔍 Search (e.g., es all nopic...)">
</div>
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<button id="btn-downloaded-filter" class="btn filter-inactive border-secondary px-3" style="border-radius: 8px;">
💾 Local: <span id="downloaded-counter" class="badge bg-secondary ms-1">0</span>
</button>
<div class="text-end">
<span class="text-secondary me-2 d-none d-sm-inline">
Selected: <strong id="selection-counter" class="text-warning">0</strong> / <span id="ui-limit"></span>
</span>
<button id="btn-start-download" class="btn btn-success px-4" disabled>Download</button>
</div>
</div>
<div id="terminal-container" class="hidden-section position-relative">
<div id="kiwix-terminal" style="background-color: #000; color: #0f0; height: 250px; overflow-y: auto; padding: 10px; font-family: monospace; border-radius: 5px; white-space: pre-wrap; margin-bottom: 15px;">Waiting for commands...</div>
<div class="collapse-btn-container d-flex justify-content-center gap-3">
<button id="btn-toggle-terminal" class="btn btn-sm btn-dark border-secondary text-secondary" style="border-radius: 20px; padding: 2px 15px;">▲ Hide Terminal</button>
<button id="btn-cancel-download" class="btn btn-sm btn-danger text-light hidden-section shadow" style="border-radius: 20px; padding: 2px 15px;">🛑 Cancel Download</button>
</div>
</div>
<div class="row g-3 mb-4" id="zims-container"></div>
<div class="text-center mb-4">
<button id="btn-load-more" class="btn btn-outline-primary hidden-section">Load more results ↓</button>
</div>
</div>
<div class="modal fade" id="solidarityModal" tabindex="-1" data-bs-theme="dark"><div class="modal-dialog"><div class="modal-content bg-dark text-light border-warning"><div class="modal-header border-secondary"><h5 class="modal-title text-warning">⚠️ Suggested Download Limit</h5><button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button></div><div class="modal-body"><p>Please complete the downloads in the queue first.</p></div><div class="modal-footer border-secondary"><button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">Got it</button></div></div></div></div>
<div class="modal fade" id="ariaMissingModal" tabindex="-1" data-bs-theme="dark"><div class="modal-dialog"><div class="modal-content bg-dark text-light border-danger"><div class="modal-header border-secondary"><h5 class="modal-title text-danger">❌ Error: Aria2 Not Found</h5><button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button></div><div class="modal-body"><p>The binary <code>/usr/bin/aria2c</code> was not detected.</p></div><div class="modal-footer border-secondary"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button></div></div></div></div>
<div class="modal fade" id="indexerMissingModal" tabindex="-1" data-bs-theme="dark"><div class="modal-dialog"><div class="modal-content bg-dark text-light border-warning"><div class="modal-header border-secondary"><h5 class="modal-title text-warning">⚠️ Warning: Missing Indexer</h5><button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button></div><div class="modal-body"><p><code>iiab-make-kiwix-lib</code> was not detected in the backend. Do you want to continue?</p></div><div class="modal-footer border-secondary"><button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button><button type="button" class="btn btn-warning text-dark" id="btn-force-download">Continue Download</button></div></div></div></div>
<script>
const MAX_ZIM_SELECTION = 3; const ZIMS_PER_PAGE = 9;
document.getElementById('ui-limit').textContent = MAX_ZIM_SELECTION;
let zimsInMemory = [];
let filteredZims = [];
let selectedZims = new Set();
let currentPage = 1;
let onlyDownloaded = false;
let terminalVisible = true;
const solidarityModal = new bootstrap.Modal(document.getElementById('solidarityModal'));
const ariaMissingModal = new bootstrap.Modal(document.getElementById('ariaMissingModal'));
const indexerMissingModal = new bootstrap.Modal(document.getElementById('indexerMissingModal'));
const kiwixTerminal = document.getElementById('kiwix-terminal');
const terminalContainer = document.getElementById('terminal-container');
const btnCancelDownload = document.getElementById('btn-cancel-download');
// 1. Initial Load and Refresh
socket.emit('request_kiwix_catalog');
socket.on('kiwix_status', (data) => document.getElementById('kiwix-status-text').textContent = data.message);
socket.on('kiwix_catalog_ready', (realData) => {
zimsInMemory = realData;
document.getElementById('kiwix-status').classList.add('hidden-section');
// Update downloaded counter
const totalDownloaded = zimsInMemory.filter(z => z.isDownloaded).length;
document.getElementById('downloaded-counter').textContent = totalDownloaded;
applyFilters();
});
socket.on('refresh_kiwix_catalog', () => {
selectedZims.clear();
document.getElementById('selection-counter').textContent = "0";
document.getElementById('btn-start-download').disabled = true;
socket.emit('request_kiwix_catalog');
});
// 2. Unified Filter Logic (Search + Downloaded Toggle)
function applyFilters() {
const terms = document.getElementById('zim-search').value.toLowerCase().trim().split(/\s+/);
filteredZims = zimsInMemory.filter(zim => {
const content = (zim.title + " " + zim.id).toLowerCase();
const matchesText = terms.every(term => content.includes(term));
const matchesDownloaded = onlyDownloaded ? zim.isDownloaded : true;
return matchesText && matchesDownloaded;
});
currentPage = 1;
renderGrid();
}
document.getElementById('zim-search').addEventListener('input', applyFilters);
document.getElementById('btn-load-more').addEventListener('click', () => { currentPage++; renderGrid(); });
// Toggle downloaded filter
document.getElementById('btn-downloaded-filter').addEventListener('click', (e) => {
onlyDownloaded = !onlyDownloaded;
const btn = e.currentTarget;
if(onlyDownloaded) {
btn.classList.replace('filter-inactive', 'filter-active');
} else {
btn.classList.replace('filter-active', 'filter-inactive');
}
applyFilters();
});
// 3. Card Rendering
function renderGrid() {
const container = document.getElementById('zims-container');
const visualLimit = currentPage * ZIMS_PER_PAGE;
const resultsToShow = filteredZims.slice(0, visualLimit);
container.innerHTML = '';
resultsToShow.forEach(zim => {
const isChecked = selectedZims.has(zim.id) ? 'checked' : '';
const borderClass = zim.isDownloaded ? 'border-success' : 'border-secondary';
const bgClass = zim.isDownloaded ? 'bg-dark text-success' : 'bg-dark';
// Delete button with unique ID
const trashBtn = zim.isDownloaded
? `<button id="btn-del-${zim.id}" class="btn btn-sm btn-outline-danger mt-2" onclick="deleteZim(event, '${zim.id}')">🗑️ Delete</button>`
: ``;
const card = `
<div class="col-12 col-md-6 col-lg-4">
<div class="card ${bgClass} ${borderClass} p-3 h-100 zim-card" style="cursor: pointer; transition: 0.2s;" onclick="toggleCard(event, '${zim.id}')">
<div class="form-check" style="pointer-events: none;">
<input class="form-check-input check-zim" type="checkbox" value="${zim.id}" id="chk-${zim.id}" ${isChecked}>
<label class="form-check-label w-100 fw-bold text-light">${zim.title}</label>
</div>
<div class="mt-2 text-secondary small d-flex flex-column justify-content-between h-100">
<div>
<div class="text-truncate" title="${zim.id}">📦 ${zim.id}</div>
<div>📅 ${zim.date} &nbsp;&nbsp; 💾 <span class="badge bg-secondary">${zim.size}</span></div>
</div>
<div class="text-end">${trashBtn}</div>
</div>
</div>
</div>`;
container.innerHTML += card;
});
const btnLoadMore = document.getElementById('btn-load-more');
if (filteredZims.length > visualLimit) btnLoadMore.classList.remove('hidden-section');
else btnLoadMore.classList.add('hidden-section');
}
function toggleCard(event, zimId) {
if (event.target.tagName.toLowerCase() === 'button' || event.target.closest('button')) return;
const checkbox = document.getElementById(`chk-${zimId}`);
checkbox.checked = !checkbox.checked;
handleSelection(checkbox, zimId);
}
function handleSelection(checkbox, zimId) {
const zimObj = zimsInMemory.find(z => z.id === zimId);
if (checkbox.checked) {
if (zimObj && zimObj.isDownloaded) {
if (!confirm(`The file ${zimObj.id} is already downloaded.\n\nDo you want to overwrite it?`)) {
checkbox.checked = false; return;
}
}
if (selectedZims.size >= MAX_ZIM_SELECTION) {
checkbox.checked = false; solidarityModal.show(); return;
}
selectedZims.add(checkbox.value);
} else {
selectedZims.delete(checkbox.value);
}
document.getElementById('selection-counter').textContent = selectedZims.size;
document.getElementById('btn-start-download').disabled = selectedZims.size === 0;
}
// 4. Terminal Visibility Logic
function forceShowTerminal() {
terminalContainer.classList.remove('hidden-section');
kiwixTerminal.style.display = 'block';
document.getElementById('btn-toggle-terminal').textContent = '▲ Hide Terminal';
terminalVisible = true;
}
document.getElementById('btn-toggle-terminal').addEventListener('click', (e) => {
if(terminalVisible) {
kiwixTerminal.style.display = 'none';
e.target.textContent = '▼ Show Terminal';
} else {
kiwixTerminal.style.display = 'block';
e.target.textContent = '▲ Hide Terminal';
}
terminalVisible = !terminalVisible;
});
// 5. Deletion Logic
function deleteZim(event, zimId) {
event.stopPropagation();
if(confirm(`⚠️ Are you absolutely sure you want to DELETE the file ${zimId}?\n\nThis will update the Kiwix database.`)){
// UX: Change button to "Deleting..." and disable it
const btnDel = document.getElementById(`btn-del-${zimId}`);
btnDel.textContent = "⏳ Deleting...";
btnDel.disabled = true;
btnDel.classList.replace('btn-outline-danger', 'btn-secondary');
forceShowTerminal();
kiwixTerminal.textContent += `\n[System] Requesting deletion from server...\n`;
socket.emit('delete_zim', zimId);
}
}
// NEW: Cancel Button
btnCancelDownload.addEventListener('click', () => {
if(confirm('⚠️ Are you completely sure you want to CANCEL the ongoing download?\n\nIncomplete files will be wiped from your hard drive.')) {
btnCancelDownload.disabled = true;
btnCancelDownload.textContent = '⏳ Aborting...';
socket.emit('cancel_kiwix_download');
}
});
// 6. Download Logic
const btnDownload = document.getElementById('btn-start-download');
function sendArrayToBackend() {
socket.emit('start_kiwix_download', Array.from(selectedZims));
indexerMissingModal.hide();
forceShowTerminal();
}
btnDownload.addEventListener('click', () => socket.emit('check_kiwix_tools'));
socket.on('kiwix_tools_status', (status) => {
if (!status.hasAria2) return ariaMissingModal.show();
if (!status.hasIndexer) return indexerMissingModal.show();
sendArrayToBackend();
});
document.getElementById('btn-force-download').addEventListener('click', sendArrayToBackend);
socket.on('kiwix_terminal_output', (data) => {
kiwixTerminal.textContent += data;
kiwixTerminal.scrollTop = kiwixTerminal.scrollHeight;
});
socket.on('kiwix_process_status', (status) => {
btnDownload.disabled = status.isRunning;
document.getElementById('zim-search').disabled = status.isRunning;
if (status.isRunning) {
btnDownload.textContent = "Downloading...";
btnDownload.classList.replace('btn-success', 'btn-secondary');
// Show cancel button
btnCancelDownload.classList.remove('hidden-section');
btnCancelDownload.disabled = false;
btnCancelDownload.textContent = '🛑 Cancel Download';
} else {
btnDownload.textContent = "Download";
btnDownload.classList.replace('btn-secondary', 'btn-success');
// Hide cancel button
btnCancelDownload.classList.add('hidden-section');
}
});
</script>

View File

@ -0,0 +1,142 @@
<div id="panel-maps" class="app-panel hidden-section pb-5">
<h2 class="mb-4">Map Extraction Dashboard (Tiles)</h2>
<div class="card bg-dark text-light mb-4 p-3 border-secondary shadow-sm">
<label for="raw-command" class="form-label text-warning fw-bold">Execute raw command (extract desert1 bbox OR delete desert1):</label>
<textarea id="raw-command" class="form-control bg-secondary text-light border-0 mb-3" rows="1" placeholder="e.g.: sudo /opt/iiab/maps/..."></textarea>
<div class="d-grid gap-2">
<button id="btn-start" class="btn btn-primary">Start Process (Terminal)</button>
</div>
</div>
<div id="terminal" style="background-color: #000; color: #0f0; height: 250px; overflow-y: auto; padding: 10px; font-family: monospace; border-radius: 5px; white-space: pre-wrap; margin-bottom: 15px;">Waiting for commands...</div>
<div id="controls-yn" class="card bg-dark border-warning p-3 hidden-section shadow mb-4">
<div class="d-flex justify-content-between align-items-center">
<span class="text-warning fw-bold">⚠️ The system requires your confirmation to continue:</span>
<div>
<button id="btn-yes" class="btn btn-success me-2 px-4">Yes (y)</button>
<button id="btn-no" class="btn btn-danger px-4">No (n)</button>
</div>
</div>
</div>
<hr class="border-secondary mb-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="text-primary mb-0">🗺️ Existing Regions on Server</h4>
<button id="btn-refresh-maps" class="btn btn-sm btn-outline-primary">🔄 Refresh List</button>
</div>
<div id="maps-status" class="alert alert-info d-flex align-items-center">
<span class="spinner-border spinner-border-sm me-3" role="status" aria-hidden="true"></span>
<span>Loading server catalog...</span>
</div>
<div class="row g-3" id="maps-container">
</div>
</div>
<script>
const terminal = document.getElementById('terminal');
const btnStart = document.getElementById('btn-start');
const rawCommandInput = document.getElementById('raw-command');
const controlsYN = document.getElementById('controls-yn');
function printToTerminal(text) {
terminal.textContent += text;
terminal.scrollTop = terminal.scrollHeight;
}
// A. Copy-Paste Logic (Old UI)
btnStart.addEventListener('click', () => socket.emit('start_command', { rawCommand: rawCommandInput.value }));
socket.on('terminal_output', (data) => {
printToTerminal(data);
if (/y\/n/i.test(data)) controlsYN.classList.remove('hidden-section');
});
function sendYN(response) {
socket.emit('send_input', response);
controlsYN.classList.add('hidden-section');
}
document.getElementById('btn-yes').addEventListener('click', () => sendYN('y'));
document.getElementById('btn-no').addEventListener('click', () => sendYN('n'));
// B. Card Catalog Logic (New UI)
let mapsInMemory = [];
const mapsContainer = document.getElementById('maps-container');
const mapsStatus = document.getElementById('maps-status');
// Request data on app load and refresh button
function requestUpdate() {
mapsStatus.classList.remove('hidden-section');
socket.emit('request_maps_catalog');
}
requestUpdate();
document.getElementById('btn-refresh-maps').addEventListener('click', requestUpdate);
// Receive catalog from Node
socket.on('maps_catalog_ready', (catalog) => {
mapsInMemory = catalog;
mapsStatus.classList.add('hidden-section');
renderMapsGrid();
});
// Draw cards
function renderMapsGrid() {
mapsContainer.innerHTML = '';
if (mapsInMemory.length === 0) {
mapsContainer.innerHTML = `<div class="col-12"><div class="alert bg-dark text-secondary text-center border-secondary">No map regions registered in extracts.json.</div></div>`;
return;
}
mapsInMemory.forEach(map => {
const card = `
<div class="col-12 col-md-6 col-lg-4">
<div class="card map-card p-3 h-100 shadow-sm d-flex flex-column justify-content-between">
<div>
<div class="fw-bold fs-5 mb-1">${map.name}</div>
<div class="map-coords text-break">[ ${map.bbox} ]</div>
</div>
<div class="text-end mt-3">
<button id="btn-del-map-${map.name}" class="btn btn-sm btn-outline-danger" onclick="deleteRegionDirectly(event, '${map.name}')">🗑️ Delete</button>
</div>
</div>
</div>
`;
mapsContainer.innerHTML += card;
});
}
// C. Direct Deletion Logic
function deleteRegionDirectly(event, regionName) {
event.stopPropagation();
if(confirm(`⚠️ Are you COMPLETELY sure you want to DELETE the region "${regionName}" from the server?\n\nThis will remove the entry in extracts.json and stop the Kiwix server.`)){
const btnDel = document.getElementById(`btn-del-map-${regionName}`);
btnDel.textContent = "⏳ Deleting...";
btnDel.disabled = true;
btnDel.classList.replace('btn-outline-danger', 'btn-secondary');
terminal.classList.remove('hidden-section');
socket.emit('delete_map_region', regionName);
}
}
// General UI Control
socket.on('process_status', (status) => {
btnStart.disabled = status.isRunning;
rawCommandInput.disabled = status.isRunning;
if (status.isRunning) {
btnStart.textContent = "Executing...";
btnStart.classList.replace('btn-primary', 'btn-secondary');
} else {
btnStart.textContent = "Start Process (Terminal)";
btnStart.classList.replace('btn-secondary', 'btn-primary');
controlsYN.classList.add('hidden-section');
}
});
</script>

View File

@ -0,0 +1,24 @@
<div class="col-md-3 col-lg-2 p-3 app-sidebar" id="sidebar">
<h4 class="text-center text-primary mb-4 sidebar-title">BobOS Admin</h4>
<ul class="nav flex-column gap-2 w-100">
<li class="nav-item">
<a class="nav-link active text-light nav-btn" id="tab-home" onclick="switchTab('home')">
<span class="nav-icon fs-4">🏠</span>
<span class="nav-text">Home</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link text-light nav-btn" id="tab-maps" onclick="switchTab('maps')">
<span class="nav-icon fs-4">🗺️</span>
<span class="nav-text">Maps</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link text-light nav-btn" id="tab-kiwix" onclick="switchTab('kiwix')">
<span class="nav-icon fs-4">📚</span>
<span class="nav-text">Kiwix</span>
</a>
</li>
</ul>
</div>

View File

@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BobOS | Admin Dashboard</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/css/dashboard.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="/socket.io/socket.io.js"></script>
<script>
const socket = io();
function switchTab(tabName) {
document.querySelectorAll('.nav-link').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.app-panel').forEach(el => el.classList.add('hidden-section'));
document.getElementById(`tab-${tabName}`).classList.add('active');
document.getElementById(`panel-${tabName}`).classList.remove('hidden-section');
}
document.addEventListener("DOMContentLoaded", () => {
switchTab('home');
});
</script>
</head>
<body class="bg-dark text-light">
<div class="container-fluid">
<div class="row">
<%- include('./components/sidebar') %>
<div class="col-md-9 col-lg-10 p-4">
<%- include('./components/app_home') %>
<%- include('./components/app_maps') %>
<%- include('./components/app_kiwix') %>
</div>
</div>
</div>
</body>
</html>