Compare commits

29 Commits

Author SHA1 Message Date
859256b68d remove dated examples 2026-10-07 16:47:42 -03:00
ec31abecea Merge branch 'rig-work' 2026-09-30 10:12:35 -03:00
c669662088 updated make docs 2026-09-30 10:12:32 -03:00
2b36799acc Merge branch 'rig-work' 2026-09-30 09:53:53 -03:00
85276d34c3 updated make docs 2026-09-30 09:53:32 -03:00
8185d16c07 Merge branch 'rig-work' 2026-09-28 10:04:31 -03:00
f00055033e proper docs and .version 2026-09-28 10:02:55 -03:00
d8e7de1c59 Merge branch 'rig-work' 2026-09-25 08:33:33 -03:00
100f389719 overlay 2026-09-25 08:32:28 -03:00
9021cbe3c1 docgen ui combine 2026-09-24 00:58:19 -03:00
73998020b3 docgen graphgen ui and converging update 2026-09-24 00:58:19 -03:00
b693e324ef init docgen 2026-09-24 00:58:19 -03:00
12ddd01940 updates 38.1-164 2026-09-24 00:58:19 -03:00
5ff513fcc5 updates 38.1 90 2026-09-24 00:58:19 -03:00
4fe3718795 updated conventions 2026-09-24 00:58:19 -03:00
514efc5e52 handover agent doing docgen-graphgen 2026-09-24 00:43:18 -03:00
8fad2223f3 big update based on real wokr 2026-09-24 00:43:18 -03:00
45018e4743 berth updates 2026-09-24 00:43:18 -03:00
907b616d1b berth updates 2026-09-24 00:43:18 -03:00
b3cf24e750 ui exporting and docgen testing 2026-09-24 00:43:18 -03:00
88adcd68bc ui work 2026-09-24 00:43:18 -03:00
54c577a700 ui refactor phase 2 2026-09-24 00:43:18 -03:00
0ce36b98e4 ui work phase 1 2026-09-24 00:43:18 -03:00
3bbfa413a3 big update based on real wokr 2026-09-23 18:52:57 -03:00
2ef6139957 bring secrets per repo 2026-09-22 14:00:43 -03:00
55c0d73ffe update dataconvert and source 2026-09-22 11:57:31 -03:00
0b227cbc0e Merge branch 'rig-work' 2026-09-22 08:18:31 -03:00
60527645f2 distilled tools 2026-09-22 08:18:25 -03:00
dee899d86f installer 2026-09-22 08:18:02 -03:00
305 changed files with 18078 additions and 4943 deletions

6
.gitignore vendored
View File

@@ -28,12 +28,10 @@ gen/
# browser, not source — and often a client's real data.
soleprint/station/tools/shuntgen/uploads/
# Room configurations (separate repo - contains credentials and room-specific data)
# Keep cfg/standalone/ and cfg/sample/ as templates, ignore actual rooms
# Client room configurations. They carry credentials and a client's data, and were
# moved out of cfg/ (2026-10-07); still ignored so a restored copy is never committed.
cfg/amar/
cfg/dlt/
# Add new rooms here as they are created
# cfg/<room>/
# Client rigs. A rig is a copy of rig/ renamed after the environment it models,
# so its k8s files spell out a real architecture — exactly the thing that must

View File

@@ -29,8 +29,8 @@ export PYTHON
ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))
ifneq ($(ARGS),)
# Declares each extra word as a target that does nothing: `:` is an empty rule
# body and `@` silences it. Without this, `make build sample` runs the build and
# then fails with "No rule to make target 'sample'", because make reads every
# body and `@` silences it. Without this, `make build myroom` runs the build and
# then fails with "No rule to make target 'myroom'", because make reads every
# word on the line as something it has been asked to build.
$(eval $(ARGS):;@:)
# ...and as PHONY, because some of those words name real directories. `cfg`,
@@ -42,7 +42,7 @@ $(eval $(ARGS):;@:)
endif
.DEFAULT_GOAL := help
.PHONY: help build start stop dist theme docs cluster deploy component
.PHONY: help build start stop dist theme ui docs cluster deploy component
help: ## list targets
@grep -hE '^[a-z]+:.*?##' $(MAKEFILE_LIST) | sed 's/:.*##/\t/' | expand -t16
@@ -58,14 +58,19 @@ start: ## run a built room [<room>] [-d] [--build]
stop: ## stop a running room [<room>]
bash ctrl/stop.sh $(or $(ARGS),$(ROOM))
dist: ## compile the plexus UIs to single files [<room>]
bash ctrl/dist.sh $(or $(ARGS),$(ROOM))
dist: ## compile the plexus UIs to single files [NAME|FILE]
bash ctrl/dist.sh $(ARGS)
# ── theme ──────────────────────────────────────────────────────────────────
theme: ## ad-hoc pages [new|parts|bake|check|export|run FILE]
bash ctrl/theme.sh $(or $(ARGS),bake)
# ── ui ─────────────────────────────────────────────────────────────────────
ui: ## slices of the UI [list|scan DIR|slice FILE|check FILE|reference|selftest]
bash ctrl/ui.sh $(or $(ARGS),list)
# ── docs ───────────────────────────────────────────────────────────────────
docs: ## documentation [serve [port]|graphs [theme]] (default serve)

5
berth/.gitignore vendored
View File

@@ -19,3 +19,8 @@ def/
# slash resolves against this file's own directory. `git check-ignore -v` is the
# only way to confirm it, and ctrl/vpn.sh refuses to write a key until it does.
ctrl/.secrets/
# Snapshots of a remote box's state (`host.sh snapshot --run`). Not secrets —
# the probe refuses to read environments, compose files or logs — but machine
# state from production, and never source. Anchored to ctrl/ for the reason above.
ctrl/.snapshots/

View File

@@ -50,7 +50,7 @@ certs: ## TLS [status|verify|renew|push] (default stat
# ── the box ────────────────────────────────────────────────────────────────
host: ## the remote box [status|ports|services] (default status)
host: ## the remote box [status|ports|services|snapshot|check] (default status)
bash ctrl/host.sh $(or $(ARGS),status)
registry: ## the image registry [status] (default status)

View File

