openvibers/openvibe.actor Commits

Actor Console: Lately (the events feed) and /me/spend (30 days) (#68)

OP OpenVibers committed 29 days ago · 6072529198 · 4 files, +127 −11

Changes

ddiff --git a/public/css/console.css b/public/css/console.css
iindex 90899a1..50b2c24 100644
---- a/public/css/console.css
++++ b/public/css/console.css
@@@ -57,6 +57,19 @@
.cn-bar i { display: block; width: 100%; max-width: 22px; border-radius: 4px 4px 2px 2px; background: color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent); }
.cn-bar:last-child i { background: var(--accent, #3b82f6); }
.cn-bar small { font-size: 11px; color: var(--text-secondary, #96a7c2); }
++.cn-month .cn-bars { gap: 2px; height: 120px; }
++.cn-month .cn-bar small { display: block; height: 1.3em; line-height: 1.3em; }
++/* The events feed: a coloured dot for how each ended, the text, then who, cost and when. */
++.cn-events { list-style: none; margin: 0; padding: 0; display: grid; }
++.cn-event { display: grid; grid-template-columns: 8px minmax(0, 1fr); column-gap: .6rem; row-gap: .1rem; align-items: center; padding: .45rem 0; border-top: 1px solid var(--border-color, rgba(255,255,255,.08)); }
++.cn-event:first-child { border-top: 0; }
++.cn-event > a, .cn-event > .cn-meta { grid-column: 2; overflow-wrap: anywhere; }
++.cn-event > a { color: var(--text-primary, #e6edf7); }
++.cn-ev-dot { grid-row: 1; width: 8px; height: 8px; border-radius: 50%; background: var(--text-secondary, #96a7c2); }
++.cn-ev-succeeded, .cn-ev-approved { background: var(--success, #22c55e); }
++.cn-ev-failed, .cn-ev-declined { background: var(--danger, #ef4444); }
++.cn-ev-pending { background: var(--accent, #3b82f6); }
++.cn-ev-expired, .cn-ev-cancelled { background: var(--text-secondary, #96a7c2); opacity: .6; }
@media (max-width: 60rem) { .cn-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 40rem) {
ddiff --git a/server/agents/feed.js b/server/agents/feed.js
nnew file mode 100644
iindex 0000000..cc84353
---- /dev/null
++++ b/server/agents/feed.js
@@@ -0,0 +1,35 @@
++'use strict';
++
++/**
++ * The Console's events feed (plan T17 layer 3): what happened lately for one person, newest first, from Actor's own
++ * records only — tasks that finished (with the agent that ran them), and approvals asked and answered. Computers and
++ * robots stay with OpenVibe.Bot; their state is on the Console's own cards.
++ *
++ * lately(s, requester, { approvals, agents, limit }) → [{ at, kind, text, href, state }]
++ */
++const FINISHED = { succeeded: 'Done', failed: 'Didn\'t finish', cancelled: 'Cancelled' };
++const DECIDED = { approved: 'You approved', declined: 'You declined', expired: 'Expired, nobody answered' };
++
++async function lately(s, requester, { approvals = [], agents = [], limit = 10 } = {}) {
++ const names = new Map(agents.map((a) => [a.id, a.name]));
++ const rows = await s.db.many(`SELECT id, task, state, via, finished_at, cost_usd FROM tasks
++ WHERE requester = $1 AND finished_at IS NOT NULL ORDER BY finished_at DESC LIMIT $2`, [requester, limit]);
++ const events = rows.map((t) => ({
++ at: t.finished_at,
++ kind: 'task',
++ state: t.state,
++ text: `${FINISHED[t.state] || t.state}: ${t.task}`,
++ by: names.get(String(t.via || '').replace(/^agent:/, '')) || null, // an agent's run is via `agent:<id>`
++ cost: t.cost_usd == null ? null : Number(t.cost_usd),
++ href: `/tasks/${t.id}`,
++ }));
++ for (const a of approvals) {
++ events.push({ at: a.created_at, kind: 'asked', state: 'pending', text: `Asked for your approval: ${a.summary}`, href: `/me/approvals/${a.id}` });
++ if (a.decided_at && DECIDED[a.state]) {
++ events.push({ at: a.decided_at, kind: 'decided', state: a.state, text: `${DECIDED[a.state]}: ${a.summary}`, href: `/me/approvals/${a.id}` });
++ }
++ }
++ return events.sort((x, y) => (x.at < y.at ? 1 : x.at > y.at ? -1 : 0)).slice(0, limit);
++}
++
++module.exports = { lately };
ddiff --git a/server/http/console-pages.js b/server/http/console-pages.js
iindex cb895e9..6965f65 100644
---- a/server/http/console-pages.js
++++ b/server/http/console-pages.js
@@@ -21,6 +21,7 @@ const catalog = require('../agents/catalog');
const { MODES } = require('../agents/router');
const { createRobots } = require('../agents/robots');
const { createComputers } = require('../agents/computers');
++const feed = require('../agents/feed');
const LIVE = new Set(['queued', 'running', 'verifying']);
const STATE_TEXT = { queued: 'Waiting', running: 'Working', verifying: 'Checking', succeeded: 'Done', failed: 'Didn\'t finish', cancelled: 'Cancelled' };
@@@ -68,16 +69,28 @@ function createConsolePages(ctx, { robots = null, computers = null } = {}) {
<span class="cn-meta">${agent ? html`${agent} · ` : ''}${t.cost_usd == null ? '' : html`${usd(Number(t.cost_usd))} · `}${time(t.created_at)}</span></li>`;
};
-- /** The last seven days of spend as bars (a table for screen readers), tallest day = full height. */
-- function weekChart(days) {
++ /**
++ * Days of spend as bars (a table for screen readers), tallest day = full height. A week labels each day; a month
++ * labels every fifth day by its date.
++ */
++ function spendChart(days, { caption, month = false } = {}) {
const top = Math.max(...days.map((d) => d.usd), 0);
const total = days.reduce((n, d) => n + d.usd, 0);
-- const label = (d) => new Date(`${d.day}T00:00:00Z`).toLocaleDateString('en', { weekday: 'short', timeZone: 'UTC' });
-- return html`<figure class="cn-week"><figcaption>Last 7 days: <b>${usd(total)}</b>, ${String(days.reduce((n, d) => n + d.tasks, 0))} tasks</figcaption>
--<div class="cn-bars" aria-hidden="true">${days.map((d) => html`<span class="cn-bar" title="${label(d)}: ${usd(d.usd)}, ${String(d.tasks)} tasks"><i style="height:${String(top > 0 ? Math.max(4, Math.round((d.usd / top) * 100)) : 4)}%"></i><small>${label(d).slice(0, 2)}</small></span>`)}</div>
++ const date = (d) => new Date(`${d.day}T00:00:00Z`);
++ const label = (d, i) => (month
++ ? (i % 5 === (days.length - 1) % 5 ? String(date(d).getUTCDate()) : '')
++ : date(d).toLocaleDateString('en', { weekday: 'short', timeZone: 'UTC' }).slice(0, 2));
++ const name = (d) => date(d).toLocaleDateString('en', { weekday: 'short', month: 'short', day: 'numeric', timeZone: 'UTC' });
++ return html`<figure class="cn-week${month ? ' cn-month' : ''}"><figcaption>${caption}: <b>${usd(total)}</b>, ${String(days.reduce((n, d) => n + d.tasks, 0))} tasks</figcaption>
++<div class="cn-bars" aria-hidden="true"${month ? raw(` style="grid-template-columns:repeat(${days.length},1fr)"`) : ''}>${days.map((d, i) => html`<span class="cn-bar" title="${name(d)}: ${usd(d.usd)}, ${String(d.tasks)} tasks"><i style="height:${String(top > 0 ? Math.max(4, Math.round((d.usd / top) * 100)) : 4)}%"></i><small>${label(d, i)}</small></span>`)}</div>
<table class="cn-sr"><caption>Spend by day</caption><tbody>${days.map((d) => html`<tr><th scope="row">${d.day}</th><td>${usd(d.usd)}</td><td>${String(d.tasks)} tasks</td></tr>`)}</tbody></table></figure>`;
}
++ /** One line of the events feed. */
++ const eventRow = (e) => html`<li class="cn-event cn-event-${e.kind}"><span class="cn-ev-dot cn-ev-${e.state}" aria-hidden="true"></span>
++<a href="${e.href}">${clip(e.text, 120)}</a>
++<span class="cn-meta">${e.by ? html`${e.by} · ` : ''}${e.cost != null ? html`${usd(e.cost)} · ` : ''}${time(e.at)}</span></li>`;
++
function computersCard(result) {
const body = !bot.computers.enabled ? html`<p class="cn-empty">Not connected to OpenVibe.Bot on this server.</p>`
: !result.ok ? html`<p class="cn-empty">${result.why}</p>`
@@@ -121,7 +134,7 @@ ${c.waiting_for_approval ? html`<span class="pill pill-running">${String(c.waiti
bot.robots.enabled ? fromBot((o) => bot.robots.mine(subject, o)) : { ok: false },
]);
const live = tasks.rows.filter((t) => LIVE.has(t.state));
-- const recent = tasks.rows.filter((t) => !LIVE.has(t.state)).slice(0, 6);
++ const events = await feed.lately(s, requester, { approvals: asked, agents, limit: 8 });
const waiting = asked.filter((a) => a.state === 'pending');
const onMachines = pcs.ok ? pcs.value.computers.reduce((n, c) => n + (c.waiting_for_approval || 0), 0) : 0;
const nWaiting = waiting.length + onMachines;
@@@ -144,11 +157,11 @@ ${nWaiting ? '' : html`<p class="cn-empty">Nothing waits for you.</p>`}
<div class="cn-meter" role="progressbar" aria-label="Free tasks used today" aria-valuemin="0" aria-valuemax="${String(tier.tasksPerDay)}" aria-valuenow="${String(today.tasks)}"><span style="width:${String(pct(today.tasks, tier.tasksPerDay))}%"></span></div>
<p class="cn-stat"><b>${usd(today.usd)}</b> of ${usd(tier.perDayUsd)}</p>
<div class="cn-meter" role="progressbar" aria-label="Spent today" aria-valuemin="0" aria-valuemax="${String(tier.perDayUsd)}" aria-valuenow="${String(today.usd)}"><span style="width:${String(pct(today.usd, tier.perDayUsd))}%"></span></div>
--${weekChart(week)}
--<p class="cn-more"><a href="/tasks">Your tasks</a></p></section>
--<section class="cn-card cn-wide" aria-labelledby="cn-recent"><h2 id="cn-recent">Recent tasks</h2>
--${recent.length ? html`<ul class="cn-tasks">${recent.map(taskRow)}</ul>` : html`<p class="cn-empty">No finished task yet. Give Actor one above.</p>`}
--<p class="cn-more"><a href="/tasks">All your tasks</a></p></section>
++${spendChart(week, { caption: 'Last 7 days' })}
++<p class="cn-more"><a href="/me/spend">Last 30 days</a> · <a href="/tasks">Your tasks</a></p></section>
++<section class="cn-card cn-wide" aria-labelledby="cn-lately"><h2 id="cn-lately">Lately</h2>
++${events.length ? html`<ul class="cn-events">${events.map(eventRow)}</ul>` : html`<p class="cn-empty">Nothing has happened yet. Give Actor a task above.</p>`}
++<p class="cn-more"><a href="/tasks">All your tasks</a> · <a href="/me/approvals">All approvals</a></p></section>
<section class="cn-card" aria-labelledby="cn-agents"><h2 id="cn-agents">Your agents</h2>
${agents.length ? html`<ul class="cn-list">${agents.slice(0, 5).map((a) => html`<li><span class="cn-dot${a.status === 'paused' ? '' : ' on'}" aria-hidden="true"></span><a href="/me/agents/${a.id}"><b>${a.name}</b></a>
<span class="cn-meta">${a.status === 'paused' ? 'Paused' : a.schedule === 'off' ? 'Runs when you run it' : `Every ${a.schedule === 'hourly' ? 'hour' : a.schedule === 'daily' ? 'day' : 'week'}`}${a.last_run_at ? html` · last ran ${time(a.last_run_at)}` : ''}</span></li>`)}</ul>`
@@@ -167,6 +180,27 @@ ${robotsCard(rovers)}
});
});
++ // ── Costs over time: the last 30 days (what Actor keeps) ──
++ r.get('/me/spend', async (req, res) => {
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
++ if (!signedIn(req)) return res.redirect(302, '/auth/login?next=%2Fme%2Fspend');
++ const requester = `user:${req.viewer.subject}`;
++ const days = await store.spendDays(s, requester, store.RETENTION_DAYS);
++ const used = days.filter((d) => d.tasks > 0 || d.usd > 0);
++ const total = days.reduce((n, d) => n + d.usd, 0);
++ const busiest = used.reduce((b, d) => (!b || d.usd > b.usd ? d : b), null);
++ page(req, res, {
++ title: 'Spend', crumbs: [{ label: 'Console', href: '/console' }, { label: 'Spend' }],
++ body: html`<header class="cn-head"><p class="cn-eyebrow">OpenVibe.Actor · console</p><h1>Spend</h1>
++<p class="cn-lede">What your tasks and agents cost, day by day, over the last ${String(store.RETENTION_DAYS)} days (Actor keeps no more).</p></header>
++<section class="cn-card" aria-labelledby="cn-spend-h"><h2 id="cn-spend-h">Last ${String(store.RETENTION_DAYS)} days</h2>
++${spendChart(days, { caption: `Last ${store.RETENTION_DAYS} days`, month: true })}
++<p class="cn-stat">${used.length ? html`<b>${usd(total / used.length)}</b> on an average day with tasks · busiest ${busiest.day}, <b>${usd(busiest.usd)}</b>` : 'Nothing has run in this time.'}</p>
++<p class="cn-note">Today's limit is ${usd(tier.perDayUsd)} and ${String(tier.tasksPerDay)} tasks on the free tier.</p>
++<p class="cn-more"><a href="/console">Back to the console</a> · <a href="/tasks">Your tasks</a></p></section>`,
++ });
++ });
++
return r;
}
ddiff --git a/test/console.test.js b/test/console.test.js
iindex cb0b051..2b0739f 100644
---- a/test/console.test.js
++++ b/test/console.test.js
@@@ -91,6 +91,40 @@ const approvals = require('../server/agents/approvals');
botMode = 'ok';
});
++ await check('Lately: finished tasks with the agent that ran them, approvals asked and answered, newest first', async () => {
++ const dee = t.network.addUser('dee');
++ const who = `user:${dee.subject}`;
++ const agent = await personal.create(t.ctx.s, t.config, who, { name: 'Price watcher', instructions: 'Check prices.', mode: 'balanced', budget_per_run_usd: 0.02, daily_runs_max: 1, schedule: 'off', status: 'active' });
++ const at = (min) => new Date(Date.now() - min * 60_000).toISOString();
++ const ins = async (text, state, finished, via = null) => {
++ const id = t.ctx.s.newId('tsk');
++ await store.insertTask(t.ctx.s, { id, requester: who, task: text, mode: 'balanced', budget_task: 0.05, budget_day: 0.5, created_at: at(finished + 1) });
++ await store.updateTask(t.ctx.s, id, { state, finished_at: at(finished), cost_usd: 0.003, ...(via ? { via } : {}) });
++ };
++ await ins('Old check', 'failed', 30);
++ await ins('Compare the three laptop prices', 'succeeded', 5, `agent:${agent.row.id}`);
++ const asked = await approvals.request(t.ctx.s, { owner: who, kind: 'codes.task', summary: 'Open a pull request on dee/site', payload: { task: 'pr' } });
++ await t.ctx.s.db.query("UPDATE actor_approvals SET state = 'declined', decided_at = $2, created_at = $3 WHERE id = $1", [asked.id || asked.approval.id, at(1), at(10)]);
++ const r = await t.get('/console', { as: dee });
++ const h = r.text;
++ assert.match(h, /<h2 id="cn-lately">Lately<\/h2>/);
++ const order = ['You declined: Open a pull request on dee/site', 'Done: Compare the three laptop prices', 'Asked for your approval: Open a pull request', 'Didn&#39;t finish: Old check'].map((x) => h.indexOf(x));
++ assert.ok(order.every((i) => i > 0), JSON.stringify(order));
++ assert.deepStrictEqual([...order].sort((a, b) => a - b), order, 'newest first');
++ assert.match(h, /Price watcher · \$0\.0030/, 'the agent that ran it, and what it cost');
++ assert.ok(!h.includes('Check the certificate of example.com'), 'only this person\'s');
++ });
++
++ await check('/me/spend: the last 30 days as bars and a table, for the signed-in person only', async () => {
++ const r = await t.get('/me/spend', { as: ada });
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /<h1>Spend<\/h1>/);
++ assert.match(r.text, /Last 30 days: <b>\$0\.0012<\/b>, 2 tasks/);
++ assert.strictEqual((r.text.match(/class="cn-bar"/g) || []).length, 30);
++ assert.strictEqual((await t.get('/me/spend')).status, 302, 'signed out: sign in first');
++ assert.match((await t.get('/console', { as: ada })).text, /href="\/me\/spend"/, 'the console links it');
++ });
++
await check('nothing running: no refresh; nothing waiting says so', async () => {
const r = await t.get('/console', { as: bo });
assert.strictEqual(r.status, 200);