From db32934100bc2bcefe6458cd37e856158e3cac2d Mon Sep 17 00:00:00 2001 From: canvas-bot Date: Mon, 15 Jun 2026 18:39:26 +0400 Subject: [PATCH] fix(nginx): static proxy_pass hostname so DNS resolves once at config-load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous $variable form forced lazy per-request DNS resolution. Per-request lookups against 10.43.0.10 → SERVFAIL → cold-cache 502 even when downstream was healthy. Switching to a literal hostname makes nginx resolve once at startup and reuse the cached upstream IP for the lifetime of the worker. Cloudflare anycast is stable so this is safe. --- nginx.conf | 12 +++-- qa/_wave1_local.mjs | 120 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 qa/_wave1_local.mjs diff --git a/nginx.conf b/nginx.conf index 32a3811..05c9689 100644 --- a/nginx.conf +++ b/nginx.conf @@ -28,12 +28,14 @@ server { resolver 10.43.0.10 1.1.1.1 8.8.8.8 valid=30s ipv6=off; # Reverse-proxy /api to the demo backend so live mode is same-origin. - # Pinned to the Cloudflare anycast IP for canvas.flow-master.ai (also - # serves demo) so per-request DNS resolution can NEVER fail mid-flight. - # 30s connect, 30s read, retry once on transient upstream failure. + # NOTE: proxy_pass uses a literal hostname (not a $variable) so nginx + # resolves demo.flow-master.ai ONCE at config-load, caches it for the + # lifetime of the worker, and never retries DNS mid-request. This is + # the only way to eliminate the per-request DNS race that returned + # fast 502s on cold cache. Cloudflare hides the demo origin behind + # anycast IPs, so a single-resolution lookup is safe and stable. location /api/ { - set $upstream_demo "https://demo.flow-master.ai"; - proxy_pass $upstream_demo; + proxy_pass https://demo.flow-master.ai; proxy_set_header Host demo.flow-master.ai; proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Forwarded-Host $host; diff --git a/qa/_wave1_local.mjs b/qa/_wave1_local.mjs new file mode 100644 index 0000000..8f38ab2 --- /dev/null +++ b/qa/_wave1_local.mjs @@ -0,0 +1,120 @@ +// Serve dist locally, screenshot the new topbar + scenes with all backends mocked. +import { chromium } from "playwright"; +import http from "node:http"; +import { readFileSync, statSync, writeFileSync } from "node:fs"; +import { extname, join } from "node:path"; + +const ROOT = "/private/tmp/canvas-work/canvas-real/dist"; +const MIME = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png", ".json": "application/json" }; + +const server = http.createServer((req, res) => { + let path = req.url.split("?")[0]; + if (path === "/") path = "/index.html"; + const full = join(ROOT, path); + try { statSync(full); res.setHeader("content-type", MIME[extname(full)] || "application/octet-stream"); res.end(readFileSync(full)); } + catch { res.setHeader("content-type", "text/html"); res.end(readFileSync(join(ROOT, "index.html"))); } +}); + +await new Promise((r) => server.listen(0, r)); +const port = server.address().port; +const URL = `http://127.0.0.1:${port}/`; +console.log("serving on", URL); + +const OUT = "/tmp/canvas-evidence/wave1-local"; +import { mkdirSync } from "node:fs"; +mkdirSync(OUT, { recursive: true }); + +const b = await chromium.launch({ headless: true }); +const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } }); +const p = await ctx.newPage(); + +// Stub backend +await ctx.route("**/api/v1/auth/microsoft/login", (r) => r.fulfill({ status: 503, body: "" })); +await ctx.route("**/internal/dev-login-config", (r) => r.fulfill({ status: 200, contentType: "application/json", body: '{"enabled":true}' })); +await ctx.route("**/api/v1/auth/dev-login", (r) => r.fulfill({ status: 200, contentType: "application/json", body: '{"access_token":"T","refresh_token":"R"}' })); +await ctx.route("**/api/v1/auth/me", (r) => r.fulfill({ status: 200, contentType: "application/json", body: '{"user_id":"u1","tenant_id":"t1","email":"ceo-head@flow-master.ai","display_name":"Mariana Cole"}' })); +await ctx.route("**/api/ea2/work-items**", (r) => r.fulfill({ status: 200, contentType: "application/json", body: '{"items":[]}' })); +await ctx.route("**/api/ea2/flow/processes**", (r) => r.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ items: [ + { _key: "k1", display_name: "Purchase requisition to PO", status: "published", kind: "definition" }, + { _key: "k2", display_name: "Laptop procurement (employee → manager → IT)", status: "published", kind: "definition" }, + { _key: "k3", display_name: "Customer refund approval", status: "published", kind: "definition" }, +] }) })); +await ctx.route("**/api/ea2/flow/**", (r) => r.fulfill({ status: 200, contentType: "application/json", body: '{"_key":"x","display_name":"X","status":"published","kind":"definition","config":{}}' })); +await ctx.route("**/api/ea2/edges/**", (r) => r.fulfill({ status: 200, contentType: "application/json", body: '{"items":[]}' })); +await ctx.route("**/api/v1/llm/generate", (r) => r.fulfill({ status: 200, contentType: "application/json", body: '{"text":"Sure — here are your published processes: purchase requisition to PO, laptop procurement, customer refund approval.","provider":"demo"}' })); +await ctx.route("**/api/ea2/apply-batch", (r) => r.fulfill({ status: 200, contentType: "application/json", body: '{"applied":1,"result":{"ops":[{"key":"new1"},{"key":"new2"}]}}' })); +await ctx.route("**/api/runtime/transactions**", (r) => r.fulfill({ status: 200, contentType: "application/json", body: '{"items":[]}' })); + +async function snap(name) { await p.screenshot({ path: `${OUT}/${name}.png`, fullPage: false }); } + +// Pre-seed localStorage to skip the onboarding tour +await ctx.addInitScript(() => { localStorage.setItem("fm.canvas.tour.seen", String(Date.now())); }); + +await p.goto(URL, { waitUntil: "domcontentloaded" }); +await p.waitForTimeout(1500); +await snap("01-login"); + +await p.locator(".persona-chip").filter({ hasText: /Mariana/ }).first().click(); +await p.waitForTimeout(3500); +await snap("02-after-login-MISSION"); +const shell = await p.evaluate(() => document.querySelector("[class*='shell-']")?.className); +console.log("post-login shell:", shell); +const tabCount = await p.locator(".tab").count(); +console.log("topbar tab count:", tabCount); +const tabTexts = await p.locator(".tab").allTextContents(); +console.log("tabs:", tabTexts); + +// avatar/menu +const avatar = p.locator(".user-avatar").first(); +console.log("user-avatar present:", await avatar.count() > 0); +if (await avatar.count()) { + await avatar.click(); + await p.waitForTimeout(800); + await snap("03-user-menu-open"); + await p.keyboard.press("Escape"); + await p.waitForTimeout(400); +} + +// Studio dogfood +await p.locator(".tab").filter({ hasText: /Studio/ }).first().click(); +await p.waitForTimeout(1500); +await p.locator("textarea").first().fill("Laptop procurement for a regional store manager."); +await snap("04-studio-intake"); +await p.locator("button.btn-primary").filter({ hasText: /draft/i }).first().click(); +await p.waitForTimeout(2500); +await snap("05-studio-analyze"); +await p.locator("button.btn-primary").filter({ hasText: /Confirm/i }).first().click(); +await p.waitForTimeout(2500); +await snap("06-studio-after-confirm"); +const toastsAfterConfirm = await p.locator(".toast, .toast-msg").allTextContents(); +console.log("studio toasts:", toastsAfterConfirm.slice(-3)); + +// Assistant +await p.locator(".tab").filter({ hasText: /Assistant/ }).first().click(); +await p.waitForTimeout(1500); +await snap("07-assistant"); +const input = p.locator(".agent-input, input[placeholder*='Ask']").first(); +if (await input.count()) { + await input.fill("list processes"); + await input.press("Enter"); + await p.waitForTimeout(3500); + await snap("08-assistant-list-processes"); + const replies = await p.locator(".agent-msg-text, .agent-msg-body").allTextContents(); + console.log("assistant last reply:", replies.slice(-1)[0]?.slice(0, 200)); +} + +// Chat +await p.locator(".tab").filter({ hasText: /Chat/ }).first().click(); +await p.waitForTimeout(2500); +await snap("09-chat"); + +// Dark theme via user menu +await p.locator(".user-avatar").first().click(); +await p.waitForTimeout(400); +await p.locator(".user-menu-item").filter({ hasText: /Switch to (dark|light)/i }).first().click(); +await p.waitForTimeout(800); +await snap("10-theme-toggled"); + +await b.close(); +server.close(); +console.log(`\nArtifacts in ${OUT}`);