The console’s design

What the console does is The console. This page is how it is built, and why the decisions a user notices were made that way.

What is plesty-lib’s, what is ours

The console takes plesty-lib’s theme — plesty.lib.ui.theme.Theme: the plesty.net palette (palette.json), the fonts and the widget chrome vocabulary — so the lab GUI, the website and the reports stay one visual system.

It does not take the docking Shell/Panel layout: a bench console is not a monitor wall. The layout is designed here from scratch and lives in one stylesheet template, plesty/server/assets/console.qss, whose {{token}} placeholders are filled from the lib palette at start-up (view.app.stylesheet()). No colour is written in Python; no CSS is written in Python either.

The one place the docking shell is used is the Control window, where the monitor wall is exactly the point.

How the view stays a view

  • The window owns a Bench (presenter) and a Worker (a small thread pool). Every menu action is one presenter call on the worker; done and failed arrive as Qt signals on the GUI thread, which repaints or alerts. Nothing in view/ imports a service or touches a process.

  • Dialogs turn form fields into model objects (to_spec(), to_run()) and report a FleetError inline instead of closing — the presenter gets a valid object or nothing.

  • Long work (install, update, field test) comes back as a Job; the window opens a JobDialog on it and refreshes when it ends. Closing the dialog does not stop the job — it is detached, like everything else (Process management).

The console as a client

The console managed the bench it ran on and nothing else; plesty.server imported nothing of plesty.bench. A master does, and that is the whole of the mechanism: BenchClient is already the client of a bench agent, and services/nodes.py is the layer between it and the presenter — one NodeLink per declared bench, one Nodes registry, both injectable so the tests script a node without a socket.

The node’s answer is the local answer. A poll is one exec(["list", "--json"]), which is the document the local list --json prints, so a remote row is rebuilt into the same DeviceView as a local one and every consumer of that view is unchanged. The only field added is host, which is empty for this bench’s own.

One round trip per bench per refresh, not per device — the table would otherwise cost a node a request per row every second. The per-device panes cost nothing at all, because for a remote device they are not read: the log, jobs, reports, terminal and traffic are files in that bench’s home.

A link is called from the window’s thread pool, so each holds a lock: a ZMQ socket belongs to one thread at a time and the pool makes no promise which. Connections are kept between polls and reconnect themselves — BenchClient drops a socket whose reply timed out, so a stale answer cannot arrive against a later question.

A node that does not answer is a state, not an exception. Its rows go and the status bar names it. Raising into the poll would take the console down with a bench somebody rebooted; keeping stale rows would say an instrument is running when nothing knows whether it is.

Master and node are configuration, not permission — see The agent and the client.

The Experiment tab

The tab holds no per-experiment code, reads a run’s state from its directory and never asks the run anything. Why, and how a run is started, stopped and reported on: Experiment runs.

What the window says without being asked

