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