openvibers/openvibe.host Commits
Host: OVHcloud dedicated-server plans, with a dedicated section on the comparison page (#287)
Changes
@@@ -18,10 +18,13 @@ OpenVibe's own static hosting moved to OpenVibe.Space ([below](#static-hosting-m- **Where plans come from.**- OpenVibe.Sources items in category `hosting` (official provider APIs), read with `openvibe-publishing/ingest`: an event consumer at `/internal/catalog/events` plus a cursor pull. The switch is `HOST_CATALOG_INGEST` (default off) with `HOST_CATALOG_SOURCES_URL`.-- - The mapping is `server/catalog/mapping.js`, keyed by source: `linode-types`, `aws-lightsail-bundles` and `vultr-plans`.-- Lightsail's price list gives numbers as text and prices by the hour; a bundle's monthly price is its hourly rate for-- 744 hours (AWS bills up to that cap), and IPv6-only bundles show 0 IPv4 addresses. Vultr's API host disallows-- robots, so that Sources seed stays disabled.++ - The mapping is `server/catalog/mapping.js`, keyed by source: `linode-types`, `aws-lightsail-bundles` and `vultr-plans`,++ plus `ovhcloud-vps` and `ovhcloud-baremetal` and Azure. Lightsail's price list gives numbers as text and prices by++ the hour; a bundle's monthly price is its hourly rate for 744 hours (AWS bills up to that cap), and IPv6-only++ bundles show 0 IPv4 addresses. OVHcloud's dedicated catalog keeps a plan's default memory, storage and bandwidth++ addons apart from the plan, so the Sources mapping joins each default addon to its own product; Host multiplies the++ socket count by the cores and one disk's size by the disk count. Vultr's API host disallows robots, so that++ Sources seed stays disabled.- Staff entries, which must name their source URL.- Nothing is estimated. A value the source does not state is shown as Unknown.- **Offers** (`/catalog/offers`, `GET /api/v1/catalog/offers`, a block on the front page). What providers post on LowEndSpirit's Offers board, from its public feed through Sources (`lowendspirit-offers`; `server/catalog/offers.js`). LowEndTalk's and LowEndBox's feeds (`lowendtalk-offers`, `lowendbox-posts`) answer 403 to the production host's network (2026-10-18), so those sources are off. Host keeps the headline, poster, date and link, and the cheapest "per month" or "per year" price the headline (or the post's opening) states, as stated, with its monthly equivalent (a yearly price divided by 12). LowEndBox editorials and giveaways are not offers; a post the source removes is hidden; offers older than 120 days drop off. Newest first, or cheapest a month within one currency (`sort=price` needs `currency`: nothing is converted); filters `currency`, `max_monthly`, `source`, `priced=1`.@@@ -7,6 +7,7 @@ const SOURCES = Object.freeze({'linode-types': { slug: 'linode', name: 'Linode', homepage_url: 'https://www.linode.com/', source_url: 'https://www.linode.com/pricing/' },'aws-lightsail-bundles': { slug: 'aws-lightsail', name: 'Amazon Lightsail', homepage_url: 'https://aws.amazon.com/lightsail/', source_url: 'https://aws.amazon.com/lightsail/pricing/' },'ovhcloud-vps': { slug: 'ovhcloud', name: 'OVHcloud', homepage_url: 'https://us.ovhcloud.com/', source_url: 'https://us.ovhcloud.com/vps/' },++ 'ovhcloud-baremetal': { slug: 'ovhcloud', name: 'OVHcloud', homepage_url: 'https://us.ovhcloud.com/', source_url: 'https://us.ovhcloud.com/bare-metal/prices/' },'azure-retail-prices': { slug: 'azure', name: 'Microsoft Azure', homepage_url: 'https://azure.microsoft.com/', source_url: 'https://azure.microsoft.com/en-us/pricing/' },});const number = (v, field) => {@@@ -70,6 +71,44 @@ function ovhcloud(x, id, title) {price_monthly: monthly, price_hourly: null, ipv4: null, locations: null,};}++// OVHcloud's dedicated-server catalogs (Sources `ovhcloud-baremetal`): the plan's CPU is on its product, while its++// default memory, storage and bandwidth are addons of that plan, each with a product of its own. The source joins++// them and gives the size of one disk and the number of disks; the plan's total is their product. A socket count is++// usually stated (two in a dual-socket HGR); where the catalog omits it the CPU is one socket, so the cores stay as++// the catalog gives them. The installation fee is the catalog's phase 0 none price, which OVHcloud states beside the++// monthly price on its own page ($204 and $204 for Advance-1 2026). Prices are hundred-millionths of a dollar.++function ovhBaremetal(x, id, title) {++ if (x.currency !== 'USD') throw new TypeError('OVHcloud price is not in USD');++ const units = number(x.price_monthly, 'monthly price');++ if (units == null) throw new TypeError('missing monthly price');++ const monthly = Math.round(units / 1e6) / 100;++ if (monthly <= 0) return null;++ const setupUnits = number(x.setup_fee, 'setup fee');++ const setup = setupUnits == null ? null : Math.round(setupUnits / 1e6) / 100;++ const cores = number(x.cpu_cores, 'cpu cores');++ const sockets = number(x.cpu_sockets, 'cpu sockets');++ const memoryGb = number(x.memory_gb, 'memory');++ const perDisk = number(x.disk_capacity_gb, 'disk capacity');++ const diskCount = number(x.disk_count, 'disk count');++ const diskGb = perDisk == null || diskCount == null ? null : perDisk * diskCount;++ const iface = String(x.disk_interface || '').toLowerCase();++ const technology = String(x.disk_technology || '').toLowerCase();++ const diskType = diskGb > 0++ ? (/nvme/.test(iface) ? 'nvme' : /ssd/.test(iface) || /ssd/.test(technology) ? 'ssd' : /hdd|sas|sata/.test(iface) || /hdd|sas|sata/.test(technology) ? 'hdd' : null)++ : null;++ const limit = number(x.bandwidth_limit, 'bandwidth limit');++ return {++ name: (typeof title === 'string' && title.trim()) || id, kind: 'dedicated',++ vcpu: cores == null ? null : cores * (sockets == null ? 1 : sockets),++ ram_mb: memoryGb == null ? null : Math.round(memoryGb * 1024),++ disk_gb: diskGb, disk_type: diskType,++ bandwidth_gb: null, unmetered: limit == null ? null : limit === 0, port_mbps: number(x.bandwidth_mbps, 'bandwidth'),++ price_monthly: monthly, price_hourly: null, ipv4: null,++ locations: Array.isArray(x.locations) && x.locations.length ? x.locations : null,++ setup_fee: setup,++ };++}++// Vultr's plan types as its pricing page names them; a plan is named from its type, CPU vendor, vCPUs and memory// ("High Performance AMD · 1 vCPU, 1 GB"), never by its API id alone.const VULTR_TYPES = Object.freeze({ vc2: 'Cloud Compute', vhf: 'High Frequency', vhp: 'High Performance', voc: 'Optimized Cloud', vcg: 'Cloud GPU', vbm: 'Bare Metal', vdc: 'Dedicated Cloud' });@@@ -118,7 +157,7 @@ function mapItem(item) {// Every price the mappers below read is the source's monthly price (Lightsail's and Azure's hourly rates// become a month's worth, OVHcloud is its commitment=0 one-month price), so the row's term is monthly. No// source states a renewal price for a server plan, so it stays unknown rather than guessed.-- price_term: 'monthly', renewal_price: null,++ price_term: 'monthly', renewal_price: null, setup_fee: null,};if (item.source_key === 'aws-lightsail-bundles') return { ...common, ...lightsail(x, id) };if (item.source_key === 'azure-retail-prices') {@@@ -135,6 +174,10 @@ function mapItem(item) {const plan = ovhcloud(x, id, item.title);return plan && { ...common, ...plan };}++ if (item.source_key === 'ovhcloud-baremetal') {++ const plan = ovhBaremetal(x, id, item.title);++ return plan && { ...common, ...plan };++ }const vultr = item.source_key === 'vultr-plans';const monthly = number(vultr ? x.monthly_cost : x.price_monthly, 'monthly price');if (monthly == null) throw new TypeError('missing monthly price');@@@ -138,7 +138,7 @@ function createCatalog({ store, community = null, log = console, indexnow = nullfresh.push(`/catalog/providers/${encodeURIComponent(v.provider.slug)}`);}const old = await t.prepare('SELECT * FROM catalog_plans WHERE provider_id = ? AND external_id = ?').get(p.id, v.external_id);-- const cols = ['name', 'kind', 'vcpu', 'ram_mb', 'disk_gb', 'disk_type', 'bandwidth_gb', 'unmetered', 'port_mbps', 'ipv4', 'locations', 'currency', 'price_monthly', 'price_hourly', 'price_term', 'renewal_price', 'source_key', 'source_item_id', 'source_url', 'checked_at'];++ const cols = ['name', 'kind', 'vcpu', 'ram_mb', 'disk_gb', 'disk_type', 'bandwidth_gb', 'unmetered', 'port_mbps', 'ipv4', 'locations', 'currency', 'price_monthly', 'price_hourly', 'price_term', 'renewal_price', 'setup_fee', 'source_key', 'source_item_id', 'source_url', 'checked_at'];if (old) {if (old.origin !== 'sources' || v.checked_at < Number(old.checked_at)) return 'hold';if (Number(old.price_monthly) !== v.price_monthly || old.currency !== v.currency) {@@@ -84,10 +84,16 @@ function createCatalogViews({ catalog, benchmarks, community, config, log = cons}async function index(query = {}) {-- const [result, providers, currencies] = await Promise.all([catalog.plans(query), catalog.providers(), catalog.currencies()]);++ // The comparison table is whatever the filters ask for; dedicated plans also get their own section, unless++ // the reader already asked for that kind (then the table itself is the dedicated view).++ 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([]),++ ]);return {title: 'Compare hosting plans',-- body: render.catalog({ result, query, providers, currencies }),++ body: render.catalog({ result, query, providers, currencies, dedicated }),indexable: !Object.keys(query).length,description: 'Compare cloud, VPS and dedicated hosting plans: price per month, per vCPU and per GB, each with the day it was checked and its source.',jsonLd: render.jsonLd.catalog(baseUrl, result.plans),@@@ -223,13 +223,21 @@ function method(hasAffiliate) {</section>`;}--function catalog({ result, query, providers, currencies }) {++function catalog({ result, query, providers, currencies, dedicated = [] }) {const plans = result.plans;const filtered = Object.keys(query || {}).some((k) => k !== 'sort' && k !== 'cursor' && query[k]);const empty = plans.length ? '' : filtered? `<div class="hc-empty"><p><strong>No plan matches these filters.</strong></p><p>${link('/catalog', 'Clear the filters')} or widen one of them.</p></div>`: `<div class="hc-empty"><p><strong>The catalog is filling up.</strong></p><p>Plans arrive from providers' official APIs as each source is checked and enabled, and from staff entries with their source pages.</p></div>`;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">++<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>` : ''}++</section>` : '';return `<header class="hc-head"><p class="hc-eyebrow">OpenVibe.Host · hosting compared · alpha</p><h1>Compare hosting plans</h1>@@@ -238,6 +246,7 @@ function catalog({ result, query, providers, currencies }) {${filters(query, providers, currencies)}${planTable(plans, { caption: `${plans.length}${result.next_cursor ? '+' : ''} plan${plans.length === 1 ? '' : 's'}, sorted by ${(SORTS.find(([v]) => v === (query.sort || 'price')) || SORTS[0])[1].toLowerCase()}` })}${empty}${next}++${dedicatedSection}<p class="hc-links">${link('/catalog/promos', 'Current promotions')} · ${link('/catalog/providers', 'All providers')}</p>${method(plans.some((p) => p.affiliate_url))}`;}@@@ -65,6 +65,32 @@ const item = (key, id, fields, time = at, title = id) => ({ id: `itm_${'0'.repeaconst row = (await catalog.plans({ provider: 'ovhcloud' })).plans[0];assert.deepStrictEqual([row.name, Number(row.price_monthly), row.unmetered, Number(row.port_mbps), row.bandwidth_gb], ['VPS-1 2027', 5.35, true, 500, null]);});++ await check('an OVHcloud dedicated plan maps from the source join: sockets multiply cores, one disk size times the count, and the installation fee is kept', async () => {++ const base = {++ product: '24adv01', currency: 'USD', range: 'advance',++ cpu_cores: 6, cpu_sockets: 1, cpu_model: 'EPYC 4244P', cpu_brand: 'AMD', memory_gb: 32,++ disk_capacity_gb: 960, disk_count: 2, disk_interface: 'NVMe', disk_technology: 'SSD',++ bandwidth_mbps: 3000, bandwidth_limit: 0, price_monthly: 14700000000, setup_fee: 14700000000,++ locations: ['vin', 'hil'],++ };++ const p = mapItem(item('ovhcloud-baremetal', '24adv01-v3-us', base, at, 'ADVANCE-1 | AMD EPYC 4244P'));++ assert.deepStrictEqual([p.provider.slug, p.name, p.kind, p.vcpu, p.ram_mb, p.disk_gb, p.disk_type, p.bandwidth_gb, p.unmetered, p.port_mbps, p.price_monthly, p.price_hourly, p.setup_fee, p.locations, p.source_url],++ ['ovhcloud', 'ADVANCE-1 | AMD EPYC 4244P', 'dedicated', 6, 32768, 1920, 'nvme', null, true, 3000, 147, null, 147, ['vin', 'hil'], 'https://us.ovhcloud.com/bare-metal/prices/']);++ const dual = mapItem(item('ovhcloud-baremetal', 'dual', { ...base, cpu_cores: 24, cpu_sockets: 2, memory_gb: 256, disk_capacity_gb: 3840, disk_count: 6, price_monthly: 165500000000, setup_fee: 165500000000 }));++ assert.deepStrictEqual([dual.vcpu, dual.ram_mb, dual.disk_gb, dual.unmetered, dual.price_monthly, dual.setup_fee], [48, 262144, 23040, true, 1655, 1655]);++ // A legacy HG product states 28 cores with no socket count: one socket, not an invented second one.++ const hg = mapItem(item('ovhcloud-baremetal', 'hg', { ...base, cpu_cores: 28, cpu_sockets: null, setup_fee: 0 }));++ assert.deepStrictEqual([hg.vcpu, hg.setup_fee], [28, 0]);++ // A Scale plan sold with no drives states 0 and 0; the comparison shows 0 GB, not Unknown.++ const bare = mapItem(item('ovhcloud-baremetal', 'bare', { ...base, disk_capacity_gb: 0, disk_count: 0 }));++ assert.deepStrictEqual([bare.disk_gb, bare.disk_type], [0, null]);++ assert.throws(() => mapItem(item('ovhcloud-baremetal', 'x', { ...base, currency: 'EUR' })), /not in USD/);++ assert.throws(() => mapItem(item('ovhcloud-baremetal', 'x', { ...base, price_monthly: null })), /missing monthly price/);++ assert.strictEqual(mapItem(item('ovhcloud-baremetal', 'x', { ...base, price_monthly: 0 })), null, 'a zero price is not listed');++ await catalog.applyItem(item('ovhcloud-baremetal', '24adv01-v3-us', base, at, 'ADVANCE-1 | AMD EPYC 4244P'));++ const row = (await catalog.plans({ provider: 'ovhcloud', kind: 'dedicated' })).plans[0];++ assert.deepStrictEqual([row.name, row.kind, Number(row.price_monthly), Number(row.setup_fee), row.source_key, row.locations], ['ADVANCE-1 | AMD EPYC 4244P', 'dedicated', 147, 147, 'ovhcloud-baremetal', ['vin', 'hil']]);++ });await check('upsert preserves first seen and records only a changed price', async () => {const raw = { vcpu_count: 1, ram: 1024, disk: 25, bandwidth: 1000, monthly_cost: 5, hourly_cost: 0.007, type: 'vc2' };await catalog.applyItem(item('vultr-plans', 'vc2-1c-1gb', raw));@@@ -96,6 +96,15 @@ const { boot, check, done } = require('./helpers/boot');assert.ok(!types((await t.api('GET', '/catalog?min_vcpu=2')).text).includes('ItemList'), 'a filtered view carries no JSON-LD');const promos = await t.api('GET', '/api/v1/catalog/promos'); assert.strictEqual(promos.json().promos.length, 1);});++ await check('the comparison page shows dedicated plans in their own section, and kind=dedicated is that table once', async () => {++ const metal = await plan(p, 'metal', 120, { kind: 'dedicated', vcpu: 8, ram_mb: 32768, disk_gb: 960, disk_type: 'nvme', setup_fee: 120 });++ const page = await t.api('GET', '/catalog');++ assert.match(page.text, /Dedicated servers compared/);++ assert.ok(page.text.includes(`href="/catalog/plans/${metal.id}"`), 'the dedicated plan is linked');++ 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');++ });await check('the sitemap dates catalog pages by their newest price check', async () => {const xml = (await t.api('GET', '/sitemap.xml')).text;const planUrl = xml.match(/<url><loc>[^<]*\/catalog\/plans\/[^<]+<\/loc><lastmod>(\d{4}-\d{2}-\d{2})<\/lastmod><\/url>/);
@@ -18,10 +18,13 @@ OpenVibe's own static hosting moved to OpenVibe.Space ([below](#static-hosting-m
@@ -18,10 +18,13 @@ OpenVibe's own static hosting moved to OpenVibe.Space ([below](#static-hosting-m
- **Where plans come from.**
- **Where plans come from.**
- OpenVibe.Sources items in category `hosting` (official provider APIs), read with `openvibe-publishing/ingest`: an event consumer at `/internal/catalog/events` plus a cursor pull. The switch is `HOST_CATALOG_INGEST` (default off) with `HOST_CATALOG_SOURCES_URL`.
- OpenVibe.Sources items in category `hosting` (official provider APIs), read with `openvibe-publishing/ingest`: an event consumer at `/internal/catalog/events` plus a cursor pull. The switch is `HOST_CATALOG_INGEST` (default off) with `HOST_CATALOG_SOURCES_URL`.
- - The mapping is `server/catalog/mapping.js`, keyed by source: `linode-types`, `aws-lightsail-bundles` and `vultr-plans`.
- Lightsail's price list gives numbers as text and prices by the hour; a bundle's monthly price is its hourly rate for
- 744 hours (AWS bills up to that cap), and IPv6-only bundles show 0 IPv4 addresses. Vultr's API host disallows
- robots, so that Sources seed stays disabled.
+ - The mapping is `server/catalog/mapping.js`, keyed by source: `linode-types`, `aws-lightsail-bundles` and `vultr-plans`,
+ plus `ovhcloud-vps` and `ovhcloud-baremetal` and Azure. Lightsail's price list gives numbers as text and prices by
+ the hour; a bundle's monthly price is its hourly rate for 744 hours (AWS bills up to that cap), and IPv6-only
+ bundles show 0 IPv4 addresses. OVHcloud's dedicated catalog keeps a plan's default memory, storage and bandwidth
+ addons apart from the plan, so the Sources mapping joins each default addon to its own product; Host multiplies the
+ socket count by the cores and one disk's size by the disk count. Vultr's API host disallows robots, so that
+ Sources seed stays disabled.
- Staff entries, which must name their source URL.
- Staff entries, which must name their source URL.
- Nothing is estimated. A value the source does not state is shown as Unknown.
- Nothing is estimated. A value the source does not state is shown as Unknown.
- **Offers** (`/catalog/offers`, `GET /api/v1/catalog/offers`, a block on the front page). What providers post on LowEndSpirit's Offers board, from its public feed through Sources (`lowendspirit-offers`; `server/catalog/offers.js`). LowEndTalk's and LowEndBox's feeds (`lowendtalk-offers`, `lowendbox-posts`) answer 403 to the production host's network (2026-10-18), so those sources are off. Host keeps the headline, poster, date and link, and the cheapest "per month" or "per year" price the headline (or the post's opening) states, as stated, with its monthly equivalent (a yearly price divided by 12). LowEndBox editorials and giveaways are not offers; a post the source removes is hidden; offers older than 120 days drop off. Newest first, or cheapest a month within one currency (`sort=price` needs `currency`: nothing is converted); filters `currency`, `max_monthly`, `source`, `priced=1`.
- **Offers** (`/catalog/offers`, `GET /api/v1/catalog/offers`, a block on the front page). What providers post on LowEndSpirit's Offers board, from its public feed through Sources (`lowendspirit-offers`; `server/catalog/offers.js`). LowEndTalk's and LowEndBox's feeds (`lowendtalk-offers`, `lowendbox-posts`) answer 403 to the production host's network (2026-10-18), so those sources are off. Host keeps the headline, poster, date and link, and the cheapest "per month" or "per year" price the headline (or the post's opening) states, as stated, with its monthly equivalent (a yearly price divided by 12). LowEndBox editorials and giveaways are not offers; a post the source removes is hidden; offers older than 120 days drop off. Newest first, or cheapest a month within one currency (`sort=price` needs `currency`: nothing is converted); filters `currency`, `max_monthly`, `source`, `priced=1`.
@@ -7,6 +7,7 @@ const SOURCES = Object.freeze({
@@ -7,6 +7,7 @@ const SOURCES = Object.freeze({
'linode-types': { slug: 'linode', name: 'Linode', homepage_url: 'https://www.linode.com/', source_url: 'https://www.linode.com/pricing/' },
'linode-types': { slug: 'linode', name: 'Linode', homepage_url: 'https://www.linode.com/', source_url: 'https://www.linode.com/pricing/' },
'aws-lightsail-bundles': { slug: 'aws-lightsail', name: 'Amazon Lightsail', homepage_url: 'https://aws.amazon.com/lightsail/', source_url: 'https://aws.amazon.com/lightsail/pricing/' },
'aws-lightsail-bundles': { slug: 'aws-lightsail', name: 'Amazon Lightsail', homepage_url: 'https://aws.amazon.com/lightsail/', source_url: 'https://aws.amazon.com/lightsail/pricing/' },
'ovhcloud-vps': { slug: 'ovhcloud', name: 'OVHcloud', homepage_url: 'https://us.ovhcloud.com/', source_url: 'https://us.ovhcloud.com/vps/' },
'ovhcloud-vps': { slug: 'ovhcloud', name: 'OVHcloud', homepage_url: 'https://us.ovhcloud.com/', source_url: 'https://us.ovhcloud.com/vps/' },
+ 'ovhcloud-baremetal': { slug: 'ovhcloud', name: 'OVHcloud', homepage_url: 'https://us.ovhcloud.com/', source_url: 'https://us.ovhcloud.com/bare-metal/prices/' },
'azure-retail-prices': { slug: 'azure', name: 'Microsoft Azure', homepage_url: 'https://azure.microsoft.com/', source_url: 'https://azure.microsoft.com/en-us/pricing/' },
'azure-retail-prices': { slug: 'azure', name: 'Microsoft Azure', homepage_url: 'https://azure.microsoft.com/', source_url: 'https://azure.microsoft.com/en-us/pricing/' },
});
});
const number = (v, field) => {
const number = (v, field) => {
@@ -70,6 +71,44 @@ function ovhcloud(x, id, title) {
@@ -70,6 +71,44 @@ function ovhcloud(x, id, title) {
price_monthly: monthly, price_hourly: null, ipv4: null, locations: null,
price_monthly: monthly, price_hourly: null, ipv4: null, locations: null,
};
};
}
}
+// OVHcloud's dedicated-server catalogs (Sources `ovhcloud-baremetal`): the plan's CPU is on its product, while its
+// default memory, storage and bandwidth are addons of that plan, each with a product of its own. The source joins
+// them and gives the size of one disk and the number of disks; the plan's total is their product. A socket count is
+// usually stated (two in a dual-socket HGR); where the catalog omits it the CPU is one socket, so the cores stay as
+// the catalog gives them. The installation fee is the catalog's phase 0 none price, which OVHcloud states beside the
+// monthly price on its own page ($204 and $204 for Advance-1 2026). Prices are hundred-millionths of a dollar.
+function ovhBaremetal(x, id, title) {
+ if (x.currency !== 'USD') throw new TypeError('OVHcloud price is not in USD');
+ const units = number(x.price_monthly, 'monthly price');
+ if (units == null) throw new TypeError('missing monthly price');
+ const monthly = Math.round(units / 1e6) / 100;
+ if (monthly <= 0) return null;
+ const setupUnits = number(x.setup_fee, 'setup fee');
+ const setup = setupUnits == null ? null : Math.round(setupUnits / 1e6) / 100;
+ const cores = number(x.cpu_cores, 'cpu cores');
+ const sockets = number(x.cpu_sockets, 'cpu sockets');
+ const memoryGb = number(x.memory_gb, 'memory');
+ const perDisk = number(x.disk_capacity_gb, 'disk capacity');
+ const diskCount = number(x.disk_count, 'disk count');
+ const diskGb = perDisk == null || diskCount == null ? null : perDisk * diskCount;
+ const iface = String(x.disk_interface || '').toLowerCase();
+ const technology = String(x.disk_technology || '').toLowerCase();
+ const diskType = diskGb > 0
+ ? (/nvme/.test(iface) ? 'nvme' : /ssd/.test(iface) || /ssd/.test(technology) ? 'ssd' : /hdd|sas|sata/.test(iface) || /hdd|sas|sata/.test(technology) ? 'hdd' : null)
+ : null;
+ const limit = number(x.bandwidth_limit, 'bandwidth limit');
+ return {
+ name: (typeof title === 'string' && title.trim()) || id, kind: 'dedicated',
+ vcpu: cores == null ? null : cores * (sockets == null ? 1 : sockets),
+ ram_mb: memoryGb == null ? null : Math.round(memoryGb * 1024),
+ disk_gb: diskGb, disk_type: diskType,
+ bandwidth_gb: null, unmetered: limit == null ? null : limit === 0, port_mbps: number(x.bandwidth_mbps, 'bandwidth'),
+ price_monthly: monthly, price_hourly: null, ipv4: null,
+ locations: Array.isArray(x.locations) && x.locations.length ? x.locations : null,
+ setup_fee: setup,
+ };
+}
+
// Vultr's plan types as its pricing page names them; a plan is named from its type, CPU vendor, vCPUs and memory
// Vultr's plan types as its pricing page names them; a plan is named from its type, CPU vendor, vCPUs and memory
// ("High Performance AMD · 1 vCPU, 1 GB"), never by its API id alone.
// ("High Performance AMD · 1 vCPU, 1 GB"), never by its API id alone.
const VULTR_TYPES = Object.freeze({ vc2: 'Cloud Compute', vhf: 'High Frequency', vhp: 'High Performance', voc: 'Optimized Cloud', vcg: 'Cloud GPU', vbm: 'Bare Metal', vdc: 'Dedicated Cloud' });
const VULTR_TYPES = Object.freeze({ vc2: 'Cloud Compute', vhf: 'High Frequency', vhp: 'High Performance', voc: 'Optimized Cloud', vcg: 'Cloud GPU', vbm: 'Bare Metal', vdc: 'Dedicated Cloud' });
@@ -118,7 +157,7 @@ function mapItem(item) {
@@ -118,7 +157,7 @@ function mapItem(item) {
// Every price the mappers below read is the source's monthly price (Lightsail's and Azure's hourly rates
// Every price the mappers below read is the source's monthly price (Lightsail's and Azure's hourly rates
// become a month's worth, OVHcloud is its commitment=0 one-month price), so the row's term is monthly. No
// become a month's worth, OVHcloud is its commitment=0 one-month price), so the row's term is monthly. No
// source states a renewal price for a server plan, so it stays unknown rather than guessed.
// source states a renewal price for a server plan, so it stays unknown rather than guessed.
- price_term: 'monthly', renewal_price: null,
+ price_term: 'monthly', renewal_price: null, setup_fee: null,
};
};
if (item.source_key === 'aws-lightsail-bundles') return { ...common, ...lightsail(x, id) };
if (item.source_key === 'aws-lightsail-bundles') return { ...common, ...lightsail(x, id) };
if (item.source_key === 'azure-retail-prices') {
if (item.source_key === 'azure-retail-prices') {
@@ -135,6 +174,10 @@ function mapItem(item) {
@@ -135,6 +174,10 @@ function mapItem(item) {
const plan = ovhcloud(x, id, item.title);
const plan = ovhcloud(x, id, item.title);
return plan && { ...common, ...plan };
return plan && { ...common, ...plan };
}
}
+ if (item.source_key === 'ovhcloud-baremetal') {
+ const plan = ovhBaremetal(x, id, item.title);
+ return plan && { ...common, ...plan };
+ }
const vultr = item.source_key === 'vultr-plans';
const vultr = item.source_key === 'vultr-plans';
const monthly = number(vultr ? x.monthly_cost : x.price_monthly, 'monthly price');
const monthly = number(vultr ? x.monthly_cost : x.price_monthly, 'monthly price');
if (monthly == null) throw new TypeError('missing monthly price');
if (monthly == null) throw new TypeError('missing monthly price');
@@ -138,7 +138,7 @@ function createCatalog({ store, community = null, log = console, indexnow = null
@@ -138,7 +138,7 @@ function createCatalog({ store, community = null, log = console, indexnow = null
fresh.push(`/catalog/providers/${encodeURIComponent(v.provider.slug)}`);
fresh.push(`/catalog/providers/${encodeURIComponent(v.provider.slug)}`);
}
}
const old = await t.prepare('SELECT * FROM catalog_plans WHERE provider_id = ? AND external_id = ?').get(p.id, v.external_id);
const old = await t.prepare('SELECT * FROM catalog_plans WHERE provider_id = ? AND external_id = ?').get(p.id, v.external_id);
- const cols = ['name', 'kind', 'vcpu', 'ram_mb', 'disk_gb', 'disk_type', 'bandwidth_gb', 'unmetered', 'port_mbps', 'ipv4', 'locations', 'currency', 'price_monthly', 'price_hourly', 'price_term', 'renewal_price', 'source_key', 'source_item_id', 'source_url', 'checked_at'];
+ const cols = ['name', 'kind', 'vcpu', 'ram_mb', 'disk_gb', 'disk_type', 'bandwidth_gb', 'unmetered', 'port_mbps', 'ipv4', 'locations', 'currency', 'price_monthly', 'price_hourly', 'price_term', 'renewal_price', 'setup_fee', 'source_key', 'source_item_id', 'source_url', 'checked_at'];
if (old) {
if (old) {
if (old.origin !== 'sources' || v.checked_at < Number(old.checked_at)) return 'hold';
if (old.origin !== 'sources' || v.checked_at < Number(old.checked_at)) return 'hold';
if (Number(old.price_monthly) !== v.price_monthly || old.currency !== v.currency) {
if (Number(old.price_monthly) !== v.price_monthly || old.currency !== v.currency) {
@@ -84,10 +84,16 @@ function createCatalogViews({ catalog, benchmarks, community, config, log = cons
@@ -84,10 +84,16 @@ function createCatalogViews({ catalog, benchmarks, community, config, log = cons
}
}
async function index(query = {}) {
async function index(query = {}) {
- const [result, providers, currencies] = await Promise.all([catalog.plans(query), catalog.providers(), catalog.currencies()]);
+ // The comparison table is whatever the filters ask for; dedicated plans also get their own section, unless
+ // the reader already asked for that kind (then the table itself is the dedicated view).
+ 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([]),
+ ]);
return {
return {
title: 'Compare hosting plans',
title: 'Compare hosting plans',
- body: render.catalog({ result, query, providers, currencies }),
+ body: render.catalog({ result, query, providers, currencies, dedicated }),
indexable: !Object.keys(query).length,
indexable: !Object.keys(query).length,
description: 'Compare cloud, VPS and dedicated hosting plans: price per month, per vCPU and per GB, each with the day it was checked and its source.',
description: 'Compare cloud, VPS and dedicated hosting plans: price per month, per vCPU and per GB, each with the day it was checked and its source.',
jsonLd: render.jsonLd.catalog(baseUrl, result.plans),
jsonLd: render.jsonLd.catalog(baseUrl, result.plans),
@@ -223,13 +223,21 @@ function method(hasAffiliate) {
@@ -223,13 +223,21 @@ function method(hasAffiliate) {
</section>`;
</section>`;
}
}
-function catalog({ result, query, providers, currencies }) {
+function catalog({ result, query, providers, currencies, dedicated = [] }) {
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
? `<div class="hc-empty"><p><strong>No plan matches these filters.</strong></p><p>${link('/catalog', 'Clear the filters')} or widen one of them.</p></div>`
? `<div class="hc-empty"><p><strong>No plan matches these filters.</strong></p><p>${link('/catalog', 'Clear the filters')} or widen one of them.</p></div>`
: `<div class="hc-empty"><p><strong>The catalog is filling up.</strong></p><p>Plans arrive from providers' official APIs as each source is checked and enabled, and from staff entries with their source pages.</p></div>`;
: `<div class="hc-empty"><p><strong>The catalog is filling up.</strong></p><p>Plans arrive from providers' official APIs as each source is checked and enabled, and from staff entries with their source pages.</p></div>`;
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
+ // 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">
+<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>` : ''}
+</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>
<h1>Compare hosting plans</h1>
<h1>Compare hosting plans</h1>
@@ -238,6 +246,7 @@ function catalog({ result, query, providers, currencies }) {
@@ -238,6 +246,7 @@ function catalog({ result, query, providers, currencies }) {
${filters(query, providers, currencies)}
${filters(query, providers, currencies)}
${planTable(plans, { caption: `${plans.length}${result.next_cursor ? '+' : ''} plan${plans.length === 1 ? '' : 's'}, sorted by ${(SORTS.find(([v]) => v === (query.sort || 'price')) || SORTS[0])[1].toLowerCase()}` })}
${planTable(plans, { caption: `${plans.length}${result.next_cursor ? '+' : ''} plan${plans.length === 1 ? '' : 's'}, sorted by ${(SORTS.find(([v]) => v === (query.sort || 'price')) || SORTS[0])[1].toLowerCase()}` })}
${empty}${next}
${empty}${next}
+${dedicatedSection}
<p class="hc-links">${link('/catalog/promos', 'Current promotions')} · ${link('/catalog/providers', 'All providers')}</p>
<p class="hc-links">${link('/catalog/promos', 'Current promotions')} · ${link('/catalog/providers', 'All providers')}</p>
${method(plans.some((p) => p.affiliate_url))}`;
${method(plans.some((p) => p.affiliate_url))}`;
}
}
@@ -65,6 +65,32 @@ const item = (key, id, fields, time = at, title = id) => ({ id: `itm_${'0'.repea
@@ -65,6 +65,32 @@ const item = (key, id, fields, time = at, title = id) => ({ id: `itm_${'0'.repea
const row = (await catalog.plans({ provider: 'ovhcloud' })).plans[0];
const row = (await catalog.plans({ provider: 'ovhcloud' })).plans[0];
assert.deepStrictEqual([row.name, Number(row.price_monthly), row.unmetered, Number(row.port_mbps), row.bandwidth_gb], ['VPS-1 2027', 5.35, true, 500, null]);
assert.deepStrictEqual([row.name, Number(row.price_monthly), row.unmetered, Number(row.port_mbps), row.bandwidth_gb], ['VPS-1 2027', 5.35, true, 500, null]);
});
});
+ await check('an OVHcloud dedicated plan maps from the source join: sockets multiply cores, one disk size times the count, and the installation fee is kept', async () => {
+ const base = {
+ product: '24adv01', currency: 'USD', range: 'advance',
+ cpu_cores: 6, cpu_sockets: 1, cpu_model: 'EPYC 4244P', cpu_brand: 'AMD', memory_gb: 32,
+ disk_capacity_gb: 960, disk_count: 2, disk_interface: 'NVMe', disk_technology: 'SSD',
+ bandwidth_mbps: 3000, bandwidth_limit: 0, price_monthly: 14700000000, setup_fee: 14700000000,
+ locations: ['vin', 'hil'],
+ };
+ const p = mapItem(item('ovhcloud-baremetal', '24adv01-v3-us', base, at, 'ADVANCE-1 | AMD EPYC 4244P'));
+ assert.deepStrictEqual([p.provider.slug, p.name, p.kind, p.vcpu, p.ram_mb, p.disk_gb, p.disk_type, p.bandwidth_gb, p.unmetered, p.port_mbps, p.price_monthly, p.price_hourly, p.setup_fee, p.locations, p.source_url],
+ ['ovhcloud', 'ADVANCE-1 | AMD EPYC 4244P', 'dedicated', 6, 32768, 1920, 'nvme', null, true, 3000, 147, null, 147, ['vin', 'hil'], 'https://us.ovhcloud.com/bare-metal/prices/']);
+ const dual = mapItem(item('ovhcloud-baremetal', 'dual', { ...base, cpu_cores: 24, cpu_sockets: 2, memory_gb: 256, disk_capacity_gb: 3840, disk_count: 6, price_monthly: 165500000000, setup_fee: 165500000000 }));
+ assert.deepStrictEqual([dual.vcpu, dual.ram_mb, dual.disk_gb, dual.unmetered, dual.price_monthly, dual.setup_fee], [48, 262144, 23040, true, 1655, 1655]);
+ // A legacy HG product states 28 cores with no socket count: one socket, not an invented second one.
+ const hg = mapItem(item('ovhcloud-baremetal', 'hg', { ...base, cpu_cores: 28, cpu_sockets: null, setup_fee: 0 }));
+ assert.deepStrictEqual([hg.vcpu, hg.setup_fee], [28, 0]);
+ // A Scale plan sold with no drives states 0 and 0; the comparison shows 0 GB, not Unknown.
+ const bare = mapItem(item('ovhcloud-baremetal', 'bare', { ...base, disk_capacity_gb: 0, disk_count: 0 }));
+ assert.deepStrictEqual([bare.disk_gb, bare.disk_type], [0, null]);
+ assert.throws(() => mapItem(item('ovhcloud-baremetal', 'x', { ...base, currency: 'EUR' })), /not in USD/);
+ assert.throws(() => mapItem(item('ovhcloud-baremetal', 'x', { ...base, price_monthly: null })), /missing monthly price/);
+ assert.strictEqual(mapItem(item('ovhcloud-baremetal', 'x', { ...base, price_monthly: 0 })), null, 'a zero price is not listed');
+ await catalog.applyItem(item('ovhcloud-baremetal', '24adv01-v3-us', base, at, 'ADVANCE-1 | AMD EPYC 4244P'));
+ const row = (await catalog.plans({ provider: 'ovhcloud', kind: 'dedicated' })).plans[0];
+ assert.deepStrictEqual([row.name, row.kind, Number(row.price_monthly), Number(row.setup_fee), row.source_key, row.locations], ['ADVANCE-1 | AMD EPYC 4244P', 'dedicated', 147, 147, 'ovhcloud-baremetal', ['vin', 'hil']]);
+ });
await check('upsert preserves first seen and records only a changed price', async () => {
await check('upsert preserves first seen and records only a changed price', async () => {
const raw = { vcpu_count: 1, ram: 1024, disk: 25, bandwidth: 1000, monthly_cost: 5, hourly_cost: 0.007, type: 'vc2' };
const raw = { vcpu_count: 1, ram: 1024, disk: 25, bandwidth: 1000, monthly_cost: 5, hourly_cost: 0.007, type: 'vc2' };
await catalog.applyItem(item('vultr-plans', 'vc2-1c-1gb', raw));
await catalog.applyItem(item('vultr-plans', 'vc2-1c-1gb', raw));
@@ -96,6 +96,15 @@ const { boot, check, done } = require('./helpers/boot');
@@ -96,6 +96,15 @@ const { boot, check, done } = require('./helpers/boot');
assert.ok(!types((await t.api('GET', '/catalog?min_vcpu=2')).text).includes('ItemList'), 'a filtered view carries no JSON-LD');
assert.ok(!types((await t.api('GET', '/catalog?min_vcpu=2')).text).includes('ItemList'), 'a filtered view carries no JSON-LD');
const promos = await t.api('GET', '/api/v1/catalog/promos'); assert.strictEqual(promos.json().promos.length, 1);
const promos = await t.api('GET', '/api/v1/catalog/promos'); assert.strictEqual(promos.json().promos.length, 1);
});
});
+ await check('the comparison page shows dedicated plans in their own section, and kind=dedicated is that table once', async () => {
+ const metal = await plan(p, 'metal', 120, { kind: 'dedicated', vcpu: 8, ram_mb: 32768, disk_gb: 960, disk_type: 'nvme', setup_fee: 120 });
+ const page = await t.api('GET', '/catalog');
+ assert.match(page.text, /Dedicated servers compared/);
+ assert.ok(page.text.includes(`href="/catalog/plans/${metal.id}"`), 'the dedicated plan is linked');
+ 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');
+ });
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;
const planUrl = xml.match(/<url><loc>[^<]*\/catalog\/plans\/[^<]+<\/loc><lastmod>(\d{4}-\d{2}-\d{2})<\/lastmod><\/url>/);
const planUrl = xml.match(/<url><loc>[^<]*\/catalog\/plans\/[^<]+<\/loc><lastmod>(\d{4}-\d{2}-\d{2})<\/lastmod><\/url>/);