openvibers/openvibe.events Commits

The openvibe.events home shows the bus at work: events today and this week, who published, the public event types with their counts and last arrival, plus the questions people ask (#104)

OP OpenVibers committed 22 days ago · 8c1ff6f006 · 6 files, +276 −20

* The openvibe.events home shows the bus at work: events today and this week, who published, the public event types with their counts and last arrival, plus the questions people ask

* On a phone each public event type is a small card with the type across the top

Changes

ddiff --git a/README.md b/README.md
iindex 7572d96..425ae8b 100644
---- a/README.md
++++ b/README.md
@@@ -7,7 +7,7 @@
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §6 and §6.3.
**License:** AGPL-3.0 (same as every OpenVibe service).
--The home page sitemap entry uses the release commit time as lastmod.
++The home page sitemap entry uses the release commit time as lastmod. The home page (`server/home.js`) shows the bus at work from this service's own tables (`server/home-stats.js`, kept five minutes): events in the last 24 hours and this week, how many services and event types published this week, active subscriptions and their consumers, deliveries made in the 30-day hot window and an empty dead-letter queue when it is empty; who published this week (events by the first segment of their type); and the public event types (visibility `public`) with their counts and when each last arrived. Only counts and names leave the reader, never a payload, an actor, a subject id or a principal; a section with nothing to show is left out. Its questions are a FAQPage in its JSON-LD.
## Purpose
ddiff --git a/server/app.js b/server/app.js
iindex d78c422..3b594ff 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -16,6 +16,7 @@ const { resourceControlRouter } = require('./api/resource-control');
const { mountLimits } = require('./limits');
const { createLimits } = require('./actor-limits');
const { renderHome, renderUpdates, renderNotFound, HOME_CSP } = require('./home');
++const { createHomeStats } = require('./home-stats');
const appIcon = require('openvibe-shared/app-icon');
const { createDiscoveryRoutes } = require('./discovery');
const ovServe = require('openvibe-shared/serve');
@@@ -117,14 +118,19 @@ function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLo
.send(renderUpdates({ siteUrl: config.siteUrl }));
});
-- app.get('/', (req, res) => {
++ // The home's live sections: counts and names from this service's own tables, kept five minutes (server/home-stats.js).
++ const homeStats = createHomeStats({ store, now: () => (clock ? clock.now() : Date.now()), log });
++ app.get('/', async (req, res, next) => {
res.vary('Accept');
if (req.accepts(['text/plain', 'text/html']) === 'text/html') {
-- return res.type('html')
-- .set('Content-Security-Policy', HOME_CSP)
-- // private: Cloudflare caches by URL and ignores Vary: Accept, so a shared copy could reach an API client.
-- .set('Cache-Control', 'private, max-age=300')
-- .send(renderHome({ siteUrl: config.siteUrl, apiUrl: config.baseUrl }));
++ try {
++ const stats = await homeStats.read();
++ return res.type('html')
++ .set('Content-Security-Policy', HOME_CSP)
++ // private: Cloudflare caches by URL and ignores Vary: Accept, so a shared copy could reach an API client.
++ .set('Cache-Control', 'private, max-age=300')
++ .send(renderHome({ siteUrl: config.siteUrl, apiUrl: config.baseUrl, stats }));
++ } catch (e) { return next(e); }
}
res.type('text/plain').send([
'OpenVibe.Events: durable events, subscriptions, signed delivery, dead letters and replay.',
ddiff --git a/server/home-stats.js b/server/home-stats.js
nnew file mode 100644
iindex 0000000..220cf3d
---- /dev/null
++++ b/server/home-stats.js
@@@ -0,0 +1,72 @@
++'use strict';
++
++/**
++ * What the home page shows of the bus at work, read from this service's own tables and kept a few minutes:
++ *
++ * createHomeStats({ store, now, ttlMs, log }) → { read() }
++ * read() → { day, week, types, services: [{ ns, n }], public: [{ type, n, last }],
++ * subscriptions, consumers, delivered, dead } | null
++ *
++ * Only counts and names leave this module: never a payload, an actor, a subject id or a publisher's principal. The
++ * per-type list is the events whose visibility is `public` (the Contracts registry's own word); the
++ * per-service counts are every event, by the first segment of its type. read() never throws: a failed read keeps the
++ * last answer (null before the first), so the page leaves the sections out instead of failing.
++ */
++const DAY_MS = 24 * 60 * 60 * 1000;
++
++function createHomeStats({ store, now = () => Date.now(), ttlMs = 5 * 60 * 1000, waitMs = 1500, log = console } = {}) {
++ let cached = null;
++ let at = 0;
++ let pending = null;
++ const db = store && store.db;
++
++ async function load() {
++ const t = now();
++ const day = t - DAY_MS;
++ const week = t - 7 * DAY_MS;
++ const [dayRow, byNs, typeRow, pub, subs, counts] = await Promise.all([
++ db.prepare('SELECT COUNT(*) AS n FROM events WHERE received_at > ?').get(day),
++ db.prepare("SELECT split_part(event_type, '.', 1) AS ns, COUNT(*) AS n FROM events WHERE received_at > ? GROUP BY 1 ORDER BY 2 DESC, 1").all(week),
++ db.prepare('SELECT COUNT(DISTINCT event_type) AS n FROM events WHERE received_at > ?').get(week),
++ db.prepare("SELECT event_type, COUNT(*) AS n, MAX(received_at) AS last FROM events WHERE received_at > ? AND visibility = 'public' GROUP BY 1 ORDER BY 2 DESC, 1").all(week),
++ db.prepare('SELECT COUNT(*) AS n, COUNT(DISTINCT consumer) AS c FROM subscriptions WHERE enabled = 1').get(),
++ store.deliveryCounts(),
++ ]);
++ const services = byNs.map((r) => ({ ns: String(r.ns), n: Number(r.n) })).filter((r) => r.ns && r.n > 0);
++ return {
++ day: Number(dayRow.n),
++ week: services.reduce((sum, r) => sum + r.n, 0),
++ types: Number(typeRow.n),
++ services,
++ public: pub.map((r) => ({ type: String(r.event_type), n: Number(r.n), last: Number(r.last) })),
++ subscriptions: Number(subs.n),
++ consumers: Number(subs.c),
++ delivered: Number(counts.delivered || 0),
++ dead: Number(counts.dead || 0),
++ measuredAt: t,
++ };
++ }
++
++ function refresh() {
++ if (!pending) {
++ pending = load()
++ .then((v) => { cached = v; at = now(); return v; })
++ .catch((e) => { log.warn(`[events] home stats: ${e.message}`); at = now() - ttlMs + 60 * 1000; return cached; })
++ .finally(() => { pending = null; });
++ }
++ return pending;
++ }
++
++ async function read() {
++ if (!db) return null;
++ // Fresh, or a failure less than a minute ago (at is moved back so the next try waits a minute, not a request).
++ if (at && now() - at < ttlMs) return cached;
++ const p = refresh();
++ if (cached) return cached;
++ return Promise.race([p, new Promise((resolve) => setTimeout(() => resolve(null), waitMs).unref())]);
++ }
++
++ return { read };
++}
++
++module.exports = { createHomeStats };
ddiff --git a/server/home.js b/server/home.js
iindex dc77495..3d6bd26 100644
---- a/server/home.js
++++ b/server/home.js
@@@ -4,9 +4,12 @@
* The product home of OpenVibe.Events (openvibe.events, plan T7: the API origin is the product origin too).
* The OpenVibe Frame (openvibe-shared/shell) around openvibe-shared/showcase sections
* for developers. Every claim restates the README (Publishing, Subscriptions and delivery, Developer apps,
-- * Realtime) and the code; nothing here reads data.
++ * Realtime) and the code. The bus at work comes from server/home-stats.js (counts and names only, never a payload):
++ * the numbers, who published this week and the public event types with when each last arrived; each section is left
++ * out when there is nothing to show.
*
-- * renderHome({ siteUrl, apiUrl }) the whole document (siteUrl: the canonical product origin; apiUrl: the API origin)
++ * renderHome({ siteUrl, apiUrl, stats }) the whole document (siteUrl: the canonical product origin; apiUrl: the API
++ * origin; stats: home-stats read(), or null)
* HOME_CSP its Content-Security-Policy (the Frame's scripts and calls to the Network)
*/
const shell = require('openvibe-shared/shell');
@@@ -14,12 +17,13 @@ const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
const ovServe = require('openvibe-shared/serve');
const appIcon = require('openvibe-shared/app-icon');
++const seo = require('openvibe-shared/seo');
const SITE_NAME = 'OpenVibe.Events';
const NETWORK_URL = 'https://openvibe.network';
const SOURCE = 'https://github.com/OpenVibers/OpenVibe.Events';
--const DESCRIPTION = 'Durable events for the OpenVibe network: publish once, and every subscriber gets it signed, retried and replayable. '
-- + 'Webhooks, pull with a cursor, dead letters, replay and realtime streams for browsers.';
++const DESCRIPTION = 'The OpenVibe network\'s event bus: publish once and every subscriber gets it signed, retried and replayable, '
++ + 'by webhook, pull or realtime stream.';
const HOME_CSP = [
"default-src 'self'",
@@@ -37,7 +41,85 @@ const HOME_CSP = [
"form-action 'self' https://openvibe.network",
].join('; ');
--function sections({ apiUrl }) {
++const esc = showcase.esc;
++const fmt = (n) => Number(n).toLocaleString('en-US');
++// Where each publishing service lives (the first segment of its event types). One not listed is shown by name only.
++const SERVICES = {
++ live: ['OpenVibe.Live', 'https://openvibe.live'], search: ['OpenVibe.Search', 'https://search.openvibe.network'], sources: ['OpenVibe.Sources', null],
++ reviews: ['OpenVibe.Reviews', 'https://openvibe.reviews'], ai: ['OpenVibe AI', 'https://ai.openvibe.services'], work: ['OpenVibe.Work', 'https://openvibe.work'],
++ trade: ['OpenVibe.Trade', 'https://openvibe.trade'], media: ['OpenVibe.Media', 'https://openvibe.media'], deals: ['OpenVibe.Deals', 'https://openvibe.deals'],
++ community: ['OpenVibe.Community', 'https://openvibe.community'], help: ['OpenVibe.Help', 'https://openvibe.help'], chat: ['OpenVibe.Chat', 'https://openvibe.chat'],
++ rent: ['OpenVibe.Rent', 'https://openvibe.rent'], host: ['OpenVibe.Host', 'https://openvibe.host'], network: ['OpenVibe.Network', 'https://openvibe.network'],
++ news: ['OpenVibe.News', 'https://openvibe.news'], coupons: ['OpenVibe.Coupons', 'https://openvibe.coupons'], games: ['OpenVibe.Games', 'https://openvibe.games'],
++ tools: ['OpenVibe.Tools', 'https://openvibe.tools'], wiki: ['OpenVibe.Wiki', 'https://openvibe.wiki'], blog: ['OpenVibe.Blog', 'https://openvibe.blog'],
++ quest: ['OpenVibe.Quest', 'https://openvibe.quest'], inventory: ['OpenVibe.Inventory', 'https://inventory.openvibe.network'],
++ services: ['OpenVibe.Services', 'https://openvibe.services'], events: ['OpenVibe.Events', 'https://openvibe.events'], bot: ['OpenVibe.Bot', 'https://openvibe.bot'],
++ actor: ['OpenVibe.Actor', 'https://openvibe.actor'], watch: ['OpenVibe.Watch', 'https://openvibe.watch'], codes: ['OpenVibe.Codes', 'https://openvibe.codes'],
++ space: ['OpenVibe.Space', 'https://openvibe.space'], run: ['OpenVibe.Run', 'https://openvibe.run'], zone: ['OpenVibe.Zone', 'https://openvibe.zone'],
++ food: ['OpenVibe.Food', 'https://openvibe.food'], download: ['OpenVibe.Download', 'https://openvibe.download'], mediahub: ['OpenVibe.Video', 'https://openvibe.video'],
++ openre: ['OpenRestream', 'https://openre.stream'], play: ['OpenVibe Play', 'https://play.openvibe.network'], billing: ['OpenVibe.Billing', null],
++ app: ['Developer apps', null],
++};
++// Plain words for the public event types people meet most; any other type is shown by its name alone.
++const TYPE_WORDS = {
++ 'live.stream.started': 'A channel went live on OpenVibe.Live', 'live.stream.ended': 'A stream ended on OpenVibe.Live',
++ 'blog.post.published': 'A post was published on OpenVibe.Blog', 'wiki.page.published': 'A page was published on OpenVibe.Wiki',
++ 'chat.message.created': 'A message in a public chat room', 'community.paste.created': 'A paste was shared on OpenVibe.Community',
++ 'community.paste.updated': 'A paste was edited on OpenVibe.Community', 'community.thread.created': 'A new forum thread on OpenVibe.Community',
++ 'coupons.coupon.created': 'A coupon was added on OpenVibe.Coupons', 'deals.offer.created': 'A deal was added on OpenVibe.Deals',
++ 'deals.offer.updated': 'A deal changed on OpenVibe.Deals', 'games.world.published': 'A creator world was published on OpenVibe.Games',
++ 'services.app.published': 'A developer app was published', 'reviews.signal.added': 'A review signal was added on OpenVibe.Reviews',
++ 'trade.observation.created': 'A market observation on OpenVibe.Trade',
++};
++
++function ago(ms, now) {
++ const s = Math.max(0, Math.round((now - ms) / 1000));
++ if (s < 60) return 'just now';
++ if (s < 3600) return `${Math.round(s / 60)} min ago`;
++ if (s < 86400) return `${Math.round(s / 3600)} h ago`;
++ const d = Math.round(s / 86400);
++ return `${d} day${d === 1 ? '' : 's'} ago`;
++}
++const serviceName = (ns) => (SERVICES[ns] || [ns])[0];
++const serviceLink = (ns) => { const [name, url] = SERVICES[ns] || [ns, null]; return url ? `<a href="${esc(url)}">${esc(name)}</a>` : esc(name); };
++
++/** The numbers, who published and the public types (each left out when empty). */
++function activity(stats) {
++ if (!stats) return '';
++ const now = stats.measuredAt || Date.now();
++ const tiles = [
++ [stats.day, 'events in the last 24 hours'], [stats.week, 'events this week'], [stats.services.length, 'services published this week'],
++ [stats.types, 'event types this week'], [stats.subscriptions, `active subscriptions${stats.consumers ? ` from ${fmt(stats.consumers)} consumers` : ''}`],
++ [stats.delivered, 'deliveries made in the last 30 days'],
++ ].filter(([n]) => n > 0);
++ const statRow = tiles.length ? `<section class="sc-sec hm-stats-sec" aria-label="The bus right now"><ul class="hm-stats">${tiles.map(([n, label]) => `<li><b>${esc(fmt(n))}</b><span>${esc(label)}</span></li>`).join('')}</ul>${stats.dead === 0 && stats.delivered ? '<p class="hm-note">No deliveries are waiting in the dead-letter queue.</p>' : ''}</section>` : '';
++ const top = stats.services.slice(0, 16);
++ const max = top.length ? top[0].n : 1;
++ const who = top.length ? `<section class="sc-sec" aria-labelledby="h-who"><h2 id="h-who">Who published this week</h2>
++<p class="sc-lede">Events by the service that published them over the last seven days. Every one was stored before any subscriber saw it.</p>
++<ul class="hm-bars">${top.map((r) => `<li><span class="hm-bar-name">${serviceLink(r.ns)}</span><span class="hm-bar" aria-hidden="true"><i style="width:${Math.max(2, Math.round((r.n / max) * 100))}%"></i></span><span class="hm-bar-n">${esc(fmt(r.n))}</span></li>`).join('')}</ul>
++${stats.services.length > top.length ? `<p class="hm-note">And ${esc(stats.services.length - top.length)} more.</p>` : ''}</section>` : '';
++ const types = stats.public.length ? `<section class="sc-sec" aria-labelledby="h-types"><h2 id="h-types">Public events this week</h2>
++<p class="sc-lede">The event types anyone may read, with how many arrived in the last seven days. Subscribe to a pattern such as live.stream.* to get them as signed webhooks.</p>
++<div class="sc-table-wrap" tabindex="0" role="region" aria-labelledby="h-types"><table class="sc-table hm-table"><thead><tr><th scope="col">Event type</th><th scope="col">What happened</th><th scope="col">This week</th><th scope="col">Last one</th></tr></thead><tbody>
++${stats.public.map((r) => `<tr><th scope="row"><code>${esc(r.type)}</code></th><td>${esc(TYPE_WORDS[r.type] || `From ${serviceName(r.type.split('.')[0])}`)}</td><td>${esc(fmt(r.n))}</td><td>${esc(ago(r.last, now))}</td></tr>`).join('')}
++</tbody></table></div></section>` : '';
++ return { statRow, who, types };
++}
++
++/** Questions people ask first, answered from the README and the code. */
++const FAQ = [
++ ['What is OpenVibe.Events?', 'The event bus of the OpenVibe network. A service publishes an event once; Events stores it, then delivers it to every subscriber as a signed webhook, keeps it for pulls with a cursor, and streams public ones to browsers.'],
++ ['Who can publish?', 'OpenVibe services with the events.event.publish capability, and developer apps under their own project\'s names (app.<project_key>.*) with an app token from OpenVibe.Network.'],
++ ['How do I know a webhook really came from Events?', 'Every delivery is signed over its timestamp and raw body (X-OpenVibe-Signature-V2). Check it with verifyDeliveryV2 from openvibe-sdk/events and refuse anything more than five minutes old.'],
++ ['What if my endpoint is down?', 'The delivery is retried with backoff. One that keeps failing waits in the dead-letter queue, where it can be replayed, and a new subscription can catch up from a point in history.'],
++ ['How long are events kept?', 'Thirty days in the hot store for delivery, pulls and replay, then a year in the replay tier for pulls and stream resumes.'],
++ ['Can a browser listen?', 'Yes. Server-sent events at /realtime/stream, authorized per topic with a one-use ticket from OpenVibe.Network, and resumable after a reconnect.'],
++ ['Are sandbox and production separate?', 'Yes. A developer app\'s sandbox events never reach production apps or subscriptions, and are pruned after seven days.'],
++];
++
++function sections({ apiUrl, stats = null }) {
++ const live = activity(stats) || { statRow: '', who: '', types: '' };
const publish = [
`curl -X POST ${apiUrl}/api/v1/events \\`,
' -H "Authorization: Bearer $TOKEN" -H \'Content-Type: application/json\' \\',
@@@ -56,14 +138,15 @@ function sections({ apiUrl }) {
`curl "${apiUrl}/api/v1/events?topic=app.<project_key>.*&limit=100" -H "Authorization: Bearer $TOKEN"`,
'# then the same with &after=<next_cursor> from the previous page',
].join('\n');
++ const publishers = stats && stats.services.filter((r) => r.ns !== 'app').length;
return showcase.hero({
eyebrow: 'OpenVibe.Events · alpha',
title: 'Events your services', accent: 'can count on.',
lede: 'The event backbone of the OpenVibe network. Publish an event once and every subscriber gets it: stored durably, signed, '
-- + 'retried with backoff, and replayable after a failure. Live, Media, Chat, Games, Network and OpenRestream publish to it in production.',
++ + `retried with backoff, and replayable after a failure.${publishers ? ` ${publishers} OpenVibe services published to it this week.` : ''}`,
actions: [{ label: 'Read the guide', href: `${SOURCE}#readme`, primary: true }, { label: 'See an example', href: '#examples' }],
note: `Alpha. The API is at ${apiUrl}; developer apps sign in through OpenVibe.Network.`,
-- }) + showcase.features({
++ }) + live.statRow + live.who + live.types + showcase.features({
title: 'What you get',
items: [
{ icon: 'ov:upload', title: 'Publish once', text: 'One event or up to a hundred at a time, stored atomically and numbered in order. Sending the same event id twice never stores it twice.' },
@@@ -90,13 +173,34 @@ function sections({ apiUrl }) {
{ title: 'Answer quickly', text: 'Reply 2xx once the event is safely stored; anything else is retried, then dead-lettered.' },
{ title: 'Expect repeats', text: 'A retry can bring the same event again: keep its event id and handle it once.' },
],
-- });
++ }) + `<section class="sc-sec" id="faq" aria-labelledby="h-faq"><h2 id="h-faq">Questions</h2>
++<div class="hm-faq">${FAQ.map(([q, a]) => `<div class="hm-qa"><h3>${esc(q)}</h3><p>${esc(a)}</p></div>`).join('')}</div></section>`;
}
// The page's own few rules (it has no stylesheet beyond the showcase's): no browser margin around the body, so the Frame's
// footer meets the edges; each curl sample on its own full-width row (three columns cut every command off); the four
// webhook steps two by two instead of three and one alone, each title level with its number.
--const HOME_STYLE = '<style>body{margin:0}.page .sc-code{grid-template-columns:minmax(0,1fr)}.page .sc-steps{grid-template-columns:repeat(auto-fit,minmax(min(100%,420px),1fr))}.page .sc-steps li{align-items:start;align-content:start}.page .sc-steps b{line-height:30px}</style>';
++// The data sections' rules follow: the stat tiles, the per-service bars, the public types table and the questions, in theme tokens (accent-light is drawn for dark themes, so text in it is mixed toward the text colour).
++const HOME_STYLE = '<style>body{margin:0}.page .sc-code{grid-template-columns:minmax(0,1fr)}.page .sc-steps{grid-template-columns:repeat(auto-fit,minmax(min(100%,420px),1fr))}.page .sc-steps li{align-items:start;align-content:start}.page .sc-steps b{line-height:30px}'
++ + '.page{--hm-accent-text:color-mix(in srgb,var(--accent-light,#60a5fa) 45%,var(--text-primary,#e6edf7))}.sc-eyebrow{color:var(--hm-accent-text)}.sc-table thead th{color:var(--text-secondary,#96a7c2)}'
++ + '.hm-stats-sec{margin-top:40px}.hm-stats{list-style:none;margin:0;padding:0;display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(min(130px,100%),1fr))}'
++ + '.hm-stats li{display:grid;gap:2px;padding:14px 16px;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}'
++ + '.hm-stats b{font-size:28px;line-height:1.1;letter-spacing:-.02em}.hm-stats span{font-size:13.5px;color:var(--text-secondary,#96a7c2)}'
++ + '.hm-note{margin:10px 0 0;font-size:14px;color:var(--text-secondary,#96a7c2)}'
++ + '.hm-bars{list-style:none;margin:0;padding:0;display:grid;gap:8px 28px;grid-template-columns:repeat(auto-fill,minmax(min(100%,460px),1fr))}'
++ + '.hm-bars li{display:grid;grid-template-columns:minmax(0,11em) minmax(0,1fr) auto;align-items:center;gap:12px;font-size:14.5px}'
++ + '.hm-bar-name{overflow-wrap:anywhere}.hm-bar-name a{color:var(--hm-accent-text);text-decoration:none}.hm-bar-name a:hover{text-decoration:underline}.hm-bar{height:10px;border-radius:999px;background:var(--bg-tertiary,#172238);overflow:hidden}'
++ + '.hm-bar i{display:block;height:100%;border-radius:999px;background:linear-gradient(90deg,var(--accent,#3b82f6),var(--accent-light,#60a5fa))}.hm-bar-n{font-variant-numeric:tabular-nums;color:var(--text-secondary,#96a7c2);text-align:right;min-width:4.5em}'
++ + '.hm-table code{font-size:13px;overflow-wrap:anywhere}.hm-table td:nth-child(3),.hm-table td:nth-child(4){white-space:nowrap;font-variant-numeric:tabular-nums}'
++ + '.hm-faq{display:grid;gap:12px 28px;grid-template-columns:repeat(auto-fill,minmax(min(380px,100%),1fr))}.hm-qa{padding:4px 0 14px;border-bottom:1px solid var(--border,rgba(255,255,255,.08))}'
++ + '.hm-qa h3{margin:0 0 6px;font-size:16px}.hm-qa p{margin:0;font-size:14.5px;line-height:1.6;color:var(--text-secondary,#96a7c2)}'
++ + '@media (max-width:520px){.page .sc-code pre{white-space:pre-wrap;overflow-wrap:anywhere}.hm-bars li{grid-template-columns:minmax(0,10.5em) minmax(0,1fr) auto;font-size:13.5px;gap:8px}.hm-bar-n{min-width:3.5em}'
++ // On a phone each public type is a small card: the type across the top, what happened below it, the count and the last one on the right.
++ + '.hm-table thead{display:none}.hm-table,.hm-table tbody{display:block}.hm-table tr{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2px 12px;padding:10px 14px;border-top:1px solid var(--border,rgba(255,255,255,.08))}'
++ + '.hm-table th,.hm-table td{display:block;padding:0;border:0}.hm-table tbody th{grid-column:1/-1;grid-row:1;width:auto}.hm-table td:nth-child(2){grid-column:1;grid-row:2/span 2;font-size:13.5px;color:var(--text-secondary,#96a7c2)}'
++ + '.hm-table td:nth-child(3){grid-column:2;grid-row:2;text-align:right}.hm-table td:nth-child(4){grid-column:2;grid-row:3;text-align:right;font-size:13px;color:var(--text-secondary,#96a7c2)}}'
++ + ''
++ + '@media (max-width:420px){.sc-hero,.sc-sec,.sc-cta{padding-left:16px;padding-right:16px}}</style>';
const FOOTER = { service: 'events', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' };
@@@ -121,7 +225,7 @@ ${frame.updatesBody({ service: 'events', siteName: SITE_NAME })}
});
}
--function renderHome({ siteUrl, apiUrl }) {
++function renderHome({ siteUrl, apiUrl, stats = null }) {
const nav = {
service: 'events',
apiBase: NETWORK_URL,
@@@ -137,6 +241,11 @@ function renderHome({ siteUrl, apiUrl }) {
summary: DESCRIPTION,
canonical: `${siteUrl}/`,
robots: 'index, follow',
++ jsonLd: [
++ seo.jsonLd.website({ name: SITE_NAME, url: siteUrl, description: DESCRIPTION }),
++ seo.jsonLd.softwareApp({ name: SITE_NAME, url: `${siteUrl}/`, description: DESCRIPTION, category: 'DeveloperApplication', keywords: ['event bus', 'webhooks', 'server-sent events', 'replay', 'dead-letter queue'] }),
++ seo.jsonLd.faq(FAQ.map(([q, a]) => ({ q, a }))),
++ ],
navbar: nav, footer, home: '/', navLinks: [{ label: 'Guide', href: `${SOURCE}#readme` }],
head: [
appIcon.headTags({ site: 'events' }),
@@@ -145,7 +254,7 @@ function renderHome({ siteUrl, apiUrl }) {
].join('\n'),
body: `<div id="navbar-mount"></div>
<main id="main" class="page">
--${sections({ apiUrl })}
++${sections({ apiUrl, stats })}
</main>`,
});
}
ddiff --git a/test/home-stats.test.js b/test/home-stats.test.js
nnew file mode 100644
iindex 0000000..2be8f3c
---- /dev/null
++++ b/test/home-stats.test.js
@@@ -0,0 +1,69 @@
++'use strict';
++// The home page's live sections (server/home-stats.js, server/home.js): counts and names read from Events' own tables,
++// the public types with when each last arrived, never a payload, internal types never named, the sections left out on an
++// empty bus, and a FAQPage that holds exactly the questions the page shows.
++const assert = require('assert');
++const { boot, request, serviceToken, envelope, suite } = require('./helpers');
++const { createHomeStats } = require('../server/home-stats');
++
++const t = suite('home-stats');
++let h;
++const html = async () => (await request(h.base, 'GET', '/', { headers: { Accept: 'text/html' } })).text;
++const section = (page, id) => (new RegExp(`<section class="sc-sec"[^>]*aria-labelledby="${id}"[\\s\\S]*?</section>`).exec(page) || [''])[0];
++
++t('boot', async () => { h = await boot(); });
++
++t('an empty bus: the live sections are left out, the rest of the page stays', async () => {
++ const page = await html();
++ assert.ok(!page.includes('aria-labelledby="h-who"'));
++ assert.ok(!page.includes('aria-labelledby="h-types"'));
++ assert.ok(!page.includes('class="hm-stats"'), 'no tiles of zeros');
++ assert.match(page, /Events your services/);
++ assert.match(page, /id="h-faq">Questions</);
++});
++
++t('published events show as counts by service, public types and the latest public events, never a payload', async () => {
++ const live = serviceToken('live', ['events.event.publish']);
++ const blog = serviceToken('blog', ['events.event.publish']);
++ const post = (token, body) => request(h.base, 'POST', '/api/v1/events', { token, body });
++ for (const ev of [
++ envelope('live', { event_type: 'live.stream.started', visibility: 'public', payload: { title: 'SECRET-TITLE' } }),
++ envelope('live', { event_type: 'live.stream.started', visibility: 'public' }),
++ envelope('live', { event_type: 'live.internal.tick', visibility: 'internal' }),
++ ]) assert.ok([200, 201, 202].includes((await post(live, ev)).status));
++ assert.ok([200, 201, 202].includes((await post(blog, envelope('blog', { event_type: 'blog.post.published', visibility: 'public', subject: { type: 'post', id: 'p1', revision: 1 } }))).status));
++
++ // A fresh reader (the app's own keeps its answer five minutes).
++ const stats = await createHomeStats({ store: h.store, log: { warn() {} } }).read();
++ assert.strictEqual(stats.week, 4);
++ assert.deepStrictEqual(stats.services, [{ ns: 'live', n: 3 }, { ns: 'blog', n: 1 }]);
++ assert.deepStrictEqual(stats.public.map((r) => [r.type, r.n]), [['live.stream.started', 2], ['blog.post.published', 1]]);
++ assert.strictEqual(JSON.stringify(stats).includes('SECRET-TITLE'), false, 'no payload leaves the reader');
++ assert.strictEqual(JSON.stringify(stats.public).includes('live.internal.tick'), false, 'an internal type is never named');
++
++ const { renderHome } = require('../server/home');
++ const page = renderHome({ siteUrl: 'https://openvibe.events', apiUrl: 'https://openvibe.events', stats });
++ assert.match(section(page, 'h-who'), /<a href="https:\/\/openvibe\.live">OpenVibe\.Live<\/a>/);
++ assert.match(section(page, 'h-types'), /<code>live\.stream\.started<\/code><\/th><td>A channel went live on OpenVibe\.Live<\/td><td>2<\/td>/);
++ assert.match(section(page, 'h-types'), /<code>blog\.post\.published<\/code><\/th><td>A post was published on OpenVibe\.Blog<\/td><td>1<\/td><td>just now<\/td>/);
++ assert.ok(!page.includes('SECRET-TITLE'));
++ assert.ok(!page.includes('live.internal.tick'));
++ assert.match(page, /<b>4<\/b><span>events this week<\/span>/);
++ assert.match(page, / 2 OpenVibe services published to it this week\./);
++});
++
++t('JSON-LD: WebSite, the app, and a FAQPage with exactly the questions shown', async () => {
++ const page = await html();
++ const ld = [...page.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
++ for (const type of ['WebSite', 'WebApplication', 'FAQPage']) assert.ok(ld.some((x) => x['@type'] === type), type);
++ const faq = (/<section class="sc-sec" id="faq"[\s\S]*?<\/section>/.exec(page) || [''])[0];
++ const shown = [...faq.matchAll(/<h3>([^<]+)<\/h3>/g)].map((m) => m[1].replace(/&#39;/g, "'"));
++ assert.ok(shown.length >= 6);
++ assert.deepStrictEqual(ld.find((x) => x['@type'] === 'FAQPage').mainEntity.map((q) => q.name), shown);
++ const description = /<meta name="description" content="([^"]*)"/.exec(page)[1];
++ assert.ok(description.length <= 160, `description ${description.length} chars`);
++});
++
++t('stop', async () => { await h.stop(); });
++
++t.run();
ddiff --git a/test/home.test.js b/test/home.test.js
iindex 2cbdd6d..cfd153a 100644
---- a/test/home.test.js
++++ b/test/home.test.js
@@@ -161,7 +161,7 @@ t('llms.txt is a plain-text map that starts with the site name', async () => {
assert.strictEqual(r.status, 200);
assert.match(r.headers['content-type'], /^text\/plain/);
assert.ok(r.body.startsWith('# OpenVibe.Events'), r.body.slice(0, 40));
-- assert.ok(r.body.includes('> Durable events for the OpenVibe network'), 'the home summary');
++ assert.ok(r.body.includes("> The OpenVibe network's event bus"), 'the home summary');
assert.ok(r.body.includes('https://github.com/OpenVibers/OpenVibe.Events#readme'), 'the guide');
assert.ok(r.body.includes(`The API and the realtime gateway are at ${SITE}`), 'the discovery names the API origin');
});
@@@ -171,7 +171,7 @@ t('llms-full.txt is the same map in full text, starting with the site name', asy
assert.strictEqual(r.status, 200);
assert.match(r.headers['content-type'], /^text\/plain/);
assert.ok(r.body.startsWith('# OpenVibe.Events'), r.body.slice(0, 40));
-- assert.ok(r.body.includes('> Durable events for the OpenVibe network'), 'the home summary');
++ assert.ok(r.body.includes("> The OpenVibe network's event bus"), 'the home summary');
assert.ok(r.body.includes('## Start here'), 'the /llms.txt sections as pages');
assert.ok(r.body.includes('### Guide'), 'each page with its full text');
});