Compare commits
29 Commits
8f7e409ec4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 859256b68d | |||
| ec31abecea | |||
| c669662088 | |||
| 2b36799acc | |||
| 85276d34c3 | |||
| 8185d16c07 | |||
| f00055033e | |||
| d8e7de1c59 | |||
| 100f389719 | |||
| 9021cbe3c1 | |||
| 73998020b3 | |||
| b693e324ef | |||
| 12ddd01940 | |||
| 5ff513fcc5 | |||
| 4fe3718795 | |||
| 514efc5e52 | |||
| 8fad2223f3 | |||
| 45018e4743 | |||
| 907b616d1b | |||
| b3cf24e750 | |||
| 88adcd68bc | |||
| 54c577a700 | |||
| 0ce36b98e4 | |||
| 3bbfa413a3 | |||
| 2ef6139957 | |||
| 55c0d73ffe | |||
| 0b227cbc0e | |||
| 60527645f2 | |||
| dee899d86f |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -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
|
||||
|
||||
15
Makefile
15
Makefile
@@ -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
5
berth/.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
17
berth/ctrl/remote/probe-admin.sh
Normal file
17
berth/ctrl/remote/probe-admin.sh
Normal 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
|
||||
79
berth/ctrl/remote/probe.sh
Normal file
79
berth/ctrl/remote/probe.sh
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
184
build.py
@@ -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
17
cfg/.gitignore
vendored
@@ -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.
|
||||
128
cfg/README.md
128
cfg/README.md
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()],
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"items": []
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"items": []
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"items": []
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"items": []
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"items": []
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"items": []
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"items": []
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"items": []
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"items": []
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"items": []
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"name": "google",
|
||||
"slug": "google",
|
||||
"title": "Google",
|
||||
"status": "live",
|
||||
"system": "artery",
|
||||
"description": "Google OAuth login + Drive/Calendar/Gmail APIs."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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"]
|
||||
@@ -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]}
|
||||
@@ -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}
|
||||
@@ -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")
|
||||
@@ -1,4 +0,0 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.32.0
|
||||
sqlalchemy==2.0.36
|
||||
psycopg2-binary==2.9.10
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"name": "bundle"
|
||||
}
|
||||
]
|
||||
}
|
||||
32
ctrl/dist.sh
32
ctrl/dist.sh
@@ -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[@]}"
|
||||
|
||||
10
ctrl/spr.py
10
ctrl/spr.py
@@ -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 "?"
|
||||
|
||||
|
||||
@@ -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
45
ctrl/ui.sh
Executable 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[@]}"
|
||||
7
docs/data/.ui-reference.generated.json
Normal file
7
docs/data/.ui-reference.generated.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": {
|
||||
"data/en/ui-reference.md": "d010b68b9599f033a3920ef0590c95d10a895e7d4c5b98e2ba5d822c26a32dd4",
|
||||
"data/ui-inventory.json": "7b8895661a3023f3198eb17e5c15101537b4915faf1a9b398abaa49cd575edc4"
|
||||
},
|
||||
"generator": "common.slice reference"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
```
|
||||
|
||||
485
docs/data/en/ui-reference.md
Normal file
485
docs/data/en/ui-reference.md
Normal 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.
|
||||
@@ -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
2701
docs/data/ui-inventory.json
Normal file
File diff suppressed because it is too large
Load Diff
6
examples/.gitignore
vendored
6
examples/.gitignore
vendored
@@ -1,6 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"])
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
@@ -1,11 +0,0 @@
|
||||
FROM node:20-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 5173
|
||||
CMD ["npm", "run", "dev"]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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" }),
|
||||
};
|
||||
@@ -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");
|
||||
@@ -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 },
|
||||
],
|
||||
});
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 },
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
9
rig/.gitignore
vendored
@@ -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
1
rig/.version
Normal file
@@ -0,0 +1 @@
|
||||
0.1.3
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
231
rig/README.md
231
rig/README.md
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
19
rig/ctrl/addons/metallb.yaml
Normal file
19
rig/ctrl/addons/metallb.yaml
Normal 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
Reference in New Issue
Block a user