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:
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="mx-auto flex w-fit flex-col items-start gap-4 mt-6 max-w-[500px]">
|
||||
<div class="mx-auto flex w-fit flex-col items-start gap-4 mt-16 max-w-[500px]">
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
<h2 class="m-0 text-2xl font-semibold text-contrast">Welcome to Modrinth</h2>
|
||||
<h2 class="m-0 text-2xl font-semibold text-contrast">Welcome to Modrinth Hosting</h2>
|
||||
<p class="m-0 text-base text-secondary">
|
||||
Your server is ready. Here's what you need to do to start playing!
|
||||
</p>
|
||||
@@ -95,6 +95,19 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
browseModpacks?: (args: {
|
||||
serverId: string
|
||||
worldId: string | null
|
||||
from: 'onboarding'
|
||||
}) => void | Promise<void>
|
||||
}>(),
|
||||
{
|
||||
browseModpacks: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const modalRef = ref<InstanceType<typeof CreationFlowModal> | null>(null)
|
||||
|
||||
const uploading = ref(false)
|
||||
@@ -109,6 +122,15 @@ const openModal = () => modalRef.value?.show()
|
||||
onBeforeUnmount(() => modalRef.value?.hide())
|
||||
|
||||
function onBrowseModpacks() {
|
||||
if (props.browseModpacks) {
|
||||
props.browseModpacks({
|
||||
serverId,
|
||||
worldId: worldId.value,
|
||||
from: 'onboarding',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
router.push({
|
||||
path: '/discover/modpacks',
|
||||
query: { sid: serverId, from: 'onboarding', wid: worldId.value },
|
||||
@@ -152,7 +174,7 @@ async function finalizeSetup() {
|
||||
client.archon.servers_v1.endIntro(serverId).then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
|
||||
})
|
||||
await router.push(`/hosting/manage/${serverId}/content`)
|
||||
await router.push(`/hosting/manage/${serverId}/`)
|
||||
}
|
||||
|
||||
/** Map UI loader names to API Modloader values */
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
<template>
|
||||
<div
|
||||
data-pyro-server-stats
|
||||
style="font-variant-numeric: tabular-nums"
|
||||
class="flex select-none flex-col items-center gap-4 md:flex-row"
|
||||
:class="{ 'pointer-events-none': loading }"
|
||||
:aria-hidden="loading"
|
||||
>
|
||||
<component
|
||||
:is="metric.link ? RouterLink : 'div'"
|
||||
v-for="(metric, index) in metrics"
|
||||
:key="index"
|
||||
:to="metric.link && !loading ? metric.link : undefined"
|
||||
class="relative isolate min-h-[145px] w-full overflow-hidden rounded-[20px] bg-surface-3 p-5"
|
||||
:class="
|
||||
metric.link && !loading
|
||||
? 'cursor-pointer transition-transform duration-100 hover:brightness-125 active:scale-95'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<div class="relative z-10 flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="stat-drop-shadow flex items-center gap-2 font-medium text-lg text-primary">
|
||||
{{ metric.title }}
|
||||
</span>
|
||||
<span class="relative">
|
||||
<component :is="metric.icon" class="stat-drop-shadow relative z-10 size-8" />
|
||||
<!-- <div
|
||||
class="absolute -right-4 -top-4 -z-10 size-14 rounded-full bg-surface-3 opacity-50 blur-lg"
|
||||
/> -->
|
||||
</span>
|
||||
</div>
|
||||
<span class="stat-drop-shadow text-4xl font-bold text-contrast">
|
||||
{{ metric.value }}
|
||||
</span>
|
||||
<!-- <div
|
||||
class="absolute -left-8 -top-4 -z-10 h-28 w-56 rounded-full bg-surface-3 opacity-50 blur-lg"
|
||||
/> -->
|
||||
</div>
|
||||
|
||||
<div v-if="metric.showGraph" class="chart-space absolute bottom-0 left-0 right-0">
|
||||
<VueApexCharts
|
||||
v-if="!loading && metric.chartOptions"
|
||||
type="area"
|
||||
height="142"
|
||||
:options="metric.chartOptions"
|
||||
:series="metric.series!"
|
||||
class="chart"
|
||||
:class="chartsReady.has(index) ? 'opacity-100' : 'opacity-0'"
|
||||
/>
|
||||
</div>
|
||||
</component>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CpuIcon, DatabaseIcon, FolderOpenIcon } from '@modrinth/assets'
|
||||
import type { Stats } from '@modrinth/utils'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { computed, defineAsyncComponent, ref, shallowRef, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import { injectModrinthServerContext } from '#ui/providers'
|
||||
|
||||
const VueApexCharts = defineAsyncComponent(() => import('vue3-apexcharts'))
|
||||
|
||||
const { serverId } = injectModrinthServerContext()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
data?: Stats
|
||||
loading?: boolean
|
||||
showMemoryAsBytes?: boolean
|
||||
}>(),
|
||||
{
|
||||
data: undefined,
|
||||
loading: false,
|
||||
showMemoryAsBytes: false,
|
||||
},
|
||||
)
|
||||
|
||||
const chartsReady = ref(new Set<number>())
|
||||
const userPreferences = useStorage(`pyro-server-${serverId || 'unknown'}-preferences`, {
|
||||
ramAsNumber: false,
|
||||
})
|
||||
|
||||
const stats = shallowRef(
|
||||
props.data?.current || {
|
||||
cpu_percent: 0,
|
||||
ram_usage_bytes: 0,
|
||||
ram_total_bytes: 1,
|
||||
storage_usage_bytes: 0,
|
||||
},
|
||||
)
|
||||
|
||||
const GRAPH_SIZE = 10
|
||||
|
||||
const padGraph = (data: number[]) => {
|
||||
const capped = data.map((v) => Math.min(v, 100))
|
||||
if (capped.length >= GRAPH_SIZE) return capped.slice(-GRAPH_SIZE)
|
||||
return [...Array(GRAPH_SIZE - capped.length).fill(0), ...capped]
|
||||
}
|
||||
|
||||
const cpuData = computed(() => padGraph(props.data?.graph.cpu ?? []))
|
||||
const ramData = computed(() => padGraph(props.data?.graph.ram ?? []))
|
||||
|
||||
const cpuPercent = computed(() => stats.value.cpu_percent)
|
||||
const ramPercent = computed(() => (stats.value.ram_usage_bytes / stats.value.ram_total_bytes) * 100)
|
||||
|
||||
const cpuWarning = computed(() => cpuPercent.value >= 90)
|
||||
const ramWarning = computed(() => ramPercent.value >= 90)
|
||||
|
||||
const cpuDataMax = 104
|
||||
const ramDataMax = 104
|
||||
|
||||
const onChartReady = (index: number) => {
|
||||
chartsReady.value.add(index)
|
||||
}
|
||||
|
||||
const buildChartOptions = (warning: boolean, index: number, dataMax: number) => ({
|
||||
chart: {
|
||||
type: 'area' as const,
|
||||
animations: { enabled: false },
|
||||
sparkline: { enabled: true },
|
||||
toolbar: { show: false },
|
||||
padding: { left: -10, right: -10, top: 0, bottom: 0 },
|
||||
events: {
|
||||
mounted: () => onChartReady(index),
|
||||
updated: () => onChartReady(index),
|
||||
},
|
||||
},
|
||||
stroke: { curve: 'smooth' as const, width: 3 },
|
||||
fill: {
|
||||
type: 'gradient' as const,
|
||||
gradient: { shadeIntensity: 1, opacityFrom: 0.25, opacityTo: 0.05, stops: [0, 100] },
|
||||
},
|
||||
tooltip: { enabled: false },
|
||||
grid: { show: false },
|
||||
xaxis: {
|
||||
labels: { show: false },
|
||||
axisBorder: { show: false },
|
||||
type: 'numeric' as const,
|
||||
tickAmount: GRAPH_SIZE,
|
||||
},
|
||||
yaxis: { show: false, min: 0, max: dataMax, forceNiceScale: false },
|
||||
colors: [warning ? 'var(--color-orange)' : 'var(--color-brand)'],
|
||||
dataLabels: { enabled: false },
|
||||
})
|
||||
|
||||
const cpuChartOptions = computed(() => buildChartOptions(cpuWarning.value, 0, cpuDataMax))
|
||||
const ramChartOptions = computed(() => buildChartOptions(ramWarning.value, 1, ramDataMax))
|
||||
|
||||
const cpuSeries = computed(() => [{ name: 'CPU', data: cpuData.value }])
|
||||
const ramSeries = computed(() => [{ name: 'Memory', data: ramData.value }])
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let value = bytes
|
||||
let unit = 0
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024
|
||||
unit++
|
||||
}
|
||||
return `${Math.round(value * 10) / 10} ${units[unit]}`
|
||||
}
|
||||
|
||||
const metrics = computed(() => {
|
||||
const storageMetric = {
|
||||
title: 'Storage',
|
||||
value: props.loading ? '0 B' : formatBytes(stats.value.storage_usage_bytes),
|
||||
icon: FolderOpenIcon,
|
||||
showGraph: false,
|
||||
chartOptions: null as ReturnType<typeof buildChartOptions> | null,
|
||||
series: null as { name: string; data: number[] }[] | null,
|
||||
link: `/hosting/manage/${encodeURIComponent(serverId)}/files`,
|
||||
}
|
||||
|
||||
if (props.loading) {
|
||||
return [
|
||||
{
|
||||
title: 'CPU',
|
||||
value: '0.00%',
|
||||
icon: CpuIcon,
|
||||
showGraph: true,
|
||||
chartOptions: cpuChartOptions.value,
|
||||
series: cpuSeries.value,
|
||||
link: null,
|
||||
},
|
||||
{
|
||||
title: 'Memory',
|
||||
value: '0.00%',
|
||||
icon: DatabaseIcon,
|
||||
showGraph: true,
|
||||
chartOptions: ramChartOptions.value,
|
||||
series: ramSeries.value,
|
||||
link: null,
|
||||
},
|
||||
storageMetric,
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
title: 'CPU',
|
||||
value: `${cpuPercent.value.toFixed(2)}%`,
|
||||
icon: CpuIcon,
|
||||
showGraph: true,
|
||||
chartOptions: cpuChartOptions.value,
|
||||
series: cpuSeries.value,
|
||||
link: null,
|
||||
},
|
||||
{
|
||||
title: 'Memory',
|
||||
value:
|
||||
props.showMemoryAsBytes || userPreferences.value.ramAsNumber
|
||||
? formatBytes(stats.value.ram_usage_bytes)
|
||||
: `${ramPercent.value.toFixed(2)}%`,
|
||||
icon: DatabaseIcon,
|
||||
showGraph: true,
|
||||
chartOptions: ramChartOptions.value,
|
||||
series: ramSeries.value,
|
||||
link: null,
|
||||
},
|
||||
storageMetric,
|
||||
]
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.data?.current,
|
||||
(newStats) => {
|
||||
if (newStats) {
|
||||
stats.value = newStats
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-drop-shadow {
|
||||
filter: drop-shadow(0 4px 6px var(--surface-3));
|
||||
}
|
||||
|
||||
.chart-space {
|
||||
height: 142px;
|
||||
width: calc(100% + 40px);
|
||||
margin-left: -20px;
|
||||
margin-right: -20px;
|
||||
}
|
||||
|
||||
.chart {
|
||||
width: 100% !important;
|
||||
height: 142px !important;
|
||||
transition: opacity 0.3s ease-out;
|
||||
box-shadow:
|
||||
0 1px 2px 0 rgba(0, 0, 0, 0.3),
|
||||
0 1px 3px 0 rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.chart :deep(svg) {
|
||||
overflow: visible;
|
||||
}
|
||||
</style>
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
injectNotificationManager,
|
||||
injectServerSettingsModal,
|
||||
} from '#ui/providers'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
@@ -31,6 +32,8 @@ import type {
|
||||
ContentModpackCardVersion,
|
||||
} from '../../../shared/content-tab/types'
|
||||
|
||||
type AddonWithUiState = Archon.Content.v1.Addon & { installing?: boolean }
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
@@ -95,6 +98,7 @@ const leaveMessages = defineMessages({
|
||||
const client = injectModrinthClient()
|
||||
const { server, worldId, busyReasons, isSyncingContent } = injectModrinthServerContext()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { openServerSettings, browseServerContent } = injectServerSettingsModal()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -397,10 +401,35 @@ if (typeof window !== 'undefined') {
|
||||
|
||||
const updatingProject = ref<ContentItem | null>(null)
|
||||
const updatingModpack = ref(false)
|
||||
const updatingProjectVersions = ref<Labrinth.Versions.v2.Version[]>([])
|
||||
const loadingVersions = ref(false)
|
||||
const loadingChangelog = ref(false)
|
||||
|
||||
const updatingProjectId = computed(() => updatingProject.value?.project?.id ?? null)
|
||||
|
||||
const projectVersionsQuery = useQuery({
|
||||
queryKey: computed(() => ['labrinth', 'versions', 'v2', updatingProjectId.value]),
|
||||
queryFn: () =>
|
||||
client.labrinth.versions_v2.getProjectVersions(updatingProjectId.value!, {
|
||||
include_changelog: false,
|
||||
}),
|
||||
enabled: computed(() => !!updatingProjectId.value && !updatingModpack.value),
|
||||
})
|
||||
|
||||
const updatingProjectVersions = computed(() => {
|
||||
const source = updatingModpack.value
|
||||
? modpackVersionsQuery.data.value
|
||||
: projectVersionsQuery.data.value
|
||||
if (!source) return []
|
||||
return [...source].sort(
|
||||
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
|
||||
)
|
||||
})
|
||||
|
||||
const loadingVersions = computed(() =>
|
||||
updatingModpack.value
|
||||
? modpackVersionsQuery.isLoading.value
|
||||
: projectVersionsQuery.isLoading.value,
|
||||
)
|
||||
|
||||
const modpackUpdateModal = ref<InstanceType<typeof ConfirmModpackUpdateModal>>()
|
||||
const pendingModpackUpdateVersion = ref<Labrinth.Versions.v2.Version | null>(null)
|
||||
const isModpackUpdateDowngrade = ref(false)
|
||||
@@ -409,6 +438,16 @@ const currentGameVersion = computed(() => contentQuery.data.value?.game_version
|
||||
const currentLoader = computed(() => contentQuery.data.value?.modloader ?? '')
|
||||
|
||||
function handleBrowseContent() {
|
||||
const contentType = type.value
|
||||
if (browseServerContent && ['mod', 'plugin', 'datapack'].includes(contentType)) {
|
||||
browseServerContent({
|
||||
serverId,
|
||||
worldId: worldId.value,
|
||||
type: contentType as 'mod' | 'plugin' | 'datapack',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
router.push({
|
||||
path: `/discover/${type.value}s`,
|
||||
query: { sid: serverId, wid: worldId.value },
|
||||
@@ -472,7 +511,7 @@ function handleUploadFiles() {
|
||||
input.click()
|
||||
}
|
||||
|
||||
function addonToContentItem(addon: Archon.Content.v1.Addon): ContentItem {
|
||||
function addonToContentItem(addon: AddonWithUiState): ContentItem {
|
||||
return {
|
||||
project: {
|
||||
id: addon.project_id ?? addon.filename,
|
||||
@@ -503,6 +542,7 @@ function addonToContentItem(addon: Archon.Content.v1.Addon): ContentItem {
|
||||
environment: addon.version?.environment ?? undefined,
|
||||
pack_client_retained: addon.pack_client_retained,
|
||||
pack_client_depends: addon.pack_client_depends,
|
||||
installing: addon.installing,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -620,31 +660,11 @@ async function handleUpdateItem(id: string) {
|
||||
|
||||
updatingModpack.value = false
|
||||
updatingProject.value = item
|
||||
updatingProjectVersions.value = []
|
||||
loadingVersions.value = true
|
||||
loadingChangelog.value = false
|
||||
|
||||
await nextTick()
|
||||
|
||||
contentUpdaterModal.value?.show(item.update_version_id ?? undefined)
|
||||
|
||||
try {
|
||||
const versions = await client.labrinth.versions_v2.getProjectVersions(item.project.id, {
|
||||
include_changelog: false,
|
||||
})
|
||||
versions.sort(
|
||||
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
|
||||
)
|
||||
updatingProjectVersions.value = versions
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.failedToLoadVersions),
|
||||
text: err instanceof Error ? err.message : undefined,
|
||||
})
|
||||
} finally {
|
||||
loadingVersions.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSwitchVersion(item: ContentItem) {
|
||||
@@ -652,31 +672,11 @@ async function handleSwitchVersion(item: ContentItem) {
|
||||
|
||||
updatingModpack.value = false
|
||||
updatingProject.value = item
|
||||
updatingProjectVersions.value = []
|
||||
loadingVersions.value = true
|
||||
loadingChangelog.value = false
|
||||
|
||||
await nextTick()
|
||||
|
||||
contentUpdaterModal.value?.show(item.version.id, { switchMode: true })
|
||||
|
||||
try {
|
||||
const versions = await client.labrinth.versions_v2.getProjectVersions(item.project.id, {
|
||||
include_changelog: false,
|
||||
})
|
||||
versions.sort(
|
||||
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
|
||||
)
|
||||
updatingProjectVersions.value = versions
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.failedToLoadVersions),
|
||||
text: err instanceof Error ? err.message : undefined,
|
||||
})
|
||||
} finally {
|
||||
loadingVersions.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleModpackUpdate() {
|
||||
@@ -687,41 +687,19 @@ async function handleModpackUpdate() {
|
||||
updatingProject.value = null
|
||||
loadingChangelog.value = false
|
||||
|
||||
const cached = modpackVersionsQuery.data.value
|
||||
if (cached) {
|
||||
const sorted = [...cached].sort(
|
||||
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
|
||||
)
|
||||
updatingProjectVersions.value = sorted
|
||||
loadingVersions.value = false
|
||||
} else {
|
||||
updatingProjectVersions.value = []
|
||||
loadingVersions.value = true
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
contentUpdaterModal.value?.show(mp.has_update ?? undefined)
|
||||
}
|
||||
|
||||
if (!cached) {
|
||||
try {
|
||||
const versions = await client.labrinth.versions_v2.getProjectVersions(mp.spec.project_id, {
|
||||
include_changelog: false,
|
||||
})
|
||||
versions.sort(
|
||||
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
|
||||
)
|
||||
updatingProjectVersions.value = versions
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.failedToLoadVersions),
|
||||
text: err instanceof Error ? err.message : undefined,
|
||||
})
|
||||
} finally {
|
||||
loadingVersions.value = false
|
||||
}
|
||||
}
|
||||
function spliceVersionInCache(fullVersion: Labrinth.Versions.v2.Version) {
|
||||
const projectId = updatingModpack.value ? modpackProjectId.value : updatingProjectId.value
|
||||
if (!projectId) return
|
||||
const key = ['labrinth', 'versions', 'v2', projectId]
|
||||
queryClient.setQueryData(key, (old: Labrinth.Versions.v2.Version[] | undefined) => {
|
||||
if (!old) return old
|
||||
return old.map((v) => (v.id === fullVersion.id ? fullVersion : v))
|
||||
})
|
||||
}
|
||||
|
||||
async function handleVersionSelect(version: Labrinth.Versions.v2.Version) {
|
||||
@@ -729,12 +707,7 @@ async function handleVersionSelect(version: Labrinth.Versions.v2.Version) {
|
||||
loadingChangelog.value = true
|
||||
try {
|
||||
const fullVersion = await client.labrinth.versions_v2.getVersion(version.id)
|
||||
const index = updatingProjectVersions.value.findIndex((v) => v.id === version.id)
|
||||
if (index !== -1) {
|
||||
const newVersions = [...updatingProjectVersions.value]
|
||||
newVersions[index] = fullVersion
|
||||
updatingProjectVersions.value = newVersions
|
||||
}
|
||||
spliceVersionInCache(fullVersion)
|
||||
} catch {
|
||||
// Silently fail on changelog fetch
|
||||
} finally {
|
||||
@@ -746,12 +719,7 @@ async function handleVersionHover(version: Labrinth.Versions.v2.Version) {
|
||||
if (version.changelog) return
|
||||
try {
|
||||
const fullVersion = await client.labrinth.versions_v2.getVersion(version.id)
|
||||
const index = updatingProjectVersions.value.findIndex((v) => v.id === version.id)
|
||||
if (index !== -1) {
|
||||
const newVersions = [...updatingProjectVersions.value]
|
||||
newVersions[index] = fullVersion
|
||||
updatingProjectVersions.value = newVersions
|
||||
}
|
||||
spliceVersionInCache(fullVersion)
|
||||
} catch {
|
||||
// Silently fail on hover prefetch
|
||||
}
|
||||
@@ -760,8 +728,6 @@ async function handleVersionHover(version: Labrinth.Versions.v2.Version) {
|
||||
function resetUpdateState() {
|
||||
updatingModpack.value = false
|
||||
updatingProject.value = null
|
||||
updatingProjectVersions.value = []
|
||||
loadingVersions.value = false
|
||||
loadingChangelog.value = false
|
||||
}
|
||||
|
||||
@@ -786,7 +752,23 @@ function handleModalUpdate(selectedVersion: Labrinth.Versions.v2.Version, event?
|
||||
performUpdate(selectedVersion)
|
||||
}
|
||||
|
||||
function setAddonInstalling(filename: string, installing: boolean) {
|
||||
queryClient.setQueryData(queryKey.value, (oldData: Archon.Content.v1.Addons | undefined) => {
|
||||
if (!oldData) return oldData
|
||||
return {
|
||||
...oldData,
|
||||
addons: (oldData.addons ?? []).map((a) =>
|
||||
a.filename === filename ? { ...a, installing } : a,
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function performUpdate(selectedVersion: Labrinth.Versions.v2.Version) {
|
||||
const item = updatingProject.value
|
||||
if (item) {
|
||||
setAddonInstalling(item.file_name, true)
|
||||
}
|
||||
try {
|
||||
if (updatingModpack.value) {
|
||||
const mp = contentQuery.data.value?.modpack
|
||||
@@ -800,8 +782,8 @@ async function performUpdate(selectedVersion: Labrinth.Versions.v2.Version) {
|
||||
},
|
||||
soft_override: true,
|
||||
})
|
||||
} else if (updatingProject.value) {
|
||||
const addon = addonLookup.value.get(updatingProject.value.file_name)
|
||||
} else if (item) {
|
||||
const addon = addonLookup.value.get(item.file_name)
|
||||
if (addon) {
|
||||
await client.archon.content_v1.updateAddon(serverId, worldId.value!, {
|
||||
filename: addon.filename,
|
||||
@@ -811,6 +793,9 @@ async function performUpdate(selectedVersion: Labrinth.Versions.v2.Version) {
|
||||
}
|
||||
await contentQuery.refetch()
|
||||
} catch (err) {
|
||||
if (item) {
|
||||
setAddonInstalling(item.file_name, false)
|
||||
}
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.failedToUpdate),
|
||||
@@ -894,7 +879,7 @@ provideContentManager({
|
||||
updateModpack: handleModpackUpdate,
|
||||
viewModpackContent: handleViewModpackContent,
|
||||
unlinkModpack: handleModpackUnlink,
|
||||
openSettings: () => router.push(`/hosting/manage/${serverId}/options/loader`),
|
||||
openSettings: () => openServerSettings({ tabId: 'installation' }),
|
||||
switchVersion: handleSwitchVersion,
|
||||
getOverflowOptions,
|
||||
mapToTableItem: (item) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { Archon, Kyros } from '@modrinth/api-client'
|
||||
import type { Kyros } from '@modrinth/api-client'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
@@ -14,12 +14,7 @@ import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import FilePageLayout from '../../../shared/files-tab/layout.vue'
|
||||
import { provideFileManager } from '../../../shared/files-tab/providers/file-manager'
|
||||
import type {
|
||||
EditingFile,
|
||||
FileItem,
|
||||
FileOperation,
|
||||
UploadState,
|
||||
} from '../../../shared/files-tab/types'
|
||||
import type { EditingFile, FileItem } from '../../../shared/files-tab/types'
|
||||
|
||||
const props = defineProps<{
|
||||
showDebugInfo?: boolean
|
||||
@@ -28,7 +23,7 @@ const props = defineProps<{
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const serverContext = injectModrinthServerContext()
|
||||
const { serverId, fsOps, fsQueuedOps, busyReasons } = serverContext
|
||||
const { serverId, fsOps, busyReasons, uploadState, cancelUpload: cancelUploadRef } = serverContext
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -350,43 +345,6 @@ async function downloadFile(path: string, fileName: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Operations tracking
|
||||
type QueuedOpWithState = Archon.Websocket.v0.QueuedFilesystemOp & { state: 'queued' }
|
||||
const localQueuedOps = ref<Archon.Websocket.v0.QueuedFilesystemOp[]>([])
|
||||
const dismissedOpIds = ref<Set<string>>(new Set())
|
||||
|
||||
const activeOps = computed<FileOperation[]>(() => [
|
||||
...localQueuedOps.value.map((x) => ({ ...x, state: 'queued' }) satisfies QueuedOpWithState),
|
||||
...fsQueuedOps.value.map((x) => ({ ...x, state: 'queued' }) satisfies QueuedOpWithState),
|
||||
...(fsOps.value.filter((op) => !op.id || !dismissedOpIds.value.has(op.id)) as FileOperation[]),
|
||||
])
|
||||
|
||||
async function dismissOperation(opId: string, action: 'dismiss' | 'cancel') {
|
||||
if (action === 'dismiss') {
|
||||
dismissedOpIds.value = new Set([...dismissedOpIds.value, opId])
|
||||
}
|
||||
try {
|
||||
await client.kyros.files_v0.modifyOperation(opId, action)
|
||||
} catch (error) {
|
||||
if (action === 'dismiss') return
|
||||
console.error(`Failed to ${action} operation:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => fsOps.value,
|
||||
(newOps) => {
|
||||
for (const op of newOps) {
|
||||
if (op.state === 'done' && op.id && !dismissedOpIds.value.has(op.id)) {
|
||||
setTimeout(() => {
|
||||
dismissOperation(op.id, 'dismiss')
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => fsOps.value,
|
||||
() => {
|
||||
@@ -396,7 +354,6 @@ watch(
|
||||
|
||||
onMounted(async () => {
|
||||
initializeFileEdit()
|
||||
localQueuedOps.value = []
|
||||
})
|
||||
|
||||
// Restart
|
||||
@@ -404,17 +361,6 @@ async function restartServer() {
|
||||
await client.archon.servers_v0.power(serverId, 'Restart')
|
||||
}
|
||||
|
||||
// Upload state
|
||||
const uploadState = ref<UploadState>({
|
||||
isUploading: false,
|
||||
currentFileName: null,
|
||||
currentFileProgress: 0,
|
||||
uploadedBytes: 0,
|
||||
totalBytes: 0,
|
||||
completedFiles: 0,
|
||||
totalFiles: 0,
|
||||
})
|
||||
|
||||
let activeUploadCancel: (() => void) | null = null
|
||||
|
||||
async function uploadFiles(files: File[]) {
|
||||
@@ -430,6 +376,7 @@ async function uploadFiles(files: File[]) {
|
||||
completedFiles: 0,
|
||||
totalFiles: files.length,
|
||||
}
|
||||
cancelUploadRef.value = () => activeUploadCancel?.()
|
||||
|
||||
let completedBytes = 0
|
||||
|
||||
@@ -464,6 +411,7 @@ async function uploadFiles(files: File[]) {
|
||||
}
|
||||
|
||||
activeUploadCancel = null
|
||||
cancelUploadRef.value = null
|
||||
refreshList()
|
||||
uploadState.value = {
|
||||
isUploading: false,
|
||||
@@ -515,8 +463,6 @@ provideFileManager({
|
||||
busyTooltip,
|
||||
busyWarning,
|
||||
extractFile,
|
||||
activeOperations: activeOps,
|
||||
dismissOperation,
|
||||
prefetchDirectory,
|
||||
prefetchFile,
|
||||
showInstallFromUrl: true,
|
||||
|
||||
@@ -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>
|
||||
|
||||
123
packages/ui/src/layouts/wrapped/hosting/manage/overview.vue
Normal file
123
packages/ui/src/layouts/wrapped/hosting/manage/overview.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div class="relative flex select-none flex-col gap-6" data-pyro-server-manager-root>
|
||||
<div class="flex flex-col-reverse gap-6 md:flex-col">
|
||||
<ServerManageStats
|
||||
:data="!isWsAuthIncorrect ? stats : undefined"
|
||||
:loading="isWsAuthIncorrect"
|
||||
/>
|
||||
|
||||
<div class="flex min-h-[700px] flex-col gap-4">
|
||||
<span class="text-2xl font-semibold text-contrast">Console</span>
|
||||
|
||||
<ConsolePageLayout />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isWsAuthIncorrect"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center bg-bg"
|
||||
>
|
||||
<h2>Could not connect to the server.</h2>
|
||||
<p>
|
||||
An error occurred while attempting to connect to your server. Please try refreshing the
|
||||
page. (WebSocket Authentication Failed)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Mclogs } from '@modrinth/api-client'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useModrinthServersConsole } from '#ui/composables'
|
||||
import { ConsolePageLayout, provideConsoleManager } from '#ui/layouts/shared/console'
|
||||
import { injectModrinthClient, injectModrinthServerContext } from '#ui/providers'
|
||||
|
||||
import ServerManageStats from './components/ServerManageStats.vue'
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const {
|
||||
server: _serverData,
|
||||
serverId,
|
||||
isConnected,
|
||||
isWsAuthIncorrect,
|
||||
stats,
|
||||
powerState: serverPowerState,
|
||||
powerStateDetails: _powerStateDetails,
|
||||
} = injectModrinthServerContext()
|
||||
const modrinthServersConsole = useModrinthServersConsole()
|
||||
|
||||
const crashAnalysis = ref<Mclogs.Insights.v1.InsightsResponse | null>(null)
|
||||
const DISMISS_DURATION_MS = 30 * 60 * 1000
|
||||
const dismissedUntil = useStorage(`modrinth-crash-dismissed-${serverId}`, 0)
|
||||
|
||||
const isDismissed = () => Date.now() < dismissedUntil.value
|
||||
|
||||
const inspectError = async () => {
|
||||
if (isDismissed()) return
|
||||
|
||||
try {
|
||||
const blob = await client.kyros.files_v0.downloadFile('/logs/latest.log')
|
||||
const log = await blob.text()
|
||||
if (!log) return
|
||||
|
||||
const data = await client.mclogs.insights_v1.analyse(log)
|
||||
if (data.analysis?.problems?.length) {
|
||||
crashAnalysis.value = data
|
||||
} else {
|
||||
crashAnalysis.value = null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to analyze logs:', error)
|
||||
crashAnalysis.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const dismissCrash = () => {
|
||||
dismissedUntil.value = Date.now() + DISMISS_DURATION_MS
|
||||
crashAnalysis.value = null
|
||||
}
|
||||
|
||||
provideConsoleManager({
|
||||
logLines: modrinthServersConsole.output,
|
||||
sendCommand: (cmd: string) => {
|
||||
try {
|
||||
client.archon.sockets.send(serverId, { event: 'command', cmd })
|
||||
} catch (error) {
|
||||
console.error('Error sending command:', error)
|
||||
}
|
||||
},
|
||||
showCommandInput: true,
|
||||
disableCommandInput: computed(() => serverPowerState.value !== 'running'),
|
||||
loading: computed(() => !isConnected.value || isWsAuthIncorrect.value),
|
||||
onClear: async () => {
|
||||
modrinthServersConsole.clear()
|
||||
try {
|
||||
await client.kyros.logs_v1.clear()
|
||||
} catch (error) {
|
||||
console.error('Failed to clear server logs:', error)
|
||||
}
|
||||
},
|
||||
shareDisabled: computed(() => !isConnected.value),
|
||||
emptyStateType: 'server',
|
||||
crashAnalysis,
|
||||
onDismissCrash: dismissCrash,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => serverPowerState.value,
|
||||
(newVal) => {
|
||||
if (newVal === 'crashed') {
|
||||
void inspectError()
|
||||
} else {
|
||||
crashAnalysis.value = null
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if (serverPowerState.value === 'crashed') {
|
||||
void inspectError()
|
||||
}
|
||||
</script>
|
||||
1580
packages/ui/src/layouts/wrapped/hosting/manage/root.vue
Normal file
1580
packages/ui/src/layouts/wrapped/hosting/manage/root.vue
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user