openvibers/openvibe.host Commits

Host: the dedicated section's link counts every dedicated server, not one page (#297)

OP OpenVibers committed 7 days ago · 8a1787534b · 5 files, +22 −11

The comparison page's "All N dedicated servers" link used the length of the
first page of dedicated plans, which the store caps at 50, so with OVHcloud's
109 bare-metal plans live the page said "All 50". plan listings now return
`total`, the count of everything the filters match, and the section links with
that. The public GET /api/v1/catalog/plans answer carries the same `total`.

Changes

ddiff --git a/README.md b/README.md
iindex d452dae..6b10706 100644
---- a/README.md
++++ b/README.md
@@@ -33,7 +33,7 @@ OpenVibe's own static hosting moved to OpenVibe.Space ([below](#static-hosting-m
- **Benchmarks** (`/catalog/benchmarks`, `/catalog/benchmarks/:id`, `/catalog/benchmarks/new`; `GET /api/v1/catalog/benchmarks`, `/benchmarks/:id`, `POST /benchmarks/links`, `DELETE /benchmarks/:id`). The owner's "benchmarks … user submittable" (Q21): a signed-in person names the provider (a catalog provider or plan, or another provider by name) and gets a one-time link (24 hours, 10 a day); `curl -sL https://yabs.sh | bash -s -- -s "<link>"` runs YABS on their server, which posts its JSON to `POST /api/v1/catalog/benchmarks/yabs/:token` and prints Host's one-line answer (the result's address). `server/catalog/benchmarks.js` reads the JSON field by field (CPU, memory, disk, the newest Geekbench with only a browser.geekbench.com link, fio in MB/s and IOPS, iperf3, the city and network YABS looked up; never the server's address) into `catalog_benchmarks` (migration 0012). Results list newest first or by Geekbench or 4k IOPS, filter by provider or plan; plan and provider pages show the medians. The submitter is never shown; they, or staff, can remove a result; account export carries results and links, and erasure deletes the links and leaves the results anonymous.
- **Order.** It comes only from the visitor's sort: price, per vCPU, per GB of RAM, per GB of disk, or recently checked. Affiliate links carry `rel="sponsored nofollow noopener"` with the provider's own disclosure beside them, and never change the order (`test/catalog-public.test.js`).
- **Staff.** `host.catalog.manage` (Network staff only) covers `POST`/`PATCH /api/v1/catalog/{providers,plans,promos}` and the no-JS forms at `/catalog/manage`.
--- **Public reads.** `GET /api/v1/catalog/plans` (filters `kind`, `min_vcpu`, `min_ram_mb`, `min_disk_gb`, `max_price`, `currency`, `location`, `provider`; `sort`; cursor), `/plans/:id` (with price history and its trend), `/plans/:id/price-trend`, `/tlds/:tld/price-trend`, `/providers`, `/providers/:slug` and `/promos`.
++- **Public reads.** `GET /api/v1/catalog/plans` (filters `kind`, `min_vcpu`, `min_ram_mb`, `min_disk_gb`, `max_price`, `currency`, `location`, `provider`; `sort`; cursor; the answer's `total` is every plan the filters match, not just this page's 50), `/plans/:id` (with price history and its trend), `/plans/:id/price-trend`, `/tlds/:tld/price-trend`, `/providers`, `/providers/:slug` and `/promos`.
- **Search.** Every public plan and provider page is an OpenVibe.Search document (`host.index_document.upserted|deleted`, `server/catalog/search-index.js` through openvibe-publishing/search-feed): sent a minute after boot, hourly, after each catalog pull and shortly after a staff change; only changed pages are sent, and a hidden or gone entry becomes a tombstone.
- **IndexNow.** With `INDEXNOW_KEY`, a catalog pull that adds a plan or a provider, or changes a plan's price, sends those pages and `/sitemap.xml` to search engines in one batch (a re-check at the same price sends nothing); a new offer post sends `/catalog/offers`. The key is served at `/<key>.txt`.
- **Pages.** `/catalog`, `/catalog/providers`, `/catalog/providers/:slug`, `/catalog/plans/:id`, `/catalog/promos`, `/catalog/offers`, `/catalog/domains` (with each ending's page and the name check) and `/catalog/benchmarks`. They are server-rendered and work without JavaScript; the styles are `public/css/catalog.css`.
ddiff --git a/server/catalog/store.js b/server/catalog/store.js
iindex 647e05f..1117266 100644
---- a/server/catalog/store.js
++++ b/server/catalog/store.js
@@@ -234,13 +234,16 @@ function createCatalog({ store, community = null, log = console, indexnow = null
const n = Number(query[key]); if (!Number.isFinite(n) || n < 0 || query[key] === '') invalid(`Invalid ${key}`);
where += ` AND ${col} ${key === 'max_price' ? '<=' : '>='} ?`; args.push(n);
}
-- // The stale cutoff is the last of the ORDER BY placeholders, so it goes after the filters.
-- if (sorts[sort].includes('?')) args.push(freshCutoff(store.now()));
++ // The stale cutoff is the last of the ORDER BY placeholders, so it goes after the filters: the count below
++ // has no ORDER BY and takes the filter arguments alone.
++ const order = sorts[sort].includes('?') ? [...args, freshCutoff(store.now())] : args;
const offset = query.cursor == null ? 0 : Number(query.cursor);
if (!Number.isSafeInteger(offset) || offset < 0) invalid('Invalid cursor');
// Affiliation is deliberately absent from the ORDER BY expression.
-- const rows = await db.prepare(`SELECT p.*, v.slug AS provider_slug, v.name AS provider_name, v.affiliate_url, v.affiliate_note FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${where} ORDER BY ${sorts[sort]} ASC NULLS LAST, p.id ASC LIMIT 51 OFFSET ${offset}`).all(...args);
-- return { plans: rows.slice(0, 50).map(planOut), next_cursor: rows.length > 50 ? String(offset + 50) : null };
++ const rows = await db.prepare(`SELECT p.*, v.slug AS provider_slug, v.name AS provider_name, v.affiliate_url, v.affiliate_note FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${where} ORDER BY ${sorts[sort]} ASC NULLS LAST, p.id ASC LIMIT 51 OFFSET ${offset}`).all(...order);
++ // Every plan the filters match, not just the 50 a page carries, so a page can say how many there are.
++ const counted = await db.prepare(`SELECT COUNT(*) AS n FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${where}`).get(...args);
++ return { plans: rows.slice(0, 50).map(planOut), next_cursor: rows.length > 50 ? String(offset + 50) : null, total: Number(counted.n) || 0 };
}
async function plan(id) {
const p = await db.prepare("SELECT p.*, v.slug AS provider_slug, v.name AS provider_name, v.homepage_url, v.affiliate_url, v.affiliate_note FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.id = ? AND p.status = 'active' AND v.status = 'active'").get(id);
ddiff --git a/server/http/catalog-views.js b/server/http/catalog-views.js
iindex 2148883..36609cd 100644
---- a/server/http/catalog-views.js
++++ b/server/http/catalog-views.js
@@@ -89,7 +89,7 @@ function createCatalogViews({ catalog, benchmarks, community, config, log = cons
const dedicatedQuery = query.kind ? null : { kind: 'dedicated', currency: query.currency || 'USD' };
const [result, providers, currencies, dedicated] = await Promise.all([
catalog.plans(query), catalog.providers(), catalog.currencies(),
-- dedicatedQuery ? catalog.plans(dedicatedQuery).then((r) => r.plans) : Promise.resolve([]),
++ dedicatedQuery ? catalog.plans(dedicatedQuery).then((r) => ({ plans: r.plans, total: r.total })) : Promise.resolve({ plans: [], total: 0 }),
]);
return {
title: 'Compare hosting plans',
ddiff --git a/server/render/catalog.js b/server/render/catalog.js
iindex dbfeeb6..6cf021e 100644
---- a/server/render/catalog.js
++++ b/server/render/catalog.js
@@@ -4,7 +4,8 @@
* The hosting catalog's pages (plan T20 step 3: hosting compared). Server-rendered, complete without JavaScript:
*
* front({ plans, providers, promos, stats, domains }) the front page of openvibe.host (`domains`: render/domains.js frontSection)
-- * catalog({ result, query, providers }) /catalog: the filterable comparison table
++ * catalog({ result, query, providers, currencies, dedicated }) /catalog: the filterable comparison table, and the
++ * dedicated plans' own section (`dedicated`: { plans, total })
* provider(p) /catalog/providers/:slug
* plan(p) /catalog/plans/:id
* promos(list) /catalog/promos
@@@ -223,7 +224,7 @@ function method(hasAffiliate) {
</section>`;
}
--function catalog({ result, query, providers, currencies, dedicated = [] }) {
++function catalog({ result, query, providers, currencies, dedicated = { plans: [], total: 0 } }) {
const plans = result.plans;
const filtered = Object.keys(query || {}).some((k) => k !== 'sort' && k !== 'cursor' && query[k]);
const empty = plans.length ? '' : filtered
@@@ -232,11 +233,11 @@ function catalog({ result, query, providers, currencies, dedicated = [] }) {
const next = result.next_cursor ? `<p class="hc-more">${link(`/catalog?${new URLSearchParams({ ...query, cursor: result.next_cursor })}`, 'Next 50 plans →', 'button hc-button-quiet')}</p>` : '';
// The catalog filter already has a Dedicated kind; when there are dedicated plans, the comparison page also
// shows them as their own section, with the same table. A kind=dedicated view is that table, so no second copy.
-- const dedicatedSection = dedicated.length ? `<section aria-labelledby="hc-dedicated-h">
++ const dedicatedSection = dedicated.plans.length ? `<section aria-labelledby="hc-dedicated-h">
<h2 id="hc-dedicated-h">Dedicated servers compared</h2>
<p class="hc-dim">Bare-metal plans from providers' official catalogs, with the same columns and the day each price was checked.</p>
--${planTable(dedicated.slice(0, 10), { caption: `Dedicated servers, lowest price first${dedicated.length > 10 ? ' (10 shown)' : ''}` })}
--${dedicated.length > 10 ? `<p class="hc-more">${link('/catalog?kind=dedicated', `All ${dedicated.length} dedicated servers →`, 'button hc-button-quiet')}</p>` : ''}
++${planTable(dedicated.plans.slice(0, 10), { caption: `Dedicated servers, lowest price first${dedicated.plans.length > 10 ? ' (10 shown)' : ''}` })}
++${dedicated.plans.length > 10 ? `<p class="hc-more">${link('/catalog?kind=dedicated', `All ${dedicated.total} dedicated servers →`, 'button hc-button-quiet')}</p>` : ''}
</section>` : '';
return `<header class="hc-head">
<p class="hc-eyebrow">OpenVibe.Host · hosting compared · alpha</p>
ddiff --git a/test/catalog-public.test.js b/test/catalog-public.test.js
iindex 7f7959f..66284eb 100644
---- a/test/catalog-public.test.js
++++ b/test/catalog-public.test.js
@@@ -1,5 +1,6 @@
'use strict';
const assert = require('assert');
++const render = require('../server/render/catalog');
const { boot, check, done } = require('./helpers/boot');
(async () => {
const t = await boot();
@@@ -104,6 +105,12 @@ const { boot, check, done } = require('./helpers/boot');
const only = await t.api('GET', '/catalog?kind=dedicated');
assert.ok(only.text.includes(`href="/catalog/plans/${metal.id}"`), 'the dedicated view lists it');
assert.doesNotMatch(only.text, /Dedicated servers compared/, 'the dedicated view is the table itself, not a second copy');
++ const list = (await t.api('GET', '/api/v1/catalog/plans?kind=dedicated')).json();
++ assert.strictEqual(list.total, 1, 'the list says how many plans match, not just how many this page carries');
++ // The link under the section counts every dedicated plan, not the 50 one page carries (109 from OVHcloud).
++ const eleven = Array.from({ length: 11 }, (_, i) => ({ ...list.plans[0], id: `metal-${i}` }));
++ const many = render.catalog({ result: { plans: [], next_cursor: null }, query: {}, providers: [], currencies: ['USD'], dedicated: { plans: eleven, total: 109 } });
++ assert.match(many, /All 109 dedicated servers →/, 'the link counts every dedicated server');
});
await check('the sitemap dates catalog pages by their newest price check', async () => {
const xml = (await t.api('GET', '/sitemap.xml')).text;