Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 20 additions & 35 deletions apps/frontend/src/pages/hosting/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion packages/api-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
33 changes: 0 additions & 33 deletions packages/api-client/src/modules/archon/servers/v0.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Archon.Servers.v0.StockResponse> {
return this.client.request<Archon.Servers.v0.StockResponse>(`/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<Archon.Servers.v0.StockResponse> {
return this.client.request<Archon.Servers.v0.StockResponse>('/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
Expand Down
33 changes: 33 additions & 0 deletions packages/api-client/src/modules/archon/servers/v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Archon.Servers.v1.StockResponse> {
return this.client.request<Archon.Servers.v1.StockResponse>('/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<Archon.Servers.v1.StockResponse> {
return this.client.request<Archon.Servers.v1.StockResponse>(
`/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
Expand Down
33 changes: 22 additions & 11 deletions packages/api-client/src/modules/archon/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<number, MemorySizeStock>
/** 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<number, number>
}
}
}

Expand Down
166 changes: 93 additions & 73 deletions packages/api-client/src/utils/pingtest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> {
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<boolean>

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<number>((resolve) => {
const samples: number[] = []
const timers = new Set<ReturnType<typeof setTimeout>>()
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<boolean>((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<number[]> {
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<number[]>((resolve) => {
const startedAt = performance.now()
const samples: number[] = []
const timers: ReturnType<typeof setTimeout>[] = []

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()
}
})
}
}
Loading
Loading