diff --git a/apps/frontend/src/pages/hosting/index.vue b/apps/frontend/src/pages/hosting/index.vue index 9f569ab55e3..7c56a058427 100644 --- a/apps/frontend/src/pages/hosting/index.vue +++ b/apps/frontend/src/pages/hosting/index.vue @@ -20,7 +20,6 @@ :return-url="`${config.public.siteUrl}/hosting/manage`" :server-name="`${auth?.user?.username}'s server`" :out-of-stock-url="outOfStockUrl" - :fetch-capacity-statuses="fetchCapacityStatuses" :regions="regions" :refresh-payment-methods="fetchPaymentData" :fetch-stock="fetchStock" @@ -1048,44 +1047,30 @@ const { data: hasServers } = useQuery({ enabled: computed(() => !!auth.value?.user), }) -function fetchStock(region, request) { - return client.archon.servers_v0.checkStock(region.shortcode, request).then((res) => res.available) +function fetchStock(memoryMb) { + return client.archon.servers_v1.getStock(memoryMb) } -async function fetchCapacityStatuses(customProduct = null) { +async function fetchCapacityStatuses() { try { - const productsToCheck = customProduct?.metadata - ? [customProduct] - : [ - ...pyroPlanProducts, - pyroProducts.reduce((min, product) => - product.metadata.ram < min.metadata.ram ? product : min, - ), - ] - const capacityChecks = [] - for (const product of productsToCheck) { - capacityChecks.push( - client.archon.servers_v0.checkStockGlobal({ - cpu: product.metadata.cpu, - memory_mb: product.metadata.ram, - swap_mb: product.metadata.swap, - storage_mb: product.metadata.storage, - }), - ) - } - - if (customProduct?.metadata) { - return { - custom: await capacityChecks[0], - } - } else { - return { - small: await capacityChecks[0], - medium: await capacityChecks[1], - large: await capacityChecks[2], - custom: await capacityChecks[3], - } + const productsToCheck = { + small: pyroPlanProducts[0], + medium: pyroPlanProducts[1], + large: pyroPlanProducts[2], + custom: pyroProducts.reduce((min, product) => + product.metadata.ram < min.metadata.ram ? product : min, + ), } + const stock = await client.archon.servers_v1.getStock( + Object.values(productsToCheck).map((product) => product.metadata.ram), + ) + + return Object.fromEntries( + Object.entries(productsToCheck).map(([plan, product]) => [ + plan, + { available: stock.total[product.metadata.ram] ?? 0 }, + ]), + ) } catch (error) { console.error('Error checking server capacities:', error) return { diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index 226adcbabdd..eefeda12efb 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -44,7 +44,7 @@ export { clearNodeAuthState, nodeAuthState, setNodeAuthState } from './state/nod export * from './types' export { withJWTRetry } from './utils/jwt-retry' export { getNodeWebSocketUrl } from './utils/node-url' -export { pingWebSocketUrl, type WebSocketPingOptions } from './utils/pingtest' +export { WebSocketPinger, type WebSocketPingOptions } from './utils/pingtest' export { type ParsedSseEvent, type ParsedSseItem, diff --git a/packages/api-client/src/modules/archon/servers/v0.ts b/packages/api-client/src/modules/archon/servers/v0.ts index 8a85532e211..f8bd46adff9 100644 --- a/packages/api-client/src/modules/archon/servers/v0.ts +++ b/packages/api-client/src/modules/archon/servers/v0.ts @@ -39,39 +39,6 @@ export class ArchonServersV0Module extends AbstractModule { }) } - /** - * Check stock availability for a region - * POST /modrinth/v0/stock?region=:region - */ - public async checkStock( - region: string, - request: Archon.Servers.v0.StockRequest, - ): Promise { - return this.client.request(`/stock?region=${region}`, { - api: 'archon', - version: 'modrinth/v0', - method: 'POST', - body: request, - skipAuth: true, - }) - } - - /** - * Check stock availability (without region filter) - * POST /modrinth/v0/stock - */ - public async checkStockGlobal( - request: Archon.Servers.v0.StockRequest, - ): Promise { - return this.client.request('/stock', { - api: 'archon', - version: 'modrinth/v0', - method: 'POST', - body: request, - skipAuth: true, - }) - } - /** * Get filesystem authentication credentials for a server * Returns URL and JWT token for accessing the server's filesystem via Kyros diff --git a/packages/api-client/src/modules/archon/servers/v1.ts b/packages/api-client/src/modules/archon/servers/v1.ts index baf1daef81f..108470f5222 100644 --- a/packages/api-client/src/modules/archon/servers/v1.ts +++ b/packages/api-client/src/modules/archon/servers/v1.ts @@ -61,6 +61,39 @@ export class ArchonServersV1Module extends AbstractModule { }) } + /** + * Get how many servers of each memory size fit in every public region + * GET /v1/stock + */ + public async getStock(memoryMb: number[]): Promise { + return this.client.request('/stock', { + api: 'archon', + version: 1, + method: 'GET', + params: { memory_mb: memoryMb.join(',') }, + skipAuth: true, + }) + } + + /** + * Get how many servers of each memory size fit in every public region when upgrading a server + * GET /v1/servers/:server_id/upgrade-stock + */ + public async getUpgradeStock( + serverId: string, + memoryMb: number[], + ): Promise { + return this.client.request( + `/servers/${serverId}/upgrade-stock`, + { + api: 'archon', + version: 1, + method: 'GET', + params: { memory_mb: memoryMb.join(',') }, + }, + ) + } + /** * End the intro flow for a server * DELETE /v1/servers/:id/flows/intro diff --git a/packages/api-client/src/modules/archon/types.ts b/packages/api-client/src/modules/archon/types.ts index a6c916a5b61..6a0ecaa07dd 100644 --- a/packages/api-client/src/modules/archon/types.ts +++ b/packages/api-client/src/modules/archon/types.ts @@ -699,17 +699,6 @@ export namespace Archon { offset?: number } - export type StockRequest = { - cpu?: number - memory_mb?: number - swap_mb?: number - storage_mb?: number - } - - export type StockResponse = { - available: number - } - export type JWTAuth = { url: string // e.g., "node-xyz.modrinth.com/modrinth/v0/fs" token: string // JWT token for filesystem access @@ -833,6 +822,28 @@ export namespace Archon { lon: number zone: string } + + export type MemorySizeStock = { + /** Number of servers of this memory size that fit in the region */ + available: number + /** Hostnames of the nodes which fit at least one server of this memory size */ + hostnames: string[] + } + + export type RegionStock = { + /** Shortcode of the region */ + region: string + /** Stock of each requested memory size, keyed by memory size in MB */ + stock: Record + /** WebSocket URLs of random nodes in the region, to measure the latency to it */ + ping_urls: string[] + } + + export type StockResponse = { + regions: RegionStock[] + /** Number of servers that fit across all regions, keyed by memory size in MB */ + total: Record + } } } diff --git a/packages/api-client/src/utils/pingtest.ts b/packages/api-client/src/utils/pingtest.ts index f79ed1b6ba2..dc48a8fad83 100644 --- a/packages/api-client/src/utils/pingtest.ts +++ b/packages/api-client/src/utils/pingtest.ts @@ -2,96 +2,116 @@ export interface WebSocketPingOptions { count?: number intervalMs?: number settleDelayMs?: number - timeoutMs?: number signal?: AbortSignal } -export async function pingWebSocketUrl( - url: string, - options: WebSocketPingOptions = {}, -): Promise { - const count = options.count ?? 5 - const intervalMs = options.intervalMs ?? 200 - const settleDelayMs = options.settleDelayMs ?? 1000 - const timeoutMs = options.timeoutMs ?? count * intervalMs + settleDelayMs + 1000 +/** + * Measures the latency to a ping test WebSocket, which echoes back every message. The connection stays open between + * measurements until {@link WebSocketPinger.close} is called. + */ +export class WebSocketPinger { + /** Resolves to whether the connection opened within the connect timeout. */ + public readonly connected: Promise - if (options.signal?.aborted) return -1 + private readonly socket: WebSocket | null + private sampleListener: ((sentAt: number, receivedAt: number) => void) | null = null + private closed = false - return await new Promise((resolve) => { - const samples: number[] = [] - const timers = new Set>() - let socket: WebSocket | undefined - let settled = false - - const setTrackedTimeout = (callback: () => void, ms: number) => { - const timer = setTimeout(() => { - timers.delete(timer) - callback() - }, ms) - timers.add(timer) - return timer + constructor(url: string, connectTimeoutMs: number) { + let socket: WebSocket | null = null + try { + socket = new WebSocket(url) + } catch { + this.closed = true } + this.socket = socket + + this.connected = new Promise((resolve) => { + if (!socket) { + resolve(false) + return + } - const cleanup = () => { - for (const timer of timers) clearTimeout(timer) - timers.clear() - options.signal?.removeEventListener('abort', abort) + const timeout = setTimeout(() => { + resolve(false) + this.close() + }, connectTimeoutMs) - if ( - socket && - (socket.readyState === WebSocket.CONNECTING || socket.readyState === WebSocket.OPEN) - ) { - socket.close() + socket.onopen = () => { + clearTimeout(timeout) + resolve(true) } - } + socket.onerror = () => { + clearTimeout(timeout) + resolve(false) + this.close() + } + socket.onclose = () => { + clearTimeout(timeout) + this.closed = true + resolve(false) + } + socket.onmessage = (event) => { + this.sampleListener?.(Number(event.data), performance.now()) + } + }) + } - const finish = (ping: number) => { - if (settled) return - settled = true - cleanup() - resolve(ping) - } + public get isOpen(): boolean { + return !this.closed && this.socket?.readyState === WebSocket.OPEN + } - const abort = () => finish(-1) - options.signal?.addEventListener('abort', abort, { once: true }) + /** + * Sends `count` pings over the connection and resolves to the latency in ms of every ping that came back, once + * all of them came back or `settleDelayMs` after the last one was sent. + */ + public async measure(options: WebSocketPingOptions = {}): Promise { + const count = options.count ?? 5 + const intervalMs = options.intervalMs ?? 200 + const settleDelayMs = options.settleDelayMs ?? 1000 + const signal = options.signal - try { - socket = new WebSocket(url) - } catch { - finish(-1) - return - } + if (!(await this.connected) || !this.isOpen || signal?.aborted) return [] - setTrackedTimeout(() => finish(-1), timeoutMs) + return await new Promise((resolve) => { + const startedAt = performance.now() + const samples: number[] = [] + const timers: ReturnType[] = [] - socket.onopen = () => { - for (let i = 0; i < count; i++) { - setTrackedTimeout(() => { - if (socket?.readyState === WebSocket.OPEN) { - socket.send(String(performance.now())) - } - }, i * intervalMs) + const finish = (result: number[]) => { + for (const timer of timers) clearTimeout(timer) + signal?.removeEventListener('abort', abort) + this.sampleListener = null + resolve(result) } + const abort = () => finish([]) + signal?.addEventListener('abort', abort, { once: true }) - setTrackedTimeout( - () => { - const ping = - samples.length > 0 - ? Math.round([...samples].sort((a, b) => a - b)[Math.floor(samples.length / 2)]) - : -1 - finish(ping) - }, - count * intervalMs + settleDelayMs, - ) - } + this.sampleListener = (sentAt, receivedAt) => { + if (sentAt < startedAt) return + samples.push(receivedAt - sentAt) + if (samples.length === count) finish(samples) + } - socket.onmessage = (event) => { - samples.push(performance.now() - Number(event.data)) - } + for (let i = 0; i < count; i++) { + timers.push( + setTimeout(() => { + if (this.isOpen) this.socket?.send(String(performance.now())) + }, i * intervalMs), + ) + } + + timers.push(setTimeout(() => finish(samples), (count - 1) * intervalMs + settleDelayMs)) + }) + } - socket.onerror = () => finish(-1) - socket.onclose = () => { - if (!settled && samples.length === 0) finish(-1) + public close() { + this.closed = true + if ( + this.socket && + (this.socket.readyState === WebSocket.CONNECTING || this.socket.readyState === WebSocket.OPEN) + ) { + this.socket.close() } - }) + } } diff --git a/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue b/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue index 5634b258a89..8543ca9393e 100644 --- a/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue +++ b/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue @@ -1,5 +1,5 @@ @@ -264,14 +235,14 @@ onMounted(() => {

{{ formatMessage(messages.prompt) }}

-
+
@@ -309,10 +280,7 @@ onMounted(() => { >

-
- Checking availability... -
-
+
const locationSubtitle = computed(() => overrideTitle.value ? props.region.display_name : undefined, ) -const pingLevel = computed(() => getPingLevel(props.ping ?? 0)) const period = computed(() => { if (interval.value === 'monthly') return 'month' @@ -211,20 +209,9 @@ function setInterval(newInterval: ServerBillingInterval) { {{ locationSubtitle }} - • - - +
diff --git a/packages/ui/src/components/billing/ServersRegionButton.vue b/packages/ui/src/components/billing/ServersRegionButton.vue index 7d44a8e5059..0af17b0aad0 100644 --- a/packages/ui/src/components/billing/ServersRegionButton.vue +++ b/packages/ui/src/components/billing/ServersRegionButton.vue @@ -1,11 +1,10 @@ + + diff --git a/packages/ui/src/components/billing/ServersUpgradeModalWrapper.vue b/packages/ui/src/components/billing/ServersUpgradeModalWrapper.vue index b07ebfecad4..12983e24c9b 100644 --- a/packages/ui/src/components/billing/ServersUpgradeModalWrapper.vue +++ b/packages/ui/src/components/billing/ServersUpgradeModalWrapper.vue @@ -112,12 +112,11 @@ async function fetchPaymentData() { await refetchPaymentMethods() } -async function fetchStock( - region: Archon.Servers.v1.Region, - request: Archon.Servers.v0.StockRequest, -): Promise { - const result = await archon.servers_v0.checkStock(region.shortcode, request) - return result.available +function fetchStock(memoryMb: number[]): Promise { + const metadata = subscription.value?.metadata + return metadata?.type === 'pyro' || metadata?.type === 'medal' + ? archon.servers_v1.getUpgradeStock(metadata.id, memoryMb) + : archon.servers_v1.getStock(memoryMb) } const subscription = ref(null) diff --git a/packages/ui/src/composables/region-pings.ts b/packages/ui/src/composables/region-pings.ts new file mode 100644 index 00000000000..fbd2669be2b --- /dev/null +++ b/packages/ui/src/composables/region-pings.ts @@ -0,0 +1,215 @@ +import { type Archon, WebSocketPinger } from '@modrinth/api-client' +import { readonly, ref } from 'vue' + +const INITIAL_PING_COUNT = 5 +const INITIAL_PING_INTERVAL = 200 +const INITIAL_PING_SETTLE_TIME = 1000 +const PING_CONNECT_TIMEOUT = 3000 +const PING_REFRESH_INTERVAL = 2500 +const PING_REFRESH_SETTLE_TIME = 2000 +const PING_SAMPLE_WINDOW = 5 + +/** + * Latency to each region in ms, averaged over its last pings and keyed by region shortcode. `-1` means pinging the + * region failed. + */ +const regionPings = ref>({}) +/** The latency of the last pings to each region over its open connection, keyed by region shortcode. */ +const regionPingSamples = new Map() +/** The open ping connection to each region, keyed by region shortcode. */ +const regionPingers = new Map() +/** The ping URL that last connected for each region, keyed by region shortcode. */ +const workingPingUrls = new Map() + +let pingedRegions: Archon.Servers.v1.RegionStock[] = [] +let pingRun: Promise | null = null +let initialPingsDone = false + +let refreshCount = 0 +let refreshController: AbortController | null = null +let refreshLoopsController: AbortController | null = null + +function setRegionPing(region: string, ping: number) { + regionPings.value = { ...regionPings.value, [region]: ping } +} + +function closeRegionPinger(region: string, pinger: WebSocketPinger) { + pinger.close() + if (regionPingers.get(region) === pinger) regionPingers.delete(region) +} + +function closeRegionPingers() { + for (const [region, pinger] of [...regionPingers]) { + closeRegionPinger(region, pinger) + } +} + +/** + * Returns the region's open ping connection, or connects through its ping URLs one at a time, starting with the one + * that last connected and falling back to the next URL only when one fails to connect. + */ +async function connectRegion( + region: Archon.Servers.v1.RegionStock, + signal?: AbortSignal, +): Promise { + const existing = regionPingers.get(region.region) + if (existing?.isOpen) return existing + if (existing) closeRegionPinger(region.region, existing) + + const workingUrl = workingPingUrls.get(region.region) + const urls = workingUrl + ? [workingUrl, ...region.ping_urls.filter((url) => url !== workingUrl)] + : region.ping_urls + + for (const url of urls) { + const pinger = new WebSocketPinger(url, PING_CONNECT_TIMEOUT) + const connected = await pinger.connected + if (signal?.aborted) { + pinger.close() + return null + } + if (connected) { + workingPingUrls.set(region.region, url) + regionPingers.set(region.region, pinger) + return pinger + } + pinger.close() + } + return null +} + +/** + * Pings a region and returns its latency averaged over its last pings. A new connection is measured with several + * pings, while an open one is measured with a single ping. + */ +async function pingRegion( + region: Archon.Servers.v1.RegionStock, + signal?: AbortSignal, +): Promise { + const reusingConnection = regionPingers.get(region.region)?.isOpen ?? false + const pinger = await connectRegion(region, signal) + if (!pinger) return -1 + + const samples = await pinger.measure( + reusingConnection + ? { count: 1, settleDelayMs: PING_REFRESH_SETTLE_TIME, signal } + : { + count: INITIAL_PING_COUNT, + intervalMs: INITIAL_PING_INTERVAL, + settleDelayMs: INITIAL_PING_SETTLE_TIME, + signal, + }, + ) + if (signal?.aborted) return -1 + if (samples.length === 0) { + closeRegionPinger(region.region, pinger) + regionPingSamples.delete(region.region) + return -1 + } + + const previousSamples = reusingConnection ? (regionPingSamples.get(region.region) ?? []) : [] + const window = [...previousSamples, ...samples].slice(-PING_SAMPLE_WINDOW) + regionPingSamples.set(region.region, window) + return Math.round(window.reduce((sum, sample) => sum + sample, 0) / window.length) +} + +function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + const onAbort = () => { + clearTimeout(timeout) + resolve() + } + const timeout = setTimeout(() => { + signal.removeEventListener('abort', onAbort) + resolve() + }, ms) + signal.addEventListener('abort', onAbort, { once: true }) + }) +} + +async function refreshRegionPing(region: Archon.Servers.v1.RegionStock, signal: AbortSignal) { + while (!signal.aborted) { + const startedAt = Date.now() + const ping = await pingRegion(region, signal) + if (signal.aborted) return + setRegionPing(region.region, ping) + await sleep(PING_REFRESH_INTERVAL - (Date.now() - startedAt), signal) + } +} + +/** + * Starts refreshing every region's ping, once the initial pings are done and while a refresh is requested. + */ +function startRefreshLoops() { + if (!initialPingsDone || !refreshController || refreshLoopsController === refreshController) { + return + } + refreshLoopsController = refreshController + for (const region of pingedRegions) { + void refreshRegionPing(region, refreshController.signal) + } +} + +/** + * Region latencies shared across the page, so every purchase modal reuses a single round of pings and a single + * connection to each region. + */ +export function useRegionPings() { + /** + * Starts pinging every region, unless a previous call already did. + */ + function startRegionPings(regions: Archon.Servers.v1.RegionStock[]) { + if (pingRun || regions.length === 0) return + + pingedRegions = regions + pingRun = Promise.all( + regions.map(async (region) => setRegionPing(region.region, await pingRegion(region))), + ).then(() => { + initialPingsDone = true + if (refreshController) { + startRefreshLoops() + } else { + closeRegionPingers() + } + }) + } + + /** + * Resolves once every region has been pinged, or after `timeoutMs`, whichever comes first. + */ + function waitForRegionPings(timeoutMs: number): Promise { + if (!pingRun) return Promise.resolve() + return Promise.race([pingRun, new Promise((resolve) => setTimeout(resolve, timeoutMs))]) + } + + /** + * Keeps re-pinging every region every `PING_REFRESH_INTERVAL` ms over its open connection once the initial pings + * are done, until the returned function is called. + */ + function startPingRefresh(): () => void { + refreshCount++ + if (refreshCount === 1) { + refreshController = new AbortController() + startRefreshLoops() + } + + let stopped = false + return () => { + if (stopped) return + stopped = true + refreshCount-- + if (refreshCount === 0) { + refreshController?.abort() + refreshController = null + if (initialPingsDone) closeRegionPingers() + } + } + } + + return { + regionPings: readonly(regionPings), + startRegionPings, + waitForRegionPings, + startPingRefresh, + } +} diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/index.vue b/packages/ui/src/layouts/wrapped/hosting/manage/index.vue index fd8f2c8f8f9..ab7a2888bc3 100644 --- a/packages/ui/src/layouts/wrapped/hosting/manage/index.vue +++ b/packages/ui/src/layouts/wrapped/hosting/manage/index.vue @@ -461,12 +461,8 @@ async function fetchPaymentData() { await Promise.all([refetchCustomer(), refetchPaymentMethods()]) } -async function fetchStock( - region: Archon.Servers.v1.Region, - request: Archon.Servers.v0.StockRequest, -): Promise { - const result = await client.archon.servers_v0.checkStock(region.shortcode, request) - return result.available +function fetchStock(memoryMb: number[]): Promise { + return client.archon.servers_v1.getStock(memoryMb) } const { diff --git a/packages/ui/src/locales/en-US/index.json b/packages/ui/src/locales/en-US/index.json index 8f6d71baecf..8f551577726 100644 --- a/packages/ui/src/locales/en-US/index.json +++ b/packages/ui/src/locales/en-US/index.json @@ -5933,6 +5933,18 @@ "servers.region.north-america-west": { "defaultMessage": "Western North America" }, + "servers.region.out-of-stock": { + "defaultMessage": "(Out of stock)" + }, + "servers.region.ping.failed": { + "defaultMessage": "Failed to ping" + }, + "servers.region.ping.latency": { + "defaultMessage": "{ping}ms" + }, + "servers.region.ping.testing": { + "defaultMessage": "Testing connection..." + }, "servers.region.prompt": { "defaultMessage": "Where would you like your server to be located?" }, diff --git a/packages/ui/src/utils/billing.ts b/packages/ui/src/utils/billing.ts index c3934a8341d..dd51497da61 100644 --- a/packages/ui/src/utils/billing.ts +++ b/packages/ui/src/utils/billing.ts @@ -33,13 +33,6 @@ export interface ServerPlan { }[] } -export interface ServerStockRequest { - cpu?: number - memory_mb?: number - swap_mb?: number - storage_mb?: number -} - export interface ServerRegion { shortcode: string country_code: string