Pages and Visualizations#
A page is a function in the target module that takes the value at one
schema position and returns a website as a folder, with all the data
inside. The server calls it on the first visit, memoizes the folder by the
value's content, and hosts it; a page links to another page by position, so
every URL is known before anything is rendered. Usually the folder is a
cgui.Page that a @cgui.PageBuilder() function built from the components of
capturegraph-ui (Pages); it may also be any directory
value holding an index.html, such as a report a tool wrote. The
walkthrough adds two pages to a target step by
step.
Pages are where the thesis becomes visible. Because the schema is declared before capture begins, analysis can run during the study rather than after it: a page is that analysis, addressed at a schema position and recomputed as data lands. Participants and investigators see coverage — which conditions the dataset still lacks — while there is still time to change what gets captured, which is the same information the interceptors use to decide whom to interrupt.
Every example on this page assumes three aliases. cgserver is the server's
own authoring module, not a separate package:
A position is the target-relative path a value sits at, spelled as the
file API spells it — sessions/00063B40E29D696A/photo,
a hidden field _metadata as the dotfile .metadata. A pattern is a
position with * for each container crossed: sessions/*/photo. The root is
the empty path. A session's key is its capture instant as sixteen hex digits
(sessions).
Quick start#
Upload one photograph as a new session (KEY mints a capture instant, sixteen
hex digits of microseconds), visit its page, wait for the render, and fetch
the hosted folder:
uv run python -c "from PIL import Image; Image.new('RGB', (640, 480), (90, 120, 40)).save('$HOME/photo.jpg')"
KEY=$(printf '%016X' $(( $(date +%s) * 1000000 )))
curl -X PUT -H 'X-CG-File-Extension: jpeg' --data-binary @"$HOME/photo.jpg" \
"http://localhost:4433/api/v1/targets/PlantJournal/files/sessions/$KEY/photo"
BASE=http://localhost:4433/pages/PlantJournal/sessions/$KEY
curl -s -o /dev/null "$BASE/"
until curl -s "$BASE/~status" | grep -q '"state": *"ready"'; do sleep 1; done
curl -s "$BASE/~/"
In a browser, $BASE/ is the session's page, http://localhost:4433/pages/PlantJournal/
the journal's root page, and http://localhost:4433/pages the index of every
page the server hosts.
Declaring a page#
| Parameter | Meaning |
|---|---|
Schema |
The target's schema class, the one the module itself declares. |
select |
A selector from the schema root to the page's position, lambda s: s.sessions.for_each(); None is the root. |
version |
The page's version. The function is memoized under the identity "<function name>/<version>"; bumping it invalidates every render and keeps every URL. |
The capture API is unauthenticated, and so are pages: anyone who can reach the server can read any page. Only the management panel is password-gated — do not put a page on an open port you would not put the capture data on.
The function takes one parameter, named as you like and annotated with
the type at the selected position, and returns a directory value whose folder
holds an index.html. The usual form is a @cgui.PageBuilder() function:
its body is annotated -> None and builds components, and the wrapper the
decorator returns is annotated -> cgui.Page, which is what @cgserver.page
sees. A plain function may instead return any directory scalar itself
(below). Anything else is a
DefinitionError, and where it is raised decides the damage. A selector the
schema lacks, or a parameter or return annotation that does not fit, fails at
decoration and so fails the whole module load, which the panel shows as a load
error. Two pages at one pattern, or a page declared against a Struct other
than the target's own, is caught after the module runs: the target keeps
serving its procedure, but every page and every interceptor it declares is
dropped, with the reason in the server log and under the target's
interceptor status (Troubleshooting).
@cgserver.page(PlantJournal, version=1)
@cgui.PageBuilder()
def journal(target: PlantJournal) -> None: ... # the root: ""
@cgserver.page(PlantJournal, lambda s: s.sessions, version=1)
@cgui.PageBuilder()
def sessions(sessions: cg.Map[cg.Date, Session]) -> None: ... # the map: sessions
@cgserver.page(PlantJournal, lambda s: s.sessions.for_each(), version=1)
@cgui.PageBuilder()
def session(session: Session) -> None: ... # each session: sessions/*
@cgserver.page(PlantJournal, lambda s: s.sessions.for_each().photo, version=1)
@cgui.PageBuilder()
def photo(photo: cg.Image) -> None: ... # each photo: sessions/*/photo
Under @cgui.PageBuilder() the body reads as the page, top to bottom:
components attach in construction order, with on a stack nests, and
cgui.Title names the page (Pages). Ordinary Python does
the rest: a for loop builds one PageLink per session, an if decides
whether to say something.
@cgserver.page(PlantJournal, version=1)
@cgui.PageBuilder()
def journal(target: PlantJournal) -> None:
sessions = target.sessions.to_dict()
cgui.Title("Plant Journal")
cgui.Markdown("## Sessions")
with cgui.HStack():
cgui.Stat("Sessions", len(sessions))
cgui.Stat("Days", len({instant.value.date() for instant in sessions}))
cgui.Gallery.of(target.sessions, lambda s: s.photo)
for instant, session in sessions.items():
cgui.PageLink(session, title=instant.value.date().isoformat())
The gallery stages every photograph at full size and links each to its session's page; Thin assembly replaces the photographs with a cached thumbnail per session.
A page function is a
cg.pure_function whose
identity is "<function name>/<version>". A memo entry is keyed by that
identity together with the content hash of the one argument, so a render
serves until the value at the position changes or the version is bumped;
editing the body without bumping the version keeps serving the old render,
deliberately (caching guide). The call is
ordinary and synchronous and its result seals into the scratch. The server
loads the position's value with cg.load
against the schema and passes it.
Selectors#
A selector is a plain function of the schema, run once at decoration against
a stand-in that records the fields it walks. The grammar and its errors are
the interceptors': for_each() is the
only way past a Map or Array, a field of a container without it is a load
error naming the fix, and indexing is a load error. What differs is where a
page may stop.
- A page may stop anywhere.
lambda s: s.sessions.for_each()is one page per session (sessions/*, parameterSession);lambda s: s.sessions.for_each().photoone per photograph (sessions/*/photo, parametercg.Image), answering at…/photo/. - A page may be declared at the container itself.
lambda s: s.sessionsis the single page of the whole map (sessions, parametercg.Map[cg.Date, Session]), answering at…/sessions/; it exists as soon as the directory does. - No data, no page. A position whose file or directory is absent is
404, whatever its type.
What a page is on disk#
A @cgui.PageBuilder() function returns a cgui.Page: the components its
body built, in a root VStack, and the title a cgui.Title set. Nothing
is written until the page is built, which the store does when the page
function returns. The built folder is:
| Entry | Contents |
|---|---|
index.html |
The rendered tree, with <title> when the page named one. |
files/ |
Every file a component shows, numbered in staging order (files/0.jpeg); one source is copied once. |
lib/<library>/ |
Each browser library a component used, copied whole with its license. |
The title is the page's name wherever it is listed: the browser tab, the
card at /pages, ~status. Derive it from the data rather than repeating
the function's name. A page with no file props has no files/; one using no
library has no lib/. Nothing in a sealed folder is ever modified.
Returning a folder that already exists#
A page function need not use cgui at all. It may return any directory
scalar — a
cg.DirScalar subclass,
such as a directory value a tool wrote its own HTML report into — as long as
the folder holds an index.html. The server hosts the folder as it stands at
the position's ~/, serving every file inside it, and reads the page's title
from the <title> of that index.html. cgui.Page is one such value, and
cgui is one way to make one. A returned folder without index.html fails
the render with an error naming index.html.
class Report(cg.DirScalar, name="edu.example.report"): ...
@cg.pure_function("coverage_report/1")
def coverage_report(sessions: cg.Map[cg.Date, Session]) -> Report:
report = Report.new()
(report / "index.html").write_text(
f"<title>Coverage</title><p>{len(sessions)} sessions captured.</p>"
)
return report
@cgserver.page(PlantJournal, lambda s: s.sessions, version=1)
def sessions(sessions: cg.Map[cg.Date, Session]) -> Report:
return coverage_report(sessions)
name="edu.example.report" is the scalar's reverse-DNS identifier, which
names its byte layout; a format you define lives under your own domain
(The name).
Such a folder links however its author wrote it; PageLink and the of
constructors are how a cgui page links, and a hand-made folder holding an
index.html is a cgui.Page as it stands: cgui.Page.load(folder) takes it,
stores it byte for byte, and reads page.title from the <title>.
Links#
A page links to another value's page by the relative path between the two
values' positions, plus one ../ for the ~/ segment the server hosts the
folder under: from the root page a session is ../sessions/<key>/. The
server sets which value a page is for (page.at) after the page function
returns, so authors never do. A link is a position URL, and only a position
with a page declared exactly at its pattern answers, so link to values whose
pattern the target declares a page at.
The only components that link to another value's page are PageLink and the
of constructors. PageLink(value) is a card to the page of a value loaded
from the target. Gallery.of, Timelapse.of and MapViewer.of walk a
loaded container and link each derived item — a thumbnail computed per
session, which has no position of its own — to the page of the entry it came
from. Every other component shows its data and links nowhere; a [text](url)
in cgui.Markdown is an ordinary hyperlink you wrote, not a position link.
The rules in full are in Pages.
URLs#
Every URL under /pages is a target name (which may nest, Plants/Tulip),
a target-relative path spelled as the file API spells it, and an optional
ask after a ~ segment. A trailing slash is optional.
/pages is the index: every page position as a tree of links, under the
server's configured name. Targets nest
under their group directories. A target with a root page links to it,
captioned by that page's <title> once it has a render and by the target's
name until then. Beneath a target, each field with a page is a link, and a
Map or Array folds open into its entries on disk, each captioned by its
key and linked when a page is declared at that pattern. Only positions with
data are listed.
| URL | Behavior |
|---|---|
/pages/<target>/<path…>/ |
The position. 302 to its ~/ when the folder is ready; the computing view otherwise. 404 unless a page is declared exactly at that path's pattern and data exists there: the map sessions/ on a target with pages only at sessions/*, a key nobody captured, or a target's root without a root page are all 404. |
…/~/ |
The hosted folder's index.html with a banner on top — a button up to the nearest ancestor with a page (the index when there is none), the trail from the site through the target and each path component, linked wherever a page is declared, and a Build log link. The banner is injected into the bytes served, never into the sealed folder. ETag = a hash of the render and the banner injected into it, Cache-Control: no-cache; 304 when the caller holds it. A folder that no longer matches the value on disk sends the visitor back through the position URL. |
…/~/<file…> |
A file of the folder held right now, tagged with the render's own hash (the hash ~status reports), so an open page keeps its files while a fresher render computes. 404 once evicted. |
…/~status |
state (ready, computing, failed, pending), hash, title, error, log (the live log of the attempt running or just failed in this process, parents before children; null otherwise), now (the server's clock, which the entries' times are on). Every key always present. 404 where the position is. |
…/~events |
The same, as server-sent events: a snapshot (now and the entries so far), an entry per change coalesced ten times a second, then one state and the end of the stream. A page computing in another worker, or not computing at all, gets its state alone. |
…/~log |
The last build's log as a page of its own, the error above it if that build failed; the computing view while a build runs in this worker process (below). 404 before any build. |
/pages/<target>/~pages |
{"patterns": [...]}: every position the target declares a page at, * per container crossed; [] for a target without pages. What the app asks. |
A component that could escape a directory is 400; a path that is not a
position of the schema, a key that is not canonical, an unknown ask, or an
unknown target is 404.
Components#
The components come from capturegraph-ui. The standard vocabulary, and
the rule that a component given Missing for any prop renders as nothing,
are in Pages; a component of your own is
Adding a component.
Thin assembly over cached per-session calls#
A page's key is the content of the value at its position, so the root page is recomputed on the first visit after any of the target's files change. That is cheap as long as the root's body is assembly: it walks the loaded data and calls memoized per-session recipes, so a new upload costs one session's work plus the assembly, never the whole study again.
def _thumbnail(session: Session) -> cg.Thumbnail:
small = cg.Thumbnail.new() # rendered inside the root's call
session.photo.pil(max_axis=256).save(small)
return small
@cgserver.page(PlantJournal, version=1)
@cgui.PageBuilder()
def journal(target: PlantJournal) -> None:
cgui.Title("Plant Journal")
cgui.Gallery.of(target.sessions, _thumbnail)
Every upload re-decodes every photo: the work is keyed by the whole target.
@cg.pure_function("session_thumbnail/1")
def session_thumbnail(photo: cg.Image) -> cg.Thumbnail:
thumbnail = cg.Thumbnail.new()
photo.pil(max_axis=256).save(thumbnail)
return thumbnail
@cgserver.page(PlantJournal, version=1)
@cgui.PageBuilder()
def journal(target: PlantJournal) -> None:
cgui.Title("Plant Journal")
cgui.Gallery.of(target.sessions, lambda s: session_thumbnail(s.photo))
Each photo is decoded once, keyed by its own content. The same call serves the thumbnail interceptor from one cache entry.
pil(max_axis=256) opens an image as a PIL image with its longest side
resized to 256 pixels, and
cg.Thumbnail is
the JPEG file scalar the app's galleries read. Anything that scales with the
number of sessions belongs in a per-session cg.pure_function, or in a page
declared at the session, whose key is that session alone. Anything that
scales with the whole dataset is either cheap enough for the root page or its
own memoized call over exactly the data it needs.
What computing looks like#
The first visit to a position with no sealed folder for its input shows
Calculating… and subscribes to ~events. The page function runs under a
log sink of the runner's own, and the view draws the tree
as it streams: the page's own call at the root, every memoized call, map,
pmap, lock and tool run beneath it nested, each scope collapsible, each
count a bar, each tool's last lines of output beneath its command, a line
logged every iteration of a loop revised in place rather than repeated. A
gallery of forty thumbnails reads as one count filling up, a worker count
per thread beneath it, each showing the thumbnail call it is on right now or
the session_thumbnail: cached line it just served. A recipe waiting for the
GPU reads as a gpu lock scope whose message names what holds it
(global locks), so a page queued behind
another target's training says so rather than sitting silent. When the final
state arrives the view reloads into the page. Each target has one worker
thread, so a slow page holds up only its own target, and two overlapping first
visits compute once.
A visit always calls the memoized page function. A page that raises
seals nothing; once the attempt ends the view shows the error text and its
notes, the exec.log tail among them, above the full tree with the failed
scope marked, and the traceback is in the server log under the page's URL.
~status reports failed with that error and log until the next visit, which
calls the function again, so reloading a failed page tries again; pending
means nothing is computed for these inputs and nothing is in flight.
The build log#
When the attempt ends, the runner stores the tree under
<root>/.cg-pages/<target>/<path…>/ and serves it at ~log, which every
hosted page's banner links as Build log; while a build runs, ~log shows
the live view instead. The sealed page itself never carries the log, so a
recompute seals to the same hash. The server root's .gitignore excludes
.cg-pages/ alongside the scratch.
Across workers#
The live log lives in the process computing the page. With workers > 1
(configuration) a browser served by
another worker learns only that the call is claimed — the runner reads the
key's call lock — and polls ~status until the folder is ready. ~log
follows the same split: the worker running the build shows it live, any other
worker shows the last stored build (or 404 if there is none) until this one
finishes; afterwards the stored log is on disk for every worker. If the
process computing a page dies, its ~events stream ends without a state
event and the kernel releases the call lock with it: the view falls back to
polling ~status, which now says pending, and nothing recomputes until the
position URL is visited again.
Eviction and recompute#
A render is an object in the recipe scratch
like any other result. The runner holds each position's current render under
the scratch name page:<target>/<path>, pinned while the server runs; the
rest is evictable history. A position computes only when someone visits it,
and then only when no sealed folder answers for the value at its position:
it has changed, its render was evicted, the version was bumped, or the last
attempt raised. An evicted render recomputes to the same hash, so old
links heal. Nothing computes on a restart: an unchanged position serves from
the scratch at once.
The app's button#
A hosted target in the app fetches ~pages when the target opens and shows a
Visualizations button in the explorer's actions toolbar whenever the
shown value's path matches a declared pattern, * standing for exactly one
component. It opens <server>/pages/<target>/<path…>/ in the browser, ready
or not. A target without pages answers {"patterns": []} and the button
never appears.
Troubleshooting#
404at a position URL. No page is declared at that path's pattern, or nothing has been captured there yet:~pageslists the patterns, and the file API says what is on disk.RuntimeError: no page is being built. Acgui.Titleor awithon a stack ran outside a@cgui.PageBuilder()function: a helper that builds part of a page must be called from inside one, or return the components for the caller to place. A stack built outside a builder takeschildren=instead ofwith.- The build fails naming a
PageLink. with no position in a target: the value was built in memory (a.clone()included) or computed by a recipe; link the loaded value instead. the page has noat: the page was built outside the server withoutat=. - The render fails naming
index.html. The page function returned a directory value whose folder holds noindex.html; write one there, or return acgui.Page. - A component appears twice. It was built inside a builder function and
then also passed as
children=to a stack; inside a builder, build it under thewithblock instead. - A gallery's pictures do not link. The pictures are derived and the
gallery was built with
Gallery(...)rather thanGallery.of(...), or the container came from memory rather than the loaded target. 404under~/that used to work. The render was evicted or replaced by a newer one. Open the position URL: an evicted render recomputes to the same hash.- The root page recomputes after every upload. By design. If each
recompute is slow, move per-session work into
cg.pure_functions (above) or into a page at the session. - A target declares a page but
~pagessays it declares none, and its interceptors are dead too. The module's declarations were rejected. Two pages at one pattern, or a page declared against aStructother than the target's own, drops every page and every interceptor in the module while the target keeps serving its procedure — one cause for both symptoms. The server log carries theInvalid declarations: …line, the panel's code editor shows it as a failing badge in the interceptor strip rather than as a load error, and Save & go live refuses the same module. A selector the schema lacks, or a parameter or return annotation that does not fit, fails the whole module load instead, which does show as a load error. - The index shows the target's name, not the page's title. Its root page has not been rendered yet, or it declares no root page.