@@ -2,13 +2,28 @@
# The remote box — the other half of the inventory.
#
# Usage:
# ./host.sh status|ports|services
# ./host.sh status|ports|services announce a batch; run nothing
# ./host.sh snapshot [--admin] print the probe that would run
# ./host.sh snapshot --run [--admin] run it, save it, then check it
# ./host.sh check [<snapshot-dir>] is the box healthy? offline, never ssh
#
# Announces what it would run over `ssh $HOST` and does not run it. Always the
# ssh alias, never a hostname: there is no `Host mcrn.ar` block, so a bare
# hostname offers every agent key and trips MaxAuthTries.
# Always the ssh alias, never a hostname: there is no `Host mcrn.ar` block, so
# a bare hostname offers every agent key and trips MaxAuthTries.
#
# snapshot + check is rig's `make check` asked of a REMOTE machine. rig's check
# reports and instructs and never fixes; so does this. rig's check already reads
# /proc/meminfo through an override path so its branches can be exercised
# "against another machine's real numbers" — this is that idea taken the rest
# of the way: collect the other machine's numbers ONCE, over one announced ssh
# session, then answer every question from the saved copy. The analysis can
# then be re-run, tested against fixtures, and compared across days, and it
# never needs the box to be reachable — which, on a box that is failing, it
# may not be.
set -euo pipefail
# Kept before the cd: a snapshot path given on the command line is relative to
# where the user stood, not to ctrl/.
CALLER_PWD="$PWD"
cd "$(dirname "$0")"
source ./lib/config.sh
@@ -31,6 +46,273 @@ announce_batch() {
echo "learned rather than scrolled past."
}
# Overridable so selftest can point check at an empty root. snapshot still
# refuses to write anywhere git does not confirm is ignored — a root outside
# the repo is refused, not honoured.
SNAP_ROOT="${SNAP_ROOT:-./.snapshots}"
snapshot() {
local run="" admin=""
for a in "$@"; do
case "$a" in
--run) run=1 ;;
--admin) admin=1 ;;
*) echo "snapshot: unknown argument '$a'" >&2; exit 1 ;;
esac
done
if [ -z "$run" ]; then
echo "would send, over 'ssh $HOST bash -s', exactly this file — ctrl/remote/probe.sh:"
echo
sed 's/^/ | /' ./remote/probe.sh
if [ -n "$admin" ]; then
echo
echo "and, over 'ssh $HOST_ADMIN bash -s', exactly this — ctrl/remote/probe-admin.sh:"
echo
sed 's/^/ | /' ./remote/probe-admin.sh
fi
echo
echo "read-only, and NOT run. What you read above is the file that is sent;"
echo "there is no second copy. To run it:"
echo " bash ctrl/host.sh snapshot --run${admin:+ --admin}"
return 0
fi
local dir; dir="$SNAP_ROOT/$ESTATE/$(date -u +%Y%m%dT%H%M%SZ)"
assert_ignored "$dir/raw" # before anything is written, not after
mkdir -p "$dir"
# Interrupted (Ctrl-C on a slow box), keep what arrived: split it and mark
# it partial. Unsplit, it was invisible to check — and newer than the last
# good snapshot, so it hid that one too.
trap 'echo " ! interrupted — keeping the partial snapshot" >&2;
echo "interrupted" > "$dir/PARTIAL"; split_sections "$dir/raw" "$dir";
[ -f "$dir/raw-admin" ] && split_sections "$dir/raw-admin" "$dir"; exit 130' INT
echo "ssh $HOST bash -s < ctrl/remote/probe.sh"
if ! ssh -o BatchMode=yes "$HOST" bash -s < ./remote/probe.sh > "$dir/raw" 2> "$dir/stderr"; then
echo " ! ssh $HOST exited non-zero — output so far is kept in $dir" >&2
sed 's/^/ /' "$dir/stderr" >&2
fi
split_sections "$dir/raw" "$dir"
if [ -n "$admin" ]; then
echo "ssh $HOST_ADMIN bash -s < ctrl/remote/probe-admin.sh"
ssh -o BatchMode=yes "$HOST_ADMIN" bash -s < ./remote/probe-admin.sh \
> "$dir/raw-admin" 2>> "$dir/stderr" || \
echo " ! ssh $HOST_ADMIN exited non-zero — see $dir/stderr" >&2
split_sections "$dir/raw-admin" "$dir"
fi
trap - INT
echo "saved: $dir"
echo
check "$dir"
}
# One file per `### name` section. An empty section still gets its file, so
# "the probe ran and found nothing" is distinguishable from "it never ran".
split_sections() {
awk -v d="$2" '/^### /{ f = d "/" $2; printf "" > f; next } f { print > f }' "$1"
}
# docker prints decimal units (kB, MB, GB). Approximate on purpose: these are
# for choosing between levers, not for accounting.
AWK_MB='function mb(s, v, u) {
v = s; u = s; sub(/[A-Za-z]+$/, "", v); sub(/^[0-9.]+/, "", u)
if (u == "B") return v / 1e6
if (u == "kB" || u == "KB") return v / 1e3
if (u == "MB") return v + 0
if (u == "GB") return v * 1e3
if (u == "TB") return v * 1e6
return 0
}'
check() {
local f="${1:-}"
if [ -z "$f" ]; then
# `|| true`: with no snapshots, ls fails, pipefail fails the pipeline and
# set -e exits silently — on exactly the path that must explain itself.
f=$(ls -1d "$SNAP_ROOT/$ESTATE"/*/meta 2>/dev/null | sort | tail -1) || true
f=${f%/meta}
fi
# Exit 1 here, unlike every other report in berth: with no snapshot there is
# no report, and a check that prints nothing alarming because it looked at
# nothing is the vacuous pass selftest exists to catch.
case "$f" in /*|"") ;; *) [ -d "$f" ] || f="$CALLER_PWD/$f" ;; esac
if [ -z "$f" ] || [ ! -f "$f/meta" ]; then
echo "no snapshot for estate '$ESTATE'. Take one — read-only, printed before it runs:"
echo " make host snapshot"
exit 1
fi
local taken box age_min
taken=$(sed -n 1p "$f/meta"); box=$(sed -n 2p "$f/meta")
age_min=$(( ( $(date -u +%s) - $(date -u -d "$taken" +%s 2>/dev/null || date -u +%s) ) / 60 ))
echo "snapshot $f"
echo " $box, taken $taken"
[ -f "$f/PARTIAL" ] && echo " ! PARTIAL — interrupted; sections after the cut are missing, not empty"
if [ "$age_min" -ge 60 ]; then
echo " ! $((age_min / 60))h old — this is how the box WAS. Re-snapshot before acting on it."
fi
# ── disk ────────────────────────────────────────────────────────────────
echo
echo "disk"
local rows; rows=$(awk 'NR > 1 && NF >= 6 { p = $5; sub(/%/, "", p); printf "%s\t%d\t%d\t%d\n", $6, p, $4, $2 }' "$f/df")
local full=0 mnt pct avail total
while IFS=$'\t' read -r mnt pct avail total; do
[ -z "$mnt" ] && continue
local mark=" "; [ "$pct" -ge 85 ] && mark=" !"
printf "%s %-22s %3d%% %7d MB free of %7d MB\n" "$mark" "$mnt" "$pct" "$avail" "$total"
[ "$pct" -ge 95 ] && full=1
done <<< "$rows"
awk 'NR > 1 && NF >= 6 { p = $5; sub(/%/, "", p); if (p + 0 >= 85) printf " ! inodes %s at %s%% — space can be free and writes still fail\n", $6, $5 }' "$f/df_inodes"
# The filesystem docker writes to: the longest mount point that prefixes
# /var/lib/docker. Usually /, but a separate /var would otherwise be missed.
local dmnt dpct davail dtotal
IFS=$'\t' read -r dmnt dpct davail dtotal < <(
awk -F'\t' '{ m = $1; if (index("/var/lib/docker", m) == 1 && (m == "/" || substr("/var/lib/docker", length(m) + 1, 1) == "/") && length(m) > best) { best = length(m); row = $0 } } END { print row }' <<< "$rows")
if [ "$full" = 1 ]; then
echo
echo " A full disk fails WRITES, not reads — so it rarely presents as 'disk':"
echo " pages render logged-out but 500 logged-in (sessions, DB writes)"
echo " databases crash-loop on start (they must write to start)"
echo " pushes and uploads die partway through"
fi
# ── memory — rig's check.sh mb_of, recomputed here (✖ B8), pointed at the box's /proc/meminfo ──
echo
echo "memory"
mb_of() {
awk -v k="$1:" '$1 == k { printf "%d", $2 / 1024; found = 1 } END { if (!found) printf "0" }' "$f/meminfo"
}
local mtotal mavail swap_used
mtotal=$(mb_of MemTotal); mavail=$(mb_of MemAvailable)
swap_used=$(( $(mb_of SwapTotal) - $(mb_of SwapFree) ))
printf " available %d MB of %d MB\n" "$mavail" "$mtotal"
[ "$mtotal" -gt 0 ] && [ $(( mavail * 100 / mtotal )) -lt 10 ] && \
echo " ! under 10% available — expect the OOM killer before anything says 'memory'"
[ "$swap_used" -gt 0 ] && printf " ! %d MB in swap\n" "$swap_used"
# ── containers ──────────────────────────────────────────────────────────
echo
echo "containers"
awk -F'\t' 'NF >= 3 { n++; if ($2 == "running") r++; else printf " ! %-26s %-11s %s\n", $1, $2, $3 }
END { printf " %d of %d running\n", r, n }' "$f/containers"
awk -F'\t' 'NF >= 3 && $3 + 0 > 0 { n = $2; sub(/^\//, "", n); printf "%d\t%s\n", $3, n }' "$f/container_config" \
| sort -rn | head -5 | awk -F'\t' '{ printf " restarted %4d× %s\n", $1, $2 }'
local unbounded
unbounded=$(awk -F'\t' 'NF >= 6 && $5 == "json-file" && ($6 == "" || $6 == "<no value>") { n = $2; sub(/^\//, "", n); printf "%s ", n }' "$f/container_config")
if [ -n "$unbounded" ]; then
echo " ! json-file logs with NO max-size — each grows until the disk is full:"
echo "$unbounded" | fold -s -w 70 | sed 's/^/ /'
fi
# ── what would free space, cheapest first ───────────────────────────────
echo
echo "space — what would free it, and what it costs (≈MB; printed, never run)"
local dfrows; dfrows=$(awk "$AWK_MB"'
NR > 1 && NF >= 5 {
line = $0; gsub(/\([0-9]+%\)/, "", line); n = split(line, a, " ")
t = a[1]; for (i = 2; i <= n - 4; i++) t = t " " a[i]
printf "%s\t%d\t%d\n", t, mb(a[n - 1]), mb(a[n])
}' "$f/docker_df")
rec() { awk -F'\t' -v t="$1" '$1 == t { print $3 }' <<< "$dfrows"; }
local r_cache r_img r_ctr r_vol j_mb logs_mb=""
r_cache=$(rec "Build Cache"); r_img=$(rec "Images"); r_ctr=$(rec "Containers"); r_vol=$(rec "Local Volumes")
j_mb=$(awk '/take up/ { for (i = 1; i <= NF; i++) if ($i ~ /^[0-9.]+[KMGT]$/) { v = $i; u = substr(v, length(v)); sub(/[KMGT]$/, "", v); m = (u == "K" ? v / 1024 : u == "M" ? v : u == "G" ? v * 1024 : v * 1048576); printf "%d", m } }' "$f/journal" 2>/dev/null)
printf " %6s build cache docker builder prune free — nothing running uses it\n" "${r_cache:-?}"
printf " %6s unused images docker image prune -a next deploy re-pulls or rebuilds\n" "${r_img:-?}"
printf " %6s stopped ctrs docker container prune they cannot be 'start'ed again\n" "${r_ctr:-?}"
printf " %6s host journal sudo journalctl --vacuum-size=200M older host logs are gone\n" "${j_mb:-?}"
if [ -f "$f/admin_container_logs" ]; then
logs_mb=$(awk '$1 ~ /^[0-9]+$/ { s += $1 } END { print s + 0 }' "$f/admin_container_logs")
printf " %6s container logs sudo truncate -s 0 <log> RELIEF, not cure: they refill\n" "$logs_mb"
awk -F'\t' 'NR == FNR { id[substr($1, 1, 12)] = $2; next }
$1 ~ /^[0-9]+$/ && $1 >= 50 { n = $2; sub(/.*containers\//, "", n); k = substr(n, 1, 12); nm = (k in id ? id[k] : k); sub(/^\//, "", nm); printf " %6d %s\n", $1, nm }' \
"$f/container_config" "$f/admin_container_logs"
else
echo " ? container logs not sized: /var/lib/docker is root-only"
echo " — 'make host snapshot' with --admin sizes them"
fi
printf " %6s unused VOLUMES (no command) DATA. Unreferenced is not unwanted —\n" "${r_vol:-?}"
echo " a dangling volume can be the only copy. Decide per volume."
if [ -s "$f/registry_repos" ] && ! grep -q '^(not found' "$f/registry_repos"; then
local blobs; blobs=$(awk -F'\t' '$1 == "blobs_mb" { print $2 }' "$f/registry_repos")
printf " %6s registry blobs registry garbage-collect a DECISION — see registry below\n" "${blobs:-?}"
fi
# rig's check tells apart two failures with opposite fixes ("stopping that
# is what helps — a bigger VM would not"). The disk has the same pair.
if [ -n "$dmnt" ] && [ "${dtotal:-0}" -gt 0 ]; then
local deficit safe
deficit=$(( (dtotal - davail) - dtotal * 80 / 100 ))
safe=$(( ${r_cache:-0} + ${r_img:-0} + ${r_ctr:-0} + ${j_mb:-0} + ${logs_mb:-0} ))
echo
if [ "$deficit" -le 0 ]; then
printf " %s is under 80%% — no deficit.\n" "$dmnt"
elif [ "$safe" -lt "$deficit" ] && [ -z "$logs_mb" ]; then
# Container logs are unsized without --admin. Counting that as 0 once
# printed "Pruning will NOT fix this" on a 15 MB margin — a verdict
# resting entirely on a number nobody measured.
printf " %s needs ≈%d MB to reach 80%%; ≈%d MB is reclaimable, NOT counting container logs.\n" "$dmnt" "$deficit" "$safe"
echo " Undetermined. Logs were not sized, and they are the one lever that can"
echo " flip this verdict. Size them first: bash ctrl/host.sh snapshot --run --admin"
elif [ "$safe" -ge "$deficit" ]; then
printf " %s needs ≈%d MB to reach 80%%; ≈%d MB is reclaimable without touching data.\n" "$dmnt" "$deficit" "$safe"
echo " Pruning is enough. A bigger disk would not be needed yet —"
echo " and if it refills, the cause is unbounded logs or builds, not size."
else
printf " %s needs ≈%d MB to reach 80%%; only ≈%d MB is reclaimable without touching data.\n" "$dmnt" "$deficit" "$safe"
echo " Pruning will NOT fix this. The rest is data — see the biggest directories."
echo " Grow the volume (EBS resizes online, then growpart + resize2fs), or move data off."
fi
fi
# What was measured, against what is used. The gap is not a finding about
# WHAT fills it — only that the unprivileged probe could not see it.
# Measured against used. Two estimates were wrong before this (2026-09-16):
# "used − docker − \$HOME" forgot the OS, and called 3 GB of /usr invisible.
# du / already walks the OS; only root-only trees are missing from it.
if [ -n "$dmnt" ] && [ -z "$logs_mb" ]; then
local seen gap
seen=$(awk -F'\t' '$2 == "/" { print $1 + 0 }' "$f/du_root" 2>/dev/null)
if [ -n "$seen" ]; then
gap=$(( (dtotal - davail) - seen ))
if [ "$gap" -gt 512 ]; then
echo
printf " ≈%d MB used on %s is root-only — du / could not read it.\n" "$gap" "$dmnt"
echo " Mostly /var/lib/docker, container logs included. --admin measures it."
fi
fi
fi
# ── where the space is ──────────────────────────────────────────────────
echo
echo "biggest directories (MB; unprivileged, so root-only trees are absent)"
awk -F'\t' 'NR <= 8 { printf " %7d %s\n", $1, $2 }' "$f/du_root" 2>/dev/null
if [ -f "$f/admin_docker_dirs" ]; then
echo " — /var/lib/docker (root):"
awk -F'\t' 'NR <= 4 && $1 ~ /^[0-9]+$/ { printf " %7d %s\n", $1, $2 }' "$f/admin_docker_dirs"
fi
echo " — under \$HOME:"
awk -F'\t' 'NR >= 2 && NR <= 8 { printf " %7d %s\n", $1, $2 }' "$f/du_home" 2>/dev/null
# ── registry ────────────────────────────────────────────────────────────
if [ -s "$f/registry_repos" ] && ! grep -q '^(not found' "$f/registry_repos"; then
echo
echo "registry (answers /v2/ anonymously — so read this list before collecting garbage)"
awk -F'\t' '$1 != "blobs_mb" { printf " %s %3s tag(s) %s\n", $1, $2, $3 }' "$f/registry_repos"
echo " A repository nobody recognises here is a finding, not clutter: with"
echo " anonymous push, it may be someone else's image in your deploy path."
fi
echo
echo "reports, never fixes. Every command above is printed; none was run."
}
guard_alias
case "${1:-status}" in
status)
@@ -59,5 +341,7 @@ for r in json.load(sys.stdin):
echo "stacks joining the shared 'gateway' network — intended design, but"
echo "nothing in the tree lists it. The inventory produces that list."
;;
*) echo "usage: $0 [status|ports|services]" >&2; exit 1 ;;
snapshot) shift; snapshot "$@" ;;
check) shift; check "$@" ;;
*) echo "usage: $0 [status|ports|services|snapshot [--run] [--admin]|check [<dir>]]" >&2; exit 1 ;;
esac

View File

@@ -145,3 +145,16 @@ service_upstream() {
*) printf '%s' "$up" ;;
esac
}
# A .gitignore pattern containing a slash anchors to its own directory, so the
# only way to know a path is ignored is to ask git. Used before writing key
# material (vpn.sh) and machine snapshots (host.sh) — refuse, don't hope.
assert_ignored() {
local path="$1"
if ! git check-ignore -q "$path" 2>/dev/null; then
echo "REFUSING: '$path' is not gitignored." >&2
echo " Writing there would stage it on the next 'git add'." >&2
echo " Verify with: git check-ignore -v $path" >&2
exit 1
fi
}

View File

@@ -0,0 +1,17 @@
# Read-only probe of what only root can size (`ssh $HOST_ADMIN`, sudo).
#
# Separate from probe.sh on purpose: INDEX §7 — the admin alias only where sudo
# is genuinely required. It is here: /var/lib/docker is root-only, so without
# this a container's json log — the classic way a small disk fills — has no size.
# Sizes only. Nothing is read out of any file.
set -u
section() { printf '\n### %s\n' "$1"; }
section admin_docker_dirs
sudo -n timeout 60 du -xm --max-depth=1 /var/lib/docker 2>&1 | sort -rn | head -20
section admin_container_logs
sudo -n timeout 60 sh -c 'du -m /var/lib/docker/containers/*/*-json.log 2>/dev/null' | sort -rn | head -20
section admin_volumes
sudo -n timeout 60 du -xm --max-depth=1 /var/lib/docker/volumes 2>&1 | sort -rn | head -20

View File

@@ -0,0 +1,79 @@
# Read-only probe of the box, as the unprivileged user (`ssh $HOST`).
#
# host.sh sends THIS FILE, verbatim, over `ssh $HOST bash -s`, and prints this
# file, verbatim, when asked what it would run. The commands you read are the
# commands that run — there is no second copy to drift from.
#
# Never read here, because a snapshot is written to disk on another machine:
# - container environments (bare `docker inspect` prints Env — every
# inspect below names the fields it prints)
# - compose files and .env (inline secrets)
# - container logs (tokens and credentials leak into logs)
# No sudo. What needs root is probe-admin.sh, a separately announced batch.
set -u
section() { printf '\n### %s\n' "$1"; }
# Every step that walks a tree is bounded. `du /` takes ~20s on this box and
# prints nothing until it finishes (its output goes through sort), so a slow or
# sick disk looks exactly like a hang. A step that times out says so and the
# probe moves on: a partial answer beats none.
bounded() { local t="$1"; shift; timeout "$t" "$@"; rc=$?; [ $rc = 124 ] && echo "(timed out after ${t}s: $*)"; return 0; }
REAL='-x tmpfs -x devtmpfs -x overlay -x squashfs -x efivarfs'
section meta
date -u +%Y-%m-%dT%H:%M:%SZ
hostname
uname -r
uptime
section df
df -P -B1M $REAL 2>/dev/null
section df_inodes
df -P -i $REAL 2>/dev/null
section meminfo
cat /proc/meminfo
section containers
docker ps -a --format '{{.Names}}\t{{.State}}\t{{.Status}}\t{{.Image}}' 2>&1
section container_config
ids=$(docker ps -aq 2>/dev/null)
[ -n "$ids" ] && docker inspect --format '{{.Id}} {{.Name}} {{.RestartCount}} {{.HostConfig.RestartPolicy.Name}} {{.HostConfig.LogConfig.Type}} {{index .HostConfig.LogConfig.Config "max-size"}} {{index .Config.Labels "com.docker.compose.project.working_dir"}}' $ids 2>&1
section docker_df
docker system df 2>&1
section docker_df_verbose
bounded 30 docker system df -v 2>&1
section daemon_json
cat /etc/docker/daemon.json 2>/dev/null || echo '(absent)'
section journal
journalctl --disk-usage 2>&1
section du_home
bounded 30 du -xm --max-depth=3 "$HOME" 2>/dev/null | sort -rn | head -40
section du_root
# Unprivileged, so /var/lib/docker is absent — probe-admin.sh sizes that. What it
# does see matters anyway: the OS under /usr, and /var/log (779 MB, 2026-09-16).
bounded 90 du -xm --max-depth=2 / 2>/dev/null | sort -rn | head -40
section registry_repos
# The registry answers /v2/ anonymously (curl -> 200, 2026-09-14), so what is
# stored in it is a question in its own right, not bookkeeping.
wd=$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project.working_dir"}}' registry 2>/dev/null)
r="${wd:-$HOME/ppl/gateway}/registry-data/docker/registry/v2/repositories"
if [ -d "$r" ]; then
timeout 30 find "$r" -type d -name _manifests -prune -print 2>/dev/null | while read -r m; do
repo=${m%/_manifests}; repo=${repo#"$r"/}
newest=$(find "$m" -type f -printf '%TY-%Tm-%Td\n' 2>/dev/null | sort | tail -1)
tags=$(ls "$m/tags" 2>/dev/null | wc -l)
printf '%s\t%s\t%s\n' "${newest:-?}" "$tags" "$repo"
done | sort
printf 'blobs_mb\t%s\n' "$(du -sm "${r%/repositories}/blobs" 2>/dev/null | cut -f1)"
else
echo "(not found at $r)"
fi

View File

@@ -195,6 +195,57 @@ check "a roaming peer's port is not the overlay's port" "51820" \
check "the capture warning clears once keys are in" "0" \
"$(bash ./check.sh 2>/dev/null | grep -c 'public keys not captured')"
note "host snapshot + check — rig's check, asked of a remote box"
# Announce-first made mechanical: without --run nothing reaches ssh or disk.
before="$(ls -1 .snapshots 2>/dev/null | wc -l)"
out="$(bash ./host.sh snapshot 2>&1)"
check "snapshot without --run says it ran nothing" "1" "$(grep -c 'and NOT run' <<< "$out")"
check "snapshot without --run writes nothing" "$before" "$(ls -1 .snapshots 2>/dev/null | wc -l)"
# The printed probe IS the sent probe — no second copy to drift from.
check "what is announced is byte-for-byte what is sent" "same" \
"$(diff -q <(sed -n 's/^ | //p; s/^ |$//p' <<< "$out" | sed -n "1,$(wc -l < remote/probe.sh)p") remote/probe.sh >/dev/null && echo same || echo differs)"
check "snapshots are gitignored" "yes" \
"$(cd .. && git check-ignore -q ctrl/.snapshots/x/raw && echo yes || echo no)"
# A snapshot is written to another machine's disk. Environments, compose files
# and logs carry secrets, so the probes must never read them. Comments excluded:
# probe.sh's header names exactly these things in order to forbid them.
check "the probes never read env, compose, or logs" "0" \
"$(grep -hvE '^\s*#' remote/probe.sh remote/probe-admin.sh \
| grep -cE 'docker logs|docker inspect [^-]|docker inspect \$|\.env\b|compose\.ya?ml|printenv|/environ')"
hfix="$(mktemp -d)"
T="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
printf '%s\nfixture\n' "$T" > "$hfix/meta"
printf 'Filesystem 1048576-blocks Used Available Capacity Mounted\n/dev/x 15731 15714 0 100%% /\n' > "$hfix/df"
printf 'Filesystem Inodes IUsed IFree IUse%% Mounted\n/dev/x 100 10 90 10%% /\n' > "$hfix/df_inodes"
printf 'MemTotal: 2000000 kB\nMemAvailable: 1000000 kB\nSwapTotal: 0 kB\nSwapFree: 0 kB\n' > "$hfix/meminfo"
printf 'mongo\trestarting\tRestarting (1) 2s ago\tmongo:6\n' > "$hfix/containers"
printf 'aaaaaaaaaaaa\t/mongo\t99\talways\tjson-file\t<no value>\t/x\n' > "$hfix/container_config"
printf 'TYPE TOTAL ACTIVE SIZE RECLAIMABLE\nImages 3 1 5GB 4GB (80%%)\nBuild Cache 1 0 1GB 1GB\n' > "$hfix/docker_df"
: > "$hfix/journal"; : > "$hfix/du_root"; : > "$hfix/du_home"
hout="$(bash ./host.sh check "$hfix" 2>&1)"
check "a full disk is flagged" "1" "$(grep -c '^ ! / ' <<< "$hout")"
check "a crash-looping container is flagged" "1" "$(grep -c '! mongo .*restarting' <<< "$hout")"
check "an unbounded json log is flagged" "1" "$(grep -c 'NO max-size' <<< "$hout")"
check "reclaimable ≥ deficit → pruning is enough" "1" "$(grep -c 'Pruning is enough' <<< "$hout")"
printf 'TYPE TOTAL ACTIVE SIZE RECLAIMABLE\nImages 3 3 5GB 0B (0%%)\n' > "$hfix/docker_df"
# Unknown is not zero. Without --admin, container logs are unsized, and they can
# flip the verdict — 2026-09-16 printed "will NOT fix" on a 15 MB margin that
# rested on counting them as 0. Unsized → undetermined, never a verdict.
check "unsized logs + a shortfall → undetermined, not a verdict" "1 0" \
"$(o="$(bash ./host.sh check "$hfix" 2>&1)"; echo "$(grep -c Undetermined <<< "$o") $(grep -c 'will NOT fix' <<< "$o")")"
printf '5000\t/var/lib/docker/containers/aaaaaaaaaaaa/aaaaaaaaaaaa-json.log\n' > "$hfix/admin_container_logs"
check "sized logs map back to container names" "1" \
"$(bash ./host.sh check "$hfix" 2>&1 | grep -cE '^ +5000 mongo$')"
: > "$hfix/admin_container_logs"
check "measured and still short → pruning will NOT fix it" "1" \
"$(bash ./host.sh check "$hfix" 2>&1 | grep -c 'Pruning will NOT fix')"
rm -rf "$hfix"
check "no snapshot is exit 1, not a silent pass" "1" \
"$(empty="$(mktemp -d)"; SNAP_ROOT="$empty" bash ./host.sh check >/dev/null 2>&1; echo $?; rmdir "$empty")"
note "every STALE entry has a check here"
# Not "$0": line 15 cd's into this script's directory, so a relative $0 no

View File

@@ -196,17 +196,7 @@ PY
return 0
}
# A .gitignore pattern containing a slash anchors to its own directory, so the
# only way to know a path is ignored is to ask git.
assert_ignored() {
local path="$1"
if ! git check-ignore -q "$path" 2>/dev/null; then
echo "REFUSING: '$path' is not gitignored." >&2
echo " Writing key material there would stage it on the next 'git add'." >&2
echo " Verify with: git check-ignore -v $path" >&2
exit 1
fi
}
# assert_ignored lives in lib/estate.sh — host.sh's snapshots need it too.
keygen() {
local peer="${1:-}"

184
build.py
View File

@@ -23,7 +23,6 @@ Generated structure for managed rooms:
"""
import argparse
import importlib.util
import json
import logging
import shutil
@@ -559,148 +558,6 @@ def _append_cabinet_env(output_dir: Path, cabinets: list[dict]):
example.write_text(existing.rstrip("\n") + "\n" + "\n".join(lines) + "\n")
def load_plexuses(room: str) -> list[dict]:
"""The plexuses a room asked for. Same shape as its sibling data/*.json."""
path = SPR_ROOT / "cfg" / room / "data" / "plexuses.json"
if not path.exists():
return []
try:
raw = json.loads(path.read_text())
except ValueError as e:
log.warning(f" plexuses.json is not valid JSON, ignoring: {e}")
return []
entries = raw.get("items", raw) if isinstance(raw, dict) else raw
out = []
for entry in entries if isinstance(entries, list) else []:
if isinstance(entry, str):
entry = {"name": entry}
if isinstance(entry, dict) and entry.get("name"):
out.append(entry)
return out
def _theme_css(theme: str) -> str:
"""The token contract plus one theme, flattened for inlining.
Only the named theme ships alongside the others it can switch to, because
the export has to work with no server: there is no /theme.css to fetch.
"""
theme_dir = SPR_ROOT / "soleprint" / "common" / "theme"
parts = []
tokens = theme_dir / "tokens.css"
if tokens.exists():
parts.append(tokens.read_text())
# Every theme, so the switcher in the page has something to switch to.
for sheet in sorted((theme_dir / "themes").glob("*.css")):
parts.append(sheet.read_text())
return "\n".join(parts)
def _inline_svg(name: str, theme: str) -> str:
"""A rendered graph, stripped of its XML prolog so it can sit in HTML.
Inlined rather than <img>-linked so the page's CSS can recolour it when the
theme switches — graphviz writes class="node accent" into the SVG, and CSS
outranks the presentation attributes it bakes in.
"""
graphs = SPR_ROOT / "docs" / "graphs"
for candidate in (graphs / f"{name}.{theme}.svg", graphs / f"{name}.svg"):
if candidate.exists():
svg = candidate.read_text()
start = svg.find("<svg")
return svg[start:] if start >= 0 else svg
log.warning(f" no rendered graph '{name}' — run docs/graphs/render.sh")
return "<p>diagram not rendered</p>"
def build_plexuses(output_dir: Path, room: str):
"""Export each plexus the room declared to a single self-contained file.
A plexus is exported, not served. The output is one index.html carrying its
theme, its data and its diagram, so it survives a locked-down machine, a zip
attachment and a double-click — which is the whole point of the format.
"""
requested = load_plexuses(room)
if not requested:
return
source_root = SPR_ROOT / "soleprint" / "artery" / "plexuses"
built = []
for entry in requested:
name = entry["name"]
source = source_root / name
manifest_path = source / "plexus.json"
if not manifest_path.exists():
available = sorted(
p.name for p in source_root.iterdir() if p.is_dir()
) if source_root.exists() else []
log.warning(
f" no such plexus: {name} (available: {', '.join(available) or 'none'})"
)
continue
try:
manifest = json.loads(manifest_path.read_text())
except ValueError as e:
log.warning(f" plexus {name} has invalid plexus.json: {e}")
continue
# The room may override anything the plexus declares — theme first.
manifest.update({k: v for k, v in entry.items() if k != "name"})
template_path = source / "app" / "index.html"
if not template_path.exists():
log.warning(f" plexus {name} has no app/index.html")
continue
theme = manifest.get("theme", "soleprint")
data = {k: v for k, v in manifest.items() if not k.startswith("_")}
# A plexus may ship a showcase.py exposing collect(): anything it returns
# is merged into the page's data. The bundle uses it to run the real
# tools over the real fixtures at build time, so what the page shows
# cannot drift from what the tools do.
collector = source / "showcase.py"
if collector.exists():
try:
spec = importlib.util.spec_from_file_location(
f"plexus_{name}_showcase", collector
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
data["showcase"] = module.collect()
except Exception as e:
log.warning(f" {name}: showcase.py failed ({type(e).__name__}: {e})")
page = template_path.read_text()
for token, value in (
("%%TITLE%%", manifest.get("title", name)),
("%%DESCRIPTION%%", manifest.get("description", "")),
("%%DEFAULT_THEME%%", theme),
("%%BUILT%%", f"{room} · built by soleprint build.py"),
("%%THEME_CSS%%", _theme_css(theme)),
("%%GRAPH%%", _inline_svg(manifest.get("graph", "system_overview"), theme)),
("%%BUNDLE%%", json.dumps(data, indent=2)),
):
page = page.replace(token, value)
target = output_dir / "plexuses" / name
ensure_dir(target)
(target / "index.html").write_text(page)
# Anything else in app/ rides along, for a plexus that outgrows one file.
for extra in (source / "app").iterdir():
if extra.name != "index.html":
copy_path(extra, target / extra.name, quiet=True)
built.append(f"{name} ({theme})")
if built:
log.info(f" plexuses: {', '.join(built)}")
def build_soleprint(output_dir: Path, room: str):
"""Build soleprint folder with core + room config merged."""
soleprint = SPR_ROOT / "soleprint"
@@ -738,11 +595,6 @@ def build_soleprint(output_dir: Path, room: str):
log.info("Composing cabinets...")
compose_cabinets(output_dir, room)
# Plexuses are exported rather than served, so this is a compile step like
# the cabinet merge above — not something run.py does at request time.
log.info("Exporting plexuses...")
build_plexuses(output_dir, room)
# Generate models
log.info("Generating models...")
if not generate_models(output_dir, room):
@@ -799,33 +651,6 @@ def build_models_only():
sys.exit(1)
def build_plexuses_only(room: str):
"""Compile just the plexus UIs, without rebuilding the room around them.
The equivalent of `vite build` for this repo: the iteration loop when you
are working on the UI itself is edit, compile, reopen the file — and a full
room build to see a CSS change is a slow way to do that.
"""
output_dir = SPR_ROOT / "gen" / room
if not output_dir.exists():
log.error(f"Room '{room}' is not built — run: python build.py --cfg {room}")
sys.exit(1)
log.info(f"Compiling plexus UIs for {room}...")
build_plexuses(output_dir, room)
built = sorted((output_dir / "plexuses").glob("*/index.html"))
if not built:
log.warning(
f" nothing compiled — does cfg/{room}/data/plexuses.json list one?"
)
return
for page in built:
log.info(f" {page.relative_to(SPR_ROOT)} ({page.stat().st_size // 1024} KB)")
log.info("\n✓ Open directly — no server needed:")
log.info(f" xdg-open {built[0]}")
def main():
parser = argparse.ArgumentParser(description="Soleprint Build Tool")
@@ -833,17 +658,10 @@ def main():
parser.add_argument("--cfg", "-c", type=str, help="Room config name")
parser.add_argument("--all", action="store_true", help="Build all rooms")
parser.add_argument("--models", action="store_true", help="Only regenerate models")
parser.add_argument(
"--plexuses",
action="store_true",
help="Only compile the plexus UIs into an already-built room",
)
args = parser.parse_args()
if args.plexuses:
build_plexuses_only(args.cfg or "standalone")
elif args.models:
if args.models:
build_models_only()
elif args.all:
build(SPR_ROOT / "gen" / "standalone", None)

17
cfg/.gitignore vendored
View File

@@ -1,17 +0,0 @@
# Environment files with credentials (use .env.example as template)
**/.env
# Database dumps (sensitive data)
**/dumps/*.sql
# Python
__pycache__/
*.pyc
*.pyo
standalone
sample
# standalone/ and sample/ are kept in the main soleprint repo as templates.
# Other rooms (amar/, dlt/, etc.) are listed in the repo-root .gitignore.

View File

@@ -1,125 +1,11 @@
# Soleprint Room Configurations
# cfg
Private repository containing room-specific configurations for Soleprint instances.
Room configurations for `build.py` (`make build [room]` → `gen/<room>/`).
## Structure
- `standalone/`: the base room, built by default.
```
cfg/
├── amar/ # Amar managed room
│ ├── config.json
│ ├── data/
│ ├── soleprint/ # Soleprint customizations
│ ├── link/ # Bridge to managed app
│ └── ctrl/ # Build/run scripts
├── dlt/ # DLT placeholder room
└── README.md
```
Rooms are a pre-rig way of running spr beside an app. Wrapping an inherited site,
with spr's sidebar injected by nginx `sub_filter`, is meant to become a rig overlay.
## Setup
This repo lives inside the main soleprint repo at `cfg/`. The `standalone/` folder is tracked in the main soleprint repo as a sample.
### Fresh clone (new machine)
```bash
# Clone main soleprint repo
git clone <soleprint-repo-url> spr
cd spr
# Clone this cfg repo into cfg/ (standalone/ already exists from main repo)
git clone <this-cfg-repo-url> cfg-private
mv cfg-private/.git cfg/
mv cfg-private/* cfg/ 2>/dev/null
rm -rf cfg-private
# Now cfg/ has both:
# - standalone/ (from main soleprint repo)
# - amar/, dlt/ (from this cfg repo)
```
### Alternative: Separate directories
```bash
# Keep repos separate, use --cfg-path
git clone <soleprint-repo-url> spr
git clone <this-cfg-repo-url> spr-cfg
# Build with external cfg path
cd spr
python build.py --cfg amar --cfg-path ../spr-cfg
```
## Building
From the main soleprint repo:
```bash
python build.py # Build standalone (sample)
python build.py --cfg amar # Build amar room
python build.py --cfg dlt # Build dlt room
```
## Deploy/Sync Workflow
### Local Development
```bash
# 1. Build the room
python build.py --cfg amar
# 2. Start with Docker
cd gen/amar/soleprint && docker compose up -d
# 3. For managed rooms, also start the app
cd gen/amar/amar && docker compose up -d
```
### Production Deploy
```bash
# On deploy server:
# 1. Pull both repos
cd /opt/spr && git pull
cd /opt/spr/cfg && git pull
# 2. Rebuild
python build.py --cfg amar
# 3. Restart services
cd gen/amar/soleprint && docker compose up -d --build
```
### CI/CD Pipeline (example)
```yaml
# Deploy soleprint changes
deploy-soleprint:
script:
- ssh deploy@server "cd /opt/spr && git pull && python build.py --cfg amar"
- ssh deploy@server "cd /opt/spr/gen/amar/soleprint && docker compose up -d --build"
# Deploy cfg changes (private repo)
deploy-cfg:
script:
- ssh deploy@server "cd /opt/spr/cfg && git pull && python ../build.py --cfg amar"
- ssh deploy@server "cd /opt/spr/gen/amar/soleprint && docker compose up -d --build"
```
## Credentials
- `.env` files are gitignored - copy from `.env.example`
- Never commit actual credentials
- Database dumps in `*/dumps/` are also gitignored
## Adding a New Room
```bash
# From cfg/ directory
mkdir -p newroom/data newroom/soleprint
cp ../cfg/standalone/config.json newroom/ # Use standalone as template
# Edit config.json for your room
# Add room-specific customizations in newroom/soleprint/
git add newroom && git commit -m "Add newroom configuration"
```
The client rooms that lived here (with their own git repo, untracked by spr) were moved
out on 2026-10-07 and are kept as reference outside spr.

View File

@@ -1,150 +0,0 @@
{
"framework": {
"name": "soleprint",
"slug": "soleprint",
"version": "0.1.0",
"description": "Development workflow and documentation system",
"tagline": "Mapping development footprints",
"icon": "",
"hub_port": 12030
},
"auth": {
"enabled": true,
"provider": "google",
"allowed_domains": [],
"allowed_emails": [],
"session_secret": "ENV:AUTH_SESSION_SECRET"
},
"veins": ["google"],
"managed": {
"name": "sample",
"repos": {
"backend": "examples/fixture-invoicing/backend",
"frontend": "examples/fixture-invoicing/frontend"
}
},
"k8s": {
"enabled": true
},
"systems": [
{
"key": "data_flow",
"name": "artery",
"slug": "artery",
"title": "Artery",
"tagline": "Todo lo vital",
"icon": ""
},
{
"key": "documentation",
"name": "atlas",
"slug": "atlas",
"title": "Atlas",
"tagline": "Documentacion accionable",
"icon": ""
},
{
"key": "execution",
"name": "station",
"slug": "station",
"title": "Station",
"tagline": "Monitores, Entornos y Herramientas",
"icon": ""
}
],
"components": {
"shared": {
"config": {
"name": "room",
"title": "Room",
"description": "Runtime environment configuration",
"plural": "rooms"
},
"data": {
"name": "depot",
"title": "Depot",
"description": "Data storage / provisions",
"plural": "depots"
}
},
"data_flow": {
"connector": {
"name": "vein",
"title": "Vein",
"description": "Stateless API connector",
"plural": "veins"
},
"mock": {
"name": "shunt",
"title": "Shunt",
"description": "Fake connector for testing",
"plural": "shunts"
},
"composed": {
"name": "pulse",
"title": "Pulse",
"description": "Composed data flow",
"plural": "pulses",
"formula": "Vein + Room + Depot"
},
"app": {
"name": "plexus",
"title": "Plexus",
"description": "Full app with backend, frontend and DB",
"plural": "plexus"
}
},
"documentation": {
"pattern": {
"name": "template",
"title": "Template",
"description": "Documentation pattern",
"plural": "templates"
},
"library": {
"name": "book",
"title": "Book",
"description": "Documentation library"
},
"composed": {
"name": "book",
"title": "Book",
"description": "Composed documentation",
"plural": "books",
"formula": "Template + Depot"
}
},
"execution": {
"utility": {
"name": "tool",
"title": "Tool",
"description": "Execution utility",
"plural": "tools"
},
"watcher": {
"name": "monitor",
"title": "Monitor",
"description": "Service monitor",
"plural": "monitors"
},
"container": {
"name": "cabinet",
"title": "Cabinet",
"description": "Tool container",
"plural": "cabinets"
},
"workspace": {
"name": "desk",
"title": "Desk",
"description": "Execution workspace"
},
"composed": {
"name": "desk",
"title": "Desk",
"description": "Composed execution bundle",
"plural": "desks",
"formula": "Cabinet + Room + Depots"
}
}
}
}

View File

@@ -1,62 +0,0 @@
#!/bin/bash
# Deploy dlt to AWS
#
# Usage:
# ./ctrl/deploy.sh # Sync and restart
# ./ctrl/deploy.sh --rebuild # Rebuild container image
# ./ctrl/deploy.sh --dry-run # Preview sync
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOM_DIR="$(dirname "$SCRIPT_DIR")"
ROOM_NAME="$(basename "$ROOM_DIR")"
SPR_DIR="$(cd "$ROOM_DIR/../.." && pwd)"
SERVER="${DEPLOY_SERVER:-mcrn.ar}"
REMOTE_BASE="${DEPLOY_REMOTE_PATH:-~/soleprint/gen}"
REMOTE_PATH="$REMOTE_BASE/$ROOM_NAME/soleprint"
LOCAL_PATH="$SPR_DIR/gen/$ROOM_NAME/soleprint"
DRY_RUN=""
REBUILD=""
[ "$1" == "--dry-run" ] && DRY_RUN="--dry-run"
[ "$1" == "--rebuild" ] && REBUILD="--build"
# Check if built
if [[ ! -d "$LOCAL_PATH" ]]; then
echo "Error: $LOCAL_PATH not found"
echo "Build first: cd $SPR_DIR && python build.py --cfg $ROOM_NAME"
exit 1
fi
echo "=== Deploying $ROOM_NAME to $SERVER ==="
# Sync files, excluding local-only
rsync -avz --delete --progress $DRY_RUN \
--exclude='.env' \
--exclude='__pycache__/' \
--exclude='*.pyc' \
--exclude='**/storage/' \
"$LOCAL_PATH/" \
"$SERVER:$REMOTE_PATH/"
if [ -n "$DRY_RUN" ]; then
echo "=== Dry run complete ==="
exit 0
fi
# Get container name from AWS .env
CONTAINER_NAME=$(ssh "$SERVER" "grep DEPLOYMENT_NAME $REMOTE_PATH/.env 2>/dev/null | cut -d= -f2" || echo "${ROOM_NAME}_spr")
echo ""
echo "=== Restarting $CONTAINER_NAME ==="
ssh "$SERVER" "cd $REMOTE_PATH && docker compose down && docker compose up -d $REBUILD"
echo ""
echo "=== Connecting to gateway network ==="
ssh "$SERVER" "docker network connect gateway $CONTAINER_NAME 2>/dev/null || true"
echo ""
echo "=== Done ==="
echo "Test at: https://$ROOM_NAME.spr.mcrn.ar"

View File

@@ -1,45 +0,0 @@
#!/bin/bash
# Start sample room (fixture invoicing + link + soleprint)
#
# Usage:
# ./start.sh # Start all (foreground, with nginx)
# ./start.sh -d # Start all (detached)
# ./start.sh --no-nginx # Skip nginx reverse proxy
# ./start.sh --build # Rebuild images
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
APP_DIR="$ROOT_DIR/sample"
LINK_DIR="$ROOT_DIR/link"
SPR_DIR="$ROOT_DIR/soleprint"
BUILD=""
DETACH=""
NGINX_OVERRIDE="-f docker-compose.nginx.yml"
for arg in "$@"; do
case $arg in
-d|--detached) DETACH="-d" ;;
--build) BUILD="--build" ;;
--no-nginx) NGINX_OVERRIDE="" ;;
esac
done
# Ensure shared docker network exists (soleprint + managed app share it)
NETWORK="$(grep -m1 '^NETWORK_NAME=' "$APP_DIR/.env" | cut -d= -f2)"
docker network inspect "$NETWORK" >/dev/null 2>&1 || docker network create "$NETWORK"
echo "=== Starting sample ==="
# 1) Managed app (db + backend + frontend)
(cd "$APP_DIR" && docker compose up -d $BUILD)
# 2) Link bridge
if [[ -f "$LINK_DIR/docker-compose.yml" ]]; then
(cd "$LINK_DIR" && docker compose --env-file "$APP_DIR/.env" up -d $BUILD)
fi
# 3) Soleprint (+ nginx sidebar injection)
(cd "$SPR_DIR" && docker compose -f docker-compose.yml $NGINX_OVERRIDE up $DETACH $BUILD)

View File

@@ -1,15 +0,0 @@
#!/bin/bash
# Stop sample room
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
echo "=== Stopping sample ==="
for d in "$ROOT_DIR/soleprint" "$ROOT_DIR/link" "$ROOT_DIR/sample"; do
if [[ -f "$d/docker-compose.yml" ]]; then
(cd "$d" && docker compose down)
fi
done

View File

@@ -1,156 +0,0 @@
"""
Soleprint Data Layer
JSON file storage (future: MongoDB)
"""
import json
from pathlib import Path
from typing import List, Optional
# Add parent to path for models import
import sys
sys.path.insert(0, str(Path(__file__).parent.parent))
from models.pydantic import (
Vein, Nest, Larder, Template, Tool,
Pulse, Book, Table,
VeinCollection, NestCollection, LarderCollection,
TemplateCollection, ToolCollection,
PulseCollection, BookCollection, TableCollection,
Status
)
DATA_DIR = Path(__file__).parent.resolve()
# Debug: print data dir on import
print(f"[data] DATA_DIR: {DATA_DIR}")
print(f"[data] DATA_DIR exists: {DATA_DIR.exists()}")
print(f"[data] veins.json exists: {(DATA_DIR / 'veins.json').exists()}")
def _load_json(filename: str) -> dict:
filepath = DATA_DIR / filename
if filepath.exists():
with open(filepath) as f:
data = json.load(f)
print(f"[data] Loaded {filename}: {len(data.get('items', []))} items")
return data
print(f"[data] File not found: {filepath}")
return {"items": []}
def _save_json(filename: str, data: dict):
filepath = DATA_DIR / filename
with open(filepath, 'w') as f:
json.dump(data, f, indent=2)
# === Loaders ===
def get_veins() -> List[Vein]:
data = _load_json("veins.json")
return VeinCollection(**data).items
def get_nests() -> List[Nest]:
data = _load_json("nests.json")
return NestCollection(**data).items
def get_larders() -> List[Larder]:
data = _load_json("larders.json")
return LarderCollection(**data).items
def get_templates() -> List[Template]:
data = _load_json("templates.json")
return TemplateCollection(**data).items
def get_tools() -> List[Tool]:
data = _load_json("tools.json")
return ToolCollection(**data).items
def get_cabinets() -> list:
"""Load cabinets (simple dict, no pydantic yet)."""
data = _load_json("cabinets.json")
return data.get("items", [])
def get_monitors() -> list:
"""Load monitors (simple dict, no pydantic yet)."""
data = _load_json("monitors.json")
return data.get("items", [])
def get_pulses() -> List[Pulse]:
data = _load_json("pulses.json")
return PulseCollection(**data).items
def get_books() -> List[Book]:
data = _load_json("books.json")
return BookCollection(**data).items
def get_tables() -> List[Table]:
data = _load_json("tables.json")
return TableCollection(**data).items
# === Helpers ===
def get_vein(name: str) -> Optional[Vein]:
for v in get_veins():
if v.name == name:
return v
return None
def get_nest(name: str) -> Optional[Nest]:
for n in get_nests():
if n.name == name:
return n
return None
def get_larder(name: str) -> Optional[Larder]:
for l in get_larders():
if l.name == name:
return l
return None
# === For frontend rendering ===
def get_artery_data() -> dict:
"""Data for artery frontend."""
return {
"veins": [v.model_dump() for v in get_veins()],
"nests": [n.model_dump() for n in get_nests()],
"larders": [l.model_dump() for l in get_larders()],
"pulses": [p.model_dump() for p in get_pulses()],
}
def get_album_data() -> dict:
"""Data for album frontend."""
return {
"templates": [t.model_dump() for t in get_templates()],
"larders": [l.model_dump() for l in get_larders()],
"books": [b.model_dump() for b in get_books()],
}
def get_ward_data() -> dict:
"""Data for ward frontend."""
return {
"tools": [t.model_dump() for t in get_tools()],
"monitors": get_monitors(),
"cabinets": get_cabinets(),
"nests": [n.model_dump() for n in get_nests()],
"larders": [l.model_dump() for l in get_larders()],
"tables": [t.model_dump() for t in get_tables()],
}

View File

@@ -1,3 +0,0 @@
{
"items": []
}

View File

@@ -1,3 +0,0 @@
{
"items": []
}

View File

@@ -1,3 +0,0 @@
{
"items": []
}

View File

@@ -1,3 +0,0 @@
{
"items": []
}

View File

@@ -1,3 +0,0 @@
{
"items": []
}

View File

@@ -1,3 +0,0 @@
{
"items": []
}

View File

@@ -1,3 +0,0 @@
{
"items": []
}

View File

@@ -1,3 +0,0 @@
{
"items": []
}

View File

@@ -1,3 +0,0 @@
{
"items": []
}

View File

@@ -1,3 +0,0 @@
{
"items": []
}

View File

@@ -1,37 +0,0 @@
{
"items": [
{
"name": "datagen",
"slug": "datagen",
"title": "Datagen",
"status": "live",
"system": "station",
"type": "app",
"description": "Generate realistic test data via faker-backed generators.",
"path": "station/tools/datagen",
"url": "/station/tools/datagen/"
},
{
"name": "graphgen",
"slug": "graphgen",
"title": "Graphgen",
"status": "live",
"system": "station",
"type": "app",
"description": "Render the data model as an interactive graph (entities + FK edges).",
"path": "station/tools/graphgen",
"url": "/station/tools/graphgen/"
},
{
"name": "modelgen",
"slug": "modelgen",
"title": "Modelgen",
"status": "live",
"system": "station",
"type": "cli",
"description": "Generate platform-specific models (Pydantic, Django, Prisma) from JSON Schema.",
"path": "station/tools/modelgen",
"cli": "python -m modelgen"
}
]
}

View File

@@ -1,12 +0,0 @@
{
"items": [
{
"name": "google",
"slug": "google",
"title": "Google",
"status": "live",
"system": "artery",
"description": "Google OAuth login + Drive/Calendar/Gmail APIs."
}
]
}

View File

@@ -1,74 +0,0 @@
# Sample Room — Managed App Services (Fixture Invoicing)
#
# Runs backend + frontend + postgres on the shared sample_network,
# so soleprint + nginx can wrap them from cfg/sample/soleprint/.
#
# Usage (from gen/sample/sample/):
# docker compose up -d
name: ${DEPLOYMENT_NAME}
services:
db:
image: postgres:16-alpine
container_name: ${DEPLOYMENT_NAME}_db
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "${DB_PORT}:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 3s
retries: 10
networks:
- default
backend:
build:
context: ./backend
container_name: ${DEPLOYMENT_NAME}_backend
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${POSTGRES_DB}
DB_USER: ${POSTGRES_USER}
DB_PASSWORD: ${POSTGRES_PASSWORD}
SEED_ON_START: "true"
ports:
- "${BACKEND_PORT}:8000"
depends_on:
db:
condition: service_healthy
networks:
- default
frontend:
build:
context: ./frontend
container_name: ${DEPLOYMENT_NAME}_frontend
environment:
# Browser hits /api/ via nginx, so leave blank to use same-origin
VITE_API_URL: ""
ports:
- "${FRONTEND_PORT}:5173"
depends_on:
- backend
volumes:
- ./frontend/src:/app/src
- ./frontend/index.html:/app/index.html
- ./frontend/vite.config.js:/app/vite.config.js
networks:
- default
volumes:
pgdata:
networks:
default:
external: true
name: ${NETWORK_NAME}

View File

@@ -1,15 +0,0 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev gcc \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

View File

@@ -1,43 +0,0 @@
"""SQLAlchemy bridge — read tables via reflection.
Works against the fixture's postgres. Reflection means the link does not
need to import the app's model code — it discovers tables at runtime.
"""
import os
from fastapi import APIRouter, HTTPException
from sqlalchemy import MetaData, create_engine, inspect, select
router = APIRouter()
def _db_url() -> str:
host = os.getenv("DB_HOST", "db")
port = os.getenv("DB_PORT", "5432")
name = os.getenv("DB_NAME", "fixture")
user = os.getenv("DB_USER", "postgres")
password = os.getenv("DB_PASSWORD", "localdev123")
return f"postgresql+psycopg2://{user}:{password}@{host}:{port}/{name}"
engine = create_engine(_db_url(), future=True)
metadata = MetaData()
@router.get("/tables")
def list_tables():
insp = inspect(engine)
return {"tables": insp.get_table_names()}
@router.get("/tables/{name}")
def table_rows(name: str, limit: int = 50):
insp = inspect(engine)
if name not in insp.get_table_names():
raise HTTPException(404, f"table '{name}' not found")
metadata.reflect(bind=engine, only=[name])
table = metadata.tables[name]
with engine.connect() as conn:
rows = conn.execute(select(table).limit(limit)).mappings().all()
return {"table": name, "rows": [dict(r) for r in rows]}

View File

@@ -1,24 +0,0 @@
# Link — Fixture DB bridge
name: ${DEPLOYMENT_NAME}_link
services:
link:
build:
context: .
dockerfile: Dockerfile
container_name: ${DEPLOYMENT_NAME}_link
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${POSTGRES_DB}
DB_USER: ${POSTGRES_USER}
DB_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "${LINK_PORT:-8121}:8000"
networks:
- default
networks:
default:
external: true
name: ${NETWORK_NAME}

View File

@@ -1,19 +0,0 @@
"""Link — DB bridge for the fixture invoicing app.
Exposes a small HTTP API that wraps SQLAlchemy access to the fixture's
postgres so soleprint tools can read/write without touching the app.
"""
from fastapi import FastAPI
from adapters.sqlalchemy_bridge import router as db_router
app = FastAPI(title="Link — Fixture Bridge")
@app.get("/health")
def health():
return {"status": "ok", "target": "fixture-invoicing"}
app.include_router(db_router, prefix="/db")

View File

@@ -1,4 +0,0 @@
fastapi==0.115.0
uvicorn[standard]==0.32.0
sqlalchemy==2.0.36
psycopg2-binary==2.9.10

View File

@@ -1,14 +0,0 @@
# =============================================================================
# sample room — Copy to .env and customize
# =============================================================================
DEPLOYMENT_NAME=sample
NETWORK_NAME=sample_network
BACKEND_PORT=8120
FRONTEND_PORT=3120
DB_PORT=5532
POSTGRES_DB=fixture
POSTGRES_USER=postgres
POSTGRES_PASSWORD=localdev123

View File

@@ -1,23 +0,0 @@
# =============================================================================
# Sample Soleprint Configuration — copy to .env and fill in
# =============================================================================
DEPLOYMENT_NAME=sample_spr
NETWORK_NAME=sample_network
# Ports (unique per room)
SOLEPRINT_PORT=12030
NGINX_PORT=8130
# =============================================================================
# Google OAuth — get values from console.cloud.google.com
# =============================================================================
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-client-secret
GOOGLE_REDIRECT_URI=http://sample.spr.local.ar/artery/google/oauth/callback
# =============================================================================
# Auth
# =============================================================================
AUTH_BYPASS=true
AUTH_SESSION_SECRET=change-me-to-a-long-random-string

View File

@@ -1,35 +0,0 @@
# Nginx Reverse Proxy for Docker-based Local Development
#
# Usage:
# # Start all services including nginx:
# docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d
#
# # Or start nginx separately after other services:
# docker compose -f docker-compose.nginx.yml up -d
#
# Routes:
# - sample.spr.local.ar -> frontend with sidebar injection
# - sample.local.ar -> frontend without sidebar
#
# Note: Requires /etc/hosts entries:
# 127.0.0.1 sample.spr.local.ar sample.local.ar
name: ${DEPLOYMENT_NAME}_nginx
services:
nginx:
image: nginx:alpine
container_name: ${DEPLOYMENT_NAME}_nginx
ports:
- "${NGINX_PORT:-80}:80"
volumes:
- ./nginx/local.conf:/etc/nginx/conf.d/default.conf:ro
networks:
- default
depends_on:
- soleprint
restart: unless-stopped
networks:
default:
name: ${NETWORK_NAME}

View File

@@ -1,37 +0,0 @@
# Soleprint Services - Docker Compose
#
# Runs soleprint hub as a single service
# Artery, atlas, station are accessed via path-based routing
#
# Usage:
# cd gen/<room>/soleprint && docker compose up -d
name: ${DEPLOYMENT_NAME}
services:
soleprint:
build:
context: .
dockerfile: Dockerfile
container_name: ${DEPLOYMENT_NAME}
user: "${UID:-1000}:${GID:-1000}"
volumes:
- .:/app
ports:
- "${SOLEPRINT_PORT}:8000"
env_file:
- .env
environment:
# For single-port mode, all subsystems are internal routes
- ARTERY_EXTERNAL_URL=/artery
- ATLAS_EXTERNAL_URL=/atlas
- STATION_EXTERNAL_URL=/station
networks:
- default
# Use run.py for single-port bare-metal mode
command: uvicorn run:app --host 0.0.0.0 --port 8000 --reload
networks:
default:
external: true
name: ${NETWORK_NAME}

View File

@@ -1,78 +0,0 @@
# Sample Room — Nginx Config
# Uses Docker DNS resolver so upstreams resolve at request time
# (lets nginx start before backend/frontend containers are up).
resolver 127.0.0.11 valid=10s;
# sample.spr.local.ar — fixture wrapped by soleprint (sidebar injected)
server {
listen 80;
server_name sample.spr.local.ar;
set $soleprint sample_spr;
set $backend sample_backend;
set $frontend sample_frontend;
# Soleprint routes (sidebar API + static assets)
location /spr/ {
rewrite ^/spr/(.*)$ /$1 break;
proxy_pass http://$soleprint:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Fixture backend API
location /api/ {
proxy_pass http://$backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Frontend (Vite dev server on 5173) with sidebar injection
location / {
proxy_pass http://$frontend:5173;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Accept-Encoding "";
sub_filter '</head>' '<link rel="stylesheet" href="/spr/sidebar.css"><script src="/spr/sidebar.js" defer></script></head>';
sub_filter_once off;
}
}
# sample.local.ar — direct fixture access (no sidebar)
server {
listen 80;
server_name sample.local.ar;
set $backend sample_backend;
set $frontend sample_frontend;
location /api/ {
proxy_pass http://$backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://$frontend:5173;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}

View File

@@ -1,60 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Fixture Invoicing Data Model",
"description": "Tables of the fixture-invoicing app — all data is obviously placeholder.",
"version": "0.1.0",
"meta": {
"purpose": "Browse and QA fixture data while iterating on soleprint tools.",
"modes": ["sql"]
},
"definitions": {
"Customer": {
"type": "object",
"description": "A fixture customer.",
"table": "customer",
"properties": {
"id": {"type": "integer", "column": "id"},
"name": {"type": "string", "column": "name"},
"email": {"type": "string", "column": "email"},
"created_at": {"type": "string", "format": "date-time", "column": "created_at"}
}
},
"Invoice": {
"type": "object",
"description": "An invoice issued to a fixture customer.",
"table": "invoice",
"properties": {
"id": {"type": "integer", "column": "id"},
"number": {"type": "string", "column": "number"},
"customer_id": {"type": "integer", "column": "customer_id", "fk": "Customer"},
"issued_at": {"type": "string", "format": "date-time", "column": "issued_at"},
"due_at": {"type": "string", "format": "date-time", "column": "due_at"},
"status": {"type": "string", "column": "status"}
}
},
"LineItem": {
"type": "object",
"description": "A billable line on an invoice.",
"table": "line_item",
"properties": {
"id": {"type": "integer", "column": "id"},
"invoice_id": {"type": "integer", "column": "invoice_id", "fk": "Invoice"},
"description": {"type": "string", "column": "description"},
"quantity": {"type": "integer", "column": "quantity"},
"unit_price": {"type": "number", "column": "unit_price"}
}
},
"Payment": {
"type": "object",
"description": "A payment recorded against an invoice.",
"table": "payment",
"properties": {
"id": {"type": "integer", "column": "id"},
"invoice_id": {"type": "integer", "column": "invoice_id", "fk": "Invoice"},
"amount": {"type": "number", "column": "amount"},
"method": {"type": "string", "column": "method"},
"paid_at": {"type": "string", "format": "date-time", "column": "paid_at"}
}
}
}
}

View File

@@ -1,57 +0,0 @@
{
"views": [
{
"name": "customers",
"title": "Customers",
"slug": "customers",
"description": "All fixture customers.",
"mode": "sql",
"entity": "Customer",
"order_by": "id ASC",
"fields": ["id", "name", "email", "created_at"],
"display_fields": {
"id": {"label": "ID", "width": "60px"},
"name": {"label": "Name", "width": "200px", "primary": true},
"email": {"label": "Email", "width": "220px"},
"created_at": {"label": "Created", "width": "180px"}
}
},
{
"name": "invoices_by_status",
"title": "Invoices by Status",
"slug": "invoices-by-status",
"description": "Invoices grouped by lifecycle status.",
"mode": "sql",
"entity": "Invoice",
"group_by": "status",
"order_by": "issued_at DESC",
"fields": ["id", "number", "customer_id", "issued_at", "due_at", "status"],
"display_fields": {
"number": {"label": "Number", "width": "150px", "primary": true},
"status": {"label": "Status", "width": "100px"},
"issued_at": {"label": "Issued", "width": "140px"},
"due_at": {"label": "Due", "width": "140px"}
}
},
{
"name": "line_items",
"title": "Line Items",
"slug": "line-items",
"description": "All line items across fixture invoices.",
"mode": "sql",
"entity": "LineItem",
"order_by": "invoice_id, id",
"fields": ["id", "invoice_id", "description", "quantity", "unit_price"]
},
{
"name": "payments",
"title": "Payments",
"slug": "payments",
"description": "All payments recorded against fixture invoices.",
"mode": "sql",
"entity": "Payment",
"order_by": "paid_at DESC",
"fields": ["id", "invoice_id", "amount", "method", "paid_at"]
}
]
}

View File

@@ -1,124 +0,0 @@
"""Datagen for the fixture-invoicing app.
Generates Customer / Invoice / LineItem / Payment records that match the
fixture's SQLAlchemy models. Obviously-fake names make it clear these are
not real business records.
"""
import random
import uuid
from datetime import datetime, timedelta
from decimal import Decimal
try:
from station.tools.datagen.base import BaseDataGenerator
except ImportError:
class BaseDataGenerator:
pass
_COMPANIES = [
"Acme Widget Co.", "Fixture Industries", "Demo Corp.", "Placeholder LLC",
"Test Customer Holdings", "Lorem Enterprises", "Ipsum & Sons",
"Sample Partners", "Dummy Data Group", "Stub Systems",
]
_METHODS = ["cash", "card", "transfer"]
_STATUSES = ["draft", "sent", "paid", "void"]
class FixtureInvoicingGenerator(BaseDataGenerator):
"""Generates obviously-fake records for the fixture app."""
def customer(self, **kwargs) -> dict:
name = kwargs.pop("name", None) or random.choice(_COMPANIES)
slug = name.lower().replace(" ", "-").replace(".", "").replace(",", "")
return {
"id": str(uuid.uuid4()),
"name": name,
"email": f"billing+{slug}@example.invalid",
"created_at": datetime.utcnow().isoformat(),
**kwargs,
}
def invoice(self, customer_id: int | str | None = None, **kwargs) -> dict:
number = kwargs.pop("number", f"DEMO-{random.randint(1000, 9999)}")
issued = datetime.utcnow() - timedelta(days=random.randint(0, 60))
return {
"id": str(uuid.uuid4()),
"number": number,
"customer_id": customer_id or 1,
"issued_at": issued.isoformat(),
"due_at": (issued + timedelta(days=30)).isoformat(),
"status": kwargs.pop("status", random.choice(_STATUSES)),
**kwargs,
}
def line_item(self, invoice_id: int | str | None = None, **kwargs) -> dict:
qty = kwargs.pop("quantity", random.randint(1, 20))
price = kwargs.pop("unit_price", round(random.uniform(5, 500), 2))
return {
"id": str(uuid.uuid4()),
"invoice_id": invoice_id or 1,
"description": kwargs.pop(
"description", f"Widget {random.choice(['A', 'B', 'C'])} (fixture)"
),
"quantity": qty,
"unit_price": float(Decimal(str(price))),
**kwargs,
}
def payment(self, invoice_id: int | str | None = None, **kwargs) -> dict:
return {
"id": str(uuid.uuid4()),
"invoice_id": invoice_id or 1,
"amount": kwargs.pop("amount", round(random.uniform(50, 2000), 2)),
"method": kwargs.pop("method", random.choice(_METHODS)),
"paid_at": datetime.utcnow().isoformat(),
**kwargs,
}
def schema(self) -> dict:
return {
"models": {
"Customer": {
"doc": "A fixture customer — obviously-placeholder name.",
"fields": {
"id": {"type": "UUID", "pk": True},
"name": {"type": "str"},
"email": {"type": "str"},
"created_at": {"type": "datetime"},
},
},
"Invoice": {
"doc": "An invoice issued to a fixture customer.",
"fields": {
"id": {"type": "UUID", "pk": True},
"number": {"type": "str"},
"customer_id": {"type": "FK:Customer"},
"issued_at": {"type": "datetime"},
"due_at": {"type": "datetime", "nullable": True},
"status": {"type": "str"},
},
},
"LineItem": {
"doc": "A single billable line on an invoice.",
"fields": {
"id": {"type": "UUID", "pk": True},
"invoice_id": {"type": "FK:Invoice"},
"description": {"type": "str"},
"quantity": {"type": "int"},
"unit_price": {"type": "Decimal"},
},
},
"Payment": {
"doc": "A payment recorded against an invoice.",
"fields": {
"id": {"type": "UUID", "pk": True},
"invoice_id": {"type": "FK:Invoice"},
"amount": {"type": "Decimal"},
"method": {"type": "str"},
"paid_at": {"type": "datetime"},
},
},
}
}

View File

@@ -1,44 +0,0 @@
{
"models": {
"Customer": {
"doc": "A fixture customer (obviously-placeholder names).",
"fields": {
"id": {"type": "int", "pk": true},
"name": {"type": "str"},
"email": {"type": "str"},
"created_at": {"type": "datetime"}
}
},
"Invoice": {
"doc": "An invoice issued to a customer. Owns line items and payments.",
"fields": {
"id": {"type": "int", "pk": true},
"number": {"type": "str"},
"customer_id": {"type": "FK:Customer"},
"issued_at": {"type": "datetime"},
"due_at": {"type": "datetime", "nullable": true},
"status": {"type": "str"}
}
},
"LineItem": {
"doc": "A single billable line on an invoice.",
"fields": {
"id": {"type": "int", "pk": true},
"invoice_id": {"type": "FK:Invoice"},
"description": {"type": "str"},
"quantity": {"type": "int"},
"unit_price": {"type": "Decimal"}
}
},
"Payment": {
"doc": "A payment recorded against an invoice.",
"fields": {
"id": {"type": "int", "pk": true},
"invoice_id": {"type": "FK:Invoice"},
"amount": {"type": "Decimal"},
"method": {"type": "str"},
"paid_at": {"type": "datetime"}
}
}
}
}

View File

@@ -1,15 +0,0 @@
{
"environments": [
{
"name": "docker",
"url": "http://sample_backend:8000",
"auth_type": "none",
"default": true
},
{
"name": "local",
"url": "http://localhost:8120",
"auth_type": "none"
}
]
}

View File

@@ -1,52 +0,0 @@
"""Smoke tests for the fixture-invoicing API.
Runs via the soleprint tester. Target URL is read from environments.json
(local = http://sample_backend:8000 inside the soleprint docker network).
"""
from tests.base import ContractTestCase
class CustomersContractTest(ContractTestCase):
def test_list_customers_returns_list(self):
resp = self.get("/api/customers")
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertIsInstance(data, list)
# seed should have produced Acme + Test Customer
names = {c["name"] for c in data}
self.assertIn("Acme Widget Co.", names)
def test_create_and_delete_customer(self):
payload = {"name": "Ephemeral Corp.", "email": "e@example.invalid"}
created = self.post("/api/customers", json=payload)
self.assertEqual(created.status_code, 201)
customer_id = created.json()["id"]
fetched = self.get(f"/api/customers/{customer_id}")
self.assertEqual(fetched.status_code, 200)
self.assertEqual(fetched.json()["name"], "Ephemeral Corp.")
self.delete(f"/api/customers/{customer_id}")
missing = self.get(f"/api/customers/{customer_id}")
self.assertEqual(missing.status_code, 404)
class InvoicesContractTest(ContractTestCase):
def test_list_invoices_returns_seeded(self):
resp = self.get("/api/invoices")
self.assertEqual(resp.status_code, 200)
invoices = resp.json()
self.assertGreaterEqual(len(invoices), 3)
numbers = {i["number"] for i in invoices}
self.assertIn("DEMO-2026-001", numbers)
def test_invoice_detail_has_customer_and_lines(self):
invoices = self.get("/api/invoices").json()
first = invoices[0]
detail = self.get(f"/api/invoices/{first['id']}")
self.assertEqual(detail.status_code, 200)
body = detail.json()
self.assertIn("customer", body)
self.assertIn("line_items", body)
self.assertIn("payments", body)

View File

@@ -1,7 +0,0 @@
{
"items": [
{
"name": "bundle"
}
]
}

View File

@@ -2,30 +2,36 @@
# Compile the plexus UIs to distributable files — this repo's `vite build`.
#
# Usage:
# ./ctrl/dist.sh # standalone
# ./ctrl/dist.sh sample # a named room
# ./ctrl/dist.sh # every plexus -> gen/plexuses/<name>/index.html
# ./ctrl/dist.sh bundle # one, by name
# ./ctrl/dist.sh path/to/ui.toml # every [[plexus]] a run file lists
# ./ctrl/dist.sh bundle --dest /tmp/out --delivery vendored --notice none
#
# A plexus is a UI that gets EXPORTED, not served. The output is a single
# index.html carrying its theme, its data and its diagrams inline, so it opens
# from a double-click on a machine with no server, no node and no network — the
# state a regulated Windows box is usually in.
#
# `make build` runs this as one of its steps. This exists for the loop where the
# UI is what you are working on: rebuilding a whole room to see a CSS change is
# a slow way to iterate.
# It needs nothing built first: a plexus is its own directory under
# soleprint/artery/plexuses/ (or wherever a run file points), and the theme it
# carries is cut to what the page reads. See soleprint/common/slice/plexus.py.
set -e
CALLER_DIR="$PWD"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
cd "$ROOT_DIR"
PYTHON="${PYTHON:-python3}"
ROOM="${1:-standalone}"
if [[ ! -d "cfg/$ROOM" ]]; then
echo "No such room: cfg/$ROOM" >&2
echo "Available: $(find cfg -mindepth 1 -maxdepth 1 -type d -not -name '.*' -printf '%f ')" >&2
exit 1
fi
args=()
for a in "$@"; do
# A bare word that names an existing path is a path the caller typed.
if [[ "$a" != -* && -e "$CALLER_DIR/$a" ]]; then
args+=("$(cd "$CALLER_DIR" && realpath -m -- "$a")")
else
args+=("$a")
fi
done
exec "$PYTHON" build.py --plexuses --cfg "$ROOM"
cd "$ROOT_DIR/soleprint"
exec "$PYTHON" -m common.slice plexus "${args[@]}"

View File

@@ -73,7 +73,15 @@ def get_version(comp_type, source):
for line in (source / "pyproject.toml").read_text().splitlines():
if line.strip().startswith("version"):
return line.split('"')[1]
except FileNotFoundError:
elif comp_type == "python":
# A plain package: no package.json, no pyproject.toml, just
# __version__ in __init__.py. station/tools/common is one -- it is
# imported and vendored, never installed, so giving it packaging
# metadata to satisfy this function would be the tail wagging the dog.
for line in (source / "__init__.py").read_text().splitlines():
if line.strip().startswith("__version__"):
return line.split('"')[1] if '"' in line else line.split("'")[1]
except (FileNotFoundError, IndexError):
pass
return "?"

View File

@@ -3,8 +3,8 @@
#
# Usage:
# ./ctrl/start.sh # standalone, foreground
# ./ctrl/start.sh amar -d # amar, detached
# ./ctrl/start.sh sample --build # flags pass straight through
# ./ctrl/start.sh myroom -d # a named room, detached
# ./ctrl/start.sh myroom --build # flags pass straight through
#
# Every room ships its own start script (gen/<room>/ctrl/start.sh) and stays
# runnable on its own — this only saves cd'ing there and picks the default room.

45
ctrl/ui.sh Executable file
View File

@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Slices: the part of spr's UI one consumer uses, generated as readable source.
#
# Usage:
# ./ctrl/ui.sh list # what can be sliced, and what each piece pulls in
# ./ctrl/ui.sh scan APP_DIR... # what an app uses; its framework copy against spr
# ./ctrl/ui.sh slice FILE [--only N] [--list] [--force]
# ./ctrl/ui.sh check FILE [--only N] # writes nothing; exit 1 if anything is stale
# ./ctrl/ui.sh reference [--check|--spots|--scaffold] # the ui-reference page of /docs, from common/ui/src
# ./ctrl/ui.sh selftest
#
# FILE is a run file (see soleprint/common/slice/ui.example.toml). It lives beside
# the consumer it describes, never in spr, and is named relative to where you
# stand — resolved here before the cd below would change what it means.
#
# `delivery = "dependency"` is for own projects: spr's names stay and a .spr stamp
# says where the copy came from. `delivery = "vendored"` renames through a mapping
# profile, drops the headers, writes no stamp, scrubs every spr word, and will
# not run without an explicit `notice`.
#
# This is not ./ctrl/theme.sh: that bakes parts into single pages inside spr.
# This writes a slice into a consumer, and checks it for drift both ways.
set -e
CALLER_DIR="$PWD"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
PYTHON="${PYTHON:-python3}"
# The first word is the command, never a path — `docs` is also a directory at the
# repo root, and resolving it turned the command into /…/docs.
args=("${1:-}")
shift || true
for a in "$@"; do
# A bare word that names an existing path is a path the caller typed.
if [[ "$a" != -* && -e "$CALLER_DIR/$a" ]]; then
args+=("$(cd "$CALLER_DIR" && realpath -m -- "$a")")
else
args+=("$a")
fi
done
cd "$ROOT_DIR/soleprint"
exec "$PYTHON" -m common.slice "${args[@]}"

View File

@@ -0,0 +1,7 @@
{
"files": {
"data/en/ui-reference.md": "d010b68b9599f033a3920ef0590c95d10a895e7d4c5b98e2ba5d822c26a32dd4",
"data/ui-inventory.json": "7b8895661a3023f3198eb17e5c15101537b4915faf1a9b398abaa49cd575edc4"
},
"generator": "common.slice reference"
}

View File

@@ -6,17 +6,17 @@ is **exported, not served**. It is compiled to a distributable file the way vite
builds for production, and that compile is the whole point of the format.
```bash
make dist # compile the plexus UIs for standalone
make dist sample # for a named room
make dist # every plexus
make dist bundle # one, by name
make dist path/to/ui.toml # the [[plexus]] tables of a run file
```
Output is one `index.html` per plexus under `gen/<room>/plexuses/<name>/`,
Output is one `index.html` per plexus under `gen/plexuses/<name>/` (or `--dest`),
carrying its theme, its data and its diagrams inline. No server, no node, no
network. Zip it, mail it, double-click it.
`make build` runs the same step as part of a room build. `make dist` exists for
the loop where the UI is what you are working on — rebuilding a whole room to
see a CSS change is a slow way to iterate.
A plexus is built on its own: nothing else has to be built first, and no other
configuration has to name it.
## The constraint that shapes it
@@ -34,20 +34,22 @@ So the data is a JS object, the theme is inlined at compile time, and the fonts
are stacks. The test that matters is opening the output with the network off and
seeing zero failed requests — everything else is cosmetic.
## Declaring one
## Choosing what gets built
A room opts in through `cfg/<room>/data/plexuses.json`, the same shape and the
same place as its sibling `data/*.json` files:
`make dist` builds every directory under `soleprint/artery/plexuses/` that holds a
`plexus.json`. To build with other settings — another theme, a plexus kept
outside spr, a different destination — list it in a run file:
```json
{ "items": [ { "name": "bundle" } ] }
```toml
[[plexus]]
name = "bundle"
theme = "mcrn"
dest = "out/bundle.html"
```
A room may override anything the plexus declares — most usefully the theme:
```json
{ "items": [ { "name": "bundle", "theme": "mcrn" } ] }
```
`delivery = "vendored"` renames the output through the mapping profile, refuses
to write if any spr word survives, and needs a `notice` — the same rules as a
vendored slice. See [Export / Compile](#export).
## Writing one
@@ -57,17 +59,22 @@ soleprint/artery/plexuses/<name>/
app/index.html the template
```
`build.py` fills these placeholders and writes one file:
`make dist` fills these placeholders and writes one file:
| Placeholder | Becomes |
| --- | --- |
| `%%THEME_CSS%%` | tokens plus every theme, so the switcher has something to switch to |
| `%%THEME_CSS%%` | only what the page can use: the custom properties it reads (and what they reach) per theme in `inline_themes`, and the rules that can match something on it |
| `%%BUNDLE%%` | `plexus.json` as a JS object |
| `%%GRAPH%%` | a rendered SVG from `docs/graphs/`, inlined |
| `%%TITLE%%` `%%DESCRIPTION%%` `%%DEFAULT_THEME%%` `%%BUILT%%` | from the manifest |
Anything else in `app/` is copied alongside, for a plexus that outgrows one file.
A plexus whose `app/` holds a `package.json` is built as a **Vue app** instead:
its `@ui` imports are sliced from `common/ui` (the same closure a consumer
would get), `vite build` runs in a temporary directory, and the emitted script
and stylesheet are inlined into one file. `live/` is the example.
## The bundle plexus
The one that ships. It answers "what does a rig installation have at its

View File

@@ -18,6 +18,21 @@ python ctrl/spr.py publish soleprint-ui <target>
The target project receives the built files and commits them directly. No npm dependency on spr.
To give a project only the parts it uses — readable source, not the whole
library — slice it instead:
```bash
make ui scan <app-dir> # what it uses, and how its copy compares with spr
./ctrl/ui.sh slice <app>/ui.toml # write the slice its run file describes
```
See [Export / Compile](#export) for run files, the two deliveries and drift.
**Reference:** [UI reference](#ui-reference) — every export's props, models, emits,
slots, closure, packages and tokens, generated from the source by `make reference` in
`soleprint/common/ui` (which also has `make check`: typecheck, tests, reference current,
parts in agreement). Hand-written notes live beside the library in `common/ui/notes/`.
## soleprint-modelgen
Model generator. Reads schema definitions and produces model files.

View File

@@ -10,7 +10,6 @@ The logic lives in the scripts, never in the Makefile.
```bash
make build # cfg/standalone -> gen/standalone
make build sample # cfg/sample -> gen/sample
make start # run it
```
@@ -19,7 +18,7 @@ make start # run it
| Command | Runs | Does |
| --- | --- | --- |
| `make build [<room>\|all\|models]` | `ctrl/build.sh` | compile a room into `gen/` |
| `make dist [<room>]` | `ctrl/dist.sh` | compile just the plexus UIs to single files |
| `make dist [NAME\|FILE]` | `ctrl/dist.sh` | compile the plexus UIs to single files — nothing built first |
| `make docs [serve\|graphs]` | `ctrl/docs.sh` | serve the docs, or re-render the diagrams |
| `make start [<room>] [-d]` | `ctrl/start.sh` | run a built room's compose stack |
| `make stop [<room>]` | `ctrl/stop.sh` | stop it |
@@ -27,8 +26,8 @@ make start # run it
| `make component [list\|publish\|sync\|watch\|diff]` | `ctrl/spr.py` | publish a distributable component |
| `make deploy` | `ctrl/deploy.sh` | rsync `gen/standalone` to the server and restart |
Bare words pass straight through, so `make build sample` becomes
`ctrl/build.sh sample`. Anything starting with a dash would be eaten by make
Bare words pass straight through, so `make build myroom` becomes
`ctrl/build.sh myroom`. Anything starting with a dash would be eaten by make
itself, so those go through `ARGS`:
```bash
@@ -52,12 +51,9 @@ make component ARGS="publish soleprint-ui /tmp/out --dist"
4. **Compose cabinets.** The dependency containers the room declared in
`data/cabinets.json` are merged into its `docker-compose.yml`. See
[Cabinets](#station-cabinets).
5. **Export plexuses.** Each plexus the room declared in `data/plexuses.json` is
compiled to a single self-contained `index.html` — theme, data and diagrams
inlined, so it opens with no server. See [Plexuses](#artery-plexuses).
6. **Generate models.** modelgen reads the room's `config.json` and writes
5. **Generate models.** modelgen reads the room's `config.json` and writes
`models/pydantic/__init__.py`.
7. **Render k8s** (optional). When the room's config enables it,
6. **Render k8s** (optional). When the room's config enables it,
`soleprint/ctrl/k8s/` writes manifests and lifecycle scripts.
## What comes out
@@ -71,7 +67,6 @@ gen/standalone/
cfg/config.json
data/*.json
models/pydantic/
plexuses/<name>/index.html # one file each, opens with no server
```
A **managed** room — one that wraps an existing application — is three folders
@@ -112,6 +107,38 @@ build it first: cd soleprint/common/ui && pnpm build
Each publish leaves a `.spr` stamp in the destination recording name, version,
type, source and mode, so a copy can say where it came from.
### Slices: only what a consumer uses
`publish` hands over the whole component. A **slice** hands over the part one
consumer actually uses, as readable source, computed rather than picked:
```bash
make ui # list: every export and part, and what it pulls in
make ui scan ../mpr/ui/detection-app # what an app uses; its framework copy against spr
./ctrl/ui.sh slice path/to/ui.toml # write the slices a run file lists
./ctrl/ui.sh check path/to/ui.toml # the same, writing nothing — exit 1 if stale
```
A run file names the consumer, where the slice goes, and whether it is read from
the consumer's own imports (`scan`) or named (`use`). `target = "vue"` takes files
from `common/ui/src` — `Panel` alone is one file and `vue`; `TimeSeriesRenderer`
brings uplot and nothing else does. `target = "plain"` takes parts from
`common/theme/parts` as one `ui.css` and one `ui.js`. See
`soleprint/common/slice/ui.example.toml`.
Two deliveries:
| `delivery` | for | what changes |
| --- | --- | --- |
| `dependency` | your own projects | nothing: spr's names stay, and a `.spr` stamp says where the copy came from |
| `vendored` | a repository that is not spr's | names go through a mapping profile, headers are stripped, no stamp is written, a scrub fails on any spr word, and `notice` must be stated |
Every run writes a manifest beside its run file recording the hash of each file
it wrote. That is what makes drift visible both ways: when spr moves on, `check`
says the slice is stale; when someone edits the copy, the next run names the file
and refuses to overwrite it without `--force` — so an improvement made in a
consumer can be brought back instead of lost.
## Deploying
```bash

View File

@@ -59,10 +59,10 @@ Open [http://localhost:9000](http://localhost:9000) and configure your room from
Start from a sample configuration:
```bash
python -m init.cli myroom --from sample
python -m init.cli myroom --from standalone
```
This copies the sample room's config as your starting point.
This copies the standalone room's config as your starting point.
## Rebuild

View File

@@ -10,7 +10,7 @@ python -m init.cli myroom
python -m init.web
# Clone from existing room
python -m init.cli myroom --from sample
python -m init.cli myroom --from standalone
```
## Layers

View File

@@ -28,16 +28,16 @@ files:
Then build. The compose merge is a step in [Export / Compile](#export):
```bash
python build.py --cfg sample
cd gen/sample && docker compose up -d
python build.py --cfg <room>
cd gen/<room> && docker compose up -d
```
`build.py` merges each cabinet's compose fragment into the room's
`docker-compose.yml`, declares its named volumes, and appends its settings to
`.env.example` — never to `.env`.
**A service the room already declares wins.** `cfg/amar/docker-compose.yml`
ships its own `db`; switching the postgres cabinet on will not replace it. The
**A service the room already declares wins.** A room whose `docker-compose.yml`
ships its own `db`: switching the postgres cabinet on will not replace it. The
build says so when it skips one:
```

View File

@@ -0,0 +1,485 @@
# common/ui — reference
> Generated from `common/ui/src` by `make reference` in common/ui (`python3 -m common.slice reference`).
> Do not edit this page: the next build replaces it. Prose goes in `soleprint/common/ui/notes/<spot>.md`,
> and `make reference-spots` lists every spot.
## What this is
A panel framework, not a dashboard: boxes with a title and a status light, splits that
resize, a feed that pushes typed events, and renderers for what arrives. It was built for
high-frequency operational views — a minimal Grafana — and every piece below exists
because an app needed it.
**The contract is the framework; Vue is one binding of it.** The contract is four things:
the token names, the element layer (`base.css`), the class names (`.panel`,
`.panel-header`, `.split-pane`, …) and the status words. Those are not whole yet: a feed
reports `idle`, `connecting`, `live`, `error`, while the panel's dot shows `idle`,
`live`, `processing`, `error` — so `connecting` has no dot, and a consumer has to map it
by hand. That is a gap in the contract, listed below. `common/theme/parts` is the second
binding: the same contract as plain CSS and JS, for pages that must open from a file with
no build. docgen's site is not a binding yet: it bakes its colours from its own style
slots, a third token vocabulary.
## The layers, as use shaped them
| layer | what | the evidence |
| --- | --- | --- |
| contract | tokens, element layer, class names, status words | every consumer reads `tokens.css`; four apps pasted their own `base.css` before it shipped here |
| chrome | `Panel` (+ maximize), `SplitPane`, `ParameterEditor`, `VideoPlayer` | `Panel` and `SplitPane` are the only exports all five consumers use; nested splits, not `LayoutGrid`, turned out to be the layout |
| feed | `DataSource`, `SSEDataSource` | one stream, typed events, a terminal `done` |
| renderers | `LogRenderer`, `TableRenderer`, `FrameRenderer`, `TimeSeriesRenderer`, `GraphRenderer` | opt-in; the only two external packages arrive through these |
| app state | not here | mpr keeps its own Pinia stores; `pinia` is imported nowhere in this library |
| delivery | slices, not this whole folder | see below |
## How to take it
Not as this folder. A consumer takes a **slice**: the closure of the exports it uses,
as readable source, with a barrel exporting only those and a `tokens.css` cut down to
what is read — `make ui scan <app>` computes it, `ctrl/ui.sh slice <run file>` writes it,
and `check` reports drift both ways. The "Contents" table is the price list.
## How this document is made
Everything outside these notes is generated from `src/` by `make reference` (in
`common/ui`), so a prop that is removed disappears from here on the next build. The notes
are the only hand-written part, one file per spot in `soleprint/common/ui/notes/`; a note
whose spot disappears fails `make check`. `make docs` — anywhere in soleprint — launches
this site.
## What came in
`common/ui/src` — **24 file(s) seen, 24 read, 0 failed**; 19 exports and 15 types in the barrel.
## Contents
**Who uses what**, measured 2026-09-22 with `make ui scan <app>` over the five apps that
consume this library. It is the one table here that is not generated — the apps live
outside this repository — so re-run the scan before trusting a row.
| export | mpr detection | mts meetus | mts doocus | unt | nvi |
| --- | --- | --- | --- | --- | --- |
| `Panel`, `SplitPane` | ✓ | ✓ | ✓ | ✓ | ✓ |
| `LogRenderer` | ✓ | | | ✓ | |
| `VideoPlayer` | | ✓ | | | |
| `ParameterEditor`, `ResizeHandle`, all renderers but Log, `SSEDataSource`, `useRegistry`, `useEditorExecution` | ✓ | | | | |
| `LayoutGrid`, `StaticDataSource`, `useDataSource`, `BBoxDrawPlugin`, `CrosshairPlugin` | | | | | |
The last row has **no users at all**. Those exports stay in the library — they are
small and a slice never carries them unless asked — but nothing should be built on them
as if they were proven. `ResizeHandle` is imported by mpr and never rendered: it is what
`SplitPane` replaced.
What each export costs a consumer that takes it: the files it pulls in, the packages it
brings, and whether a plain-HTML part exists for it (`common/theme/parts`).
| export | kind | file | pulls in | packages | size | plain part |
| --- | --- | --- | --- | --- | --- | --- |
| [`DataSource`](#datasource) | datasource | `datasources/DataSource.ts` | — | vue | 1.3 KB | feed |
| [`SSEDataSource`](#ssedatasource) | datasource | `datasources/SSEDataSource.ts` | DataSource.ts | vue | 3.8 KB | feed |
| [`StaticDataSource`](#staticdatasource) | datasource | `datasources/StaticDataSource.ts` | DataSource.ts | vue | 2.4 KB | — |
| [`useDataSource`](#usedatasource) | composable | `composables/useDataSource.ts` | DataSource.ts | vue | 2.0 KB | — |
| [`useRegistry`](#useregistry) | composable | `composables/useRegistry.ts` | — | vue | 1.9 KB | — |
| [`useEditorExecution`](#useeditorexecution) | composable | `composables/useEditorExecution.ts` | — | vue | 1.4 KB | — |
| [`Panel`](#panel) | component | `components/Panel.vue` | — | vue | 4.3 KB | maximize, panel |
| [`LayoutGrid`](#layoutgrid) | component | `components/LayoutGrid.vue` | — | vue | 0.5 KB | — |
| [`ResizeHandle`](#resizehandle) | component | `components/ResizeHandle.vue` | — | vue | 1.3 KB | — |
| [`SplitPane`](#splitpane) | component | `components/SplitPane.vue` | — | vue | 3.7 KB | split |
| [`ParameterEditor`](#parametereditor) | component | `components/ParameterEditor.vue` | — | vue | 3.1 KB | params |
| [`VideoPlayer`](#videoplayer) | component | `components/VideoPlayer.vue` | — | vue | 4.1 KB | — |
| [`LogRenderer`](#logrenderer) | renderer | `renderers/LogRenderer.vue` | — | vue | 3.7 KB | — |
| [`TimeSeriesRenderer`](#timeseriesrenderer) | renderer | `renderers/TimeSeriesRenderer.vue` | — | uplot, vue | 4.3 KB | — |
| [`GraphRenderer`](#graphrenderer) | renderer | `renderers/GraphRenderer.vue` | — | @vue-flow/core, vue | 7.9 KB | — |
| [`FrameRenderer`](#framerenderer) | renderer | `renderers/FrameRenderer.vue` | — | vue | 4.9 KB | — |
| [`TableRenderer`](#tablerenderer) | renderer | `renderers/TableRenderer.vue` | — | vue | 2.4 KB | — |
| [`BBoxDrawPlugin`](#bboxdrawplugin) | plugin | `plugins/BBoxDrawPlugin.ts` | InteractionPlugin.ts | — | 3.0 KB | — |
| [`CrosshairPlugin`](#crosshairplugin) | plugin | `plugins/CrosshairPlugin.ts` | InteractionPlugin.ts | — | 2.5 KB | — |
## Components
### Panel
The one component everything uses — 13 instances in mpr, 7 across mts's two apps.
**Maximize came from mts.** mts's copy grew `v-model:maximized` and `maximizable` while
spr's stood still, and it was brought upstream on 2026-09-22 — the framework spec's own
rule ("a second application finds the holes") working as written. The reason it is the
panel's job and not the video's is in the source: a fullscreen `<video>` paints over the
panel's actions and overlays.
**`#actions` is where panel buttons go.** mts fills it in 6 of 7 panels; mpr fills it in
none and absolutely-positions buttons inside the body instead. The slot is the answer.
**`#overlay` is filled by no consumer**; it stays for the interaction plugins, which have
no consumer either.
`components/Panel.vue` · 4.3 KB with its closure · packages: `vue` ^3.5 · plain part: `maximize`, `panel`
| prop | type | required | default | |
| --- | --- | --- | --- | --- |
| `title` | `string` | yes | — | — |
| `status` | `'idle' \| 'live' \| 'processing' \| 'error'` | no | — | Omit to show no indicator at all — see the note on `.panel-status`. |
| `maximizable` | `boolean` | no | `true` | Set false for panels that make no sense filling the window. |
| v-model | type | default |
| --- | --- | --- |
| `v-model:maximized` | `boolean` | `false` |
slots: `#actions`, `#default`, `#overlay`
reads: `--font-size-sm` `--font-ui` `--panel-border` `--panel-header-height` `--panel-radius` `--space-1` `--space-2` `--space-3` `--status-error` `--status-idle` `--status-live` `--status-processing` `--surface-1` `--surface-2` `--text-primary` `--text-secondary`
### LayoutGrid
`components/LayoutGrid.vue` · 0.5 KB with its closure · packages: `vue` ^3.5
| prop | type | required | default | |
| --- | --- | --- | --- | --- |
| `columns` | `number` | no | `2` | — |
| `rows` | `number` | no | `2` | — |
| `gap` | `string` | no | `'var(--space-2)'` | — |
slots: `#default`
### ResizeHandle
`components/ResizeHandle.vue` · 1.3 KB with its closure · packages: `vue` ^3.5
| prop | type | required | default | |
| --- | --- | --- | --- | --- |
| `direction` | `'horizontal' \| 'vertical'` | yes | — | — |
| emits | payload | |
| --- | --- | --- |
| `resize` | `[delta: number]` | — |
reads: `--text-dim`
### SplitPane
The real layout primitive. Every app lays itself out with nested splits — mpr three
deep — and none uses `LayoutGrid`. `sizeMode="ratio"` is the common case (all of mts);
`"px"` pins a sidebar (mpr). `resizable: false` has never been passed by anyone: a split
that cannot move is a flex row.
`components/SplitPane.vue` · 3.7 KB with its closure · packages: `vue` ^3.5 · plain part: `split`
| prop | type | required | default | |
| --- | --- | --- | --- | --- |
| `direction` | `'horizontal' \| 'vertical'` | no | `'horizontal'` | Split direction |
| `initialSize` | `number` | no | `1` | Initial size of the sized pane (px or flex ratio) |
| `sizeMode` | `'px' \| 'ratio'` | no | `'ratio'` | Size mode: 'px' = sized pane fixed in pixels, 'ratio' = flex ratio |
| `anchor` | `'first' \| 'second'` | no | `'first'` | Which pane is sized: 'first' or 'second'. Default: 'first'. |
| `min` | `number` | no | `0.1` | Min size (px in px-mode, ratio in ratio-mode) |
| `max` | `number` | no | `10` | Max size (px in px-mode, ratio in ratio-mode) |
| `resizable` | `boolean` | no | `true` | Whether the divider is draggable |
slots: `#first`, `#second`
reads: `--text-dim`
### ParameterEditor
Schema-driven: the same field list a pipeline stage declares renders its sliders. Two
gaps, both fixed in the plain binding (`params` part) and not yet here: a field with
`options` (an enum) renders **nothing**, because only `int`/`float` and `bool` are
filtered for; and the `reset` event is declared but never emitted — mpr wires a handler
to it that cannot fire.
`components/ParameterEditor.vue` · 3.1 KB with its closure · packages: `vue` ^3.5 · plain part: `params`
| prop | type | required | default | |
| --- | --- | --- | --- | --- |
| `fields` | `ConfigField[]` | yes | — | — |
| `values` | `Record<string, unknown>` | yes | — | — |
| emits | payload | |
| --- | --- | --- |
| `update` | `[name: string, value: unknown]` | — |
| `reset` | `[]` | — |
reads: `--space-2` `--surface-3` `--text-dim` `--text-primary` `--text-secondary`
### VideoPlayer
Agnostic seekable media player.
mts's, and only mts uses it. It came upstream with its `#overlay` slot on 2026-09-22.
It deliberately does no fullscreen of its own: put it in a `Panel` and maximize that.
`components/VideoPlayer.vue` · 4.1 KB with its closure · packages: `vue` ^3.5
| prop | type | required | default | |
| --- | --- | --- | --- | --- |
| `src` | `string` | yes | — | Media source URL |
| `currentTime` | `number` | no | `0` | Playback position in seconds (v-model:currentTime) |
| `playing` | `boolean` | no | `false` | Whether the media is playing (v-model:playing) |
| emits | payload | |
| --- | --- | --- |
| `update:currentTime` | `[value: number]` | — |
| `update:playing` | `[value: boolean]` | — |
| `duration` | `[value: number]` | Media duration once metadata has loaded |
| `ended` | `[]` | Playback reached the end |
slots: `#overlay`
reads: `--surface-0`
## Renderers
### LogRenderer
Virtual scroll, and the only one in any of the apps: rows outside the viewport are not
rendered, and auto-scroll pauses while the reader scrolls up. mts's long lists and mpr's
React side render every row instead. `rowHeight` and `autoScroll` have never been passed
by a consumer; the defaults are what is proven.
`renderers/LogRenderer.vue` · 3.7 KB with its closure · packages: `vue` ^3.5
| prop | type | required | default | |
| --- | --- | --- | --- | --- |
| `entries` | `LogEntry[]` | yes | — | — |
| `rowHeight` | `number` | no | `24` | — |
| `autoScroll` | `boolean` | no | `true` | — |
reads: `--font-mono` `--space-2` `--space-6` `--status-error` `--status-escalating` `--status-live` `--status-processing` `--text-dim`
### TimeSeriesRenderer
One of the only two exports that bring a package (`uplot`, with its stylesheet), and the
reason a chart cannot be a plain part: baking uplot into every standalone page is the
bundle this library refuses to be. A page that needs a real chart is a served page.
`renderers/TimeSeriesRenderer.vue` · 4.3 KB with its closure · packages: `uplot` ^1.6, `vue` ^3.5
| prop | type | required | default | |
| --- | --- | --- | --- | --- |
| `series` | `TimeSeriesSeries[]` | yes | — | Array of series configs (label + color) |
| `data` | `uPlot.AlignedData` | yes | — | Data: [timestamps[], series1[], series2[], ...] |
| `title` | `string` | no | — | Chart title (optional) |
| `stacked` | `boolean` | no | `false` | Stacked area mode |
reads: `--font-mono` `--surface-2` `--surface-3` `--text-primary` `--text-secondary`
### GraphRenderer
`renderers/GraphRenderer.vue` · 7.9 KB with its closure · packages: `@vue-flow/core` ^1.48.2, `vue` ^3.5
| prop | type | required | default | |
| --- | --- | --- | --- | --- |
| `nodes` | `GraphNode[]` | yes | — | — |
| `mode` | `GraphMode` | no | `'observe'` | Interaction mode — changes visual treatment and click behavior |
| `activeStage` | `string \| null` | no | `null` | Currently edited stage (highlighted in edit modes) |
| `regionStages` | `string[]` | no | — | Stages that have a region editor (bbox/polygon) |
| emits | payload | |
| --- | --- | --- |
| `open-region-editor` | `[stage: string]` | — |
| `open-stage-editor` | `[stage: string]` | — |
| `node-click` | `[stage: string]` | — |
reads: `--font-mono` `--font-size-sm` `--panel-radius` `--status-processing` `--text-secondary`
### FrameRenderer
**Known bug:** box colours by confidence are returned as `'var(--conf-high)'` strings and
handed to a canvas `strokeStyle`. Canvas does not resolve custom properties, so those
boxes draw in the context's previous colour. That is why the "Tokens" table shows the
`--conf-*` tokens as read by nobody: in effect, they are not.
`renderers/FrameRenderer.vue` · 4.9 KB with its closure · packages: `vue` ^3.5
| prop | type | required | default | |
| --- | --- | --- | --- | --- |
| `imageSrc` | `string` | yes | — | Base64 JPEG image |
| `boxes` | `FrameBBox[]` | yes | — | Bounding boxes to overlay |
| `overlays` | `FrameOverlay[]` | no | — | Debug overlay layers (edge images, line visualizations, etc.) |
reads: `--text-dim`
### TableRenderer
`renderers/TableRenderer.vue` · 2.4 KB with its closure · packages: `vue` ^3.5
| prop | type | required | default | |
| --- | --- | --- | --- | --- |
| `columns` | `TableColumn[]` | yes | — | — |
| `rows` | `Record<string, unknown>[]` | yes | — | — |
| `sortKey` | `string` | no | — | — |
| `sortDir` | `'asc' \| 'desc'` | no | — | — |
| emits | payload | |
| --- | --- | --- |
| `sort` | `[key: string]` | — |
reads: `--font-mono` `--font-size-sm` `--panel-border` `--space-1` `--space-2` `--space-3` `--space-6` `--surface-2` `--surface-3` `--text-dim` `--text-primary` `--text-secondary`
## Datasources
### DataSource
`datasources/DataSource.ts` · 1.3 KB with its closure · packages: `vue` ^3.5 · plain part: `feed`
Base class for all data sources. A DataSource connects to some event stream, exposes reactive state, and lets consumers subscribe to typed events. Panels read from these reactively — they never touch the transport layer directly.
### SSEDataSource
One stream, many typed events: panels subscribe with `on(type, handler)` to the event
types they draw, and the stream ends with a terminal `done`. `status` uses the same four
words as a panel's dot, so wiring one to the other needs no mapping.
Two things the code does not do, both learned from mpr. **It does not throttle**: at a few
hundred events a second, a handler that writes to the DOM per event stalls — the plain
binding's `feed` part adds `onFrame()` (one call per animation frame, newest payload) for
exactly this, and a Vue consumer has to coalesce the same way. **Nobody unsubscribes**:
`on()` returns an unsubscribe function and every call site in mpr drops it, which only
works because its panels live as long as the page.
`datasources/SSEDataSource.ts` · 3.8 KB with its closure · packages: `vue` ^3.5 · plain part: `feed`
DataSource backed by native EventSource (Server-Sent Events). Connects to a single SSE endpoint and demultiplexes events by type. Multiple panels can subscribe to different event types from the same source.
### StaticDataSource
`datasources/StaticDataSource.ts` · 2.4 KB with its closure · packages: `vue` ^3.5
DataSource that replays a fixture array of events. Used for development and testing without a running backend. Events are emitted in sequence with optional delays.
## Composables
### useDataSource
Composable that connects a component to a DataSource.
`composables/useDataSource.ts` · 2.0 KB with its closure · packages: `vue` ^3.5
Composable that connects a component to a DataSource. Connects on mount, disconnects on unmount. Returns reactive refs for data, status, and error.
### useRegistry
Generic registry composable — fetches typed data from a URL, caches it, exposes it reactively.
`composables/useRegistry.ts` · 1.9 KB with its closure · packages: `vue` ^3.5
### useEditorExecution
`composables/useEditorExecution.ts` · 1.4 KB with its closure · packages: `vue` ^3.5
Generic editor execution pattern — debounced apply with auto-apply toggle, loading/error/timing state tracking. The caller provides the actual execution function. This composable handles the orchestration: debounce, auto-apply, loading state, timing.
## Plugins
### BBoxDrawPlugin
BBoxDrawPlugin — draw bounding boxes on the frame viewer.
`plugins/BBoxDrawPlugin.ts` · 3.0 KB with its closure · packages: none
### CrosshairPlugin
CrosshairPlugin — synchronized vertical crosshair across time-series panels.
`plugins/CrosshairPlugin.ts` · 2.5 KB with its closure · packages: none
## Types
Types ride with the file that declares them: taking the export takes its types.
| type | declared in | |
| --- | --- | --- |
| `DataSourceStatus` | `datasources/DataSource.ts` | type |
| `EditorExecutionOptions` | `composables/useEditorExecution.ts` | interface |
| `ConfigField` | `components/ParameterEditor.vue` | interface |
| `FrameBBox` | `renderers/FrameRenderer.vue` | interface |
| `FrameOverlay` | `renderers/FrameRenderer.vue` | interface |
| `LogEntry` | `renderers/LogRenderer.vue` | interface |
| `GraphNode` | `renderers/GraphRenderer.vue` | interface |
| `GraphMode` | `renderers/GraphRenderer.vue` | type |
| `TableColumn` | `renderers/TableRenderer.vue` | interface |
| `TimeSeriesSeries` | `renderers/TimeSeriesRenderer.vue` | interface |
| `InteractionPlugin` | `plugins/InteractionPlugin.ts` | interface |
| `PluginContext` | `plugins/InteractionPlugin.ts` | interface |
| `BBoxResult` | `plugins/BBoxDrawPlugin.ts` | interface |
| `BBoxCallback` | `plugins/BBoxDrawPlugin.ts` | type |
| `CrosshairCallback` | `plugins/CrosshairPlugin.ts` | type |
## Tokens
**There are two definitions, and the ⚠ rows are where they disagree.** `src/tokens.css`
is the library's own, dark-only; the theme contract in `common/theme` derives the same
names from its own palette. They agree on every colour but `--surface-3` and on neither
font stack. A Vue slice uses the library's values by default so swapping a consumer's
copy for a slice changes no pixel; `theme = "soleprint"` opts into the contract, and
that is a visible change. Retiring the library's file in favour of the contract is the
open question — it changes what every current consumer renders.
"read by nobody" means no stylesheet in the library reads it. Apps read tokens directly
too — `make ui scan` reports between 14 and 23 per app — and a slice carries those.
Every custom property the library defines or reads. `library` is `src/tokens.css`, what
Vue consumers render today; `theme` is the contract plain pages use
(`common/theme/tokens.css` + `themes/soleprint.css`). A row where the two differ is a
visible change for a consumer that switches.
| token | library | theme | read by |
| --- | --- | --- | --- |
| `--border` | `#2e2e38` | `#2e2e38` | tokens.css |
| `--conf-high` | `#3ecf8e` | `#3ecf8e` | *nobody* |
| `--conf-low` | `#f06565` | `#f06565` | *nobody* |
| `--conf-mid` | `#f5a623` | `#f5a623` | *nobody* |
| `--font-mono` ⚠ | `'JetBrains Mono', 'Fira Code', monospace` | `"JetBrains Mono", "Cascadia Mono", Consolas, monospace` | GraphRenderer.vue, LogRenderer.vue, TableRenderer.vue, TimeSeriesRenderer.vue |
| `--font-size-base` | `13px` | `13px` | base.css |
| `--font-size-lg` | `15px` | `15px` | *nobody* |
| `--font-size-sm` | `11px` | `11px` | GraphRenderer.vue, Panel.vue, TableRenderer.vue |
| `--font-ui` ⚠ | `'Inter', system-ui, sans-serif` | `Inter, "Segoe UI", system-ui, -apple-system, Arial, sans-serif` | Panel.vue, base.css |
| `--panel-border` | `1px solid #2e2e38` | `1px solid #2e2e38` | Panel.vue, TableRenderer.vue, base.css |
| `--panel-header-height` | `36px` | `36px` | Panel.vue |
| `--panel-radius` | `6px` | `6px` | GraphRenderer.vue, Panel.vue, base.css |
| `--space-1` | `4px` | `4px` | Panel.vue, TableRenderer.vue, base.css |
| `--space-2` | `8px` | `8px` | LogRenderer.vue, Panel.vue, ParameterEditor.vue, TableRenderer.vue |
| `--space-3` | `12px` | `12px` | Panel.vue, TableRenderer.vue, base.css |
| `--space-4` | `16px` | `16px` | *nobody* |
| `--space-6` | `24px` | `24px` | LogRenderer.vue, TableRenderer.vue |
| `--space-8` | `32px` | `32px` | *nobody* |
| `--status-error` | `#f06565` | `#f06565` | LogRenderer.vue, Panel.vue |
| `--status-escalating` | `#f5a623` | `#f5a623` | LogRenderer.vue, tokens.css |
| `--status-idle` | `#555568` | `#555568` | Panel.vue |
| `--status-live` | `#3ecf8e` | `#3ecf8e` | LogRenderer.vue, Panel.vue, tokens.css |
| `--status-processing` | `#4f9cf9` | `#4f9cf9` | GraphRenderer.vue, LogRenderer.vue, Panel.vue, tokens.css |
| `--surface-0` | `#0d0d0f` | `#0d0d0f` | VideoPlayer.vue, base.css, theme.css |
| `--surface-1` | `#16161a` | `#16161a` | Panel.vue |
| `--surface-2` | `#1e1e24` | `#1e1e24` | Panel.vue, TableRenderer.vue, TimeSeriesRenderer.vue, base.css |
| `--surface-3` ⚠ | `#26262f` | `#2e2e38` | ParameterEditor.vue, TableRenderer.vue, TimeSeriesRenderer.vue, base.css |
| `--text-dim` | `#555568` | `#555568` | FrameRenderer.vue, LogRenderer.vue, ParameterEditor.vue, ResizeHandle.vue, SplitPane.vue, TableRenderer.vue |
| `--text-primary` | `#e8e8f0` | `#e8e8f0` | Panel.vue, ParameterEditor.vue, TableRenderer.vue, TimeSeriesRenderer.vue, base.css |
| `--text-secondary` | `#8888a0` | `#8888a0` | GraphRenderer.vue, Panel.vue, ParameterEditor.vue, TableRenderer.vue, TimeSeriesRenderer.vue |
## What came out
nodes: 1 barrel · 6 component · 3 composable · 3 datasource · 19 export · 3 package · 6 part · 3 plugin · 5 renderer · 3 stylesheet · 30 token · 15 type
edges: 7 binds · 28 imports · 70 reads · 17 requires
- ok 24 file(s) read produced 24 file node(s)
- ok 19 export(s) and 15 type(s) in the barrel, each with a node
- ok 11 of 11 component(s) have their surface read
- ok every note lands on a spot
## Where it stands
- **Done:** the reference is generated and checked; slices are cut per consumer with a
two-way drift check; mts's additions (maximize, the video overlay) are upstream.
- **Behind:** the copies in mpr, unt and nvi date from 2026-04 and lack maximize,
`VideoPlayer`, `base.css` and `theme.css`. Their `.spr` stamp names a commit that no
longer exists. Replacing each with a slice is the next step for each.
- **Open:** one token definition instead of two (three, counting docgen's style slots);
the `connecting` status the panel cannot show; `--accent`, which nvi and doocus read and
nothing defines; the `FrameRenderer` colour bug; `ParameterEditor`'s enum fields.
## Rebuilding this
`make reference` in `common/ui`. `make check` fails when this page is stale, when a
generated file was edited by hand, or when a note no longer has its spot. Extraction reads the
source with patterns, not a TypeScript parser; `common/slice/selftest.py` pins what the
patterns must find, so one that stops matching fails the suite rather than thinning this
page.

View File

@@ -167,6 +167,13 @@
"en": "Shared Components"
}
},
{
"id": "ui-reference",
"title": {
"en": "↳ UI reference"
},
"sub": true
},
{
"id": "export",
"title": {

2701
docs/data/ui-inventory.json Normal file

File diff suppressed because it is too large Load Diff

6
examples/.gitignore vendored
View File

@@ -1,6 +0,0 @@
node_modules/
dist/
.vite/
__pycache__/
*.pyc
.venv/

View File

@@ -1,46 +0,0 @@
# Fixture Invoicing
> **⚠ THIS IS A FIXTURE, NOT A PRODUCT.**
> A deliberately minimal invoicing app used as a test surface for the
> [Soleprint](../../README.md) framework. Seed data is obviously placeholder
> ("Acme Widget Co.", "Test Customer 001"). Do not mistake this for real
> invoicing software.
## What's here
- `backend/` — FastAPI + SQLAlchemy against Postgres
- `frontend/` — Vue 3 + Vite, three pages (customers, invoices, invoice detail)
- `docker-compose.yml` — backend + frontend + postgres as three containers
## Entities
```
Customer ──< Invoice ──< LineItem
└──────< Payment
```
- `Customer` — name, email
- `Invoice` — number, customer_id (FK), issued_at, due_at, status
- `LineItem` — invoice_id (FK), description, quantity, unit_price
- `Payment` — invoice_id (FK), amount, method, paid_at
## Run it standalone
```bash
cd examples/fixture-invoicing
docker compose up --build
```
- Backend: http://localhost:8100
- Frontend: http://localhost:3100
- Postgres: localhost:5532 (user `postgres`, password `fixture`, db `fixture`)
## Run it wrapped by soleprint
The `cfg/sample/` room wraps this fixture. See the main soleprint docs.
```bash
cd ../.. # back to repo root
python build.py --cfg sample
cd gen/sample && ./ctrl/start.sh
```

View File

@@ -1,15 +0,0 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev gcc \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

View File

@@ -1,53 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from db import get_session
from models import Customer
from schemas import CustomerIn, CustomerOut
router = APIRouter()
@router.get("", response_model=list[CustomerOut])
def list_customers(session: Session = Depends(get_session)):
return session.query(Customer).order_by(Customer.id).all()
@router.post("", response_model=CustomerOut, status_code=201)
def create_customer(payload: CustomerIn, session: Session = Depends(get_session)):
customer = Customer(**payload.model_dump())
session.add(customer)
session.commit()
session.refresh(customer)
return customer
@router.get("/{customer_id}", response_model=CustomerOut)
def get_customer(customer_id: int, session: Session = Depends(get_session)):
customer = session.get(Customer, customer_id)
if not customer:
raise HTTPException(404, "customer not found")
return customer
@router.put("/{customer_id}", response_model=CustomerOut)
def update_customer(
customer_id: int, payload: CustomerIn, session: Session = Depends(get_session)
):
customer = session.get(Customer, customer_id)
if not customer:
raise HTTPException(404, "customer not found")
for k, v in payload.model_dump().items():
setattr(customer, k, v)
session.commit()
session.refresh(customer)
return customer
@router.delete("/{customer_id}", status_code=204)
def delete_customer(customer_id: int, session: Session = Depends(get_session)):
customer = session.get(Customer, customer_id)
if not customer:
raise HTTPException(404, "customer not found")
session.delete(customer)
session.commit()

View File

@@ -1,71 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session, joinedload
from db import get_session
from models import Invoice
from schemas import InvoiceDetail, InvoiceIn, InvoiceOut
router = APIRouter()
@router.get("", response_model=list[InvoiceOut])
def list_invoices(
status: str | None = None,
customer_id: int | None = None,
session: Session = Depends(get_session),
):
query = session.query(Invoice).order_by(Invoice.issued_at.desc())
if status:
query = query.filter(Invoice.status == status)
if customer_id:
query = query.filter(Invoice.customer_id == customer_id)
return query.all()
@router.post("", response_model=InvoiceOut, status_code=201)
def create_invoice(payload: InvoiceIn, session: Session = Depends(get_session)):
invoice = Invoice(**payload.model_dump())
session.add(invoice)
session.commit()
session.refresh(invoice)
return invoice
@router.get("/{invoice_id}", response_model=InvoiceDetail)
def get_invoice(invoice_id: int, session: Session = Depends(get_session)):
invoice = (
session.query(Invoice)
.options(
joinedload(Invoice.customer),
joinedload(Invoice.line_items),
joinedload(Invoice.payments),
)
.filter(Invoice.id == invoice_id)
.first()
)
if not invoice:
raise HTTPException(404, "invoice not found")
return invoice
@router.put("/{invoice_id}", response_model=InvoiceOut)
def update_invoice(
invoice_id: int, payload: InvoiceIn, session: Session = Depends(get_session)
):
invoice = session.get(Invoice, invoice_id)
if not invoice:
raise HTTPException(404, "invoice not found")
for k, v in payload.model_dump().items():
setattr(invoice, k, v)
session.commit()
session.refresh(invoice)
return invoice
@router.delete("/{invoice_id}", status_code=204)
def delete_invoice(invoice_id: int, session: Session = Depends(get_session)):
invoice = session.get(Invoice, invoice_id)
if not invoice:
raise HTTPException(404, "invoice not found")
session.delete(invoice)
session.commit()

View File

@@ -1,40 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from db import get_session
from models import Invoice, LineItem
from schemas import LineItemIn, LineItemOut
router = APIRouter()
@router.get("", response_model=list[LineItemOut])
def list_line_items(
invoice_id: int | None = None, session: Session = Depends(get_session)
):
query = session.query(LineItem).order_by(LineItem.id)
if invoice_id:
query = query.filter(LineItem.invoice_id == invoice_id)
return query.all()
@router.post("/invoices/{invoice_id}", response_model=LineItemOut, status_code=201)
def add_line_item(
invoice_id: int, payload: LineItemIn, session: Session = Depends(get_session)
):
if not session.get(Invoice, invoice_id):
raise HTTPException(404, "invoice not found")
item = LineItem(invoice_id=invoice_id, **payload.model_dump())
session.add(item)
session.commit()
session.refresh(item)
return item
@router.delete("/{line_item_id}", status_code=204)
def delete_line_item(line_item_id: int, session: Session = Depends(get_session)):
item = session.get(LineItem, line_item_id)
if not item:
raise HTTPException(404, "line item not found")
session.delete(item)
session.commit()

View File

@@ -1,49 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from db import get_session
from models import Invoice, Payment
from schemas import PaymentIn, PaymentOut
router = APIRouter()
@router.get("", response_model=list[PaymentOut])
def list_payments(
invoice_id: int | None = None, session: Session = Depends(get_session)
):
query = session.query(Payment).order_by(Payment.paid_at.desc())
if invoice_id:
query = query.filter(Payment.invoice_id == invoice_id)
return query.all()
@router.post("/invoices/{invoice_id}", response_model=PaymentOut, status_code=201)
def record_payment(
invoice_id: int, payload: PaymentIn, session: Session = Depends(get_session)
):
invoice = session.get(Invoice, invoice_id)
if not invoice:
raise HTTPException(404, "invoice not found")
payment = Payment(invoice_id=invoice_id, **payload.model_dump())
session.add(payment)
total_paid = sum(
(p.amount for p in invoice.payments), start=payment.amount
)
total_owed = sum((li.unit_price * li.quantity for li in invoice.line_items), start=0)
if total_owed > 0 and total_paid >= total_owed:
invoice.status = "paid"
session.commit()
session.refresh(payment)
return payment
@router.delete("/{payment_id}", status_code=204)
def delete_payment(payment_id: int, session: Session = Depends(get_session)):
payment = session.get(Payment, payment_id)
if not payment:
raise HTTPException(404, "payment not found")
session.delete(payment)
session.commit()

View File

@@ -1,29 +0,0 @@
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
def _db_url() -> str:
host = os.getenv("DB_HOST", "localhost")
port = os.getenv("DB_PORT", "5432")
name = os.getenv("DB_NAME", "fixture")
user = os.getenv("DB_USER", "postgres")
password = os.getenv("DB_PASSWORD", "fixture")
return f"postgresql+psycopg2://{user}:{password}@{host}:{port}/{name}"
engine = create_engine(_db_url(), echo=False, future=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
class Base(DeclarativeBase):
pass
def get_session():
session = SessionLocal()
try:
yield session
finally:
session.close()

View File

@@ -1,52 +0,0 @@
import logging
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from api import customers, invoices, line_items, payments
from db import Base, engine
from seed import seed_if_empty
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
log = logging.getLogger("fixture")
app = FastAPI(
title="Fixture Invoicing",
description="SOLEPRINT FIXTURE APP — not a real invoicing product",
version="0.1.0",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.on_event("startup")
def startup():
Base.metadata.create_all(bind=engine)
if os.getenv("SEED_ON_START", "false").lower() == "true":
seed_if_empty()
@app.get("/")
def root():
return {
"app": "fixture-invoicing",
"warning": "THIS IS A SOLEPRINT FIXTURE — NOT A REAL PRODUCT",
"docs": "/docs",
}
@app.get("/health")
def health():
return {"status": "ok"}
app.include_router(customers.router, prefix="/api/customers", tags=["customers"])
app.include_router(invoices.router, prefix="/api/invoices", tags=["invoices"])
app.include_router(line_items.router, prefix="/api/line-items", tags=["line-items"])
app.include_router(payments.router, prefix="/api/payments", tags=["payments"])

View File

@@ -1,63 +0,0 @@
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from db import Base
class Customer(Base):
__tablename__ = "customer"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(120), nullable=False)
email: Mapped[str] = mapped_column(String(200), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
invoices: Mapped[list["Invoice"]] = relationship(
back_populates="customer", cascade="all, delete-orphan"
)
class Invoice(Base):
__tablename__ = "invoice"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
number: Mapped[str] = mapped_column(String(40), nullable=False, unique=True)
customer_id: Mapped[int] = mapped_column(ForeignKey("customer.id"), nullable=False)
issued_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
due_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
status: Mapped[str] = mapped_column(String(20), default="draft")
customer: Mapped[Customer] = relationship(back_populates="invoices")
line_items: Mapped[list["LineItem"]] = relationship(
back_populates="invoice", cascade="all, delete-orphan"
)
payments: Mapped[list["Payment"]] = relationship(
back_populates="invoice", cascade="all, delete-orphan"
)
class LineItem(Base):
__tablename__ = "line_item"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
invoice_id: Mapped[int] = mapped_column(ForeignKey("invoice.id"), nullable=False)
description: Mapped[str] = mapped_column(String(200), nullable=False)
quantity: Mapped[int] = mapped_column(Integer, default=1)
unit_price: Mapped[Decimal] = mapped_column(Numeric(10, 2), default=0)
invoice: Mapped[Invoice] = relationship(back_populates="line_items")
class Payment(Base):
__tablename__ = "payment"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
invoice_id: Mapped[int] = mapped_column(ForeignKey("invoice.id"), nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
method: Mapped[str] = mapped_column(String(20), default="cash")
paid_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
invoice: Mapped[Invoice] = relationship(back_populates="payments")

View File

@@ -1,5 +0,0 @@
fastapi==0.115.0
uvicorn[standard]==0.32.0
sqlalchemy==2.0.36
psycopg2-binary==2.9.10
pydantic==2.9.2

View File

@@ -1,58 +0,0 @@
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict
class CustomerIn(BaseModel):
name: str
email: str
class CustomerOut(CustomerIn):
model_config = ConfigDict(from_attributes=True)
id: int
created_at: datetime
class LineItemIn(BaseModel):
description: str
quantity: int = 1
unit_price: Decimal = Decimal("0")
class LineItemOut(LineItemIn):
model_config = ConfigDict(from_attributes=True)
id: int
invoice_id: int
class PaymentIn(BaseModel):
amount: Decimal
method: str = "cash"
class PaymentOut(PaymentIn):
model_config = ConfigDict(from_attributes=True)
id: int
invoice_id: int
paid_at: datetime
class InvoiceIn(BaseModel):
number: str
customer_id: int
due_at: datetime | None = None
status: str = "draft"
class InvoiceOut(InvoiceIn):
model_config = ConfigDict(from_attributes=True)
id: int
issued_at: datetime
class InvoiceDetail(InvoiceOut):
line_items: list[LineItemOut] = []
payments: list[PaymentOut] = []
customer: CustomerOut

View File

@@ -1,64 +0,0 @@
"""Seed obviously-fake demo data so the fixture is non-empty on first boot."""
import logging
from datetime import datetime, timedelta
from decimal import Decimal
from db import SessionLocal
from models import Customer, Invoice, LineItem, Payment
log = logging.getLogger(__name__)
def seed_if_empty() -> None:
session = SessionLocal()
try:
if session.query(Customer).count() > 0:
return
log.info("Seeding fixture data…")
acme = Customer(name="Acme Widget Co.", email="billing@acme.example")
test = Customer(name="Test Customer 001", email="test@example.invalid")
session.add_all([acme, test])
session.flush()
inv1 = Invoice(
number="DEMO-2026-001",
customer_id=acme.id,
issued_at=datetime(2026, 1, 15),
due_at=datetime(2026, 2, 15),
status="sent",
)
inv2 = Invoice(
number="DEMO-2026-002",
customer_id=acme.id,
issued_at=datetime(2026, 2, 1),
due_at=datetime(2026, 3, 1),
status="paid",
)
inv3 = Invoice(
number="DEMO-2026-003",
customer_id=test.id,
issued_at=datetime(2026, 3, 10),
due_at=datetime(2026, 4, 10),
status="draft",
)
session.add_all([inv1, inv2, inv3])
session.flush()
session.add_all([
LineItem(invoice_id=inv1.id, description="Widget type A", quantity=10, unit_price=Decimal("19.99")),
LineItem(invoice_id=inv1.id, description="Widget type B", quantity=5, unit_price=Decimal("49.00")),
LineItem(invoice_id=inv2.id, description="Consulting (hours)", quantity=8, unit_price=Decimal("120.00")),
LineItem(invoice_id=inv3.id, description="Placeholder line item", quantity=1, unit_price=Decimal("0")),
])
session.add_all([
Payment(invoice_id=inv2.id, amount=Decimal("960.00"), method="transfer", paid_at=datetime(2026, 2, 20)),
Payment(invoice_id=inv1.id, amount=Decimal("100.00"), method="card", paid_at=datetime(2026, 2, 1)),
])
session.commit()
log.info("Seed complete.")
finally:
session.close()

View File

@@ -1,54 +0,0 @@
name: fixture_invoicing
services:
db:
image: postgres:16-alpine
container_name: fixture_db
environment:
POSTGRES_DB: fixture
POSTGRES_USER: postgres
POSTGRES_PASSWORD: fixture
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5532:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d fixture"]
interval: 5s
timeout: 3s
retries: 10
backend:
build:
context: ./backend
container_name: fixture_backend
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: fixture
DB_USER: postgres
DB_PASSWORD: fixture
SEED_ON_START: "true"
ports:
- "8100:8000"
depends_on:
db:
condition: service_healthy
frontend:
build:
context: ./frontend
container_name: fixture_frontend
environment:
VITE_API_URL: http://localhost:8100
ports:
- "3100:5173"
depends_on:
- backend
volumes:
- ./frontend/src:/app/src
- ./frontend/index.html:/app/index.html
- ./frontend/vite.config.js:/app/vite.config.js
volumes:
pgdata:

View File

@@ -1,11 +0,0 @@
FROM node:20-slim
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev"]

View File

@@ -1,19 +0,0 @@
{
"name": "fixture-invoicing-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"vue": "^3.5.12",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"vite": "^5.4.11"
}
}

View File

@@ -1,17 +0,0 @@
<template>
<div class="fixture-banner">
⚠ FIXTURE APP — SOLEPRINT DEMO — NOT A REAL PRODUCT ⚠
</div>
<header class="app-nav">
<h1>Fixture Invoicing</h1>
<nav>
<router-link to="/customers">Customers</router-link>
<router-link to="/invoices">Invoices</router-link>
</nav>
</header>
<main class="app-main">
<router-view />
</main>
</template>

View File

@@ -1,42 +0,0 @@
const apiBase = import.meta.env.VITE_API_URL || "";
async function request(path, options = {}) {
const resp = await fetch(`${apiBase}${path}`, {
headers: { "Content-Type": "application/json" },
...options,
});
if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`);
if (resp.status === 204) return null;
return resp.json();
}
export const api = {
listCustomers: () => request("/api/customers"),
createCustomer: (data) =>
request("/api/customers", { method: "POST", body: JSON.stringify(data) }),
deleteCustomer: (id) => request(`/api/customers/${id}`, { method: "DELETE" }),
listInvoices: (params = {}) => {
const qs = new URLSearchParams(params).toString();
return request(`/api/invoices${qs ? "?" + qs : ""}`);
},
getInvoice: (id) => request(`/api/invoices/${id}`),
createInvoice: (data) =>
request("/api/invoices", { method: "POST", body: JSON.stringify(data) }),
deleteInvoice: (id) => request(`/api/invoices/${id}`, { method: "DELETE" }),
addLineItem: (invoiceId, data) =>
request(`/api/line-items/invoices/${invoiceId}`, {
method: "POST",
body: JSON.stringify(data),
}),
deleteLineItem: (id) =>
request(`/api/line-items/${id}`, { method: "DELETE" }),
recordPayment: (invoiceId, data) =>
request(`/api/payments/invoices/${invoiceId}`, {
method: "POST",
body: JSON.stringify(data),
}),
deletePayment: (id) => request(`/api/payments/${id}`, { method: "DELETE" }),
};

View File

@@ -1,6 +0,0 @@
import { createApp } from "vue";
import App from "./App.vue";
import { router } from "./router.js";
import "./style.css";
createApp(App).use(router).mount("#app");

View File

@@ -1,15 +0,0 @@
import { createRouter, createWebHistory } from "vue-router";
import Customers from "./views/Customers.vue";
import Invoices from "./views/Invoices.vue";
import InvoiceDetail from "./views/InvoiceDetail.vue";
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/", redirect: "/invoices" },
{ path: "/customers", component: Customers },
{ path: "/invoices", component: Invoices },
{ path: "/invoices/:id", component: InvoiceDetail, props: true },
],
});

View File

@@ -1,119 +0,0 @@
:root {
--bg: #0d1b2a;
--panel: #152438;
--panel-2: #1b2e45;
--text: #e5e5e5;
--muted: #8ca0b4;
--accent: #6fc3df;
--warn: #ffb454;
--danger: #ff6b6b;
--good: #7dd87d;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
#app { min-height: 100vh; display: flex; flex-direction: column; }
.fixture-banner {
background: repeating-linear-gradient(
45deg, #3a1f00, #3a1f00 10px, #4a2800 10px, #4a2800 20px
);
color: var(--warn);
text-align: center;
padding: 8px;
font-weight: 700;
letter-spacing: 2px;
font-size: 12px;
border-bottom: 2px solid var(--warn);
}
header.app-nav {
background: var(--panel);
padding: 16px 24px;
display: flex;
align-items: center;
gap: 24px;
border-bottom: 1px solid #223;
}
header.app-nav h1 {
font-size: 18px;
color: var(--accent);
}
header.app-nav nav a {
color: var(--muted);
text-decoration: none;
margin-right: 16px;
padding: 4px 8px;
border-radius: 4px;
}
header.app-nav nav a.router-link-active {
color: var(--text);
background: var(--panel-2);
}
main.app-main {
flex: 1;
padding: 24px;
max-width: 1100px;
width: 100%;
margin: 0 auto;
}
h2 { color: var(--accent); margin-bottom: 16px; font-size: 22px; }
h3 { color: var(--text); margin: 16px 0 8px; font-size: 16px; }
table {
width: 100%;
border-collapse: collapse;
background: var(--panel);
border-radius: 6px;
overflow: hidden;
}
th, td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid #223;
}
th { background: var(--panel-2); color: var(--muted); font-weight: 500; font-size: 12px; text-transform: uppercase; }
tr:last-child td { border-bottom: none; }
.status { padding: 2px 8px; border-radius: 10px; font-size: 11px; text-transform: uppercase; }
.status.draft { background: #334; color: var(--muted); }
.status.sent { background: #3a2c10; color: var(--warn); }
.status.paid { background: #1e3a1e; color: var(--good); }
.status.void { background: #3a1e1e; color: var(--danger); }
.card {
background: var(--panel);
padding: 16px;
border-radius: 6px;
margin-bottom: 16px;
}
.muted { color: var(--muted); font-size: 13px; }
button, .btn {
background: var(--accent);
color: #0d1b2a;
border: none;
padding: 8px 14px;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
font-size: 13px;
}
button.ghost { background: transparent; color: var(--muted); border: 1px solid #334; }
a.link { color: var(--accent); text-decoration: none; }
a.link:hover { text-decoration: underline; }
.actions { margin-top: 12px; display: flex; gap: 8px; }

View File

@@ -1,71 +0,0 @@
<template>
<h2>Customers</h2>
<div class="card">
<h3>Add customer</h3>
<div class="actions">
<input v-model="draft.name" placeholder="Name" />
<input v-model="draft.email" placeholder="Email" />
<button @click="add" :disabled="!draft.name || !draft.email">Add</button>
</div>
</div>
<table v-if="customers.length">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Email</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="c in customers" :key="c.id">
<td>{{ c.id }}</td>
<td>{{ c.name }}</td>
<td>{{ c.email }}</td>
<td>
<button class="ghost" @click="remove(c.id)">Delete</button>
</td>
</tr>
</tbody>
</table>
<p v-else class="muted">No customers yet.</p>
</template>
<script setup>
import { ref, onMounted, reactive } from "vue";
import { api } from "../api.js";
const customers = ref([]);
const draft = reactive({ name: "", email: "" });
async function load() {
customers.value = await api.listCustomers();
}
async function add() {
await api.createCustomer({ name: draft.name, email: draft.email });
draft.name = "";
draft.email = "";
await load();
}
async function remove(id) {
await api.deleteCustomer(id);
await load();
}
onMounted(load);
</script>
<style scoped>
.actions input {
padding: 8px 10px;
border: 1px solid #334;
border-radius: 4px;
background: #0d1b2a;
color: var(--text);
min-width: 180px;
}
</style>

View File

@@ -1,148 +0,0 @@
<template>
<div v-if="invoice">
<h2>
Invoice {{ invoice.number }}
<span :class="['status', invoice.status]">{{ invoice.status }}</span>
</h2>
<p class="muted">
Customer: <strong>{{ invoice.customer.name }}</strong> ({{ invoice.customer.email }})
· Issued {{ invoice.issued_at?.slice(0, 10) }}
<span v-if="invoice.due_at"> · Due {{ invoice.due_at.slice(0, 10) }}</span>
</p>
<div class="card">
<h3>Line items</h3>
<table v-if="invoice.line_items.length">
<thead>
<tr>
<th>Description</th>
<th>Qty</th>
<th>Unit price</th>
<th>Total</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="li in invoice.line_items" :key="li.id">
<td>{{ li.description }}</td>
<td>{{ li.quantity }}</td>
<td>${{ li.unit_price }}</td>
<td>${{ (li.quantity * li.unit_price).toFixed(2) }}</td>
<td><button class="ghost" @click="removeLine(li.id)">Remove</button></td>
</tr>
</tbody>
</table>
<p v-else class="muted">No line items.</p>
<div class="actions" style="margin-top: 12px;">
<input v-model="lineDraft.description" placeholder="Description" />
<input type="number" v-model.number="lineDraft.quantity" style="width: 80px;" />
<input type="number" step="0.01" v-model.number="lineDraft.unit_price" style="width: 120px;" />
<button @click="addLine" :disabled="!lineDraft.description">Add line</button>
</div>
</div>
<div class="card">
<h3>Payments</h3>
<table v-if="invoice.payments.length">
<thead>
<tr>
<th>Amount</th>
<th>Method</th>
<th>Paid at</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="p in invoice.payments" :key="p.id">
<td>${{ p.amount }}</td>
<td>{{ p.method }}</td>
<td>{{ p.paid_at?.slice(0, 10) }}</td>
<td><button class="ghost" @click="removePayment(p.id)">Remove</button></td>
</tr>
</tbody>
</table>
<p v-else class="muted">No payments.</p>
<div class="actions" style="margin-top: 12px;">
<input type="number" step="0.01" v-model.number="payDraft.amount" placeholder="Amount" />
<select v-model="payDraft.method">
<option value="cash">cash</option>
<option value="card">card</option>
<option value="transfer">transfer</option>
</select>
<button @click="addPayment" :disabled="!payDraft.amount">Record payment</button>
</div>
</div>
<p class="muted">
Total billed: <strong>${{ totalBilled.toFixed(2) }}</strong>
· Total paid: <strong>${{ totalPaid.toFixed(2) }}</strong>
· Balance: <strong>${{ (totalBilled - totalPaid).toFixed(2) }}</strong>
</p>
</div>
<p v-else class="muted">Loading…</p>
</template>
<script setup>
import { ref, reactive, computed, onMounted, watch } from "vue";
import { api } from "../api.js";
const props = defineProps({ id: { type: [String, Number], required: true } });
const invoice = ref(null);
const lineDraft = reactive({ description: "", quantity: 1, unit_price: 0 });
const payDraft = reactive({ amount: 0, method: "cash" });
const totalBilled = computed(() =>
(invoice.value?.line_items || []).reduce(
(sum, li) => sum + Number(li.quantity) * Number(li.unit_price), 0
)
);
const totalPaid = computed(() =>
(invoice.value?.payments || []).reduce(
(sum, p) => sum + Number(p.amount), 0
)
);
async function load() {
invoice.value = await api.getInvoice(props.id);
}
async function addLine() {
await api.addLineItem(props.id, { ...lineDraft });
lineDraft.description = "";
lineDraft.quantity = 1;
lineDraft.unit_price = 0;
await load();
}
async function removeLine(id) {
await api.deleteLineItem(id);
await load();
}
async function addPayment() {
await api.recordPayment(props.id, { ...payDraft });
payDraft.amount = 0;
await load();
}
async function removePayment(id) {
await api.deletePayment(id);
await load();
}
watch(() => props.id, load);
onMounted(load);
</script>
<style scoped>
.actions input, .actions select {
padding: 8px 10px;
border: 1px solid #334;
border-radius: 4px;
background: #0d1b2a;
color: var(--text);
}
</style>

View File

@@ -1,93 +0,0 @@
<template>
<h2>Invoices</h2>
<div class="card">
<h3>Create invoice</h3>
<div class="actions">
<input v-model="draft.number" placeholder="Number (e.g. DEMO-2026-004)" />
<select v-model="draft.customer_id">
<option :value="null" disabled>Customer…</option>
<option v-for="c in customers" :key="c.id" :value="c.id">
{{ c.name }}
</option>
</select>
<button @click="add" :disabled="!draft.number || !draft.customer_id">
Create
</button>
</div>
</div>
<table v-if="invoices.length">
<thead>
<tr>
<th>#</th>
<th>Number</th>
<th>Customer</th>
<th>Issued</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr v-for="inv in invoices" :key="inv.id">
<td>{{ inv.id }}</td>
<td>
<router-link :to="`/invoices/${inv.id}`" class="link">
{{ inv.number }}
</router-link>
</td>
<td>{{ customerName(inv.customer_id) }}</td>
<td>{{ inv.issued_at?.slice(0, 10) }}</td>
<td><span :class="['status', inv.status]">{{ inv.status }}</span></td>
</tr>
</tbody>
</table>
<p v-else class="muted">No invoices yet.</p>
</template>
<script setup>
import { ref, onMounted, reactive, computed } from "vue";
import { api } from "../api.js";
const invoices = ref([]);
const customers = ref([]);
const draft = reactive({ number: "", customer_id: null });
const customerById = computed(() =>
Object.fromEntries(customers.value.map((c) => [c.id, c]))
);
function customerName(id) {
return customerById.value[id]?.name || `#${id}`;
}
async function load() {
[invoices.value, customers.value] = await Promise.all([
api.listInvoices(),
api.listCustomers(),
]);
}
async function add() {
await api.createInvoice({
number: draft.number,
customer_id: draft.customer_id,
status: "draft",
});
draft.number = "";
draft.customer_id = null;
await load();
}
onMounted(load);
</script>
<style scoped>
.actions input, .actions select {
padding: 8px 10px;
border: 1px solid #334;
border-radius: 4px;
background: #0d1b2a;
color: var(--text);
min-width: 200px;
}
</style>

View File

@@ -1,12 +0,0 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
server: {
host: "0.0.0.0",
port: 5173,
allowedHosts: true,
watch: { usePolling: true },
},
});

View File

@@ -7,7 +7,7 @@ generating cfg/<room>/ for a new soleprint room.
Usage:
python -m init.cli myroom # Interactive wizard
python -m init.cli myroom --from sample # Clone existing room as variant
python -m init.cli myroom --from standalone # Clone existing room as variant
"""
import argparse

View File

@@ -1,10 +1,14 @@
{
"soleprint-ui": {
"type": "npm",
"path": "soleprint/common/ui"
"soleprint-cli": {
"type": "python",
"path": "soleprint/station/tools/common"
},
"soleprint-modelgen": {
"type": "pip",
"path": "soleprint/station/tools/modelgen"
},
"soleprint-ui": {
"type": "npm",
"path": "soleprint/common/ui"
}
}

9
rig/.gitignore vendored
View File

@@ -6,14 +6,7 @@ def
.env.local
ctrl/.env
# generated: the .dot is a build artifact rendered from arch/*.json, never hand-edited.
# The .svg IS committed — onboarding material should render in a repo browser.
arch/*.dot
# ctrl/Tiltfile.gen was here for a generator that no longer exists. ctrl/Tiltfile
# is now a real, committed file that derives its values when Tilt parses it, so
# there is nothing generated to ignore.
# binaries pulled by `make deps-bundle` for the air-gapped installer image
# the manifests the air-gapped installer image unpacks (docs/notes/Dockerfile.deps.md)
vendor
# Overlays that live with no version control of their own, or clones of their own

1
rig/.version Normal file
View File

@@ -0,0 +1 @@
0.1.3

View File

@@ -83,9 +83,8 @@ fetches with `curl`. A stock `debian:trixie-slim` has no curl — detection runs
fine, then the first download dies with `curl: command not found` and an exit
code of 127. That is the bootstrap paradox `ctrl/Dockerfile.deps` exists
to kill — the installer carries its own toolchain so the host needs only Docker —
but building the image and running it are two different things, and only the
build has a Makefile target today. **On a genuinely bare machine, run it by
hand:**
but `make deps image` only builds the image; running it is the step below.
**On a genuinely bare machine, run it by hand:**
```bash
make deps image # builds rig-deps:deps
@@ -143,6 +142,10 @@ From here on this machine has curl, so **`make deps` is the short form** for
every later run and every later copy of this directory. The container path is
the first-time path.
How the installer itself is tested — machine fixtures, clean containers, a
Workspace snapshot, a throwaway WSL distro — is in
[`docs/notes/installer-testing.md`](docs/notes/installer-testing.md).
## Prove the machine before blaming the project
@@ -203,13 +206,13 @@ never tracks it), or keep it anywhere else and name it by path.
## Run it
```bash
make kind-up # idempotent create, then selects the context
make tilt-up # context + your assigned port
make cluster up # idempotent create, then selects the context
make tilt # context + your assigned port
```
`tilt-up` passes `--context kind-<name>` every time, which is the point of going
`make tilt` passes `--context kind-<name>` every time, which is the point of going
through `make` at all: tilt cannot deploy into whichever cluster you last looked
at.
`make tilt-down` and `make kind-down` close the loop, and `make kind-reset` is
`make tilt down` and `make cluster down` close the loop, and `make cluster reset` is
delete-and-recreate for when a cluster wedges.

View File

@@ -70,8 +70,8 @@ tilt: ## dev loop [up|down] (default
# The counterpart to check: that one asks about the MACHINE and never fails,
# this one asks about RIG and exits 1. Checks are written as the decisions they
# defend, so a failure names what is being undone.
selftest: ## does rig still do what it says? exits 1 if not
bash ctrl/selftest.sh
selftest: ## does rig still do what it says? exits 1 if not [install]
bash ctrl/selftest.sh $(ARGS)
# The one-file versions of rig's tools, one folder per profile, for machines the
# full rig is not going to. Generated from rig as it is, never edited by hand;

View File

@@ -1,198 +1,99 @@
# rig
A runnable local model of a large, regulated estate — legacy and new side by
side. Its job is onboarding and exploration, not a production replica: most
services are deliberately mocked, because what has to be faithful is the
topology, not the workloads.
rig builds a disposable Kubernetes environment on your machine, so you can see how a system
fits together without access to any of it. It is for **onboarding and exploration**, not a
production replica.
## Prerequisite
**Docker.** Nothing else — no curl, no jq, no python, no apt repositories.
### Starting from plain Windows
Everything here is bash and runs *inside* a Linux shell, so on a Windows machine
that means WSL. Nothing in rig installs WSL, and nothing will: `wsl --install`
enables Windows features and requires a reboot, which is not something a script
should do to a machine on your behalf — and there is no tested undo for it.
From an elevated PowerShell or Command Prompt, once:
Most services in it are **not real**. What has to be faithful is the topology: names, ports,
dependency order, who can reach whom, and how it fails. That is what makes a whole estate fit on
a laptop. A real 20-service platform does not fit once in 14 GB; stubs of about 30 MB each fit
three times.
```powershell
wsl --install
```
Under the hood it is a kind cluster, a local image registry and a Tilt dev loop, each with its
own name and ports, so several environments run side by side. What runs on it is an
**overlay**: a folder outside rig that holds one use case.
Then reboot and open the Linux shell it installed.
## Using rig from a project
**If you cloned this on the Windows side, copy it into WSL before carrying on.**
WSL can reach the Windows drives at `/mnt/c`, and working from there mostly
functions — slowly — but file watching does not: that filesystem raises no
inotify events, so anything watching for edits silently stops seeing them.
You don't need to read rig to use it. Get a release (`rig-<version>.tar.gz`, or a checkout at
the tag `rig-<version>`), extract it anywhere, and point the project at it (`RIG_DIR`). Then run
`make check`: it says what the machine still lacks and which command comes next. `.version`
names the release; a project records the one it was last run against. See
[`docs/notes/overlay.md`](docs/notes/overlay.md).
```bash
cp -r /mnt/c/Users/<you>/rig ~/rig
cd ~/rig
```
## Prerequisite
`make deps` reports it if you are running from `/mnt/...`. Then carry on below.
**Docker.** Nothing else: no curl, jq, python or apt repositories.
If it fails, the usual causes give unhelpful messages:
On Windows, rig runs inside WSL. Run `wsl --install` once, from an elevated shell, then reboot.
Work inside the Linux filesystem, not `/mnt/c`: file watching doesn't work there, and
`make deps` warns about it. Git Bash, MSYS and Cygwin don't work (no `/proc`, no docker socket).
| symptom | cause |
| --- | --- |
| "the virtual machine could not be started" | virtualization disabled in BIOS/UEFI |
| the command is not recognised | Windows build too old — needs 2004 or later |
| the command is not recognised | Windows build too old (needs 2004 or later) |
| the install starts, then nothing works | a reboot is still pending |
Running the scripts from **Git Bash, MSYS or Cygwin does not work** — those look
close enough to a Linux shell to get started and then fail without `/proc` or a
docker socket. `ctrl/deps.sh` detects that and says so rather than letting you
find out the slow way.
## Read the docs first
```bash
make docs # serves on localhost, prints the URL
```
They run before anything is installed, which matters because they are the
instructions for everything else. No cluster and no toolchain required.
Why the code is the way it is — the reasoning, measurements and gotchas — lives
in [`docs/notes/`](docs/notes/), one file per script, so the code keeps short comments.
## Then
```bash
make check # is this machine ready? short; `make check all` for every detail
make deps # install the toolchain (add `core` on a managed machine)
make cluster up # cluster + registry + the profile's addons
```
On a machine with nothing but Docker, [BOOTSTRAP.md](BOOTSTRAP.md) runs the toolchain through
the installer container.
That is the whole setup. `make cluster up` also starts this environment's local
registry and wires it into the node, so an image built locally is pullable by the
cluster without going near docker.io. `make check` shows its port, among
everything else:
## What it does
```bash
make check # ... registry localhost:<port> (running)
docker build -t localhost:<port>/app:1 .
docker push localhost:<port>/app:1
kubectl --context kind-$(basename $PWD) run app --image=localhost:<port>/app:1
```
| command | does |
| --- | --- |
| `make docs` | serves the docs locally; works before anything is installed |
| `make check` | is this machine ready? memory, toolchain, cluster, the next command (`check all` for every detail) |
| `make deps` | installs the pinned toolchain (`deps core` on a managed machine) |
| `make cluster up` | the cluster, its registry and the configured addons (`down`, `reset`, `list`, `free`) |
| `make tilt` | the dev loop: rig's part, then the overlay's Tiltfile |
| `make selftest` | checks rig itself |
| `make help` | every target |
The port block is derived from the directory name, so two copies of rig never
collide — nothing to configure. `make check all` lists it; `bash ctrl/ports.sh persist`
pins it into `ctrl/.env` if you want it fixed:
Every verb is a `Makefile` target calling one script in `ctrl/`. The aliases (`kind-up`,
`tilt-up`, …) call the same scripts.
```bash
make cluster list # every cluster on this machine, with memory
make cluster free # stop the others if memory is tight
make cluster down # remove this cluster and its registry
```
## Overlays
**The verbs are yours to change.** `cluster` is the script — `ctrl/cluster.sh` —
and every spelling above is a `Makefile` target that calls it. `make kind-up` is
an alias for `make cluster up`, kept because the other projects on this machine
answer to that spelling and muscle memory spans repos rather than stopping at
one. Nothing outside the `Makefile` reads these names, so rename them, drop the
ones you never type, or add whatever your own projects already say: each alias
is two lines at the bottom of the file, calling the same script the canonical
target does.
**`make tilt` works on a fresh copy, unedited.** rig's `ctrl/Tiltfile` does
rig's part — identity, context guard, registry, the manifests — and then includes
the overlay's own Tiltfile. With no overlay named that is `examples/starter`, so
the dev loop comes up with its two examples running and nothing to configure.
It hardcodes nothing. It asks `ctrl/ports.sh active` for this environment's
cluster name, kube context, ports and paths — the same values every other rig
script resolves through `ctrl/lib/config.sh` — so a copied and renamed rig, or a
moved overlay, deploys into its own cluster with no edits.
`make help` lists every target.
On a machine where Docker really is the only thing installed, `make deps` has
nothing to download with — see [BOOTSTRAP.md](BOOTSTRAP.md), which runs the
toolchain through the installer container and carries on to scaffolding and running
a new project.
## What runs is an overlay
rig is the machine: toolchain, cluster, registry, port block, the dev loop's
plumbing. What runs on it is an **overlay** — one folder, outside rig's version
control, holding a use case: its settings (`rig.env`), its manifests
(`k8s/overlays/dev`), its images and Tiltfile, its addons, its kind config if it
needs its own. rig reads it and never writes into it. See
[`docs/notes/overlay.md`](docs/notes/overlay.md).
An overlay holds its settings (`rig.env`), its manifests (`k8s/overlays/dev`), its Tiltfile and
images, its addons, and its kind config if it needs one. rig reads it and never writes into it.
```bash
cp -r examples/starter local/myenv # local/ is gitignored
OVERLAY=local/myenv make cluster up # or OVERLAY=local/myenv in ctrl/.env
cp -r examples/starter local/myenv # local/ is ignored
OVERLAY=local/myenv make cluster up
OVERLAY=local/myenv make tilt
```
An overlay can also be a repo of its own, anywhere, or a project folder that
carries rig at `<project>/rig/` with a three-line forwarding Makefile.
With no overlay named, rig runs `examples/starter`. [`examples/data`](examples/data/) adds
postgres, redis and airflow as plain manifests. A project can carry rig at `./rig`, or keep it
anywhere and name it with `RIG_DIR`.
## One environment per folder
The cluster name, context, image tags and ports derive from the overlay folder's name (rig's
own, with no overlay), so environments never collide, and one's teardown never touches another.
Cluster name, kubectl context, image tags and the host port block all derive
from a folder name — the overlay's when one is named, else rig's own — so copies
never collide and neither one's teardown can touch the other. Two overlays run
side by side from one rig; two copies of rig do too.
## Configuration
## Profiles
Layers, weakest first: built-in defaults < `ctrl/versions.env` < the profile < the overlay's
`rig.env` < `ctrl/.env` < the command line. A profile (`ctrl/env.d/`) is optional and says how
this machine reaches the world: `mirror` (a pull-through cache) and `offline` (air-gapped) ship
as examples. The cluster itself is one kind template: the overlay's, or rig's one-node default.
**rig needs no profile.** With none named it runs on built-in defaults: one node,
no addons, a local registry, the newest Kubernetes version it pins. A profile is
an optional file in `ctrl/env.d/`, named by `PROFILE`, that says how this machine
reaches the world. rig ships two as **examples**; copy one to use it (the copy is
gitignored):
rig's own addons make the cluster work: `metallb` (LoadBalancer addresses), `cert-manager` (a
local CA), `metrics-server` (`kubectl top`). A workload's database, cache or scheduler belongs to
its overlay. There is no ingress controller: services are reached through LoadBalancer
addresses, or through the overlay's own gateway on the environment's HTTP port.
| example | what it changes |
| --- | --- |
| `mirror.env.example` | images through a pull-through cache of an internal registry |
| `offline.env.example` | air-gapped: everything from a preloaded local registry |
## Where things are
```bash
cp ctrl/env.d/mirror.env.example ctrl/env.d/mirror.env
PROFILE=mirror make cluster up
```
The layers, weakest first: built-in defaults < `ctrl/versions.env` < the profile
< the overlay's `rig.env` < `ctrl/.env` < your command line.
The **cluster itself** is one file: the overlay's `kind-config.yaml.tpl` if it has
one, else rig's `ctrl/k8s/kind-config.yaml.tpl` (one node). To change it — more
nodes, other port mappings, mounts — edit it and `make cluster reset`. The node
count is read back out of it, so there is nothing to drift.
## Addons
Each addon is its own idempotent script, and `ADDONS` names the ones to install,
in order. Adding one is adding a file — there is no dispatcher to edit. An
overlay's `addons/<name>.sh` is found before rig's own.
**There is no ingress controller, deliberately.** They pin a narrow window of
Kubernetes versions, so depending on one would constrain which k8s a rig can be
built with — and running a trailing-edge control plane is often the point.
Services are reached through MetalLB and `type: LoadBalancer`, which carries no
such constraint and is also what a real cluster does.
rig's own addons make the cluster work:
| Addon | Does |
| --- | --- |
| `metallb` | gives `type: LoadBalancer` an address it can actually reach |
| `cert-manager` | a local CA, so TLS works offline |
| `metrics-server` | makes `kubectl top` work on kind |
What a workload needs — a database, a cache, a scheduler — belongs to its
overlay. [`examples/data`](examples/data/) carries postgres, redis and airflow as
plain manifests (no helm: a chart repo is a network dependency), with passwords
generated on first install and kept across re-runs:
```bash
OVERLAY=examples/data make cluster up
Makefile the verbs
.version this release: patch and minor steps are edits here; milestones get a tag
ctrl/ one script per verb; lib/config.sh (settings and layers); Tiltfile (rig's
half of the dev loop); k8s/kind-config.yaml.tpl; addons/; env.d/; versions.env
docs/ index.html (make docs); notes/, one per script: why the code is as it is
examples/ starter (the default overlay), data (postgres, redis, airflow)
standalone/ single-file kits generated from ctrl/ (make standalone); never edited by hand
tests/ the installer's host fixtures
BOOTSTRAP.md from a bare machine with only Docker
STALE.md assumptions withdrawn, each checked by selftest
```

View File

@@ -1,5 +1,5 @@
# Machine-local config. Copy to ctrl/.env (gitignored) and edit.
# Cluster SHAPE: an optional profile in ctrl/env.d/. Architecture MODEL: arch/<name>.json.
# Cluster SHAPE: an optional profile in ctrl/env.d/.
# Notes: docs/notes/env.md
# A profile in ctrl/env.d/ to build. Empty means rig's built-in defaults, which

View File

@@ -3,6 +3,7 @@
# The pool is derived from the kind Docker network at install time.
# Notes: docs/notes/addons.md
set -euo pipefail
ADDON_DIR="$(cd "$(dirname "$0")" && pwd)" # its manifest sits beside it
cd "${RIG_CTRL:-$(dirname "$0")/..}"
source ./lib/config.sh
@@ -61,27 +62,7 @@ $K rollout status daemonset/speaker -n metallb-system --timeout=240s
# on a race that resolves itself in seconds.
echo " configuring the address pool"
for attempt in 1 2 3 4 5 6 7 8 9 10; do
if $K apply -f - >/dev/null 2>&1 <<YAML
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: default
namespace: metallb-system
spec:
addresses:
- ${pool_start}-${pool_end}
---
# Layer 2 mode: one node answers ARP for each address. No BGP peer needed, which
# is what makes this work on a laptop.
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: default
namespace: metallb-system
spec:
ipAddressPools:
- default
YAML
if render_manifest "$ADDON_DIR/metallb.yaml" | $K apply -f - >/dev/null 2>&1
then
echo " pool ready: ${pool_start}-${pool_end}"
exit 0

View File

@@ -0,0 +1,19 @@
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: default
namespace: metallb-system
spec:
addresses:
- ${pool_start}-${pool_end}
---
# Layer 2 mode: one node answers ARP for each address. No BGP peer needed, which
# is what makes this work on a laptop.
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: default
namespace: metallb-system
spec:
ipAddressPools:
- default

Some files were not shown because too many files have changed in this diff Show More