Building web apps with Jnu data binding
Every job you deploy leaves rows in the Scheduler database: the job itself, its slices, its results, its performance figures. Jnu is the part of dcp-client that lets your own programs read those rows. Each query is signed by your identity and checked against the same security policy that the DCP Portal uses, so you see your own data and nothing else, and you never handle database credentials.
In a web page you don’t have to write the queries at all. The Jnu data-binding library, the same library that renders the Jobs page of the Portal, provides a <jnu-record-set> HTML element and a family of jnu-* attributes. You describe the query on the element and mark up where each column goes, and the library fetches, renders, sorts, pages and refreshes.
The division of labour: tags are for showing values that are in the rows: a job’s name, its status, when it started. Anything computed from those values, a count, a percentage, a total, is a few lines of JavaScript that read the same connection the tags use.
This tutorial has two parts:
Part 1 builds a page of fifteen lines that shows the completion percentage of one job and keeps it up to date.
Part 2 grows it into a small dashboard: a sortable, paged table of all your jobs, a sign-in form, and a details panel for the job you click on.
Both pages were run against the live Scheduler to confirm that they work.
💡 The complete code of each part is at the end of that part.
Before you start
You need:
A modern web browser
An API key, or a keystore file, for the identity that deployed your jobs (see getting setup)
Any static web server for the page, for example
python3 -m http.serverin the page’s directory
The identity that signs a query decides what comes back. The Scheduler policy for the jobs table returns only rows whose owner is the signing address, and the tables that hang off a job (jobResults, jobPerfData, jobFinData, activeSlices, jobArguments, jobData, …) are reachable only through a job you own. The only columns you can change are name, description and link.
In practice: query with the API key or identity keystore that deployed the jobs you want to look at. If you get an empty result for a job you know exists, the key is almost always the reason.
Two columns of the jobs table are often confused:
opaqueIdis the public job id, the value ofjob.idin the Compute API and the id shown in the Portal.idis the database primary key. The other tables refer to a job by this number, in theirjobIdcolumn.
The columns used in this tutorial:
Table |
Columns used |
|---|---|
|
|
|
|
The full schema is available at run time: every record set has a schema property, and Object.keys($('#job').schema.tables.jobs) in the browser console is the quickest way to see what else is there.
Part 1: A job completion page
The goal: a page that shows one job’s name, status and the percentage of its slices that have finished, refreshed every ten seconds. Start from an empty HTML file and add the pieces in order.
Step 1: Load the libraries
Two script tags in <head>:
<script src="https://scheduler.distributed.computer/dcp-client/dcp-client.js"></script>
<script
type="module"
src="https://cdn.distributed.computer/components/jnu-databinding.js"
></script>
The first is the DCP client bundle you use for any browser DCP application; it defines the dcp global (dcp.identity, dcp.jnu, dcp.wallet, …) and dcpConfig. The second is the Jnu data-binding library, an ES module served from the DCP CDN. It defines the <jnu-record-set> custom element and loads its own dependencies from the same CDN: dcp-dom-tk.js, date-fns and jnu-databinding.css.
Note
dcp-dom-tk.js installs $ and $$ as shorthand for document.querySelector and querySelectorAll, adds .on()/.off() to every EventTarget, exposes a global modules object, and shows uncaught errors and rejections in a modal. It refuses to load if window.$ is already defined, so the data-binding library can’t share a page with jQuery.
Step 2: Sign in
Every <jnu-record-set> waits for dcp.identity to have a session before it runs its first query, so a page that never sets one shows nothing. The shortest way to set one is a third script tag with the API key in it:
<script>
dcp.identity.set('<api-key>');
</script>
identity.set() accepts anything the Keystore constructor accepts, so a private-key string works as is. As soon as it resolves, every record set on the page that was waiting runs its query.
Anyone who can read the page source can read the key, so this form is only for a key that can do nothing but query, or for a page you don’t publish. Part 2 replaces it with a form that accepts an API key or a keystore file.
Step 3: Find the job
A <jnu-record-set> is one query against one table, described by attributes. This one selects the row of the jobs table whose opaqueId is the job id you want to watch:
<jnu-record-set
id="job"
database="scheduler"
table="jobs"
autofetch
limit="2"
filter-by="opaqueId"
filter="<job-id>"
>
<div>
<template jnu-record-set="job"
><h1>
<span jnu-field="name"></span> (<span jnu-field="status"></span>)
</h1></template
>
</div>
</jnu-record-set>
databaseis the Jnu service name (scheduler,portalorpxAuth) andtablethe table to query.autofetchruns the query as soon as the element is in the document and the identity is set.filter-byandfilterare a one-column equality filter. Anything more complex goes through the element’sselectQueryproperty, which Step 4 uses.A
<template>is stamped out once per row of the result, andjnu-field="column"on the copies is filled in with that column’s value. A template that isn’t under a<tbody>inside the record set needs thejnu-record-set="id"attribute to be found, which is why the<h1>template carries one.
Note
limit="2" rather than 1 for a query that returns one row looks odd. A record set with limit="1" automatically chooses its row on fetch, and as of 2026-10-03 that automatic choice fails on the record set’s first fetch (known issue, expected to be fixed). Any other limit avoids it; the result is one row either way.
Save and open the page now and you have the job’s name and status. The record set is hidden by the bundled stylesheet until its first fetch completes, so the page is blank for a moment rather than showing empty spans.
Step 4: Count the results
The jobs row tells you how many slices a job has (lastSliceNumber) and how many have been handed out (nextSliceNumber - 1), but not how many have come back. That number is the count of jobResults rows that belong to the job. It isn’t a column anywhere, so it isn’t a job for a tag; it’s a function in the script tag from Step 2:
async function showCompletion(job) {
const done = await $('#job').useDb((db) =>
db.count({ table: 'jobResults', prototype: { jobId: job.id } }),
);
const total = job.lastSliceNumber;
$('#completion').textContent =
`${done} of ${total} slices complete (${((100 * done) / total).toFixed(1)}%)`;
}
useDb(callback)hands your callback the same JnuConnectionthe record set queries through, inside the same queue, with the element’sio-busyattribute set for the duration. No second sign-in and no second schema download are needed.count()is aselectvariant that resolves with a plain number.prototypeis the partial row every counted result has to match, here the job’s databaseid.
Call it when the job has rendered, with an onrenderfetch handler on the record set from Step 3, and give the sentence somewhere to go:
<jnu-record-set id="job" … onrenderfetch="showCompletion(this.records[0])">
…
<p id="completion"></p>
</jnu-record-set>
on… attributes on a record set are event handlers, evaluated with this set to the element, like the built-in onclick. The library fires beforefetch, fetch, renderfetch, renderrecord, refresh, renderrefresh, choose, unchoose, ready and others. renderfetch fires once the rows of a fetch are in the DOM, so by then this.records[0] is the job.
Reload the page and the line “10 of 10 slices complete (100.0%)” appears under the job’s name and status.
Step 5: Keep it live
One more line in the script tag from Step 2:
setInterval(() => $('#job').fetch(), 10e3);
Re-fetching the job fires its onrenderfetch handler again, which re-counts the results. Database calls from every record set and useDb() call on a page go through one queue, so a slow response and the next tick don’t race each other.
Part 1: Complete code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Job completion</title>
<script src="https://scheduler.distributed.computer/dcp-client/dcp-client.js"></script>
<script
type="module"
src="https://cdn.distributed.computer/components/jnu-databinding.js"
></script>
<script>
/* The identity that signs the queries. Record sets wait for this before they fetch. */
dcp.identity.set('<api-key>');
/* Finished slices = rows in jobResults for this job. Counting is one query on the same connection. */
async function showCompletion(job) {
const done = await $('#job').useDb((db) =>
db.count({ table: 'jobResults', prototype: { jobId: job.id } }),
);
const total = job.lastSliceNumber;
$('#completion').textContent =
`${done} of ${total} slices complete (${((100 * done) / total).toFixed(1)}%)`;
}
/* Re-read the job every 10 s; onrenderfetch below re-counts the results. */
setInterval(() => $('#job').fetch(), 10e3);
</script>
</head>
<body>
<!-- Look up the job by its public id. There is one match, rendered through the template. -->
<jnu-record-set
id="job"
database="scheduler"
table="jobs"
autofetch
limit="2"
filter-by="opaqueId"
filter="<job-id>"
onrenderfetch="showCompletion(this.records[0])"
>
<div>
<template jnu-record-set="job">
<h1>
<span jnu-field="name"></span> (<span jnu-field="status"></span>)
</h1>
</template>
</div>
<p id="completion"></p>
</jnu-record-set>
</body>
</html>
Part 2: A jobs dashboard
Part 1 watches one job whose id is in the page. The dashboard lists all the jobs the identity owns in a table that can be sorted and paged, lets you click a job to open a details panel with its completion count, and refreshes every ten seconds. The key moves out of the page source and into a sign-in form. Everything from Part 1 still applies; this part adds to it. The split stays the same: tags show the columns, and the one computed number, the completion count, comes from useDb().
Signing in with a form
The form accepts either an API key or a keystore file and passphrase:
async function connect(form) {
const privateKey = form.elements.privateKey.value.trim();
const file = form.elements.keystore.files[0];
if (privateKey) await dcp.identity.set(privateKey);
else if (file) {
const passphrase = form.elements.passphrase.value;
const keystore = await new dcp.wallet.Keystore(
await file.text(),
passphrase,
);
await keystore.unlock(passphrase);
await dcp.identity.set(keystore);
}
// ...
}
A Keystore built from a file is locked; unlock() it first, or pass { passphrase } as the second argument to set(). Until set() resolves, the record sets on the page sit waiting; the moment it does, they fetch.
Note
An application for Portal users rather than API-key holders can call dcp.identity.login() instead, which takes the user through the DCP authentication pages and back, leaving the page with a reduced-privilege proxy key; pages inside the Portal itself get the identity from the parent frame with the inherit-identity attribute on the dcp-client.js script tag. Either way, once a session exists, the record sets behave exactly as described here.
The record set element
The jobs record set now has no filter, so it returns every job the identity owns, ten at a time, newest first:
<jnu-record-set
id="jobs"
database="scheduler"
table="jobs"
autofetch
limit="10"
order-by="startTime"
order-dir="desc"
onchoose="loadResults(this.chosen)"
>
…
</jnu-record-set>
Attribute |
Meaning |
|---|---|
|
Jnu service name: |
|
Table (strictly, security-policy name) to query. |
|
Run the query as soon as the element is in the document, and again whenever |
|
Page size, sort column and |
|
A one-column equality filter, for example |
|
Event handlers, evaluated with |
The element also reports its state as attributes you can style against or read: page, page-count, total-row-count (rows matching the query, regardless of limit), record-count (rows in memory), record-id (the chosen record), io-busy and initialized. The bundled stylesheet hides a record set until it’s initialized and disables pointer events while it’s io-busy.
Rendering rows with templates
A <template> placed as the only child of a <tbody> inside the record set is stamped out once per row, with no jnu-record-set attribute needed, and the jnu-* attributes on the copies are filled in from the record:
<tbody>
<template>
<tr
jnu-attributes="status: status"
onclick="this.jnuRecordSet.choose(this.jnuRecord)"
>
<td jnu-field="name"></td>
<td jnu-field="status"></td>
<td jnu-field="opaqueId" class="mono"></td>
<td jnu-field="startTime" jnu-date-fmt="ago"></td>
<td>
<span
jnu-field="nextSliceNumber"
jnu-render="this.textContent = value - 1"
></span>
/ <span jnu-field="lastSliceNumber"></span>
</td>
<td></td>
</tr>
</template>
</tbody>
jnu-field="column"renders the column’s value into the element’s text, or into itsvaluefor<input>,<select>and<textarea>. Dates are formatted for the user’s locale;jnu-date-fmt="ago"gives “3 days ago” instead, and any other value is passed todate-fnsas a format string.jnu-render="…"is JavaScript run instead of the default rendering, withthisset to the element andfieldName,valueandrecordin scope. Returnfalseto fall through to the default rendering afterwards.jnu-attributes="attr: column, attr2: column2"renders columns into attributes instead of content. Here it copies the job’s status onto the row, so the stylesheet can bold running jobs withtr[status="running"]. On the details panel the same mechanism turns alinkcolumn into anhref.Every element rendered from a record gets
jnuRecordandjnuRecordSetproperties, which is how the row’sonclickchooses itself.
Sorting, paging and refreshing
These need no JavaScript at all:
<thead>
<tr>
<th jnu-order-by="name">Name</th>
<th jnu-order-by="status">Status</th>
…
<th><button jnu-nav="refresh">Refresh</button></th>
</tr>
</thead>
…
<tfoot>
<tr>
<td colspan="6">
<button jnu-nav="prev">‹ Prev</button>
Page
<span jnu-onfetch="this.textContent = this.jnuRecordSet.page"></span> of
<span jnu-onfetch="this.textContent = this.jnuRecordSet.pageCount"></span>
<button jnu-nav="next">Next ›</button>
(<span
jnu-onfetch="this.textContent = this.jnuRecordSet.totalRowCount"
></span>
jobs)
</td>
</tr>
</tfoot>
Clicking a
<th jnu-order-by="column">sorts by that column; clicking it again flips the direction. The stylesheet adds ▲/▼ indicators.jnu-navbuttons move through the pages:first,prev,next,last;pageszon a<select>changes the page size;refreshre-reads the rows currently on screen and re-renders only those that changed, whilereload(or shift-clickingrefresh) re-runs the whole query.The classes
jnu-hide-when-empty-setandjnu-show-when-empty-set, on direct children of the record set (or on its table’s rows and cells), show the table or a “no jobs” message depending ontotal-row-count.
Choosing a record
Elements with jnu-field that are inside the record set but outside any template are bound to the chosen record, the one passed to .choose(record). Until something is chosen, they’re empty; the page uses the record-id attribute to keep the panel hidden until then:
#jobs:not([record-id]) .details {
display: none;
}
<section class="details">
<h2 jnu-field="name"></h2>
<dl>
<dt>Job Id</dt>
<dd jnu-field="opaqueId" class="mono"></dd>
<dt>Status</dt>
<dd jnu-field="status"></dd>
<dt>Started</dt>
<dd jnu-field="startTime"></dd>
<dt>Last update</dt>
<dd jnu-field="lastUpdate" jnu-date-fmt="ago"></dd>
<dt>Link</dt>
<dd>
<a jnu-field="link" jnu-attributes="href: link" target="_blank"></a>
</dd>
…
</dl>
<button jnu-action="unchoose">Close</button>
</section>
jnu-action="unchoose" clears the choice; the chosen row also carries a jnu-chosen-record attribute for styling. If a form in the panel has <input> elements whose name matches a column, the library keeps the chosen record in sync as the user types and jnu-action="save-changes" writes it back; the Portal’s Jobs page uses this to let you edit a job’s name, description and link. This page is read-only and doesn’t use that part.
Counting results in the details panel
The completion count is the same count() query as in Part 1, run for the chosen job instead of a fixed one. The jobs record set’s onchoose handler calls it, and the details panel has an element for the answer:
<dt>Completed</dt>
<dd id="completion"></dd>
async function loadResults(job) {
const jobs = document.getElementById('jobs');
const done = await jobs.useDb((db) =>
db.count({ table: 'jobResults', prototype: { jobId: job.id } }),
);
const total = job.lastSliceNumber ?? 0;
const pct = total > 0 ? ((done / total) * 100).toFixed(1) + '%' : 'N/A';
document.getElementById('completion').textContent =
`${done} / ${total} slices (${pct})`;
}
Note
A page can have more than one record set when a second table’s columns belong on screen as values rather than as a computed number. The Portal’s Jobs page does this with jobPerfData, showing CPU and GPU time beside each chosen job. Elements bind to the nearest enclosing record set; jnu-record-set="id" on an element points it at a different one by id. Two things to know if you go that way: jnu-on* handler attributes are only wired up for elements inside their own record set element or its templates, and a record set with limit="1" automatically chooses its row on fetch, which fails on its first fetch as of 2026-10-03.
Live updates
Once signed in, the page refreshes itself:
setInterval(() => {
const jobs = document.getElementById('jobs');
jobs.refresh();
if (jobs.chosen) loadResults(jobs.chosen);
}, 10e3);
Unlike Part 1, this calls refresh() rather than fetch() on the jobs record set. refresh() re-reads only the records already on screen, by id, and re-renders the ones whose data changed, so a page that’s just watching a running job doesn’t repaint on every tick.
Using the connection directly
useDb() is the general escape hatch, not just for counting: anything the Connection can do, including search, groupCount and the aggregate variants, is available inside its callback. Outside a record set, dcp.jnu.connect('scheduler') gives you the same object once an identity is set.
Part 2: Complete code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>DCP Job Completion</title>
<!-- 1. The DCP client bundle: defines the dcp global (dcp.identity, dcp.jnu, dcp.wallet, ...) and dcpConfig. -->
<script src="https://scheduler.distributed.computer/dcp-client/dcp-client.js"></script>
<!-- 2. Jnu data binding: defines the <jnu-record-set> custom element and the jnu-* attributes.
It pulls in its own dependencies (dcp-dom-tk.js, date-fns, jnu-databinding.css) from the CDN. -->
<script
type="module"
src="https://cdn.distributed.computer/components/jnu-databinding.js"
></script>
<script>
/**
* Every <jnu-record-set> waits for dcp.identity to have a session before it runs its first
* query, so the page shows nothing until this function has run. The identity decides which
* jobs the scheduler returns: only jobs whose owner is this address.
*/
async function connect(form) {
const status = document.getElementById('connect-status');
const privateKey = form.elements.privateKey.value.trim();
const file = form.elements.keystore.files[0];
try {
if (privateKey) await dcp.identity.set(privateKey);
else if (file) {
const passphrase = form.elements.passphrase.value;
const keystore = await new dcp.wallet.Keystore(
await file.text(),
passphrase,
);
await keystore.unlock(passphrase);
await dcp.identity.set(keystore);
} else throw new Error('Enter an API key or choose a keystore file');
} catch (error) {
status.textContent = error.message;
return;
}
form.hidden = true;
document.getElementById('whoami').textContent =
dcp.identity.get().address;
/* Live data: every 10 s, re-read the rows on screen and re-render only the ones that changed. */
setInterval(() => {
const jobs = document.getElementById('jobs');
jobs.refresh();
if (jobs.chosen) loadResults(jobs.chosen);
}, 10e3);
}
/**
* Count the finished slices of the chosen job. There is no column for this on the jobs table: it
* is the number of jobResults rows for the job, so it is one count query on the connection the
* record set already has. Tags are for showing values that are in the rows; anything computed is
* done here in JavaScript.
*/
async function loadResults(job) {
const jobs = document.getElementById('jobs');
const done = await jobs.useDb((db) =>
db.count({ table: 'jobResults', prototype: { jobId: job.id } }),
);
const total = job.lastSliceNumber ?? 0;
const pct = total > 0 ? ((done / total) * 100).toFixed(1) + '%' : 'N/A';
document.getElementById('completion').textContent =
`${done} / ${total} slices (${pct})`;
}
</script>
<style>
body {
font-family: system-ui, sans-serif;
margin: 2em;
}
table {
border-collapse: collapse;
}
th,
td {
padding: 0.3em 0.8em;
border-bottom: 1px solid #ccc;
text-align: left;
}
tbody tr:hover {
background: #eef;
cursor: pointer;
}
tr[status='running'] td {
font-weight: bold;
}
tr[status='cancelled'] td {
color: #999;
}
tbody tr[jnu-chosen-record] {
background: #ffd;
}
.mono {
font-family: monospace;
}
/* The details panel is only meaningful once a row has been chosen */
#jobs:not([record-id]) .details {
display: none;
}
.details {
border: 1px solid #ccc;
padding: 1em;
margin-top: 1em;
max-width: 40em;
}
.details dt {
font-weight: bold;
float: left;
clear: left;
width: 10em;
}
</style>
</head>
<body>
<h1>Job Completion</h1>
<form id="connect" onsubmit="connect(this); return false;">
<p>
API key (private key):
<input name="privateKey" type="password" size="70" />
</p>
<p>
or keystore file:
<input
name="keystore"
type="file"
accept=".keystore,application/json"
/>
passphrase: <input name="passphrase" type="password" />
</p>
<button type="submit">Connect</button>
<span id="connect-status"></span>
</form>
<p>Signed in as <span id="whoami" class="mono"></span></p>
<!-- The jobs record set: one query against the scheduler's jobs table, described by attributes. -->
<jnu-record-set
id="jobs"
database="scheduler"
table="jobs"
autofetch
limit="10"
order-by="startTime"
order-dir="desc"
onchoose="loadResults(this.chosen)"
>
<table class="jnu-hide-when-empty-set">
<thead>
<tr>
<th jnu-order-by="name">Name</th>
<th jnu-order-by="status">Status</th>
<th>Job Id</th>
<th jnu-order-by="startTime">Created</th>
<th>Distributed</th>
<th><button jnu-nav="refresh">Refresh</button></th>
</tr>
</thead>
<tbody>
<!-- One copy of the template is stamped out per row of the result set -->
<template>
<tr
jnu-attributes="status: status"
onclick="this.jnuRecordSet.choose(this.jnuRecord)"
>
<td jnu-field="name"></td>
<td jnu-field="status"></td>
<td jnu-field="opaqueId" class="mono"></td>
<td jnu-field="startTime" jnu-date-fmt="ago"></td>
<td>
<span
jnu-field="nextSliceNumber"
jnu-render="this.textContent = value - 1"
></span>
/ <span jnu-field="lastSliceNumber"></span>
</td>
<td></td>
</tr>
</template>
</tbody>
<tfoot>
<tr>
<td colspan="6">
<button jnu-nav="prev">‹ Prev</button>
Page
<span
jnu-onfetch="this.textContent = this.jnuRecordSet.page"
></span>
of
<span
jnu-onfetch="this.textContent = this.jnuRecordSet.pageCount"
></span>
<button jnu-nav="next">Next ›</button>
(<span
jnu-onfetch="this.textContent = this.jnuRecordSet.totalRowCount"
></span>
jobs)
</td>
</tr>
</tfoot>
</table>
<p class="jnu-show-when-empty-set">No jobs found for this identity.</p>
<!-- Elements outside the template bind to the chosen record -->
<section class="details">
<h2 jnu-field="name"></h2>
<dl>
<dt>Job Id</dt>
<dd jnu-field="opaqueId" class="mono"></dd>
<dt>Status</dt>
<dd jnu-field="status"></dd>
<dt>Started</dt>
<dd jnu-field="startTime"></dd>
<dt>Last update</dt>
<dd jnu-field="lastUpdate" jnu-date-fmt="ago"></dd>
<dt>Link</dt>
<dd>
<a jnu-field="link" jnu-attributes="href: link" target="_blank"></a>
</dd>
<dt>Completed</dt>
<dd id="completion"></dd>
</dl>
<button jnu-action="unchoose">Close</button>
</section>
</jnu-record-set>
</body>
</html>
The same query from Node.js
Nothing in this tutorial is browser-specific except the data binding. A Node.js program uses the same tables through the Jnu API directly: pass the keystore to connect(), select() the job, and count() its results.
const wallet = require('dcp/wallet');
const jnu = require('dcp/jnu');
const db = await jnu.connect('scheduler', await wallet.get('id'));
const job = await (
await db.select({ table: 'jobs', prototype: { opaqueId } })
).getFirst();
const completed = await db.count({
table: 'jobResults',
prototype: { jobId: job.id },
});
console.log(`${completed}/${job.lastSliceNumber}`);
A complete command-line version of this application is in the dcp/jnu API reference.
Further reading
dcp/jnuAPI reference:connect(),ConnectionandResultSet, including thesearch,count,groupCountand aggregate query variants.dcp/identityAPI reference: sessions,set()andlogin().dcp/walletAPI reference: keystores and API keys.The source of the Portal’s Jobs page (view source on https://dcp.cloud/jobs) is a larger example of the same data-binding library, including search forms, editing and a second record set for
jobPerfData.