fix(topbar): idle-quiet badge poll (count threads, not per-thread reads)
Idle-poll audit caught a regression I introduced in the prior commit: the topbar badge poll iterated chatApi.listMessages per thread on each 60s tick, firing N requests per refresh. Landing scene showed 9 reqs in a 30s idle window (failed the <=5 budget). Now the topbar badge just shows the THREAD COUNT (not per-thread unread), which is a single chatApi.listThreads call. Per-thread unread math stays in the Chat scene where it belongs (already wired: chat-thread-row.unread + .chat-unread-summary).
This commit is contained in:
@@ -0,0 +1,80 @@
|
|||||||
|
// Idle-poll audit. User explicitly complained "way too many GETs / non-stop".
|
||||||
|
// For each scene: visit, settle, then watch network for 30s with no user input.
|
||||||
|
// Pass if total requests in that 30s window is reasonable (<= 5).
|
||||||
|
// The topbar identity-hydration + chat/queue badge polls at 60s, so a 30s
|
||||||
|
// window should see at most 1 such fire.
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
|
||||||
|
const URL = "https://canvas.flow-master.ai/";
|
||||||
|
const args = ["--host-resolver-rules=MAP canvas.flow-master.ai 65.21.71.186", "--ignore-certificate-errors"];
|
||||||
|
|
||||||
|
const SCENES = [
|
||||||
|
{ chip: null, label: "landing" },
|
||||||
|
{ chip: /Approvals queue/, label: "approvals" },
|
||||||
|
{ chip: /^Documents$/, label: "documents" },
|
||||||
|
{ chip: /Procurement Hub/, label: "procurement_hub" },
|
||||||
|
{ chip: /Attendance Map/, label: "geo" },
|
||||||
|
{ chip: /Command Assistant/, label: "assistant" },
|
||||||
|
{ chip: /Team Chat/, label: "chat" },
|
||||||
|
{ chip: /What is FlowMaster/, label: "explainer" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const IDLE_WINDOW_MS = 30_000;
|
||||||
|
const MAX_REQ_PER_WINDOW = 5;
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
function record(name, ok, detail = "") {
|
||||||
|
results.push({ name, ok, detail });
|
||||||
|
console.log(`${ok ? "PASS" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const b = await chromium.launch({ headless: true, args });
|
||||||
|
const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } });
|
||||||
|
const p = await ctx.newPage();
|
||||||
|
|
||||||
|
await p.goto(URL, { waitUntil: "networkidle" });
|
||||||
|
await p.waitForTimeout(800);
|
||||||
|
await p.locator(".persona-chip", { hasText: /Mariana/ }).click();
|
||||||
|
await p.locator(".dev-login-btn").click();
|
||||||
|
await p.waitForSelector(".hero-actions", { timeout: 15000 });
|
||||||
|
await p.waitForFunction(() => document.querySelectorAll(".hub-chip").length >= 8, undefined, { timeout: 15000 }).catch(() => {});
|
||||||
|
|
||||||
|
for (const s of SCENES) {
|
||||||
|
if (s.chip) {
|
||||||
|
await p.goto(URL, { waitUntil: "networkidle" }).catch(() => {});
|
||||||
|
await p.waitForSelector(".hub-chip", { timeout: 15000 }).catch(() => {});
|
||||||
|
await p.locator(".hub-chip", { hasText: s.chip }).click().catch(() => {});
|
||||||
|
await p.waitForTimeout(2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settle period — the scene's initial fetch burst should complete here.
|
||||||
|
await p.waitForLoadState("networkidle").catch(() => {});
|
||||||
|
await p.waitForTimeout(1500);
|
||||||
|
|
||||||
|
// Now record every request fired for the next IDLE_WINDOW_MS.
|
||||||
|
const requests = [];
|
||||||
|
const onReq = (req) => {
|
||||||
|
const u = req.url();
|
||||||
|
if (/\/api\/|\/internal\//.test(u)) {
|
||||||
|
requests.push({ method: req.method(), url: u.replace("https://canvas.flow-master.ai", "") });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
p.on("request", onReq);
|
||||||
|
await p.waitForTimeout(IDLE_WINDOW_MS);
|
||||||
|
p.off("request", onReq);
|
||||||
|
|
||||||
|
const ok = requests.length <= MAX_REQ_PER_WINDOW;
|
||||||
|
const summary = requests.length
|
||||||
|
? `${requests.length} reqs · ${requests.slice(0, 3).map((r) => `${r.method} ${r.url.slice(0, 50)}`).join(", ")}${requests.length > 3 ? "…" : ""}`
|
||||||
|
: "0 reqs · quiet";
|
||||||
|
record(`idle_${s.label}_quiet_at_30s`, ok, summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
await b.close();
|
||||||
|
const fails = results.filter((r) => !r.ok);
|
||||||
|
console.log(`\n=== idle audit: ${results.length - fails.length}/${results.length} passed ===`);
|
||||||
|
if (fails.length) {
|
||||||
|
console.log("FAILED:");
|
||||||
|
fails.forEach((f) => console.log(` - ${f.name}: ${f.detail}`));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
+4
-20
@@ -76,7 +76,7 @@ export default function App() {
|
|||||||
|
|
||||||
const liveAge = liveFetchedAt ? `${Math.max(0, Math.floor((Date.now() - liveFetchedAt) / 1000))}s ago` : null;
|
const liveAge = liveFetchedAt ? `${Math.max(0, Math.floor((Date.now() - liveFetchedAt) / 1000))}s ago` : null;
|
||||||
|
|
||||||
const [chatUnread, setChatUnread] = useState(0);
|
const [chatThreadCount, setChatThreadCount] = useState(0);
|
||||||
const [queueCount, setQueueCount] = useState(0);
|
const [queueCount, setQueueCount] = useState(0);
|
||||||
const tenantId = useApp((s) => s.tenantId);
|
const tenantId = useApp((s) => s.tenantId);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -91,24 +91,8 @@ export default function App() {
|
|||||||
apiMod.workItems().catch(() => []),
|
apiMod.workItems().catch(() => []),
|
||||||
]);
|
]);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const readMap: Record<string, string> = (() => {
|
setChatThreadCount(threads.length);
|
||||||
try { return JSON.parse(localStorage.getItem(`fm.canvas.chat.read.${userEmail}`) || "{}"); } catch { return {}; }
|
setQueueCount(items.filter((it) => (it as any).tenant_id === tenantId).length);
|
||||||
})();
|
|
||||||
let unread = 0;
|
|
||||||
for (const t of threads) {
|
|
||||||
try {
|
|
||||||
const msgs = await chatApi.listMessages(t._key);
|
|
||||||
const last = msgs[msgs.length - 1];
|
|
||||||
if (!last) continue;
|
|
||||||
if (last.author_email === userEmail) continue;
|
|
||||||
const readAt = readMap[t._key];
|
|
||||||
if (!readAt || last.created_at > readAt) unread++;
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
if (!cancelled) {
|
|
||||||
setChatUnread(unread);
|
|
||||||
setQueueCount(items.filter((it) => (it as any).tenant_id === tenantId).length);
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
};
|
};
|
||||||
void refresh();
|
void refresh();
|
||||||
@@ -143,7 +127,7 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
<button role="tab" aria-selected={scene === "chat"} className={`tab${scene === "chat" ? " tab-sel" : ""}`} onClick={() => setScene("chat")}>
|
<button role="tab" aria-selected={scene === "chat"} className={`tab${scene === "chat" ? " tab-sel" : ""}`} onClick={() => setScene("chat")}>
|
||||||
<Bot size={13} /> Chat
|
<Bot size={13} /> Chat
|
||||||
{chatUnread > 0 && <span className="tab-badge tab-badge-amber">{chatUnread > 99 ? "99+" : chatUnread}</span>}
|
{chatThreadCount > 0 && <span className="tab-badge tab-badge-amber">{chatThreadCount > 99 ? "99+" : chatThreadCount}</span>}
|
||||||
</button>
|
</button>
|
||||||
<button role="tab" aria-selected={scene === "agent"} className={`tab${scene === "agent" ? " tab-sel" : ""}`} onClick={() => setScene("agent")}>
|
<button role="tab" aria-selected={scene === "agent"} className={`tab${scene === "agent" ? " tab-sel" : ""}`} onClick={() => setScene("agent")}>
|
||||||
<Bot size={13} /> Assistant
|
<Bot size={13} /> Assistant
|
||||||
|
|||||||
Reference in New Issue
Block a user