diff --git a/.env.example b/.env.example
index e19b392..5f0af76 100644
--- a/.env.example
+++ b/.env.example
@@ -6,4 +6,9 @@ JWT_SECRET=""
# DeepL API key for automated translation (https://www.deepl.com/pro-api)
# Free tier: use a key ending in :fx | Paid tier: standard key
-DEEPL_API_KEY=""
\ No newline at end of file
+DEEPL_API_KEY=""
+
+# status.nodebyte.host public status API — admin token unlocks hidden monitors
+# and bypasses the shared CDN cache for fresher data.
+STATUS_API_URL="https://status.nodebyte.host"
+STATUS_TOKEN=""
\ No newline at end of file
diff --git a/README.md b/README.md
index c5aa232..0a952b2 100644
--- a/README.md
+++ b/README.md
@@ -122,7 +122,7 @@ The frontend uses a centralized API client (`packages/core/lib/api.ts`) that rou
A small set of lightweight API routes remain in the Next.js app for public-facing proxy endpoints:
- `/api/github/releases` -- GitHub release data
-- `/api/instatus` -- Status page integration
+- `/api/status` -- status.nodebyte.host integration (node/service monitor status, uptime, latency)
- `/api/panel/*` -- Public panel data (counts, nodes, servers, stats, users)
- `/api/trustpilot` -- Trustpilot review data
@@ -142,7 +142,7 @@ A small set of lightweight API routes remain in the Next.js app for public-facin
│ │ └── users/ # User management
│ ├── api/ # Lightweight proxy routes
│ │ ├── github/releases/ # GitHub releases proxy
-│ │ ├── instatus/ # Status page proxy
+│ │ ├── status/ # status.nodebyte.host proxy
│ │ ├── panel/ # Public panel data
│ │ └── trustpilot/ # Trustpilot proxy
│ ├── auth/ # Authentication pages
diff --git a/app/api/billing-debug/route.ts b/app/api/billing-debug/route.ts
index 674dcba..b2ddfaf 100644
--- a/app/api/billing-debug/route.ts
+++ b/app/api/billing-debug/route.ts
@@ -1,4 +1,5 @@
import { fetchAllBillingProducts } from "@/packages/core/lib/bytepay"
+import { parseDescriptionSpecs } from "@/packages/core/lib/spec-parser"
export const dynamic = "force-dynamic"
@@ -27,5 +28,20 @@ export async function GET(request: Request) {
description: p.description,
}))
- return Response.json({ count: summary.length, products: summary })
+ // Live products whose description fails to parse cpu/ramGB/storageGB — a
+ // subset (or all) of these fields are required by getGamePlans/getVpsPlans/
+ // getDedicatedPlans, so a missing one here means the product silently
+ // disappears from its billing page.
+ const dropped = filtered.flatMap((p) => {
+ const parsed = parseDescriptionSpecs(p.description)
+ const missing = [
+ !parsed.cpu && "cpu",
+ !parsed.ramGB && "ramGB",
+ !parsed.storageGB && "storageGB",
+ ].filter((v): v is string => Boolean(v))
+ if (missing.length === 0) return []
+ return [{ id: p.id, name: p.name, slug: p.slug, categorySlug: p.categorySlug, missing }]
+ })
+
+ return Response.json({ count: summary.length, products: summary, dropped })
}
diff --git a/app/api/instatus/route.ts b/app/api/instatus/route.ts
deleted file mode 100644
index 21369c1..0000000
--- a/app/api/instatus/route.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-import { NextResponse } from "next/server"
-
-export const revalidate = 60 // Cache for 60 seconds
-
-interface InstatusPage {
- name: string
- url: string
- status: "UP" | "HASISSUES" | "UNDERMAINTENANCE"
-}
-
-interface InstatusIncident {
- id: string
- name: string
- started: string
- status: string
- impact: string
- url: string
- updatedAt: string
-}
-
-interface InstatusMaintenance {
- id: string
- name: string
- start: string
- status: string
- duration: string
- url: string
- updatedAt: string
-}
-
-interface InstatusResponse {
- page: InstatusPage
- activeIncidents: InstatusIncident[]
- activeMaintenances: InstatusMaintenance[]
-}
-
-export async function GET() {
- try {
- const response = await fetch("https://nodebytestat.us/summary.json", {
- next: { revalidate: 60 },
- headers: {
- "Accept": "application/json",
- },
- })
-
- if (!response.ok) {
- throw new Error(`Instatus API returned ${response.status}`)
- }
-
- const data: InstatusResponse = await response.json()
-
- // Safely handle potentially undefined arrays
- const activeIncidents = data.activeIncidents || []
- const activeMaintenances = data.activeMaintenances || []
-
- return NextResponse.json({
- status: data.page?.status || "UP",
- url: data.page?.url || "https://nodebytestat.us",
- hasIncidents: activeIncidents.length > 0,
- hasMaintenance: activeMaintenances.length > 0,
- incidents: activeIncidents.map((incident) => ({
- id: incident.id,
- name: incident.name,
- status: incident.status,
- impact: incident.impact,
- url: incident.url,
- })),
- maintenances: activeMaintenances.map((maintenance) => ({
- id: maintenance.id,
- name: maintenance.name,
- status: maintenance.status,
- url: maintenance.url,
- })),
- })
- } catch (error) {
- console.error("Failed to fetch Instatus data:", error)
-
- // Return a fallback response
- return NextResponse.json(
- {
- status: "UP",
- url: "https://nodebytestat.us",
- hasIncidents: false,
- hasMaintenance: false,
- incidents: [],
- maintenances: [],
- error: "Failed to fetch status",
- },
- { status: 200 } // Still return 200 to not break the UI
- )
- }
-}
diff --git a/app/api/status/route.ts b/app/api/status/route.ts
new file mode 100644
index 0000000..36ba89d
--- /dev/null
+++ b/app/api/status/route.ts
@@ -0,0 +1,52 @@
+import { NextResponse } from "next/server"
+import { computeLatencyStats, fetchStatusSnapshot, type MonitorStatus, type MonitorType } from "@/packages/core/lib/status"
+
+export const revalidate = 30
+
+export interface StatusApiMonitor {
+ name: string
+ type: MonitorType
+ groupName: string | null
+ subgroupName: string | null
+ status: MonitorStatus
+ uptime30dPct: number | null
+ latency: { fast: number; avg: number; slow: number } | null
+}
+
+export interface StatusApiResponse {
+ available: boolean
+ generatedAt: number | null
+ overallStatus: MonitorStatus | null
+ monitors: StatusApiMonitor[]
+}
+
+export async function GET() {
+ const snapshot = await fetchStatusSnapshot()
+
+ if (!snapshot) {
+ return NextResponse.json(
+ { available: false, generatedAt: null, overallStatus: null, monitors: [] },
+ { status: 200 },
+ )
+ }
+
+ const monitors: StatusApiMonitor[] = snapshot.monitors.map((m) => ({
+ name: m.name,
+ type: m.type,
+ groupName: m.group_name,
+ subgroupName: m.subgroup_name,
+ status: m.status,
+ uptime30dPct: m.uptime_30d_pct,
+ latency: computeLatencyStats(m),
+ }))
+
+ return NextResponse.json(
+ { available: true, generatedAt: snapshot.generated_at, overallStatus: snapshot.overall_status, monitors },
+ {
+ status: 200,
+ headers: {
+ "Cache-Control": "public, s-maxage=30, stale-while-revalidate=60",
+ },
+ },
+ )
+}
diff --git a/packages/core/constants/services.ts b/packages/core/constants/services.ts
index 2a8b2ab..c993694 100644
--- a/packages/core/constants/services.ts
+++ b/packages/core/constants/services.ts
@@ -65,7 +65,26 @@ export const SERVICE_CATEGORIES: ServiceCategory[] = [
highlights: [
"Enterprise-grade processors",
"Full root / SSH access",
- "NVMe SSD storage",
+ "Lightning fast networks",
+ "Enterprise DDoS protection",
+ ],
+ enabled: true,
+ },
+ {
+ id: "dedicated",
+ name: "Dedicated Servers",
+ description:
+ "Physical bare-metal servers with fully dedicated CPU cores, enterprise storage, and IPMI out-of-band access. Zero resource contention and maximum raw performance.",
+ href: "/dedicated",
+ icon: Cpu,
+ gradient: "from-amber-600/25 via-amber-500/8 to-transparent",
+ iconColor: "text-amber-400",
+ accentBorder: "hover:border-amber-400/40",
+ startingPriceGBP: 50,
+ highlights: [
+ "100% dedicated CPU cores",
+ "IPMI out-of-band access",
+ "Enterprise storage",
"Enterprise DDoS protection",
],
enabled: true,
diff --git a/packages/core/constants/status-mapping.ts b/packages/core/constants/status-mapping.ts
new file mode 100644
index 0000000..b902edb
--- /dev/null
+++ b/packages/core/constants/status-mapping.ts
@@ -0,0 +1,30 @@
+/**
+ * Bridges the two independent naming schemes between status.nodebyte.host
+ * (monitor names) and this site's node/location identifiers. Update these
+ * maps whenever a node is renamed or a new Region ping monitor is added
+ * upstream — unmapped entries simply render without live data.
+ */
+
+/** Website node `name` (STATIC_NODES) → status.nodebyte.host monitor `name`. */
+export const NODE_MONITOR_MAP: Record = {
+ "NEWC-GAME1": "NEWC-GAME1",
+ "HEL-VPS1": "HEL-VPS1",
+}
+
+/**
+ * Website location `id` (LOCATIONS) → status.nodebyte.host "Regions" monitor
+ * `name`. Only locations with a confirmed matching ping monitor are listed;
+ * the Regions group also includes PoPs (e.g. Ashburn VA, Atlanta GA) that
+ * don't correspond to an actual NodeByte data centre location.
+ */
+export const LOCATION_MONITOR_MAP: Record = {
+ lon: "London, UK",
+ fal: "Falkenstein, DE",
+ fra: "Frankfurt, DE",
+ hel: "Helsinki, FI",
+ tor: "Toronto, ON",
+ vhv: "Ashburn, VA", // Vint Hill, VA is in the same Northern Virginia / DC-metro area
+ sgp: "Singapore, Singapore",
+ syd: "Sydney, Australia",
+ mum: "Mumbai, India",
+}
diff --git a/packages/core/hooks/use-node-status.ts b/packages/core/hooks/use-node-status.ts
new file mode 100644
index 0000000..1c5b6c4
--- /dev/null
+++ b/packages/core/hooks/use-node-status.ts
@@ -0,0 +1,41 @@
+"use client"
+
+import { useEffect, useState } from "react"
+import type { StatusApiMonitor, StatusApiResponse } from "@/app/api/status/route"
+import type { MonitorStatus } from "@/packages/core/lib/status"
+
+const REFRESH_INTERVAL_MS = 60_000
+
+export function useNodeStatus() {
+ const [monitors, setMonitors] = useState([])
+ const [available, setAvailable] = useState(false)
+ const [overallStatus, setOverallStatus] = useState(null)
+
+ useEffect(() => {
+ let cancelled = false
+
+ const load = () => {
+ fetch("/api/status")
+ .then((r) => r.json())
+ .then((data: StatusApiResponse) => {
+ if (cancelled) return
+ setAvailable(data.available)
+ setMonitors(data.monitors)
+ setOverallStatus(data.overallStatus)
+ })
+ .catch(() => {})
+ }
+
+ load()
+ const interval = setInterval(load, REFRESH_INTERVAL_MS)
+ return () => {
+ cancelled = true
+ clearInterval(interval)
+ }
+ }, [])
+
+ const findMonitor = (name: string) =>
+ monitors.find((m) => m.name.trim().toLowerCase() === name.trim().toLowerCase()) ?? null
+
+ return { available, overallStatus, monitors, findMonitor }
+}
diff --git a/packages/core/lib/bytepay.ts b/packages/core/lib/bytepay.ts
index 6221cb1..a2ffee1 100644
--- a/packages/core/lib/bytepay.ts
+++ b/packages/core/lib/bytepay.ts
@@ -5,8 +5,54 @@
* JSON:API response into a flat, typed structure the website can consume.
*/
-const BYTEPAY_HOST = process.env.BYTEPAY_HOST!
-const BYTEPAY_TOKEN = process.env.BYTEPAY_TOKEN!
+import { unstable_cache } from "next/cache"
+
+function getConfig(): { host: string; token: string } {
+ const host = process.env.BYTEPAY_HOST
+ const token = process.env.BYTEPAY_TOKEN
+ if (!host || !token) {
+ throw new Error(
+ "Billing API misconfigured: BYTEPAY_HOST and BYTEPAY_TOKEN must both be set.",
+ )
+ }
+ return { host, token }
+}
+
+const REQUEST_TIMEOUT_MS = 10_000
+const MAX_ATTEMPTS = 3
+const PAGE_SIZE = 100
+
+/** Fetch with a timeout and retries for transient failures (429/5xx/network errors). */
+async function fetchWithRetry(url: string, token: string): Promise {
+ let lastError: unknown
+
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
+ try {
+ const res = await fetch(url, {
+ headers: { Authorization: `Bearer ${token}` },
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
+ next: { revalidate: 300 },
+ })
+
+ if (res.ok) return res
+
+ const isRetryable = res.status === 429 || res.status >= 500
+ if (!isRetryable || attempt === MAX_ATTEMPTS) return res
+
+ const retryAfter = Number(res.headers.get("Retry-After"))
+ const delayMs = Number.isFinite(retryAfter) && retryAfter > 0
+ ? retryAfter * 1000
+ : 250 * 2 ** (attempt - 1)
+ await new Promise((resolve) => setTimeout(resolve, delayMs))
+ } catch (err) {
+ lastError = err
+ if (attempt === MAX_ATTEMPTS) throw err
+ await new Promise((resolve) => setTimeout(resolve, 250 * 2 ** (attempt - 1)))
+ }
+ }
+
+ throw lastError
+}
// ─── JSON:API wire types ───────────────────────────────────────────────────
@@ -80,28 +126,90 @@ function nameToSlug(name: string): string {
.replace(/^-|-$/g, "")
}
+interface CategoryInfo {
+ id: string
+ name: string
+ parentId: string | null
+}
+
/**
- * Derive the category slug from the included map.
- * Paymenter's API exposes `name` on categories but not a `slug` attribute.
- * Uses `full_slug` if the version of Paymenter provides it, otherwise falls
- * back to slugifying the category name.
+ * Fetch every category from the admin Categories endpoint (authoritative —
+ * independent of whatever a given products page happens to `include`).
+ * Paymenter doesn't expose a `slug` attribute on categories, only `name`
+ * and `parent_id`, so slugs are still derived by slugifying the name.
*/
-function resolveCategorySlug(catId: string, map: Map): string {
- const cat = map.get(`categories:${catId}`)
- if (!cat) return ""
- if (typeof cat.attributes.full_slug === "string") return cat.attributes.full_slug
- return nameToSlug((cat.attributes.name as string) ?? "")
+async function fetchAllCategories(): Promise {
+ const { host, token } = getConfig()
+ const all: CategoryInfo[] = []
+ let page = 1
+
+ while (true) {
+ const url = new URL(`${host}/api/v1/admin/categories`)
+ url.searchParams.set("per_page", String(PAGE_SIZE))
+ url.searchParams.set("page", String(page))
+
+ const res = await fetchWithRetry(url.toString(), token)
+ if (!res.ok) {
+ throw new Error(`Billing API error ${res.status}: ${res.statusText}`)
+ }
+
+ const json: JsonApiResponse = await res.json()
+ for (const cat of json.data) {
+ all.push({
+ id: cat.id,
+ name: (cat.attributes.name as string) ?? "",
+ parentId: cat.attributes.parent_id != null ? String(cat.attributes.parent_id) : null,
+ })
+ }
+
+ if (!json.links?.next) break
+ page++
+ }
+
+ return all
+}
+
+const getCachedCategories = unstable_cache(fetchAllCategories, ["billing-categories"], {
+ revalidate: 600,
+})
+
+/**
+ * Build category id → slug from the authoritative category list, warning on
+ * any two categories whose names slugify to the same value (since site pages
+ * key off the flat leaf slug, a collision would silently merge two categories'
+ * products together).
+ */
+function buildCategorySlugMap(categories: CategoryInfo[]): Map {
+ const slugMap = new Map()
+ const ownerOfSlug = new Map()
+
+ for (const cat of categories) {
+ const slug = nameToSlug(cat.name)
+ slugMap.set(cat.id, slug)
+
+ const existingOwner = ownerOfSlug.get(slug)
+ if (existingOwner && existingOwner !== cat.id) {
+ console.warn(
+ `[bytepay] Category slug collision: "${cat.name}" (id ${cat.id}) and category id ${existingOwner} both slugify to "${slug}" — products in one category may shadow the other on the site.`,
+ )
+ } else {
+ ownerOfSlug.set(slug, cat.id)
+ }
+ }
+
+ return slugMap
}
function normalisePage(
data: JsonApiResource[],
map: Map,
+ categorySlugMap: Map,
): BillingProduct[] {
return data.map((product): BillingProduct => {
const attr = product.attributes
const catRef = product.relationships?.category?.data as JsonApiRelRef | null | undefined
- const categorySlug = catRef ? resolveCategorySlug(catRef.id, map) : ""
+ const categorySlug = catRef ? (categorySlugMap.get(catRef.id) ?? "") : ""
const planRefs = (product.relationships?.plans?.data ?? []) as JsonApiRelRef[]
const plans = planRefs
@@ -153,16 +261,17 @@ function normalisePage(
async function fetchPage(
page: number,
+ categorySlugMap: Map,
): Promise<{ products: BillingProduct[]; hasNext: boolean }> {
- const url = new URL(`${BYTEPAY_HOST}/api/v1/admin/products`)
+ const { host, token } = getConfig()
+
+ const url = new URL(`${host}/api/v1/admin/products`)
url.searchParams.set("include", "category,plans,plans.prices")
url.searchParams.set("filter[hidden]", "0")
+ url.searchParams.set("per_page", String(PAGE_SIZE))
url.searchParams.set("page", String(page))
- const res = await fetch(url.toString(), {
- headers: { Authorization: `Bearer ${BYTEPAY_TOKEN}` },
- next: { revalidate: 300 },
- })
+ const res = await fetchWithRetry(url.toString(), token)
if (!res.ok) {
throw new Error(`Billing API error ${res.status}: ${res.statusText}`)
@@ -172,7 +281,7 @@ async function fetchPage(
const map = buildIncludedMap(json.included)
return {
- products: normalisePage(json.data, map),
+ products: normalisePage(json.data, map, categorySlugMap),
hasNext: Boolean(json.links?.next),
}
}
@@ -181,11 +290,14 @@ async function fetchPage(
/** Fetch every non-hidden product across all pagination pages. */
export async function fetchAllBillingProducts(): Promise {
+ const categories = await getCachedCategories()
+ const categorySlugMap = buildCategorySlugMap(categories)
+
const all: BillingProduct[] = []
let page = 1
while (true) {
- const { products, hasNext } = await fetchPage(page)
+ const { products, hasNext } = await fetchPage(page, categorySlugMap)
all.push(...products)
if (!hasNext) break
page++
diff --git a/packages/core/lib/spec-parser.ts b/packages/core/lib/spec-parser.ts
index 75383d6..367f0d5 100644
--- a/packages/core/lib/spec-parser.ts
+++ b/packages/core/lib/spec-parser.ts
@@ -74,8 +74,9 @@ export function parseDescriptionSpecs(html: string | null): ParsedSpecs {
// ── Storage ────────────────────────────────────────────────────────────────
// "25 GB SSD", "40 GB NVMe SSD", "100 GB SSD Storage", "40GB Disk Storage"
+ // "80 GB Local NVMe Storage" (no SSD/HDD/Disk keyword)
// Also handles TB drives: "2 x 1 TB NVMe SSD", "4 x 16 TB SATA HDD"
- const storageMatchGB = text.match(/(\d+)\s*GB\s+(?:NVMe\s+)?(?:SSD|Disk|HDD)(?:\s+Storage)?/i)
+ const storageMatchGB = text.match(/(\d+)\s*GB\s+(?:Local\s+)?(?:NVMe\s+)?(?:SSD|Disk|HDD|Storage)\b/i)
const storageMatchTB = !storageMatchGB
? text.match(/(\d+)\s*TB\s+(?:NVMe\s+|Enterprise\s+|SATA\s+)?(?:SSD|HDD|Disk)/i)
: null
diff --git a/packages/core/lib/status.ts b/packages/core/lib/status.ts
new file mode 100644
index 0000000..bea5ca8
--- /dev/null
+++ b/packages/core/lib/status.ts
@@ -0,0 +1,123 @@
+/**
+ * Server-side only — STATUS_TOKEN is never sent to the browser.
+ *
+ * Fetches monitor data from the status.nodebyte.host public status API
+ * and normalises it into the flat, typed shape the website needs.
+ */
+
+export type MonitorStatus = "up" | "down" | "degraded" | "maintenance" | "paused" | "unknown"
+export type MonitorType = "http" | "tcp" | "smtp" | "ping" | "group"
+
+export interface StatusHeartbeat {
+ checked_at: number
+ status: "up" | "down" | "maintenance" | "unknown"
+ latency_ms: number | null
+}
+
+export interface StatusMonitor {
+ id: number
+ name: string
+ type: MonitorType
+ group_name: string | null
+ subgroup_name: string | null
+ status: MonitorStatus
+ last_checked_at: number | null
+ last_latency_ms: number | null
+ heartbeats: StatusHeartbeat[]
+ uptime_30d_pct: number | null
+}
+
+export interface StatusSnapshot {
+ generated_at: number
+ overall_status: MonitorStatus
+ monitors: StatusMonitor[]
+}
+
+function getConfig(): { host: string; token: string | undefined } {
+ const host = process.env.STATUS_API_URL
+ if (!host) {
+ throw new Error("Status API misconfigured: STATUS_API_URL must be set.")
+ }
+ return { host, token: process.env.STATUS_TOKEN }
+}
+
+async function fetchStatusJson(host: string, token: string | undefined): Promise> {
+ const res = await fetch(`${host}/api/v1/public/status`, {
+ headers: {
+ Accept: "application/json",
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ },
+ next: { revalidate: 30 },
+ })
+
+ if (res.status === 503 && token) {
+ // The status app's edge proxy refuses to forward the Authorization header
+ // upstream until its own UPTIMER_API_SENSITIVE_ORIGIN is configured. Fall
+ // back to the unauthenticated public payload rather than failing outright.
+ const body = await res.clone().json().catch(() => null)
+ if (body?.error?.code === "API_ORIGIN_UNTRUSTED_FOR_SENSITIVE_HEADERS") {
+ return fetchStatusJson(host, undefined)
+ }
+ }
+
+ if (!res.ok) {
+ throw new Error(`Status API returned ${res.status}`)
+ }
+
+ return res.json()
+}
+
+/** Fetch the current status snapshot. Returns null on any failure so callers can fall back to static data. */
+export async function fetchStatusSnapshot(): Promise {
+ try {
+ const { host, token } = getConfig()
+ const data = await fetchStatusJson(host, token)
+ const rawMonitors = Array.isArray(data.monitors) ? (data.monitors as Record[]) : []
+
+ const monitors: StatusMonitor[] = rawMonitors.map((m) => ({
+ id: m.id as number,
+ name: m.name as string,
+ type: m.type as MonitorType,
+ group_name: (m.group_name as string | null) ?? null,
+ subgroup_name: (m.subgroup_name as string | null) ?? null,
+ status: m.status as MonitorStatus,
+ last_checked_at: (m.last_checked_at as number | null) ?? null,
+ last_latency_ms: (m.last_latency_ms as number | null) ?? null,
+ heartbeats: Array.isArray(m.heartbeats) ? (m.heartbeats as StatusHeartbeat[]) : [],
+ uptime_30d_pct:
+ m.uptime_30d && typeof (m.uptime_30d as Record).uptime_pct === "number"
+ ? ((m.uptime_30d as Record).uptime_pct as number)
+ : null,
+ }))
+
+ return {
+ generated_at: data.generated_at as number,
+ overall_status: data.overall_status as MonitorStatus,
+ monitors,
+ }
+ } catch (error) {
+ console.error("Failed to fetch status snapshot:", error)
+ return null
+ }
+}
+
+/** Find a monitor by exact name (case-insensitive). */
+export function findMonitor(snapshot: StatusSnapshot | null, name: string): StatusMonitor | null {
+ if (!snapshot) return null
+ const target = name.trim().toLowerCase()
+ return snapshot.monitors.find((m) => m.name.trim().toLowerCase() === target) ?? null
+}
+
+/** Compute fast/avg/slow latency (ms) from a monitor's recent heartbeats. */
+export function computeLatencyStats(monitor: StatusMonitor): { fast: number; avg: number; slow: number } | null {
+ const samples = monitor.heartbeats
+ .map((h) => h.latency_ms)
+ .filter((ms): ms is number => typeof ms === "number")
+
+ if (samples.length === 0) return null
+
+ const fast = Math.min(...samples)
+ const slow = Math.max(...samples)
+ const avg = Math.round(samples.reduce((sum, ms) => sum + ms, 0) / samples.length)
+ return { fast, avg, slow }
+}
diff --git a/packages/core/products/billing-service.ts b/packages/core/products/billing-service.ts
index 4bfb52b..03bfbc8 100644
--- a/packages/core/products/billing-service.ts
+++ b/packages/core/products/billing-service.ts
@@ -12,6 +12,7 @@ import {
} from "@/packages/core/lib/bytepay"
import { parseDescriptionSpecs, parseProductName } from "@/packages/core/lib/spec-parser"
import { POPULAR_SLUGS, DEFAULT_DDOS } from "@/packages/core/constants/product-overrides"
+import type { BillingProduct } from "@/packages/core/lib/bytepay"
const getCachedProducts = unstable_cache(
fetchAllBillingProducts,
@@ -19,6 +20,22 @@ const getCachedProducts = unstable_cache(
{ revalidate: 300 },
)
+/**
+ * A live, non-hidden product is visible in Paymenter but got filtered out
+ * because its description didn't parse into the specs the site needs.
+ * Logged so a wording change (e.g. a new storage phrasing) surfaces
+ * immediately instead of being discovered by a customer.
+ */
+function warnDroppedProduct(
+ product: BillingProduct,
+ categorySlug: string,
+ missingFields: string[],
+): void {
+ console.warn(
+ `[billing-service] Product "${product.name}" (id ${product.id}, slug ${product.slug}) in category "${categorySlug}" is live but missing parsed spec(s): ${missingFields.join(", ")}. Check its description formatting.`,
+ )
+}
+
/**
* Returns live-priced game plans for the given billing category slug.
* RAM and storage are parsed from the billing panel description automatically.
@@ -29,7 +46,13 @@ export async function getGamePlans(categorySlug: string): Promise {
const parsed = parseDescriptionSpecs(product.description)
- if (!parsed.ramGB || !parsed.storageGB) return []
+ if (!parsed.ramGB || !parsed.storageGB) {
+ const missing = [!parsed.ramGB && "ramGB", !parsed.storageGB && "storageGB"].filter(
+ (v): v is string => Boolean(v),
+ )
+ warnDroppedProduct(product, categorySlug, missing)
+ return []
+ }
return [
{
@@ -57,7 +80,10 @@ export async function getDedicatedPlans(categorySlug: string): Promise {
const parsed = parseDescriptionSpecs(product.description)
- if (!parsed.ramGB) return []
+ if (!parsed.ramGB) {
+ warnDroppedProduct(product, categorySlug, ["ramGB"])
+ return []
+ }
return [
{
@@ -93,7 +119,15 @@ export async function getVpsPlans(categorySlug: string): Promise
const parsed = parseDescriptionSpecs(product.description)
const { sku, lineup, series } = parseProductName(product.name)
- if (!parsed.cpu || !parsed.ramGB || !parsed.storageGB) return []
+ if (!parsed.cpu || !parsed.ramGB || !parsed.storageGB) {
+ const missing = [
+ !parsed.cpu && "cpu",
+ !parsed.ramGB && "ramGB",
+ !parsed.storageGB && "storageGB",
+ ].filter((v): v is string => Boolean(v))
+ warnDroppedProduct(product, categorySlug, missing)
+ return []
+ }
return [
{
diff --git a/packages/ui/components/Layouts/Home/services.tsx b/packages/ui/components/Layouts/Home/services.tsx
index b147267..0a05278 100644
--- a/packages/ui/components/Layouts/Home/services.tsx
+++ b/packages/ui/components/Layouts/Home/services.tsx
@@ -37,12 +37,12 @@ export function Services() {
{/* Service Hub Cards */}
{activeServices.map((service) => (
@@ -59,23 +59,23 @@ export function Services() {
{/* Card header — gradient visual */}
{/* Large faded background icon */}
-
+
{/* Centred icon badge */}
-
+
{/* Title + starting price */}
-
-
{service.name}
-
+
+
{service.name}
+
{t("servicesHome.startingFrom")} /mo
diff --git a/packages/ui/components/Layouts/Nodes/nodes-client.tsx b/packages/ui/components/Layouts/Nodes/nodes-client.tsx
index 7d8e37a..c9676f3 100644
--- a/packages/ui/components/Layouts/Nodes/nodes-client.tsx
+++ b/packages/ui/components/Layouts/Nodes/nodes-client.tsx
@@ -23,6 +23,9 @@ import {
} from "@/packages/ui/components/ui/accordion"
import { Alert, AlertDescription } from "@/packages/ui/components/ui/alert"
import type { PublicNode } from "@/packages/core/constants/node-types"
+import { NODE_MONITOR_MAP, LOCATION_MONITOR_MAP } from "@/packages/core/constants/status-mapping"
+import { useNodeStatus } from "@/packages/core/hooks/use-node-status"
+import type { StatusApiMonitor } from "@/app/api/status/route"
import { cn } from "@/lib/utils"
import Link from "next/link"
import { LINKS } from "@/packages/core/constants/links"
@@ -36,24 +39,20 @@ interface ExtendedNode extends PublicNode {
const STATIC_NODES: ExtendedNode[] = [
{
id: 1,
- name: "NB-GNODE-NC1",
+ name: "NEWC-GAME1",
locationCode: "Newcastle, UK",
isMaintenanceMode: false,
memory: 1310089,
disk: 1811000,
- cpu: "AMD Ryzen™ 9 5900X",
- ramType: "DDR4 ECC",
uptime: 99.9,
},
{
id: 2,
- name: "NB-VNODE-HEL1",
+ name: "HEL-VPS1",
locationCode: "Helsinki, FI",
isMaintenanceMode: false,
memory: 65104,
disk: 512000,
- cpu: "AMD Ryzen™ 7 1700X",
- ramType: "DDR4 ECC",
uptime: 99.8,
},
]
@@ -112,6 +111,22 @@ function formatSize(mib: number): string {
return `${mib} MiB`
}
+const LIVE_STATE_STYLES: Record = {
+ up: { label: "Online", dot: "bg-green-400 animate-pulse", border: "hover:border-green-500/30 hover:shadow-xl hover:shadow-green-500/5", badge: "border-green-500/30 text-green-400 bg-green-500/5" },
+ degraded: { label: "Degraded", dot: "bg-amber-400", border: "hover:border-amber-500/30 hover:shadow-xl hover:shadow-amber-500/5", badge: "border-amber-500/30 text-amber-400 bg-amber-500/5" },
+ maintenance: { label: "Maintenance", dot: "bg-amber-400", border: "hover:border-amber-500/30 hover:shadow-xl hover:shadow-amber-500/5", badge: "border-amber-500/30 text-amber-400 bg-amber-500/5" },
+ down: { label: "Offline", dot: "bg-red-400", border: "hover:border-red-500/30 hover:shadow-xl hover:shadow-red-500/5", badge: "border-red-500/30 text-red-400 bg-red-500/5" },
+}
+
+function resolveNodeState(node: ExtendedNode, live: StatusApiMonitor | null) {
+ if (live && live.status in LIVE_STATE_STYLES) {
+ return LIVE_STATE_STYLES[live.status]
+ }
+ return node.isMaintenanceMode
+ ? { label: "Maintenance", dot: "bg-amber-400", border: "hover:border-amber-500/30 hover:shadow-xl hover:shadow-amber-500/5", badge: "border-amber-500/30 text-amber-400 bg-amber-500/5" }
+ : { label: "Online", dot: "bg-green-400 animate-pulse", border: "hover:border-green-500/30 hover:shadow-xl hover:shadow-green-500/5", badge: "border-green-500/30 text-green-400 bg-green-500/5" }
+}
+
function groupByCountry(locations: DataCentreLocation[]) {
const map = new Map()
for (const loc of locations) {
@@ -126,25 +141,26 @@ function groupByCountry(locations: DataCentreLocation[]) {
}
-function NodeCard({ node }: { node: ExtendedNode }) {
- const online = !node.isMaintenanceMode
+function NodeCard({ node, live }: { node: ExtendedNode; live: StatusApiMonitor | null }) {
+ const state = resolveNodeState(node, live)
+ const uptime = live?.uptime30dPct ?? node.uptime
+
return (
-
+
@@ -158,17 +174,9 @@ function NodeCard({ node }: { node: ExtendedNode }) {
)}
-
-
- {online ? "Online" : "Maintenance"}
+
+
+ {state.label}
@@ -190,13 +198,21 @@ function NodeCard({ node }: { node: ExtendedNode }) {
{node.ramType}
)}
- {node.uptime !== undefined && (
+ {live?.latency && (
+
+
+ Ping
+
+ {live.latency.avg}ms avg
+
+ )}
+ {uptime !== undefined && uptime !== null && (
Uptime
-
= 99.9 ? "text-green-400" : "text-amber-400")}>
- {node.uptime.toFixed(1)}%
+ = 99.9 ? "text-green-400" : "text-amber-400")}>
+ {uptime.toFixed(1)}%
)}
@@ -211,10 +227,12 @@ function LocationCountryRow({
country,
flag,
locations,
+ findMonitor,
}: {
country: string
flag: string
locations: DataCentreLocation[]
+ findMonitor: (name: string) => StatusApiMonitor | null
}) {
return (
@@ -222,21 +240,27 @@ function LocationCountryRow({
{country}
- {locations.map((loc) => (
-
- {loc.city}
- {loc.area && · {loc.area}}
- {loc.primary && }
-
- ))}
+ {locations.map((loc) => {
+ const monitorName = LOCATION_MONITOR_MAP[loc.id]
+ const live = monitorName ? findMonitor(monitorName) : null
+ const liveState = live ? LIVE_STATE_STYLES[live.status] : null
+ return (
+
+ {liveState && }
+ {loc.city}
+ {loc.area && · {loc.area}}
+ {loc.primary && }
+
+ )
+ })}
@@ -282,8 +306,15 @@ const FAQS = [
export function NodesClient() {
const nodes = STATIC_NODES
- const onlineCount = nodes.filter((n) => !n.isMaintenanceMode).length
- const maintenanceCount = nodes.filter((n) => n.isMaintenanceMode).length
+ const { findMonitor } = useNodeStatus()
+ const nodeLiveStates = nodes.map((node) => {
+ const monitorName = NODE_MONITOR_MAP[node.name]
+ return monitorName ? findMonitor(monitorName) : null
+ })
+ const onlineCount = nodeLiveStates.filter(
+ (live, i) => (live ? live.status === "up" : !nodes[i].isMaintenanceMode),
+ ).length
+ const maintenanceCount = nodes.length - onlineCount
const hydrated = useSyncExternalStore(() => () => {}, () => true, () => false)
return (
@@ -341,8 +372,8 @@ export function NodesClient() {
- {nodes.map((node) => (
-
+ {nodes.map((node, i) => (
+
))}
@@ -384,6 +415,7 @@ export function NodesClient() {
country={country}
flag={flag}
locations={locations}
+ findMonitor={findMonitor}
/>
))}
diff --git a/packages/ui/components/Static/footer.tsx b/packages/ui/components/Static/footer.tsx
index d02e72b..fb9e16c 100644
--- a/packages/ui/components/Static/footer.tsx
+++ b/packages/ui/components/Static/footer.tsx
@@ -10,6 +10,7 @@ import { Logo } from "@/packages/ui/components/logo"
import Image from "next/image"
import { useTranslations } from "next-intl"
import { LINKS } from "@/packages/core/constants/links"
+import { StatusBadge } from "@/packages/ui/components/Static/status-badge"
export function Footer() {
const t = useTranslations()
@@ -262,6 +263,7 @@ export function Footer() {
© {new Date().getFullYear()} NodeByte LTD. {t("footer.copyright")}
+
Company No. 15432941
diff --git a/packages/ui/components/Static/status-badge.tsx b/packages/ui/components/Static/status-badge.tsx
new file mode 100644
index 0000000..1d5cc76
--- /dev/null
+++ b/packages/ui/components/Static/status-badge.tsx
@@ -0,0 +1,44 @@
+"use client"
+
+import { useTranslations } from "next-intl"
+import { cn } from "@/lib/utils"
+import { useNodeStatus } from "@/packages/core/hooks/use-node-status"
+import { LINKS } from "@/packages/core/constants/links"
+
+const STATUS_DOT: Record = {
+ up: "bg-green-400 animate-pulse",
+ degraded: "bg-amber-400",
+ maintenance: "bg-amber-400",
+ paused: "bg-amber-400",
+ down: "bg-red-400",
+ unknown: "bg-muted-foreground",
+}
+
+const STATUS_LABEL_KEY: Record = {
+ up: "operational",
+ degraded: "degraded",
+ maintenance: "maintenance",
+ paused: "maintenance",
+ down: "down",
+ unknown: "unavailable",
+}
+
+export function StatusBadge() {
+ const t = useTranslations()
+ const { available, overallStatus } = useNodeStatus()
+
+ const labelKey = available && overallStatus ? STATUS_LABEL_KEY[overallStatus] : "unavailable"
+ const dotClass = available && overallStatus ? STATUS_DOT[overallStatus] : "bg-muted-foreground"
+
+ return (
+
+
+ {t(`footer.statusLabels.${labelKey}`)}
+
+ )
+}
diff --git a/translations b/translations
index 67a79ea..8059fbd 160000
--- a/translations
+++ b/translations
@@ -1 +1 @@
-Subproject commit 67a79ea5ff53a62e9c7c8595861c6f200c56128f
+Subproject commit 8059fbdb3a00abe975a08ddb568de30393f0335f