Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions docs/linkedin-announcement.md
Original file line number Diff line number Diff line change
@@ -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

---
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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/",
Expand Down
114 changes: 114 additions & 0 deletions scripts/record-demo.mjs
Original file line number Diff line number Diff line change
@@ -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 <file>.webm -c:v libx264 -crf 23 azloflows-demo.mp4');
})();
6 changes: 6 additions & 0 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 (
<>
<MeshBackground />
Expand Down
147 changes: 147 additions & 0 deletions src/lib/urlParams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/**
* URL parameter integration for embedded / iframe usage.
*
* Supported query parameters (all optional, comma-separated lists allowed):
* ?scenario=<slug> Loads a predefined scenario file from PredefinedScenarios/manifest.json.
* Matches against the filename (without .json) or a slugified label.
* &config=<slug> 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<boolean> {
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<FlowSource>;
activeFlowTypes?: Set<FlowType>;
} = {};

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;
}
Loading