The run banner reads only what the tables read. While a run is going, the line across the top names it, its progress and how many of the devices it uses are up (#41). It is built from the rows the two tables last drew — the run’s state, the devices its job recorded, which devices are alive — so it costs the once-a-second refresh nothing, and no subprocess runs on that path.

Buttons are icons with names. Each card button carries a Lucide icon (vendored SVGs, ISC, assets/icons/) tinted from the theme’s tokens, and its name and purpose in the tooltip; the label it had is kept as a property, so the menus that mirror a button still read its name.

Every install, update and uninstall ends in one line. Green when it worked, red with the last line its job wrote when it did not, on one line with the whole text as tooltip — so the outcome does not live only in a job window that may have been closed (#38).

What the start offers, it offers once. The start-up update check asks PyPI off the GUI thread and at most daily (#39), so no network never holds the window. The welcome opens by itself only on a bench that holds nothing — no module, device or experiment — and only the first time (welcome-seen, #43); Help ▸ Welcome… opens it again.

Why failures are not modal

What fails in a console fails asynchronously: an install runs for minutes and the operator is often elsewhere. A modal blocks Qt’s event loop — the table stops refreshing, an open job log stops tailing, and the console is frozen until somebody clicks it away. So failures go to the status bar with a Dismiss button and stay until dismissed or until the next action succeeds. A job’s own log dialog is a different thing and opens as before: it is what the operator asked for.

The Traffic tab

The console is a different process from the server, so it cannot watch the wire. plesty-lib writes one record per request to the server log, and this reads them back — one file read, nothing on the wire.

Metadata only, deliberately: a result can be a whole spectrum, and a log is the wrong home for measurement data; a failure shows its exception type, not its message. A reply that could not be routed is called out as answer lost — the client timed out and reconnected, the device did the work anyway, and the caller is about to report a failure that did not happen. It is read only while the tab is in front, and the history reaches back exactly as far as the log does. An empty pane says which of its two causes it is: nothing served yet, or a server on a plesty-lib older than the records.

The Terminal tab

A command run against the venv that serves the selected device — what otherwise means locating that venv by hand in a system terminal. A checkout is synced --no-dev (see The installer), so the authoring tools — plesty check, pytest — are not in it; the module’s own commands and tests/field_test.py are. The venv’s script directory goes first on PATH; the working directory is the checkout for a git install and the device’s run directory for a release. Each command is a job like any other, so it appears under Jobs and Stop cancels it.

It is not a shell: the line is split into arguments, so there is no pipe, no redirection and no glob. A shell would need an interpreter per platform and a quoting model to match; the thing it is for does not need one.

The Control window

View ▸ Control window, or Control on the device table, opens a separate window holding one panel per running device — the wall. Device ▸ Control… opens the selected device’s panel in a window of its own.

It is not a modal, and not one window per device. Several devices get driven at once — a stage and the meter reading it — and the console has to keep refreshing while they are, for the same reason failures are not modal. So the window is plesty-lib’s docking Shell, opened inside the console’s own application: panels dock, tab, drag out to a second screen, and the arrangement is remembered per bench.

The controls are the device’s, not ours. A panel is built from the schema the device declares, fetched with describe: dtype picks the widget, min_value/max_value bound it, options makes a drop-down, read_only a readout, unit the suffix, description the tooltip. The console holds no per-device GUI code and no table of limits, so nothing here can drift from what the instrument actually accepts. Operations become buttons; the ones a module declares lifecycle — homing, reset, a dark reference — are left off.

A panel starts read-only. The device may be driven by a running experiment and the console is a second client on it: the connection claims no resources, and every editor and button stays disabled until Take control is ticked on that panel.

  • Opening the window opens what is running. View ▸ Control window gives every device whose server is answering a panel, so the window means “control this bench” rather than “whatever you opened earlier”. Devices already open keep theirs, so it can be used again to pick up one started since; a device that refuses is named in the status bar instead of quietly missing, and an empty window says why it is empty.

  • Asking twice for one device focuses its panel rather than adding a second — two panels would poll one instrument on two connections.

  • A device that stops leaves its panel in place with the reason in its title bar. Stop/start is constant while working, and removing the panel would throw away an arrangement the operator built.

  • One worker thread per panel, so a server that stopped answering costs its own panel and not the window; its title-bar dot goes red.

  • An older device server — one on a plesty-lib whose describe carries no schema — is reported as such instead of showing an empty form.

  • Closing the console closes this window first, releasing the connections before the servers they point at are stopped.

The same panels are opened from another machine by plesty-bench control, driven through the agent’s device relay (The agent and the client).

Why closing the console stops the fleet

Closing the window stops what it manages — every running server and job — after a confirmation that lists them. Nothing should be left unmanaged behind a window that is gone, and an accidental close must not take a bench down silently: hence the confirmation, and hence fleet run as the answer for a bench that must keep its servers up without a window.

Testing

tests/test_view.py runs the whole thing offscreen (QT_QPA_PLATFORM=offscreen): it builds the window on a bench with the fake server, starts and stops a device through the menu actions, and checks the table, the log tab, the jobs and reports tabs, the dialogs’ conversions and the job dialog following a real job. Skipped when PySide6 is absent.