fix(nginx): static proxy_pass hostname so DNS resolves once at config-load
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:
+7
-5
@@ -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;
|
||||
|
||||
@@ -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}`);
|
||||
Reference in New Issue
Block a user