diff --git a/admin.html b/admin.html index 0e71eee..9a55cfe 100644 --- a/admin.html +++ b/admin.html @@ -81,6 +81,6 @@ - + diff --git a/admin.js b/admin.js index cc85914..3232812 100644 --- a/admin.js +++ b/admin.js @@ -27,6 +27,8 @@ const containerStatus = document.querySelector('#containerStatus'); let config = null; let tokenSet = null; let opsRefreshTimer = null; +let latestWatchtower = null; +let lastManualWatchtowerRun = null; function base64UrlEncode(buffer) { return btoa(String.fromCharCode(...new Uint8Array(buffer))) @@ -312,6 +314,23 @@ function renderDisks(disks = []) { } function renderWatchtower(watchtower) { + latestWatchtower = watchtower; + if (lastManualWatchtowerRun) { + const run = lastManualWatchtowerRun; + const session = run.session; + const finished = run.finishedAt ? new Date(run.finishedAt).toLocaleString('de-DE') : 'laeuft noch'; + const state = run.running ? 'Laeuft' : `Beendet (${run.exitCode ?? '--'})`; + watchtowerStatus.innerHTML = ` +
Manueller Lauf${state}
+
Gestartet${run.startedAt ? new Date(run.startedAt).toLocaleString('de-DE') : '--'}
+
Fertig${finished}
+
Geprueft${session?.scanned ?? '--'}
+
Aktualisiert${session?.updated ?? '--'}
+
Fehler${session?.failed ?? (run.exitCode ? 1 : 0)}
+ `; + return; + } + const session = watchtower?.lastSession; if (!session) { const tail = watchtower?.tail?.slice(-4)?.join(' | '); @@ -424,12 +443,20 @@ async function runWatchtowerOnce() { const payload = await response.json().catch(() => ({})); if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`); + lastManualWatchtowerRun = { + id: payload.fullId || payload.id, + name: payload.name, + running: true, + startedAt: payload.startedAt, + session: null, + tail: [] + }; watchtowerStatus.innerHTML = `
Manueller LaufGestartet
Container${payload.name}
`; window.clearTimeout(opsRefreshTimer); - opsRefreshTimer = window.setTimeout(refreshOps, 25000); + pollWatchtowerRun(payload.fullId || payload.id); } catch (error) { watchtowerStatus.textContent = `Watchtower konnte nicht gestartet werden: ${error.message}`; } finally { @@ -440,6 +467,32 @@ async function runWatchtowerOnce() { } } +async function pollWatchtowerRun(runId, attempt = 0) { + try { + const cleanup = attempt > 0 ? '&cleanup=1' : ''; + const response = await fetch(`/api/watchtower/runs/${encodeURIComponent(runId)}?${cleanup}`, { + cache: 'no-store', + headers: { + Authorization: `Bearer ${tokenSet.id_token}` + } + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`); + + lastManualWatchtowerRun = payload; + renderWatchtower(latestWatchtower); + + if (payload.running && attempt < 36) { + opsRefreshTimer = window.setTimeout(() => pollWatchtowerRun(runId, attempt + 1), 5000); + return; + } + + opsRefreshTimer = window.setTimeout(refreshOps, 4000); + } catch (error) { + watchtowerStatus.textContent = `Manueller Watchtower-Lauf nicht lesbar: ${error.message}`; + } +} + async function refreshOps() { diskStatus.textContent = 'Lade...'; watchtowerStatus.textContent = 'Lade...'; diff --git a/server.js b/server.js index 550cefb..50701db 100644 --- a/server.js +++ b/server.js @@ -297,7 +297,7 @@ async function runWatchtowerOnce() { 'com.centurylinklabs.watchtower.enable': 'false' }, HostConfig: { - AutoRemove: true, + AutoRemove: false, Binds: [ '/var/run/docker.sock:/var/run/docker.sock', `${WATCHTOWER_CONFIG_PATH}:/config.json:ro` @@ -317,12 +317,61 @@ async function runWatchtowerOnce() { await dockerRequest(`/containers/${created.Id}/start`, { method: 'POST' }); return { id: created.Id.slice(0, 12), + fullId: created.Id, name, image: WATCHTOWER_RUN_IMAGE, startedAt: new Date().toISOString() }; } +async function removeContainer(id) { + await dockerRequest(`/containers/${encodeURIComponent(id)}?force=1`, { method: 'DELETE' }); +} + +async function getWatchtowerRun(runId, cleanup = false) { + const id = normalizeContainerName(runId); + if (!id || !/^[a-fA-F0-9]{12,64}$/.test(id)) { + const error = new Error('Ungueltige Watchtower-Run-ID.'); + error.statusCode = 400; + throw error; + } + + const [inspect, logs] = await Promise.all([ + dockerRequest(`/containers/${encodeURIComponent(id)}/json`), + dockerLogs(id, 260).catch((error) => [`watchtower run logs unavailable: ${error.message}`]) + ]); + + const labels = inspect.Config?.Labels || {}; + if (labels['com.mischlabs.role'] !== 'manual-watchtower-run') { + const error = new Error('Container gehoert nicht zu manuellen Watchtower-Laeufen.'); + error.statusCode = 403; + throw error; + } + + const state = inspect.State || {}; + const parsed = Array.isArray(logs) ? parseWatchtower(logs) : null; + const payload = { + id: inspect.Id.slice(0, 12), + fullId: inspect.Id, + name: normalizeContainerName(inspect.Name), + image: inspect.Config?.Image || WATCHTOWER_RUN_IMAGE, + state: state.Status || 'unknown', + running: Boolean(state.Running), + exitCode: state.ExitCode, + startedAt: state.StartedAt || null, + finishedAt: state.FinishedAt && !state.FinishedAt.startsWith('0001-') ? state.FinishedAt : null, + session: parsed, + tail: Array.isArray(logs) ? logs.slice(-20) : logs + }; + + if (cleanup && !payload.running) { + removeContainer(inspect.Id).catch(() => {}); + payload.cleanedUp = true; + } + + return payload; +} + async function getDisks() { const disks = []; @@ -549,6 +598,24 @@ const server = http.createServer(async (req, res) => { return; } + const watchtowerRunMatch = req.url?.match(/^\/api\/watchtower\/runs\/([^/?#]+)(?:[?#].*)?$/); + if (watchtowerRunMatch) { + if (req.method !== 'GET') { + json(res, 405, { error: 'Method not allowed' }); + return; + } + + try { + await verifyAdminRequest(req); + const requestUrl = new URL(req.url, `http://${req.headers.host}`); + const cleanup = requestUrl.searchParams.get('cleanup') === '1'; + json(res, 200, await getWatchtowerRun(decodeURIComponent(watchtowerRunMatch[1]), cleanup)); + } catch (error) { + json(res, error.statusCode || 500, { error: error.message }); + } + return; + } + await serveStatic(req, res); });