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

vidocq-runtime-spi, never vidocq-runtime-devconsole-spi

The -dev module

The live panel — sampled values, charts, actions

The runtime extension, vidocq-runtime-devconsole-spi

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.LivePanel
io.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-module
vidocq-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 of beforeStart, once every pool is open, MansartPoolsLive.clear() first thing in onStop, before any pool closes; the -dev module’s panel only ever reads MansartPoolsLive.pools().

  • A live bean recomputed from the ExtensionContext, when the state is a CDI bean the panel can look up itself: Knock’s KnockLiveBean.of(BeanManager) and Dirac’s DiracLiveBean.of(BeanManager), both called from the live panel’s own start(ExtensionContext context) with context.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 DevConsolePanel, the shape this page opened with. It still works, and third-party extensions may keep it — DevConsolePanel is unchanged. But whatever implements it ships in every binary that has the extension, vidocq:dev or not: prefer a -dev module above, the shape every extension this repository ships now uses, so that the live half of a section is a dev tool that only vidocq:dev ever adds.

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-extension under vidocq-runtime-extensions-microprofile for 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.xml says 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-extension that "in addition to the wrapper exposes a ContainerRequestFilter`": 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.xml and the Javadoc of its module-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.java
public 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.java
module 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.StartupReportContributor
io.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 contribute after every onStart, and the console keeps that instance for the boot; a dev reload creates a new one. So the panel finds what it reads in contribute, with context.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 @Dependent instance, which lookup destroys when contribute returns.

  • No onStop to 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(), then id().

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

contribute(context, section)

Once per boot, on the booting thread, after every onStart.

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 DETAILED level, so that every row reaches the console even on a reload that logs the summary only; what the log prints still follows its own level.

charts()

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.

sample(out)

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.

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

gauge(key, value, unit)

A level that goes up and down: the threads alive, the borrowers waiting.

The number.

gauge(key, value, max, unit)

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 CEILING series draws the max as a dashed rule.

counter(key, total, unit)

A total that only grows during a boot, as the component keeps it: the borrows so far.

The total and +N since last poll; a RATE series plots its growth per second.

duration(key, value)

A duration, such as a mean over the whole boot.

Formatted, never plotted.

text(key, value)

A short text: a state, a mode.

As it is.

absent(key, reason)

A value the panel cannot give now: leak detection off, no borrow yet, a figure the JVM does not publish.

The reason, greyed. Never a zero, which would read as a measure.

table(key, columns, rows)

A small table: the entries of a registry.

A table.

group(name)

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) and RATIO (0 to 1, shown as a percentage: a CPU load is a RATIO gauge 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 as heap.used or mean-borrow. Any other key throws IllegalArgumentException, 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: group on a group throws IllegalStateException. The name is what the page prints, such as audit or G1 Young Generation, neither null nor blank. Calling group again 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 a null duration 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

AREA

A gauge, filled down to zero.

A level: the heap in use, the active connections.

STACKED

A gauge, filled on top of the AREA or STACKED series before it.

One part of a whole: the idle connections over the active ones.

LINE

A gauge, as a line.

A level that is not part of a whole: the borrowers waiting, the threads.

RATE

A counter, as its growth per second between two polls; for a counter of NANOS, the share of wall time spent.

Throughput: borrows per second, timeouts per second, the time a garbage collector took.

CEILING

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 IllegalArgumentException for an id or a key that breaks the key rule, a blank title, no series, the same key twice in the same style, or a STACKED series with no AREA or STACKED series before it. Build the charts in a static final field: 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 connections once, and the page draws it for each pool.

  • A key the sample does not write, or writes as another kind, draws nothing. AREA, STACKED, LINE and CEILING need a gauge, CEILING a gauge with a max, and RATE a 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"))));
}
  • dev only. The console calls actions() in a dev launch only, once per boot, after the report is written. In a test or prod launch it never calls it, the page shows no button, and a request to run one gets 405. A RuntimeException or a LinkageError thrown by actions() leaves the panel without actions.

  • Checked where it is written. The id follows the key rule, the label is not blank, a confirmation is null or a question, and each argument is named once. Build them in a method that reads the panel’s volatile state when it runs, as sample() does.

  • Arguments are strings the console checks first. An argument is either Argument.oneOf(name, label, values…), which the page offers as a list, or Argument.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 with 400 before the action is called: run receives 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. run returns 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; null reads as done. 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 run is logged at WARNING with its class, its stack trace at DEBUG, and the page shows failed: 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. Unlike sample(), an action may do I/O and block: that is what it is for.

  • A confirmation for what cannot be undone. When confirmation is 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.

  • summary is what run used to return: the line the page shows and the console logs, at most 200 characters, null reading as done.

  • body is optional content shown under the line, at most 256 KiB: contentType is text/plain or application/json, the page showing JSON in its JSON viewer, or ActionResult.CSV, text/csv NEW, 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.

  • error is true when the call went through but its outcome is an error of the action’s target, such as a tool returning isError; it is distinct from an exception call throws, which stays the usual 500 with the exception’s simple class name, never its message.

  • details is 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 as Tools: 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; a table with 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 prints configured or not configured and takes no value. sample never writes one.

  • Never render a configuration object’s toString(): Mansart’s PoolConfig prints its password. Write the fields you mean, one by one.

  • A JDBC URL goes through a redactor first. The Mansart pool extension’s JdbcUrls.redact removes the user info (//user:password@, Oracle’s user/password@), through its last @ before the first ? or ; whatever the password holds, and every ?, & or ; entry whose key holds password, passwd, pwd, secret, token or credential, and shows jdbc:<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() is LaunchMode.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.

  • active and idle have maxSize as their max, for the fill bar and the ceiling; waiting is an estimate and has none.

  • leaks is absent, with the reason, while leak detection is off: 0 would claim that leaks were looked for.

  • mean-borrow is a mean over the pool’s whole life: a duration, 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-module
vidocq-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, or stop() called — sample writes 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:

  1. 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 on vidocq-runtime-devconsole-spi.

  2. It is found: the extension class implements DevConsolePanel, or the wrapper’s one class, never a VidocqExtension, is declared with provides io.vidocq.runtime.spi.report.StartupReportContributor in its module-info and in META-INF/services; the wrapper keeps its Java modules workaround, its pom.xml and module-info no 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.

  3. Its id() is stable, lowercase and unique, not startup, config, cdi, logs, tests, jvm or devconsole, and title() names it for a person.

  4. contribute reads memory only, writes a short summary with no configured value, its rows for DETAILED, and its anomalies under a code of its own.

  5. sample reads memory only: no I/O, network, blocking call, application lock, bean creation or logging, and it is never flagged slow.

  6. Its state is one volatile immutable snapshot, assigned once complete, cleared first thing in onStop, read once per call.

  7. No static field holds a Class, a bean or any object of the application.

  8. No secret is ever written: secrets through secret(key, configured) only, no toString() of a configuration object, URLs without their credentials, configuration values in dev only, property keys without their values.

  9. Every key follows [a-z][a-z0-9.-]{0,39}, every series names a value the sample writes with the right kind, a CEILING a gauge with a max, a RATE a counter, and no lifetime mean is plotted; the rows of a group start with its name.

  10. 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.

  11. It is tested against a recording section and a recording sample — in dev and outside, the secrets searched for, the charts against the sample, empty after onStop — and seen once in a real mvn vidocq:dev page.

Next steps NEW