From 5f6499125a4446cf5a70e2428ad679a823593e48 Mon Sep 17 00:00:00 2001 From: alexz Date: Wed, 22 Jul 2026 09:01:10 -0700 Subject: [PATCH] sap-drawio: add 'Sketch to SAP BTP' custom-store app (FastAPI+React, single-port, seed-on-first-run SQLite, exposed via auto-SSO) --- apps/sap-drawio/build/README.md | 52 +++++++++++++++ apps/sap-drawio/build/build.sh | 80 ++++++++++++++++++++++++ apps/sap-drawio/config.json | 49 +++++++++++++++ apps/sap-drawio/docker-compose.json | 31 +++++++++ apps/sap-drawio/metadata/description.md | 32 ++++++++++ apps/sap-drawio/metadata/logo.jpg | Bin 0 -> 20776 bytes 6 files changed, 244 insertions(+) create mode 100644 apps/sap-drawio/build/README.md create mode 100644 apps/sap-drawio/build/build.sh create mode 100644 apps/sap-drawio/config.json create mode 100644 apps/sap-drawio/docker-compose.json create mode 100644 apps/sap-drawio/metadata/description.md create mode 100644 apps/sap-drawio/metadata/logo.jpg diff --git a/apps/sap-drawio/build/README.md b/apps/sap-drawio/build/README.md new file mode 100644 index 0000000..f70235b --- /dev/null +++ b/apps/sap-drawio/build/README.md @@ -0,0 +1,52 @@ +# sap-drawio — Build Files + +Image source repo (Dockerfile lives there, NOT in this store folder): +`~/projects/sap-architecture-diagrams` (git remote `git.alexzaw.dev/alexz/sap-architecture-diagrams`). + +## What the image is + +A single multi-stage image, `git.alexzaw.dev/alexz/sap-drawio:latest`: + +1. **Builder stage** (`node:20-bookworm-slim`) — builds the React (CRA + CRACO, + Yarn Classic 1.22.22) frontend with `REACT_APP_BACKEND_URL=""` so all API + calls are same-origin (`/api`), working from any host/domain. +2. **Runtime stage** (`python:3.12-slim`) — installs `backend/requirements.txt`, + copies `backend/` to `/app/backend` and the built UI to `/app/frontend/build` + (FastAPI/uvicorn serves both from one process on port 8011), bakes the + 12 MB SQLite catalog seed to `/seed/app.db`, and runs as root (homelab — + avoids the non-root volume-permission trap). + +## How to build + +```bash +cd /etc/runtipi/repos/runtipi/apps/sap-drawio/build +./build.sh +``` + +Builds and pushes `git.alexzaw.dev/alexz/sap-drawio:latest`. Override +`IMAGE_NAME` / `IMAGE_TAG` / `SRC` env vars if needed. To build without +pushing, run the `docker build` line directly from `$SRC`. + +## Seed-on-first-run behavior + +`docker/entrypoint.sh` (in the source repo) copies `/seed/app.db` → +`/data/app.db` only if `/data/app.db` is absent, then execs uvicorn. This +means: +- First install: the persistent volume (`${APP_DATA_DIR}/data` on the host) + gets the baked catalog (icons/services) as its starting point. +- Every subsequent `app update`/restart: the existing `/data/app.db` (with any + saved conversions, added logos, etc.) is left untouched — the image rebuild + never overwrites live data. + +## LLM dependency + +Calls an Ollama-compatible host at `OLLAMA_URL` (form field, defaults to +`http://192.168.0.32:11434`, this homeserver's local `ollama-nvidia` app). +Models (`VISION_MODEL`, `VALIDATOR_MODEL`) are also form fields so they can be +changed without a rebuild. `qwen3-vl:8b` / `gemma4:31b` must exist on the +Ollama host for the defaults to work. + +## Architecture note + +**amd64 only.** No multi-arch build — matches the host architecture; not +needed for this homelab deployment. diff --git a/apps/sap-drawio/build/build.sh b/apps/sap-drawio/build/build.sh new file mode 100644 index 0000000..e759a1b --- /dev/null +++ b/apps/sap-drawio/build/build.sh @@ -0,0 +1,80 @@ +#!/bin/bash +set -euo pipefail +# Builds (and, unless SKIP_PUSH=1, pushes) the sap-drawio image from the app +# source repo. Both the floating "latest" tag and the version-pinned tag +# (kept in sync with config.json "version") are built and pushed. +# +# Before building, this script (re)generates a deterministic, catalog-only +# seed DB (backend/data/seed.db) from the developer's live app.db. This is +# REQUIRED — the live app.db is WAL-mode with uncommitted -wal data and a +# full conversion/history table, neither of which may ever ship in the image +# (see sap-drawio Phase-1 code review FIX B1 / M2). The live app.db is never +# opened directly by this script; it is only ever `cp`-copied at the OS +# level, so it cannot be mutated by an incidental SQLite WAL checkpoint. +# +# Env overrides: +# IMAGE_NAME default git.alexzaw.dev/alexz/sap-drawio +# IMAGE_TAG default latest +# VERSION_TAG default 1.0.0 (must match config.json "version") +# SRC default ~/projects/sap-architecture-diagrams +# SKIP_PUSH set to 1 to build both tags locally only (no push) — used +# for pre-push smoke-testing. + +IMAGE_NAME="${IMAGE_NAME:-git.alexzaw.dev/alexz/sap-drawio}" +IMAGE_TAG="${IMAGE_TAG:-latest}" +VERSION_TAG="${VERSION_TAG:-1.0.0}" +SRC="${SRC:-$HOME/projects/sap-architecture-diagrams}" +SKIP_PUSH="${SKIP_PUSH:-0}" + +cd "$SRC" + +echo "== [1/3] Generating deterministic catalog-only seed DB ==" +SEED_WORK="$(mktemp -d)" +trap 'rm -rf "$SEED_WORK"' EXIT + +# Copy the live dev DB (main + WAL + SHM, if present) at the OS level ONLY. +# backend/data/app.db is NEVER opened directly by this script. +cp backend/data/app.db "$SEED_WORK/app.db" +[ -f backend/data/app.db-wal ] && cp backend/data/app.db-wal "$SEED_WORK/app.db-wal" +[ -f backend/data/app.db-shm ] && cp backend/data/app.db-shm "$SEED_WORK/app.db-shm" + +# .backup against the COPY (never the original) merges any committed WAL +# frames into a single consistent snapshot file. +sqlite3 "$SEED_WORK/app.db" ".backup '$SEED_WORK/seed.db'" + +# Clear ONLY the conversion/history table (db.py: insert_conversion() writes +# to `conversions`). Every catalog/icon/service/brand/override row is kept. +sqlite3 "$SEED_WORK/seed.db" "DELETE FROM conversions;" +sqlite3 "$SEED_WORK/seed.db" "VACUUM;" + +echo "-- Catalog row counts (source vs seed) --" +for t in icon_assets sap_services label_overrides schema_meta; do + src_count=$(sqlite3 "$SEED_WORK/app.db" "SELECT COUNT(*) FROM $t;") + seed_count=$(sqlite3 "$SEED_WORK/seed.db" "SELECT COUNT(*) FROM $t;") + printf ' %-16s source=%-8s seed=%-8s\n' "$t" "$src_count" "$seed_count" + if [ "$src_count" != "$seed_count" ]; then + echo "ERROR: catalog table $t row count mismatch (source=$src_count seed=$seed_count)" >&2 + exit 1 + fi +done +conv_count=$(sqlite3 "$SEED_WORK/seed.db" "SELECT COUNT(*) FROM conversions;") +echo " conversions seed=$conv_count (must be 0)" +if [ "$conv_count" != "0" ]; then + echo "ERROR: seed.db still has $conv_count conversion/history rows" >&2 + exit 1 +fi + +cp "$SEED_WORK/seed.db" backend/data/seed.db +echo "Seed written to backend/data/seed.db ($(du -h backend/data/seed.db | cut -f1))" + +echo "== [2/3] Building image (tags: ${IMAGE_TAG}, ${VERSION_TAG}) ==" +sudo docker build "$@" -t "${IMAGE_NAME}:${IMAGE_TAG}" -t "${IMAGE_NAME}:${VERSION_TAG}" . + +if [ "$SKIP_PUSH" = "1" ]; then + echo "== [3/3] SKIP_PUSH=1 — not pushing. Built locally: ${IMAGE_NAME}:${IMAGE_TAG}, ${IMAGE_NAME}:${VERSION_TAG} ==" + exit 0 +fi + +echo "== [3/3] Pushing image ==" +sudo docker push "${IMAGE_NAME}:${IMAGE_TAG}" +sudo docker push "${IMAGE_NAME}:${VERSION_TAG}" diff --git a/apps/sap-drawio/config.json b/apps/sap-drawio/config.json new file mode 100644 index 0000000..4db2ed4 --- /dev/null +++ b/apps/sap-drawio/config.json @@ -0,0 +1,49 @@ +{ + "name": "Sketch to SAP BTP", + "id": "sap-drawio", + "available": true, + "short_desc": "Turn a hand-drawn or Mermaid sketch into a polished SAP BTP draw.io diagram.", + "author": "alexz", + "port": 8011, + "categories": ["development", "utilities"], + "tipi_version": 1, + "version": "1.0.0", + "source": "https://git.alexzaw.dev/alexz/sap-architecture-diagrams", + "website": "https://git.alexzaw.dev/alexz/sap-architecture-diagrams", + "exposable": true, + "dynamic_config": true, + "supported_architectures": ["amd64"], + "min_tipi_version": "4.0.0", + "form_fields": [ + { + "type": "text", + "label": "Ollama URL", + "hint": "Ollama-compatible LLM host", + "required": true, + "env_variable": "OLLAMA_URL", + "default": "http://192.168.0.32:11434" + }, + { + "type": "text", + "label": "Vision model", + "required": true, + "env_variable": "VISION_MODEL", + "default": "qwen3-vl:8b" + }, + { + "type": "text", + "label": "Validator model", + "required": true, + "env_variable": "VALIDATOR_MODEL", + "default": "gemma4:31b" + }, + { + "type": "password", + "label": "Brandfetch API key (optional)", + "required": false, + "env_variable": "BRANDFETCH_API_KEY" + } + ], + "created_at": 1784733792011, + "updated_at": 1784733792011 +} diff --git a/apps/sap-drawio/docker-compose.json b/apps/sap-drawio/docker-compose.json new file mode 100644 index 0000000..75cdecb --- /dev/null +++ b/apps/sap-drawio/docker-compose.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://schemas.runtipi.io/dynamic-compose.json", + "schemaVersion": 2, + "services": [ + { + "name": "sap-drawio", + "image": "git.alexzaw.dev/alexz/sap-drawio:latest", + "isMain": true, + "internalPort": 8011, + "environment": [ + { "key": "TZ", "value": "${TZ}" }, + { "key": "OLLAMA_URL", "value": "${OLLAMA_URL}" }, + { "key": "VISION_MODEL", "value": "${VISION_MODEL}" }, + { "key": "VALIDATOR_MODEL", "value": "${VALIDATOR_MODEL}" }, + { "key": "BRANDFETCH_API_KEY", "value": "${BRANDFETCH_API_KEY}" }, + { "key": "SQLITE_DB_PATH", "value": "/data/app.db" }, + { "key": "CORS_ORIGINS", "value": "https://sap-drawio.alexzaw.dev" } + ], + "volumes": [ + { "hostPath": "${APP_DATA_DIR}/data", "containerPath": "/data", "readOnly": false } + ], + "healthCheck": { + "test": "python -c \"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8011/api/health', timeout=5).status==200 else 1)\"", + "interval": "30s", + "timeout": "10s", + "retries": 3, + "startPeriod": "40s" + } + } + ] +} diff --git a/apps/sap-drawio/metadata/description.md b/apps/sap-drawio/metadata/description.md new file mode 100644 index 0000000..79205c4 --- /dev/null +++ b/apps/sap-drawio/metadata/description.md @@ -0,0 +1,32 @@ +# Sketch to SAP BTP + +Turn a rough architecture idea into a polished **SAP BTP architecture diagram** +in seconds. Feed it either a hand-drawn/whiteboard **sketch (image)** or a +**Mermaid diagram**, and it produces a clean draw.io XML diagram (plus an +inline SVG preview) styled in SAP's official BTP solution-diagram visual +language — complete with an LLM-generated architecture review calling out +gaps, risks, and suggestions. + +## Two ways in + +- **Image sketch** — upload a photo or screenshot of a whiteboard sketch; the + vision model reads the shapes, labels, and connections and maps them to the + matching SAP BTP services/icons. +- **Mermaid** — paste a Mermaid diagram and get the same SAP BTP-styled + draw.io output without needing to draw anything by hand. + +Every conversion is saved to a local history so past diagrams and their +review notes can be revisited later, exported again, or refined further. + +## Access note + +This app has **no built-in authentication** of its own — access control is +handled at the edge (Authentik SSO via the homeserver's forward-auth gate). +Anyone who reaches the app through the gate can drive the full pipeline. + +## Dependencies + +Requires a reachable Ollama-compatible LLM endpoint (configured via the +"Ollama URL" field) with the configured vision and validator models +available. An optional Brandfetch API key enables looking up third-party +brand logos for non-SAP components in a diagram. diff --git a/apps/sap-drawio/metadata/logo.jpg b/apps/sap-drawio/metadata/logo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0d9033e6ccbdee99bd27e3b146f9706249b6c34e GIT binary patch literal 20776 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

&`X7888QV|r#+&GCZTuJ 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 PsQ)1FZz8~UIP^aNr3JiN literal 0 HcmV?d00001