feat: implement container logs modal, service diagnostics inspector, and directory sizer tool
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 13s

This commit is contained in:
Kroonk
2026-05-21 18:49:59 +02:00
parent 6ff401ad74
commit 0a044bbb92
4 changed files with 649 additions and 7 deletions

View File

@@ -6,7 +6,7 @@
<title>MischLabs Admin</title> <title>MischLabs Admin</title>
<meta name="description" content="MischLabs dashboard administration."> <meta name="description" content="MischLabs dashboard administration.">
<meta name="theme-color" content="#0f172a"> <meta name="theme-color" content="#0f172a">
<link rel="stylesheet" href="/style.css?v=24"> <link rel="stylesheet" href="/style.css?v=25">
</head> </head>
<body> <body>
<header class="shell hero admin-hero"> <header class="shell hero admin-hero">
@@ -101,12 +101,27 @@
<button class="ghost-button mini" id="refreshOpsButton" type="button">Aktualisieren</button> <button class="ghost-button mini" id="refreshOpsButton" type="button">Aktualisieren</button>
</div> </div>
<div id="diskStatus" class="ops-list">Noch nicht geladen.</div> <div id="diskStatus" class="ops-list">Noch nicht geladen.</div>
<div class="disk-scanner-tool" style="margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--border);">
<h4 style="margin-bottom: 8px; color: #fff; font-size: 1.05rem; font-weight: 700;">Verzeichnis-Größen-Scanner</h4>
<p style="font-size: 0.85rem; color: var(--dim); margin-bottom: 14px; line-height: 1.4;">
Scanne Ordnergrößen auf den Host-Volumes (z.B. <code>/host/volume2</code> oder <code>/host/volume1/docker</code>), um Speicherfresser zu finden.
</p>
<div class="scanner-input-group" style="display: flex; gap: 8px; margin-bottom: 16px;">
<input type="text" id="scannerPathInput" value="/host/volume2" placeholder="/host/volume2" style="flex-grow: 1; padding: 10px 14px; background: rgba(0, 0, 0, 0.2); border: 1px solid var(--border); border-radius: var(--radius); color: #fff; font-family: monospace; font-size: 0.9rem;">
<button class="ghost-button mini" id="scannerStartBtn" type="button" style="padding-inline: 16px;">Scan starten</button>
</div>
<div id="scannerResults" class="ops-list" style="max-height: 350px; overflow-y: auto;" hidden></div>
</div>
</section> </section>
<section class="ops-card admin-tab-content" id="card-watchtower"> <section class="ops-card admin-tab-content" id="card-watchtower">
<div class="ops-card-head"> <div class="ops-card-head">
<span>Watchtower</span> <span>Watchtower</span>
<button class="ghost-button mini" id="runWatchtowerButton" type="button">Jetzt pruefen</button> <div style="display: flex; gap: 8px;">
<button class="ghost-button mini" id="runWatchtowerButton" type="button">Jetzt pruefen</button>
<button class="ghost-button mini" id="viewWatchtowerLogsBtn" type="button">Logs</button>
</div>
</div> </div>
<div id="watchtowerStatus" class="ops-list">Noch nicht geladen.</div> <div id="watchtowerStatus" class="ops-list">Noch nicht geladen.</div>
</section> </section>
@@ -182,6 +197,44 @@
</nav> </nav>
</aside> </aside>
<script src="/admin.js?v=20" defer></script> <!-- Container Logs Modal Overlay -->
<div class="modal-overlay" id="logsModalOverlay" hidden>
<div class="modal-card">
<div class="modal-header">
<h3 id="logsModalTitle">Container Logs</h3>
<button class="modal-close-btn" id="logsModalCloseBtn" type="button">&times;</button>
</div>
<div class="modal-body">
<div class="terminal-logs-controls" style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; font-size: 0.85rem;">
<div>
<label for="logsTailSelect" style="color: var(--dim); margin-right: 6px;">Zeilen:</label>
<select id="logsTailSelect" style="background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius); color: #fff; padding: 4px 8px; cursor: pointer;">
<option value="50">50</option>
<option value="150" selected>150</option>
<option value="300">300</option>
<option value="500">500</option>
</select>
</div>
<button class="ghost-button mini" id="logsModalRefreshBtn" type="button">Aktualisieren</button>
</div>
<pre class="terminal-logs" id="logsModalConsole">Lade Logs...</pre>
</div>
</div>
</div>
<!-- Service Details Modal Overlay -->
<div class="modal-overlay" id="serviceModalOverlay" hidden>
<div class="modal-card">
<div class="modal-header">
<h3 id="serviceModalTitle">Dienst-Inspektion</h3>
<button class="modal-close-btn" id="serviceModalCloseBtn" type="button">&times;</button>
</div>
<div class="modal-body" id="serviceModalContent">
<!-- Dynamically populated details -->
</div>
</div>
</div>
<script src="/admin.js?v=21" defer></script>
</body> </body>
</html> </html>

