Console: the job-channel ping (MF-11, 7 October 2026). Every 0.3.21 app names itself on its wake long-poll (machine, version, last job); /wake records one row per machine in relay_wake_seen before it holds; the Machines tab reads "job channel polled N ago, last job X" and, after 15 minutes without a poll, "job channel silent since <time>, last job X" in red, whatever the uploads say (PC 2 lost power at 10:46Z and nothing said so until a person read the intake). Tests in relay/test/wake.test.mjs.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
388a239f47
commit
d945d4cda5
5 changed files with 119 additions and 8 deletions
|
|
@ -65,6 +65,10 @@ Wake (`api/wake.mjs`, 0.3.6, 5 October 2026): `GET /wake?since=<stamp>` is publi
|
|||
|
||||
An unknown hostname that registers appears in the feed with a "name this machine" box, or `node tools/relay.mjs name <hostname> PC2`. PC1 is DESKTOP-KMCV30N.
|
||||
|
||||
## The job-channel ping (MF-11, 7 October 2026)
|
||||
|
||||
PC 2 lost power at 10:46Z on 7 October 2026 and nothing said so until a person read the intake. From 0.3.21 every app wake request carries `machine=<id8>&v=<version>&job=<last job id>`; `/wake` records one row per machine in `relay_wake_seen` before it holds (`relay/lib/wake.mjs`: `recordSeen`, `seenList`, `pingState`, `PING_SILENT_S` = 900 s); the console's Machines tab reads it as "job channel polled N ago, last job X" and, after 15 minutes without a poll, "job channel silent since <time>, last job X" in red, whatever the uploads say (`api/console.mjs` `poll`, `ui.html`). No secret travels: id8 is what the console already shows. Test: `node --test relay/test/wake.test.mjs`.
|
||||
|
||||
## Deploy
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
// POST sync {items:[...]} bulk upsert by key
|
||||
import { neon, authed, readJson, str, iso } from '../lib/relay.mjs';
|
||||
import { kv, kvNum, lastMatch, FAULT, parseLabel, parseMinerTail, parseHeader, parseAppTail, STALE_S, markStale } from '../lib/parse.mjs';
|
||||
import { seenList, pingState, PING_SILENT_S } from '../lib/wake.mjs';
|
||||
import { parseRecords, aggregate } from '../lib/ember.mjs';
|
||||
|
||||
const json = (res, status, obj) => { res.status(status).setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify(obj)); };
|
||||
|
|
@ -117,6 +118,10 @@ async function machines(sql) {
|
|||
}
|
||||
}
|
||||
await otaMemo(sql, groups);
|
||||
// the job channel's ping (MF-11, 7 October 2026): every 0.3.21 app names itself on its wake long-poll; a machine whose
|
||||
// last poll is over PING_SILENT_S old reads "job channel silent since <time>, last job <name>" on its card
|
||||
let pings = new Map();
|
||||
try { pings = new Map((await seenList(sql)).map(p => [p.machine, p])); } catch (e) { /* the cards render without it */ }
|
||||
const now = Date.now();
|
||||
const out = [...groups.values()].map(g => {
|
||||
const id8 = g.id.startsWith('legacy:') ? null : g.id;
|
||||
|
|
@ -127,6 +132,7 @@ async function machines(sql) {
|
|||
name: id8 ? (NAMES[id8] || (g.platform === 'mac' ? 'Mac' : 'PC ' + id8)) : g.id.slice(7),
|
||||
host: [...g.hosts][0] || null, legacy: !id8,
|
||||
last_seen: g.last_seen, silent_s, silent: silent_s === null || silent_s > SILENT_S,
|
||||
poll: id8 ? pingState(pings.get(id8), now) : null,
|
||||
stopped: (silent_s === null || silent_s > SILENT_S) && g.app && g.app.stopped ? g.app.stopped : null,
|
||||
app_version: (g.app && g.app.version) || (g.header && g.header.version) || null,
|
||||
app: g.app ? { at: g.app.at, status: g.app.status || null, ota: g.app.ota || null, power_cap: g.app.power_cap || null, jobs_file: g.app.jobs_file || null, jobs: g.app.jobs || [] } : null,
|
||||
|
|
@ -149,9 +155,9 @@ async function machines(sql) {
|
|||
// several Mac installs (test runs): the newest is "Mac", the others keep their id
|
||||
let macSeen = false;
|
||||
for (const m of out) if (m.platform === 'mac' && m.name === 'Mac') { if (macSeen) m.name = `Mac ${m.id8}`; macSeen = true; }
|
||||
for (const n of ['Mac', 'PC 1', 'PC 2', "Sam's Mac"]) if (!out.some(m => m.name === n)) out.push({ id: n, id8: Object.keys(NAMES).find(k => NAMES[k] === n) || null, platform: n.endsWith('Mac') ? 'mac' : 'win', name: n, host: null, legacy: false, last_seen: null, silent_s: null, silent: true, app_version: null, app: null, node: {}, cards: [], hash_total: 0, accepted_total: 0, faults: 0, telemetry: [], events: [], gpus: [], gpus_at: null, hotplug: [], labels: [], runs: [], never: true });
|
||||
for (const n of ['Mac', 'PC 1', 'PC 2', "Sam's Mac"]) if (!out.some(m => m.name === n)) { const id8 = Object.keys(NAMES).find(k => NAMES[k] === n) || null; out.push({ id: n, id8, platform: n.endsWith('Mac') ? 'mac' : 'win', name: n, host: null, legacy: false, last_seen: null, silent_s: null, silent: true, poll: id8 ? pingState(pings.get(id8), now) : null, app_version: null, app: null, node: {}, cards: [], hash_total: 0, accepted_total: 0, faults: 0, telemetry: [], events: [], gpus: [], gpus_at: null, hotplug: [], labels: [], runs: [], never: true }); }
|
||||
out.sort((a, b) => order(a) - order(b) || (b.last_seen || '').localeCompare(a.last_seen || ''));
|
||||
return { machines: out.filter(m => NAMED.has(m.name) || (m.silent_s !== null && m.silent_s < HIDE_AFTER_S)), silent_after_s: SILENT_S, hide_after_s: HIDE_AFTER_S };
|
||||
return { machines: out.filter(m => NAMED.has(m.name) || (m.silent_s !== null && m.silent_s < HIDE_AFTER_S)), silent_after_s: SILENT_S, hide_after_s: HIDE_AFTER_S, poll_silent_after_s: PING_SILENT_S };
|
||||
}
|
||||
|
||||
// ---- jobs ---------------------------------------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@
|
|||
// GET wake?since=<stamp>[&hold=45] holds up to 45 s; answers as soon as the stored stamp differs from `since`,
|
||||
// else the unchanged stamp at the deadline; without `since` the current stamp
|
||||
// at once. Reply: {ok, stamp, at, added, changed, held_ms}.
|
||||
// [&machine=<id8>&v=<version>&job=<id>] the ping (MF-11, 0.3.21): the app names itself on every request and the
|
||||
// relay keeps one row per machine (relay_wake_seen: last poll, version, last job);
|
||||
// the console shows "silent since <time>, last job <name>" after PING_SILENT_S
|
||||
// without a poll. No secret: id8 is what the console already shows.
|
||||
// POST wake {stamp, added?} records the stamp (publish-jobs.sh, after a verified deploy). Reply:
|
||||
// {ok, stamp, at, added}.
|
||||
// Storage: table relay_wake, one row per stamp, the newest row is the stamp; the first POST creates the table.
|
||||
|
|
@ -18,6 +22,8 @@ export const HOLDS_MAX = 64; // held requests per instance; above it
|
|||
const EMPTY = { stamp: '', at: null, added: [] };
|
||||
|
||||
export const validStamp = s => typeof s === 'string' && s.length > 0 && s.length <= STAMP_MAX && /^[\w.:-]+$/.test(s);
|
||||
export const validMachine = s => typeof s === 'string' && /^[0-9a-f]{8}$/.test(s);
|
||||
export const PING_SILENT_S = 900; // 15 minutes without a poll from a machine: its job channel is dead
|
||||
|
||||
const isoOf = v => { if (!v) return null; const d = new Date(String(v).replace(' ', 'T').replace(/([+-]\d\d)$/, '$1:00')); return isNaN(d) ? String(v) : d.toISOString(); };
|
||||
const clip = (v, max) => (v === undefined || v === null ? '' : String(v)).slice(0, max);
|
||||
|
|
@ -70,13 +76,53 @@ export async function latest(sql) {
|
|||
}
|
||||
}
|
||||
|
||||
/** The ping table: one row per machine, the last poll, the app version and the last job it named. */
|
||||
export async function ensureSeenTable(sql, state = {}) {
|
||||
if (state.seenTable) return;
|
||||
await sql(`CREATE TABLE IF NOT EXISTS relay_wake_seen (machine text PRIMARY KEY, last_seen timestamptz NOT NULL DEFAULT now(), version text NOT NULL DEFAULT '', last_job text NOT NULL DEFAULT '')`);
|
||||
state.seenTable = true;
|
||||
}
|
||||
|
||||
/** Records one poll from a machine (the GET's machine, v and job); invalid or absent ids record nothing. */
|
||||
export async function recordSeen(sql, { machine, version, job }, state = {}) {
|
||||
if (!validMachine(machine)) return false;
|
||||
await ensureSeenTable(sql, state);
|
||||
await sql(`INSERT INTO relay_wake_seen (machine, last_seen, version, last_job) VALUES ($1, now(), $2, $3)
|
||||
ON CONFLICT (machine) DO UPDATE SET last_seen = now(), version = EXCLUDED.version, last_job = CASE WHEN EXCLUDED.last_job = '' THEN relay_wake_seen.last_job ELSE EXCLUDED.last_job END`,
|
||||
[machine, clip(version, 32), clip(job, 80)]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Every machine's last poll: [{machine, last_seen, version, last_job}]; [] before the first ping. */
|
||||
export async function seenList(sql) {
|
||||
try {
|
||||
const rows = await sql(`SELECT machine, last_seen, version, last_job FROM relay_wake_seen ORDER BY machine`);
|
||||
return rows.map(r => ({ machine: String(r.machine), last_seen: isoOf(r.last_seen), version: String(r.version || ''), last_job: String(r.last_job || '') }));
|
||||
} catch (e) {
|
||||
if (/relay_wake_seen/.test(String(e.message)) && /does not exist/.test(String(e.message))) return [];
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the console says about a machine's job channel from its last ping: {silent, silent_s, since, last_job, version,
|
||||
* never}. Silent after PING_SILENT_S without a poll; `since` is the last poll's time (what the card prints after
|
||||
* "silent since"); never = no ping on record.
|
||||
*/
|
||||
export function pingState(seen, now = Date.now(), silentAfterS = PING_SILENT_S) {
|
||||
if (!seen || !seen.last_seen) return { silent: true, never: true, silent_s: null, since: null, last_job: '', version: '' };
|
||||
const t = Date.parse(seen.last_seen);
|
||||
const silent_s = isNaN(t) ? null : Math.max(0, Math.round((now - t) / 1000));
|
||||
return { silent: silent_s === null || silent_s > silentAfterS, never: false, silent_s, since: isNaN(t) ? seen.last_seen : new Date(t).toISOString(), last_job: seen.last_job || '', version: seen.version || '' };
|
||||
}
|
||||
|
||||
export const ipOf = req => String(req.headers['x-forwarded-for'] || (req.socket && req.socket.remoteAddress) || '').split(',')[0].trim();
|
||||
|
||||
/**
|
||||
* The handler with its dependencies injected: sql (Neon), authed (the relay's check), readJson, json (the reply
|
||||
* writer), limiter, holds (a counter shared by the instance), now and sleep (the test's fake clock).
|
||||
*/
|
||||
export function makeHandler({ sql, authed, readJson, json, limiter = new RateLimit(), holds = { n: 0 }, now = Date.now, sleep }) {
|
||||
export function makeHandler({ sql, authed, readJson, json, limiter = new RateLimit(), holds = { n: 0 }, now = Date.now, sleep, state = {} }) {
|
||||
return async function handler(req, res) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
const q = req.query || {};
|
||||
|
|
@ -84,6 +130,8 @@ export function makeHandler({ sql, authed, readJson, json, limiter = new RateLim
|
|||
if (req.method === 'GET') {
|
||||
const wait = limiter.take(ipOf(req));
|
||||
if (wait !== null) { res.setHeader('Retry-After', String(wait)); return json(res, 429, { ok: false, error: 'rate limited', retry_after: wait }); }
|
||||
// the ping lands before the hold, so a machine is "seen" the moment it asks, not 45 s later
|
||||
if (q.machine !== undefined) { try { await recordSeen(sql, { machine: clip(q.machine, 16), version: q.v, job: q.job }, state); } catch (e) { /* the wake still answers */ } }
|
||||
const since = clip(q.since, STAMP_MAX * 2);
|
||||
const asked = q.hold === undefined ? HOLD_MS_MAX : (Number(q.hold) || 0) * 1000;
|
||||
const holdMs = holds.n >= HOLDS_MAX ? 0 : Math.min(HOLD_MS_MAX, asked);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// node --test relay/test/wake.test.mjs (no dependencies, no database: a fake sql, a fake clock, a fake sleep)
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { waitForChange, RateLimit, validStamp, makeHandler, HOLD_MS_MAX, HOLDS_MAX } from '../lib/wake.mjs';
|
||||
import { waitForChange, RateLimit, validStamp, makeHandler, HOLD_MS_MAX, HOLDS_MAX, recordSeen, seenList, pingState, PING_SILENT_S, validMachine } from '../lib/wake.mjs';
|
||||
|
||||
// a clock that only moves when something sleeps on it
|
||||
function clock(start = 1_000_000) {
|
||||
|
|
@ -12,10 +12,21 @@ function clock(start = 1_000_000) {
|
|||
// the smallest Neon stand-in: one table relay_wake, newest row first, no table until the first CREATE
|
||||
function fakeDb() {
|
||||
const rows = [];
|
||||
const seen = [];
|
||||
let created = false;
|
||||
let seenCreated = false;
|
||||
let seq = 0;
|
||||
const nowIso = () => '2026-10-07T13:00:00.000Z';
|
||||
const sql = async (query, params = []) => {
|
||||
if (/CREATE TABLE/.test(query)) { created = true; return []; }
|
||||
if (/CREATE TABLE IF NOT EXISTS relay_wake /.test(query)) { created = true; return []; }
|
||||
if (/CREATE TABLE IF NOT EXISTS relay_wake_seen/.test(query)) { seenCreated = true; return []; }
|
||||
if (/INSERT INTO relay_wake_seen/.test(query)) {
|
||||
const [machine, version, last_job] = params;
|
||||
const hit = seen.find(r => r.machine === machine);
|
||||
if (hit) { hit.last_seen = nowIso(); hit.version = version; if (last_job) hit.last_job = last_job; } else seen.push({ machine, last_seen: nowIso(), version, last_job });
|
||||
return [];
|
||||
}
|
||||
if (/SELECT machine, last_seen, version, last_job FROM relay_wake_seen/.test(query)) { if (!seenCreated) throw new Error('relation "relay_wake_seen" does not exist'); return seen.map(r => ({ ...r })); }
|
||||
if (!created) throw new Error('relation "relay_wake" does not exist');
|
||||
if (/INSERT INTO relay_wake/.test(query)) {
|
||||
const [stamp, meta] = params;
|
||||
|
|
@ -28,7 +39,7 @@ function fakeDb() {
|
|||
if (/SELECT stamp, at, meta FROM relay_wake/.test(query)) { const r = rows[rows.length - 1]; return r ? [r] : []; }
|
||||
throw new Error('unexpected query ' + query);
|
||||
};
|
||||
return { sql, rows, set: (stamp, added = []) => { rows.push({ id: ++seq, stamp, at: '2026-10-05 12:00:00+00', meta: { added } }); created = true; } };
|
||||
return { sql, rows, seen, set: (stamp, added = []) => { rows.push({ id: ++seq, stamp, at: '2026-10-05 12:00:00+00', meta: { added } }); created = true; } };
|
||||
}
|
||||
|
||||
function res() {
|
||||
|
|
@ -150,3 +161,40 @@ test('handler: a database error is a 500 with its message, not a hang', async ()
|
|||
const r = res(); await h(req('GET', { since: 'S1' }), r);
|
||||
assert.deepEqual([reply(r).code, reply(r).error], [500, 'connection refused']);
|
||||
});
|
||||
|
||||
test('MF-11: a wake request that names its machine is recorded as a ping with the version and the last job; a bad id records nothing', async () => {
|
||||
const db = fakeDb(); const c = clock();
|
||||
db.set('2026-10-07T13:00:00Z.aaaa');
|
||||
const h = makeHandler({ sql: db.sql, authed: () => 'token', readJson: async () => ({}), json, limiter: new RateLimit({ now: c.now }), now: c.now, sleep: c.sleep });
|
||||
let r = res();
|
||||
await h(req('GET', { since: '2026-10-07T13:00:00Z.aaaa', hold: '0', machine: '1ccfe586', v: '0.3.21', job: 'update-now-0319-1ccfe586' }), r);
|
||||
assert.equal(reply(r).code, 200);
|
||||
assert.deepEqual(db.seen, [{ machine: '1ccfe586', last_seen: '2026-10-07T13:00:00.000Z', version: '0.3.21', last_job: 'update-now-0319-1ccfe586' }]);
|
||||
// a later poll without a job keeps the last job; a bad machine id is ignored
|
||||
r = res(); await h(req('GET', { hold: '0', machine: '1ccfe586', v: '0.3.21' }), r);
|
||||
assert.equal(db.seen[0].last_job, 'update-now-0319-1ccfe586');
|
||||
r = res(); await h(req('GET', { hold: '0', machine: 'DROP TABLE', v: 'x' }), r);
|
||||
assert.equal(reply(r).code, 200);
|
||||
assert.equal(db.seen.length, 1);
|
||||
assert.equal(await recordSeen(db.sql, { machine: 'zz', version: '', job: '' }), false);
|
||||
assert.equal(validMachine('1ccfe586'), true);
|
||||
assert.equal(validMachine('1CCFE586'), false);
|
||||
const list = await seenList(db.sql);
|
||||
assert.equal(list[0].machine, '1ccfe586');
|
||||
assert.equal(list[0].last_seen, '2026-10-07T13:00:00.000Z');
|
||||
// before the first ping the list is empty rather than an error
|
||||
assert.deepEqual(await seenList(fakeDb().sql), []);
|
||||
});
|
||||
|
||||
test('MF-11: the console reads a machine as silent after 15 minutes without a poll, with the time of the last one and the last job', () => {
|
||||
const seen = { machine: '1ccfe586', last_seen: '2026-10-07T10:46:00.000Z', version: '0.3.19', last_job: 'update-now-0319-1ccfe586' };
|
||||
const at = t => Date.parse(t);
|
||||
assert.equal(PING_SILENT_S, 900);
|
||||
const fresh = pingState(seen, at('2026-10-07T10:50:00Z'));
|
||||
assert.equal(fresh.silent, false);
|
||||
assert.equal(fresh.silent_s, 240);
|
||||
const quiet = pingState(seen, at('2026-10-07T11:01:01Z'));
|
||||
assert.deepEqual(quiet, { silent: true, never: false, silent_s: 901, since: '2026-10-07T10:46:00.000Z', last_job: 'update-now-0319-1ccfe586', version: '0.3.19' });
|
||||
assert.equal(pingState(seen, at('2026-10-07T11:00:59Z')).silent, false, 'exactly inside the window');
|
||||
assert.deepEqual(pingState(null, at('2026-10-07T11:00:00Z')), { silent: true, never: true, silent_s: null, since: null, last_job: '', version: '' });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -268,11 +268,16 @@ input[type=text]{width:100%}
|
|||
function machineCard(mc) {
|
||||
const n = mc.node || {};
|
||||
const silentTxt = mc.never ? 'never seen' : mc.stopped ? `stopped (${esc(mc.stopped.reason)}) ${ago(mc.stopped.at)} ago` : (mc.silent ? `silent ${ago(mc.last_seen)}` : `seen ${ago(mc.last_seen)} ago`);
|
||||
// the job channel's ping (MF-11): a dead app polls nothing, and this line says so after 15 minutes, with the last job it ran
|
||||
const p = mc.poll;
|
||||
const pollTxt = !p ? '' : p.never ? `<div class="note" style="margin-top:8px;color:var(--ash)">job channel: no poll on record (an app before 0.3.21, or never started)</div>`
|
||||
: p.silent ? `<div class="note" style="margin-top:8px;color:var(--red)">job channel silent since ${when(p.since)} (${ago(p.since)} ago)${p.last_job ? `, last job ${esc(p.last_job)}` : ', no job on record'}${p.version ? ` <small>(app ${esc(p.version)})</small>` : ''}: nothing reaches this machine until the app runs again</div>`
|
||||
: `<div class="note" style="margin-top:8px">job channel polled ${ago(p.since)} ago${p.last_job ? `, last job ${esc(p.last_job)}` : ''}</div>`;
|
||||
const tele = mc.telemetry && mc.telemetry.length ? `<div class="tw"><table class="t"><thead><tr><th>telemetry</th><th class="num">p95</th><th class="num">max</th><th class="num">cap</th><th class="num">GPU</th><th class="num">mem</th></tr></thead><tbody>${mc.telemetry.map(t => `<tr><td>${esc(t.name)}</td><td class="num">${t.p95_w} W</td><td class="num">${t.max_w} W</td><td class="num">${t.cap_w} W</td><td class="num ${t.max_gpu_c >= 85 ? 'bad' : ''}">${t.max_gpu_c} C</td><td class="num ${t.max_mem_c >= 95 ? 'bad' : ''}">${t.max_mem_c} C</td></tr>`).join('')}</tbody></table></div>` : (mc.cards.length ? `<div class="note" style="margin-top:8px">${mc.app ? 'no stability lines yet (power and temperatures come from nvidia-smi every 5 min; none on a Mac)' : 'the app log is not in the intake (an app before 0.3.3)'}</div>` : '');
|
||||
const events = mc.events && mc.events.length ? `<div style="margin-top:8px">${mc.events.map(e => `<div class="ev"><span class="t">${when(e.at)}</span><span class="k ${e.kind}">${e.kind}</span><span>${esc(e.text)}</span></div>`).join('')}</div>` : '';
|
||||
return `<article class="card ${mc.stopped ? 'stopped' : mc.silent ? 'red' : 'live'}">
|
||||
<h3>${esc(mc.name)}<span class="id">${esc(mc.id8 || '')}${mc.host ? ' ' + esc(mc.host) : ''}${mc.legacy ? ' (old launcher)' : ''}</span><span class="seen ${mc.silent ? 'bad' : ''}">${silentTxt}</span></h3>
|
||||
${mc.never ? '<div class="note" style="margin-top:8px">nothing from this machine in the log intake in the last 48 h</div>' : kvs([
|
||||
${pollTxt}${mc.never ? '<div class="note" style="margin-top:8px">nothing from this machine in the log intake in the last 48 h</div>' : kvs([
|
||||
['app', mc.app_version ? esc(mc.app_version) : '<small>not in the intake</small>'],
|
||||
['ota', mc.app && mc.app.ota ? esc(mc.app.ota.state) : (mc.app_version ? 'no update pending' : '?'), mc.app && mc.app.ota ? ((mc.app.ota.state === 'updated' || mc.app.ota.state === 'current') ? 'good' : 'warm') : ''],
|
||||
['node', n.version ? esc(n.version) : '?'],
|
||||
|
|
@ -302,7 +307,7 @@ input[type=text]{width:100%}
|
|||
const live = j.machines.filter(mc => !mc.silent).length;
|
||||
$('#n-machines').textContent = `${live}/${j.machines.length}`;
|
||||
$('#n-machines').className = 'n' + (j.machines.some(mc => mc.silent && !mc.never && mc.name !== 'Mac') ? ' hot' : '');
|
||||
$('#p-machines').innerHTML = `<div class="note">red after ${j.silent_after_s / 60} min without an upload; unnamed ids drop off after ${j.hide_after_s / 60} min. Hash rate and accepted blocks come from the newest STATUS line per card, height and peers from the node log, power and temperatures from the app's stability lines. Refreshed ${when(j.now)}.</div><div class="grid">${j.machines.map(machineCard).join('')}</div>`;
|
||||
$('#p-machines').innerHTML = `<div class="note">red after ${j.silent_after_s / 60} min without an upload; the job channel reads silent after ${(j.poll_silent_after_s || 900) / 60} min without a poll; unnamed ids drop off after ${j.hide_after_s / 60} min. Hash rate and accepted blocks come from the newest STATUS line per card, height and peers from the node log, power and temperatures from the app's stability lines. Refreshed ${when(j.now)}.</div><div class="grid">${j.machines.map(machineCard).join('')}</div>`;
|
||||
}
|
||||
|
||||
// ---- jobs ----
|
||||
|
|
|
|||
Loading…
Reference in a new issue