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
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 13s
This commit is contained in:
57
admin.html
57
admin.html
@@ -6,7 +6,7 @@
|
||||
<title>MischLabs Admin</title>
|
||||
<meta name="description" content="MischLabs dashboard administration.">
|
||||
<meta name="theme-color" content="#0f172a">
|
||||
<link rel="stylesheet" href="/style.css?v=24">
|
||||
<link rel="stylesheet" href="/style.css?v=25">
|
||||
</head>
|
||||
<body>
|
||||
<header class="shell hero admin-hero">
|
||||
@@ -101,12 +101,27 @@
|
||||
<button class="ghost-button mini" id="refreshOpsButton" type="button">Aktualisieren</button>
|
||||
</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 class="ops-card admin-tab-content" id="card-watchtower">
|
||||
<div class="ops-card-head">
|
||||
<span>Watchtower</span>
|
||||
<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 id="watchtowerStatus" class="ops-list">Noch nicht geladen.</div>
|
||||
</section>
|
||||
@@ -182,6 +197,44 @@
|
||||
</nav>
|
||||
</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">×</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">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="serviceModalContent">
|
||||
<!-- Dynamically populated details -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/admin.js?v=21" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
288
admin.js
288
admin.js
@@ -387,7 +387,10 @@ function renderServicesStatus(services = []) {
|
||||
|
||||
return `
|
||||
<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-inner"></div>
|
||||
</div>
|
||||
@@ -395,6 +398,16 @@ function renderServicesStatus(services = []) {
|
||||
`;
|
||||
}).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');
|
||||
if (miniServices) {
|
||||
let ledHtml = itemsToRender.map(item => {
|
||||
@@ -536,12 +549,21 @@ function renderContainers(containers) {
|
||||
<div class="ops-line container-line" data-container="${container.name}">
|
||||
<strong>${container.name}</strong>
|
||||
<span>${container.status}</span>
|
||||
<div style="display: flex; gap: 6px;">
|
||||
<button class="ghost-button mini view-container-logs" type="button">Logs</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>
|
||||
`).join('');
|
||||
|
||||
containerStatus.querySelectorAll('.view-container-logs').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
openLogsModal(button.closest('.container-line').dataset.container);
|
||||
});
|
||||
});
|
||||
|
||||
containerStatus.querySelectorAll('.restart-container').forEach((button) => {
|
||||
button.addEventListener('click', () => restartContainer(button.closest('.container-line')));
|
||||
});
|
||||
@@ -786,6 +808,262 @@ function startOpsAutoRefresh() {
|
||||
}, 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
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() {
|
||||
document.querySelectorAll('.ops-card-head').forEach((head) => {
|
||||
const card = head.closest('.ops-card');
|
||||
@@ -934,6 +1212,8 @@ async function boot() {
|
||||
renderEditor();
|
||||
initCollapsibleCards();
|
||||
initTabNavigation();
|
||||
initModals();
|
||||
initFolderScanner();
|
||||
refreshOps();
|
||||
startOpsAutoRefresh();
|
||||
}
|
||||
@@ -976,6 +1256,12 @@ if (refreshServicesButton) {
|
||||
refreshServicesButton.addEventListener('click', () => refreshOps());
|
||||
}
|
||||
runWatchtowerButton.addEventListener('click', runWatchtowerOnce);
|
||||
const viewWatchtowerLogsBtn = document.getElementById('viewWatchtowerLogsBtn');
|
||||
if (viewWatchtowerLogsBtn) {
|
||||
viewWatchtowerLogsBtn.addEventListener('click', () => {
|
||||
openLogsModal('watchtower');
|
||||
});
|
||||
}
|
||||
dangerModeButton.addEventListener('click', unlockDangerMode);
|
||||
|
||||
boot().catch((error) => {
|
||||
|
||||
185
server.js
185
server.js
@@ -509,6 +509,127 @@ async function getDisks() {
|
||||
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) {
|
||||
const sessions = [];
|
||||
let current = null;
|
||||
@@ -828,6 +949,70 @@ const server = http.createServer(async (req, res) => {
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
118
style.css
118
style.css
@@ -1437,3 +1437,121 @@ main {
|
||||
background-color: var(--down);
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user