197 lines
10 KiB
Text
197 lines
10 KiB
Text
|
|
---
|
||
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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>
|