openvibers/openvibe.host Commits
Host: the dedicated section's link counts every dedicated server, not one page (#297)
Changes
@@@ -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`.@@@ -234,13 +234,16 @@ function createCatalog({ store, community = null, log = console, indexnow = nullconst 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);@@@ -89,7 +89,7 @@ function createCatalogViews({ catalog, benchmarks, community, config, log = consconst 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',@@@ -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>@@@ -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;
@@ -33,7 +33,7 @@ OpenVibe's own static hosting moved to OpenVibe.Space ([below](#static-hosting-m
@@ -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.
- **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`).
- **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`.
- **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.
- **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`.
- **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`.
- **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`.
@@ -234,13 +234,16 @@ function createCatalog({ store, community = null, log = console, indexnow = null
@@ -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}`);
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);
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);
const offset = query.cursor == null ? 0 : Number(query.cursor);
if (!Number.isSafeInteger(offset) || offset < 0) invalid('Invalid cursor');
if (!Number.isSafeInteger(offset) || offset < 0) invalid('Invalid cursor');
// Affiliation is deliberately absent from the ORDER BY expression.
// 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) {
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);
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);
@@ -89,7 +89,7 @@ function createCatalogViews({ catalog, benchmarks, community, config, log = cons
@@ -89,7 +89,7 @@ function createCatalogViews({ catalog, benchmarks, community, config, log = cons
const dedicatedQuery = query.kind ? null : { kind: 'dedicated', currency: query.currency || 'USD' };
const dedicatedQuery = query.kind ? null : { kind: 'dedicated', currency: query.currency || 'USD' };
const [result, providers, currencies, dedicated] = await Promise.all([
const [result, providers, currencies, dedicated] = await Promise.all([
catalog.plans(query), catalog.providers(), catalog.currencies(),
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 {
return {
title: 'Compare hosting plans',
title: 'Compare hosting plans',
@@ -4,7 +4,8 @@
@@ -4,7 +4,8 @@
* The hosting catalog's pages (plan T20 step 3: hosting compared). Server-rendered, complete without JavaScript:
* 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)
* 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
* provider(p) /catalog/providers/:slug
* plan(p) /catalog/plans/:id
* plan(p) /catalog/plans/:id
* promos(list) /catalog/promos
* promos(list) /catalog/promos
@@ -223,7 +224,7 @@ function method(hasAffiliate) {
@@ -223,7 +224,7 @@ function method(hasAffiliate) {
</section>`;
</section>`;
}
}
-function catalog({ result, query, providers, currencies, dedicated = [] }) {
+function catalog({ result, query, providers, currencies, dedicated = { plans: [], total: 0 } }) {
const plans = result.plans;
const plans = result.plans;
const filtered = Object.keys(query || {}).some((k) => k !== 'sort' && k !== 'cursor' && query[k]);
const filtered = Object.keys(query || {}).some((k) => k !== 'sort' && k !== 'cursor' && query[k]);
const empty = plans.length ? '' : filtered
const empty = plans.length ? '' : filtered
@@ -232,11 +233,11 @@ function catalog({ result, query, providers, currencies, dedicated = [] }) {
@@ -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>` : '';
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
// 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.
// 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>
<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>
<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>` : '';
</section>` : '';
return `<header class="hc-head">
return `<header class="hc-head">
<p class="hc-eyebrow">OpenVibe.Host · hosting compared · alpha</p>
<p class="hc-eyebrow">OpenVibe.Host · hosting compared · alpha</p>
@@ -1,5 +1,6 @@
@@ -1,5 +1,6 @@
'use strict';
'use strict';
const assert = require('assert');
const assert = require('assert');
+const render = require('../server/render/catalog');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
(async () => {
(async () => {
const t = await boot();
const t = await boot();
@@ -104,6 +105,12 @@ const { boot, check, done } = require('./helpers/boot');
@@ -104,6 +105,12 @@ const { boot, check, done } = require('./helpers/boot');
const only = await t.api('GET', '/catalog?kind=dedicated');
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.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');
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 () => {
await check('the sitemap dates catalog pages by their newest price check', async () => {
const xml = (await t.api('GET', '/sitemap.xml')).text;
const xml = (await t.api('GET', '/sitemap.xml')).text;