feat: server management in app (#5628)

* start new server settings tabs

* update properties tab to match design

* better stying in general tab

* feat: add suffix input for hostname field

* implement tables for allocations and DNS records

* add tags for dns record type

* small gap adjustment

* polish advanced page

* adjust properties page hierarchy

* fix searching properties, empty state and projection radius appearing

* pnpm prepr

* update copy to match designs

* fix suffix input component

* style fixes and match heading size

* small fix

* fix search allocations placeholder

* adjust table styles

* move all installation settings helper text to below input

* update icon to use overflow menu buttons

* fix modal to be consistent

* open advanced properties when search

* remove other and custom properties, and update styles

* remove hide/show all java versions

* handle mc 26

* refactor: move server settings pages into /ui and add app ServerSettingsModal

* hook up server pages for app

* add server page header to app

* hook up server settings modal

* use large size

* fix card box shadow style

* fix hostname input for app

* fix app/website card containers

* implement external tabs for billing and admin billing

* fix save banner fixed to parent instead of page body

* remove unused prop to FriendsList causing warning in app

* fix client-only not available for app

* fix bottom cut off

* wire node auth

* implement full copy buttons

* dedup copy button tailwind styles

* fix hover class not working in @apply

* fix spacing

* fix error validation styles

* apply consistent styles and spacing

* feat: update hosting server card (#5609)

* fix type errors

* fix some stylesheets not imported for storybook

* add server listing stories

* add fix for frontend stylesheet imports

* remove props.

* convert copy code to use tailwind

* update server listing component styles

* update server info label styles

* start status/player count info label, more style updates and fixes

* add new server card buttons

* hook up server cards and implement updated styles

* hook up on download button

* fix tauri throwing error when api returns 204 No Content

* hook up purchase server modal in app

* fix upgrading state loading icon

* pnpm prepr

* filter out servers past 30 days after cancellation

* do not apply opacity on lock or spiner icons

* fix disabled server icon background

* update pending change stage

* handle known suspension states

* refactor: reduce code duplication for server listing

* update disabled state text color

* fix loading icon color

* clean up copy

* fix disabled opacity for server card

* update server listing files kept to be countdown

* implement resubscribe modal

* implement proper provisioning state for resubscribe

* fix duplicate attribute and pnpm prepr

* feat: add shared UI package auth DI

* feat: update purchase server flow (#5714)

* implement server list empty state component

* fix stories and adjust spacing

* implement select plan design refresh

* implement auth for empty server list

* use refs instead of reactive

* pnpm prepr

* fix auth usage for empty servers list

* move app auth provider setup to src/providers/setup

* pnpm prepr

* fix max height

* style fix

* fix getCreds no auth is blocking api client

* implement servers guest plan modal and signin which redirects back to modal's next step

* refactor guest plan select logic into provider

* implement sign in or create account popup

* remove force empty serverList

* add download button for suspended mod and generic

* add handling for when user logs out

* QA pass style fixes

* more consistent page styles

* fix duplicate export

* refactor: remove all fallback stuff from resubscribe modal

* implement shared download latest backup util

* i18n pass

* pnpm prepr

* fix region being selected if ping failed

* pnpm prepr

* feat: servers in app finalization (#5744)

* feat: start on shared console implementation into logs and overview pages

* fix: terminal gap issues

* feat: swap word wrap for full screen

* fix: stats cards alignment

* fix: stats

* feat: fix console clear + remove copy

* fix: lint

* fix: use reset not clear

* feat: shared server header & overview page for app and website (#5736)

* feat: implement shared server header for app and website

* feat: implement wrapped overview page with shared composable and hook it up

* pnpm prepr

* fix: bugs

* qa: cleanup

* feat: root.vue shared layout

* feat: delete old options pages + fix discovery frontend

* fix: discovery

* fix: misc style/layout issues

* fix page padding

* fix: modal height jankiness

* feat: implement server install content in app and server setup modal with DI

* fix: spacing

* remove servers in app feature flag

* Revert "remove servers in app feature flag"

This reverts commit 86e284c4bdd6fa42c3c8fbaf1efbec41f4d1c6d2.

* fix: qa

* feat: remove legacy components from apps/frontend/src/components/ui/servers

---------

Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>

* qa pass (#5738)

* fix: qa

* feat: qa

* fix: server icon fetch fails due to global node auth race condition overriding each other

* fix: lint

* fix: server icon upload/sync and centralize logic

* fix: server settings modal not closing for server reset

* fix: better server sorting

* feat: copy address in server listing card

* fix: notification panel in modal and when overlapping with action bar

* fix: empty server list empty state flashing when refresh, fixed by adding isReady auth flag

* feat: use floating action bar for save banner

* fix: saving state in save bar

* fix: edit server icon styling

* fix: confirm modal to have consistent buttons

* feat: loading animation for server panel + caching improvements for app

* pnpm prepr

* feat: search page deduplication (#5754)

* fix: action bar behind modal

* fix: remove warning modal for stopping

* fix: server cards states

* we hate webkit we hate webkit

* fix: update allocation creation to not use modal

* fix: properties tab spacing and styles

* feat: add files tab copy

* fix: advanced properties icon

* fix: remove back to all servers link

* feat: add files tab link in copy

* fix: server header styles to be consistent with instance

* fix: add header icons back

* feat: update instance settings icon to be consistent

* fix: icon container

* feat: upload state persistence across tabs

* fix: server labels text wrapping

* fix: use surface-5 border

* fix: loading spinner showing with onboarding below

* feat: new server button shows purchase modal in website

* fix: billing page not showing quarterly interval

* fix: server downgrade not showing updated subscription notification

* fix: server settings invalidate saved state and remove server context provider since its already provided in the page

* pnpm prepr

* add stripe publishable key to app build

* feat: console highlighting

* fix: rename servers title to modrinth hosting

* feat: search fix

* fix: qa/styles

* fix: ip click active and remove power dont ask again

* fix: qa

* feat: highlighting fix console

* fix: disable conflicts action

* fix: error dismiss bug

* feat: modal clarification

* fix: files perms issue

* fix: lint

* feat: modal fix

* enable show uptime

* fix: add loading state to edit server icon

* fix: notification panel take in has sidebar from settings

* fix: consistency pass on app settings

* fix: consistency pass on instance settings

* pnpm prepr

* fix: nagivate to billing button in app to go to website

* fix: stripe return url in app causing app to open modrinth.com in tauri

* refactor: better show polling UI code

* fix: new server polling comparison to use server ids instead of length

* fix: buttonstyled story

* fix: button styling

* fix: content.vue regression

* feat: project url redirects

* fix: breadcrumbs

* fix: purchase with newly added card

* fix: console ordering problems

* fix: app-frontend missing env config and staging environment

* fix: log syncing for instances and server panel accidentally

* fix: QA issues

* fix: server page loading state

* fix: stats card logic

* fix: lint

* fix: qa

* fix: console height padding

* fix: terminal padding + loading indicator

* feat: update medal server listing styling

* fix: no upgrade button for medal server listing in app

* fix: go to overview instead of content tab after onboarding

* fix: qa

* fix: teleport modals to body

* fix: logs tab + qa

* fix: local storage for user preferences

* fix: qa loading indic

* feat: considitonal debug and trace

* fix: jump to top on install bug

* feat: swap out server hard drive icon to server stack icon

* feat: servers in app feature flag default true

* fix: highlight row ufll

* fix: webkit thing onto a tag

* fix: input field

* fix: clear fix

* fix: lint

* fix: fmt

* feat: improve share modal and bring it back for sharing log

* pnpm prepr

* fix: menu overflowing

* feat: remove servers in app feature flag

* fix: server stat charts no longer showing color

* fix: library nav no primary state

* fix: better modal height and width

* fix: highlighting bugs

* fix: empty states

* fix: delay import to fix overview page slow load on MacOS

* fix: medal server listing too bright on light mode

* fix: admon analysis + fix logs

* fix: bug

* fix: clear purchase intent from sign-in after closing modal

* performance: improve server manage stats loading by splitting reactivity

* fix: deploy + admon + disable highlighting

* fix: clippy

---------

Co-authored-by: tdgao <mr.trumgao@gmail.com>
Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>

* feat: temp wrangler

* fix: lint

* fix: logs upload

* fix: console empty state and admon regressions

* fix: fields

* feat: log deleting + prefetch for Logs.vue

* feat: move delete before share

* feat: clear endpoint

* feat: we ball!

---------

Co-authored-by: Calum H. <calum@modrinth.com>
Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
This commit is contained in:
Truman Gao
2026-04-12 15:38:08 -06:00
committed by GitHub
parent a2a97d1313
commit 693a371d61
278 changed files with 15974 additions and 12608 deletions

View File

@@ -1,18 +1,41 @@
<template>
<div
data-pyro-server-list-root
class="experimental-styles-within relative mx-auto mb-6 flex min-h-screen w-full max-w-[1280px] flex-col px-6"
class="experimental-styles-within relative mx-auto mb-6 flex w-full flex-col p-6"
:class="serverList.length ? 'min-h-screen' : 'min-h-[calc(100vh-4.5rem)]'"
>
<ServersUpgradeModalWrapper
v-if="isNuxt"
ref="upgradeModal"
:stripe-publishable-key
:site-url
:products
<ServersGuestPlanModal
ref="guestPlanModal"
:available-products="pyroProducts"
:currency="selectedCurrency"
:logged-in="loggedIn"
@continue="handleGuestPlanContinue"
/>
<ModrinthServersPurchaseModal
v-if="customer && paymentMethods && regions"
ref="purchaseModal"
:publishable-key="props.stripePublishableKey"
:initiate-payment="
async (body) => await client.labrinth.billing_internal.initiatePayment(body)
"
:available-products="pyroProducts"
:on-error="handleError"
:customer="customer"
:payment-methods="paymentMethods"
:currency="selectedCurrency"
:pings="regionPings"
:regions="regions"
:refresh-payment-methods="fetchPaymentData"
:fetch-stock="fetchStock"
:affiliate-code="affiliateCode"
plan-stage
@purchase-success="handlePurchaseSuccess"
@hide="clearPurchaseIntent"
/>
<ResubscribeModal ref="resubscribeModal" @resubscribe="handleResubscribeConfirm" />
<div
v-if="hasError || fetchError"
v-if="hasError"
class="mx-auto flex h-full min-h-[calc(100vh-4rem)] flex-col items-center justify-center gap-4 text-left"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
@@ -21,28 +44,22 @@
<div class="grid place-content-center rounded-full bg-bg-blue p-4">
<HammerIcon class="size-12 text-blue" />
</div>
<h1 class="m-0 w-fit text-3xl font-bold">Servers could not be loaded</h1>
<h1 class="m-0 w-fit text-3xl font-bold">{{ formatMessage(messages.errorTitle) }}</h1>
</div>
<p class="text-lg text-secondary">We may have temporary issues with our servers.</p>
<p class="text-lg text-secondary">{{ formatMessage(messages.errorDescription) }}</p>
<ul class="m-0 list-disc space-y-4 p-0 pl-4 text-left text-sm leading-[170%]">
<li>{{ formatMessage(messages.errorAlertNotice) }}</li>
<li>
Our systems automatically alert our team when there's an issue. We are already working
on getting them back online.
</li>
<li>
If you recently purchased your Modrinth Hosting server, it is currently in a queue and
will appear here as soon as it's ready. <br />
<span class="font-medium text-contrast"
>Do not attempt to purchase a new server.</span
>
</li>
<li>
If you require personalized support regarding the status of your server, please
contact Modrinth Support.
<IntlFormatted :message-id="messages.errorQueueNotice">
<template #warning="{ children }">
<span class="font-medium text-contrast"><component :is="() => children" /></span>
</template>
</IntlFormatted>
</li>
<li>{{ formatMessage(messages.errorSupportNotice) }}</li>
<li v-if="fetchError" class="text-red">
<p>Error details:</p>
<p>{{ formatMessage(messages.errorDetails) }}</p>
<CopyCode
:text="(fetchError as ModrinthServersFetchError).message || 'Unknown error'"
:copyable="false"
@@ -53,21 +70,25 @@
</ul>
</div>
<ButtonStyled size="large" type="standard" color="brand">
<AutoLink class="mt-6 !w-full" to="https://support.modrinth.com"
>Contact Modrinth Support</AutoLink
>
<AutoLink class="mt-6 !w-full" to="https://support.modrinth.com">{{
formatMessage(messages.contactSupportButton)
}}</AutoLink>
</ButtonStyled>
<ButtonStyled size="large" @click="() => router.go(0)">
<button class="mt-3 !w-full">Reload</button>
<button class="mt-3 !w-full">{{ formatMessage(messages.reloadButton) }}</button>
</ButtonStyled>
</div>
</div>
<Transition v-else name="fade" mode="out-in">
<div v-if="isLoading && !serverResponse" key="loading" class="flex flex-col gap-4 py-8">
<div
v-if="(isLoading || !authReady) && !serverResponse"
key="loading"
class="flex flex-col gap-4 py-8"
>
<div class="mb-4 text-center">
<LoaderCircleIcon class="mx-auto size-8 animate-spin text-contrast" />
<p class="m-0 mt-2 text-secondary">Loading your servers...</p>
<p class="m-0 mt-2 text-secondary">{{ formatMessage(messages.loadingServers) }}</p>
</div>
<div
v-for="i in 3"
@@ -85,27 +106,23 @@
<div
v-else-if="serverList.length === 0 && !isPollingForNewServers"
key="empty"
class="flex h-full flex-col items-center justify-center gap-8"
class="flex h-full flex-col items-center justify-center gap-8 grow max-h-[1100px]"
>
<img
src="https://cdn.modrinth.com/servers/excitement.webp"
alt=""
class="max-w-[360px]"
style="
mask-image: radial-gradient(97% 77% at 50% 25%, #d9d9d9 0, hsla(0, 0%, 45%, 0) 100%);
"
<ServerListEmpty
:logged-in="loggedIn"
@click-new-server="openPurchaseModal"
@click-sign-in="handleSignIn"
/>
<h1 class="m-0 text-contrast">You don't have any servers yet!</h1>
<p class="m-0">Modrinth Hosting is a new way to play modded Minecraft with your friends.</p>
<ButtonStyled size="large" type="standard" color="brand">
<AutoLink to="/servers#plan">Create a server</AutoLink>
</ButtonStyled>
</div>
<div v-else key="list">
<div class="relative flex h-fit w-full flex-col items-center justify-between md:flex-row">
<h1 class="w-full text-4xl font-bold text-contrast">Servers</h1>
<div class="mb-4 flex w-full flex-row items-center justify-end gap-2 md:mb-0 md:gap-4">
<div
class="relative flex h-fit w-full flex-col mb-4 items-center justify-between md:flex-row"
>
<h1 class="w-full text-2xl m-0 font-extrabold text-contrast">
{{ formatMessage(messages.serversTitle) }}
</h1>
<div class="flex w-full flex-row items-center justify-end gap-2 md:mb-0">
<StyledInput
id="search"
v-model="searchInput"
@@ -113,14 +130,16 @@
type="search"
name="search"
autocomplete="off"
placeholder="Search servers..."
:placeholder="
formatMessage(messages.searchPlaceholder, { count: filteredData.length })
"
wrapper-class="w-full md:w-72"
/>
<ButtonStyled v-if="isNuxt" type="standard">
<AutoLink :to="{ path: '/servers', hash: '#plan' }">
<ButtonStyled type="standard" color="brand">
<button @click="openPurchaseModal">
<PlusIcon />
New server
</AutoLink>
{{ formatMessage(messages.newServerButton) }}
</button>
</ButtonStyled>
</div>
</div>
@@ -134,11 +153,11 @@
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="isPollingForNewServers"
v-if="showPollingForNewServers"
class="bg-brand/10 my-4 flex items-center justify-center gap-2 rounded-full px-4 py-2 text-sm text-brand"
>
<LoaderCircleIcon class="size-4 animate-spin" />
<span>Checking for new servers...</span>
<span>{{ formatMessage(messages.checkingForNewServers) }}</span>
</div>
</Transition>
@@ -146,64 +165,347 @@
v-if="filteredData.length > 0 || isPollingForNewServers"
name="list"
tag="ul"
class="m-0 flex flex-col gap-4 p-0"
class="m-0 flex flex-col gap-3 p-0"
>
<MedalServerListing
v-for="server in filteredData.filter((s) => s.is_medal)"
:key="server.server_id"
v-bind="server"
@upgrade="openUpgradeModal(server.server_id)"
@upgrade="openPurchaseModal"
/>
<ServerListing
v-for="server in filteredData.filter((s) => !s.is_medal)"
:key="server.server_id"
v-bind="server"
:cancellation-date="serverBillingMap.get(server.server_id)?.cancellationDate"
:is-provisioning="serverBillingMap.get(server.server_id)?.isProvisioning"
:on-resubscribe="serverBillingMap.get(server.server_id)?.onResubscribe"
:on-download-backup="serverBillingMap.get(server.server_id)?.onDownloadBackup"
/>
</TransitionGroup>
<div v-else class="flex h-full items-center justify-center">
<div v-else-if="isLoading" class="flex h-full items-center justify-center">
<p class="text-contrast"><LoaderCircleIcon class="size-5 animate-spin" /></p>
</div>
<div v-else>{{ formatMessage(messages.noServersFound) }}</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { type Archon, type Labrinth, NuxtModrinthClient } from '@modrinth/api-client'
import type { Archon, Labrinth } from '@modrinth/api-client'
import { HammerIcon, LoaderCircleIcon, PlusIcon, SearchIcon } from '@modrinth/assets'
import { AutoLink, ButtonStyled, CopyCode, injectModrinthClient, StyledInput } from '@modrinth/ui'
import {
AutoLink,
ButtonStyled,
CopyCode,
defineMessages,
injectAuth,
injectModrinthClient,
injectNotificationManager,
IntlFormatted,
ModrinthServersPurchaseModal,
ResubscribeModal,
ServerListEmpty,
ServersGuestPlanModal,
StyledInput,
useServerBackupDownload,
useVIntl,
} from '@modrinth/ui'
import type { ModrinthServersFetchError } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { useIntervalFn } from '@vueuse/core'
import dayjs from 'dayjs'
import Fuse from 'fuse.js'
import type { ComponentPublicInstance } from 'vue'
import type Stripe from 'stripe'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ServersUpgradeModalWrapper from '#ui/components/billing/ServersUpgradeModalWrapper.vue'
import MedalServerListing from '#ui/components/servers/marketing/MedalServerListing.vue'
import ServerListing from '#ui/components/servers/ServerListing.vue'
import { createHostingPurchaseIntentContext, provideHostingPurchaseIntent } from '#ui/providers'
defineProps<{
stripePublishableKey?: string
const props = defineProps<{
stripePublishableKey: string
siteUrl?: string
products?: Labrinth.Billing.Internal.Product[]
products: Labrinth.Billing.Internal.Product[]
}>()
const router = useRouter()
const route = useRoute()
const auth = injectAuth()
const client = injectModrinthClient()
const loggedIn = computed(() => !!auth.user.value)
const authReady = computed(() => auth.isReady?.value ?? true)
const { formatMessage } = useVIntl()
const isNuxt = computed(() => client instanceof NuxtModrinthClient)
const messages = defineMessages({
errorTitle: { id: 'servers.manage.error.title', defaultMessage: 'Servers could not be loaded' },
errorDescription: {
id: 'servers.manage.error.description',
defaultMessage: 'We may have temporary issues with our servers.',
},
errorAlertNotice: {
id: 'servers.manage.error.alert-notice',
defaultMessage:
"Our systems automatically alert our team when there's an issue. We are already working on getting them back online.",
},
errorQueueNotice: {
id: 'servers.manage.error.queue-notice',
defaultMessage:
"If you recently purchased your Modrinth Hosting server, it is currently in a queue and will appear here as soon as it's ready. <warning>Do not attempt to purchase a new server.</warning>",
},
errorSupportNotice: {
id: 'servers.manage.error.support-notice',
defaultMessage:
'If you require personalized support regarding the status of your server, please contact Modrinth Support.',
},
errorDetails: { id: 'servers.manage.error.details', defaultMessage: 'Error details:' },
contactSupportButton: {
id: 'servers.manage.contact-support-button',
defaultMessage: 'Contact Modrinth Support',
},
reloadButton: { id: 'servers.manage.reload-button', defaultMessage: 'Reload' },
loadingServers: {
id: 'servers.manage.loading-servers',
defaultMessage: 'Loading your servers...',
},
serversTitle: { id: 'servers.manage.servers-title', defaultMessage: 'Modrinth Hosting' },
searchPlaceholder: {
id: 'servers.manage.search-placeholder',
defaultMessage: 'Search {count} {count, plural, one {server} other {servers}}...',
},
newServerButton: { id: 'servers.manage.new-server-button', defaultMessage: 'New server' },
checkingForNewServers: {
id: 'servers.manage.checking-for-new-servers',
defaultMessage: 'Checking for new servers...',
},
noServersFound: { id: 'servers.manage.no-servers-found', defaultMessage: 'No servers found.' },
handleErrorTitle: {
id: 'servers.manage.handle-error.title',
defaultMessage: 'An error occurred',
},
purchaseUnavailableTitle: {
id: 'servers.manage.purchase-unavailable.title',
defaultMessage: 'Purchase unavailable',
},
purchaseUnavailableText: {
id: 'servers.manage.purchase-unavailable.text',
defaultMessage:
'Payment information is still loading. Opening checkout as soon as it is ready.',
},
resubscribeSubmittedTitle: {
id: 'servers.manage.resubscribe-submitted.title',
defaultMessage: 'Resubscription request submitted',
},
resubscribeSubmittedText: {
id: 'servers.manage.resubscribe-submitted.text',
defaultMessage:
'If the server is currently cancelled, it may take up to 10 minutes for another charge attempt to be made.',
},
resubscribeSuccessTitle: {
id: 'servers.manage.resubscribe-success.title',
defaultMessage: 'Success',
},
resubscribeSuccessText: {
id: 'servers.manage.resubscribe-success.text',
defaultMessage: 'Server subscription resubscribed successfully',
},
resubscribeErrorTitle: {
id: 'servers.manage.resubscribe-error.title',
defaultMessage: 'Error resubscribing',
},
resubscribeErrorText: {
id: 'servers.manage.resubscribe-error.text',
defaultMessage: 'An error occurred while resubscribing to your Modrinth server.',
},
})
const hasError = ref(false)
const isPollingForNewServers = ref(false)
const showPollingForNewServers = ref(false)
let pollingShowTimeout: ReturnType<typeof setTimeout> | undefined
watch(isPollingForNewServers, (polling) => {
clearTimeout(pollingShowTimeout)
if (polling) {
pollingShowTimeout = setTimeout(() => {
showPollingForNewServers.value = isPollingForNewServers.value
}, 1500)
} else {
showPollingForNewServers.value = false
}
})
const pollingState = ref({
enabled: false,
count: 0,
initialServers: [] as Archon.Servers.v0.Server[],
initialServerIds: new Set<string>(),
})
function startNewServerPolling(initialServers: Archon.Servers.v0.Server[]) {
if (pollingState.value.enabled) return
isPollingForNewServers.value = true
pollingState.value = {
enabled: true,
count: 0,
initialServerIds: new Set(initialServers.map((s) => s.server_id)),
}
}
const guestPlanModal = ref<InstanceType<typeof ServersGuestPlanModal> | null>(null)
const purchaseModal = ref<InstanceType<typeof ModrinthServersPurchaseModal> | null>(null)
const resubscribeModal = ref<InstanceType<typeof ResubscribeModal> | null>(null)
const affiliateCode = ref<string | null>(null)
const selectedCurrency = ref<string>('USD')
const regionPings = ref<
{
region: string
ping: number
}[]
>([])
const pyroProducts = computed(() => {
return [...props.products]
.filter((p) => p?.metadata?.type === 'pyro' || p?.metadata?.type === 'medal')
.sort((a, b) => {
const aRam =
a?.metadata?.type === 'pyro' || a?.metadata?.type === 'medal' ? a.metadata.ram : 0
const bRam =
b?.metadata?.type === 'pyro' || b?.metadata?.type === 'medal' ? b.metadata.ram : 0
return aRam - bRam
})
})
const {
data: customer,
refetch: refetchCustomer,
isLoading: customerLoading,
} = useQuery({
queryKey: ['billing', 'customer'],
queryFn: () => client.labrinth.billing_internal.getCustomer() as Promise<Stripe.Customer>,
enabled: loggedIn,
})
const {
data: paymentMethods,
refetch: refetchPaymentMethods,
isLoading: paymentMethodsLoading,
} = useQuery({
queryKey: ['billing', 'payment-methods'],
queryFn: () =>
client.labrinth.billing_internal.getPaymentMethods() as Promise<Stripe.PaymentMethod[]>,
enabled: loggedIn,
})
const { data: regions, isLoading: regionsLoading } = useQuery({
queryKey: ['servers', 'regions'],
queryFn: () => client.archon.servers_v1.getRegions(),
enabled: loggedIn,
})
watch(
regions,
(newRegions) => {
regionPings.value = []
if (newRegions) {
newRegions.forEach((region) => {
runPingTest(region)
})
}
},
{ immediate: true },
)
async function fetchPaymentData() {
await Promise.all([refetchCustomer(), refetchPaymentMethods()])
}
async function fetchStock(
region: Archon.Servers.v1.Region,
request: Archon.Servers.v0.StockRequest,
): Promise<number> {
const result = await client.archon.servers_v0.checkStock(region.shortcode, request)
return result.available
}
const PING_COUNT = 20
const PING_INTERVAL = 200
const MAX_PING_TIME = 1000
function runPingTest(region: Archon.Servers.v1.Region, index = 1) {
if (index > 10) {
regionPings.value = regionPings.value.filter((entry) => entry.region !== region.shortcode)
regionPings.value.push({
region: region.shortcode,
ping: -1,
})
return
}
const wsUrl = `wss://${region.shortcode}${index}.${region.zone}/pingtest`
try {
const socket = new WebSocket(wsUrl)
const pings: number[] = []
let finalized = false
const finalize = (ping: number) => {
if (finalized) return
finalized = true
clearTimeout(connectTimeout)
regionPings.value = regionPings.value.filter((entry) => entry.region !== region.shortcode)
regionPings.value.push({
region: region.shortcode,
ping,
})
socket.close()
}
const retryNext = () => {
if (finalized) return
finalized = true
clearTimeout(connectTimeout)
socket.close()
runPingTest(region, index + 1)
}
// Prevent hangs where the socket never opens or errors.
const connectTimeout = setTimeout(() => {
retryNext()
}, 3000)
socket.onopen = () => {
clearTimeout(connectTimeout)
for (let i = 0; i < PING_COUNT; i++) {
setTimeout(() => {
socket.send(String(performance.now()))
}, i * PING_INTERVAL)
}
setTimeout(
() => {
const median =
pings.length > 0
? Math.round([...pings].sort((a, b) => a - b)[Math.floor(pings.length / 2)])
: -1
finalize(median)
},
PING_COUNT * PING_INTERVAL + MAX_PING_TIME,
)
}
socket.onmessage = (event) => {
const start = Number(event.data)
pings.push(performance.now() - start)
}
socket.onerror = () => {
retryNext()
}
} catch {
runPingTest(region, index + 1)
}
}
const {
data: serverResponse,
error: fetchError,
@@ -211,7 +513,7 @@ const {
} = useQuery({
queryKey: ['servers'],
queryFn: async () => {
const response = await client.archon.servers_v0.list()
const response = await client.archon.servers_v0.list({ limit: 100 })
// Fetch subscriptions for medal servers
const hasMedalServers = response.servers.some((s) => s.is_medal)
@@ -232,9 +534,13 @@ const {
// Check if new servers appeared (stop polling)
if (pollingState.value.enabled) {
pollingState.value.count++
if (response.servers.length !== pollingState.value.initialServers.length) {
const hasNewServer = response.servers.some(
(s) => !pollingState.value.initialServerIds.has(s.server_id),
)
if (hasNewServer) {
pollingState.value.enabled = false
isPollingForNewServers.value = false
router.replace({ query: {} })
} else if (pollingState.value.count >= 5) {
pollingState.value.enabled = false
@@ -245,14 +551,13 @@ const {
return response
},
refetchInterval: computed(() => (pollingState.value.enabled ? 5000 : false)),
enabled: loggedIn,
})
watch([fetchError, serverResponse], ([error, response]) => {
hasError.value = !!error || !response
})
const hasError = computed(() => loggedIn.value && !!fetchError.value)
const serverList = computed<Archon.Servers.v0.Server[]>(() => {
if (!serverResponse.value) return []
if (!loggedIn.value || !serverResponse.value) return []
return serverResponse.value.servers
})
@@ -267,19 +572,48 @@ const fuse = computed(() => {
})
})
function introToTop(array: Archon.Servers.v0.Server[]): Archon.Servers.v0.Server[] {
function isSetToCancel(server: Archon.Servers.v0.Server): boolean {
return (
server.status !== 'suspended' &&
Boolean(serverBillingMap.value.get(server.server_id)?.cancellationDate)
)
}
function getStatusPriority(server: Archon.Servers.v0.Server): number {
if (server.status === 'suspended') return 2
if (isSetToCancel(server)) return 1
return 0
}
function sortServers(array: Archon.Servers.v0.Server[]): Archon.Servers.v0.Server[] {
return array.slice().sort((a, b) => {
return Number(b.flows?.intro) - Number(a.flows?.intro)
const priorityDiff = getStatusPriority(a) - getStatusPriority(b)
if (priorityDiff !== 0) return priorityDiff
const introDiff = Number(b.flows?.intro) - Number(a.flows?.intro)
if (introDiff !== 0) return introDiff
return (a.name || '').localeCompare(b.name || '')
})
}
// files expire 30 days after cancellation
function filesExpired(server: Archon.Servers.v0.Server): boolean {
if (server.status !== 'suspended' || server.suspension_reason !== 'cancelled') return false
const cancellationDate = serverBillingMap.value.get(server.server_id)?.cancellationDate
if (!cancellationDate) return false
const cancellation = new Date(cancellationDate)
const thirtyDaysLater = new Date(cancellation.getTime() + 30 * 24 * 60 * 60 * 1000)
return new Date() > thirtyDaysLater
}
const filteredData = computed<Archon.Servers.v0.Server[]>(() => {
if (!searchInput.value.trim()) {
return introToTop(serverList.value)
}
return fuse.value
? introToTop(fuse.value.search(searchInput.value).map((result) => result.item))
: []
const base = !searchInput.value.trim()
? sortServers(serverList.value)
: fuse.value
? sortServers(fuse.value.search(searchInput.value).map((result) => result.item))
: []
return base.filter((server) => !filesExpired(server))
})
// Start polling only after initial data is available so the baseline is correct
@@ -290,23 +624,307 @@ watch(serverResponse, (response) => {
!pollingState.value.enabled &&
pollingState.value.count === 0
) {
isPollingForNewServers.value = true
pollingState.value = {
enabled: true,
count: 0,
initialServers: [...response.servers],
}
startNewServerPolling(response.servers)
}
})
type ServersUpgradeModalWrapperRef = ComponentPublicInstance<{
open: (id: string) => void | Promise<void>
}>
const { addNotification } = injectNotificationManager()
const queryClient = useQueryClient()
const { getLatestBackupDownload } = useServerBackupDownload()
const upgradeModal = ref<ServersUpgradeModalWrapperRef | null>(null)
function openUpgradeModal(serverId: string) {
upgradeModal.value?.open(serverId)
function handlePurchaseSuccess() {
startNewServerPolling(serverResponse.value?.servers ?? [])
void Promise.all([
queryClient.invalidateQueries({ queryKey: ['servers'] }),
queryClient.invalidateQueries({ queryKey: ['servers', 'v1'] }),
])
}
watch(
() => auth.user.value,
(user, previousUser) => {
if (user || !previousUser) return
isPollingForNewServers.value = false
pollingState.value = {
enabled: false,
count: 0,
initialServerIds: new Set(),
}
void Promise.all([
queryClient.resetQueries({ queryKey: ['billing'] }),
queryClient.resetQueries({ queryKey: ['servers'] }),
])
},
)
const canOpenPurchaseModal = computed(() => {
return (
Boolean(props.stripePublishableKey) &&
Boolean(customer.value) &&
paymentMethods.value !== undefined &&
Boolean(regions.value) &&
!customerLoading.value &&
!paymentMethodsLoading.value &&
!regionsLoading.value
)
})
function handleError(err: unknown) {
const error = err as Error & { data?: { description?: string } }
addNotification({
title: formatMessage(messages.handleErrorTitle),
type: 'error',
text: error?.message ?? error?.data?.description ?? String(err),
})
}
function handleSignIn() {
void auth.requestSignIn('/hosting/manage')
}
const hostingPurchaseIntent = createHostingPurchaseIntentContext({
authRequestSignIn: auth.requestSignIn,
signInRedirectPath: '/hosting/manage',
intentSource: 'hosting-manage',
loggedIn,
availableProducts: pyroProducts,
canOpenCheckout: canOpenPurchaseModal,
guestPlanModal,
checkoutModal: purchaseModal,
onCheckoutPending: () => {
addNotification({
title: formatMessage(messages.purchaseUnavailableTitle),
text: formatMessage(messages.purchaseUnavailableText),
type: 'info',
})
},
})
provideHostingPurchaseIntent(hostingPurchaseIntent)
const { openPurchaseModal, handleGuestPlanContinue, clearPurchaseIntent } = hostingPurchaseIntent
const { data: subscriptions } = useQuery({
queryKey: ['billing', 'subscriptions'],
queryFn: () => client.labrinth.billing_internal.getSubscriptions(),
enabled: loggedIn,
})
const { data: charges } = useQuery({
queryKey: ['billing', 'payments'],
queryFn: () => client.labrinth.billing_internal.getPayments(),
enabled: loggedIn,
})
const CHARGE_POLL_INTERVAL_MS = 20_000
const hasProvisioningSubscription = computed(() => {
if (!subscriptions.value || !charges.value) return false
return subscriptions.value
.filter((s) => s?.metadata?.type === 'pyro')
.some((sub) => {
if (sub.status !== 'unprovisioned') return false
const charge = charges.value?.find((c) => c.subscription_id === sub.id)
return charge?.status === 'processing' || charge?.status === 'open'
})
})
const { pause: pauseChargePoll, resume: resumeChargePoll } = useIntervalFn(
() => {
queryClient.invalidateQueries({ queryKey: ['billing', 'payments'] })
queryClient.invalidateQueries({ queryKey: ['billing', 'subscriptions'] })
queryClient.invalidateQueries({ queryKey: ['servers'] })
},
CHARGE_POLL_INTERVAL_MS,
{ immediate: false },
)
watch(
hasProvisioningSubscription,
(isProvisioning) => {
if (isProvisioning) {
resumeChargePoll()
} else {
pauseChargePoll()
}
},
{ immediate: true },
)
const { data: serverFullList } = useQuery({
queryKey: ['servers', 'v1'],
queryFn: () => client.archon.servers_v1.list(),
enabled: loggedIn,
})
type ServerBillingInfo = {
cancellationDate?: string | null
isProvisioning?: boolean
onResubscribe?: () => void
onDownloadBackup?: (() => void) | null
}
type ResubscribeRequest = {
subscriptionId: string
wasSuspended: boolean
}
function getProductFromPriceId(priceId: string | null | undefined) {
if (!priceId) return null
return (
pyroProducts.value.find((product) => product.prices.some((price) => price.id === priceId)) ??
null
)
}
function getPlanName(product: Labrinth.Billing.Internal.Product | null): string {
if (!product) return 'Medium plan'
if (product.metadata.type !== 'pyro' && product.metadata.type !== 'medal') return 'Medium plan'
switch (product.metadata.ram) {
case 4096:
return 'Small plan'
case 6144:
return 'Medium plan'
case 8192:
return 'Large plan'
default:
return 'Custom plan'
}
}
function getRamGb(product: Labrinth.Billing.Internal.Product | null): number | undefined {
if (!product) return undefined
if (product.metadata.type !== 'pyro' && product.metadata.type !== 'medal') return undefined
return product.metadata.ram / 1024
}
function getStorageGb(product: Labrinth.Billing.Internal.Product | null): number | undefined {
if (!product) return undefined
if (product.metadata.type !== 'pyro' && product.metadata.type !== 'medal') return undefined
return product.metadata.storage / 1024
}
function getSharedCpus(product: Labrinth.Billing.Internal.Product | null): number | undefined {
if (!product) return undefined
if (product.metadata.type !== 'pyro' && product.metadata.type !== 'medal') return undefined
return product.metadata.cpu / 2
}
function getRecurringPrice(
product: Labrinth.Billing.Internal.Product | null,
interval: Labrinth.Billing.Internal.PriceDuration,
preferredCurrency?: string,
): { amount: number; currencyCode: string } | null {
if (!product) return null
const recurringPrices = product.prices.filter((price) => price.prices.type === 'recurring')
const preferredPrice = preferredCurrency
? recurringPrices.find((price) => price.currency_code === preferredCurrency)
: undefined
const usdPrice = recurringPrices.find((price) => price.currency_code === 'USD')
const selectedPrice = preferredPrice ?? usdPrice ?? recurringPrices[0]
if (!selectedPrice || selectedPrice.prices.type !== 'recurring') return null
return {
amount: selectedPrice.prices.intervals[interval],
currencyCode: selectedPrice.currency_code,
}
}
function openResubscribeModal(
serverId: string,
subscription: Labrinth.Billing.Internal.UserSubscription,
charge?: Labrinth.Billing.Internal.Charge | null,
) {
const displayInterval = charge?.subscription_interval ?? subscription.interval
const displayPriceId = charge?.price_id ?? subscription.price_id
const product = getProductFromPriceId(displayPriceId)
const fallbackPrice = getRecurringPrice(product, displayInterval, charge?.currency_code)
resubscribeModal.value?.show({
subscriptionId: subscription.id,
wasSuspended: !!charge?.due && dayjs(charge.due).isBefore(dayjs()),
serverName:
serverList.value.find((server) => server.server_id === serverId)?.name ?? 'this server',
planName: getPlanName(product),
ramGb: getRamGb(product),
storageGb: getStorageGb(product),
sharedCpus: getSharedCpus(product),
priceCents: charge?.amount ?? fallbackPrice?.amount,
currencyCode: charge?.currency_code ?? fallbackPrice?.currencyCode,
interval: displayInterval,
nextChargeDate: charge?.due,
})
}
async function handleResubscribeConfirm({ subscriptionId, wasSuspended }: ResubscribeRequest) {
try {
await client.labrinth.billing_internal.editSubscription(subscriptionId, {
cancelled: false,
})
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['billing'] }),
queryClient.invalidateQueries({ queryKey: ['servers'] }),
])
if (wasSuspended) {
addNotification({
title: formatMessage(messages.resubscribeSubmittedTitle),
text: formatMessage(messages.resubscribeSubmittedText),
type: 'success',
})
} else {
addNotification({
title: formatMessage(messages.resubscribeSuccessTitle),
text: formatMessage(messages.resubscribeSuccessText),
type: 'success',
})
}
} catch {
addNotification({
title: formatMessage(messages.resubscribeErrorTitle),
text: formatMessage(messages.resubscribeErrorText),
type: 'error',
})
}
}
const serverBillingMap = computed(() => {
const map = new Map<string, ServerBillingInfo>()
if (!subscriptions.value || !charges.value) return map
const pyroSubs = subscriptions.value.filter((s) => s?.metadata?.type === 'pyro')
for (const sub of pyroSubs) {
const serverId = (sub.metadata as { id?: string })?.id
if (!serverId) continue
const charge = charges.value.find(
(c) => c.subscription_id === sub.id && c.status !== 'succeeded',
)
const info: ServerBillingInfo = {
isProvisioning:
sub.status === 'unprovisioned' &&
(charge?.status === 'processing' || charge?.status === 'open'),
}
info.onDownloadBackup = getLatestBackupDownload(serverId, serverFullList.value)
if (charge?.status === 'cancelled') {
info.cancellationDate = charge.due
info.onResubscribe = () => openResubscribeModal(serverId, sub, charge)
}
map.set(serverId, info)
}
return map
})
</script>
<style scoped>