Compare commits
31 Commits
f4b2c15a9f
...
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 | |||
| 8f7e409ec4 | |||
| 2a0a793f19 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -28,12 +28,10 @@ gen/
|
|||||||
# browser, not source — and often a client's real data.
|
# browser, not source — and often a client's real data.
|
||||||
soleprint/station/tools/shuntgen/uploads/
|
soleprint/station/tools/shuntgen/uploads/
|
||||||
|
|
||||||
# Room configurations (separate repo - contains credentials and room-specific data)
|
# Client room configurations. They carry credentials and a client's data, and were
|
||||||
# Keep cfg/standalone/ and cfg/sample/ as templates, ignore actual rooms
|
# moved out of cfg/ (2026-10-07); still ignored so a restored copy is never committed.
|
||||||
cfg/amar/
|
cfg/amar/
|
||||||
cfg/dlt/
|
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,
|
# 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
|
# 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))
|
ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))
|
||||||
ifneq ($(ARGS),)
|
ifneq ($(ARGS),)
|
||||||
# Declares each extra word as a target that does nothing: `:` is an empty rule
|
# 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
|
# body and `@` silences it. Without this, `make build myroom` runs the build and
|
||||||
# then fails with "No rule to make target 'sample'", because make reads every
|
# 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.
|
# word on the line as something it has been asked to build.
|
||||||
$(eval $(ARGS):;@:)
|
$(eval $(ARGS):;@:)
|
||||||
# ...and as PHONY, because some of those words name real directories. `cfg`,
|
# ...and as PHONY, because some of those words name real directories. `cfg`,
|
||||||
@@ -42,7 +42,7 @@ $(eval $(ARGS):;@:)
|
|||||||
endif
|
endif
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.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
|
help: ## list targets
|
||||||
@grep -hE '^[a-z]+:.*?##' $(MAKEFILE_LIST) | sed 's/:.*##/\t/' | expand -t16
|
@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>]
|
stop: ## stop a running room [<room>]
|
||||||
bash ctrl/stop.sh $(or $(ARGS),$(ROOM))
|
bash ctrl/stop.sh $(or $(ARGS),$(ROOM))
|
||||||
|
|
||||||
dist: ## compile the plexus UIs to single files [<room>]
|
dist: ## compile the plexus UIs to single files [NAME|FILE]
|
||||||
bash ctrl/dist.sh $(or $(ARGS),$(ROOM))
|
bash ctrl/dist.sh $(ARGS)
|
||||||
|
|
||||||
# ── theme ──────────────────────────────────────────────────────────────────
|
# ── theme ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
theme: ## ad-hoc pages [new|parts|bake|check|export|run FILE]
|
theme: ## ad-hoc pages [new|parts|bake|check|export|run FILE]
|
||||||
bash ctrl/theme.sh $(or $(ARGS),bake)
|
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 ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
docs: ## documentation [serve [port]|graphs [theme]] (default serve)
|
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
|
# 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.
|
# only way to confirm it, and ctrl/vpn.sh refuses to write a key until it does.
|
||||||
ctrl/.secrets/
|
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 ────────────────────────────────────────────────────────────────
|
# ── 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)
|
bash ctrl/host.sh $(or $(ARGS),status)
|
||||||
|
|
||||||
registry: ## the image registry [status] (default status)
|
registry: ## the image registry [status] (default status)
|
||||||
|
|||||||
@@ -2,13 +2,28 @@
|
|||||||
# The remote box — the other half of the inventory.
|
# The remote box — the other half of the inventory.
|
||||||
#
|
#
|
||||||
# Usage:
|
# 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
|
# Always the ssh alias, never a hostname: there is no `Host mcrn.ar` block, so
|
||||||
# ssh alias, never a hostname: there is no `Host mcrn.ar` block, so a bare
|
# a bare hostname offers every agent key and trips MaxAuthTries.
|
||||||
# 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
|
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")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
source ./lib/config.sh
|
source ./lib/config.sh
|
||||||
@@ -31,6 +46,273 @@ announce_batch() {
|
|||||||
echo "learned rather than scrolled past."
|
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
|
guard_alias
|
||||||
case "${1:-status}" in
|
case "${1:-status}" in
|
||||||
status)
|
status)
|
||||||
@@ -59,5 +341,7 @@ for r in json.load(sys.stdin):
|
|||||||
echo "stacks joining the shared 'gateway' network — intended design, but"
|
echo "stacks joining the shared 'gateway' network — intended design, but"
|
||||||
echo "nothing in the tree lists it. The inventory produces that list."
|
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
|
esac
|
||||||
|
|||||||
@@ -145,3 +145,16 @@ service_upstream() {
|
|||||||
*) printf '%s' "$up" ;;
|
*) printf '%s' "$up" ;;
|
||||||
esac
|
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" \
|
check "the capture warning clears once keys are in" "0" \
|
||||||
"$(bash ./check.sh 2>/dev/null | grep -c 'public keys not captured')"
|
"$(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"
|
note "every STALE entry has a check here"
|
||||||
|
|
||||||
# Not "$0": line 15 cd's into this script's directory, so a relative $0 no
|
# Not "$0": line 15 cd's into this script's directory, so a relative $0 no
|
||||||
|
|||||||
@@ -196,17 +196,7 @@ PY
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# A .gitignore pattern containing a slash anchors to its own directory, so the
|
# assert_ignored lives in lib/estate.sh — host.sh's snapshots need it too.
|
||||||
# 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
|
|
||||||
}
|
|
||||||
|
|
||||||
keygen() {
|
keygen() {
|
||||||
local peer="${1:-}"
|
local peer="${1:-}"
|
||||||
|
|||||||
184
build.py
184
build.py
@@ -23,7 +23,6 @@ Generated structure for managed rooms:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import importlib.util
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
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")
|
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):
|
def build_soleprint(output_dir: Path, room: str):
|
||||||
"""Build soleprint folder with core + room config merged."""
|
"""Build soleprint folder with core + room config merged."""
|
||||||
soleprint = SPR_ROOT / "soleprint"
|
soleprint = SPR_ROOT / "soleprint"
|
||||||
@@ -738,11 +595,6 @@ def build_soleprint(output_dir: Path, room: str):
|
|||||||
log.info("Composing cabinets...")
|
log.info("Composing cabinets...")
|
||||||
compose_cabinets(output_dir, room)
|
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
|
# Generate models
|
||||||
log.info("Generating models...")
|
log.info("Generating models...")
|
||||||
if not generate_models(output_dir, room):
|
if not generate_models(output_dir, room):
|
||||||
@@ -799,33 +651,6 @@ def build_models_only():
|
|||||||
sys.exit(1)
|
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():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="Soleprint Build Tool")
|
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("--cfg", "-c", type=str, help="Room config name")
|
||||||
parser.add_argument("--all", action="store_true", help="Build all rooms")
|
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("--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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.plexuses:
|
if args.models:
|
||||||
build_plexuses_only(args.cfg or "standalone")
|
|
||||||
elif args.models:
|
|
||||||
build_models_only()
|
build_models_only()
|
||||||
elif args.all:
|
elif args.all:
|
||||||
build(SPR_ROOT / "gen" / "standalone", None)
|
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.
|
||||||
|
|
||||||
```
|
Rooms are a pre-rig way of running spr beside an app. Wrapping an inherited site,
|
||||||
cfg/
|
with spr's sidebar injected by nginx `sub_filter`, is meant to become a rig overlay.
|
||||||
├── 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
|
|
||||||
```
|
|
||||||
|
|
||||||
## Setup
|
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.
|
||||||
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"
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
# KIND_CONFIG spr's own kind config, which maps the rooms' gateway NodePorts
|
# KIND_CONFIG spr's own kind config, which maps the rooms' gateway NodePorts
|
||||||
# REGISTRY_MODE=none rooms load images straight into the node
|
# REGISTRY_MODE=none rooms load images straight into the node
|
||||||
# PROFILE= ADDONS= set empty here, so rig's own ctrl/.env can never quietly
|
# PROFILE= ADDONS= set empty here, so rig's own ctrl/.env can never quietly
|
||||||
# pick a profile or add addons to spr's cluster
|
# OVERLAY= pick a profile, an overlay or addons for spr's cluster
|
||||||
#
|
#
|
||||||
# status stays here: it answers a question about rooms, not about the cluster.
|
# status stays here: it answers a question about rooms, not about the cluster.
|
||||||
set -e
|
set -e
|
||||||
@@ -27,6 +27,7 @@ rig() {
|
|||||||
KIND_CONFIG="$SCRIPT_DIR/k8s/kind-config.yaml" \
|
KIND_CONFIG="$SCRIPT_DIR/k8s/kind-config.yaml" \
|
||||||
REGISTRY_MODE=none \
|
REGISTRY_MODE=none \
|
||||||
PROFILE= \
|
PROFILE= \
|
||||||
|
OVERLAY= \
|
||||||
ADDONS= \
|
ADDONS= \
|
||||||
bash "$RIG_CTRL/cluster.sh" "$@"
|
bash "$RIG_CTRL/cluster.sh" "$@"
|
||||||
}
|
}
|
||||||
|
|||||||
32
ctrl/dist.sh
32
ctrl/dist.sh
@@ -2,30 +2,36 @@
|
|||||||
# Compile the plexus UIs to distributable files — this repo's `vite build`.
|
# Compile the plexus UIs to distributable files — this repo's `vite build`.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./ctrl/dist.sh # standalone
|
# ./ctrl/dist.sh # every plexus -> gen/plexuses/<name>/index.html
|
||||||
# ./ctrl/dist.sh sample # a named room
|
# ./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
|
# 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
|
# 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
|
# from a double-click on a machine with no server, no node and no network — the
|
||||||
# state a regulated Windows box is usually in.
|
# state a regulated Windows box is usually in.
|
||||||
#
|
#
|
||||||
# `make build` runs this as one of its steps. This exists for the loop where the
|
# It needs nothing built first: a plexus is its own directory under
|
||||||
# UI is what you are working on: rebuilding a whole room to see a CSS change is
|
# soleprint/artery/plexuses/ (or wherever a run file points), and the theme it
|
||||||
# a slow way to iterate.
|
# carries is cut to what the page reads. See soleprint/common/slice/plexus.py.
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
CALLER_DIR="$PWD"
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
|
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
cd "$ROOT_DIR"
|
|
||||||
|
|
||||||
PYTHON="${PYTHON:-python3}"
|
PYTHON="${PYTHON:-python3}"
|
||||||
ROOM="${1:-standalone}"
|
|
||||||
|
|
||||||
if [[ ! -d "cfg/$ROOM" ]]; then
|
args=()
|
||||||
echo "No such room: cfg/$ROOM" >&2
|
for a in "$@"; do
|
||||||
echo "Available: $(find cfg -mindepth 1 -maxdepth 1 -type d -not -name '.*' -printf '%f ')" >&2
|
# A bare word that names an existing path is a path the caller typed.
|
||||||
exit 1
|
if [[ "$a" != -* && -e "$CALLER_DIR/$a" ]]; then
|
||||||
fi
|
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():
|
for line in (source / "pyproject.toml").read_text().splitlines():
|
||||||
if line.strip().startswith("version"):
|
if line.strip().startswith("version"):
|
||||||
return line.split('"')[1]
|
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
|
pass
|
||||||
return "?"
|
return "?"
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./ctrl/start.sh # standalone, foreground
|
# ./ctrl/start.sh # standalone, foreground
|
||||||
# ./ctrl/start.sh amar -d # amar, detached
|
# ./ctrl/start.sh myroom -d # a named room, detached
|
||||||
# ./ctrl/start.sh sample --build # flags pass straight through
|
# ./ctrl/start.sh myroom --build # flags pass straight through
|
||||||
#
|
#
|
||||||
# Every room ships its own start script (gen/<room>/ctrl/start.sh) and stays
|
# 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.
|
# 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.
|
builds for production, and that compile is the whole point of the format.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make dist # compile the plexus UIs for standalone
|
make dist # every plexus
|
||||||
make dist sample # for a named room
|
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
|
carrying its theme, its data and its diagrams inline. No server, no node, no
|
||||||
network. Zip it, mail it, double-click it.
|
network. Zip it, mail it, double-click it.
|
||||||
|
|
||||||
`make build` runs the same step as part of a room build. `make dist` exists for
|
A plexus is built on its own: nothing else has to be built first, and no other
|
||||||
the loop where the UI is what you are working on — rebuilding a whole room to
|
configuration has to name it.
|
||||||
see a CSS change is a slow way to iterate.
|
|
||||||
|
|
||||||
## The constraint that shapes 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
|
are stacks. The test that matters is opening the output with the network off and
|
||||||
seeing zero failed requests — everything else is cosmetic.
|
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
|
`make dist` builds every directory under `soleprint/artery/plexuses/` that holds a
|
||||||
same place as its sibling `data/*.json` files:
|
`plexus.json`. To build with other settings — another theme, a plexus kept
|
||||||
|
outside spr, a different destination — list it in a run file:
|
||||||
|
|
||||||
```json
|
```toml
|
||||||
{ "items": [ { "name": "bundle" } ] }
|
[[plexus]]
|
||||||
|
name = "bundle"
|
||||||
|
theme = "mcrn"
|
||||||
|
dest = "out/bundle.html"
|
||||||
```
|
```
|
||||||
|
|
||||||
A room may override anything the plexus declares — most usefully the theme:
|
`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
|
||||||
```json
|
vendored slice. See [Export / Compile](#export).
|
||||||
{ "items": [ { "name": "bundle", "theme": "mcrn" } ] }
|
|
||||||
```
|
|
||||||
|
|
||||||
## Writing one
|
## Writing one
|
||||||
|
|
||||||
@@ -57,17 +59,22 @@ soleprint/artery/plexuses/<name>/
|
|||||||
app/index.html the template
|
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 |
|
| 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 |
|
| `%%BUNDLE%%` | `plexus.json` as a JS object |
|
||||||
| `%%GRAPH%%` | a rendered SVG from `docs/graphs/`, inlined |
|
| `%%GRAPH%%` | a rendered SVG from `docs/graphs/`, inlined |
|
||||||
| `%%TITLE%%` `%%DESCRIPTION%%` `%%DEFAULT_THEME%%` `%%BUILT%%` | from the manifest |
|
| `%%TITLE%%` `%%DESCRIPTION%%` `%%DEFAULT_THEME%%` `%%BUILT%%` | from the manifest |
|
||||||
|
|
||||||
Anything else in `app/` is copied alongside, for a plexus that outgrows one file.
|
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 bundle plexus
|
||||||
|
|
||||||
The one that ships. It answers "what does a rig installation have at its
|
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.
|
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
|
## soleprint-modelgen
|
||||||
|
|
||||||
Model generator. Reads schema definitions and produces model files.
|
Model generator. Reads schema definitions and produces model files.
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ The logic lives in the scripts, never in the Makefile.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
make build # cfg/standalone -> gen/standalone
|
make build # cfg/standalone -> gen/standalone
|
||||||
make build sample # cfg/sample -> gen/sample
|
|
||||||
make start # run it
|
make start # run it
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -19,7 +18,7 @@ make start # run it
|
|||||||
| Command | Runs | Does |
|
| Command | Runs | Does |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `make build [<room>\|all\|models]` | `ctrl/build.sh` | compile a room into `gen/` |
|
| `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 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 start [<room>] [-d]` | `ctrl/start.sh` | run a built room's compose stack |
|
||||||
| `make stop [<room>]` | `ctrl/stop.sh` | stop it |
|
| `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 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 |
|
| `make deploy` | `ctrl/deploy.sh` | rsync `gen/standalone` to the server and restart |
|
||||||
|
|
||||||
Bare words pass straight through, so `make build sample` becomes
|
Bare words pass straight through, so `make build myroom` becomes
|
||||||
`ctrl/build.sh sample`. Anything starting with a dash would be eaten by make
|
`ctrl/build.sh myroom`. Anything starting with a dash would be eaten by make
|
||||||
itself, so those go through `ARGS`:
|
itself, so those go through `ARGS`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -52,12 +51,9 @@ make component ARGS="publish soleprint-ui /tmp/out --dist"
|
|||||||
4. **Compose cabinets.** The dependency containers the room declared in
|
4. **Compose cabinets.** The dependency containers the room declared in
|
||||||
`data/cabinets.json` are merged into its `docker-compose.yml`. See
|
`data/cabinets.json` are merged into its `docker-compose.yml`. See
|
||||||
[Cabinets](#station-cabinets).
|
[Cabinets](#station-cabinets).
|
||||||
5. **Export plexuses.** Each plexus the room declared in `data/plexuses.json` is
|
5. **Generate models.** modelgen reads the room's `config.json` and writes
|
||||||
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
|
|
||||||
`models/pydantic/__init__.py`.
|
`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.
|
`soleprint/ctrl/k8s/` writes manifests and lifecycle scripts.
|
||||||
|
|
||||||
## What comes out
|
## What comes out
|
||||||
@@ -71,7 +67,6 @@ gen/standalone/
|
|||||||
cfg/config.json
|
cfg/config.json
|
||||||
data/*.json
|
data/*.json
|
||||||
models/pydantic/
|
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
|
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,
|
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.
|
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
|
## Deploying
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -59,10 +59,10 @@ Open [http://localhost:9000](http://localhost:9000) and configure your room from
|
|||||||
Start from a sample configuration:
|
Start from a sample configuration:
|
||||||
|
|
||||||
```bash
|
```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
|
## Rebuild
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ python -m init.cli myroom
|
|||||||
python -m init.web
|
python -m init.web
|
||||||
|
|
||||||
# Clone from existing room
|
# Clone from existing room
|
||||||
python -m init.cli myroom --from sample
|
python -m init.cli myroom --from standalone
|
||||||
```
|
```
|
||||||
|
|
||||||
## Layers
|
## Layers
|
||||||
|
|||||||
@@ -28,16 +28,16 @@ files:
|
|||||||
Then build. The compose merge is a step in [Export / Compile](#export):
|
Then build. The compose merge is a step in [Export / Compile](#export):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python build.py --cfg sample
|
python build.py --cfg <room>
|
||||||
cd gen/sample && docker compose up -d
|
cd gen/<room> && docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
`build.py` merges each cabinet's compose fragment into the room's
|
`build.py` merges each cabinet's compose fragment into the room's
|
||||||
`docker-compose.yml`, declares its named volumes, and appends its settings to
|
`docker-compose.yml`, declares its named volumes, and appends its settings to
|
||||||
`.env.example` — never to `.env`.
|
`.env.example` — never to `.env`.
|
||||||
|
|
||||||
**A service the room already declares wins.** `cfg/amar/docker-compose.yml`
|
**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
|
ships its own `db`: switching the postgres cabinet on will not replace it. The
|
||||||
build says so when it skips one:
|
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"
|
"en": "Shared Components"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "ui-reference",
|
||||||
|
"title": {
|
||||||
|
"en": "↳ UI reference"
|
||||||
|
},
|
||||||
|
"sub": true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "export",
|
"id": "export",
|
||||||
"title": {
|
"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:
|
Usage:
|
||||||
python -m init.cli myroom # Interactive wizard
|
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
|
import argparse
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
{
|
{
|
||||||
"soleprint-ui": {
|
"soleprint-cli": {
|
||||||
"type": "npm",
|
"type": "python",
|
||||||
"path": "soleprint/common/ui"
|
"path": "soleprint/station/tools/common"
|
||||||
},
|
},
|
||||||
"soleprint-modelgen": {
|
"soleprint-modelgen": {
|
||||||
"type": "pip",
|
"type": "pip",
|
||||||
"path": "soleprint/station/tools/modelgen"
|
"path": "soleprint/station/tools/modelgen"
|
||||||
|
},
|
||||||
|
"soleprint-ui": {
|
||||||
|
"type": "npm",
|
||||||
|
"path": "soleprint/common/ui"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
23
rig/.gitignore
vendored
23
rig/.gitignore
vendored
@@ -6,16 +6,17 @@ def
|
|||||||
.env.local
|
.env.local
|
||||||
ctrl/.env
|
ctrl/.env
|
||||||
|
|
||||||
# generated: the .dot is a build artifact rendered from arch/*.json, never hand-edited.
|
# the manifests the air-gapped installer image unpacks (docs/notes/Dockerfile.deps.md)
|
||||||
# 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
|
|
||||||
vendor
|
vendor
|
||||||
|
|
||||||
# Client rigs are NOT ignored here. A copy is a SIBLING of this directory
|
# Overlays that live with no version control of their own, or clones of their own
|
||||||
# (../acme-rig), so a rule in this file cannot see it — the rules live in the
|
# repos: rig reads them and never tracks them (docs/notes/overlay.md).
|
||||||
# parent repo's .gitignore, anchored at its root, where `*-rig/` matches them.
|
/local/
|
||||||
|
|
||||||
|
# A profile you activated (cp env.d/<name>.env.example env.d/<name>.env) is this
|
||||||
|
# machine's choice; only the examples are committed.
|
||||||
|
ctrl/env.d/*.env
|
||||||
|
|
||||||
|
# Only the default kit is committed; a kit for a local profile is this machine's.
|
||||||
|
standalone/*
|
||||||
|
!standalone/default/
|
||||||
|
|||||||
1
rig/.version
Normal file
1
rig/.version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
0.1.3
|
||||||
123
rig/BOOTSTRAP.md
123
rig/BOOTSTRAP.md
@@ -1,18 +1,9 @@
|
|||||||
# From a machine with nothing on it to a project you can work in
|
# From a machine with nothing on it to an environment you can work in
|
||||||
|
|
||||||
The README says the prerequisite is Docker and nothing else. This is what that
|
The README says the prerequisite is Docker and nothing else. This is what that
|
||||||
actually looks like end to end: a bare Linux box, and a new project running under
|
actually looks like end to end: a bare Linux box, and an overlay running under
|
||||||
Tilt at the end of it.
|
Tilt at the end of it.
|
||||||
|
|
||||||
A copy of this directory is a sibling of it, named after the environment it
|
|
||||||
models (`acme-rig`). Paths below are relative to the parent checkout.
|
|
||||||
|
|
||||||
It spans three repos because the work does. **rig** prepares the machine — the
|
|
||||||
pinned toolchain, the cluster, the port arithmetic. **all** owns the shape a
|
|
||||||
project takes, in `all/projects/templates/conventions.md` and the `broad`
|
|
||||||
scaffold beside it. **ppl** owns everything after local, and is where this
|
|
||||||
document stops.
|
|
||||||
|
|
||||||
Read it once before running anything. Three of the steps below need root and one
|
Read it once before running anything. Three of the steps below need root and one
|
||||||
needs a logout, so knowing about them in advance is cheaper than meeting them
|
needs a logout, so knowing about them in advance is cheaper than meeting them
|
||||||
halfway through.
|
halfway through.
|
||||||
@@ -92,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
|
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
|
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 —
|
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
|
but `make deps image` only builds the image; running it is the step below.
|
||||||
build has a Makefile target today. **On a genuinely bare machine, run it by
|
**On a genuinely bare machine, run it by hand:**
|
||||||
hand:**
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make deps image # builds rig-deps:deps
|
make deps image # builds rig-deps:deps
|
||||||
@@ -108,7 +98,7 @@ docker run --rm \
|
|||||||
```
|
```
|
||||||
|
|
||||||
The image name follows the directory, like everything else here: in `rig`
|
The image name follows the directory, like everything else here: in `rig`
|
||||||
it is `rig-deps`, in a copy called `acme-rig` it is `acme-rig-deps`. The
|
it is `rig-deps`, in a copy called `other-rig` it is `other-rig-deps`. The
|
||||||
tag is `deps` (or `full`, below), not `latest`.
|
tag is `deps` (or `full`, below), not `latest`.
|
||||||
|
|
||||||
None of the four arguments are guessable, so:
|
None of the four arguments are guessable, so:
|
||||||
@@ -152,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
|
every later run and every later copy of this directory. The container path is
|
||||||
the first-time path.
|
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
|
## Prove the machine before blaming the project
|
||||||
|
|
||||||
@@ -183,103 +177,42 @@ worth reading before rather than after. `make cluster free <names>` stops
|
|||||||
clusters without deleting them; `docker start` brings them back untouched.
|
clusters without deleting them; `docker start` brings them back untouched.
|
||||||
|
|
||||||
|
|
||||||
## Scaffold the project
|
## Start an overlay
|
||||||
|
|
||||||
The canonical layout is [`all/projects/templates/conventions.md`](../all/projects/templates/conventions.md).
|
|
||||||
Read it — it is short, opinionated, and exists precisely so nobody
|
|
||||||
reverse-engineers a layout from whichever repo they happened to open. What
|
|
||||||
follows is only the mechanical part.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SLUG=<slug> # short, lowercase, no separators
|
|
||||||
cp -r ~/wdir/semester/all/projects/templates/broad ~/wdir/semester/"$SLUG"
|
|
||||||
cd ~/wdir/semester/"$SLUG"
|
|
||||||
grep -rl '<slug>' ctrl | xargs sed -i "s/<slug>/$SLUG/g"
|
|
||||||
cp ctrl/k8s/.env.example ctrl/k8s/.env
|
|
||||||
git init && git add -A && git commit -m "scaffold $SLUG from broad"
|
|
||||||
```
|
|
||||||
|
|
||||||
`<slug>` is the only placeholder and it lives only under `ctrl/` — cluster name,
|
|
||||||
namespace, ConfigMap name, and the `NAME=` in `kind-up.sh` / `kind-down.sh`. One
|
|
||||||
sed does all of it.
|
|
||||||
|
|
||||||
The slug is the folder name, lowercase and short — `mpr`, `unt`, `nvi`. The
|
What runs lives outside rig, in an overlay — see
|
||||||
cluster takes that name and the context becomes `kind-<slug>`, derived by the
|
[`docs/notes/overlay.md`](docs/notes/overlay.md). Start from rig's own:
|
||||||
scaffold's Makefile from the directory, so there is nothing to edit for either.
|
|
||||||
|
|
||||||
**Pick the Tilt port deliberately.** `ctrl/k8s/.env.example` ships a value that
|
|
||||||
is already in use, so copying it unchanged puts two projects on one port:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
grep -h '^TILT_PORT=' ~/wdir/semester/*/ctrl/k8s/.env 2>/dev/null | sort
|
cp -r examples/starter local/myenv # local/ is gitignored by rig
|
||||||
```
|
echo 'OVERLAY=local/myenv' >> ctrl/.env
|
||||||
|
make check # shows the overlay, its cluster and ports
|
||||||
Choose a free one in `10300–10399` — the range ALL reserves in
|
|
||||||
`projects/index.json` under `policy` — avoiding `10350`, which is Tilt's own
|
|
||||||
default. Currently taken: `nvi` 10330, `unt` 10340, `mpr` 10360, `mlv` 10370,
|
|
||||||
`eth` 10380, `lng` 10390. This is the Tilt *web UI* port, not a service port;
|
|
||||||
each project owns its own service ports separately. The scaffold ships it blank
|
|
||||||
on purpose, so there is nothing to collide with until you choose.
|
|
||||||
|
|
||||||
The scaffold's `ctrl/k8s/` is the same shape as every other project here, and it
|
|
||||||
builds as shipped:
|
|
||||||
|
|
||||||
```
|
|
||||||
kind-config.yaml one node; gateway NodePort 30080 -> hostPort 8080
|
|
||||||
base/ namespace, configmap, app (Deployment + Service)
|
|
||||||
overlays/dev/ promotes the app Service to NodePort 30080
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Check it before `kind` spends minutes on anything — this renders the whole tree
|
The cluster takes the overlay's folder name and the context becomes
|
||||||
without a cluster and catches a broken patch immediately:
|
`kind-<name>`, so there is nothing to edit for either. Replace the two example
|
||||||
|
components under `k8s/base/`, and add your images and resources to the
|
||||||
|
overlay's `Tiltfile`. Check the manifests before `kind` spends minutes on anything
|
||||||
|
— this renders the whole tree without a cluster and catches a broken patch
|
||||||
|
immediately:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
kubectl kustomize ctrl/k8s/overlays/dev
|
kubectl kustomize local/myenv/k8s/overlays/dev
|
||||||
```
|
```
|
||||||
|
|
||||||
The workload is an nginx placeholder so a fresh copy reaches something that
|
If the overlay is to be versioned, make `local/myenv` a repository of its own (rig
|
||||||
answers; replace it. Keep `30080` in step between the overlay patch and
|
never tracks it), or keep it anywhere else and name it by path.
|
||||||
`kind-config.yaml`'s `containerPort` — the hostPort is this project's to pick.
|
|
||||||
Reachability is a plain kind port mapping: no ingress controller and no MetalLB.
|
|
||||||
Caddy maps `<slug>.local.ar` onto the host port (`~/wdir/semester/ppl/local/Caddyfile`),
|
|
||||||
with `*.local.ar` resolving to 127.0.0.1 through dnsmasq. That is the whole chain.
|
|
||||||
|
|
||||||
**For `ctrl/Tiltfile`, copy rig's** rather than a live project's. rig ships one
|
|
||||||
that derives its cluster, context, ports and manifest directory from
|
|
||||||
`ctrl/ports.sh active` instead of hardcoding a slug, and carries a catalogue of
|
|
||||||
the blocks every project here ends up needing. Copying from `unt` or `nvi` is
|
|
||||||
what the estate did until now, and it is why the same Tiltfile preamble exists
|
|
||||||
in six places with the slug typed in by hand five times each.
|
|
||||||
|
|
||||||
> **Two things in this document disagree with rig and are not settled.** It
|
|
||||||
> mandates Tilt ports in `10300–10399`, while rig derives a block from the
|
|
||||||
> directory name at `20000+` so copies cannot collide — a rig-managed project
|
|
||||||
> takes rig's. And it names `ctrl/k8s/.env.example`, which is the `broad`
|
|
||||||
> scaffold's layout; rig's is `ctrl/.env.example`. Both are this document
|
|
||||||
> describing the house scaffold from inside rig's tree.
|
|
||||||
|
|
||||||
|
|
||||||
## Run it
|
## Run it
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make kind-up # idempotent create, then selects the context
|
make cluster up # idempotent create, then selects the context
|
||||||
make tilt-up # context + your assigned port
|
make tilt # context + your assigned port
|
||||||
```
|
```
|
||||||
|
|
||||||
`tilt-up` passes `--context kind-<slug>` 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
|
through `make` at all: tilt cannot deploy into whichever cluster you last looked
|
||||||
at.
|
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.
|
delete-and-recreate for when a cluster wedges.
|
||||||
|
|
||||||
|
|
||||||
## Register it
|
|
||||||
|
|
||||||
The project exists; now it is findable. Add an entry to
|
|
||||||
`~/wdir/semester/all/projects/index.json` and write its `projects/<slug>.md` beside the
|
|
||||||
others. Structured fields in the index, prose in the markdown.
|
|
||||||
|
|
||||||
Putting it on the CI server and deploying it is `ppl`'s half, and it starts at
|
|
||||||
`~/wdir/semester/ppl/ctrl/init-repo.sh` — gitea remote, then Woodpecker. That is a
|
|
||||||
different document.
|
|
||||||
|
|||||||
10
rig/Makefile
10
rig/Makefile
@@ -4,8 +4,10 @@
|
|||||||
# Notes: docs/notes/Makefile.md
|
# Notes: docs/notes/Makefile.md
|
||||||
|
|
||||||
# Identity and ports, asked once of ctrl/ports.sh, read positionally (selftest pins the order):
|
# Identity and ports, asked once of ctrl/ports.sh, read positionally (selftest pins the order):
|
||||||
# CLUSTER KUBECONTEXT HTTP HTTPS TILT REGISTRY MANIFESTS_DIR
|
# CLUSTER KUBECONTEXT HTTP HTTPS TILT REGISTRY MANIFESTS_DIR OVERLAY_DIR
|
||||||
FACTS := $(shell bash ctrl/ports.sh active 2>/dev/null)
|
# OVERLAY/CLUSTER given as make arguments are handed over explicitly: before make 4.4,
|
||||||
|
# $(shell) does not see them, and the context would follow the wrong environment.
|
||||||
|
FACTS := $(shell $(if $(OVERLAY),OVERLAY='$(OVERLAY)') $(if $(CLUSTER),CLUSTER='$(CLUSTER)') bash ctrl/ports.sh active 2>/dev/null)
|
||||||
SLUG := $(shell echo '$(notdir $(CURDIR))' | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-' '-' | sed 's/^-*//; s/-*$$//')
|
SLUG := $(shell echo '$(notdir $(CURDIR))' | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-' '-' | sed 's/^-*//; s/-*$$//')
|
||||||
# Fall back to the folder name, not empty, if ports.sh fails on a broken config.
|
# Fall back to the folder name, not empty, if ports.sh fails on a broken config.
|
||||||
CLUSTER := $(or $(word 1,$(FACTS)),$(SLUG))
|
CLUSTER := $(or $(word 1,$(FACTS)),$(SLUG))
|
||||||
@@ -68,8 +70,8 @@ tilt: ## dev loop [up|down] (default
|
|||||||
# The counterpart to check: that one asks about the MACHINE and never fails,
|
# 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
|
# this one asks about RIG and exits 1. Checks are written as the decisions they
|
||||||
# defend, so a failure names what is being undone.
|
# defend, so a failure names what is being undone.
|
||||||
selftest: ## does rig still do what it says? exits 1 if not
|
selftest: ## does rig still do what it says? exits 1 if not [install]
|
||||||
bash ctrl/selftest.sh
|
bash ctrl/selftest.sh $(ARGS)
|
||||||
|
|
||||||
# The one-file versions of rig's tools, one folder per profile, for machines the
|
# 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;
|
# full rig is not going to. Generated from rig as it is, never edited by hand;
|
||||||
|
|||||||
241
rig/README.md
241
rig/README.md
@@ -1,206 +1,99 @@
|
|||||||
# rig
|
# rig
|
||||||
|
|
||||||
A runnable local model of a large, regulated estate — legacy and new side by
|
rig builds a disposable Kubernetes environment on your machine, so you can see how a system
|
||||||
side. Its job is onboarding and exploration, not a production replica: most
|
fits together without access to any of it. It is for **onboarding and exploration**, not a
|
||||||
services are deliberately mocked, because what has to be faithful is the
|
production replica.
|
||||||
topology, not the workloads.
|
|
||||||
|
|
||||||
## Prerequisite
|
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
|
||||||
**Docker.** Nothing else — no curl, no jq, no python, no apt repositories.
|
a laptop. A real 20-service platform does not fit once in 14 GB; stubs of about 30 MB each fit
|
||||||
|
three times.
|
||||||
### 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:
|
|
||||||
|
|
||||||
```powershell
|
Under the hood it is a kind cluster, a local image registry and a Tilt dev loop, each with its
|
||||||
wsl --install
|
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.**
|
You don't need to read rig to use it. Get a release (`rig-<version>.tar.gz`, or a checkout at
|
||||||
WSL can reach the Windows drives at `/mnt/c`, and working from there mostly
|
the tag `rig-<version>`), extract it anywhere, and point the project at it (`RIG_DIR`). Then run
|
||||||
functions — slowly — but file watching does not: that filesystem raises no
|
`make check`: it says what the machine still lacks and which command comes next. `.version`
|
||||||
inotify events, so anything watching for edits silently stops seeing them.
|
names the release; a project records the one it was last run against. See
|
||||||
|
[`docs/notes/overlay.md`](docs/notes/overlay.md).
|
||||||
|
|
||||||
```bash
|
## Prerequisite
|
||||||
cp -r /mnt/c/Users/<you>/rig ~/rig
|
|
||||||
cd ~/rig
|
|
||||||
```
|
|
||||||
|
|
||||||
`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 |
|
| symptom | cause |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| "the virtual machine could not be started" | virtualization disabled in BIOS/UEFI |
|
| "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 |
|
| 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
|
On a machine with nothing but Docker, [BOOTSTRAP.md](BOOTSTRAP.md) runs the toolchain through
|
||||||
close enough to a Linux shell to get started and then fail without `/proc` or a
|
the installer container.
|
||||||
docker socket. `ctrl/deps.sh` detects that and says so rather than letting you
|
|
||||||
find out the slow way.
|
|
||||||
|
|
||||||
## Read the docs first
|
## What it does
|
||||||
|
|
||||||
```bash
|
| command | does |
|
||||||
make docs # serves on localhost, prints the URL
|
| --- | --- |
|
||||||
```
|
| `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) |
|
||||||
They run before anything is installed, which matters because they are the
|
| `make deps` | installs the pinned toolchain (`deps core` on a managed machine) |
|
||||||
instructions for everything else. No cluster and no toolchain required.
|
| `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 |
|
||||||
Why the code is the way it is — the reasoning, measurements and gotchas — lives
|
| `make selftest` | checks rig itself |
|
||||||
in [`docs/notes/`](docs/notes/), one file per script, so the code keeps short comments.
|
| `make help` | every target |
|
||||||
|
|
||||||
## 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
|
|
||||||
```
|
|
||||||
|
|
||||||
That is the whole setup. `make cluster up` also starts this environment's local
|
Every verb is a `Makefile` target calling one script in `ctrl/`. The aliases (`kind-up`,
|
||||||
registry and wires it into the node, so an image built locally is pullable by the
|
`tilt-up`, …) call the same scripts.
|
||||||
cluster without going near docker.io. `make check` shows its port, among
|
|
||||||
everything else:
|
|
||||||
|
|
||||||
```bash
|
## Overlays
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
The port block is derived from the directory name, so two copies of rig never
|
An overlay holds its settings (`rig.env`), its manifests (`k8s/overlays/dev`), its Tiltfile and
|
||||||
collide — nothing to configure. `make check all` lists it; `bash ctrl/ports.sh persist`
|
images, its addons, and its kind config if it needs one. rig reads it and never writes into it.
|
||||||
pins it into `ctrl/.env` if you want it fixed:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make cluster list # every cluster on this machine, with memory
|
cp -r examples/starter local/myenv # local/ is ignored
|
||||||
make cluster free # stop the others if memory is tight
|
OVERLAY=local/myenv make cluster up
|
||||||
make cluster down # remove this cluster and its registry
|
OVERLAY=local/myenv make tilt
|
||||||
```
|
```
|
||||||
|
|
||||||
**The verbs are yours to change.** `cluster` is the script — `ctrl/cluster.sh` —
|
With no overlay named, rig runs `examples/starter`. [`examples/data`](examples/data/) adds
|
||||||
and every spelling above is a `Makefile` target that calls it. `make kind-up` is
|
postgres, redis and airflow as plain manifests. A project can carry rig at `./rig`, or keep it
|
||||||
an alias for `make cluster up`, kept because the other projects on this machine
|
anywhere and name it with `RIG_DIR`.
|
||||||
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 ships `ctrl/Tiltfile`, and
|
|
||||||
`k8s/base` already boots, so the dev loop comes up with the two examples running
|
|
||||||
and nothing to configure first.
|
|
||||||
|
|
||||||
It hardcodes nothing. It asks `ctrl/ports.sh active` for this environment's
|
|
||||||
cluster name, kube context, ports and manifest directory — the same values every
|
|
||||||
other rig script resolves through `ctrl/lib/config.sh` — so a copied and renamed
|
|
||||||
rig deploys into its own cluster with no edits. Every other project here writes
|
|
||||||
its slug into the Tiltfile five or six times by hand, which is exactly the
|
|
||||||
collision `kind-config.yaml.tpl` exists to avoid.
|
|
||||||
|
|
||||||
What it deploys is `MANIFESTS_DIR`, defaulting to rig's own `ctrl/k8s/overlays/dev`.
|
|
||||||
Point that at an overlay versioned elsewhere and rig stops owning the manifests.
|
|
||||||
|
|
||||||
Replace the examples, then add your images and resources in the two marked
|
|
||||||
sections. The catalogue below them holds the blocks that recur across every
|
|
||||||
project here — `docker_build`, resource ordering, gateway reload, port-forwards —
|
|
||||||
with the parts that are easy to get wrong already commented.
|
|
||||||
|
|
||||||
`make help` lists every target.
|
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.
|
||||||
|
|
||||||
On a machine where Docker really is the only thing installed, `make deps` has
|
## Configuration
|
||||||
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.
|
|
||||||
|
|
||||||
## One directory is one environment
|
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.
|
||||||
|
|
||||||
Copy this directory, rename it, run it. Cluster name, kubectl context, image
|
rig's own addons make the cluster work: `metallb` (LoadBalancer addresses), `cert-manager` (a
|
||||||
tags and the host port block all derive from the directory name, so copies never
|
local CA), `metrics-server` (`kubectl top`). A workload's database, cache or scheduler belongs to
|
||||||
collide and neither one's teardown can touch the other.
|
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.
|
||||||
|
|
||||||
A copy of this directory is a **sibling** of it, named after the environment it
|
## Where things are
|
||||||
models (`acme-rig`). That is why the ignore rules for copies sit in the *parent*
|
|
||||||
repo's `.gitignore` rather than here: a rule in this directory cannot see a
|
|
||||||
directory beside it.
|
|
||||||
|
|
||||||
## Profiles
|
|
||||||
|
|
||||||
**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 overlay — a file in `ctrl/env.d/`, named by `PROFILE` — for when you
|
|
||||||
want different addons or registry.
|
|
||||||
|
|
||||||
rig ships **examples**, not active profiles, because each one is a use case rather
|
|
||||||
than something every rig needs. Copy one to use it:
|
|
||||||
|
|
||||||
| example | what it changes |
|
|
||||||
| --- | --- |
|
|
||||||
| `client.env.example` | images through a mirror of a corporate registry |
|
|
||||||
| `offline.env.example` | air-gapped: everything from a preloaded local registry |
|
|
||||||
| `data.env.example` | databases and a scheduler: postgres, redis, airflow |
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp ctrl/env.d/data.env.example ctrl/env.d/data.env
|
|
||||||
PROFILE=data make cluster up # installs the profile's addons too
|
|
||||||
PROFILE=data make check # what the profile wants, and what exists
|
|
||||||
```
|
```
|
||||||
|
Makefile the verbs
|
||||||
The **cluster itself** is one file, `ctrl/k8s/kind-config.yaml.tpl` (one node).
|
.version this release: patch and minor steps are edits here; milestones get a tag
|
||||||
To change it — more nodes, other port mappings — edit it and `make cluster
|
ctrl/ one script per verb; lib/config.sh (settings and layers); Tiltfile (rig's
|
||||||
reset`. The node count is read back out of it, so there is nothing to drift. The layout under `ctrl/k8s/` is the
|
half of the dev loop); k8s/kind-config.yaml.tpl; addons/; env.d/; versions.env
|
||||||
same as every other project here — a kind config, a kustomize `base/`, an
|
docs/ index.html (make docs); notes/, one per script: why the code is as it is
|
||||||
`overlays/dev/` — see [`ctrl/k8s/README.md`](ctrl/k8s/README.md).
|
examples/ starter (the default overlay), data (postgres, redis, airflow)
|
||||||
|
standalone/ single-file kits generated from ctrl/ (make standalone); never edited by hand
|
||||||
## Addons
|
tests/ the installer's host fixtures
|
||||||
|
BOOTSTRAP.md from a bare machine with only Docker
|
||||||
Each addon is its own idempotent script in `ctrl/addons/`, and a profile names
|
STALE.md assumptions withdrawn, each checked by selftest
|
||||||
the ones it wants in `ADDONS`. Adding one is adding a file — there is no
|
|
||||||
dispatcher to edit.
|
|
||||||
|
|
||||||
**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 to model a legacy estate
|
|
||||||
is the whole point. Services are reached through MetalLB and
|
|
||||||
`type: LoadBalancer`, which carries no such constraint and is also what a real
|
|
||||||
cluster does.
|
|
||||||
|
|
||||||
| 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 |
|
|
||||||
| `postgres` | database, in the `data` namespace |
|
|
||||||
| `redis` | cache and broker |
|
|
||||||
| `airflow` | scheduled pipelines; needs postgres and redis |
|
|
||||||
|
|
||||||
The last three are **cabinets**: a public service dropped in as-is, the upstream
|
|
||||||
image unmodified, reachable at a known address. A cabinet is declared once and
|
|
||||||
installs on either target — a `service.yml` composes it for a laptop, and these
|
|
||||||
install the same one here. The names match on purpose: each cabinet carries a
|
|
||||||
`rig_addon` field pointing at `ctrl/addons/<name>.sh`.
|
|
||||||
|
|
||||||
Plain manifests rather than helm charts, like every other addon: a chart repo is
|
|
||||||
a network dependency, and the `offline` profile exists precisely so there is a
|
|
||||||
path with none. Images are pinned in `ctrl/versions.env` and can be preloaded.
|
|
||||||
|
|
||||||
Passwords are generated on first install and kept across re-runs, so re-running
|
|
||||||
an addon never rotates a credential out from under something already connected:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl -n data get secret postgres -o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d
|
|
||||||
kubectl -n data port-forward svc/airflow 8080:8080
|
|
||||||
```
|
```
|
||||||
|
|||||||
68
rig/STALE.md
Normal file
68
rig/STALE.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# rig — withdrawn assumptions
|
||||||
|
|
||||||
|
**Everything in this file is no longer true.**
|
||||||
|
|
||||||
|
It exists so the live docs stay short and a withdrawn assumption cannot quietly
|
||||||
|
return: each entry carries a **check**, and `ctrl/selftest.sh` runs every one of
|
||||||
|
them under "withdrawn stays withdrawn". A retraction that is only prose is one
|
||||||
|
nobody re-reads.
|
||||||
|
|
||||||
|
- **Do not restate these** in a plan, a README or a comment; point here (`✖ S2`).
|
||||||
|
- **Ids are stable.**
|
||||||
|
- **Only withdrawn things belong here.** A warning that is still actionable is a
|
||||||
|
live rule and stays where it is.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**✖ S1 — "rig supplies `ctrl/Tiltfile` but does not own it: replace the examples and
|
||||||
|
add your images in its marked sections."** *(ctrl/Tiltfile, README, 2026-09-13)*
|
||||||
|
Withdrawn 2026-09-22. Every project that used rig edited rig's own file, so no
|
||||||
|
update to rig could land without merging those edits by hand. rig's `ctrl/Tiltfile`
|
||||||
|
is now rig's: identity, context guard, registry, manifests, namespaces. The
|
||||||
|
workload's half is the overlay's own `Tiltfile`, which rig's includes.
|
||||||
|
**Check:** `ctrl/Tiltfile` includes the overlay's Tiltfile and has no "Images" section
|
||||||
|
of its own.
|
||||||
|
|
||||||
|
**✖ S2 — "A second environment is a copy of rig renamed after it (`../<name>-rig`), and
|
||||||
|
its use case is written into the copy."** *(README, BOOTSTRAP, .gitignore, 2026-08)*
|
||||||
|
Withdrawn 2026-09-22. A use case is an overlay, kept outside rig's version control
|
||||||
|
(`docs/notes/overlay.md`); rig itself is replaced as a whole. Copying rig still gives
|
||||||
|
a separate environment, but carries no use case of its own.
|
||||||
|
**Check:** rig's `.gitignore` ignores `/local/`; no `acme` example name remains in rig.
|
||||||
|
|
||||||
|
**✖ S3 — "rig's addons include the services a workload needs (a database, a cache, a
|
||||||
|
scheduler), described in the host project's vocabulary."** *(ctrl/addons/, README,
|
||||||
|
versions.env, 2026-08)* Withdrawn 2026-09-22. Which services a workload needs is
|
||||||
|
not rig's business. `ctrl/addons/` holds what makes a cluster work; workload addons
|
||||||
|
live with overlays, and `examples/data` carries them as an example.
|
||||||
|
**Check:** `ctrl/addons/` holds exactly cert-manager, metallb and metrics-server;
|
||||||
|
`versions.env` pins no workload image.
|
||||||
|
|
||||||
|
**✖ S4 — "The dev loop's namespace is named after the cluster."** *(ctrl/Tiltfile,
|
||||||
|
2026-09-13)* Withdrawn 2026-09-22. The Tiltfile created and grouped
|
||||||
|
`<CLUSTER>:namespace` while the example manifests declared `rig`, so every copy not
|
||||||
|
named `rig` stopped at load: `No object identified by the fragment
|
||||||
|
"acme-rig:namespace"`. The first real use worked around it with a Namespace named
|
||||||
|
`to_be_replaced` that its overlay renamed. The Tiltfile now creates the namespaces
|
||||||
|
the manifests use and groups the ones they declare.
|
||||||
|
**Check:** `ctrl/Tiltfile` does not build a namespace name from `CLUSTER`.
|
||||||
|
|
||||||
|
**✖ S5 — "`MANIFESTS_DIR` defaults to `ctrl/k8s/overlays/dev`, rig's own examples."**
|
||||||
|
*(lib/config.sh, .env.example, 2026-09-13)* Withdrawn 2026-09-22. The default is the
|
||||||
|
overlay's `k8s/overlays/dev`; rig's examples are `examples/starter`. The old value,
|
||||||
|
still pinned by older `.env` files, is ignored while that folder does not exist, and
|
||||||
|
`make check` says to delete it.
|
||||||
|
**Check:** `ctrl/k8s/overlays` does not exist; `.env.example` does not set
|
||||||
|
`MANIFESTS_DIR`.
|
||||||
|
|
||||||
|
**✖ S6 — "The example profiles are `client`, `data` and `offline`."** *(ctrl/env.d/,
|
||||||
|
2026-09-17)* Withdrawn 2026-09-22. "client" names the customer, not a registry
|
||||||
|
mode: the example is `mirror`. `data` was a workload, not a way of reaching the
|
||||||
|
world: it is the `examples/data` overlay.
|
||||||
|
**Check:** `ctrl/env.d/` holds no `client` or `data` example.
|
||||||
|
|
||||||
|
**✖ S7 — "BOOTSTRAP spans three repos: rig prepares the machine, the house repos own the
|
||||||
|
project's shape and everything after local."** *(BOOTSTRAP.md, 2026-08)* Withdrawn
|
||||||
|
2026-09-22. rig's docs describe rig. The house scaffold and registration sections
|
||||||
|
moved out of rig; BOOTSTRAP now ends with starting an overlay.
|
||||||
|
**Check:** no house path or host name (`semester`, `local.ar`) in rig.
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
# Machine-local config. Copy to ctrl/.env (gitignored) and edit.
|
# 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
|
# Notes: docs/notes/env.md
|
||||||
|
|
||||||
# A profile in ctrl/env.d/ to build. Empty means rig's built-in defaults, which
|
# A profile in ctrl/env.d/ to build. Empty means rig's built-in defaults, which
|
||||||
# need no profile at all. Copy an env.d/*.env.example to <name>.env to add one.
|
# need no profile at all. Copy an env.d/*.env.example to <name>.env to add one.
|
||||||
PROFILE=
|
PROFILE=
|
||||||
|
|
||||||
|
# The overlay: one folder, outside rig's version control, holding what runs —
|
||||||
|
# its settings (rig.env), manifests, addons, Tiltfile. Relative to rig's folder,
|
||||||
|
# or absolute. Unset: rig's own examples/starter. See docs/notes/overlay.md.
|
||||||
|
# OVERLAY=local/<name>
|
||||||
|
|
||||||
# Cluster name; the kubectl context becomes kind-<CLUSTER>.
|
# Cluster name; the kubectl context becomes kind-<CLUSTER>.
|
||||||
# LEAVE UNSET: it defaults to this folder's name, which keeps the folder copyable.
|
# LEAVE UNSET: it defaults to this folder's name, which keeps the folder copyable.
|
||||||
# CLUSTER=
|
# CLUSTER=
|
||||||
@@ -17,9 +22,8 @@ PROFILE=
|
|||||||
# TILT_PORT=
|
# TILT_PORT=
|
||||||
# REGISTRY_PORT=
|
# REGISTRY_PORT=
|
||||||
|
|
||||||
# Where the application manifests live; repoint at their own repo, e.g.
|
# Where the manifests live. Leave unset: the overlay's k8s/overlays/dev.
|
||||||
# MANIFESTS_DIR=../platform-manifests/overlays/dev
|
# MANIFESTS_DIR=../platform-manifests/overlays/dev
|
||||||
MANIFESTS_DIR=ctrl/k8s/overlays/dev
|
|
||||||
|
|
||||||
# Where the installer fetches the pinned binaries from:
|
# Where the installer fetches the pinned binaries from:
|
||||||
# upstream (needs internet) | artifactory (generic repo) | baked (in the image)
|
# upstream (needs internet) | artifactory (generic repo) | baked (in the image)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user