A panel is a section of the startup report that the dev console also shows live. Your extension already says, once per boot, what it found; as a panel, it also says, about once a second, what it is doing — how many connections are in use, how many requests are waiting — and names the values the page should plot. This page is for the author of an extension. It walks through the Mansart pool panel, the first one shipped, and ends with a checklist.
In short NEW
public final class AcmeCacheExtension implements VidocqExtension, DevConsolePanel {
private static final List<Chart> CHARTS = List.of(
new Chart("entries", "Entries", List.of(Series.area("entries"), Series.ceiling("entries"))));
private volatile AcmeCache cache; // set in onStart, cleared first in onStop
// name(), onStart(...), onStop() as in any extension
public String id() { return "acme-cache"; } // the section, the tab
public void contribute(StartupReportContext context, StartupReportSection section) {
AcmeCache c = cache; // boot facts, once per boot
section.summary(c == null ? "not started" : c.capacity() + " entries max");
}
public List<Chart> charts() { return CHARTS; } // what the page plots
public void sample(PanelSample out) { // live values, every poll
AcmeCache c = cache;
if (c == null) return;
out.gauge("entries", c.size(), c.capacity(), Unit.COUNT)
.counter("hits", c.hits(), Unit.COUNT);
}
}
DevConsolePanel extends StartupReportContributor: a panel is a contributor with two more methods, sample and charts. It lives in the package io.vidocq.runtime.spi.devconsole of the artifact io.vidocq.runtime:vidocq-runtime-devconsole-spi, which the extension module depends on:
<dependency>
<groupId>io.vidocq.runtime</groupId>
<artifactId>vidocq-runtime-devconsole-spi</artifactId>
<version>0.4.0-SNAPSHOT</version>
</dependency>
module io.vidocq.runtime.extensions.jakartaee.web.mansart.pool {
requires transitive io.vidocq.runtime.spi;
requires transitive io.vidocq.runtime.spi.devconsole; // transitive: the exported extension class is a panel
// ...
}
It is a hard requirement, since a class does not load without the interfaces it implements, and a light one: the module holds five small types and brings only io.vidocq.runtime.spi. It is an ordinary API jar, not a dev tool: unlike the console, it ships in the binary with an extension that requires it. The console itself stays the application’s choice: without it, the panel is a contributor like any other, and its sample is never called.
A -dev module, never packaged NEW
The panel above is the all-in-one form: AcmeCacheExtension is both the extension and the panel, and whatever
implements DevConsolePanel ships in every binary that has the extension, whether or not vidocq:dev is ever
run (Vidocq/vidocq#143). Every extension the Vidocq Runtime itself ships — Mansart’s pools, Knock’s health,
Cassini’s REST mounts, Dirac’s metrics, the schema migration extension, the langchain4j-cdi MCP server — instead
splits its live half into a -dev module: a second, small JPMS module next to the runtime extension, that
vidocq:dev alone puts on the child’s module path. The all-in-one form still works, still ships in the binary,
and stays the right shape for an extension of your own that has no packaging concerns of its own to keep small;
the split below is what this repository’s own extensions do, and what a -dev module of a component with a
release cycle of its own — an application’s, or a third-party extension’s — should follow.
Three modules, one section NEW
| Module | Holds | Depends on |
|---|---|---|
The brick |
Its read-only public API — a counter kept in memory, a registry, a snapshot |
Nothing of the console |
The runtime extension |
The integration, the startup-report section (boot facts, every mode), the descriptor |
|
The |
The live panel — sampled values, charts, actions |
The runtime extension, |
The brick never changes for this: it is used outside Vidocq and depends on no Vidocq SPI, in or out of a
-dev module. The runtime extension keeps the startup-report section — it is not a dev tool, it prints in
every mode, production logs included — and drops only the live half, sample/charts/actions, into the
-dev module next to it, same parent, same version, artifact id suffixed -dev, JPMS module name suffixed
.dev: vidocq-runtime-mansart-pool-extension-dev, module io.vidocq.runtime.extensions.jakartaee.web.
mansart.pool.dev. Nothing else ever depends on the -dev module, and it is never a dependency of anything
that gets packaged.
LivePanel NEW
The -dev module declares a io.vidocq.runtime.spi.devconsole.LivePanel as a service, not a
DevConsolePanel:
public interface LivePanel {
String id(); // the section this panel makes live
default void start(ExtensionContext context) {} // once per boot, before the first sample
default void stop() {} // once per boot, when the console stops
default List<Chart> charts() { return List.of(); }
void sample(PanelSample sample);
default List<PanelAction> actions() { return List.of(); }
}
It writes no section of its own: id() names the startup-report section the runtime extension already writes,
and the console pairs the two — the section for the title and the boot facts, the live panel for everything
sampled. Its Javadoc’s rules: start and stop each run once per boot, a dev reload included, in that order
around every sample; the rules of DevConsolePanel still apply to sample — memory only, never blocks,
never creates a bean, no secret in a value. A live panel that has no matching section is never shown
(VIDOCQ-DEVC-008); a section whose own contributor is already a
DevConsolePanel loses to a live panel of the same id
(VIDOCQ-DEVC-009) — never both.
Declared as any service, provides for the module path and the file for the class path:
module io.vidocq.runtime.extensions.jakartaee.web.mansart.pool.dev {
requires io.vidocq.runtime.extensions.jakartaee.web.mansart.pool;
requires io.vidocq.runtime.spi.devconsole;
requires io.vidocq.mansart.pool.api;
requires io.vidocq.mansart.pool.core;
provides io.vidocq.runtime.spi.devconsole.LivePanel
with io.vidocq.runtime.extensions.jakartaee.web.mansart.pool.dev.PoolsLivePanel;
}
src/main/resources/META-INF/services/io.vidocq.runtime.spi.devconsole.LivePanelio.vidocq.runtime.extensions.jakartaee.web.mansart.pool.dev.PoolsLivePanel
The console loads every LivePanel it finds this way (uses io.vidocq.runtime.spi.devconsole.LivePanel; in
its own module-info) and matches each to the section of the same id.
The descriptor NEW
The runtime extension names its -dev companion in one file, so that an application declares nothing and
vidocq:dev finds it on its own:
src/main/resources/META-INF/vidocq/dev-modulevidocq-runtime-mansart-pool-extension-dev
One line, the companion’s artifactId — its groupId and version are the runtime extension’s own, which
vidocq:dev reads from the resolved jar itself. Blank lines and lines starting with # are ignored. A file
holding more than one artifactId, or one that is not a valid Maven identifier, is unreadable: vidocq:dev logs
a warning naming the jar, and that extension gets no companion — it never fails the goal.
What the panel reads NEW
The -dev module never reaches the runtime extension’s implementation packages — those stay closed. The
runtime extension exports one small .live package to its companion only, exports <package>.live to
<the -dev module>;, and nothing else sees it: a qualified export to a module that happens to be absent (every
binary, every launch but vidocq:dev) is legal, so the runtime extension works alone.
// what the runtime extension's module-info.java adds, next to its usual exports
exports io.vidocq.runtime.extensions.jakartaee.web.mansart.pool.live
to io.vidocq.runtime.extensions.jakartaee.web.mansart.pool.dev;
What that package holds is either of two shapes, both public, read-only, and free of any Vidocq SPI type but
ExtensionContext:
-
A holder the extension publishes and clears, when the extension already keeps the state the panel needs in a field: Mansart’s
MansartPoolsLive.publish(…)at the end ofbeforeStart, once every pool is open,MansartPoolsLive.clear()first thing inonStop, before any pool closes; the-devmodule’s panel only ever readsMansartPoolsLive.pools(). -
A live bean recomputed from the
ExtensionContext, when the state is a CDI bean the panel can look up itself: Knock’sKnockLiveBean.of(BeanManager)and Dirac’sDiracLiveBean.of(BeanManager), both called from the live panel’s ownstart(ExtensionContext context)withcontext.beanManager()— recomputed on every boot, nothing published by the extension itself.
Either way, the -dev module’s own class — PoolsLivePanel, HealthLivePanel, RestLivePanel,
MetricsLivePanel — stays package-private: only its LivePanel service registration, never a compile-time
dependency, is what the console needs.
The manifest marking NEW
The -dev module’s pom.xml marks its own jar Vidocq-Dev-Only: true, the block every dev-only module in
this repository carries (Dev tools and binaries):
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<manifestEntries>
<Vidocq-Dev-Only>true</Vidocq-Dev-Only>
</manifestEntries>
</archive>
</configuration>
</plugin>
That one entry is what vidocq:run, the packaging goals and vidocq:checkpom read: whatever the declaring
scope, a jar that carries it never ships, and a project that declares it anyway is warned, never failed.
Where the panel lives NEW
In the brick’s existing runtime extension, the module under vidocq-runtime-extensions that adapts a component — Mansart, Knock, Cassini — to Vidocq, a class-less wrapper included, which then gains its first class (A wrapper without an extension class NEW). Never in the component, and never in an artifact of its own.
|
The all-in-one form still ships in the binary
Everything below this note describes a panel that is its extension’s own |
The runtime extension is the indirection between the component and Vidocq: the layer that configures the component and adapts it. It is the one module that knows both: it configures the component from vidocq.* keys, adapts it to the container and to Chappe, and reads what the component publishes. What the component has to say in the startup report and in the console is one more such adaptation, so it belongs there. Most runtime extensions hold code for that already: fifteen of the eighteen that bring a component in do, and only the Knock, Dirac and Heisenberg wrappers have none yet.
-
Not in the component. A component such as Knock or Mansart is used outside Vidocq and depends on no Vidocq SPI. What a panel needs from it is public, read-only state: counters kept in memory, a registry, a snapshot. When the component does not publish what the panel needs, the component gains that public accessor, and the panel stays in the extension.
-
Not in an artifact of its own. The panel is there exactly when the brick is: the application that adds the extension gets its section and its panel, and has nothing else to know about, to add, or to keep at the same version. The runtime extension exists already for every brick,
vidocq-runtime-knock-health-extensionundervidocq-runtime-extensions-microprofilefor Knock, so a panel never needs a new module, not even for a wrapper that has no class yet.
The console finds a panel the way the report finds a contributor: it keeps the contributors the boot called that are panels, the very instances. There is nothing new to declare, and there are two cases.
The extension class is the panel NEW
An extension with a VidocqExtension class implements DevConsolePanel next to it. It is found with no second declaration, and its section comes with those of the extensions, in the order they start:
public final class MansartPoolExtension implements VidocqExtension, DevConsolePanel {
This is the case of the Mansart pool extension, and of most extensions. The panel reads the fields the extension already fills in its lifecycle, and clears them in its onStop.
A wrapper without an extension class NEW
Some extensions have no Java class at all. The Knock, Dirac and Heisenberg extensions are wrappers: a pom.xml and a module-info that bring the component in, which then integrates through standard CDI and JAX-RS discovery alone.
Their panel goes into the wrapper all the same. For Knock, that is one adapter class, such as KnockHealthPanel, in vidocq-runtime-knock-health-extension itself, declared as a service through ServiceLoader: provides io.vidocq.runtime.spi.report.StartupReportContributor in the module-info, and the same class in META-INF/services. It is never a VidocqExtension, and it never goes into a new artifact beside the wrapper: that layout was weighed against this one and rejected, for the reasons above (Where the panel lives NEW).
Knock’s ADR-002 (docs/adr/ADR-002-vidocq-runtime-integration-strategy.md in the knock repository) is where the class-less shape is written down, and the panel departs from it, deliberately:
-
What the ADR decided. Its decision is option B, a wrapper "without Java code" — "No own Java classes" — over a
KnockExtension implements VidocqExtension, its option A, because in M5 the standard CDI and JAX-RS SPIs did the whole integration: there was no code to write. -
Its principle, which the panel keeps. What only Vidocq needs stays on the Vidocq side and never goes into Knock, so that Knock stays portable. The wrapper’s
pom.xmlsays it in a comment: "Toute logique spécifique vidocq doit aller dans un module séparé pour préserver la portabilité de Knock hors écosystème vidocq" — any logic specific to Vidocq goes into a separate module, to keep Knock portable outside the Vidocq ecosystem. The panel is Vidocq-specific logic on the Vidocq side: Knock is not touched, depends on no Vidocq SPI, and stays usable outside Vidocq. -
What the panel departs from. Option B’s "No own Java classes": the wrapper gains one. That is a decision of the Vidocq maintainers, not a reading of the ADR. Nor is the panel the one later addition the ADR sketches, an optional
vidocq-runtime-knock-secure-extensionthat "in addition to the wrapper exposes aContainerRequestFilter`": that is middleware an application opts into, while a panel is the brick’s own section, there whenever the brick is. Record the departure where the class-less shape is written down when the Knock panel lands: a note in Knock’s ADR-002, and, in the same change, the description and comments of the wrapper’s `pom.xmland the Javadoc of itsmodule-info, which say it has no Java class of its own. Dirac’s and Heisenberg’s say the same, pointing at the same ADR, and change the same way when they gain a panel.
The rest of the wrapper’s shape stays: no VidocqExtension, no lifecycle and no priority, so option A stays rejected.
A wrapper around a component, Ledger, whose @ApplicationScoped LedgerStats bean keeps its counters in memory:
src/main/java/io/vidocq/runtime/extensions/ledger/LedgerPanel.javapublic final class LedgerPanel implements DevConsolePanel {
private static final List<Chart> CHARTS = List.of(
new Chart("writes", "Writes", List.of(Series.rate("appended"), Series.rate("rejected"))),
new Chart("writers", "Writers", List.of(Series.area("writers"), Series.ceiling("writers"))));
/** The component's statistics, found once per boot by contribute(); read by sample(). */
private volatile LedgerStats stats;
@Override
public String id() {
return "ledger";
}
@Override
public String title() {
return "Ledger";
}
@Override
public void contribute(StartupReportContext context, StartupReportSection section) {
LedgerStats found = context.lookup(LedgerStats.class).orElse(null); // @ApplicationScoped
stats = found;
if (found == null) {
section.summary("no ledger deployed");
return;
}
section.summary(found.journals().size() + " journals, " + found.maxWriters() + " writers max")
.list("journals", found.journals());
}
@Override
public List<Chart> charts() {
return CHARTS;
}
@Override
public void sample(PanelSample out) {
LedgerStats s = stats;
if (s == null) {
return;
}
out.counter("appended", s.appended(), Unit.COUNT)
.counter("rejected", s.rejected(), Unit.COUNT)
.gauge("writers", s.writers(), s.maxWriters(), Unit.COUNT);
}
}
It is declared twice, as every service contributor is (Contributing to the startup report): provides for the module path, where the JDK ignores META-INF/services in a named module, and the file for the class path.
src/main/java/module-info.javamodule io.vidocq.runtime.extensions.ledger {
requires transitive com.example.ledger;
requires io.vidocq.runtime.spi.devconsole;
provides io.vidocq.runtime.spi.report.StartupReportContributor
with io.vidocq.runtime.extensions.ledger.LedgerPanel;
}
src/main/resources/META-INF/services/io.vidocq.runtime.spi.report.StartupReportContributorio.vidocq.runtime.extensions.ledger.LedgerPanel
Every wrapper has the standard layout: module-info.java in src/main/java, compiled with the code, and the tests on the module path. (The Knock, Dirac and Heisenberg wrappers used to keep their module-info.java in a source root of their own, src/main/module-info, compiled late in prepare-package; that workaround was removed in 2026-10.) The first class therefore goes into src/main/java next to the others, and its provides into the wrapper’s module-info.java.
What changes for a service:
-
No lifecycle. The core creates the instance once per boot, calls its
contributeafter everyonStart, and the console keeps that instance for the boot; a dev reload creates a new one. So the panel finds what it reads incontribute, withcontext.lookup(Type.class), which returns the bean of the default qualifier. Keep a normal-scoped bean (@ApplicationScoped), whose client proxy stays valid while the container runs, or values copied from it; never a@Dependentinstance, whichlookupdestroys whencontributereturns. -
No
onStopto clear its fields. None is needed: a stopping boot withdraws its report first, so that the console samples no contributed panel any more, and closes the console’s listener before any other extension stops. -
Its place in the report comes after the sections of the extensions, by
order(), thenid().
A wrapper that only has boot facts to show implements StartupReportContributor alone, declared the same way: every section is a panel of the console, with its boot facts. Knock’s KnockHealthPanel is such a case for now, a class of the wrapper like any other: its checks can be listed by probe, but their status is only known by running them, which a sample must not do (sample() reads memory only NEW), until Knock keeps its last result in memory. Implement DevConsolePanel when there is something to sample.
Two halves: boot facts and live values NEW
| Method | Called | For |
|---|---|---|
|
Once per boot, on the booting thread, after every |
The boot facts: what the boot found, as rows, lists and secrets, and what is wrong with it, as anomalies. They are both the section of the report written to the log and the boot facts of the panel: one contract, two renderings. Whenever the console is on, Vidocq gives the contributors the |
|
Once per boot, after the report is written. |
The charts the page draws: which values, how. It may depend on what the boot found. None by default. |
|
On every poll of the page, about once a second per open tab: on the console’s request threads, possibly several at once, and only once the report of the boot is written. |
The live values: what changes while the application runs. |
The boot facts are drawn from what contribute writes: the summary is the headline of the panel, the anomalies come first, and the rows follow as a table. When the sample has groups, a row whose key is a group’s name is shown in that group’s header, and a row whose key starts with the name and a space goes to that group’s card, without the name: the Mansart pool panel writes @Default and @Default size, and the card of the pool @Default shows its URL in its header and size in its boot facts. The other rows stay in the panel’s own table.
Links NEW
A section can offer to open something the application serves, such as a user interface or a document: section.link("Swagger UI", "default", "/openapi/ui/"). The log prints a row, the label then the absolute URL; the console shows a button under the panel’s title that opens it in a new tab.
A panel never writes a URL. It names a listener and a path, and Vidocq joins them with the address that listener really bound, as it does for route(): the http section declares the application’s listeners. A link whose listener no section declared is printed as its path and gets no button. The page only makes a link of an absolute http or https URL, opened with rel="noopener noreferrer".
A GET route with no template variable, such as GET /openapi, is a URL a browser can open as it is: its URL in the route table is a link too.
An extension that serves a page on a listener it does not describe itself advertises it instead, with ChappeMountPoint.instance().advertise(listener, label, path) from its onStart. The sections that describe that listener offer to open it without knowing the extension: the Swagger UI extension advertises /openapi/ui/, and the rest panel shows the button.
Values NEW
PanelSample takes each value under a key, and every method returns a scope, so that calls chain. A value is one of these kinds:
| Method | For | The page shows |
|---|---|---|
|
A level that goes up and down: the threads alive, the borrowers waiting. |
The number. |
|
A level with the most it can reach: the active connections of a pool of 8, the heap out of its maximum. |
The number and a fill bar; a |
|
A total that only grows during a boot, as the component keeps it: the borrows so far. |
The total and |
|
A duration, such as a mean over the whole boot. |
Formatted, never plotted. |
|
A short text: a state, a mode. |
As it is. |
|
A value the panel cannot give now: |
The reason, greyed. Never a zero, which would read as a measure. |
|
A small table: the entries of a registry. |
A table. |
|
One named item among several of the same kind: a pool, a garbage collector. |
A card per group, with its values, its charts and its boot facts. |
-
Units.
COUNT(a number of things),BYTES(shown as a size),NANOS(shown as a duration; the rate of a counter of nanoseconds is the share of wall time spent) andRATIO(0 to 1, shown as a percentage: a CPU load is aRATIOgauge with a max of 1). -
Keys. A lowercase letter, then up to 39 lowercase letters, digits, dots or hyphens:
[a-z][a-z0-9.-]{0,39}, such asheap.usedormean-borrow. Any other key throwsIllegalArgumentException, which fails the sample, so that the mistake shows at once in development.PanelSample.requireKey(key)checks one. A key written twice in one scope keeps the last value. -
Groups. One level only:
groupon a group throwsIllegalStateException. The name is what the page prints, such asauditorG1 Young Generation, neithernullnor blank. Callinggroupagain with the same name returns the same scope, and groups keep the order of their first call. -
What the console keeps. 64 groups, 64 values per scope, 100 rows of 8 columns per table; the rest is dropped and the sample flagged
truncated. Every string is cleaned of control characters and cut at 200 characters, as the report does. A gauge that is not a finite number is shown as absent, a max that is not a finite number above zero is left out, and anullduration or text is absent. -
No time, no rate. The console stamps each poll with its own clock and the page computes the rates. A panel never adds a timestamp nor divides by time, so that a missed poll or a debugger paused on a breakpoint does no harm. A counter that goes down reads as a count that started again: its rate has a gap there.
Charts NEW
A Chart has an id, a title and the series it plots, in drawing order. Each Series names a value key and a style:
| Style | Plots | For |
|---|---|---|
|
A gauge, filled down to zero. |
A level: the heap in use, the active connections. |
|
A gauge, filled on top of the |
One part of a whole: the idle connections over the active ones. |
|
A gauge, as a line. |
A level that is not part of a whole: the borrowers waiting, the threads. |
|
A counter, as its growth per second between two polls; for a counter of |
Throughput: borrows per second, timeouts per second, the time a garbage collector took. |
|
The max of a gauge, as a dashed rule. |
What a level can reach: the size of a pool over its connections. |
new Chart("connections", "Connections", List.of(
Series.area("active"), Series.stacked("idle"), Series.line("waiting"), Series.ceiling("active")))
-
Checked where it is written. The constructors throw
IllegalArgumentExceptionfor an id or a key that breaks the key rule, a blank title, no series, the same key twice in the same style, or aSTACKEDseries with noAREAorSTACKEDseries before it. Build the charts in astatic finalfield: a mistake then fails the extension’s tests, not the page. -
Repeated per group. A chart plots the values of the panel’s own scope, and is repeated for every group that holds at least one of its keys: the Mansart pool panel declares
connectionsonce, and the page draws it for each pool. -
A key the sample does not write, or writes as another kind, draws nothing.
AREA,STACKED,LINEandCEILINGneed a gauge,CEILINGa gauge with a max, andRATEa counter. -
The console keeps the history, five minutes of it, on the server; a panel never keeps one. It is filled by a thread of the console’s own, once a second, whether or not a page is open — see Where the history lives.
A lifetime mean — a mean over the whole boot, such as Mansart’s mean borrow time — is sampled as a duration and shown as a number, never plotted. After an hour, one more minute of slow borrows barely moves a mean of the whole hour: its curve would stay flat while the last minutes get worse, and a flat line reads as "nothing happens". Plot what changes between two polls, a level or the rate of a counter. A mean over the last minutes would need the component to keep a total of the time spent, and a series style that divides its growth by the count’s: neither exists yet.
Actions NEW
In a dev launch, a panel may offer to do something, such as setting a log level or migrating a database again. It lists what with actions(), one PanelAction each, and the page shows a button per action next to the panel’s links, with a field per argument:
@Override
public List<PanelAction> actions() {
return List.of(
new PanelAction("clear", "Clear the cache", "Drop every entry of the cache?", arguments -> {
AcmeCache c = cache;
if (c == null) return "not started";
return c.clear() + " entries dropped";
}),
new PanelAction("set-level", "Set level", null, List.of(
PanelAction.Argument.matching("logger", "Logger", "[A-Za-z0-9_.$]{1,120}"),
PanelAction.Argument.oneOf("level", "Level", "ERROR", "WARNING", "INFO", "DEBUG")),
arguments -> levels.set(arguments.get("logger"), arguments.get("level"))));
}
-
devonly. The console callsactions()in adevlaunch only, once per boot, after the report is written. In atestorprodlaunch it never calls it, the page shows no button, and a request to run one gets405. ARuntimeExceptionor aLinkageErrorthrown byactions()leaves the panel without actions. -
Checked where it is written. The id follows the key rule, the label is not blank, a confirmation is
nullor a question, and each argument is named once. Build them in a method that reads the panel’svolatilestate when it runs, assample()does. -
Arguments are strings the console checks first. An argument is either
Argument.oneOf(name, label, values…), which the page offers as a list, orArgument.matching(name, label, regex), a text field whose value must match the regular expression as a whole; 200 characters at most either way. A request whose keys are not exactly the declared names, or whose value is not accepted, is refused with400before the action is called:runreceives the declared names only, each with an accepted value. -
Never a path, a class or a URL. An action acts on what its panel already holds; an argument picks among those things, a pool by name, a level. It never takes a file path, a class name or a URL to act on, and keep its pattern as narrow as the value it takes.
-
One short line back.
runreturns what the page shows and the console logs at INFO,Vidocq dev console: action acme-cache/clear by 127.0.0.1: 12 entries dropped;nullreads asdone. It is cleaned and cut at 200 characters, like every text a panel writes, and must never carry a secret. -
A failure shows its class only. An exception thrown by
runis logged at WARNING with its class, its stack trace at DEBUG, and the page showsfailed: IllegalStateException, never the message, which may carry a secret. -
It runs on its own thread. A virtual thread of the console, one action at a time per panel: a second request while one runs gets
409. Past 60 seconds the page stops waiting and says so; the action goes on, and its outcome shows when it ends. Unlikesample(), an action may do I/O and block: that is what it is for. -
A confirmation for what cannot be undone. When
confirmationis set, the page asks it inline, next to the button, and sends only on a second click. -
The console guards the request, the panel never parses one. Who may run an action — the console’s own page only, behind an origin check and the token of the boot — is decided in one place: see the checks of an action request.
A JSON argument NEW
PanelAction.Argument.json(String name, String label, String schema) takes a JSON document rather than a string:
schema is a JSON Schema, as the text of a JSON object of at most 32 KiB, checked when the argument is built, or
the constructor throws. One action has at most one json argument, beside any oneOf and matching ones, and the
value reaches call as its JSON text, in the same Map<String, String>. accepts(String) stays the only check:
for a json argument it means "parses as a JSON object", whatever its length.
The page renders it as a form generated from the schema when the schema allows one, an object of scalars one level
down included (the generated form NEW), and as the
JSON editor NEW otherwise, which colours, checks and completes the
value against the schema, whose keywords that page lists; the console never validates the value against the schema
itself — the action’s target does. The MCP inspector’s own tool action is a short example
(TestPanels.InspectorPanel’s `tool.weather):
static final String SCHEMA = "{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},"
+ "\"required\":[\"city\"]}";
new PanelAction("tool.weather", "Weather", null,
List.of(PanelAction.Argument.json("arguments", "Arguments", SCHEMA)), arguments -> {
runs.add(arguments);
return new PanelAction.ActionResult("ok in 3 ms", "application/json",
"{\"temp\":21}", false, "{\"request\":{\"method\":\"tools/call\"}}");
}, "Tools", "The weather in a city.\nIn Celsius.");
A string property whose schema also says "format": "textarea" NEW is a field of several lines in that
form rather than a one-line one: four lines of monospace text to start with, taller when dragged, its value sent as
any other string’s.
Such a field whose schema also says "contentMediaType": "text/x-query" and "x-language": "<id>" NEW
is the query editor, which colours, completes and checks a query with the vocabulary of the panel’s language of that
id, and one that says "x-parameters-of": "<query property>" NEW is the JSON editor of that query’s
parameters, its schema following the query (A query language for a panel).
The Mansart Data panel’s JDQL tab uses both for its statement and its parameters
(A JDQL console for Mansart Data):
{"type": "object",
"properties": {"query": {"type": "string", "format": "textarea", "contentMediaType": "text/x-query",
"x-language": "jdql"},
"params": {"type": "string", "format": "textarea", "x-parameters-of": "query"}},
"required": ["query"]}
Such a field whose schema also says "contentMediaType": "text/csv" NEW gets a Choose file input next
to it: the file picked is read by the browser and replaces the field’s value, nothing being sent until the form is.
It is read as UTF-8, then again as Windows-1252 when it is not UTF-8 (as a spreadsheet of a decimal-comma locale
saves CSV), the line under the input saying read as Windows-1252 (not UTF-8). While the field still shows the file
unedited, the form sends the file’s own text, its \r\n line ends kept. The same file can be chosen again; a file
whose reading ends after the form was sent is dropped (the form was sent before the file was read: choose it again). A file larger than 60 KiB is not read, the file is larger than 60 KiB showing under the input. The
console’s 64 KiB limit on a request stays the last word, and a CSV text grows a little once sent as JSON. The Mansart
Data panel’s Import CSV uses it
(CSV export and import for Mansart Data):
{"type": "object",
"properties": {"entity": {"type": "string", "enum": ["Product"]},
"csv": {"type": "string", "format": "textarea", "contentMediaType": "text/csv"}},
"required": ["entity", "csv"]}
A structured result NEW
The 7-argument constructor, PanelAction(id, label, confirmation, arguments, call, group, description), takes a
Function<Map<String, String>, ActionResult> call in place of run: call returns an
ActionResult(summary, contentType, body, error, details) rather than one line.
-
summaryis whatrunused to return: the line the page shows and the console logs, at most 200 characters,nullreading asdone. -
bodyis optional content shown under the line, at most 256 KiB:contentTypeistext/plainorapplication/json, the page showing JSON in its JSON viewer, orActionResult.CSV,text/csvNEW, which the page shows as text with a Download button: the body saved by the browser as<action id>-<yyyyMMdd-HHmmss>.csv(the id’s characters outside[A-Za-z0-9._-]replaced by-), without a request. The Mansart Data panel’s Export CSV answers so. -
erroristruewhen the call went through but its outcome is an error of the action’s target, such as a tool returningisError; it is distinct from an exceptioncallthrows, which stays the usual500with the exception’s simple class name, never its message. -
detailsis optional JSON, at most 256 KiB, shown folded under "Exchange" — the MCP inspector puts the exact JSON-RPC request and response there.
A body or details longer than 256 KiB is truncated, ending with … truncated at 256 KiB.
The old constructors keep working unchanged: PanelAction(id, label, confirmation, arguments, run) and
PanelAction(id, label, confirmation, run), with run still a Function<Map<String, String>, String>, wrap it as
args → ActionResult.of(run.apply(args)) internally, and run() still returns a function to the summary line, so
an action written before this section existed answers exactly {"result": "…"}, as before.
The records themselves gained components, though: PanelAction is now
(id, label, confirmation, arguments, call, group, description) and PanelAction.Argument
(name, label, allowedValues, pattern, schema). Code that builds actions and calls run() is unaffected; a record
deconstruction pattern such as case PanelAction(var id, var label, var confirmation, var arguments, var run), or
reflection over the record components, written against the old records must be updated. equals now compares the
internal call, and each constructor wraps an old run anew, so two actions built from the same run are no
longer equal.
Groups and descriptions NEW
Two more optional fields, both on every constructor:
-
group, at most 40 characters, such asTools: the page shows each group as a sub-tab of the panel, in the order each group is first seen, after a Monitoring sub-tab that keeps everything else — the summary, the links, the ungrouped actions, the values, the charts and the boot facts. A group’s sub-tab picks one action at a time from a list, shows its form, then its result in a block of its own; atablewith a replay column moves out of Monitoring to the group sub-tabs. A panel with no grouped action has no sub-tab and looks as before. -
description, at most 2,000 characters, line breaks kept, such as a tool’s own description: shown under the action’s label.
Past ten actions of a group, the page adds a text filter above its list; past ten ungrouped actions, one above the
panel’s own buttons. A panel keeps 128 actions at most; an id seen again, or one past the 128th, is dropped, as an
id actions() returns twice always was.
A replay column NEW
A table column headed PanelSample.REPLAY_COLUMN ("replay") turns each of its cells into a "Replay" button on
the page: the cell is the id of an action of the same panel, a space, then a JSON object of its arguments by name,
such as tool.weather {"arguments":{"city":"Paris"}}. The button fills that action’s form with those arguments and
sends nothing; the user still has to submit. A value of "*" inside such a cell is left empty instead, for the
user to type again — how the MCP inspector’s history marks an argument it masked as a secret.
The console keeps such a cell whole up to PanelSample.MAX_REPLAY_CELL, 4,096 characters, and empties a longer one
rather than truncate it into something that would not parse. A cell of that column that is not of this form, or
whose id is no action of the panel, is shown as text, cut at 200 characters as any other cell; the column keeps its
header unless at least one of its cells is a Replay button.
When the panel’s actions have groups, the table is shown in the group sub-tabs rather than in Monitoring, each sub-tab keeping the rows whose cell names one of its own actions. A row whose cell names no action of the panel — one a dev reload orphaned, or a cell emptied for its length — is left out there.
The rules NEW
Each has its reason. The console enforces what it can; the rest is up to the panel.
sample() reads memory only NEW
No I/O, no network, no blocking call, no lock the application may hold, no bean created, nothing logged; well under a millisecond.
No bean created is the part that is easy to break. A panel that reads CDI beans keeps their Bean metadata, resolved once in onStart, and asks the bean’s Context for the instance it already has, with the single-argument Context.get, which returns null rather than creating one; holding a client proxy obtained at boot and calling it from sample creates the bean on the first poll of the page. McpExtension, in vidocq-runtime-langchain4j-cdi-mcp-extension, does it that way for a concrete reason: creating McpSessionManager starts its mcp-session-cleanup thread, so a panel that merely observes would start a thread, every second, on an application that asked for nothing (the mcp panel). A bean with no instance has no number to give, so the value is written absent with the reason — no request served yet, not created yet — never a zero.
Why: the page polls about once a second, per open tab, on the request threads, several at once. A panel that ran a query, called a service or ran the health checks on each poll would make the console a load generator, and its figures would measure the console. A panel that took a lock the application holds could stall the application’s own threads. A value that only I/O could give is left out, or written absent with the reason; when it matters, the component keeps it in memory and the panel reads that. contribute follows the same rule, for the report’s sake. The console times every sample and flags one above 5 ms slow.
State is published through a volatile immutable snapshot NEW
What sample reads is one volatile field that holds an immutable value — a record, a List.copyOf — assigned once complete, and cleared first thing in onStop, before what it refers to is closed. sample reads the field once, into a local variable, and works from that.
Why: the polls arrive on Chappe’s virtual threads, while the boot thread fills the extension’s fields and, on a dev reload, tears the extension down. A volatile field makes what the boot thread wrote visible to the request threads; an immutable value never changes under the reader, so a poll never walks a collection that onStop is clearing (a ConcurrentModificationException); one read gives one consistent state, where two reads could straddle a reload; and clearing it before closing means a late poll finds nothing rather than a closed pool.
Nothing of the application outlives a reload NEW
No static field holds a Class, a bean, a proxy or any object of the application.
Why: the SPI and the runtime extensions stay in the boot layer across dev reloads, while the application’s modules are loaded again in a new layer. A static field of an extension outlives the reload: holding a Class of the previous application, or an object whose class is one, keeps that whole layer in memory, and hands the next boot objects of classes it no longer uses. Keep per-boot state in instance fields; a static field may keep a plain value, as the console keeps the port it bound.
Secrets never leave, configuration only in dev NEW
-
A secret — a password, a token, a key — is written only as
section.secret(key, configured), which printsconfiguredornot configuredand takes no value.samplenever writes one. -
Never render a configuration object’s
toString(): Mansart’sPoolConfigprints its password. Write the fields you mean, one by one. -
A JDBC URL goes through a redactor first. The Mansart pool extension’s
JdbcUrls.redactremoves the user info (//user:password@, Oracle’suser/password@), through its last@before the first?or;whatever the password holds, and every?,∨entry whose key holdspassword,passwd,pwd,secret,tokenorcredential, and showsjdbc:<sub-protocol>:…for a URL it cannot read: it fails closed. It is package-private in that extension until a second extension needs it, and then moves to the SPI; until then, an extension that shows a URL removes its credentials the same way, and fails closed too. -
A configuration value that may carry one, such as a JDBC URL or a user name, is written only when
context.launchMode()isLaunchMode.DEV. Outside it, write what it is — the database kind,h2 mem,postgresql— which is all a production log needs. -
Driver or client properties: their keys, never their values.
Why: the report goes to logs that are shared, pasted into issues and kept, and the page is served to a browser. A value that was never written cannot leak.
Ids are stable and lowercase NEW
id() is the name of the section and of the panel, such as mansart-pool: lowercase, stable across boots and releases, and unique. startup, config, cdi, logs NEW, tests NEW, jvm and devconsole are the console’s. Chart ids and value keys follow the key rule, and stay the same from one boot to the next.
Why: the id is how a reader finds the section in a log, and how the report keeps a section from appearing twice: a second contributor with the same id is skipped as VIDOCQ-RPT-002, and so is one that takes startup, config, cdi, logs, tests or jvm. The history is keyed by panel, group and value key: a key that changes between two ticks starts a new curve.
A failure costs one sample NEW
A RuntimeException or a LinkageError thrown by sample drops that poll’s live values, never the page: the tab shows the class of the exception, never its message, which may carry a secret, and the boot facts stay. The console logs VIDOCQ-DEVC-005 once per panel and boot, its stack trace at DEBUG, and calls the panel again on the next poll. charts() that throws leaves the panel without charts. A failing contribute costs the section, as for any contributor (VIDOCQ-RPT-001).
Worked example: the Mansart pool panel NEW
MansartPoolExtension, in vidocq-runtime-mansart-pool-extension, opens one pool for vidocq.pool.url and one per vidocq.pool.<name>.url. Its live values are vidocq-runtime-mansart-pool-extension-dev’s `PoolsLivePanel, in the package suffixed .dev; the excerpts below are the two modules' code, following the shape of A -dev module, never packaged.
Step 1: the extension keeps its section; the -dev module makes it live NEW
public final class MansartPoolExtension implements VidocqExtension, StartupReportContributor {
@Override
public String name() {
return "mansart-pool";
}
@Override
public String id() {
return "mansart-pool";
}
@Override
public String title() {
return "Mansart pools";
}
MansartPoolExtension no longer implements DevConsolePanel: charts() and sample(PanelSample) moved out, and its module no longer requires io.vidocq.runtime.spi.devconsole at all — contribute, the boot facts below, is all a StartupReportContributor needs. PoolsLivePanel, in the -dev module, is the LivePanel service that makes the mansart-pool section live; see Step 4: the -dev module’s live values, one group per pool NEW.
Step 2: publish what the live panel reads NEW
One record per open pool, built once when the pool opens, that holds no secret — the URL is already redacted. This is unchanged by the split: it still feeds the boot facts of Step 3: the boot facts NEW.
private record PoolView(String label, boolean isDefault, MansartDataSource pool, String safeUrl, String kind,
boolean urlCredentials, String devService) {}
private volatile List<PoolView> views = List.of();
The pools open in beforeStart, before the container, so that the extensions after this one find them. Once every pool is open, the extension publishes two things: its own views, for contribute, and, to MansartPoolsLive — the small public holder in the module’s qualified-exported .live package — the label and the MansartDataSource of each pool, for the -dev module’s panel:
@Override
public void beforeStart(VaubanContainerBuilder builder) {
List<PoolView> opened = new ArrayList<>();
if (poolConfig != null) {
this.pool = MansartDataSource.of(poolConfig);
// ...
opened.add(view(DEFAULT_LABEL, true, PREFIX, pool));
}
for (Map.Entry<String, PoolConfig> e : namedConfigs.entrySet()) {
MansartDataSource ds = MansartDataSource.of(e.getValue());
// ...
opened.add(view(e.getKey(), false, PREFIX + e.getKey() + ".", ds));
}
views = List.copyOf(opened);
MansartPoolsLive.publish(
opened.stream().map(v -> new MansartPoolsLive.Pool(v.label(), v.pool())).toList());
}
and both are emptied before any pool is closed, the holder first — a dev console poll from that instant reads no pool:
@Override
public void onStop() {
// First, before any pool is closed: a dev console poll from now on reads no pool.
MansartPoolsLive.clear();
views = List.of();
namedWithoutBean = Set.of();
prefillFailed = Set.of();
// ... then close the pools
}
MansartPoolsLive itself, in io.vidocq.runtime.extensions.jakartaee.web.mansart.pool.live, is the whole of what the -dev module ever reads from this extension — a holder, not a proxy, with no lifecycle of its own:
public final class MansartPoolsLive {
/** An open pool and its label, such as {@code @Default}. */
public record Pool(String label, MansartDataSource pool) {}
private static volatile List<Pool> pools = List.of();
public static List<Pool> pools() {
return pools;
}
public static void publish(List<Pool> opened) {
pools = List.copyOf(opened);
}
public static void clear() {
pools = List.of();
}
}
and the module exports that package to its companion only:
// what MansartPoolExtension's module-info.java adds, next to its usual exports
exports io.vidocq.runtime.extensions.jakartaee.web.mansart.pool.live
to io.vidocq.runtime.extensions.jakartaee.web.mansart.pool.dev;
The unnamed pool is labelled @Default, which no pool name can be mistaken for, a pool named default included. Named pools follow in name order.
Step 3: the boot facts NEW
contribute reads the list once, writes the summary at every level, the rows of each pool at DETAILED only, then the anomalies found in onStart:
@Override
public void contribute(StartupReportContext context, StartupReportSection section) {
List<PoolView> opened = views;
if (opened.isEmpty()) {
section.summary("idle: no vidocq.pool[.<name>].url");
return;
}
int max = opened.stream().mapToInt(v -> v.pool().config().maxSize()).sum();
section.summary(opened.size() + (opened.size() == 1 ? " pool (" : " pools (")
+ opened.stream().map(PoolView::label).collect(Collectors.joining(", ")) + "), "
+ max + (max == 1 ? " connection max" : " connections max"));
if (context.verbosity() == Verbosity.DETAILED) {
boolean dev = context.launchMode() == LaunchMode.DEV;
for (PoolView v : opened) {
rows(section, v, dev);
}
}
// ... MANSART-POOL-001 and MANSART-POOL-002
}
Every row of a pool starts with its label, which is how the page puts it on the pool’s card. The URL and the user are configuration values, written in a dev launch only; outside it, the pool’s row names the database kind:
private static void rows(StartupReportSection section, PoolView v, boolean dev) {
PoolConfig c = v.pool().config();
String label = v.label();
section.row(label, dev ? v.safeUrl() : v.kind());
if (dev) {
section.row(label + " user", c.username() != null ? c.username() : "not set");
}
// ... size, timeouts, checks, xa, and the dev service that provided the pool, in dev only
section.secret(label + " password", c.password() != null);
if (v.urlCredentials()) {
section.secret(label + " url credentials", true);
}
// the keys only, never the values: a driver property may be a password
section.list(label + " driver properties", new TreeSet<>(c.driverProperties().keySet()));
}
The H2 example, launched in dev, logs:
mansart-pool Mansart pools | 1 ms
2 pools (@Default, audit), 12 connections max
@Default jdbc:h2:mem:mansart-vidocq-demo;DB_CLOSE_DELAY=-1
@Default user sa
@Default size min idle 0 (boot only), max 8
@Default timeouts acquire PT5S, idle PT10M, lifetime PT30M
@Default checks validation PERIODIC PT1S, leaks off
@Default xa org.h2.jdbcx.JdbcDataSource
@Default password not configured
audit jdbc:h2:mem:mansart-vidocq-audit;DB_CLOSE_DELAY=-1
...
In prod, the same rows read @Default h2 mem, and there is no user row.
Step 4: the -dev module’s live values, one group per pool NEW
PoolsLivePanel, in vidocq-runtime-mansart-pool-extension-dev, is the whole of the live half. It opens no
pool and closes none — it only reads MansartPoolsLive.pools(), the holder Step 2 publishes
and clears. Its sample writes one group per pool, from the pool’s own counters:
MansartDataSource.snapshot() reads them without a lock and without I/O:
public final class PoolsLivePanel implements LivePanel {
@Override
public String id() {
return "mansart-pool";
}
@Override
public void sample(PanelSample sample) {
for (MansartPoolsLive.Pool v : MansartPoolsLive.pools()) {
PoolConfig c = v.pool().config();
PoolMetrics m = v.pool().snapshot();
PanelSample pool = sample.group(v.label())
.gauge("active", m.active(), c.maxSize(), Unit.COUNT)
.gauge("idle", m.idle(), c.maxSize(), Unit.COUNT)
.gauge("waiting", m.waiting(), Unit.COUNT)
.counter("borrows", m.totalBorrows(), Unit.COUNT)
.counter("timeouts", m.totalTimeouts(), Unit.COUNT);
if (c.leakDetectionThreshold().isZero()) {
pool.absent("leaks", "leak detection off");
} else {
pool.counter("leaks", m.totalLeaks(), Unit.COUNT);
}
if (m.totalBorrows() == 0) {
pool.absent("mean-borrow", "no borrow yet");
} else {
pool.duration("mean-borrow", m.meanBorrowDuration());
}
}
}
No start(ExtensionContext) override: MansartPoolsLive is filled and cleared entirely by the runtime
extension’s own beforeStart/onStop, so the panel has nothing to resolve on its own when the console starts
it. Once the holder is cleared — the boot stopped, or a dev reload is between two boots — sample writes no
group at all: an empty loop, not an anomaly.
-
activeandidlehavemaxSizeas their max, for the fill bar and the ceiling;waitingis an estimate and has none. -
leaksis absent, with the reason, while leak detection is off:0would claim that leaks were looked for. -
mean-borrowis a mean over the pool’s whole life: aduration, never a chart.
What the snapshot carries for one pool:
{"name": "@Default", "values": [
{"key": "active", "kind": "gauge", "value": 0, "max": 8, "unit": "count"},
{"key": "idle", "kind": "gauge", "value": 1, "max": 8, "unit": "count"},
{"key": "waiting", "kind": "gauge", "value": 0, "unit": "count"},
{"key": "borrows", "kind": "counter", "value": 1, "unit": "count"},
{"key": "timeouts", "kind": "counter", "value": 0, "unit": "count"},
{"key": "leaks", "kind": "absent", "reason": "leak detection off"},
{"key": "mean-borrow", "kind": "duration", "nanos": 867083}]}
The panel’s documentation also says how not to read these gauges: maxSize - active - idle is not free capacity, since a borrower still opening its connection counts in none of them, and a snapshot is not atomic, since each figure is read on its own.
Step 5: two charts, repeated per pool NEW
charts() moved to PoolsLivePanel too — the same two charts, unchanged:
private static final List<Chart> CHARTS = List.of(
new Chart("connections", "Connections", List.of(Series.area("active"), Series.stacked("idle"),
Series.line("waiting"), Series.ceiling("active"))),
new Chart("throughput", "Throughput", List.of(Series.rate("borrows"), Series.rate("timeouts"))));
@Override
public List<Chart> charts() {
return CHARTS;
}
}
connections stacks the idle connections on the active ones under the dashed maxSize, with the waiting borrowers as a line; throughput plots borrows and timeouts per second. Both are declared once and drawn on each pool’s card, since each group holds their keys.
PoolsLivePanel’s `module-info.java declares the service, and nothing else about the runtime extension’s own package:
module io.vidocq.runtime.extensions.jakartaee.web.mansart.pool.dev {
requires io.vidocq.runtime.extensions.jakartaee.web.mansart.pool;
requires io.vidocq.runtime.spi.devconsole;
requires io.vidocq.mansart.pool.api;
requires io.vidocq.mansart.pool.core;
provides io.vidocq.runtime.spi.devconsole.LivePanel
with io.vidocq.runtime.extensions.jakartaee.web.mansart.pool.dev.PoolsLivePanel;
}
and its descriptor is what the runtime extension carries, so that vidocq:dev finds this whole module on its own:
vidocq-runtime-mansart-pool-extension’s `src/main/resources/META-INF/vidocq/dev-modulevidocq-runtime-mansart-pool-extension-dev
Testing a panel NEW
A panel needs neither the console nor a running application to be tested, and, once the boot facts and the
live values are two classes (A -dev module, never packaged), they are tested apart, each in
its own module: call contribute with a section that records what it receives, in the runtime module’s own
tests, and sample with a sample that does the same, in the -dev module’s. The Mansart pool extension keeps
one of each in its test sources — RecordedSection in vidocq-runtime-mansart-pool-extension’s,
`RecordedSample in `vidocq-runtime-mansart-pool-extension-dev’s — copy the one you need into your own tests.
The recording sample checks every key as the console does, and keeps each value as a record, so that a test compares values whole:
final class RecordedSample implements PanelSample {
sealed interface Value permits Gauge, Counter, Elapsed, Text, Absent, Table {}
record Gauge(double value, Double max, Unit unit) implements Value {}
record Counter(long total, Unit unit) implements Value {}
// ... Elapsed, Text, Absent, Table
private final boolean group;
private final Map<String, Value> values = new LinkedHashMap<>();
private final Map<String, RecordedSample> groups = new LinkedHashMap<>();
private PanelSample put(String key, Value value) {
values.put(PanelSample.requireKey(key), value);
return this;
}
@Override
public PanelSample gauge(String key, double value, double max, Unit unit) {
return put(key, new Gauge(value, max, Objects.requireNonNull(unit, "unit")));
}
@Override
public PanelSample group(String name) {
if (group) {
throw new IllegalStateException("groups do not nest");
}
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("a group has a name");
}
return groups.computeIfAbsent(name, n -> new RecordedSample(true));
}
// ... the other kinds, and keys(), value(key), groupNames(), written(name) for the assertions
}
The boot facts, on the extension NEW
The recording section keeps the summary, the rows in order and the anomalies, a secret as the report prints it, configured or not configured; a StartupReportContext record gives the launch mode and the level. The tests drive the extension as Vidocq would — configure, beforeStart, onStart — and read what it wrote, exactly as before the split:
@Test
void inDevTheBootFactsShowTheUrlWithoutItsCredentialsAndTheUser() {
bootTwoPools(); // configure + beforeStart, one pool's URL and password carrying secrets
RecordedSection section = new RecordedSection();
ext.contribute(ReportContext.detailed(LaunchMode.DEV), section);
assertEquals("2 pools (@Default, audit), 12 connections max", section.summary());
assertEquals("jdbc:postgresql://db.example:5432/audit?ssl=true", section.value("audit"));
assertEquals("configured", section.value("audit password"));
for (String secret : List.of("s3cret", "k3y", PASSWORD)) {
assertFalse(section.everything().contains(secret), secret + " written: " + section.everything());
}
}
The live values, on the -dev module’s LivePanel NEW
PoolsLivePanelTest, in vidocq-runtime-mansart-pool-extension-dev, drives PoolsLivePanel directly — never
the extension’s sample/charts, which no longer exist — but still boots the real extension to open real
pools through MansartPoolsLive, the holder Step 2 publishes:
class PoolsLivePanelTest {
private final MansartPoolExtension ext = new MansartPoolExtension();
private final PoolsLivePanel panel = new PoolsLivePanel();
@AfterEach
void stop() {
ext.onStop();
}
/** Configures the extension and opens its pools, as Vidocq does before the container starts: publishes them. */
private void boot(String... keysAndValues) {
ext.configure(config(keysAndValues));
ext.beforeStart(new VaubanContainerBuilder());
}
private RecordedSample sample() {
RecordedSample sample = new RecordedSample();
panel.sample(sample);
return sample;
}
@Test
void afterTheHolderIsClearedTheSampleWritesNoGroup() {
MansartPoolsLive.clear();
assertEquals(List.of(), sample().groupNames(), "a dev reload never shows the previous boot's pools");
}
@Test
void everyChartPlotsValuesTheSampleWrites() {
boot("vidocq.pool.url", h2("default"), "vidocq.pool.audit.url", h2("audit"));
RecordedSample sample = sample();
for (String pool : sample.groupNames()) {
RecordedSample group = sample.written(pool);
for (Chart chart : panel.charts()) {
for (Series series : chart.series()) {
RecordedSample.Value value = group.value(series.key());
if (series.style() == Series.Style.RATE) {
assertInstanceOf(Counter.class, value, chart.id() + " " + series);
} else {
Gauge gauge = assertInstanceOf(Gauge.class, value, chart.id() + " " + series);
if (series.style() == Series.Style.CEILING) {
assertNotNull(gauge.max(), chart.id() + ": a ceiling needs a max");
}
}
}
}
}
}
}
boot(…) still runs the real extension’s configure/beforeStart, since that is what fills
MansartPoolsLive; panel.sample(…) and panel.charts() are what the test actually calls now.
afterTheHolderIsClearedTheSampleWritesNoGroup is the -dev form of "once stopped, the panel shows nothing":
with a holder cleared by the extension’s own onStop, the equivalent check for a live bean recomputed from
ExtensionContext (Knock’s, Dirac’s) is to call the panel’s own stop() and sample again.
An extension that keeps the older, all-in-one DevConsolePanel form (A -dev module, never
packaged) is tested exactly as above, but on the extension itself: ext.sample(sample) and ext.charts(),
with no second test class and no -dev module needed.
|
The tests worth writing for any panel:
-
on the extension — the boot facts in a dev launch, and outside it: no URL, no user, no configured value; a search of everything written for each secret the test configured; below
DETAILED, the summary and the anomalies only; each anomaly, with its message and hint; -
on the
LivePanel(or the extension, for the all-in-one form) — the live values of each group, with a known state: the pool test holds a connection and samples, then releases it and samples again; every chart plots values the sample writes, of the right kind; once the state the panel reads is gone — the holder cleared, orstop()called —samplewrites nothing.
Then see it for real: run mvn vidocq:dev on an application that has your extension — and Chappe, so the
console has something to serve it on — open the URL, and read GET /api/snapshot, the document the page
draws, to check what your panel sends.
Checklist NEW
Before shipping a panel, check that:
-
It lives in the brick’s existing runtime extension, a class-less wrapper included, never in the component and never in a new artifact beside it; the module requires
io.vidocq.runtime.spi.devconsole(transitively when the panel class is exported) and the pom depends onvidocq-runtime-devconsole-spi. -
It is found: the extension class implements
DevConsolePanel, or the wrapper’s one class, never aVidocqExtension, is declared withprovides io.vidocq.runtime.spi.report.StartupReportContributorin itsmodule-infoand inMETA-INF/services; the wrapper keeps its Java modules workaround, itspom.xmlandmodule-infono longer say it has no class, and Knock’s ADR-002 has a note recording the departure from option B. Its section is in the detailed report. -
Its
id()is stable, lowercase and unique, notstartup,config,cdi,logs,tests,jvmordevconsole, andtitle()names it for a person. -
contributereads memory only, writes a short summary with no configured value, its rows forDETAILED, and its anomalies under a code of its own. -
samplereads memory only: no I/O, network, blocking call, application lock, bean creation or logging, and it is never flaggedslow. -
Its state is one
volatileimmutable snapshot, assigned once complete, cleared first thing inonStop, read once per call. -
No
staticfield holds aClass, a bean or any object of the application. -
No secret is ever written: secrets through
secret(key, configured)only, notoString()of a configuration object, URLs without their credentials, configuration values indevonly, property keys without their values. -
Every key follows
[a-z][a-z0-9.-]{0,39}, every series names a value the sample writes with the right kind, aCEILINGa gauge with a max, aRATEa counter, and no lifetime mean is plotted; the rows of a group start with its name. -
Its actions, if any, are harmless on a developer’s machine NEW: each takes only declared arguments, a list of values or a narrow pattern, never a path, a class or a URL, returns one short line with no secret, and is tested with the arguments it refuses.
-
It is tested against a recording section and a recording sample — in
devand outside, the secrets searched for, the charts against the sample, empty afteronStop— and seen once in a realmvn vidocq:devpage.
Next steps NEW
-
The dev console NEW — what the user sees
-
The panel SPI NEW — the types, one by one
-
Contributing to the startup report — the contract a panel extends