[a-dash] una dashboard para iiab-oa
This commit is contained in:
parent
ab609ae6eb
commit
2fa54d273b
|
|
@ -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
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(`=========================================`);
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2022",
|
||||||
|
"module": "commonjs",
|
||||||
|
"moduleResolution": "node16",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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> ·
|
||||||
|
IP: <span id="sys-ip">...</span> ·
|
||||||
|
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>
|
||||||
|
|
@ -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} 💾 <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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
Loading…
Reference in New Issue