seedproject-web/app/src/pages/admin/qa.astro

197 lines
10 KiB
Text
Raw Normal View History

---
import AdminLayout from "../../layouts/AdminLayout.astro";
---
<AdminLayout title="QA">
<div style="display:flex;align-items:baseline;gap:1.25rem">
<h1>QA</h1>
<button type="button" class="btn" id="runBtn" style="margin-left:auto">Run QA now</button>
</div>
<p class="lead">Automated checks against the live site — links, images, the contact form, the API, and SEO. Turn any finding into a Web Designer fix with one click.</p>
<p class="msg" id="msg" role="status" aria-live="polite"></p>
<section id="runWrap" class="run" hidden>
<div class="run-head">
<span class="run-when" id="runWhen"></span>
<span class="pills" id="runPills"></span>
</div>
<p class="run-summary" id="runSummary"></p>
</section>
<div id="findings" class="findings">Loading…</div>
<details class="history">
<summary>Previous runs</summary>
<div id="history"></div>
</details>
<style>
.run { border:1px solid var(--rule); border-radius:2px; padding:1.25rem 1.4rem; margin:1.5rem 0; }
.run-head { display:flex; align-items:center; gap:1rem; margin-bottom:.6rem; }
.run-when { font-family:var(--mono); font-size:.65rem; letter-spacing:.08em; text-transform:uppercase; color:var(--ink4); }
.pills { margin-left:auto; display:flex; gap:.5rem; }
.pill { font-family:var(--mono); font-size:.6rem; letter-spacing:.08em; text-transform:uppercase; padding:.2rem .55rem; border-radius:2px; border:1px solid var(--rule); color:var(--ink3); }
.pill.error { color:var(--seal); border-color:var(--seal); }
.pill.ok { color:var(--ink); }
.run-summary { margin:0; font-size:.95rem; line-height:1.6; color:var(--ink2); }
.findings { display:flex; flex-direction:column; }
.grp { margin-top:1.75rem; }
.grp h2 { font-family:var(--mono); font-size:.62rem; letter-spacing:.14em; text-transform:uppercase; color:var(--ink4); margin:0 0 .5rem; }
.fnd { display:flex; align-items:flex-start; gap:.9rem; padding:.85rem 0; border-top:1px solid var(--rule); }
.fnd:last-child { border-bottom:1px solid var(--rule); }
.sev { flex:0 0 auto; margin-top:.15rem; width:.5rem; height:.5rem; border-radius:50%; background:var(--ink4); }
.sev.error { background:var(--seal); } .sev.warning { background:var(--ink3); }
.fnd .body { flex:1; min-width:0; }
.fnd .top { display:flex; gap:.6rem; align-items:baseline; flex-wrap:wrap; }
.fnd .ck { font-family:var(--mono); font-size:.6rem; letter-spacing:.08em; text-transform:uppercase; color:var(--ink4); }
.fnd .url { font-family:var(--mono); font-size:.78rem; color:var(--ink2); word-break:break-all; }
.fnd .detail { margin:.25rem 0 0; font-size:.88rem; color:var(--ink3); }
.fnd .acts { flex:0 0 auto; display:flex; gap:.5rem; align-items:center; }
.fnd .acts button { font-family:var(--mono); font-size:.58rem; letter-spacing:.06em; text-transform:uppercase;
background:none; border:1px solid var(--rule); border-radius:2px; padding:.35rem .6rem; color:var(--ink3); cursor:pointer; }
.fnd .acts button.fix:hover { color:var(--paper); background:var(--ink); border-color:var(--ink); }
.fnd .acts button.ignore:hover { color:var(--seal); }
.fnd .state { font-family:var(--mono); font-size:.58rem; letter-spacing:.06em; text-transform:uppercase; color:var(--ink4); }
.clean { padding:2rem 0; color:var(--ink3); }
.history { margin-top:2.5rem; }
.history summary { font-family:var(--mono); font-size:.62rem; letter-spacing:.12em; text-transform:uppercase; color:var(--ink4); cursor:pointer; }
.history .hrow { display:flex; gap:1rem; padding:.6rem 0; border-top:1px solid var(--rule); font-size:.85rem; }
.history .hrow .hw { font-family:var(--mono); font-size:.62rem; color:var(--ink4); }
</style>
<script>
const $ = (id) => document.getElementById(id);
const msg = $("msg");
const setMsg = (t, cls = "") => { msg.textContent = t; msg.className = "msg " + cls; };
let polling = null;
const SEV_ORDER = ["error", "warning", "info"];
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
$("runBtn").addEventListener("click", async () => {
const btn = $("runBtn"); btn.disabled = true;
setMsg("Running QA against the live site… this takes a moment.");
try {
const r = await fetch("/devconsole/qa/run", { method: "POST", credentials: "same-origin" });
if (r.status === 429) { setMsg("A QA run is already in progress.", "error"); btn.disabled = false; return; }
// poll for a fresh completed run
pollRuns(true);
} catch { setMsg("Couldn't start the QA run.", "error"); btn.disabled = false; }
});
async function pollRuns(waitForNew = false) {
if (polling) clearInterval(polling);
let seen = waitForNew ? await latestRunId() : null;
const tick = async () => {
const runs = await getRuns();
renderHistory(runs);
const latest = runs[0];
if (!latest) return;
if (waitForNew && latest.status !== "done") { setMsg("Running QA…"); return; }
if (waitForNew && latest.id === seen && latest.status === "done") { /* still old */ return; }
// a done run is available
renderRun(latest);
await renderFindings(latest.id);
setMsg("");
$("runBtn").disabled = false;
if (latest.status === "done") { clearInterval(polling); polling = null; }
};
polling = setInterval(tick, 4000);
tick();
}
async function latestRunId() { const r = await getRuns(); return r[0]?.id ?? null; }
async function getRuns() {
try { const r = await fetch("/api/adminqa/runs", { credentials: "same-origin" }); return (await r.json())?.data?.runs || []; }
catch { return []; }
}
function renderRun(run) {
$("runWrap").hidden = false;
$("runWhen").textContent = (run.trigger === "scheduled" ? "Scheduled · " : "") + (run.finished || run.started || "");
const c = run.counts || {};
const pills = [];
pills.push(`<span class="pill ${c.error ? "error" : "ok"}">${c.error || 0} error${c.error === 1 ? "" : "s"}</span>`);
pills.push(`<span class="pill">${c.warning || 0} warning${c.warning === 1 ? "" : "s"}</span>`);
pills.push(`<span class="pill">${c.pages || 0} pages</span>`);
$("runPills").innerHTML = pills.join("");
$("runSummary").textContent = run.summary || "";
}
async function renderFindings(runId) {
const el = $("findings");
let data;
try { data = (await (await fetch("/api/adminqa/findings?run=" + runId, { credentials: "same-origin" })).json())?.data; }
catch { el.textContent = "Couldn't load findings."; return; }
const findings = (data?.findings || []).filter((f) => f.status !== "ignored");
if (!findings.length) { el.innerHTML = `<p class="clean">✓ No open issues in this run.</p>`; return; }
el.innerHTML = "";
for (const sev of SEV_ORDER) {
const group = findings.filter((f) => f.severity === sev);
if (!group.length) continue;
const wrap = document.createElement("div");
wrap.className = "grp";
wrap.innerHTML = `<h2>${sev} · ${group.length}</h2>`;
for (const f of group) {
const row = document.createElement("div");
row.className = "fnd";
const acts = f.status === "fix_queued"
? `<span class="state">fix queued</span>`
: `<button class="fix" data-fix="${f.id}">Create fix task</button><button class="ignore" data-ignore="${f.id}">Ignore</button>`;
row.innerHTML = `
<span class="sev ${f.severity}"></span>
<div class="body">
<div class="top"><span class="ck">${esc(f.check)}</span><span class="url">${esc(f.url)}</span></div>
<p class="detail">${esc(f.detail)}</p>
</div>
<div class="acts">${acts}</div>`;
const fixBtn = row.querySelector("[data-fix]");
if (fixBtn) fixBtn.addEventListener("click", () => createFix(f.id, runId));
const igBtn = row.querySelector("[data-ignore]");
if (igBtn) igBtn.addEventListener("click", () => ignore(f.id, runId));
wrap.appendChild(row);
}
el.appendChild(wrap);
}
}
async function createFix(id, runId) {
try {
const r = await fetch("/api/adminqa/fix", {
method: "POST", credentials: "same-origin",
headers: { "content-type": "application/json" }, body: JSON.stringify({ finding_id: id }),
});
const d = await r.json();
if (!r.ok || !d?.data?.ok) { setMsg(d?.error?.message || "Couldn't queue the fix.", "error"); return; }
await fetch("/devconsole/tasks/run", { method: "POST", credentials: "same-origin" });
setMsg("Fix queued — the Web Designer is on it.", "ok");
renderFindings(runId);
} catch { setMsg("Couldn't queue the fix.", "error"); }
}
async function ignore(id, runId) {
try {
await fetch("/api/adminqa/ignore", { method: "POST", credentials: "same-origin", headers: { "content-type": "application/json" }, body: JSON.stringify({ finding_id: id }) });
renderFindings(runId);
} catch {}
}
function renderHistory(runs) {
$("history").innerHTML = runs.slice(0, 15).map((r) => {
const c = r.counts || {};
return `<div class="hrow"><span class="hw">${esc(r.finished || r.started || "")}</span><span>${c.error || 0} err · ${c.warning || 0} warn · ${r.status}</span></div>`;
}).join("");
}
// initial load: show the latest completed run
(async () => {
const runs = await getRuns();
renderHistory(runs);
const latest = runs[0];
if (latest && latest.status === "done") { renderRun(latest); await renderFindings(latest.id); }
else if (latest && latest.status === "running") { setMsg("A QA run is in progress…"); pollRuns(false); }
else { $("findings").innerHTML = `<p class="clean">No QA runs yet — click “Run QA now”.</p>`; }
})();
</script>
</AdminLayout>