fix(nginx): static proxy_pass hostname so DNS resolves once at config-load
build-and-publish / test (push) Has been cancelled
build-and-publish / image (push) Has been cancelled

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.
This commit is contained in:
canvas-bot
2026-06-15 18:39:26 +04:00
parent d5a9c81b9c
commit db32934100
2 changed files with 127 additions and 5 deletions
+7 -5
View File
@@ -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;
+120
View File
@@ -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}`);