diff --git a/Dockerfile b/Dockerfile index ef44c0a..46d1fc0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,9 +46,11 @@ COPY src ./src COPY config.docker.json ./config.docker.json COPY docker-entrypoint.sh ./docker-entrypoint.sh -# Bake git hash into image for version tracking (passed via --build-arg) +# Bake git hash and timestamp into image for version tracking (passed via --build-arg) ARG BUILD_GIT_HASH=unknown RUN echo "${BUILD_GIT_HASH}" > /app/.git-hash +ARG BUILD_GIT_TIMESTAMP=0 +RUN echo "${BUILD_GIT_TIMESTAMP}" > /app/.git-timestamp # Fix Windows CRLF line endings (build context may come from Windows filesystem) RUN sed -i 's/\r$//' ./docker-entrypoint.sh && chmod +x ./docker-entrypoint.sh diff --git a/public/index.html b/public/index.html index f5bc81d..67c0477 100644 --- a/public/index.html +++ b/public/index.html @@ -243,6 +243,31 @@

Watcher Status

} } + async function stopSpecificWatcher(watcherId, btn) { + btn.disabled = true; + btn.textContent = 'Stopping...'; + try { + await fetch('/api/watcher/stop', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workspace: WorkspaceContext.active, watcherId }) + }); + // Poll until this watcher disappears (up to 20s) + for (let i = 0; i < 10; i++) { + await new Promise(r => setTimeout(r, 2000)); + const sr = await WorkspaceContext.serviceFetch('/watcher-status'); + const sd = await sr.json(); + const still = (sd.watchers || []).find(w => w.watcherId === watcherId && w.status === 'active'); + if (!still) { loadWatcher(); loadOverview(); return; } + } + btn.textContent = 'Timed out'; + setTimeout(() => { btn.textContent = 'Stop'; btn.disabled = false; }, 3000); + } catch (err) { + btn.textContent = 'Error'; + setTimeout(() => { btn.textContent = 'Stop'; btn.disabled = false; }, 3000); + } + } + async function loadWatcher() { const watcherEl = document.getElementById('watcher'); try { @@ -275,7 +300,26 @@

Watcher Status

Errors${w.counters?.errorsCount || 0}
Last ingest${lastIngest}
Next reconcile${nextReconcile}
- ${data.watchers.filter(x => x.status === 'active').length > 1 ? `
Warning${data.watchers.filter(x => x.status === 'active').length} watchers connected
` : ''} +
+ + +
+ ${activeWatchers.length > 1 ? ` +
Warning${activeWatchers.length} watchers connected
+
+
Active watchers — stop duplicates to avoid redundant indexing:
+ ${activeWatchers.map(aw => { + const id = aw.watcherId || 'unknown'; + const started = aw.startedAt ? formatRelative(aw.startedAt) : '?'; + const files = (aw.counters?.filesIngested || 0).toLocaleString(); + return `
+ ${esc(id)} + started ${started} · ${files} files + +
`; + }).join('')} +
+ ` : ''} `; } else { watcherEl.innerHTML = ` @@ -420,17 +464,33 @@

Watcher Status

} // Version mismatch detection + const serviceGitTimestamp = healthData?.gitTimestamp || 0; + const watcherGitTimestamp = w?.gitTimestamp || 0; const mismatch = serviceGitHash && watcherGitHash && serviceGitHash !== 'unknown' && watcherGitHash !== 'unknown' && serviceGitHash !== watcherGitHash; if (mismatch) { + let message, actionHtml; + if (serviceGitTimestamp && watcherGitTimestamp && serviceGitTimestamp !== watcherGitTimestamp) { + if (serviceGitTimestamp < watcherGitTimestamp) { + message = 'Service is outdated'; + actionHtml = ''; + } else { + message = 'Watcher is outdated'; + actionHtml = ''; + } + } else { + message = 'Version mismatch'; + actionHtml = ` + + `; + } versionAlert.classList.add('visible'); document.getElementById('version-alert-hashes').textContent = `Service (${serviceGitHash}) \u00b7 Watcher (${watcherGitHash})`; - const actionsEl = document.getElementById('version-alert-actions'); - actionsEl.innerHTML = ` - - `; + document.querySelector('.version-alert-header .label').textContent = message; + document.getElementById('version-alert-actions').innerHTML = + actionHtml + ''; } else { versionAlert.classList.remove('visible'); } @@ -464,32 +524,54 @@

