Files
AstralRinth/packages/ui/src/composables/server-manage-core-runtime.ts
T
Truman Gao 693a371d61 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>
2026-04-12 21:38:08 +00:00

444 lines
12 KiB
TypeScript

import {
type Archon,
clearNodeAuthState,
setNodeAuthState,
type UploadState,
} from '@modrinth/api-client'
import type { Stats } from '@modrinth/utils'
import type { ComputedRef, Ref } from 'vue'
import { computed, reactive, ref, watch } from 'vue'
import type { FileOperation } from '../layouts/shared/files-tab/types'
import { injectModrinthClient, provideModrinthServerContext } from '../providers'
import type { BusyReason } from '../providers/server-context'
import { defineMessage } from './i18n'
import { useModrinthServersConsole } from './server-console'
type ReadableRef<T> = Ref<T> | ComputedRef<T>
type SocketUnsubscriber = () => void
type ConnectSocketOptions = {
force?: boolean
extraSubscriptions?: (targetServerId: string) => SocketUnsubscriber[]
}
type UseServerManageCoreRuntimeOptions = {
serverId: ReadableRef<string>
worldId: ReadableRef<string | null>
server: ReadableRef<Archon.Servers.v0.Server | null | undefined>
isSyncingContent: ReadableRef<boolean>
markBackupCancelled?: (backupId: string) => void
includeBackupBusyReasons?: boolean
setDisconnectedOnAuthIncorrect?: boolean
syncUptimeFromState?: boolean
incrementUptimeLocally?: boolean
eventGuard?: () => boolean
onStateEvent?: (data: Archon.Websocket.v0.WSStateEvent) => void
}
const createInitialStats = (): Stats => ({
current: {
cpu_percent: 0,
ram_usage_bytes: 0,
ram_total_bytes: 1,
storage_usage_bytes: 0,
storage_total_bytes: 0,
},
past: {
cpu_percent: 0,
ram_usage_bytes: 0,
ram_total_bytes: 1,
storage_usage_bytes: 0,
storage_total_bytes: 0,
},
graph: {
cpu: [],
ram: [],
},
})
const appendGraphData = (dataArray: number[], newValue: number): number[] => {
const updated = [...dataArray, newValue]
if (updated.length > 10) updated.shift()
return updated
}
const mapPowerStateFromStateEvent = (
data: Archon.Websocket.v0.WSStateEvent,
): Archon.Websocket.v0.PowerState => {
const powerMap: Record<Archon.Websocket.v0.FlattenedPowerState, Archon.Websocket.v0.PowerState> =
{
not_ready: 'stopped',
starting: 'starting',
running: 'running',
stopping: 'stopping',
idle:
data.was_oom || (data.exit_code != null && data.exit_code !== 0) ? 'crashed' : 'stopped',
}
return powerMap[data.power_variant]
}
export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOptions) {
const client = injectModrinthClient()
const modrinthServersConsole = useModrinthServersConsole()
const shouldProcessEvent = () => (options.eventGuard ? options.eventGuard() : true)
const isConnected = ref(false)
const isWsAuthIncorrect = ref(false)
const serverPowerState = ref<Archon.Websocket.v0.PowerState>('stopped')
const powerStateDetails = ref<{ oom_killed?: boolean; exit_code?: number }>()
const isServerRunning = computed(() => serverPowerState.value === 'running')
const stats = ref<Stats>(createInitialStats())
const uptimeSeconds = ref(0)
const backupsState = reactive(new Map())
const fsAuth = ref<{ url: string; token: string } | null>(null)
const fsOps = ref<Archon.Websocket.v0.FilesystemOperation[]>([])
const fsQueuedOps = ref<Archon.Websocket.v0.QueuedFilesystemOp[]>([])
const connectedSocketServerId = ref<string | null>(null)
const socketUnsubscribers = ref<SocketUnsubscriber[]>([])
const cpuData = ref<number[]>([])
const ramData = ref<number[]>([])
let uptimeIntervalId: ReturnType<typeof setInterval> | null = null
const markBackupCancelled =
options.markBackupCancelled ??
((backupId: string) => {
backupsState.delete(backupId)
})
const busyReasons = computed<BusyReason[]>(() => {
const reasons: BusyReason[] = []
if (options.server.value?.status === 'installing') {
reasons.push({
reason: defineMessage({
id: 'servers.busy.installing',
defaultMessage: 'Server is installing',
}),
})
}
if (options.isSyncingContent.value) {
reasons.push({
reason: defineMessage({
id: 'servers.busy.syncing-content',
defaultMessage: 'Content sync in progress',
}),
})
}
if (options.includeBackupBusyReasons) {
for (const entry of backupsState.values()) {
if (entry.create?.state === 'ongoing') {
reasons.push({
reason: defineMessage({
id: 'servers.busy.backup-creating',
defaultMessage: 'Backup creation in progress',
}),
})
break
}
if (entry.restore?.state === 'ongoing') {
reasons.push({
reason: defineMessage({
id: 'servers.busy.backup-restoring',
defaultMessage: 'Backup restore in progress',
}),
})
break
}
}
}
return reasons
})
const stopUptimeTicker = () => {
if (uptimeIntervalId) {
clearInterval(uptimeIntervalId)
uptimeIntervalId = null
}
}
const startUptimeTicker = () => {
if (!options.incrementUptimeLocally || uptimeIntervalId) return
uptimeIntervalId = setInterval(() => {
uptimeSeconds.value += 1
}, 1000)
}
const updateStats = (currentStats: Stats['current']) => {
if (!shouldProcessEvent()) return
if (!isConnected.value) isConnected.value = true
cpuData.value = appendGraphData(cpuData.value, currentStats.cpu_percent)
ramData.value = appendGraphData(
ramData.value,
Math.floor((currentStats.ram_usage_bytes / currentStats.ram_total_bytes) * 100),
)
stats.value = {
current: currentStats,
past: { ...stats.value.current },
graph: {
cpu: cpuData.value,
ram: ramData.value,
},
}
}
const updatePowerState = (
state: Archon.Websocket.v0.PowerState,
details?: { oom_killed?: boolean; exit_code?: number },
) => {
if (!shouldProcessEvent()) return
serverPowerState.value = state
powerStateDetails.value = state === 'crashed' ? details : undefined
if (state === 'stopped' || state === 'crashed') {
stopUptimeTicker()
uptimeSeconds.value = 0
}
}
const handleLog = (data: Archon.Websocket.v0.WSLogEvent) => {
if (!shouldProcessEvent()) return
modrinthServersConsole.addLegacyLog(data.message)
}
const handleLog4j = (data: Archon.Websocket.v0.WSLog4jEvent) => {
if (!shouldProcessEvent()) return
modrinthServersConsole.addLog4jEvent(data)
}
const handleStats = (data: Archon.Websocket.v0.WSStatsEvent) => {
updateStats({
cpu_percent: data.cpu_percent,
ram_usage_bytes: data.ram_usage_bytes,
ram_total_bytes: data.ram_total_bytes,
storage_usage_bytes: data.storage_usage_bytes,
storage_total_bytes: data.storage_total_bytes,
})
}
const handlePowerState = (data: Archon.Websocket.v0.WSPowerStateEvent) => {
if (data.state === 'crashed') {
updatePowerState(data.state, {
oom_killed: data.oom_killed,
exit_code: data.exit_code,
})
} else {
updatePowerState(data.state)
}
}
const handleState = (data: Archon.Websocket.v0.WSStateEvent) => {
if (!shouldProcessEvent()) return
options.onStateEvent?.(data)
updatePowerState(mapPowerStateFromStateEvent(data), {
exit_code: data.exit_code ?? undefined,
oom_killed: data.was_oom,
})
if (options.syncUptimeFromState && data.uptime > 0) {
stopUptimeTicker()
uptimeSeconds.value = data.uptime
startUptimeTicker()
}
}
const handleUptime = (data: Archon.Websocket.v0.WSUptimeEvent) => {
if (!shouldProcessEvent()) return
stopUptimeTicker()
uptimeSeconds.value = data.uptime
startUptimeTicker()
}
const handleAuthIncorrect = () => {
if (!shouldProcessEvent()) return
isWsAuthIncorrect.value = true
if (options.setDisconnectedOnAuthIncorrect) {
isConnected.value = false
}
}
const handleAuthOk = () => {
if (!shouldProcessEvent()) return
isWsAuthIncorrect.value = false
isConnected.value = true
}
const clearSocketListeners = () => {
for (const unsub of socketUnsubscribers.value) unsub()
socketUnsubscribers.value = []
}
const disconnectSocket = (targetServerId?: string) => {
if (!targetServerId && !connectedSocketServerId.value) return
clearSocketListeners()
if (targetServerId) {
client.archon.sockets.disconnect(targetServerId)
}
stopUptimeTicker()
connectedSocketServerId.value = null
isConnected.value = false
isWsAuthIncorrect.value = false
serverPowerState.value = 'stopped'
powerStateDetails.value = undefined
uptimeSeconds.value = 0
}
const connectSocket = async (
targetServerId: string,
connectOptions: ConnectSocketOptions = {},
): Promise<boolean> => {
if (
connectedSocketServerId.value === targetServerId &&
(isConnected.value || isWsAuthIncorrect.value)
) {
return true
}
disconnectSocket(connectedSocketServerId.value ?? undefined)
try {
const safeConnectOptions = connectOptions.force ? { force: true } : undefined
await client.archon.sockets.safeConnect(targetServerId, safeConnectOptions)
connectedSocketServerId.value = targetServerId
isConnected.value = true
isWsAuthIncorrect.value = false
modrinthServersConsole.clear()
const baseSubscriptions: SocketUnsubscriber[] = [
client.archon.sockets.on(targetServerId, 'log', handleLog),
client.archon.sockets.on(targetServerId, 'log4j', handleLog4j),
client.archon.sockets.on(targetServerId, 'stats', handleStats),
client.archon.sockets.on(targetServerId, 'state', handleState),
client.archon.sockets.on(targetServerId, 'power-state', handlePowerState),
client.archon.sockets.on(targetServerId, 'uptime', handleUptime),
client.archon.sockets.on(targetServerId, 'auth-incorrect', handleAuthIncorrect),
client.archon.sockets.on(targetServerId, 'auth-ok', handleAuthOk),
]
const extraSubscriptions = connectOptions.extraSubscriptions?.(targetServerId) ?? []
socketUnsubscribers.value = [...baseSubscriptions, ...extraSubscriptions]
return true
} catch (error) {
console.error('[hosting/manage] Failed to connect server socket:', error)
isConnected.value = false
return false
}
}
const uploadState = ref<UploadState>({
isUploading: false,
currentFileName: null,
currentFileProgress: 0,
uploadedBytes: 0,
totalBytes: 0,
completedFiles: 0,
totalFiles: 0,
})
const cancelUpload = ref<(() => void) | null>(null)
type QueuedOpWithState = Archon.Websocket.v0.QueuedFilesystemOp & { state: 'queued' }
const dismissedOpIds = ref<Set<string>>(new Set())
const activeOperations = computed<FileOperation[]>(() => [
...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 },
)
const refreshFsAuth = async () => {
if (!options.serverId.value) {
fsAuth.value = null
return
}
fsAuth.value = await client.archon.servers_v0.getFilesystemAuth(options.serverId.value)
}
provideModrinthServerContext({
get serverId() {
return options.serverId.value
},
worldId: options.worldId as Ref<string | null>,
server: options.server as Ref<Archon.Servers.v0.Server>,
isConnected,
isWsAuthIncorrect,
powerState: serverPowerState,
powerStateDetails,
isServerRunning,
stats,
uptimeSeconds,
backupsState,
markBackupCancelled,
isSyncingContent: options.isSyncingContent as Ref<boolean>,
busyReasons,
fsAuth,
fsOps,
fsQueuedOps,
refreshFsAuth,
uploadState,
cancelUpload,
activeOperations,
dismissOperation,
})
setNodeAuthState(() => fsAuth.value, refreshFsAuth)
const cleanupCoreRuntime = (targetServerId?: string) => {
disconnectSocket(targetServerId ?? connectedSocketServerId.value ?? undefined)
clearNodeAuthState()
}
return {
activeOperations,
backupsState,
busyReasons,
cancelUpload,
cleanupCoreRuntime,
connectSocket,
connectedSocketServerId,
cpuData,
disconnectSocket,
dismissOperation,
fsAuth,
fsOps,
fsQueuedOps,
isConnected,
isServerRunning,
isWsAuthIncorrect,
powerStateDetails,
ramData,
refreshFsAuth,
serverPowerState,
stats,
uptimeSeconds,
uploadState,
}
}