Job-Log Card
A job-log card starts a long-running job and shows its progress as it runs. It has a small form and a log pane.
Submitting the form posts to your endpoint, which starts an AsyncJobsMixin job and returns its AsyncJobRef. The
card then streams the job's progress lines and shows the outcome when the job ends. The script is
juneau-joblog.js, and <@page> loads it for you.
<@card type="job-log" id="rebuild" title="Rebuild index">
{ submit: { endpoint: '/ops/jobs/rebuild', submitLabel: 'Rebuild',
fields: [ { name: 'shards', label: 'Shards', type: 'number', default: 4 } ] },
urlParam: 'job' }
</@card>
From Java there is no typed builder; use CardSpec:
page.card(CardSpec.of("job-log", "rebuild").title("Rebuild index")
.body("submit", Map.of("endpoint", "/ops/jobs/rebuild", "submitLabel", "Rebuild"))
.body("urlParam", "job"));
The endpoint
The submit endpoint receives the field values as JSON, plus an idempotencyKey that the card mints fresh for every
submit, and returns AsyncJobRef.of(job). Because the card owns that key, a form field named idempotencyKey is
rejected when the page renders. Report progress with job.progress("..."); each call becomes one line in the log pane,
shown as text. The endpoint may also answer synchronously with an ActionResult instead of a job ref; the card then
shows the outcome without a stream. The job's final ActionResult sets the banner:
| Outcome | Banner |
|---|---|
success | Done. |
failure | The job failed. |
refusal | The job was refused. |
cancelled | Cancelled. |
cancelled-after-effect | Cancelled after the job had already made changes. |
unknown | The job ended with an unknown outcome. |
The result's message, if any, follows the banner text. A success banner is styled as ok and every other outcome
as an error.
Action envelope
By default (submit.envelope: 'flat') the field values are top-level keys of the body. With
submit.envelope: 'action' the card sends the action-dialog shape instead, which needs submit.action:
{ "action": "rebuild", "targetId": "<key>", "idempotencyKey": "<key>", "fields": { "shards": 4 } }
On the page card targetId is the key itself, the self-targeted form an action dialog sends for a modal that is not
bound to a row, so an endpoint that already serves such a dialog can serve the card unchanged. Because the fields are
nested, no field name is reserved in this mode. A number field left empty is sent as null.
Hidden fields
A field with type: 'hidden' renders an <input type="hidden">: no label, not focusable, and never reported as a
missing required field. On the page card it sends its default. In a row-detail region it can take its value from the
row instead (see In a row-detail region).
Output
output writes a value from the terminal result into the card, once, when the job ends:
output: { target: { field: 'answer' }, from: 'row.answer', mode: 'replace', when: 'success' }
| Key | Meaning |
|---|---|
target.field | The element stamped data-juneau-field="<name>". Every input the card builds carries this stamp, so a field can receive the result. |
target.selector | A CSS selector, first match. Exactly one of field and selector is set. |
from | message (default), the result's message; or row.<key>, an own property of the result's row. |
mode | replace (default), or append, which adds a new line after any existing text. An absent value writes nothing. |
when | success (default) writes only on a successful outcome; any writes on every outcome. |
The target is looked up inside the card only (in a region, inside the region), so a result can't reach the rest of the page. An input or textarea gets its value set; any other element gets its text set. The value is never written as markup. A target that isn't found, or a selector that isn't valid, logs E-JS-112 and writes nothing.
Body keys
| Key | Meaning |
|---|---|
submit.endpoint | Required. A same-origin path (no scheme, no // prefix, no .. segment). |
submit.method | POST (default) or PUT. |
submit.envelope | flat (default) or action (see Action envelope). |
submit.action | The action id, matching [A-Za-z][A-Za-z0-9_-]{0,63}. Required with envelope: 'action'. |
submit.fields | Form fields: { name, label, type, placeholder, required, default, source }. name is required and type is text, textarea, number or hidden; any other type, or any other key, fails the page. In the flat envelope a name of idempotencyKey is reserved. source needs a row, so it is only allowed in a row-detail region. A missing required field shows an error banner and focuses the field; nothing is sent. |
submit.submitLabel | The button text (default "Run"). |
height | The log pane height in pixels, at least 80 (default 240). |
urlParam | Mirror the running job id into this URL query parameter (a name matching [A-Za-z][A-Za-z0-9_-]*). |
clearOnSubmit | Clear the previous run's log on a new submit: true (default) or false. |
output | Write a value from the result into the card (see Output). |
Cancel and reload
While a job runs, the button becomes Cancel, which posts to the job's cancelUrl; it is disabled when the
AsyncJobRef has no cancelUrl. Whichever ends the job first, the cancel response or the stream's result, sets the
banner. A cancel that fails shows its message in an error banner and leaves the job running.
The card remembers the running job in the browser's local storage. A fresh attach to a job stream is live-only, so
the card always connects with ?lastEventId=0 added to the streamUrl and gets the progress so far, both straight
after a submit and after a reload. Every progress event carries an SSE id; when the browser reconnects a dropped
stream by itself it sends Last-Event-ID, and the server resumes after the last line shown. The card shows every
progress line it receives and does no client-side de-duplication, so a server that repeats a line shows a visible
repeat rather than hiding it. With urlParam, it reconnects only when the URL names the same job. A
job the server no longer knows (or any stream that closes before delivering a single event, which includes a 429 or 5xx on attach) shows "Job not found; it may have finished or the server restarted." in the log pane
and returns the form to its idle state, and the stored job and URL parameter are cleared. If the stream drops for
good after it had delivered events, a warning banner says the job may still be running and to reload to reattach.
A browser without EventSource shows a warning that live progress is unavailable. New run forgets the finished
job and shows the form again.
In a row-detail region
The same card can live inside a table row's detail panel, where it runs a job for that row. Declare it as the
detail's region with populate: 'job-log'; the region's config takes the card's body keys plus submit.keyUrl and
an optional detail grid:
<@card type="datatables" id="species">{
dataMode: 'client', dataUrl: '/console/ops/jobs/species',
columns: [ { key: 'species', label: 'Species' }, { key: 'available', label: 'Available' } ],
detail: {
endpoint: '/console/ops/jobs/species/{id}',
region: {
id: 'restock-species', populate: 'job-log',
config: {
submit: {
endpoint: '/console/ops/jobs/species/{id}/restock', action: 'restock-species',
keyUrl: '/console/ops/jobs/species/{id}/key', submitLabel: 'Restock',
fields: [ { name: 'count', label: 'How many', type: 'number', default: 1, required: true },
{ name: 'species', type: 'hidden', source: 'row.species' } ]
},
detail: { renderer: 'fieldGrid', fields: [ { data: 'available', label: 'Available' } ] },
output: { target: { field: 'available' }, from: 'row.available' }
}
}
}
}</@card>
From Java, build the same region with JobLogRegion and hand it to the row detail:
RowDetail.create("/console/ops/jobs/species/{id}")
.region(JobLogRegion.create("restock-species")
.submit(JobLogRegion.Submit.create("/console/ops/jobs/species/{id}/restock", "restock-species",
"/console/ops/jobs/species/{id}/key")
.submitLabel("Restock")
.field(JobLogRegion.Field.of("count").label("How many").type("number").defaultValue(1).required(true))
.field(JobLogRegion.Field.of("species").type("hidden").source("row.species")))
.detail(JobLogRegion.Detail.of("fieldGrid", RegionDef.Field.of("available").label("Available")))
.output(JobLogRegion.Output.field("available").from("row.available"))
.toRegion());
In a region:
- The envelope is always
action, andtargetIdis the row's own id, read from the panel's row stamp. It never comes from a field or from row data.envelopemay be omitted; any value other thanactionfails the page. - The key comes from the server. When the panel opens, the card posts to
submit.keyUrl(with{id}replaced) and keeps the submit button disabled until a key for this row and action arrives. A refused or mismatched key shows E-JS-109 and a Retry button. A key is reused only until the server answers; the next run fetches a fresh one. {id}is the only token insubmit.endpointandsubmit.keyUrl, and both are same-origin path templates.urlParamis rejected.source: 'row.<field>'fills a field from the row's data: an own property of the parent row only, never another row, the page or the DOM. The name matches[A-Za-z_][A-Za-z0-9_]{0,63}, with no paths. A hidden field binds at submit, from the row as of the click; a visible field is prefilled when the panel renders and the user's edit wins. Only strings, finite numbers, booleans andnullare allowed: anything else falls back to the field'sdefault, and with nodefaultthe submit is refused with E-JS-110 and nothing is sent.detailpaints a field grid above the form with the namedJuneauViews.helpersrenderer and the row's detail payload. Itsdata-juneau-fieldslots are whatoutput.target.fieldaddresses, so a result can update the panel's own values.- One job per row. While the row's job runs, the row is marked and its row actions are disabled; a row action activated anyway (from a menu, a pill or a detail button) is refused with
row-job-runningand sends nothing. Collapsing the panel leaves the job running; expanding it again re-attaches and replays the log from the start. The re-attached view has no submit until the job ends. While it runs, it shows Cancel when the job has acancelUrl, and a cancel from either view goes throughJuneauViews.jobs.cancel. The outcome still comes from the job's stream. A job can be watched from one re-attached view at a time; a second collapse and re-expand during the same job may not re-attach. A page reload does not re-attach. - The table's poll waits. While the open panel's job runs, the table's timer reload is held and the staleness pill shows Paused, so the panel isn't redrawn under the log.
The card registers its job-log populator whatever the script order. An app that registers its own job-log
populator first keeps it, and one registered later replaces the card's. A page that declares a job-log region
without juneau-views.js and juneau-regions.js logs E-JS-111. A datatables card with a job-log region pulls the
joblog asset pack on its own; you don't add requires.
window.JuneauJobLog.mount(el, config, {signal}) mounts the same row-bound card programmatically into any element
inside a view table row or its detail panel, from a region config. It returns {destroy, cancel, phase}; aborting
signal removes the view but never cancels the job. It throws E-JS-111 when JuneauViews.jobs is missing.
The row-job runtime, helpers.jobButton, and the server side (JobKeyLedger, RowJobSubmit) are covered in
Row Jobs.
Security
The streamUrl and cancelUrl of the returned AsyncJobRef must resolve to the page's own origin, because the cancel
request carries the console's CSRF token. A ref from another origin is refused with E-JS-77. The same check applies
to a job ref read back from local storage after a reload, except that a bad stored ref is silently ignored (no E-JS-77) and the card starts idle. submit.endpoint is checked at render by the same
same-origin-path rule as ChartDef hrefs, and again in the browser before each submit, because that request carries
the CSRF token too: an endpoint that resolves to another origin, contains a backslash or starts with a space is refused
with E-JS-81 and nothing is sent.
In a row-detail region the server's key binding is what makes the request row-bound. The key endpoint issues a key
bound to the action, the row id and the caller, and the submit endpoint verifies all three; a key issued for one row
is refused for any other with the same 409 used for an unknown or expired key. Presets from source and hidden fields
are ordinary request data that the user can edit, so the server must never decide which row to act on, or whether the
caller may act, from them. Use the verified targetId. The key endpoint must apply the same authorization as the
action it keys.
Accessibility
The log pane is role="log" with aria-live="polite" and takes keyboard focus, so a screen reader reads new lines as
they arrive. Errors, including a rejected submit, a missing required field and a failed job, appear in a banner with
role="alert", so they are announced at once, and again if the same error comes back. The ok and warning banners (a successful job, an interrupted stream, a
browser without EventSource) have no role and are announced politely through the console's status region instead.
When the Run/Cancel button changes or disappears, keyboard focus moves to the control that replaces it, or to the log
pane, rather than falling back to the page.
Errors
| Code | When |
|---|---|
| E-JS-77 | The submit response is not a valid AsyncJobRef: jobId or streamUrl is missing, or streamUrl or cancelUrl is not same-origin. Shown in an alert banner above the form, which stays usable. |
| E-JS-78 | The stream's result event could not be parsed, or has no outcome. Shown in the alert banner. |
| E-JS-81 | submit.endpoint is not a same-origin path. Shown in the alert banner; nothing is sent. |
| E-JS-109 | Region mode: the key endpoint refused, or answered with a body whose contractVersion, action, targetId or key doesn't match this row and action. Shown in the alert banner with a Retry button; submit stays disabled. |
| E-JS-110 | A source: 'row.<field>' preset didn't resolve to a string, finite number, boolean or null, and the field has no default; or a hidden field has neither a source nor a default (the message then names only the missing default, since a page card has no row). Nothing is sent. |
| E-JS-111 | A job-log region, JuneauJobLog.mount or helpers.jobButton was used without window.JuneauViews.regions or window.JuneauViews.jobs. |
| E-JS-112 | output.target was not found inside the card (or region), or output.target.selector is not a valid selector. Nothing is written. |
| E-JS-113 | A JuneauViews.jobs hook threw. It is logged and the job's stream carries on. |
Java checks the card body when the page renders: a missing submit.endpoint, an unknown key in the body, in submit
or in a field, a cross-origin submit.endpoint, a submit.method other than POST or PUT, a field without a
name or with a type other than text, textarea, number or hidden, a height below 80, a malformed
urlParam, a clearOnSubmit that isn't a boolean, a field named idempotencyKey in the flat envelope, an
envelope: 'action' without submit.action, a source on the page card, a malformed output, and a src= or
template= on the card all fail the page. A job-log region's config is checked by the same rules (the shared
JobLogChecks), plus a required keyUrl, {id}-only templates and no urlParam.
A rejected submit (for example a job limit) shows the server's message above the form.