Watcher Status

} async function stopAndRestartWatcher(btn) { - const status = document.getElementById('version-alert-status'); + // Find the nearest status element — works from version alert or watcher card + const status = document.getElementById('version-alert-status') + || document.getElementById('watcher-restart-status') + || btn.parentElement?.querySelector('span'); + const setStatus = msg => { if (status) status.textContent = msg; }; btn.disabled = true; btn.textContent = 'Stopping watcher...'; - status.textContent = ''; + setStatus(''); try { - await fetch('/api/watcher/stop', { + // Stop via setup-gui (uses direct PID kill — instant, no heartbeat delay) + const stopResp = await fetch('/api/watcher/stop', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workspace: WorkspaceContext.active }) }); - status.textContent = 'Waiting for watcher to stop...'; - let stopped = false; - for (let i = 0; i < 20; i++) { - await new Promise(r => setTimeout(r, 1000)); - const sr = await WorkspaceContext.serviceFetch('/watcher-status'); - const sd = await sr.json(); - if (!sd.hasActiveWatcher) { stopped = true; break; } - } - if (!stopped) { - status.textContent = 'Watcher did not stop in time. Try closing it manually.'; - btn.textContent = 'Restart Watcher'; - btn.disabled = false; - return; + const stopData = await stopResp.json(); + const wasKilled = stopData.method === 'pid-kill'; + + // Brief wait for process to die (PID kill is ~instant, heartbeat needs longer) + const waitMs = wasKilled ? 1000 : 5000; + setStatus(wasKilled ? 'Process killed, starting new watcher...' : 'Waiting for watcher to stop...'); + await new Promise(r => setTimeout(r, waitMs)); + + // If heartbeat-based, poll until the old watcher is gone + if (!wasKilled) { + let stopped = false; + for (let i = 0; i < 15; i++) { + try { + const sr = await WorkspaceContext.serviceFetch('/watcher-status'); + const sd = await sr.json(); + if (!sd.hasActiveWatcher) { stopped = true; break; } + } catch { + // Service unreachable — watcher is likely already gone + stopped = true; break; + } + await new Promise(r => setTimeout(r, 1000)); + } + if (!stopped) { + setStatus('Watcher did not stop in time. Try closing it manually.'); + btn.textContent = 'Restart Watcher'; + btn.disabled = false; + return; + } } + + // Start a new watcher btn.textContent = 'Starting watcher...'; - status.textContent = ''; + setStatus(''); const startResp = await fetch('/api/watcher/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -497,23 +579,27 @@

Watcher Status

}); if (!startResp.ok) { const err = await startResp.json(); - status.textContent = err.error || 'Failed to start watcher'; + setStatus(err.error || 'Failed to start watcher'); btn.textContent = 'Restart Watcher'; btn.disabled = false; return; } - status.textContent = 'Waiting for new watcher...'; + setStatus('Waiting for new watcher to connect...'); for (let i = 0; i < 30; i++) { await new Promise(r => setTimeout(r, 2000)); - const sr = await WorkspaceContext.serviceFetch('/watcher-status'); - const sd = await sr.json(); - if (sd.hasActiveWatcher) { loadAllWithOverview(); return; } + try { + const sr = await WorkspaceContext.serviceFetch('/watcher-status'); + const sd = await sr.json(); + if (sd.hasActiveWatcher) { loadAllWithOverview(); return; } + } catch { + // Service might still be starting — keep polling + } } - status.textContent = 'Watcher started but still reconciling. Refresh shortly.'; + setStatus('Watcher started but still connecting. Refresh shortly.'); btn.textContent = 'Restart Watcher'; btn.disabled = false; } catch (err) { - status.textContent = 'Error: ' + err.message; + setStatus('Error: ' + err.message); btn.textContent = 'Restart Watcher'; btn.disabled = false; } @@ -608,6 +694,75 @@

Watcher Status

}, 2000); } + async function overviewRebuildAndRestart(btn) { + const status = document.getElementById('version-alert-status'); + btn.disabled = true; + btn.textContent = 'Rebuilding...'; + if (status) status.textContent = ''; + try { + // Step 1: Trigger Docker rebuild via SSE + const buildResp = await new Promise((resolve, reject) => { + const evtSource = new EventSource('/api/docker/build'); + let lastLine = ''; + evtSource.addEventListener('output', e => { + const data = JSON.parse(e.data); + lastLine = data.line; + if (status) status.textContent = lastLine; + }); + evtSource.addEventListener('done', e => { + const data = JSON.parse(e.data); + evtSource.close(); + resolve(data); + }); + evtSource.addEventListener('error', e => { + evtSource.close(); + reject(new Error('Build stream error')); + }); + evtSource.onerror = () => { evtSource.close(); reject(new Error('Build connection lost')); }; + }); + if (buildResp.code !== 0) { + if (status) status.textContent = 'Build failed (exit code ' + buildResp.code + ')'; + btn.textContent = 'Rebuild & Restart Service'; + btn.disabled = false; + return; + } + // Step 2: Restart container + btn.textContent = 'Restarting...'; + if (status) status.textContent = 'Restarting container...'; + const startResp = await fetch('/api/docker/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workspace: WorkspaceContext.active }) + }); + if (!startResp.ok) { + const err = await startResp.json(); + if (status) status.textContent = err.error || 'Container restart failed'; + btn.textContent = 'Rebuild & Restart Service'; + btn.disabled = false; + return; + } + // Step 3: Wait for service to come back + if (status) status.textContent = 'Waiting for service...'; + for (let i = 0; i < 30; i++) { + await new Promise(r => setTimeout(r, 2000)); + try { + const resp = await WorkspaceContext.serviceFetch('/health'); + if (resp.ok) { + loadAllWithOverview(); + return; + } + } catch {} + } + if (status) status.textContent = 'Service did not come back. Check Docker logs.'; + btn.textContent = 'Rebuild & Restart Service'; + btn.disabled = false; + } catch (err) { + if (status) status.textContent = 'Error: ' + err.message; + btn.textContent = 'Rebuild & Restart Service'; + btn.disabled = false; + } + } + // --- Projects card (merged config + stats + freshness) --- async function loadConfig() { diff --git a/public/setup.html b/public/setup.html index cb864ce..8ec0f72 100644 --- a/public/setup.html +++ b/public/setup.html @@ -92,6 +92,17 @@ .error-msg { color: #f44747; font-size: 13px; margin-top: 4px; } .success-msg { color: #4ec9b0; font-size: 13px; margin-top: 4px; } .info-msg { color: #808080; font-size: 12px; margin-top: 4px; } + + .watcher-status { margin-top: 8px; padding: 8px 12px; background: #1e1e1e; border: 1px solid #3e3e3e; border-radius: 4px; font-size: 12px; } + .watcher-status .ws-label { color: #808080; margin-bottom: 4px; } + .watcher-status .ws-label .dot { vertical-align: middle; } + .watcher-progress-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-family: 'Cascadia Code', 'Consolas', monospace; } + .watcher-progress-row .proj-name { width: 140px; color: #d4d4d4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .watcher-progress-bar { flex: 1; height: 10px; background: #2d2d2d; border-radius: 3px; overflow: hidden; max-width: 200px; } + .watcher-progress-bar .fill { height: 100%; background: #4ec9b0; border-radius: 3px; transition: width 0.4s ease; } + .watcher-progress-pct { width: 50px; text-align: right; color: #9cdcfe; } + .watcher-progress-count { color: #808080; } + .watcher-progress-done { color: #4ec9b0; } @@ -408,6 +419,9 @@

Install Hooks

+
+
Watcher: checking...
+