diff --git a/docs/linkedin-announcement.md b/docs/linkedin-announcement.md new file mode 100644 index 0000000..c745371 --- /dev/null +++ b/docs/linkedin-announcement.md @@ -0,0 +1,33 @@ +# LinkedIn Announcement — AzLoFlows v1.5 + +--- + +As a Product Manager, I believe the pinnacle of our craft is taking something deeply complex and making it instantly understandable. Not with a 40-page doc. Not with a static Visio diagram that's outdated before the meeting starts. But with a dynamic, interactive visual that lets people explore the complexity on their own terms. + +That belief is what drove me to build **AzLoFlows** — a personal, free, open-source, browser-based isometric diagram builder purpose-built for Azure Local network architectures. + +Azure Local deployments involve intricate traffic flows across firewalls, proxies, Arc gateways, private endpoints, and public paths. Explaining how traffic routes differently depending on the configuration — with proxy, without proxy, with Arc gateway, without — is one of the hardest things to communicate clearly. I've seen architects struggle with it, engineers debate it during security reviews, and customers get lost in static slides. + +So I built the tool I wished existed: + +šŸ”¹ **One-click predefined scenarios** — Load a complete Public Path or Private Path diagram instantly +šŸ”¹ **Interactive scenario switching** — Toggle between No Proxy, Proxy only, Arc Gateway only, or Proxy + Arc and watch the traffic flows reroute in real time +šŸ”¹ **Traffic source filtering** — Isolate flows from Hosts, ARB, AKS, or different VM configurations to focus on exactly what matters +šŸ”¹ **Traffic type breakdown** — See HTTP endpoints, Arc gateway allowed endpoints, Azure Private Endpoints, bypass routes, and non-allowed public endpoints — each as distinct animated flows +šŸ”¹ **Export-ready** — PNG, SVG, JSON, and self-contained interactive HTML for docs, presentations, and stakeholder reviews +šŸ”¹ **No sign-up. No server. Runs entirely in your browser.** + +It's also a general-purpose isometric diagramming tool — 14+ shape types, animated connectors, dark/light themes, snap-to-grid, layers panel, and more. Think lightweight Visio for cloud architecture, but interactive and free. + +Check out the 30-second demo below to see it in action — loading the Public Path scenario and filtering through different sources and traffic types interactively. + +šŸ”— **Try it now**: https://cristianedwards.github.io/AzLoFlows/ +šŸ“¦ **Source code**: https://github.com/CristianEdwards/AzLoFlows + +MIT licensed. Built with React 19, TypeScript, and Canvas 2D. + +What network scenarios would be most useful for your team? I'd love to hear your feedback. + +#AzureLocal #Azure #NetworkArchitecture #OpenSource #CloudArchitecture #AzureStackHCI #ProductManagement #DiagramBuilder #TypeScript #React + +--- diff --git a/package.json b/package.json index b02a1ba..71412bc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "azloflows", "private": false, - "version": "1.5.0", + "version": "1.6.0", "description": "Interactive isometric diagram builder for Azure Local network architectures and traffic flows", "keywords": ["diagram-builder", "azure-local", "network-visualization", "isometric", "canvas", "flow-visualization", "react"], "homepage": "https://cristianedwards.github.io/AzLoFlows/", diff --git a/scripts/record-demo.mjs b/scripts/record-demo.mjs new file mode 100644 index 0000000..8136ce5 --- /dev/null +++ b/scripts/record-demo.mjs @@ -0,0 +1,114 @@ +/** + * Playwright script to record a ~30-second demo video of AzLoFlows + * showing the Public Path scenario with Proxy + Arc gateway, + * enabling Hosts → ARB → AKS → VM without proxy, then cycling + * through traffic types. + * + * Output: 1920Ɨ1080 WebM (LinkedIn-compatible). + * + * Usage: + * 1. Start dev server: npm run dev + * 2. Run: node scripts/record-demo.mjs + */ + +import { chromium } from '@playwright/test'; + +const BASE = 'http://localhost:8125/AzLoFlows/'; +const VIDEO_DIR = './docs/demo-video'; +// 1080p — LinkedIn recommended resolution (supports 256Ɨ144 to 4096Ɨ2304) +const VIEWPORT = { width: 1920, height: 1080 }; + +const pause = (ms = 800) => new Promise((r) => setTimeout(r, ms)); + +(async () => { + const browser = await chromium.launch({ headless: false }); + const context = await browser.newContext({ + viewport: VIEWPORT, + deviceScaleFactor: 2, // HiDPI — sharper text & icons + recordVideo: { dir: VIDEO_DIR, size: VIEWPORT }, + }); + const page = await context.newPage(); + + // ── 1. Open the app — empty canvas ────────────────────── + await page.goto(BASE, { waitUntil: 'networkidle' }); + await pause(1500); + + // ── 2. Load "Azure Local Public Path" predefined scenario ── + const publicPathBtn = page.locator('.predefined-scenario-btn', { + hasText: 'Azure Local Public Path', + }); + await publicPathBtn.click(); + await pause(2000); // let the diagram render and fit to screen + + // ── 2b. Zoom in so labels are readable ────────────────── + const zoomInBtn = page.locator('button[aria-label="Zoom in"]'); + for (let i = 0; i < 6; i++) { + await zoomInBtn.click(); + await pause(150); + } + await pause(800); + + // ── 3. Select "Proxy + Arc" scenario ──────────────────── + const proxyArcPill = page.locator('.scenario-pill', { hasText: 'Proxy + Arc' }); + await proxyArcPill.click(); + await pause(1200); + + // ── 4. Enable traffic sources one by one ──────────────── + // Hosts (required dependency for ARB & AKS in Proxy + Arc) + await page.locator('.source-picker .flow-pill', { hasText: 'Hosts' }).click(); + await pause(1200); + + // ARB (now unlocked) + await page.locator('.source-picker .flow-pill', { hasText: 'ARB' }).click(); + await pause(1200); + + // AKS + await page.locator('.source-picker .flow-pill', { hasText: 'AKS' }).click(); + await pause(1200); + + // VM without proxy (mutual-exclusion group — selects this VM variant) + await page.locator('.source-picker .flow-pill', { hasText: 'VM without proxy' }).click(); + await pause(1500); + + // ── 5. Cycle through traffic types ────────────────────── + const typeButtons = page.locator('.type-picker .flow-pill'); + const typeCount = await typeButtons.count(); + + // Turn off all active types one by one (reverse order, fast) + for (let i = typeCount - 1; i >= 0; i--) { + const btn = typeButtons.nth(i); + const isActive = await btn.evaluate((el) => el.classList.contains('is-active')); + if (isActive) { + await btn.click({ force: true }); + await pause(400); + } + } + await pause(600); + + // Turn them back on one by one (slower, so viewer sees each layer) + for (let i = 0; i < typeCount; i++) { + await typeButtons.nth(i).click({ force: true }); + await pause(700); + } + await pause(1000); + + // ── 6. Switch scenario to "No Proxy, No Arc" for contrast ── + await page.locator('.scenario-pill', { hasText: 'No Proxy, No Arc' }).click(); + await pause(1500); + + // Switch to "Proxy, No Arc" + await page.locator('.scenario-pill').filter({ hasText: /^Proxy, No Arc$/ }).click(); + await pause(1500); + + // Back to "Proxy + Arc" for the finale + await proxyArcPill.click(); + await pause(2000); + + // ── Done — close to flush the video file ──────────────── + await context.close(); + await browser.close(); + + console.log(`\nāœ… Demo video saved to: ${VIDEO_DIR}/`); + console.log('The .webm file can be uploaded directly to LinkedIn.'); + console.log('To convert to .mp4: ffmpeg -i .webm -c:v libx264 -crf 23 azloflows-demo.mp4'); +})(); diff --git a/src/app/App.tsx b/src/app/App.tsx index 43a8beb..34bfa86 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -14,6 +14,7 @@ import ShapePalette from '@/features/palette/ShapePalette'; import ScenarioToolbar from '@/features/scenarios/ScenarioToolbar'; import type { ViewportSize } from '@/lib/geometry/iso'; import { saveDocument } from '@/lib/serialization/storage'; +import { applyUrlParams } from '@/lib/urlParams'; import { useEditorStore } from '@/state/useEditorStore'; const SAVE_DEBOUNCE_MS = 400; @@ -38,6 +39,11 @@ export default function App() { window.document.documentElement.setAttribute('data-theme', theme); }, [theme]); + // Apply URL parameters once on mount (for iframe / deep-link integrations). + useEffect(() => { + void applyUrlParams(); + }, []); + return ( <> diff --git a/src/lib/urlParams.ts b/src/lib/urlParams.ts new file mode 100644 index 0000000..5968a80 --- /dev/null +++ b/src/lib/urlParams.ts @@ -0,0 +1,147 @@ +/** + * URL parameter integration for embedded / iframe usage. + * + * Supported query parameters (all optional, comma-separated lists allowed): + * ?scenario= Loads a predefined scenario file from PredefinedScenarios/manifest.json. + * Matches against the filename (without .json) or a slugified label. + * &config= Activates a scenario tag (entry in document.scenarios). + * &sources=a,b,c Activates flow sources (entries in document.flowSources). + * &types=a,b,c Activates flow types (entries in document.flowTypes). + * + * All slug matching is fuzzy (slugified id or label). Unknown values are ignored silently. + */ + +import { useEditorStore } from '@/state/useEditorStore'; +import { normalizeDocument } from '@/lib/serialization/storage'; +import { + getDocScenarios, + getDocFlowSources, + getDocFlowTypes, + type PickerDef, + type FlowSource, + type FlowType, +} from '@/types/document'; + +interface ScenarioManifestEntry { + file: string; + label: string; +} + +function slugify(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +function parseList(value: string | null): string[] { + if (!value) return []; + return value + .split(',') + .map((v) => v.trim()) + .filter(Boolean); +} + +/** Find a picker entry whose id or slugified label matches the requested slug. */ +function matchPicker(defs: PickerDef[], slug: string): PickerDef | undefined { + const target = slugify(slug); + return defs.find((d) => slugify(d.id) === target || slugify(d.label) === target); +} + +function matchScenarioFile(entries: ScenarioManifestEntry[], slug: string): ScenarioManifestEntry | undefined { + const target = slugify(slug); + return entries.find((e) => { + const fileSlug = slugify(e.file.replace(/\.json$/i, '')); + const labelSlug = slugify(e.label); + return fileSlug === target || labelSlug === target; + }); +} + +/** + * Read URL params and apply them to the editor store. Returns a promise that + * resolves once the optional scenario file has been loaded and selections applied. + * Safe to call multiple times; subsequent calls re-apply if params change. + */ +export async function applyUrlParams(): Promise { + if (typeof window === 'undefined') return false; + const params = new URLSearchParams(window.location.search); + const scenarioParam = params.get('scenario'); + const configParam = params.get('config'); + const sourcesParam = parseList(params.get('sources')); + const typesParam = parseList(params.get('types')); + + if (!scenarioParam && !configParam && sourcesParam.length === 0 && typesParam.length === 0) { + return false; + } + + // Step 1: optionally load a scenario file from manifest. + if (scenarioParam) { + try { + const manifestUrl = `${import.meta.env.BASE_URL}PredefinedScenarios/manifest.json`; + const manifestRes = await fetch(manifestUrl); + if (manifestRes.ok) { + const entries: ScenarioManifestEntry[] = await manifestRes.json(); + const match = matchScenarioFile(entries, scenarioParam); + if (match) { + const fileUrl = `${import.meta.env.BASE_URL}PredefinedScenarios/${encodeURIComponent(match.file)}`; + const docRes = await fetch(fileUrl); + if (docRes.ok) { + const json = await docRes.json(); + const doc = normalizeDocument(json); + useEditorStore.getState().importDocument(doc); + } + } + } + } catch { + // Silent fallback — keep existing document. + } + } + + // Step 2: apply config / sources / types against the (possibly newly imported) document. + const state = useEditorStore.getState(); + const doc = state.document; + const scenarioDefs = getDocScenarios(doc); + const sourceDefs = getDocFlowSources(doc); + const typeDefs = getDocFlowTypes(doc); + + const patch: { + activeScenario?: string | null; + activeFlowSources?: Set; + activeFlowTypes?: Set; + } = {}; + + if (configParam) { + const matched = matchPicker(scenarioDefs, configParam); + if (matched) patch.activeScenario = matched.id; + } + + if (sourcesParam.length > 0) { + const matchedSources = sourcesParam + .map((slug) => matchPicker(sourceDefs, slug)?.id) + .filter((id): id is string => Boolean(id)); + if (matchedSources.length > 0) patch.activeFlowSources = new Set(matchedSources); + } + + if (typesParam.length > 0) { + const matchedTypes = typesParam + .map((slug) => matchPicker(typeDefs, slug)?.id) + .filter((id): id is string => Boolean(id)); + if (matchedTypes.length > 0) patch.activeFlowTypes = new Set(matchedTypes); + } + + if (Object.keys(patch).length > 0) { + useEditorStore.setState(patch); + } + + // Fit the imported scenario to screen if a scenario was loaded. + if (scenarioParam) { + requestAnimationFrame(() => { + const canvas = window.document.querySelector('canvas'); + if (canvas) { + useEditorStore.getState().fitToScreen(canvas.clientWidth, canvas.clientHeight); + } + }); + } + + return true; +}