294
admin.js
View File

@@ -387,7 +387,10 @@ function renderServicesStatus(services = []) {
return ` return `
<a class="status-board-item ${statusClass}" href="${item.url}" target="_blank" rel="noopener noreferrer" title="${tooltip}"> <a class="status-board-item ${statusClass}" href="${item.url}" target="_blank" rel="noopener noreferrer" title="${tooltip}">
<h4>${item.name}</h4> <div style="display: flex; justify-content: space-between; align-items: center; width: 100%;">
<h4 style="margin: 0; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; flex-grow: 1;">${item.name}</h4>
<span class="service-inspect-trigger" data-url="${item.url}" data-name="${item.name}" title="Live-Diagnose ausführen" style="font-size: 0.85rem; cursor: pointer; padding: 2px 4px; background: rgba(255,255,255,0.08); border-radius: 4px; margin-left: 6px; transition: transform 0.1s ease; user-select: none;">🔍</span>
</div>
<div class="status-board-item-bar"> <div class="status-board-item-bar">
<div class="status-board-item-bar-inner"></div> <div class="status-board-item-bar-inner"></div>
</div> </div>
@@ -395,6 +398,16 @@ function renderServicesStatus(services = []) {
`; `;
}).join(''); }).join('');
servicesStatusBoard.querySelectorAll('.service-inspect-trigger').forEach((trigger) => {
trigger.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const url = trigger.dataset.url;
const name = trigger.dataset.name;
openServiceInspectModal(url, name);
});
});
const miniServices = document.getElementById('mini-services-status'); const miniServices = document.getElementById('mini-services-status');
if (miniServices) { if (miniServices) {
let ledHtml = itemsToRender.map(item => { let ledHtml = itemsToRender.map(item => {
@@ -536,12 +549,21 @@ function renderContainers(containers) {
<div class="ops-line container-line" data-container="${container.name}"> <div class="ops-line container-line" data-container="${container.name}">
<strong>${container.name}</strong> <strong>${container.name}</strong>
<span>${container.status}</span> <span>${container.status}</span>
<button class="ghost-button mini restart-container ${dangerActive && !container.restartable ? 'is-danger-enabled' : ''}" type="button" title="${container.restartable ? 'Container neu starten' : (dangerActive ? 'Danger-Modus aktiv: Neustart erlaubt' : (container.restartBlockReason || 'Neustart gesperrt'))}" ${(container.restartable || dangerActive) ? '' : 'disabled'}> <div style="display: flex; gap: 6px;">
Neustarten <button class="ghost-button mini view-container-logs" type="button">Logs</button>
</button> <button class="ghost-button mini restart-container ${dangerActive && !container.restartable ? 'is-danger-enabled' : ''}" type="button" title="${container.restartable ? 'Container neu starten' : (dangerActive ? 'Danger-Modus aktiv: Neustart erlaubt' : (container.restartBlockReason || 'Neustart gesperrt'))}" ${(container.restartable || dangerActive) ? '' : 'disabled'}>
Neustarten
</button>
</div>
</div> </div>
`).join(''); `).join('');
containerStatus.querySelectorAll('.view-container-logs').forEach((button) => {
button.addEventListener('click', () => {
openLogsModal(button.closest('.container-line').dataset.container);
});
});
containerStatus.querySelectorAll('.restart-container').forEach((button) => { containerStatus.querySelectorAll('.restart-container').forEach((button) => {
button.addEventListener('click', () => restartContainer(button.closest('.container-line'))); button.addEventListener('click', () => restartContainer(button.closest('.container-line')));
}); });
@@ -786,6 +808,262 @@ function startOpsAutoRefresh() {
}, 60000); }, 60000);
} }
let currentLogsContainer = '';
async function fetchContainerLogs(containerName, tail = 150) {
const consoleEl = document.getElementById('logsModalConsole');
if (!consoleEl) return;
consoleEl.textContent = 'Lade Logs...';
try {
const response = await fetch(`/api/containers/${encodeURIComponent(containerName)}/logs?tail=${tail}`, {
headers: {
Authorization: `Bearer ${tokenSet?.id_token}`
}
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
if (typeof data.logs === 'string') {
consoleEl.textContent = data.logs || 'Keine Logs vorhanden.';
} else {
consoleEl.textContent = JSON.stringify(data.logs, null, 2);
}
consoleEl.scrollTop = consoleEl.scrollHeight;
} catch (error) {
consoleEl.textContent = `Fehler beim Laden der Logs: ${error.message}`;
}
}
function openLogsModal(containerName) {
currentLogsContainer = containerName;
const overlay = document.getElementById('logsModalOverlay');
const title = document.getElementById('logsModalTitle');
const tailSelect = document.getElementById('logsTailSelect');
if (overlay && title) {
title.textContent = `Logs von "${containerName}"`;
if (tailSelect) {
tailSelect.value = "150";
}
overlay.removeAttribute('hidden');
overlay.classList.add('is-active');
fetchContainerLogs(containerName, 150);
}
}
function closeLogsModal() {
const overlay = document.getElementById('logsModalOverlay');
if (overlay) {
overlay.setAttribute('hidden', '');
overlay.classList.remove('is-active');
currentLogsContainer = '';
}
}
async function openServiceInspectModal(url, name) {
const overlay = document.getElementById('serviceModalOverlay');
const title = document.getElementById('serviceModalTitle');
const content = document.getElementById('serviceModalContent');
if (!overlay || !content) return;
title.textContent = `Inspektion: ${name}`;
content.innerHTML = '<div class="ops-line">Führe Live-Diagnose aus...</div>';
overlay.removeAttribute('hidden');
overlay.classList.add('is-active');
try {
const response = await fetch(`/api/services/inspect?url=${encodeURIComponent(url)}`, {
headers: {
Authorization: `Bearer ${tokenSet?.id_token}`
}
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
const statusClass = data.ok ? 'text-ok' : 'text-down';
let headersHtml = '';
if (data.headers) {
headersHtml = Object.entries(data.headers).map(([key, val]) => `
<div class="header-row" style="display: flex; justify-content: space-between; font-size: 0.8rem; border-bottom: 1px solid var(--surface-2); padding: 4px 0;">
<strong style="color: var(--dim); word-break: break-all;">${key}:</strong>
<span style="color: #fff; text-align: right; word-break: break-all; margin-left: 8px;">${val}</span>
</div>
`).join('');
}
let previewHtml = '';
if (data.bodyPreview) {
const escaped = data.bodyPreview
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
previewHtml = `
<div style="margin-top: 14px;">
<h4 style="margin-bottom: 6px; font-size: 0.9rem;">Antwort-Vorschau (erste 1000 Zeichen):</h4>
<pre class="terminal-logs" style="max-height: 150px; font-size: 0.8rem; padding: 10px; background: rgba(0,0,0,0.3); border-radius: var(--radius); overflow-y: auto;">${escaped}</pre>
</div>
`;
}
content.innerHTML = `
<div class="ops-line">
<strong>Status:</strong>
<span class="${statusClass}" style="font-weight: 700; color: ${data.ok ? 'var(--ok)' : 'var(--down)'}">${data.statusCode ? `HTTP ${data.statusCode} ${data.statusMessage || ''}` : 'Fehler'}</span>
</div>
<div class="ops-line">
<strong>Latenz:</strong>
<span>${data.latency} ms</span>
</div>
${data.error ? `<div class="ops-line is-warning"><strong>Fehlerursache:</strong><span>${data.error}</span></div>` : ''}
<div style="margin-top: 14px;">
<h4 style="margin-bottom: 6px; font-size: 0.9rem;">HTTP Header:</h4>
<div class="headers-grid" style="max-height: 200px; overflow-y: auto; background: rgba(0,0,0,0.15); border: 1px solid var(--border); border-radius: var(--radius); padding: 10px;">
${headersHtml || '<div style="color: var(--dim); font-size: 0.8rem;">Keine Header geladen</div>'}
</div>
</div>
${previewHtml}
`;
} catch (error) {
content.innerHTML = `<div class="ops-line is-warning"><strong>Fehler bei Diagnose:</strong><span>${error.message}</span></div>`;
}
}
function closeServiceModal() {
const overlay = document.getElementById('serviceModalOverlay');
if (overlay) {
overlay.setAttribute('hidden', '');
overlay.classList.remove('is-active');
}
}
async function startFolderScan(dirPath) {
const resultsEl = document.getElementById('scannerResults');
const btn = document.getElementById('scannerStartBtn');
if (!resultsEl || !btn) return;
btn.disabled = true;
const originalText = btn.textContent;
btn.textContent = 'Scanne...';
resultsEl.removeAttribute('hidden');
resultsEl.innerHTML = '<div class="ops-line">Scanne Verzeichnis und berechne Größen. Bitte warten...</div>';
try {
const response = await fetch(`/api/disks/scan-folder?path=${encodeURIComponent(dirPath)}`, {
headers: {
Authorization: `Bearer ${tokenSet?.id_token}`
}
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
if (!data.children || data.children.length === 0) {
resultsEl.innerHTML = '<div class="ops-line">Keine Dateien oder Verzeichnisse gefunden oder Zugriff verweigert.</div>';
return;
}
resultsEl.innerHTML = data.children.map((child) => {
const sizeStr = formatBytes(child.size);
const icon = child.isDirectory ? '📁' : child.isLink ? '🔗' : '📄';
const nameColor = child.isDirectory ? '#67e8f9' : '#fff';
let clickAction = '';
if (child.isDirectory && !child.error) {
clickAction = `class="scanner-folder-link" data-path="${child.path}" style="cursor: pointer; color: ${nameColor}; text-decoration: underline;"`;
} else {
clickAction = `style="color: ${nameColor};"`;
}
const errorText = child.error ? ' <span style="color: var(--down); font-size: 0.75rem;">(Fehler beim Lesen)</span>' : '';
return `
<div class="ops-line">
<span ${clickAction}>${icon} ${child.name}${errorText}</span>
<span>${sizeStr}</span>
</div>
`;
}).join('');
resultsEl.querySelectorAll('.scanner-folder-link').forEach((link) => {
link.addEventListener('click', () => {
const nextPath = link.dataset.path;
document.getElementById('scannerPathInput').value = nextPath;
startFolderScan(nextPath);
});
});
} catch (error) {
resultsEl.innerHTML = `<div class="ops-line is-warning"><strong>Scan fehlgeschlagen:</strong><span>${error.message}</span></div>`;
} finally {
btn.disabled = false;
btn.textContent = originalText;
}
}
function initFolderScanner() {
const btn = document.getElementById('scannerStartBtn');
const input = document.getElementById('scannerPathInput');
if (btn && input) {
btn.addEventListener('click', () => {
const val = input.value.trim();
if (val) startFolderScan(val);
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const val = input.value.trim();
if (val) startFolderScan(val);
}
});
}
}
function initModals() {
const logsCloseBtn = document.getElementById('logsModalCloseBtn');
const logsOverlay = document.getElementById('logsModalOverlay');
const logsTailSelect = document.getElementById('logsTailSelect');
const logsRefreshBtn = document.getElementById('logsModalRefreshBtn');
if (logsCloseBtn) {
logsCloseBtn.addEventListener('click', closeLogsModal);
}
if (logsOverlay) {
logsOverlay.addEventListener('click', (e) => {
if (e.target === logsOverlay) closeLogsModal();
});
}
if (logsTailSelect) {
logsTailSelect.addEventListener('change', () => {
if (currentLogsContainer) {
fetchContainerLogs(currentLogsContainer, Number(logsTailSelect.value));
}
});
}
if (logsRefreshBtn) {
logsRefreshBtn.addEventListener('click', () => {
if (currentLogsContainer) {
const tail = logsTailSelect ? Number(logsTailSelect.value) : 150;
fetchContainerLogs(currentLogsContainer, tail);
}
});
}
const serviceCloseBtn = document.getElementById('serviceModalCloseBtn');
const serviceOverlay = document.getElementById('serviceModalOverlay');
if (serviceCloseBtn) {
serviceCloseBtn.addEventListener('click', closeServiceModal);
}
if (serviceOverlay) {
serviceOverlay.addEventListener('click', (e) => {
if (e.target === serviceOverlay) closeServiceModal();
});
}
}
function initCollapsibleCards() { function initCollapsibleCards() {
document.querySelectorAll('.ops-card-head').forEach((head) => { document.querySelectorAll('.ops-card-head').forEach((head) => {
const card = head.closest('.ops-card'); const card = head.closest('.ops-card');
@@ -934,6 +1212,8 @@ async function boot() {
renderEditor(); renderEditor();
initCollapsibleCards(); initCollapsibleCards();
initTabNavigation(); initTabNavigation();
initModals();
initFolderScanner();
refreshOps(); refreshOps();
startOpsAutoRefresh(); startOpsAutoRefresh();
} }
@@ -976,6 +1256,12 @@ if (refreshServicesButton) {
refreshServicesButton.addEventListener('click', () => refreshOps()); refreshServicesButton.addEventListener('click', () => refreshOps());
} }
runWatchtowerButton.addEventListener('click', runWatchtowerOnce); runWatchtowerButton.addEventListener('click', runWatchtowerOnce);
const viewWatchtowerLogsBtn = document.getElementById('viewWatchtowerLogsBtn');
if (viewWatchtowerLogsBtn) {
viewWatchtowerLogsBtn.addEventListener('click', () => {
openLogsModal('watchtower');
});
}
dangerModeButton.addEventListener('click', unlockDangerMode); dangerModeButton.addEventListener('click', unlockDangerMode);
boot().catch((error) => { boot().catch((error) => {

185
server.js
View File

@@ -509,6 +509,127 @@ async function getDisks() {
return disks; return disks;
} }
function isPathAllowed(userPath) {
if (!userPath) return false;
const resolved = path.resolve(userPath);
return resolved.startsWith('/host/volume1') || resolved.startsWith('/host/volume2') || resolved === '/';
}
async function getDirectorySize(dirPath, maxDepth = 3, currentDepth = 0) {
let size = 0;
try {
const stats = await fsp.lstat(dirPath);
if (stats.isSymbolicLink()) return 0;
if (stats.isFile()) {
return stats.size;
}
if (stats.isDirectory()) {
if (currentDepth >= maxDepth) return 0;
const files = await fsp.readdir(dirPath, { withFileTypes: true });
const promises = files.map(async (file) => {
const fullPath = path.join(dirPath, file.name);
if (file.isSymbolicLink()) return 0;
if (file.isDirectory()) {
return getDirectorySize(fullPath, maxDepth, currentDepth + 1);
}
if (file.isFile()) {
try {
const fileStats = await fsp.lstat(fullPath);
return fileStats.size;
} catch {
return 0;
}
}
return 0;
});
const sizes = await Promise.all(promises);
size = sizes.reduce((acc, curr) => acc + curr, 0);
}
} catch (err) {
// Ignore errors
}
return size;
}
async function scanDirectoryChildren(dirPath) {
if (!isPathAllowed(dirPath)) {
throw new Error('Unzulässiger Pfad. Zugriff verweigert.');
}
const files = await fsp.readdir(dirPath, { withFileTypes: true });
const results = [];
const promises = files.map(async (file) => {
const fullPath = path.join(dirPath, file.name);
try {
if (file.isSymbolicLink()) {
results.push({ name: file.name, path: fullPath, isDirectory: false, size: 0, isLink: true });
return;
}
if (file.isDirectory()) {
const dirSize = await getDirectorySize(fullPath, 2);
results.push({ name: file.name, path: fullPath, isDirectory: true, size: dirSize });
} else {
const stats = await fsp.lstat(fullPath);
results.push({ name: file.name, path: fullPath, isDirectory: false, size: stats.size });
}
} catch {
results.push({ name: file.name, path: fullPath, isDirectory: file.isDirectory(), size: 0, error: true });
}
});
await Promise.all(promises);
results.sort((a, b) => b.size - a.size);
return results;
}
function inspectService(urlStr) {
return new Promise((resolve) => {
try {
const parsed = new URL(urlStr);
const client = parsed.protocol === 'https:' ? https : http;
const start = Date.now();
const req = client.get(parsed.href, {
timeout: 4000,
headers: { 'User-Agent': 'MischLabs-Diagnostics/1.0' }
}, (res) => {
const chunks = [];
let bodyLength = 0;
res.on('data', (chunk) => {
if (bodyLength < 2000) {
chunks.push(chunk);
bodyLength += chunk.length;
}
});
res.on('end', () => {
const duration = Date.now() - start;
const bodyPreview = Buffer.concat(chunks).toString('utf8').slice(0, 1000);
resolve({
ok: res.statusCode >= 200 && res.statusCode < 400,
statusCode: res.statusCode,
statusMessage: res.statusMessage,
latency: duration,
headers: res.headers,
bodyPreview: bodyPreview
});
});
});
req.on('timeout', () => {
req.destroy();
resolve({ ok: false, error: 'Timeout nach 4 Sekunden', latency: 4000 });
});
req.on('error', (err) => {
resolve({ ok: false, error: err.message, latency: Date.now() - start });
});
} catch (err) {
resolve({ ok: false, error: err.message, latency: 0 });
}
});
}
function parseWatchtower(lines) { function parseWatchtower(lines) {
const sessions = []; const sessions = [];
let current = null; let current = null;
@@ -828,6 +949,70 @@ const server = http.createServer(async (req, res) => {
return; return;
} }
const containerLogsMatch = req.url?.match(/^\/api\/containers\/([^/?#]+)\/logs(?:[?#].*)?$/);
if (containerLogsMatch) {
if (req.method !== 'GET') {
json(res, 405, { error: 'Method not allowed' });
return;
}
try {
await verifyAdminRequest(req);
const containerName = decodeURIComponent(containerLogsMatch[1]);
const urlParams = new URL(req.url, 'http://localhost').searchParams;
const tail = Number(urlParams.get('tail') || 150);
const logs = await dockerLogs(containerName, tail);
json(res, 200, { name: containerName, logs });
} catch (error) {
json(res, error.statusCode || 500, { error: error.message });
}
return;
}
if (req.url?.startsWith('/api/services/inspect')) {
if (req.method !== 'GET') {
json(res, 405, { error: 'Method not allowed' });
return;
}
try {
await verifyAdminRequest(req);
const urlParams = new URL(req.url, 'http://localhost').searchParams;
const inspectUrl = urlParams.get('url');
if (!inspectUrl) {
json(res, 400, { error: 'Missing url parameter' });
return;
}
const data = await inspectService(inspectUrl);
json(res, 200, data);
} catch (error) {
json(res, error.statusCode || 500, { error: error.message });
}
return;
}
if (req.url?.startsWith('/api/disks/scan-folder')) {
if (req.method !== 'GET') {
json(res, 405, { error: 'Method not allowed' });
return;
}
try {
await verifyAdminRequest(req);
const urlParams = new URL(req.url, 'http://localhost').searchParams;
const scanPath = urlParams.get('path');
if (!scanPath) {
json(res, 400, { error: 'Missing path parameter' });
return;
}
const data = await scanDirectoryChildren(scanPath);
json(res, 200, { path: scanPath, children: data });
} catch (error) {
json(res, error.statusCode || 500, { error: error.message });
}
return;
}
await serveStatic(req, res); await serveStatic(req, res);
}); });

118
style.css
View File

@@ -1437,3 +1437,121 @@ main {
background-color: var(--down); background-color: var(--down);
box-shadow: 0 0 4px rgba(251, 113, 133, 0.3); box-shadow: 0 0 4px rgba(251, 113, 133, 0.3);
} }
/* ==========================================================================
Advanced Diagnostics & Terminal Modals (Premium Elements)
========================================================================== */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(8, 11, 20, 0.75);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
.modal-overlay.is-active {
opacity: 1;
pointer-events: auto;
}
.modal-card {
background: var(--surface-1);
border: 1px solid var(--border-strong);
border-radius: calc(var(--radius) * 1.5);
width: 90%;
max-width: 680px;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5), 0 0 100px rgba(99, 102, 241, 0.05);
transform: translateY(20px) scale(0.96);
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
overflow: hidden;
}
.modal-overlay.is-active .modal-card {
transform: translateY(0) scale(1);
}
.modal-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
background: var(--surface-2);
}
.modal-header h3 {
margin: 0;
font-size: 1.15rem;
font-weight: 700;
color: #fff;
}
.modal-close-btn {
background: none;
border: none;
color: var(--dim);
font-size: 1.6rem;
cursor: pointer;
line-height: 1;
padding: 4px;
transition: color 0.15s ease, transform 0.15s ease;
}
.modal-close-btn:hover {
color: #fff;
transform: scale(1.1);
}
.modal-body {
padding: 20px;
overflow-y: auto;
flex-grow: 1;
}
/* Terminal Console Theme for logs */
.terminal-logs {
background: #090d16;
border: 1px solid #1e293b;
border-radius: var(--radius);
padding: 14px;
color: #38bdf8;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.82rem;
line-height: 1.5;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
max-height: 420px;
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.8);
}
/* Custom folders scanner text sizes & margins */
.scanner-folder-link:hover {
color: #22d3ee !important;
text-shadow: 0 0 8px rgba(34, 211, 238, 0.4);
}
/* Live Inspect highlights */
.text-ok {
color: var(--ok);
}
.text-down {
color: var(--down);
}
.service-inspect-trigger:hover {
background: rgba(255, 255, 255, 0.18) !important;
transform: scale(1.15);
}