adding a wizard for setup

This commit is contained in:
Ace
2026-06-26 12:08:32 +02:00
parent a4d2ad143b
commit 5feb95187c
3 changed files with 605 additions and 54 deletions
+139
View File
@@ -1,6 +1,7 @@
import http from "node:http" import http from "node:http"
import fs from "node:fs" import fs from "node:fs"
import path from "node:path" import path from "node:path"
import { spawn } from "node:child_process"
import { fileURLToPath } from "node:url" import { fileURLToPath } from "node:url"
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -34,6 +35,10 @@ const DATABASE_URL = process.env.DATABASE_URL
const JOBS_PATH = process.env.JOBS_PATH || path.join(ROOT, "data", "ai", "jobs.jsonl") const JOBS_PATH = process.env.JOBS_PATH || path.join(ROOT, "data", "ai", "jobs.jsonl")
const AI_MD_PATH = process.env.AI_MD_PATH || path.join(ROOT, "data", "ai", "ai.md") const AI_MD_PATH = process.env.AI_MD_PATH || path.join(ROOT, "data", "ai", "ai.md")
// ── Setup state ──────────────────────────────────────────────────
let pullProcess = null
let pullProgress = { status: "idle", progress: 0, message: "" }
// ── Job loading ───────────────────────────────────────────────── // ── Job loading ─────────────────────────────────────────────────
function loadJobs() { function loadJobs() {
try { try {
@@ -297,6 +302,140 @@ const server = http.createServer(async (req, res) => {
return sendJSON(res, 200, results) return sendJSON(res, 200, results)
} }
// ── Setup endpoints ─────────────────────────────────────────
// GET /setup/status — check environment
if (req.method === "GET" && pathname === "/setup/status") {
const envExists = fs.existsSync(path.join(ROOT, ".env.local"))
// Ollama check
let ollamaRunning = false
try {
await fetch(`${OLLAMA_URL}/api/tags`, { signal: AbortSignal.timeout(3000) })
ollamaRunning = true
} catch {}
// Model check
let modelAvailable = false
if (ollamaRunning) {
try {
const r = await fetch(`${OLLAMA_URL}/api/show`, {
method: "POST", body: JSON.stringify({ name: MODEL }),
signal: AbortSignal.timeout(5000),
})
modelAvailable = r.ok
} catch {}
}
// Profile auto-detect
let profilePath = process.env.FX_PROFILE || ""
let profileDetected = !!profilePath
if (!profileDetected) {
try {
const r = await fetch("http://127.0.0.1:3008/health", { signal: AbortSignal.timeout(2000) })
if (r.ok) {
const diag = await (await fetch("http://127.0.0.1:3008/setup/profile", { signal: AbortSignal.timeout(5000) })).json()
if (diag.path) { profilePath = diag.path; profileDetected = true }
}
} catch {}
}
// Login check
let facebookLoggedIn = false
if (profileDetected) {
try {
const r = await fetch("http://127.0.0.1:3008/setup/check-login", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ profile_path: profilePath }),
signal: AbortSignal.timeout(15000),
})
if (r.ok) { const d = await r.json(); facebookLoggedIn = d.logged_in === true }
} catch {}
}
const firstRun = !envExists || !ollamaRunning || !profileDetected || !modelAvailable
return sendJSON(res, 200, {
first_run: firstRun,
env_exists: envExists,
ollama_running: ollamaRunning,
model_available: modelAvailable,
model_name: MODEL,
profile_detected: profileDetected,
profile_path: profilePath || null,
facebook_logged_in: facebookLoggedIn,
})
}
// POST /setup/profile — save profile path to .env.local
if (req.method === "POST" && pathname === "/setup/profile") {
const body = await parseBody(req)
const profilePath = (body.path || "").trim()
if (!profilePath) return sendJSON(res, 400, { error: "Path required" })
if (!fs.existsSync(profilePath)) return sendJSON(res, 400, { error: "Path does not exist" })
const envPath = path.join(ROOT, ".env.local")
let content = ""
try { content = fs.readFileSync(envPath, "utf-8") } catch {}
const lines = content.split("\n")
let found = false
for (let i = 0; i < lines.length; i++) {
if (lines[i].trim().startsWith("FX_PROFILE=")) {
lines[i] = `FX_PROFILE=${profilePath}`
found = true
break
}
}
if (!found) lines.push(`FX_PROFILE=${profilePath}`)
fs.writeFileSync(envPath, lines.join("\n"), "utf-8")
process.env.FX_PROFILE = profilePath
return sendJSON(res, 200, { success: true, path: profilePath })
}
// POST /setup/check-login — verify Facebook login in the given profile
if (req.method === "POST" && pathname === "/setup/check-login") {
const body = await parseBody(req)
const profilePath = body.profile_path || process.env.FX_PROFILE || ""
if (!profilePath) return sendJSON(res, 200, { logged_in: false, reason: "no_profile" })
try {
const r = await fetch("http://127.0.0.1:3008/setup/check-login", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ profile_path: profilePath }),
signal: AbortSignal.timeout(20000),
})
if (r.ok) { const d = await r.json(); return sendJSON(res, 200, d) }
} catch {}
return sendJSON(res, 200, { logged_in: false, reason: "scraper_unavailable" })
}
// POST /setup/ollama/pull — start pulling the model
if (req.method === "POST" && pathname === "/setup/ollama/pull") {
if (pullProcess) return sendJSON(res, 200, { status: "already_running" })
pullProgress = { status: "downloading", progress: 0, message: "Starting..." }
const isWin = process.platform === "win32"
const cmd = isWin ? "ollama.exe" : "ollama"
pullProcess = spawn(cmd, ["pull", MODEL], { stdio: ["ignore", "pipe", "pipe"] })
pullProcess.stdout.on("data", (data) => {
const text = data.toString()
pullProgress.message = text.trim()
// Extract percentage from patterns like "pulling xxxx... 45%"
const m = text.match(/(\d+)%/)
if (m) pullProgress.progress = parseInt(m[1], 10)
})
pullProcess.on("close", (code) => {
pullProcess = null
pullProgress.status = code === 0 ? "done" : "failed"
if (code === 0) pullProgress.progress = 100
})
return sendJSON(res, 200, { status: "started" })
}
// GET /setup/ollama/pull/progress
if (req.method === "GET" && pathname === "/setup/ollama/pull/progress") {
return sendJSON(res, 200, pullProgress)
}
// GET /ai/jobs // GET /ai/jobs
if (req.method === "GET" && pathname === "/ai/jobs") { if (req.method === "GET" && pathname === "/ai/jobs") {
return sendJSON(res, 200, { jobs: loadedJobs }) return sendJSON(res, 200, { jobs: loadedJobs })
+30
View File
@@ -1105,6 +1105,36 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
async def health(): async def health():
return {"status": "ok"} return {"status": "ok"}
@app.get("/setup/profile")
async def setup_profile():
"""Auto-detect Firefox profile path."""
path = FX_PROFILE or find_firefox_profile()
return {"path": path, "detected": bool(path)}
@app.post("/setup/check-login")
async def setup_check_login(body: dict):
"""Check if Facebook is logged in using the given profile."""
profile_path = (body.get("profile_path") or "").strip() or FX_PROFILE or find_firefox_profile()
if not profile_path:
return {"logged_in": False, "reason": "no_profile", "error": "No Firefox profile found"}
try:
profile_dir = copy_firefox_profile(profile_path)
async with async_playwright() as pw:
context = await pw.firefox.launch_persistent_context(
user_data_dir=profile_dir, headless=True,
firefox_user_prefs={"dom.webdriver.enabled": False},
)
page = context.pages[0] if context.pages else await context.new_page()
await page.goto("https://www.facebook.com/", wait_until="domcontentloaded", timeout=30000)
await page.wait_for_timeout(3000)
logged_in = "/login" not in page.url.lower()
await context.close()
shutil.rmtree(profile_dir, ignore_errors=True)
return {"logged_in": logged_in, "reason": None if logged_in else "login_page"}
except Exception as e:
logger.error("Setup check-login failed: %s", e)
return {"logged_in": False, "reason": "error", "error": str(e)}
@app.post("/agent/run") @app.post("/agent/run")
async def agent_run(task: str = Body(..., embed=True)): async def agent_run(task: str = Body(..., embed=True)):
"""Run a browser-use Agent with ChatOllama (free/local).""" """Run a browser-use Agent with ChatOllama (free/local)."""
+436 -54
View File
@@ -293,6 +293,125 @@
.retry-btn.active { .retry-btn.active {
display: inline-block; display: inline-block;
} }
/* ── Setup Wizard ──────────────────────────────────────────── */
.setup-wizard {
display: none;
position: fixed;
inset: 0;
background: #0a0a1a;
z-index: 200;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
overflow-y: auto;
}
.setup-wizard.active { display: flex; }
.setup-step {
display: none;
flex-direction: column;
align-items: center;
max-width: 520px;
width: 100%;
animation: fadeIn 0.3s ease-out;
}
.setup-step.active { display: flex; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
.setup-steps {
display: flex;
gap: 8px;
margin-bottom: 32px;
}
.setup-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: rgba(255,255,255,0.15);
transition: all 0.3s;
}
.setup-dot.done { background: #22d3ee; box-shadow: 0 0 6px rgba(34,211,238,0.5); }
.setup-dot.current { background: #6366f1; box-shadow: 0 0 6px rgba(99,102,241,0.5); }
.setup-title { font-size: 24px; font-weight: 700; margin-bottom: 8px; }
.setup-subtitle { font-size: 14px; opacity: 0.5; margin-bottom: 28px; text-align: center; }
.setup-card {
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 12px;
padding: 20px;
width: 100%;
margin-bottom: 20px;
}
.check-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid rgba(255,255,255,0.04);
}
.check-row:last-child { border-bottom: none; }
.check-icon { font-size: 18px; min-width: 24px; text-align: center; }
.check-label { flex: 1; font-size: 14px; }
.check-value { font-size: 12px; opacity: 0.5; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.setup-input {
width: 100%;
padding: 10px 14px;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 8px;
color: #fff;
font-size: 14px;
font-family: inherit;
outline: none;
transition: border-color 0.2s;
}
.setup-input:focus { border-color: #6366f1; }
.setup-btn {
padding: 10px 28px;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
border: none;
border-radius: 8px;
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.setup-btn:hover { opacity: 0.85; }
.setup-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.setup-btn.outline {
background: transparent;
border: 1px solid rgba(255,255,255,0.15);
}
.setup-btn.outline:hover { background: rgba(255,255,255,0.06); }
.setup-btns { display: flex; gap: 12px; margin-top: 8px; }
.progress-bar {
width: 100%;
height: 8px;
background: rgba(255,255,255,0.08);
border-radius: 4px;
overflow: hidden;
margin: 12px 0;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #6366f1, #22d3ee);
border-radius: 4px;
transition: width 0.5s ease;
width: 0%;
}
.status-ok { color: #22d3ee; }
.status-err { color: #ef4444; }
.status-warn { color: #f59e0b; }
</style> </style>
</head> </head>
<body> <body>
@@ -354,100 +473,363 @@
<div class="error-msg" id="error-msg"></div> <div class="error-msg" id="error-msg"></div>
<button class="retry-btn" id="retry-btn" onclick="location.reload()">Try Again</button> <button class="retry-btn" id="retry-btn" onclick="location.reload()">Try Again</button>
<!-- ── Setup Wizard ── -->
<div class="setup-wizard" id="setup-wizard">
<div class="setup-steps">
<div class="setup-dot current" id="sdot-1"></div>
<div class="setup-dot" id="sdot-2"></div>
<div class="setup-dot" id="sdot-3"></div>
<div class="setup-dot" id="sdot-4"></div>
<div class="setup-dot" id="sdot-5"></div>
</div>
<!-- Step 1: Welcome -->
<div class="setup-step active" id="sstep-1">
<div class="robot" style="margin-bottom:20px">
<div class="robot-antenna"></div>
<div class="robot-head"><div class="robot-eye left"></div><div class="robot-eye right"></div></div>
<div class="robot-body">
<div class="robot-arm left"></div><div class="robot-arm right"></div>
<div class="robot-leg left"></div><div class="robot-leg right"></div>
</div>
</div>
<div class="setup-title">Welcome to CoastIT CRM</div>
<div class="setup-subtitle">Let's check your environment before we start</div>
<div class="setup-card" id="env-checks">
<div class="check-row"><span class="check-icon" id="env-ollama"></span><span class="check-label">Ollama</span><span class="check-value" id="env-ollama-val">checking...</span></div>
<div class="check-row"><span class="check-icon" id="env-model"></span><span class="check-label">AI Model</span><span class="check-value" id="env-model-val">checking...</span></div>
<div class="check-row"><span class="check-icon" id="env-profile"></span><span class="check-label">Browser Profile</span><span class="check-value" id="env-profile-val">checking...</span></div>
<div class="check-row"><span class="check-icon" id="env-fb"></span><span class="check-label">Facebook Login</span><span class="check-value" id="env-fb-val">checking...</span></div>
</div>
<button class="setup-btn" id="welcome-next" onclick="goStep(2)">Next →</button>
</div>
<!-- Step 2: Browser Profile -->
<div class="setup-step" id="sstep-2">
<div class="setup-title">Browser Profile</div>
<div class="setup-subtitle">We need a Firefox or Chrome profile with Facebook logged in</div>
<div class="setup-card">
<div class="check-row">
<span class="check-icon" id="prof-auto-icon"></span>
<span class="check-label">Auto-detected</span>
<span class="check-value" id="prof-auto-val">scanning...</span>
</div>
<div style="margin-top:14px;font-size:13px;opacity:0.5;margin-bottom:8px">Or enter the path manually:</div>
<input class="setup-input" id="prof-input" placeholder="C:\Users\You\AppData\Roaming\Mozilla\Firefox\Profiles\..." oninput="onProfileInput()">
<div style="margin-top:6px;font-size:12px;color:#22d3ee" id="prof-feedback"></div>
</div>
<div class="setup-btns">
<button class="setup-btn outline" onclick="goStep(1)">← Back</button>
<button class="setup-btn" id="prof-next" onclick="saveProfile()" disabled>Next →</button>
</div>
</div>
<!-- Step 3: Facebook Login -->
<div class="setup-step" id="sstep-3">
<div class="setup-title">Facebook Login</div>
<div class="setup-subtitle">Make sure you're logged into Facebook in your browser</div>
<div class="setup-card" style="text-align:center">
<div style="font-size:48px;margin-bottom:12px" id="fb-icon">🔍</div>
<div style="font-size:14px;opacity:0.7" id="fb-status">Click "Check Login" to verify</div>
<div class="progress-bar" id="fb-progress" style="display:none;margin-top:16px"><div class="progress-fill" id="fb-progress-fill"></div></div>
</div>
<div class="setup-btns">
<button class="setup-btn outline" onclick="goStep(2)">← Back</button>
<button class="setup-btn" id="fb-check-btn" onclick="checkFacebookLogin()">Check Login</button>
</div>
</div>
<!-- Step 4: Ollama Model -->
<div class="setup-step" id="sstep-4">
<div class="setup-title">AI Model</div>
<div class="setup-subtitle">We need to pull the AI model for the scraper to work</div>
<div class="setup-card" style="text-align:center">
<div style="font-size:14px;margin-bottom:8px;font-weight:600" id="model-name">dolphin-llama3:8b</div>
<div class="progress-bar"><div class="progress-fill" id="model-progress-fill"></div></div>
<div style="font-size:12px;opacity:0.5;margin-top:6px" id="model-status">Not downloaded yet</div>
</div>
<div class="setup-btns">
<button class="setup-btn outline" onclick="goStep(3)">← Back</button>
<button class="setup-btn" id="model-pull-btn" onclick="pullModel()">Pull Model</button>
</div>
</div>
<!-- Step 5: Done -->
<div class="setup-step" id="sstep-5">
<div style="font-size:64px;margin-bottom:16px">🎉</div>
<div class="setup-title">All set!</div>
<div class="setup-subtitle">Your environment is configured. We'll now start all services.</div>
<div class="setup-card" id="final-checks" style="text-align:center">
<div style="font-size:13px;opacity:0.7" id="final-summary">All checks passed</div>
</div>
<button class="setup-btn" onclick="finishSetup()" style="margin-top:8px">Launch 🚀</button>
</div>
</div>
<script> <script>
// ── State ──
let setupData = null;
let currentStep = 1;
const TOTAL_STEPS = 5;
// ── Normal loading state ──
const MAX_ATTEMPTS = 30; const MAX_ATTEMPTS = 30;
let attempts = 0; let attempts = 0;
const CHECKS = [ const CHECKS = [
{ id: 'ai', key: 'ai', ready: false, failed: false }, { id: 'ai', key: 'ai', ready: false, failed: false },
{ id: 'scraper', key: 'scraper', ready: false, failed: false }, { id: 'scraper', key: 'scraper', ready: false, failed: false },
{ id: 'frontend',key: 'frontend', ready: false, failed: false }, { id: 'frontend',key: 'frontend', ready: false, failed: false },
]; ];
const MSGS = { ai: 'AI Ready', scraper: 'Scraper Ready', frontend: 'Frontend Ready' }; const MSGS = { ai: 'AI Ready', scraper: 'Scraper Ready', frontend: 'Frontend Ready' };
const NAMES = { ai: 'AI Server', scraper: 'Scraper', frontend: 'Frontend' }; const NAMES = { ai: 'AI Server', scraper: 'Scraper', frontend: 'Frontend' };
// ── Step navigation ──
function goStep(n) {
currentStep = n;
document.querySelectorAll('.setup-step').forEach((el, i) => {
el.classList.toggle('active', i + 1 === n);
});
document.querySelectorAll('.setup-dot').forEach((el, i) => {
el.classList.toggle('current', i + 1 === n);
el.classList.toggle('done', i + 1 < n);
});
}
// ── Step 1: Welcome / Env check ──
async function checkEnv() {
try {
const res = await fetch('/setup/status');
setupData = await res.json();
} catch {
setupData = { first_run: true, ollama_running: false, model_available: false, profile_detected: false, facebook_logged_in: false };
}
setEnvStatus('ollama', setupData.ollama_running, setupData.ollama_running ? 'Running' : 'Not running');
setEnvStatus('model', setupData.model_available, setupData.model_available ? `${setupData.model_name}` : 'Not pulled');
setEnvStatus('profile', setupData.profile_detected, setupData.profile_path || 'Not found');
setEnvStatus('fb', setupData.facebook_logged_in, setupData.facebook_logged_in ? 'Logged in' : 'Not checked');
if (!setupData.first_run) {
document.getElementById('setup-wizard').classList.remove('active');
startNormalFlow();
return;
}
document.getElementById('setup-wizard').classList.add('active');
// Pre-fill step 2 fields
updateProfileUI();
}
function setEnvStatus(id, ok, label) {
document.getElementById('env-' + id).textContent = ok ? '✅' : '❌';
document.getElementById('env-' + id + '-val').textContent = label;
}
// ── Step 2: Browser Profile ──
function updateProfileUI() {
const icon = document.getElementById('prof-auto-icon');
const val = document.getElementById('prof-auto-val');
const input = document.getElementById('prof-input');
if (setupData.profile_path) {
icon.textContent = '✅';
val.textContent = setupData.profile_path;
input.value = setupData.profile_path;
document.getElementById('prof-next').disabled = false;
} else {
icon.textContent = '❌';
val.textContent = 'None detected';
}
}
function onProfileInput() {
const val = document.getElementById('prof-input').value.trim();
const fb = document.getElementById('prof-feedback');
const btn = document.getElementById('prof-next');
if (!val) { fb.textContent = ''; btn.disabled = true; return }
fb.textContent = 'Entered: ' + val;
btn.disabled = false;
}
async function saveProfile() {
const path = document.getElementById('prof-input').value.trim();
if (!path) return;
const btn = document.getElementById('prof-next');
btn.textContent = 'Saving...';
btn.disabled = true;
try {
const res = await fetch('/setup/profile', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path }) });
const data = await res.json();
if (data.success) {
setupData.profile_detected = true;
setupData.profile_path = path;
goStep(3);
} else {
document.getElementById('prof-feedback').textContent = 'Error: ' + (data.error || 'failed');
}
} catch { document.getElementById('prof-feedback').textContent = 'Error: could not save'; }
btn.textContent = 'Next →';
btn.disabled = false;
}
// ── Step 3: Facebook Login ──
async function checkFacebookLogin() {
const btn = document.getElementById('fb-check-btn');
const status = document.getElementById('fb-status');
const icon = document.getElementById('fb-icon');
const prog = document.getElementById('fb-progress');
const fill = document.getElementById('fb-progress-fill');
btn.disabled = true;
btn.textContent = 'Checking...';
prog.style.display = 'block';
fill.style.width = '50%';
status.textContent = 'Opening Facebook...';
try {
const path = setupData.profile_path || '';
const res = await fetch('/setup/check-login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profile_path: path }) });
const data = await res.json();
if (data.logged_in) {
fill.style.width = '100%';
icon.textContent = '✅';
status.textContent = 'You are logged into Facebook!';
status.className = 'status-ok';
setupData.facebook_logged_in = true;
setTimeout(() => goStep(4), 1000);
} else {
fill.style.width = '100%';
icon.textContent = '❌';
status.textContent = 'Not logged in. Log into Facebook in your browser and try again.';
status.className = 'status-err';
btn.textContent = 'Retry';
btn.disabled = false;
}
} catch {
icon.textContent = '❌';
status.textContent = 'Could not reach the scraper service';
status.className = 'status-err';
btn.textContent = 'Retry';
btn.disabled = false;
}
}
// ── Step 4: Ollama Model ──
let pullPolling = false;
async function pullModel() {
if (pullPolling) return;
const btn = document.getElementById('model-pull-btn');
const fill = document.getElementById('model-progress-fill');
const status = document.getElementById('model-status');
btn.disabled = true;
btn.textContent = 'Starting...';
try {
const res = await fetch('/setup/ollama/pull', { method: 'POST' });
const data = await res.json();
if (data.status === 'already_running') { btn.textContent = 'Already running'; return }
btn.textContent = 'Downloading...';
pullPolling = true;
pollPullProgress();
} catch {
status.textContent = 'Failed to start download';
status.className = 'status-err';
btn.disabled = false;
btn.textContent = 'Retry';
}
}
async function pollPullProgress() {
const fill = document.getElementById('model-progress-fill');
const status = document.getElementById('model-status');
const btn = document.getElementById('model-pull-btn');
try {
const res = await fetch('/setup/ollama/pull/progress');
const data = await res.json();
fill.style.width = data.progress + '%';
if (data.status === 'done') {
status.textContent = '✅ Model downloaded successfully!';
status.className = 'status-ok';
btn.textContent = 'Done';
setupData.model_available = true;
pullPolling = false;
setTimeout(() => goStep(5), 1500);
return;
}
if (data.status === 'failed') {
status.textContent = '❌ Download failed: ' + data.message;
status.className = 'status-err';
btn.textContent = 'Retry';
btn.disabled = false;
pullPolling = false;
return;
}
status.textContent = data.message || `Downloading... ${data.progress}%`;
setTimeout(pollPullProgress, 1000);
} catch {
status.textContent = 'Checking...';
setTimeout(pollPullProgress, 2000);
}
}
// ── Step 5: Finish ──
function finishSetup() {
document.getElementById('setup-wizard').classList.remove('active');
startNormalFlow();
}
// ── Normal loading flow ──
function startNormalFlow() {
poll();
}
async function checkAllServices() { async function checkAllServices() {
try { try {
const res = await fetch('/status', { cache: 'no-store' }); const res = await fetch('/status', { cache: 'no-store' });
const data = await res.json(); const data = await res.json();
for (const svc of CHECKS) { for (const svc of CHECKS) svc.ready = data[svc.key] === true;
svc.ready = data[svc.key] === true;
}
} catch { } catch {
for (const svc of CHECKS) svc.ready = false; for (const svc of CHECKS) svc.ready = false;
} }
} }
function updateUI() { function updateUI() {
let allReady = true; let allReady = true, anyFailed = false;
let anyFailed = false;
const failedNames = []; const failedNames = [];
for (const svc of CHECKS) { for (const svc of CHECKS) {
const el = document.getElementById('svc-' + svc.id); const el = document.getElementById('svc-' + svc.id);
const statusText = document.getElementById('status-' + svc.id); const st = document.getElementById('status-' + svc.id);
el.classList.remove('ready', 'failed'); el.classList.remove('ready', 'failed');
if (svc.ready) { el.classList.add('ready'); st.textContent = MSGS[svc.id] }
if (svc.ready) { else if (svc.failed) { el.classList.add('failed'); st.textContent = 'Failed'; anyFailed = true; allReady = false; failedNames.push(NAMES[svc.id]) }
el.classList.add('ready'); else { st.textContent = 'waiting...'; allReady = false }
statusText.textContent = MSGS[svc.id];
} else if (svc.failed) {
el.classList.add('failed');
statusText.textContent = 'Failed';
anyFailed = true;
allReady = false;
failedNames.push(NAMES[svc.id]);
} else {
statusText.textContent = 'waiting...';
allReady = false;
} }
} const lt = document.getElementById('loading-text'), em = document.getElementById('error-msg'), rb = document.getElementById('retry-btn'), lo = document.getElementById('launch-overlay');
const loadingText = document.getElementById('loading-text');
const errorMsg = document.getElementById('error-msg');
const retryBtn = document.getElementById('retry-btn');
if (anyFailed) { if (anyFailed) {
loadingText.className = 'loading-text error'; lt.className = 'loading-text error'; lt.textContent = 'Something went wrong';
loadingText.textContent = 'Something went wrong'; em.textContent = failedNames.join(', ') + ' failed to start. Check your terminal.';
errorMsg.textContent = failedNames.join(', ') + ' failed to start. Check your terminal for details.'; em.classList.add('active'); rb.classList.add('active'); lo.classList.remove('active');
errorMsg.classList.add('active');
retryBtn.classList.add('active');
document.getElementById('launch-overlay').classList.remove('active');
} else if (allReady) { } else if (allReady) {
loadingText.className = 'loading-text'; lt.className = 'loading-text'; lt.textContent = 'All systems ready!';
loadingText.textContent = 'All systems ready!'; em.classList.remove('active'); rb.classList.remove('active');
errorMsg.classList.remove('active'); lo.classList.add('active');
retryBtn.classList.remove('active');
document.getElementById('launch-overlay').classList.add('active');
setTimeout(() => { window.location.href = 'http://localhost:3006/login'; }, 5000); setTimeout(() => { window.location.href = 'http://localhost:3006/login'; }, 5000);
} else { } else {
loadingText.className = 'loading-text'; lt.className = 'loading-text';
const ready = CHECKS.filter(s => s.ready).length; lt.textContent = `Loading... (${CHECKS.filter(s => s.ready).length}/${CHECKS.length})`;
loadingText.textContent = `Loading... (${ready}/${CHECKS.length})`;
} }
} }
async function poll() { async function poll() {
attempts++; attempts++;
if (attempts > MAX_ATTEMPTS) { if (attempts > MAX_ATTEMPTS) {
for (const svc of CHECKS) { for (const svc of CHECKS) { if (!svc.ready) svc.failed = true }
if (!svc.ready) svc.failed = true; updateUI(); return;
} }
updateUI(); await checkAllServices(); updateUI();
return; if (!CHECKS.every(s => s.ready) && !CHECKS.some(s => s.failed))
}
await checkAllServices();
updateUI();
if (!CHECKS.every(s => s.ready) && !CHECKS.some(s => s.failed)) {
setTimeout(poll, 2000); setTimeout(poll, 2000);
}
} }
poll(); // ── Boot ──
checkEnv();
</script> </script>
</body> </body>
</html> </html>