openvibers/openvibe.actor Commits
Actor: the Console shows the last 7 days of spend (#59)
Changes
@@@ -45,6 +45,14 @@.cn-stat b { color: var(--text-primary, #e6edf7); font-size: 1.25rem; font-variant-numeric: tabular-nums; }.cn-meter { height: 6px; border-radius: 999px; background: color-mix(in srgb, var(--text-secondary, #96a7c2) 20%, transparent); overflow: hidden; }.cn-meter span { display: block; height: 100%; border-radius: inherit; background: var(--accent, #3b82f6); }++.cn-week { margin: .35rem 0 0; }++.cn-week figcaption { font-size: 13px; color: var(--text-secondary, #96a7c2); }++.cn-week figcaption b { color: var(--text-primary, #e6edf7); font-variant-numeric: tabular-nums; }++.cn-bars { display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px; align-items: end; height: 72px; margin-top: .45rem; }++.cn-bar { display: flex; flex-direction: column; align-items: center; justify-content: flex-end; gap: 3px; height: 100%; }++.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); }@media (max-width: 60rem) { .cn-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }@media (max-width: 40rem) {@@@ -68,6 +68,16 @@ 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) {++ 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>++<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>`;++ }++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>`@@@ -101,9 +111,10 @@ ${c.waiting_for_approval ? html`<span class="pill pill-running">${String(c.waiti}const requester = `user:${req.viewer.subject}`;const subject = req.viewer.subject;-- const [tasks, today, asked, agents, pcs, rovers] = await Promise.all([++ const [tasks, today, week, asked, agents, pcs, rovers] = await Promise.all([store.listTasks(s, requester, { limit: 30 }),store.spendToday(s, requester),++ store.spendDays(s, requester, 7),approvals.list(s, requester),personal.list(s, requester),bot.computers.enabled ? fromBot((o) => bot.computers.mine(subject, o)) : { ok: false },@@@ -133,6 +144,7 @@ ${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>`}@@@ -106,6 +106,15 @@ async function spendToday(s, requester) {return { usd: row ? Number(row.usd) : 0, tasks: row ? Number(row.tasks) : 0 };}++/** The last `days` days of a requester's spend, oldest first, each day present (0 when nothing ran). */++async function spendDays(s, requester, days = 7) {++ const today = Date.parse(`${dayOf(s.now())}T00:00:00Z`);++ const keys = Array.from({ length: days }, (_, i) => dayOf(today - (days - 1 - i) * 86_400_000));++ const rows = await s.db.many('SELECT day, usd, tasks FROM spend_daily WHERE requester = $1 AND day = ANY($2)', [requester, keys]);++ const by = new Map(rows.map((r) => [String(r.day).slice(0, 10), r]));++ return keys.map((day) => ({ day, usd: by.has(day) ? Number(by.get(day).usd) : 0, tasks: by.has(day) ? Number(by.get(day).tasks) : 0 }));++}++/** At boot: a task a stopped process left open did not finish, and is not re-run behind the person's back. */async function failInterrupted(s) {const rows = await s.db.many(@@@ -151,4 +160,4 @@ function toWire(row, { baseUrl }) {return t;}--module.exports = { insertTask, getTask, byIdempotency, listTasks, updateTask, appendEvent, eventsAfter, addSpend, spendToday, lockSpendToday, failInterrupted, prune, toWire, subjectRef, dayOf, parseJson: parse, OPEN, RETENTION_DAYS };++module.exports = { insertTask, getTask, byIdempotency, listTasks, updateTask, appendEvent, eventsAfter, addSpend, spendToday, spendDays, lockSpendToday, failInterrupted, prune, toWire, subjectRef, dayOf, parseJson: parse, OPEN, RETENTION_DAYS };@@@ -55,6 +55,7 @@ const approvals = require('../server/agents/approvals');const running = await task(owner, 'Check the certificate of example.com', 'running');await task(owner, 'Summarise the Node.js release notes', 'succeeded');await task(`user:${bo.subject}`, 'Bo\'s private task', 'running');++ await store.addSpend(t.ctx.s, owner, 0.0012);await approvals.request(t.ctx.s, { owner, kind: 'codes.task', summary: 'Fix the failing build in ada/site', payload: { task: 'fix it' } });await personal.create(t.ctx.s, t.config, owner, { name: 'Morning briefing', instructions: 'Summarise what shipped.', mode: 'balanced', budget_per_run_usd: 0.02, daily_runs_max: 1, schedule: 'daily', schedule_at: '08:00', status: 'active' });const r = await t.get('/console', { as: ada });@@@ -73,6 +74,8 @@ const approvals = require('../server/agents/approvals');assert.match(h, /desk-pc[\s\S]*Ask first · terminal, files/);assert.match(h, /Desk rover[\s\S]*battery 80%/);assert.match(h, /openvibe\.services\/projects/);++ assert.match(h, /Last 7 days: <b>\$0\.0012<\/b>, 2 tasks/, 'the week: what the two tasks spent today');++ assert.strictEqual((h.match(/class="cn-bar"/g) || []).length, 7);assert.deepStrictEqual(asked.filter((a) => a[1] !== ada.subject), [], 'Bot is asked as the person only');});
@@ -45,6 +45,14 @@
@@ -45,6 +45,14 @@
.cn-stat b { color: var(--text-primary, #e6edf7); font-size: 1.25rem; font-variant-numeric: tabular-nums; }
.cn-stat b { color: var(--text-primary, #e6edf7); font-size: 1.25rem; font-variant-numeric: tabular-nums; }
.cn-meter { height: 6px; border-radius: 999px; background: color-mix(in srgb, var(--text-secondary, #96a7c2) 20%, transparent); overflow: hidden; }
.cn-meter { height: 6px; border-radius: 999px; background: color-mix(in srgb, var(--text-secondary, #96a7c2) 20%, transparent); overflow: hidden; }
.cn-meter span { display: block; height: 100%; border-radius: inherit; background: var(--accent, #3b82f6); }
.cn-meter span { display: block; height: 100%; border-radius: inherit; background: var(--accent, #3b82f6); }
+.cn-week { margin: .35rem 0 0; }
+.cn-week figcaption { font-size: 13px; color: var(--text-secondary, #96a7c2); }
+.cn-week figcaption b { color: var(--text-primary, #e6edf7); font-variant-numeric: tabular-nums; }
+.cn-bars { display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px; align-items: end; height: 72px; margin-top: .45rem; }
+.cn-bar { display: flex; flex-direction: column; align-items: center; justify-content: flex-end; gap: 3px; height: 100%; }
+.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); }
@media (max-width: 60rem) { .cn-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 60rem) { .cn-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 40rem) {
@media (max-width: 40rem) {
@@ -68,6 +68,16 @@ function createConsolePages(ctx, { robots = null, computers = null } = {}) {
@@ -68,6 +68,16 @@ 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>`;
<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) {
+ 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>
+<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>`;
+ }
+
function computersCard(result) {
function computersCard(result) {
const body = !bot.computers.enabled ? html`<p class="cn-empty">Not connected to OpenVibe.Bot on this server.</p>`
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>`
: !result.ok ? html`<p class="cn-empty">${result.why}</p>`
@@ -101,9 +111,10 @@ ${c.waiting_for_approval ? html`<span class="pill pill-running">${String(c.waiti
@@ -101,9 +111,10 @@ ${c.waiting_for_approval ? html`<span class="pill pill-running">${String(c.waiti
}
}
const requester = `user:${req.viewer.subject}`;
const requester = `user:${req.viewer.subject}`;
const subject = req.viewer.subject;
const subject = req.viewer.subject;
- const [tasks, today, asked, agents, pcs, rovers] = await Promise.all([
+ const [tasks, today, week, asked, agents, pcs, rovers] = await Promise.all([
store.listTasks(s, requester, { limit: 30 }),
store.listTasks(s, requester, { limit: 30 }),
store.spendToday(s, requester),
store.spendToday(s, requester),
+ store.spendDays(s, requester, 7),
approvals.list(s, requester),
approvals.list(s, requester),
personal.list(s, requester),
personal.list(s, requester),
bot.computers.enabled ? fromBot((o) => bot.computers.mine(subject, o)) : { ok: false },
bot.computers.enabled ? fromBot((o) => bot.computers.mine(subject, o)) : { ok: false },
@@ -133,6 +144,7 @@ ${nWaiting ? '' : html`<p class="cn-empty">Nothing waits for you.</p>`}
@@ -133,6 +144,7 @@ ${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>
<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>
<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>
<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>
<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>
<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>`}
${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>`}
@@ -106,6 +106,15 @@ async function spendToday(s, requester) {
@@ -106,6 +106,15 @@ async function spendToday(s, requester) {
return { usd: row ? Number(row.usd) : 0, tasks: row ? Number(row.tasks) : 0 };
return { usd: row ? Number(row.usd) : 0, tasks: row ? Number(row.tasks) : 0 };
}
}
+/** The last `days` days of a requester's spend, oldest first, each day present (0 when nothing ran). */
+async function spendDays(s, requester, days = 7) {
+ const today = Date.parse(`${dayOf(s.now())}T00:00:00Z`);
+ const keys = Array.from({ length: days }, (_, i) => dayOf(today - (days - 1 - i) * 86_400_000));
+ const rows = await s.db.many('SELECT day, usd, tasks FROM spend_daily WHERE requester = $1 AND day = ANY($2)', [requester, keys]);
+ const by = new Map(rows.map((r) => [String(r.day).slice(0, 10), r]));
+ return keys.map((day) => ({ day, usd: by.has(day) ? Number(by.get(day).usd) : 0, tasks: by.has(day) ? Number(by.get(day).tasks) : 0 }));
+}
+
/** At boot: a task a stopped process left open did not finish, and is not re-run behind the person's back. */
/** At boot: a task a stopped process left open did not finish, and is not re-run behind the person's back. */
async function failInterrupted(s) {
async function failInterrupted(s) {
const rows = await s.db.many(
const rows = await s.db.many(
@@ -151,4 +160,4 @@ function toWire(row, { baseUrl }) {
@@ -151,4 +160,4 @@ function toWire(row, { baseUrl }) {
return t;
return t;
}
}
-module.exports = { insertTask, getTask, byIdempotency, listTasks, updateTask, appendEvent, eventsAfter, addSpend, spendToday, lockSpendToday, failInterrupted, prune, toWire, subjectRef, dayOf, parseJson: parse, OPEN, RETENTION_DAYS };
+module.exports = { insertTask, getTask, byIdempotency, listTasks, updateTask, appendEvent, eventsAfter, addSpend, spendToday, spendDays, lockSpendToday, failInterrupted, prune, toWire, subjectRef, dayOf, parseJson: parse, OPEN, RETENTION_DAYS };
@@ -55,6 +55,7 @@ const approvals = require('../server/agents/approvals');
@@ -55,6 +55,7 @@ const approvals = require('../server/agents/approvals');
const running = await task(owner, 'Check the certificate of example.com', 'running');
const running = await task(owner, 'Check the certificate of example.com', 'running');
await task(owner, 'Summarise the Node.js release notes', 'succeeded');
await task(owner, 'Summarise the Node.js release notes', 'succeeded');
await task(`user:${bo.subject}`, 'Bo\'s private task', 'running');
await task(`user:${bo.subject}`, 'Bo\'s private task', 'running');
+ await store.addSpend(t.ctx.s, owner, 0.0012);
await approvals.request(t.ctx.s, { owner, kind: 'codes.task', summary: 'Fix the failing build in ada/site', payload: { task: 'fix it' } });
await approvals.request(t.ctx.s, { owner, kind: 'codes.task', summary: 'Fix the failing build in ada/site', payload: { task: 'fix it' } });
await personal.create(t.ctx.s, t.config, owner, { name: 'Morning briefing', instructions: 'Summarise what shipped.', mode: 'balanced', budget_per_run_usd: 0.02, daily_runs_max: 1, schedule: 'daily', schedule_at: '08:00', status: 'active' });
await personal.create(t.ctx.s, t.config, owner, { name: 'Morning briefing', instructions: 'Summarise what shipped.', mode: 'balanced', budget_per_run_usd: 0.02, daily_runs_max: 1, schedule: 'daily', schedule_at: '08:00', status: 'active' });
const r = await t.get('/console', { as: ada });
const r = await t.get('/console', { as: ada });
@@ -73,6 +74,8 @@ const approvals = require('../server/agents/approvals');
@@ -73,6 +74,8 @@ const approvals = require('../server/agents/approvals');
assert.match(h, /desk-pc[\s\S]*Ask first · terminal, files/);
assert.match(h, /desk-pc[\s\S]*Ask first · terminal, files/);
assert.match(h, /Desk rover[\s\S]*battery 80%/);
assert.match(h, /Desk rover[\s\S]*battery 80%/);
assert.match(h, /openvibe\.services\/projects/);
assert.match(h, /openvibe\.services\/projects/);
+ assert.match(h, /Last 7 days: <b>\$0\.0012<\/b>, 2 tasks/, 'the week: what the two tasks spent today');
+ assert.strictEqual((h.match(/class="cn-bar"/g) || []).length, 7);
assert.deepStrictEqual(asked.filter((a) => a[1] !== ada.subject), [], 'Bot is asked as the person only');
assert.deepStrictEqual(asked.filter((a) => a[1] !== ada.subject), [], 'Bot is asked as the person only');
});
});