From 31e800f1359e06846d4fd90fe6ab2c32be79e1bc Mon Sep 17 00:00:00 2001 From: jovonni Date: Sun, 8 Mar 2026 14:20:35 -0400 Subject: [PATCH 1/3] Add SVG architecture diagram, replace ASCII diagram in README Generated via docs/diagrams/generate-architecture.js. Shows full system topology: clients, K8s cluster with core services, database, ephemeral pods, persistent volumes, and external integrations with labeled data flow arrows. --- README.md | 22 +- docs/diagrams/architecture.svg | 94 +++++++ docs/diagrams/generate-architecture.js | 344 +++++++++++++++++++++++++ 3 files changed, 439 insertions(+), 21 deletions(-) create mode 100644 docs/diagrams/architecture.svg create mode 100644 docs/diagrams/generate-architecture.js diff --git a/README.md b/README.md index 5579bce..cd00f18 100644 --- a/README.md +++ b/README.md @@ -156,27 +156,7 @@ This will: ## Architecture -``` - Kind Cluster - - +-----------+ +----------+ +-------------+ +------------+ - | Frontend | | Rust API | | PostGraphile| | JupyterHub | - | Next.js | | Axum | | GraphQL | | | - | :31000 | | :31001 | | :31002 | | :31003 | - +-----+-----+ +----+-----+ +------+------+ +------+-----+ - | | | | - | +----+---------------+----+ | - | | PostgreSQL 16 | | - | | :5432 | | - | +-------------------------+ | - | | | - | +----+-----------+ | - | | Model Runner | +------------+ | - | | Pods (ephemeral| | User | | - | | - Python | | Notebook | | - | | - Rust | | Pods | | - | +----------------+ +------------+ | -``` +![Architecture](docs/diagrams/architecture.svg) ### Components diff --git a/docs/diagrams/architecture.svg b/docs/diagrams/architecture.svg new file mode 100644 index 0000000..a48daa3 --- /dev/null +++ b/docs/diagrams/architecture.svg @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + +OpenModelStudio — System Architecture +CLIENTS +Browser +Python SDK +CLI +EXTERNAL +GitHub RegistryModel Registry +LLM ProvidersOpenAI / Anthropic +S3 / MinIOArtifact Storage + +Kubernetes Cluster +namespace: openmodelstudio +CORE SERVICES +FrontendNext.js + shadcn/ui:31000 +Rust APIAxum + SQLx:31001 +PostGraphileAuto-gen GraphQL:31002 +JupyterHubWorkspace Mgmt:31003 +HTTP +REST +REST +REST + SSE + +GraphQL +DATA LAYER + +PostgreSQL 16 +27 tables · System of Record +:5432 +SQL +SQL +EPHEMERAL PODS + +Model Runner Pods +Ephemeral K8s Jobs +Python (PyTorch/sklearn) · Rust (tch-rs) +oms-job-* + +Workspace Pods +Per-User JupyterLab +SDK + Tutorial Notebooks + Datasets +oms-ws-* + +Job orchestration + +Metrics + Logs (HTTP) +Pod spawning + +SDK → REST +PERSISTENT VOLUMES + +models-pvc +Model Code + +datasets-pvc +Training Data + +artifacts-pvc +Job Outputs + +postgres-data +Database + +Model fetch + +HTTPS + +Presigned URLs + +Frontend / UI + +API / Backend + +Data / Storage + +Ephemeral Pods + +External Services + +Workspaces + \ No newline at end of file diff --git a/docs/diagrams/generate-architecture.js b/docs/diagrams/generate-architecture.js new file mode 100644 index 0000000..f84a5f1 --- /dev/null +++ b/docs/diagrams/generate-architecture.js @@ -0,0 +1,344 @@ +#!/usr/bin/env node +/** + * OpenModelStudio Architecture Diagram Generator + * + * Generates a professional SVG architecture diagram. + * Run: node docs/diagrams/generate-architecture.js + * Output: docs/diagrams/architecture.svg + */ + +const fs = require("fs"); +const path = require("path"); + +// ── Layout constants ────────────────────────────────────────────────── +const W = 1200; +const H = 620; +const PAD = 40; + +// Colors +const BG = "#0d1117"; +const BORDER = "rgba(255,255,255,0.08)"; +const TEXT_PRIMARY = "#e6edf3"; +const TEXT_SECONDARY = "rgba(230,237,243,0.55)"; +const TEXT_DIM = "rgba(230,237,243,0.35)"; +const LABEL_COLOR = "rgba(230,237,243,0.45)"; + +// Component colors +const VIOLET = { fill: "rgba(139,92,246,0.12)", stroke: "rgba(139,92,246,0.4)", text: "#a78bfa" }; +const BLUE = { fill: "rgba(59,130,246,0.12)", stroke: "rgba(59,130,246,0.4)", text: "#60a5fa" }; +const TEAL = { fill: "rgba(20,184,166,0.12)", stroke: "rgba(20,184,166,0.4)", text: "#2dd4bf" }; +const AMBER = { fill: "rgba(245,158,11,0.10)", stroke: "rgba(245,158,11,0.35)", text: "#fbbf24" }; +const SLATE = { fill: "rgba(148,163,184,0.08)", stroke: "rgba(148,163,184,0.25)", text: "#94a3b8" }; +const EMERALD = { fill: "rgba(16,185,129,0.10)", stroke: "rgba(16,185,129,0.35)", text: "#34d399" }; + +// ── SVG helpers ─────────────────────────────────────────────────────── + +function roundRect(x, y, w, h, { fill, stroke }, rx = 10, extra = "") { + return ``; +} + +function text(x, y, content, { size = 13, fill = TEXT_PRIMARY, weight = 400, anchor = "middle", family = "'Inter','Segoe UI',system-ui,sans-serif" } = {}) { + return `${content}`; +} + +function monoText(x, y, content, { size = 10, fill = TEXT_DIM } = {}) { + return `${content}`; +} + +function serviceBox(x, y, w, h, title, subtitle, port, color) { + const cx = x + w / 2; + let s = roundRect(x, y, w, h, color, 8); + s += text(cx, y + h / 2 - 8, title, { size: 13, fill: color.text, weight: 600 }); + s += text(cx, y + h / 2 + 7, subtitle, { size: 10, fill: TEXT_SECONDARY }); + if (port) { + s += monoText(cx, y + h / 2 + 22, port, { size: 9, fill: TEXT_DIM }); + } + return s; +} + +function smallBox(x, y, w, h, title, color, subtitle) { + const cx = x + w / 2; + let s = roundRect(x, y, w, h, color, 6); + s += text(cx, y + h / 2 - (subtitle ? 4 : 0), title, { size: 11, fill: color.text, weight: 500 }); + if (subtitle) { + s += text(cx, y + h / 2 + 10, subtitle, { size: 9, fill: TEXT_DIM }); + } + return s; +} + +function arrow(x1, y1, x2, y2, label, { color = "rgba(255,255,255,0.15)", dashed = false, labelSide = "right" } = {}) { + const dashAttr = dashed ? ' stroke-dasharray="4,3"' : ""; + const mx = (x1 + x2) / 2; + const my = (y1 + y2) / 2; + // arrowhead marker is defined in defs + let s = ``; + if (label) { + const isVertical = Math.abs(y2 - y1) > Math.abs(x2 - x1); + if (isVertical) { + const lx = labelSide === "right" ? mx + 8 : mx - 8; + const anc = labelSide === "right" ? "start" : "end"; + s += text(lx, my, label, { size: 9, fill: LABEL_COLOR, anchor: anc }); + } else { + s += text(mx, my - 6, label, { size: 9, fill: LABEL_COLOR }); + } + } + return s; +} + +function biArrow(x1, y1, x2, y2, label, opts = {}) { + const color = opts.color || "rgba(255,255,255,0.15)"; + const dashed = opts.dashed ? ' stroke-dasharray="4,3"' : ""; + const mx = (x1 + x2) / 2; + const my = (y1 + y2) / 2; + let s = ``; + if (label) { + const isVertical = Math.abs(y2 - y1) > Math.abs(x2 - x1); + if (isVertical) { + const labelSide = opts.labelSide || "right"; + const lx = labelSide === "right" ? mx + 8 : mx - 8; + const anc = labelSide === "right" ? "start" : "end"; + s += text(lx, my, label, { size: 9, fill: LABEL_COLOR, anchor: anc }); + } else { + s += text(mx, my - 6, label, { size: 9, fill: LABEL_COLOR }); + } + } + return s; +} + +function sectionLabel(x, y, label) { + return text(x, y, label, { size: 10, fill: TEXT_DIM, weight: 600, anchor: "start" }); +} + +// ── Build the SVG ───────────────────────────────────────────────────── + +function generateSVG() { + const parts = []; + + parts.push(``); + + // Defs (arrowheads, filters) + parts.push(` + + + + + + + + + + + `); + + // Background + parts.push(``); + + // Title + parts.push(text(W / 2, 32, "OpenModelStudio — System Architecture", { size: 18, fill: TEXT_PRIMARY, weight: 700 })); + + // ──────────────────────────────────────────────────────────────── + // LAYER 1: Users & Clients (top) + // ──────────────────────────────────────────────────────────────── + const clientY = 52; + parts.push(sectionLabel(PAD, clientY + 10, "CLIENTS")); + + const clientBoxW = 120; + const clientBoxH = 38; + const clientSpacing = 160; + const clientStartX = W / 2 - (clientSpacing * 1); // 3 items centered + + parts.push(smallBox(clientStartX, clientY, clientBoxW, clientBoxH, "Browser", SLATE)); + parts.push(smallBox(clientStartX + clientSpacing, clientY, clientBoxW, clientBoxH, "Python SDK", SLATE)); + parts.push(smallBox(clientStartX + clientSpacing * 2, clientY, clientBoxW, clientBoxH, "CLI", SLATE)); + + // ──────────────────────────────────────────────────────────────── + // LAYER 2: External integrations (left and right of cluster) + // ──────────────────────────────────────────────────────────────── + + // External services — positioned on the left side + const extY = 155; + const extW = 130; + const extH = 55; + const extGap = 12; + + // Left side externals + parts.push(sectionLabel(PAD, extY - 14, "EXTERNAL")); + parts.push(smallBox(PAD, extY, extW, extH, "GitHub Registry", SLATE, "Model Registry")); + parts.push(smallBox(PAD, extY + extH + extGap, extW, extH, "LLM Providers", SLATE, "OpenAI / Anthropic")); + parts.push(smallBox(PAD, extY + (extH + extGap) * 2, extW, extH, "S3 / MinIO", SLATE, "Artifact Storage")); + + // ──────────────────────────────────────────────────────────────── + // LAYER 3: Kubernetes Cluster (main container) + // ──────────────────────────────────────────────────────────────── + const clusterX = PAD + extW + 30; + const clusterY = 108; + const clusterW = W - clusterX - PAD; + const clusterH = 460; // fits: services + db + pods + pvcs + + // Cluster background + parts.push(roundRect(clusterX, clusterY, clusterW, clusterH, { fill: "rgba(255,255,255,0.02)", stroke: "rgba(255,255,255,0.06)" }, 16)); + + // Cluster label + parts.push(text(clusterX + 16, clusterY + 20, "Kubernetes Cluster", { size: 11, fill: TEXT_DIM, weight: 600, anchor: "start" })); + parts.push(monoText(clusterX + clusterW - 60, clusterY + 20, "namespace: openmodelstudio", { size: 9, fill: TEXT_DIM })); + + // ── Core Services Row ── + const svcY = clusterY + 40; + const svcH = 62; + const svcGap = 16; + const svcCount = 4; + const svcTotalW = clusterW - 40; + const svcW = (svcTotalW - svcGap * (svcCount - 1)) / svcCount; + const svcStartX = clusterX + 20; + + parts.push(sectionLabel(svcStartX, svcY - 4, "CORE SERVICES")); + + parts.push(serviceBox(svcStartX, svcY, svcW, svcH, "Frontend", "Next.js + shadcn/ui", ":31000", VIOLET)); + parts.push(serviceBox(svcStartX + (svcW + svcGap), svcY, svcW, svcH, "Rust API", "Axum + SQLx", ":31001", BLUE)); + parts.push(serviceBox(svcStartX + (svcW + svcGap) * 2, svcY, svcW, svcH, "PostGraphile", "Auto-gen GraphQL", ":31002", BLUE)); + parts.push(serviceBox(svcStartX + (svcW + svcGap) * 3, svcY, svcW, svcH, "JupyterHub", "Workspace Mgmt", ":31003", EMERALD)); + + // ── Arrows: Clients → Core Services ── + // Browser → Frontend + const browserCx = clientStartX + clientBoxW / 2; + const sdkCx = clientStartX + clientSpacing + clientBoxW / 2; + const cliCx = clientStartX + clientSpacing * 2 + clientBoxW / 2; + const frontendCx = svcStartX + svcW / 2; + const apiCx = svcStartX + (svcW + svcGap) + svcW / 2; + const pgCx = svcStartX + (svcW + svcGap) * 2 + svcW / 2; + const jupCx = svcStartX + (svcW + svcGap) * 3 + svcW / 2; + + parts.push(arrow(browserCx, clientY + clientBoxH, frontendCx, svcY, "HTTP", { color: VIOLET.stroke })); + parts.push(arrow(sdkCx, clientY + clientBoxH, apiCx, svcY, "REST", { color: BLUE.stroke })); + parts.push(arrow(cliCx, clientY + clientBoxH, apiCx + 20, svcY, "REST", { color: BLUE.stroke, labelSide: "left" })); + + // ── Frontend ↔ API arrows (horizontal) ── + const svcMidY = svcY + svcH / 2; + parts.push(biArrow(svcStartX + svcW, svcMidY, svcStartX + svcW + svcGap, svcMidY, "REST + SSE", { color: "rgba(139,92,246,0.3)" })); + // Frontend ↔ PostGraphile + parts.push(biArrow(svcStartX + svcW, svcMidY + 12, svcStartX + (svcW + svcGap) * 2, svcMidY + 12, "", { color: "rgba(96,165,250,0.2)", dashed: true })); + parts.push(text(svcStartX + svcW + (svcW + svcGap), svcMidY + 26, "GraphQL", { size: 8, fill: TEXT_DIM })); + + // ── Database Layer ── + const dbY = svcY + svcH + 50; + const dbW = 260; + const dbH = 60; + const dbX = clusterX + clusterW / 2 - dbW / 2; + + parts.push(sectionLabel(svcStartX, dbY - 4, "DATA LAYER")); + parts.push(roundRect(dbX, dbY, dbW, dbH, TEAL, 8)); + parts.push(text(dbX + dbW / 2, dbY + dbH / 2 - 10, "PostgreSQL 16", { size: 14, fill: TEAL.text, weight: 600 })); + parts.push(text(dbX + dbW / 2, dbY + dbH / 2 + 6, "27 tables \u00b7 System of Record", { size: 10, fill: TEXT_SECONDARY })); + parts.push(monoText(dbX + dbW / 2, dbY + dbH / 2 + 20, ":5432", { size: 9 })); + + // API → DB arrow + parts.push(biArrow(apiCx, svcY + svcH, dbX + dbW / 2 - 20, dbY, "SQL", { color: TEAL.stroke, labelSide: "left" })); + // PostGraphile → DB arrow + parts.push(biArrow(pgCx, svcY + svcH, dbX + dbW / 2 + 20, dbY, "SQL", { color: TEAL.stroke })); + + // ── Ephemeral Pods ── + const podY = dbY + dbH + 50; + const podH = 72; + const podGap = 24; + const podW = (clusterW - 40 - podGap) / 2; + + parts.push(sectionLabel(svcStartX, podY - 4, "EPHEMERAL PODS")); + + // Model Runner Pods + const runnerX = svcStartX; + parts.push(roundRect(runnerX, podY, podW, podH, AMBER, 8, 'stroke-dasharray="6,3"')); + parts.push(text(runnerX + podW / 2, podY + 18, "Model Runner Pods", { size: 12, fill: AMBER.text, weight: 600 })); + parts.push(text(runnerX + podW / 2, podY + 34, "Ephemeral K8s Jobs", { size: 10, fill: TEXT_SECONDARY })); + parts.push(text(runnerX + podW / 2, podY + 50, "Python (PyTorch/sklearn) \u00b7 Rust (tch-rs)", { size: 9, fill: TEXT_DIM })); + parts.push(monoText(runnerX + podW / 2, podY + 64, "oms-job-*", { size: 9 })); + + // Workspace Pods + const wsX = svcStartX + podW + podGap; + parts.push(roundRect(wsX, podY, podW, podH, EMERALD, 8, 'stroke-dasharray="6,3"')); + parts.push(text(wsX + podW / 2, podY + 18, "Workspace Pods", { size: 12, fill: EMERALD.text, weight: 600 })); + parts.push(text(wsX + podW / 2, podY + 34, "Per-User JupyterLab", { size: 10, fill: TEXT_SECONDARY })); + parts.push(text(wsX + podW / 2, podY + 50, "SDK + Tutorial Notebooks + Datasets", { size: 9, fill: TEXT_DIM })); + parts.push(monoText(wsX + podW / 2, podY + 64, "oms-ws-*", { size: 9 })); + + // API → Model Pods arrow + parts.push(arrow(apiCx, svcY + svcH, runnerX + podW / 2, podY, "", { color: AMBER.stroke, dashed: true })); + parts.push(text(apiCx - 40, (svcY + svcH + podY) / 2 + 30, "Job orchestration", { size: 8, fill: LABEL_COLOR, anchor: "end" })); + + // Model Pods → API (metrics) + parts.push(arrow(runnerX + podW / 2 + 30, podY, apiCx + 30, svcY + svcH, "", { color: AMBER.stroke })); + parts.push(text(apiCx + 60, (svcY + svcH + podY) / 2 + 30, "Metrics + Logs (HTTP)", { size: 8, fill: LABEL_COLOR, anchor: "start" })); + + // JupyterHub → Workspace Pods + parts.push(arrow(jupCx, svcY + svcH, wsX + podW / 2, podY, "Pod spawning", { color: EMERALD.stroke, dashed: true })); + + // Workspace SDK → API + parts.push(biArrow(wsX + podW / 2 - 30, podY, apiCx + 60, svcY + svcH, "", { color: BLUE.stroke, dashed: true })); + parts.push(text(wsX + 10, (svcY + svcH + podY) / 2 + 40, "SDK → REST", { size: 8, fill: LABEL_COLOR, anchor: "start" })); + + // ── Persistent Volumes ── + const pvY = podY + podH + 40; + const pvCount = 4; + const pvGap = 12; + const pvTotalW = clusterW - 40; + const pvW = (pvTotalW - pvGap * (pvCount - 1)) / pvCount; + + parts.push(sectionLabel(svcStartX, pvY - 4, "PERSISTENT VOLUMES")); + + const pvNames = ["models-pvc", "datasets-pvc", "artifacts-pvc", "postgres-data"]; + const pvLabels = ["Model Code", "Training Data", "Job Outputs", "Database"]; + for (let i = 0; i < pvCount; i++) { + const px = svcStartX + i * (pvW + pvGap); + parts.push(roundRect(px, pvY, pvW, 42, { fill: "rgba(255,255,255,0.03)", stroke: "rgba(255,255,255,0.06)" }, 6)); + // Storage icon (simple cylinder-ish shape) + parts.push(monoText(px + pvW / 2, pvY + 16, pvNames[i], { size: 9, fill: TEXT_SECONDARY })); + parts.push(text(px + pvW / 2, pvY + 32, pvLabels[i], { size: 9, fill: TEXT_DIM })); + } + + // ── External Integration Arrows ── + const extMidX = PAD + extW; + const apiLeft = svcStartX + (svcW + svcGap); // left edge of API box + + // GitHub Registry → API + const ghMidY = extY + extH / 2; + parts.push(biArrow(extMidX, ghMidY, apiLeft, svcMidY - 8, "", { color: SLATE.stroke })); + parts.push(text(extMidX + 24, ghMidY - 8, "Model fetch", { size: 8, fill: LABEL_COLOR, anchor: "start" })); + + // LLM → API + const llmMidY = extY + extH + extGap + extH / 2; + parts.push(biArrow(extMidX, llmMidY, apiLeft, svcMidY + 8, "", { color: SLATE.stroke })); + parts.push(text(extMidX + 24, llmMidY + 14, "HTTPS", { size: 8, fill: LABEL_COLOR, anchor: "start" })); + + // S3 → API + const s3MidY = extY + (extH + extGap) * 2 + extH / 2; + parts.push(biArrow(extMidX, s3MidY, apiLeft, svcY + svcH + 10, "", { color: SLATE.stroke })); + parts.push(text(extMidX + 24, s3MidY + 14, "Presigned URLs", { size: 8, fill: LABEL_COLOR, anchor: "start" })); + + // ── Legend ── + const legY = H - 32; + const legX = PAD; + const legSpacing = 170; + const legends = [ + { label: "Frontend / UI", color: VIOLET }, + { label: "API / Backend", color: BLUE }, + { label: "Data / Storage", color: TEAL }, + { label: "Ephemeral Pods", color: AMBER }, + { label: "External Services", color: SLATE }, + { label: "Workspaces", color: EMERALD }, + ]; + for (let i = 0; i < legends.length; i++) { + const lx = legX + i * legSpacing; + parts.push(``); + parts.push(text(lx + 18, legY + 4, legends[i].label, { size: 9, fill: TEXT_SECONDARY, anchor: "start" })); + } + + parts.push(""); + + return parts.join("\n"); +} + +// ── Main ────────────────────────────────────────────────────────────── + +const svg = generateSVG(); +const outPath = path.join(__dirname, "architecture.svg"); +fs.writeFileSync(outPath, svg); +console.log(`Generated ${outPath} (${(svg.length / 1024).toFixed(1)} KB)`); From 89c015eca59dfbb67e52ec9241169d316bfcc5eb Mon Sep 17 00:00:00 2001 From: jovonni Date: Sun, 8 Mar 2026 14:32:54 -0400 Subject: [PATCH 2/3] Redesign architecture diagram with clean grid layout and elbow connectors Replaced messy straight-line arrows with orthogonal elbow connectors. Strict grid-based layout with clear visual hierarchy: - Row 0: Clients (Browser, SDK, CLI) - Row 1: Core Services (Frontend, API, PostGraphile, JupyterHub) - Row 2: PostgreSQL data layer - Row 3: Ephemeral pods (Model Runners, Workspaces) - Row 4: Persistent Volumes - Left column: External services (GitHub, LLMs, S3) --- docs/diagrams/architecture.svg | 203 +++++---- docs/diagrams/generate-architecture.js | 574 ++++++++++++------------- 2 files changed, 391 insertions(+), 386 deletions(-) diff --git a/docs/diagrams/architecture.svg b/docs/diagrams/architecture.svg index a48daa3..19b65c2 100644 --- a/docs/diagrams/architecture.svg +++ b/docs/diagrams/architecture.svg @@ -1,94 +1,113 @@ - + - - - - - - - - - - - - -OpenModelStudio — System Architecture -CLIENTS -Browser -Python SDK -CLI -EXTERNAL -GitHub RegistryModel Registry -LLM ProvidersOpenAI / Anthropic -S3 / MinIOArtifact Storage - -Kubernetes Cluster -namespace: openmodelstudio -CORE SERVICES -FrontendNext.js + shadcn/ui:31000 -Rust APIAxum + SQLx:31001 -PostGraphileAuto-gen GraphQL:31002 -JupyterHubWorkspace Mgmt:31003 -HTTP -REST -REST -REST + SSE - -GraphQL -DATA LAYER - -PostgreSQL 16 -27 tables · System of Record -:5432 -SQL -SQL -EPHEMERAL PODS - -Model Runner Pods -Ephemeral K8s Jobs -Python (PyTorch/sklearn) · Rust (tch-rs) -oms-job-* - -Workspace Pods -Per-User JupyterLab -SDK + Tutorial Notebooks + Datasets -oms-ws-* - -Job orchestration - -Metrics + Logs (HTTP) -Pod spawning - -SDK → REST -PERSISTENT VOLUMES - -models-pvc -Model Code - -datasets-pvc -Training Data - -artifacts-pvc -Job Outputs - -postgres-data -Database - -Model fetch - -HTTPS - -Presigned URLs - -Frontend / UI - -API / Backend - -Data / Storage - -Ephemeral Pods - -External Services - -Workspaces + + + + +OpenModelStudio — System Architecture + +Browser + +Python SDK + +CLI + +Kubernetes Cluster +ns: openmodelstudio +CORE SERVICES + +Frontend +Next.js + shadcn/ui +:31000 + +Rust API +Axum + SQLx +:31001 + +PostGraphile +Auto-gen GraphQL +:31002 + +JupyterHub +Workspace Manager +:31003 + +HTTP + +REST + +REST + +REST + SSE + +GraphQL +DATA LAYER + +PostgreSQL 16 +27 tables · System of Record +:5432 + +SQL + +SQL +EPHEMERAL PODS + +Model Runner Pods +Ephemeral K8s Jobs +Python (PyTorch / sklearn) · Rust (tch-rs) +oms-job-* + +Workspace Pods +Per-User JupyterLab +SDK + Tutorial Notebooks + Datasets +oms-ws-* + +Job orchestration + +Metrics + Logs + +Pod spawning + +SDK → REST +PERSISTENT VOLUMES + +models-pvc +Model Code + +datasets-pvc +Training Data + +artifacts-pvc +Job Outputs + +postgres-data +Database +EXTERNAL SERVICES + +GitHub Registry +Open Model Registry + +LLM Providers +OpenAI / Anthropic / Ollama + +S3 / MinIO +Artifact Storage + +Model fetch + +HTTPS + +Presigned URLs + +Frontend / UI + +API / Backend + +Data Layer + +Ephemeral Pods + +Workspaces + +External \ No newline at end of file diff --git a/docs/diagrams/generate-architecture.js b/docs/diagrams/generate-architecture.js index f84a5f1..cb51447 100644 --- a/docs/diagrams/generate-architecture.js +++ b/docs/diagrams/generate-architecture.js @@ -2,343 +2,329 @@ /** * OpenModelStudio Architecture Diagram Generator * - * Generates a professional SVG architecture diagram. - * Run: node docs/diagrams/generate-architecture.js + * Generates a clean, professional SVG architecture diagram with + * orthogonal (elbow) connectors and strict grid alignment. + * + * Run: node docs/diagrams/generate-architecture.js * Output: docs/diagrams/architecture.svg */ const fs = require("fs"); const path = require("path"); -// ── Layout constants ────────────────────────────────────────────────── -const W = 1200; -const H = 620; -const PAD = 40; +// ── Canvas ──────────────────────────────────────────────────────────── +const W = 1100; +const H = 700; -// Colors +// ── Colors ──────────────────────────────────────────────────────────── const BG = "#0d1117"; -const BORDER = "rgba(255,255,255,0.08)"; const TEXT_PRIMARY = "#e6edf3"; -const TEXT_SECONDARY = "rgba(230,237,243,0.55)"; -const TEXT_DIM = "rgba(230,237,243,0.35)"; -const LABEL_COLOR = "rgba(230,237,243,0.45)"; - -// Component colors -const VIOLET = { fill: "rgba(139,92,246,0.12)", stroke: "rgba(139,92,246,0.4)", text: "#a78bfa" }; -const BLUE = { fill: "rgba(59,130,246,0.12)", stroke: "rgba(59,130,246,0.4)", text: "#60a5fa" }; -const TEAL = { fill: "rgba(20,184,166,0.12)", stroke: "rgba(20,184,166,0.4)", text: "#2dd4bf" }; -const AMBER = { fill: "rgba(245,158,11,0.10)", stroke: "rgba(245,158,11,0.35)", text: "#fbbf24" }; -const SLATE = { fill: "rgba(148,163,184,0.08)", stroke: "rgba(148,163,184,0.25)", text: "#94a3b8" }; -const EMERALD = { fill: "rgba(16,185,129,0.10)", stroke: "rgba(16,185,129,0.35)", text: "#34d399" }; - -// ── SVG helpers ─────────────────────────────────────────────────────── - -function roundRect(x, y, w, h, { fill, stroke }, rx = 10, extra = "") { - return ``; +const TEXT_SEC = "rgba(230,237,243,0.5)"; +const TEXT_DIM = "rgba(230,237,243,0.3)"; +const LINE_COLOR = "rgba(230,237,243,0.12)"; + +const C = { + violet: { bg: "rgba(139,92,246,0.10)", border: "rgba(139,92,246,0.35)", text: "#a78bfa" }, + blue: { bg: "rgba(59,130,246,0.10)", border: "rgba(59,130,246,0.35)", text: "#60a5fa" }, + teal: { bg: "rgba(20,184,166,0.10)", border: "rgba(20,184,166,0.35)", text: "#2dd4bf" }, + amber: { bg: "rgba(245,158,11,0.08)", border: "rgba(245,158,11,0.30)", text: "#fbbf24" }, + emerald: { bg: "rgba(16,185,129,0.08)", border: "rgba(16,185,129,0.30)", text: "#34d399" }, + slate: { bg: "rgba(148,163,184,0.06)", border: "rgba(148,163,184,0.20)", text: "#94a3b8" }, + storage: { bg: "rgba(255,255,255,0.025)", border: "rgba(255,255,255,0.06)", text: TEXT_SEC }, +}; + +// ── SVG primitives ──────────────────────────────────────────────────── +const p = []; // SVG parts accumulator + +function rect(x, y, w, h, color, { rx = 8, dash = false } = {}) { + const d = dash ? ` stroke-dasharray="6,4"` : ""; + p.push(``); } -function text(x, y, content, { size = 13, fill = TEXT_PRIMARY, weight = 400, anchor = "middle", family = "'Inter','Segoe UI',system-ui,sans-serif" } = {}) { - return `${content}`; +function txt(x, y, str, { size = 12, fill = TEXT_PRIMARY, bold = false, anchor = "middle", mono = false } = {}) { + const fam = mono + ? `'JetBrains Mono','SF Mono','Fira Code',monospace` + : `'Inter','Segoe UI',system-ui,sans-serif`; + const fw = bold ? 600 : 400; + p.push(`${str}`); } -function monoText(x, y, content, { size = 10, fill = TEXT_DIM } = {}) { - return `${content}`; +function label(x, y, str) { + txt(x, y, str.toUpperCase(), { size: 9, fill: TEXT_DIM, bold: true, anchor: "start" }); } -function serviceBox(x, y, w, h, title, subtitle, port, color) { - const cx = x + w / 2; - let s = roundRect(x, y, w, h, color, 8); - s += text(cx, y + h / 2 - 8, title, { size: 13, fill: color.text, weight: 600 }); - s += text(cx, y + h / 2 + 7, subtitle, { size: 10, fill: TEXT_SECONDARY }); - if (port) { - s += monoText(cx, y + h / 2 + 22, port, { size: 9, fill: TEXT_DIM }); - } - return s; +// Service box: colored rect with title, subtitle, optional port +function svc(x, y, w, h, title, sub, port, color) { + rect(x, y, w, h, color); + txt(x + w / 2, y + (port ? 20 : h / 2 - 3), title, { size: 12, fill: color.text, bold: true }); + txt(x + w / 2, y + (port ? 35 : h / 2 + 12), sub, { size: 9, fill: TEXT_SEC }); + if (port) txt(x + w / 2, y + 50, port, { size: 8, fill: TEXT_DIM, mono: true }); } -function smallBox(x, y, w, h, title, color, subtitle) { - const cx = x + w / 2; - let s = roundRect(x, y, w, h, color, 6); - s += text(cx, y + h / 2 - (subtitle ? 4 : 0), title, { size: 11, fill: color.text, weight: 500 }); - if (subtitle) { - s += text(cx, y + h / 2 + 10, subtitle, { size: 9, fill: TEXT_DIM }); - } - return s; +function pill(x, y, w, h, title, color, sub) { + rect(x, y, w, h, color, { rx: 6 }); + txt(x + w / 2, y + (sub ? h / 2 - 4 : h / 2 + 4), title, { size: 10, fill: color.text, bold: true }); + if (sub) txt(x + w / 2, y + h / 2 + 10, sub, { size: 8, fill: TEXT_DIM }); } -function arrow(x1, y1, x2, y2, label, { color = "rgba(255,255,255,0.15)", dashed = false, labelSide = "right" } = {}) { - const dashAttr = dashed ? ' stroke-dasharray="4,3"' : ""; - const mx = (x1 + x2) / 2; - const my = (y1 + y2) / 2; - // arrowhead marker is defined in defs - let s = ``; - if (label) { - const isVertical = Math.abs(y2 - y1) > Math.abs(x2 - x1); - if (isVertical) { - const lx = labelSide === "right" ? mx + 8 : mx - 8; - const anc = labelSide === "right" ? "start" : "end"; - s += text(lx, my, label, { size: 9, fill: LABEL_COLOR, anchor: anc }); - } else { - s += text(mx, my - 6, label, { size: 9, fill: LABEL_COLOR }); - } +// ── Elbow connectors ────────────────────────────────────────────────── +// All connectors use orthogonal paths (only horizontal + vertical segments) + +function elbowV(x1, y1, x2, y2, lbl, { color = LINE_COLOR, dash = false, arrow = "end", lblPos = "right" } = {}) { + // Vertical-first elbow: go down to midY, then horizontal, then down + const midY = (y1 + y2) / 2; + const d = `M${x1},${y1} V${midY} H${x2} V${y2}`; + const da = dash ? ` stroke-dasharray="4,3"` : ""; + let markers = ""; + if (arrow === "end") markers = ` marker-end="url(#ah)"`; + if (arrow === "both") markers = ` marker-start="url(#ah-r)" marker-end="url(#ah)"`; + p.push(``); + if (lbl) { + const lx = lblPos === "right" ? Math.max(x1, x2) + 6 : Math.min(x1, x2) - 6; + const anc = lblPos === "right" ? "start" : "end"; + txt(lx, midY + 3, lbl, { size: 8, fill: TEXT_DIM, anchor: anc }); } - return s; } -function biArrow(x1, y1, x2, y2, label, opts = {}) { - const color = opts.color || "rgba(255,255,255,0.15)"; - const dashed = opts.dashed ? ' stroke-dasharray="4,3"' : ""; - const mx = (x1 + x2) / 2; - const my = (y1 + y2) / 2; - let s = ``; - if (label) { - const isVertical = Math.abs(y2 - y1) > Math.abs(x2 - x1); - if (isVertical) { - const labelSide = opts.labelSide || "right"; - const lx = labelSide === "right" ? mx + 8 : mx - 8; - const anc = labelSide === "right" ? "start" : "end"; - s += text(lx, my, label, { size: 9, fill: LABEL_COLOR, anchor: anc }); - } else { - s += text(mx, my - 6, label, { size: 9, fill: LABEL_COLOR }); - } +function elbowH(x1, y1, x2, y2, lbl, { color = LINE_COLOR, dash = false, arrow = "end" } = {}) { + // Horizontal-first elbow: go right to midX, then vertical, then right + const midX = (x1 + x2) / 2; + const d = `M${x1},${y1} H${midX} V${y2} H${x2}`; + const da = dash ? ` stroke-dasharray="4,3"` : ""; + let markers = ""; + if (arrow === "end") markers = ` marker-end="url(#ah)"`; + if (arrow === "both") markers = ` marker-start="url(#ah-r)" marker-end="url(#ah)"`; + p.push(``); + if (lbl) { + txt(midX, Math.min(y1, y2) - 5, lbl, { size: 8, fill: TEXT_DIM }); } - return s; } -function sectionLabel(x, y, label) { - return text(x, y, label, { size: 10, fill: TEXT_DIM, weight: 600, anchor: "start" }); +function lineH(x1, y, x2, lbl, { color = LINE_COLOR, arrow = "end" } = {}) { + let markers = ""; + if (arrow === "end") markers = ` marker-end="url(#ah)"`; + if (arrow === "both") markers = ` marker-start="url(#ah-r)" marker-end="url(#ah)"`; + p.push(``); + if (lbl) txt((x1 + x2) / 2, y - 6, lbl, { size: 8, fill: TEXT_DIM }); } -// ── Build the SVG ───────────────────────────────────────────────────── - -function generateSVG() { - const parts = []; +function lineV(x, y1, y2, lbl, { color = LINE_COLOR, arrow = "end", dash = false, lblPos = "right" } = {}) { + const da = dash ? ` stroke-dasharray="4,3"` : ""; + let markers = ""; + if (arrow === "end") markers = ` marker-end="url(#ah)"`; + if (arrow === "both") markers = ` marker-start="url(#ah-r)" marker-end="url(#ah)"`; + p.push(``); + if (lbl) { + const lx = lblPos === "right" ? x + 7 : x - 7; + const anc = lblPos === "right" ? "start" : "end"; + txt(lx, (y1 + y2) / 2 + 3, lbl, { size: 8, fill: TEXT_DIM, anchor: anc }); + } +} - parts.push(``); +// ── GENERATE ────────────────────────────────────────────────────────── - // Defs (arrowheads, filters) - parts.push(` - - - - - - - - - - - `); +function generate() { + p.push(``); + p.push(` + + +`); // Background - parts.push(``); + p.push(``); // Title - parts.push(text(W / 2, 32, "OpenModelStudio — System Architecture", { size: 18, fill: TEXT_PRIMARY, weight: 700 })); - - // ──────────────────────────────────────────────────────────────── - // LAYER 1: Users & Clients (top) - // ──────────────────────────────────────────────────────────────── - const clientY = 52; - parts.push(sectionLabel(PAD, clientY + 10, "CLIENTS")); - - const clientBoxW = 120; - const clientBoxH = 38; - const clientSpacing = 160; - const clientStartX = W / 2 - (clientSpacing * 1); // 3 items centered - - parts.push(smallBox(clientStartX, clientY, clientBoxW, clientBoxH, "Browser", SLATE)); - parts.push(smallBox(clientStartX + clientSpacing, clientY, clientBoxW, clientBoxH, "Python SDK", SLATE)); - parts.push(smallBox(clientStartX + clientSpacing * 2, clientY, clientBoxW, clientBoxH, "CLI", SLATE)); - - // ──────────────────────────────────────────────────────────────── - // LAYER 2: External integrations (left and right of cluster) - // ──────────────────────────────────────────────────────────────── - - // External services — positioned on the left side - const extY = 155; - const extW = 130; - const extH = 55; - const extGap = 12; - - // Left side externals - parts.push(sectionLabel(PAD, extY - 14, "EXTERNAL")); - parts.push(smallBox(PAD, extY, extW, extH, "GitHub Registry", SLATE, "Model Registry")); - parts.push(smallBox(PAD, extY + extH + extGap, extW, extH, "LLM Providers", SLATE, "OpenAI / Anthropic")); - parts.push(smallBox(PAD, extY + (extH + extGap) * 2, extW, extH, "S3 / MinIO", SLATE, "Artifact Storage")); - - // ──────────────────────────────────────────────────────────────── - // LAYER 3: Kubernetes Cluster (main container) - // ──────────────────────────────────────────────────────────────── - const clusterX = PAD + extW + 30; - const clusterY = 108; - const clusterW = W - clusterX - PAD; - const clusterH = 460; // fits: services + db + pods + pvcs - - // Cluster background - parts.push(roundRect(clusterX, clusterY, clusterW, clusterH, { fill: "rgba(255,255,255,0.02)", stroke: "rgba(255,255,255,0.06)" }, 16)); - - // Cluster label - parts.push(text(clusterX + 16, clusterY + 20, "Kubernetes Cluster", { size: 11, fill: TEXT_DIM, weight: 600, anchor: "start" })); - parts.push(monoText(clusterX + clusterW - 60, clusterY + 20, "namespace: openmodelstudio", { size: 9, fill: TEXT_DIM })); - - // ── Core Services Row ── - const svcY = clusterY + 40; - const svcH = 62; - const svcGap = 16; - const svcCount = 4; - const svcTotalW = clusterW - 40; - const svcW = (svcTotalW - svcGap * (svcCount - 1)) / svcCount; - const svcStartX = clusterX + 20; - - parts.push(sectionLabel(svcStartX, svcY - 4, "CORE SERVICES")); - - parts.push(serviceBox(svcStartX, svcY, svcW, svcH, "Frontend", "Next.js + shadcn/ui", ":31000", VIOLET)); - parts.push(serviceBox(svcStartX + (svcW + svcGap), svcY, svcW, svcH, "Rust API", "Axum + SQLx", ":31001", BLUE)); - parts.push(serviceBox(svcStartX + (svcW + svcGap) * 2, svcY, svcW, svcH, "PostGraphile", "Auto-gen GraphQL", ":31002", BLUE)); - parts.push(serviceBox(svcStartX + (svcW + svcGap) * 3, svcY, svcW, svcH, "JupyterHub", "Workspace Mgmt", ":31003", EMERALD)); - - // ── Arrows: Clients → Core Services ── - // Browser → Frontend - const browserCx = clientStartX + clientBoxW / 2; - const sdkCx = clientStartX + clientSpacing + clientBoxW / 2; - const cliCx = clientStartX + clientSpacing * 2 + clientBoxW / 2; - const frontendCx = svcStartX + svcW / 2; - const apiCx = svcStartX + (svcW + svcGap) + svcW / 2; - const pgCx = svcStartX + (svcW + svcGap) * 2 + svcW / 2; - const jupCx = svcStartX + (svcW + svcGap) * 3 + svcW / 2; - - parts.push(arrow(browserCx, clientY + clientBoxH, frontendCx, svcY, "HTTP", { color: VIOLET.stroke })); - parts.push(arrow(sdkCx, clientY + clientBoxH, apiCx, svcY, "REST", { color: BLUE.stroke })); - parts.push(arrow(cliCx, clientY + clientBoxH, apiCx + 20, svcY, "REST", { color: BLUE.stroke, labelSide: "left" })); - - // ── Frontend ↔ API arrows (horizontal) ── - const svcMidY = svcY + svcH / 2; - parts.push(biArrow(svcStartX + svcW, svcMidY, svcStartX + svcW + svcGap, svcMidY, "REST + SSE", { color: "rgba(139,92,246,0.3)" })); - // Frontend ↔ PostGraphile - parts.push(biArrow(svcStartX + svcW, svcMidY + 12, svcStartX + (svcW + svcGap) * 2, svcMidY + 12, "", { color: "rgba(96,165,250,0.2)", dashed: true })); - parts.push(text(svcStartX + svcW + (svcW + svcGap), svcMidY + 26, "GraphQL", { size: 8, fill: TEXT_DIM })); - - // ── Database Layer ── - const dbY = svcY + svcH + 50; - const dbW = 260; - const dbH = 60; - const dbX = clusterX + clusterW / 2 - dbW / 2; - - parts.push(sectionLabel(svcStartX, dbY - 4, "DATA LAYER")); - parts.push(roundRect(dbX, dbY, dbW, dbH, TEAL, 8)); - parts.push(text(dbX + dbW / 2, dbY + dbH / 2 - 10, "PostgreSQL 16", { size: 14, fill: TEAL.text, weight: 600 })); - parts.push(text(dbX + dbW / 2, dbY + dbH / 2 + 6, "27 tables \u00b7 System of Record", { size: 10, fill: TEXT_SECONDARY })); - parts.push(monoText(dbX + dbW / 2, dbY + dbH / 2 + 20, ":5432", { size: 9 })); - - // API → DB arrow - parts.push(biArrow(apiCx, svcY + svcH, dbX + dbW / 2 - 20, dbY, "SQL", { color: TEAL.stroke, labelSide: "left" })); - // PostGraphile → DB arrow - parts.push(biArrow(pgCx, svcY + svcH, dbX + dbW / 2 + 20, dbY, "SQL", { color: TEAL.stroke })); - - // ── Ephemeral Pods ── - const podY = dbY + dbH + 50; - const podH = 72; - const podGap = 24; - const podW = (clusterW - 40 - podGap) / 2; - - parts.push(sectionLabel(svcStartX, podY - 4, "EPHEMERAL PODS")); - - // Model Runner Pods - const runnerX = svcStartX; - parts.push(roundRect(runnerX, podY, podW, podH, AMBER, 8, 'stroke-dasharray="6,3"')); - parts.push(text(runnerX + podW / 2, podY + 18, "Model Runner Pods", { size: 12, fill: AMBER.text, weight: 600 })); - parts.push(text(runnerX + podW / 2, podY + 34, "Ephemeral K8s Jobs", { size: 10, fill: TEXT_SECONDARY })); - parts.push(text(runnerX + podW / 2, podY + 50, "Python (PyTorch/sklearn) \u00b7 Rust (tch-rs)", { size: 9, fill: TEXT_DIM })); - parts.push(monoText(runnerX + podW / 2, podY + 64, "oms-job-*", { size: 9 })); - - // Workspace Pods - const wsX = svcStartX + podW + podGap; - parts.push(roundRect(wsX, podY, podW, podH, EMERALD, 8, 'stroke-dasharray="6,3"')); - parts.push(text(wsX + podW / 2, podY + 18, "Workspace Pods", { size: 12, fill: EMERALD.text, weight: 600 })); - parts.push(text(wsX + podW / 2, podY + 34, "Per-User JupyterLab", { size: 10, fill: TEXT_SECONDARY })); - parts.push(text(wsX + podW / 2, podY + 50, "SDK + Tutorial Notebooks + Datasets", { size: 9, fill: TEXT_DIM })); - parts.push(monoText(wsX + podW / 2, podY + 64, "oms-ws-*", { size: 9 })); - - // API → Model Pods arrow - parts.push(arrow(apiCx, svcY + svcH, runnerX + podW / 2, podY, "", { color: AMBER.stroke, dashed: true })); - parts.push(text(apiCx - 40, (svcY + svcH + podY) / 2 + 30, "Job orchestration", { size: 8, fill: LABEL_COLOR, anchor: "end" })); - - // Model Pods → API (metrics) - parts.push(arrow(runnerX + podW / 2 + 30, podY, apiCx + 30, svcY + svcH, "", { color: AMBER.stroke })); - parts.push(text(apiCx + 60, (svcY + svcH + podY) / 2 + 30, "Metrics + Logs (HTTP)", { size: 8, fill: LABEL_COLOR, anchor: "start" })); - - // JupyterHub → Workspace Pods - parts.push(arrow(jupCx, svcY + svcH, wsX + podW / 2, podY, "Pod spawning", { color: EMERALD.stroke, dashed: true })); - - // Workspace SDK → API - parts.push(biArrow(wsX + podW / 2 - 30, podY, apiCx + 60, svcY + svcH, "", { color: BLUE.stroke, dashed: true })); - parts.push(text(wsX + 10, (svcY + svcH + podY) / 2 + 40, "SDK → REST", { size: 8, fill: LABEL_COLOR, anchor: "start" })); - - // ── Persistent Volumes ── - const pvY = podY + podH + 40; - const pvCount = 4; - const pvGap = 12; - const pvTotalW = clusterW - 40; - const pvW = (pvTotalW - pvGap * (pvCount - 1)) / pvCount; - - parts.push(sectionLabel(svcStartX, pvY - 4, "PERSISTENT VOLUMES")); - - const pvNames = ["models-pvc", "datasets-pvc", "artifacts-pvc", "postgres-data"]; - const pvLabels = ["Model Code", "Training Data", "Job Outputs", "Database"]; - for (let i = 0; i < pvCount; i++) { - const px = svcStartX + i * (pvW + pvGap); - parts.push(roundRect(px, pvY, pvW, 42, { fill: "rgba(255,255,255,0.03)", stroke: "rgba(255,255,255,0.06)" }, 6)); - // Storage icon (simple cylinder-ish shape) - parts.push(monoText(px + pvW / 2, pvY + 16, pvNames[i], { size: 9, fill: TEXT_SECONDARY })); - parts.push(text(px + pvW / 2, pvY + 32, pvLabels[i], { size: 9, fill: TEXT_DIM })); + txt(W / 2, 28, "OpenModelStudio — System Architecture", { size: 16, bold: true }); + + // ═══════════════════════════════════════════════════════════════════ + // ROW 0: Clients (y = 48) + // ═══════════════════════════════════════════════════════════════════ + const R0 = 48; + const clientW = 110, clientH = 34; + // Center 3 clients above the cluster's core services + const c1x = 310, c2x = 530, c3x = 750; + pill(c1x, R0, clientW, clientH, "Browser", C.slate); + pill(c2x, R0, clientW, clientH, "Python SDK", C.slate); + pill(c3x, R0, clientW, clientH, "CLI", C.slate); + + // ═══════════════════════════════════════════════════════════════════ + // KUBERNETES CLUSTER BOX (main container) + // ═══════════════════════════════════════════════════════════════════ + const KX = 250, KY = 100, KW = 820, KH = 540; + rect(KX, KY, KW, KH, { bg: "rgba(255,255,255,0.015)", border: "rgba(255,255,255,0.05)" }, { rx: 14 }); + txt(KX + 14, KY + 16, "Kubernetes Cluster", { size: 10, fill: TEXT_DIM, bold: true, anchor: "start" }); + txt(KX + KW - 14, KY + 16, "ns: openmodelstudio", { size: 8, fill: TEXT_DIM, anchor: "end", mono: true }); + + // ═══════════════════════════════════════════════════════════════════ + // ROW 1: Core Services (y = 130) + // ═══════════════════════════════════════════════════════════════════ + const R1 = 130; + const sW = 175, sH = 58, sGap = 18; + const s1x = KX + 30; // Frontend + const s2x = s1x + sW + sGap; // Rust API + const s3x = s2x + sW + sGap; // PostGraphile + const s4x = s3x + sW + sGap; // JupyterHub + + label(s1x, R1 - 6, "Core Services"); + svc(s1x, R1, sW, sH, "Frontend", "Next.js + shadcn/ui", ":31000", C.violet); + svc(s2x, R1, sW, sH, "Rust API", "Axum + SQLx", ":31001", C.blue); + svc(s3x, R1, sW, sH, "PostGraphile", "Auto-gen GraphQL", ":31002", C.blue); + svc(s4x, R1, sW, sH, "JupyterHub", "Workspace Manager", ":31003", C.emerald); + + // Center-x of each service + const f_cx = s1x + sW / 2; + const a_cx = s2x + sW / 2; + const pg_cx = s3x + sW / 2; + const j_cx = s4x + sW / 2; + + // ── Client → Service arrows (straight vertical drops, clean) ── + lineV(c1x + clientW / 2, R0 + clientH, R1, "HTTP", { color: C.violet.border }); + lineV(c2x + clientW / 2, R0 + clientH, R1, "REST", { color: C.blue.border }); + // CLI arrow elbows to API + elbowV(c3x + clientW / 2, R0 + clientH, a_cx + 20, R1, "REST", { color: C.blue.border, lblPos: "left" }); + + // ── Horizontal: Frontend ↔ API ── + lineH(s1x + sW, R1 + sH / 2, s2x, "REST + SSE", { arrow: "both", color: C.violet.border }); + + // ── Horizontal: Frontend ↔ PostGraphile (skip over API) ── + const gqlY = R1 + sH + 8; + p.push(``); + txt((f_cx + pg_cx) / 2, gqlY + 12, "GraphQL", { size: 8, fill: TEXT_DIM }); + + // ═══════════════════════════════════════════════════════════════════ + // ROW 2: PostgreSQL (y = 250) + // ═══════════════════════════════════════════════════════════════════ + const R2 = 260; + const dbW = 280, dbH = 55; + const dbX = KX + KW / 2 - dbW / 2; + + label(s1x, R2 - 6, "Data Layer"); + rect(dbX, R2, dbW, dbH, C.teal); + txt(dbX + dbW / 2, R2 + 18, "PostgreSQL 16", { size: 13, fill: C.teal.text, bold: true }); + txt(dbX + dbW / 2, R2 + 33, "27 tables \u00b7 System of Record", { size: 9, fill: TEXT_SEC }); + txt(dbX + dbW / 2, R2 + 47, ":5432", { size: 8, fill: TEXT_DIM, mono: true }); + + const db_cx = dbX + dbW / 2; + + // ── API → DB (vertical drop) ── + lineV(a_cx, R1 + sH, R2, "SQL", { color: C.teal.border, arrow: "both" }); + + // ── PostGraphile → DB (elbow) ── + elbowV(pg_cx, R1 + sH + 20, db_cx + 40, R2, "SQL", { color: C.teal.border, arrow: "both", lblPos: "left" }); + + // ═══════════════════════════════════════════════════════════════════ + // ROW 3: Ephemeral Pods (y = 370) + // ═══════════════════════════════════════════════════════════════════ + const R3 = 375; + const podW = (KW - 60 - 20) / 2, podH = 70; + const pod1x = KX + 30; + const pod2x = pod1x + podW + 20; + + label(s1x, R3 - 6, "Ephemeral Pods"); + + // Model Runner + rect(pod1x, R3, podW, podH, C.amber, { dash: true }); + txt(pod1x + podW / 2, R3 + 17, "Model Runner Pods", { size: 11, fill: C.amber.text, bold: true }); + txt(pod1x + podW / 2, R3 + 32, "Ephemeral K8s Jobs", { size: 9, fill: TEXT_SEC }); + txt(pod1x + podW / 2, R3 + 46, "Python (PyTorch / sklearn) \u00b7 Rust (tch-rs)", { size: 8, fill: TEXT_DIM }); + txt(pod1x + podW / 2, R3 + 60, "oms-job-*", { size: 8, fill: TEXT_DIM, mono: true }); + + // Workspace + rect(pod2x, R3, podW, podH, C.emerald, { dash: true }); + txt(pod2x + podW / 2, R3 + 17, "Workspace Pods", { size: 11, fill: C.emerald.text, bold: true }); + txt(pod2x + podW / 2, R3 + 32, "Per-User JupyterLab", { size: 9, fill: TEXT_SEC }); + txt(pod2x + podW / 2, R3 + 46, "SDK + Tutorial Notebooks + Datasets", { size: 8, fill: TEXT_DIM }); + txt(pod2x + podW / 2, R3 + 60, "oms-ws-*", { size: 8, fill: TEXT_DIM, mono: true }); + + const runner_cx = pod1x + podW / 2; + const ws_cx = pod2x + podW / 2; + + // ── API → Runner (elbow: down from API, right to runner) ── + elbowV(a_cx - 15, R2 + dbH + 10, runner_cx, R3, "Job orchestration", { color: C.amber.border, dash: true, lblPos: "left" }); + + // ── Runner → API (metrics — separate path, elbow back up) ── + elbowV(runner_cx + 40, R3, a_cx + 15, R2 + dbH + 10, "Metrics + Logs", { color: C.amber.border, arrow: "end", lblPos: "right" }); + + // ── JupyterHub → Workspace Pods ── + lineV(j_cx, R1 + sH + 20, R3, "Pod spawning", { color: C.emerald.border, dash: true }); + + // ── Workspace SDK → API (elbow) ── + elbowV(ws_cx - 40, R3, a_cx + 40, R2 + dbH + 10, "SDK \u2192 REST", { color: C.blue.border, dash: true, arrow: "both", lblPos: "right" }); + + // ═══════════════════════════════════════════════════════════════════ + // ROW 4: Persistent Volumes (y = 500) + // ═══════════════════════════════════════════════════════════════════ + const R4 = 500; + const pvW = (KW - 60 - 16 * 3) / 4, pvH = 40; + + label(s1x, R4 - 6, "Persistent Volumes"); + + const pvData = [ + ["models-pvc", "Model Code"], + ["datasets-pvc", "Training Data"], + ["artifacts-pvc", "Job Outputs"], + ["postgres-data", "Database"], + ]; + for (let i = 0; i < 4; i++) { + const px = KX + 30 + i * (pvW + 16); + rect(px, R4, pvW, pvH, C.storage, { rx: 6 }); + txt(px + pvW / 2, R4 + 16, pvData[i][0], { size: 9, fill: TEXT_SEC, mono: true }); + txt(px + pvW / 2, R4 + 30, pvData[i][1], { size: 8, fill: TEXT_DIM }); } - // ── External Integration Arrows ── - const extMidX = PAD + extW; - const apiLeft = svcStartX + (svcW + svcGap); // left edge of API box - - // GitHub Registry → API - const ghMidY = extY + extH / 2; - parts.push(biArrow(extMidX, ghMidY, apiLeft, svcMidY - 8, "", { color: SLATE.stroke })); - parts.push(text(extMidX + 24, ghMidY - 8, "Model fetch", { size: 8, fill: LABEL_COLOR, anchor: "start" })); - - // LLM → API - const llmMidY = extY + extH + extGap + extH / 2; - parts.push(biArrow(extMidX, llmMidY, apiLeft, svcMidY + 8, "", { color: SLATE.stroke })); - parts.push(text(extMidX + 24, llmMidY + 14, "HTTPS", { size: 8, fill: LABEL_COLOR, anchor: "start" })); - - // S3 → API - const s3MidY = extY + (extH + extGap) * 2 + extH / 2; - parts.push(biArrow(extMidX, s3MidY, apiLeft, svcY + svcH + 10, "", { color: SLATE.stroke })); - parts.push(text(extMidX + 24, s3MidY + 14, "Presigned URLs", { size: 8, fill: LABEL_COLOR, anchor: "start" })); - - // ── Legend ── - const legY = H - 32; - const legX = PAD; - const legSpacing = 170; + // ═══════════════════════════════════════════════════════════════════ + // LEFT COLUMN: External Services + // ═══════════════════════════════════════════════════════════════════ + const EX = 30, EW = 190, EH = 50, EGap = 14; + const E1y = 140, E2y = E1y + EH + EGap, E3y = E2y + EH + EGap; + + label(EX, E1y - 8, "External Services"); + pill(EX, E1y, EW, EH, "GitHub Registry", C.slate, "Open Model Registry"); + pill(EX, E2y, EW, EH, "LLM Providers", C.slate, "OpenAI / Anthropic / Ollama"); + pill(EX, E3y, EW, EH, "S3 / MinIO", C.slate, "Artifact Storage"); + + // ── External → API (horizontal elbows into left edge of API) ── + const extRight = EX + EW; + const apiLeft = s2x; + + // GitHub → API (straight horizontal — same Y as API row) + lineH(extRight, E1y + EH / 2, apiLeft, "Model fetch", { color: C.slate.border, arrow: "both" }); + + // LLM → API (elbow: goes right, up to API mid, then right into API) + const llmMidX = extRight + 30; + p.push(``); + txt(llmMidX + 4, R1 + 16, "HTTPS", { size: 8, fill: TEXT_DIM, anchor: "start" }); + + // S3 → API (elbow: goes right, up to API bottom area, then right into API) + const s3MidX = extRight + 50; + p.push(``); + txt(s3MidX + 4, R1 + 36, "Presigned URLs", { size: 8, fill: TEXT_DIM, anchor: "start" }); + + // ═══════════════════════════════════════════════════════════════════ + // LEGEND (bottom) + // ═══════════════════════════════════════════════════════════════════ + const LY = H - 26; const legends = [ - { label: "Frontend / UI", color: VIOLET }, - { label: "API / Backend", color: BLUE }, - { label: "Data / Storage", color: TEAL }, - { label: "Ephemeral Pods", color: AMBER }, - { label: "External Services", color: SLATE }, - { label: "Workspaces", color: EMERALD }, + ["Frontend / UI", C.violet], + ["API / Backend", C.blue], + ["Data Layer", C.teal], + ["Ephemeral Pods", C.amber], + ["Workspaces", C.emerald], + ["External", C.slate], ]; + const legSpacing = 170; for (let i = 0; i < legends.length; i++) { - const lx = legX + i * legSpacing; - parts.push(``); - parts.push(text(lx + 18, legY + 4, legends[i].label, { size: 9, fill: TEXT_SECONDARY, anchor: "start" })); + const lx = 40 + i * legSpacing; + p.push(``); + txt(lx + 16, LY + 4, legends[i][0], { size: 9, fill: TEXT_SEC, anchor: "start" }); } - parts.push(""); - - return parts.join("\n"); + p.push(""); + return p.join("\n"); } // ── Main ────────────────────────────────────────────────────────────── - -const svg = generateSVG(); +const svg = generate(); const outPath = path.join(__dirname, "architecture.svg"); fs.writeFileSync(outPath, svg); console.log(`Generated ${outPath} (${(svg.length / 1024).toFixed(1)} KB)`); From ff1047362b50472e1b4b8294f6abff9edf9ddfe2 Mon Sep 17 00:00:00 2001 From: jovonni Date: Sun, 8 Mar 2026 14:43:28 -0400 Subject: [PATCH 3/3] Fix architecture diagram edge routing to avoid node overlap Route external service arrows (GitHub, LLM, S3) below the Frontend row using elbow connectors so no edges pass through any nodes. --- docs/diagrams/architecture.svg | 12 ++++++------ docs/diagrams/generate-architecture.js | 25 ++++++++++++++----------- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/docs/diagrams/architecture.svg b/docs/diagrams/architecture.svg index 19b65c2..be8beec 100644 --- a/docs/diagrams/architecture.svg +++ b/docs/diagrams/architecture.svg @@ -92,12 +92,12 @@ S3 / MinIO Artifact Storage - -Model fetch - -HTTPS - -Presigned URLs + +Model fetch + +HTTPS + +Presigned URLs Frontend / UI diff --git a/docs/diagrams/generate-architecture.js b/docs/diagrams/generate-architecture.js index cb51447..eeb9d4e 100644 --- a/docs/diagrams/generate-architecture.js +++ b/docs/diagrams/generate-architecture.js @@ -283,22 +283,25 @@ function generate() { pill(EX, E2y, EW, EH, "LLM Providers", C.slate, "OpenAI / Anthropic / Ollama"); pill(EX, E3y, EW, EH, "S3 / MinIO", C.slate, "Artifact Storage"); - // ── External → API (horizontal elbows into left edge of API) ── + // ── External → API ── + // Route all external arrows BELOW the Frontend row so no edges cross nodes. + // Each path: right from ext box → down to a channel below services → right to API bottom edge const extRight = EX + EW; const apiLeft = s2x; + const channelX = s1x - 8; // just left of Frontend box + const underRow = R1 + sH; // bottom of service boxes - // GitHub → API (straight horizontal — same Y as API row) - lineH(extRight, E1y + EH / 2, apiLeft, "Model fetch", { color: C.slate.border, arrow: "both" }); + // GitHub → API: right to channel, down under Frontend, right to API bottom + p.push(``); + txt((channelX + a_cx) / 2, underRow + 18, "Model fetch", { size: 8, fill: TEXT_DIM }); - // LLM → API (elbow: goes right, up to API mid, then right into API) - const llmMidX = extRight + 30; - p.push(``); - txt(llmMidX + 4, R1 + 16, "HTTPS", { size: 8, fill: TEXT_DIM, anchor: "start" }); + // LLM → API: right to channel, down under Frontend, right to API bottom + p.push(``); + txt((channelX + a_cx) / 2, underRow + 26, "HTTPS", { size: 8, fill: TEXT_DIM }); - // S3 → API (elbow: goes right, up to API bottom area, then right into API) - const s3MidX = extRight + 50; - p.push(``); - txt(s3MidX + 4, R1 + 36, "Presigned URLs", { size: 8, fill: TEXT_DIM, anchor: "start" }); + // S3 → API: right to channel, down under Frontend, right to API bottom + p.push(``); + txt((channelX + a_cx) / 2, underRow + 34, "Presigned URLs", { size: 8, fill: TEXT_DIM }); // ═══════════════════════════════════════════════════════════════════ // LEGEND (bottom)