feat: server management in app (#5628)

* start new server settings tabs

* update properties tab to match design

* better stying in general tab

* feat: add suffix input for hostname field

* implement tables for allocations and DNS records

* add tags for dns record type

* small gap adjustment

* polish advanced page

* adjust properties page hierarchy

* fix searching properties, empty state and projection radius appearing

* pnpm prepr

* update copy to match designs

* fix suffix input component

* style fixes and match heading size

* small fix

* fix search allocations placeholder

* adjust table styles

* move all installation settings helper text to below input

* update icon to use overflow menu buttons

* fix modal to be consistent

* open advanced properties when search

* remove other and custom properties, and update styles

* remove hide/show all java versions

* handle mc 26

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

* hook up server pages for app

* add server page header to app

* hook up server settings modal

* use large size

* fix card box shadow style

* fix hostname input for app

* fix app/website card containers

* implement external tabs for billing and admin billing

* fix save banner fixed to parent instead of page body

* remove unused prop to FriendsList causing warning in app

* fix client-only not available for app

* fix bottom cut off

* wire node auth

* implement full copy buttons

* dedup copy button tailwind styles

* fix hover class not working in @apply

* fix spacing

* fix error validation styles

* apply consistent styles and spacing

* feat: update hosting server card (#5609)

* fix type errors

* fix some stylesheets not imported for storybook

* add server listing stories

* add fix for frontend stylesheet imports

* remove props.

* convert copy code to use tailwind

* update server listing component styles

* update server info label styles

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

* add new server card buttons

* hook up server cards and implement updated styles

* hook up on download button

* fix tauri throwing error when api returns 204 No Content

* hook up purchase server modal in app

* fix upgrading state loading icon

* pnpm prepr

* filter out servers past 30 days after cancellation

* do not apply opacity on lock or spiner icons

* fix disabled server icon background

* update pending change stage

* handle known suspension states

* refactor: reduce code duplication for server listing

* update disabled state text color

* fix loading icon color

* clean up copy

* fix disabled opacity for server card

* update server listing files kept to be countdown

* implement resubscribe modal

* implement proper provisioning state for resubscribe

* fix duplicate attribute and pnpm prepr

* feat: add shared UI package auth DI

* feat: update purchase server flow (#5714)

* implement server list empty state component

* fix stories and adjust spacing

* implement select plan design refresh

* implement auth for empty server list

* use refs instead of reactive

* pnpm prepr

* fix auth usage for empty servers list

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

* pnpm prepr

* fix max height

* style fix

* fix getCreds no auth is blocking api client

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

* refactor guest plan select logic into provider

* implement sign in or create account popup

* remove force empty serverList

* add download button for suspended mod and generic

* add handling for when user logs out

* QA pass style fixes

* more consistent page styles

* fix duplicate export

* refactor: remove all fallback stuff from resubscribe modal

* implement shared download latest backup util

* i18n pass

* pnpm prepr

* fix region being selected if ping failed

* pnpm prepr

* feat: servers in app finalization (#5744)

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

* fix: terminal gap issues

* feat: swap word wrap for full screen

* fix: stats cards alignment

* fix: stats

* feat: fix console clear + remove copy

* fix: lint

* fix: use reset not clear

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

* feat: implement shared server header for app and website

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

* pnpm prepr

* fix: bugs

* qa: cleanup

* feat: root.vue shared layout

* feat: delete old options pages + fix discovery frontend

* fix: discovery

* fix: misc style/layout issues

* fix page padding

* fix: modal height jankiness

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

* fix: spacing

* remove servers in app feature flag

* Revert "remove servers in app feature flag"

This reverts commit 86e284c4bdd6fa42c3c8fbaf1efbec41f4d1c6d2.

* fix: qa

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

---------

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

* qa pass (#5738)

* fix: qa

* feat: qa

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

* fix: lint

* fix: server icon upload/sync and centralize logic

* fix: server settings modal not closing for server reset

* fix: better server sorting

* feat: copy address in server listing card

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

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

* feat: use floating action bar for save banner

* fix: saving state in save bar

* fix: edit server icon styling

* fix: confirm modal to have consistent buttons

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

* pnpm prepr

* feat: search page deduplication (#5754)

* fix: action bar behind modal

* fix: remove warning modal for stopping

* fix: server cards states

* we hate webkit we hate webkit

* fix: update allocation creation to not use modal

* fix: properties tab spacing and styles

* feat: add files tab copy

* fix: advanced properties icon

* fix: remove back to all servers link

* feat: add files tab link in copy

* fix: server header styles to be consistent with instance

* fix: add header icons back

* feat: update instance settings icon to be consistent

* fix: icon container

* feat: upload state persistence across tabs

* fix: server labels text wrapping

* fix: use surface-5 border

* fix: loading spinner showing with onboarding below

* feat: new server button shows purchase modal in website

* fix: billing page not showing quarterly interval

* fix: server downgrade not showing updated subscription notification

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

* pnpm prepr

* add stripe publishable key to app build

* feat: console highlighting

* fix: rename servers title to modrinth hosting

* feat: search fix

* fix: qa/styles

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

* fix: qa

* feat: highlighting fix console

* fix: disable conflicts action

* fix: error dismiss bug

* feat: modal clarification

* fix: files perms issue

* fix: lint

* feat: modal fix

* enable show uptime

* fix: add loading state to edit server icon

* fix: notification panel take in has sidebar from settings

* fix: consistency pass on app settings

* fix: consistency pass on instance settings

* pnpm prepr

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

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

* refactor: better show polling UI code

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

* fix: buttonstyled story

* fix: button styling

* fix: content.vue regression

* feat: project url redirects

* fix: breadcrumbs

* fix: purchase with newly added card

* fix: console ordering problems

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

* fix: log syncing for instances and server panel accidentally

* fix: QA issues

* fix: server page loading state

* fix: stats card logic

* fix: lint

* fix: qa

* fix: console height padding

* fix: terminal padding + loading indicator

* feat: update medal server listing styling

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

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

* fix: qa

* fix: teleport modals to body

* fix: logs tab + qa

* fix: local storage for user preferences

* fix: qa loading indic

* feat: considitonal debug and trace

* fix: jump to top on install bug

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

* feat: servers in app feature flag default true

* fix: highlight row ufll

* fix: webkit thing onto a tag

* fix: input field

* fix: clear fix

* fix: lint

* fix: fmt

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

* pnpm prepr

* fix: menu overflowing

* feat: remove servers in app feature flag

* fix: server stat charts no longer showing color

* fix: library nav no primary state

* fix: better modal height and width

* fix: highlighting bugs

* fix: empty states

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

* fix: medal server listing too bright on light mode

* fix: admon analysis + fix logs

* fix: bug

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

* performance: improve server manage stats loading by splitting reactivity

* fix: deploy + admon + disable highlighting

* fix: clippy

---------

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

* feat: temp wrangler

* fix: lint

* fix: logs upload

* fix: console empty state and admon regressions

* fix: fields

* feat: log deleting + prefetch for Logs.vue

* feat: move delete before share

* feat: clear endpoint

* feat: we ball!

---------

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

View File

@@ -8,6 +8,11 @@ export * from './i18n'
export * from './i18n-debug'
export * from './page-leave-safety'
export * from './scroll-indicator'
export * from './server-backup'
export * from './server-console'
export * from './server-manage-core-runtime'
export * from './sticky-observer'
export * from './terminal'
export * from './use-server-image'
export * from './use-server-project'
export * from './virtual-scroll'

View File

@@ -1,17 +1,26 @@
import { computed, ref } from 'vue'
const isClient = typeof window !== 'undefined'
const stack: symbol[] = []
const stackSizeRef = ref(0)
export function useModalStack() {
const id = Symbol()
function push() {
if (isClient && !stack.includes(id)) stack.push(id)
if (isClient && !stack.includes(id)) {
stack.push(id)
stackSizeRef.value = stack.length
}
}
function pop() {
if (!isClient) return
const idx = stack.indexOf(id)
if (idx !== -1) stack.splice(idx, 1)
if (idx !== -1) {
stack.splice(idx, 1)
stackSizeRef.value = stack.length
}
}
function isTopmost() {
@@ -23,5 +32,7 @@ export function useModalStack() {
return isClient ? stack.length : 0
}
return { push, pop, isTopmost, stackSize }
const hasModal = computed(() => stackSizeRef.value > 0)
return { push, pop, isTopmost, stackSize, hasModal }
}

View File

@@ -0,0 +1,54 @@
import type { Archon } from '@modrinth/api-client'
import { injectModrinthClient } from '../providers/api-client'
import { injectNotificationManager } from '../providers/web-notifications'
export function useServerBackupDownload() {
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
function getLatestBackupDownload(
serverId: string,
serverFullList: Archon.Servers.v1.ServerFull[] | null | undefined,
): (() => Promise<void>) | null {
const serverFull = serverFullList?.find((s) => s.id === serverId)
if (!serverFull) return null
const activeWorld = serverFull.worlds.find((w) => w.is_active) ?? serverFull.worlds[0]
if (!activeWorld?.backups?.length) return null
const latestBackup = activeWorld.backups
.filter((b) => b.status === 'done')
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0]
if (!latestBackup) return null
return async () => {
try {
const server = await client.archon.servers_v0.get(serverId)
const kyrosUrl = server.node?.instance
const jwt = server.node?.token
if (!kyrosUrl || !jwt) {
addNotification({
title: 'Download unavailable',
text: 'Server connection info is not available. Please contact support.',
type: 'error',
})
return
}
window.open(
`https://${kyrosUrl}/modrinth/v0/backups/${latestBackup.id}/download?auth=${jwt}`,
'_blank',
)
} catch {
addNotification({
title: 'Download failed',
text: 'An error occurred while trying to download the backup.',
type: 'error',
})
}
}
}
return { getLatestBackupDownload }
}

View File

@@ -0,0 +1,351 @@
import { createGlobalState } from '@vueuse/core'
import { type Ref, shallowRef, triggerRef } from 'vue'
import { detectLogLevel } from '../layouts/shared/console/composables/log-level'
import type { Log4jEvent, LogLevel, LogLine } from '../layouts/shared/console/types'
// Flip to true during development to enable console perf logging.
// Uses a plain constant to avoid turbo env-var declarations.
const DEBUG_PERF = false
// TODO: for true unbounded history, consider IndexedDB or similar
const ARCHIVE_CAPACITY = 500_000
const ENTRY_START_RE = /^\[\d{2}:\d{2}:\d{2}\]/
/**
* Reorders a batch of log lines so that continuation lines (lines without a
* timestamp prefix) stay grouped with their parent error/warn entry, even when
* unrelated timestamped lines arrive between them from the server.
*/
function groupContinuations(lines: LogLine[]): LogLine[] {
if (lines.length <= 1) return lines
const groups: LogLine[][] = []
for (const line of lines) {
if (ENTRY_START_RE.test(line.text)) {
groups.push([line])
} else if (groups.length > 0) {
let target = groups.length - 1
const lastEntry = groups[target][0]
if (lastEntry.level !== 'error' && lastEntry.level !== 'warn') {
if (line.level === 'error' || line.level === null) {
for (let i = groups.length - 2; i >= 0; i--) {
if (groups[i][0].level === 'error' || groups[i][0].level === 'warn') {
target = i
break
}
}
}
}
groups[target].push(line)
} else {
groups.push([line])
}
}
return groups.flat()
}
const batchTimeout = 300
const initialBatchSize = 256
const LogLevelCode = {
None: 0,
Trace: 1,
Debug: 2,
Info: 3,
Warn: 4,
Error: 5,
} as const
type LogLevelCode = (typeof LogLevelCode)[keyof typeof LogLevelCode]
function encodeLevel(level: LogLevel | null): LogLevelCode {
if (!level) return LogLevelCode.None
switch (level) {
case 'trace':
return LogLevelCode.Trace
case 'debug':
return LogLevelCode.Debug
case 'info':
return LogLevelCode.Info
case 'warn':
return LogLevelCode.Warn
case 'error':
return LogLevelCode.Error
}
}
function decodeLevel(code: LogLevelCode): LogLevel | null {
switch (code) {
case LogLevelCode.Trace:
return 'trace'
case LogLevelCode.Debug:
return 'debug'
case LogLevelCode.Info:
return 'info'
case LogLevelCode.Warn:
return 'warn'
case LogLevelCode.Error:
return 'error'
default:
return null
}
}
// Columnar ring buffer: stores text and level in parallel arrays instead of
// LogLine objects, eliminating ~40 bytes of object header per line (~20MB
// saved at 500k lines). Lines are stored by value — get(i) returns a fresh
// LogLine each call, so consumers must not rely on reference identity.
class ColumnarRingBuffer {
texts: (string | undefined)[]
levels: Uint8Array
private head = 0
private _size = 0
constructor(readonly capacity: number) {
this.texts = new Array(capacity)
this.levels = new Uint8Array(capacity)
}
get size(): number {
return this._size
}
push(text: string, level: LogLevel | null): boolean {
const wrapped = this._size === this.capacity
this.texts[this.head] = text
this.levels[this.head] = encodeLevel(level)
this.head = (this.head + 1) % this.capacity
if (!wrapped) this._size++
return wrapped
}
get(index: number): LogLine {
if (index < 0 || index >= this._size) {
throw new RangeError(`Index ${index} out of bounds [0, ${this._size})`)
}
const start = this._size === this.capacity ? this.head : 0
const physical = (start + index) % this.capacity
return {
text: this.texts[physical] as string,
level: decodeLevel(this.levels[physical] as LogLevelCode),
}
}
toArray(): LogLine[] {
if (this._size === 0) return []
const start = this._size === this.capacity ? this.head : 0
const result = new Array<LogLine>(this._size)
for (let i = 0; i < this._size; i++) {
const physical = (start + i) % this.capacity
result[i] = {
text: this.texts[physical] as string,
level: decodeLevel(this.levels[physical] as LogLevelCode),
}
}
return result
}
clear(): void {
this.texts = new Array(this.capacity)
this.levels = new Uint8Array(this.capacity)
this.head = 0
this._size = 0
}
}
function mapLog4jLevel(level?: string): LogLevel | null {
if (!level) return null
switch (level.toUpperCase()) {
case 'FATAL':
case 'ERROR':
return 'error'
case 'WARN':
return 'warn'
case 'INFO':
return 'info'
case 'DEBUG':
return 'debug'
case 'TRACE':
return 'trace'
default:
return null
}
}
function formatTimestamp(millis?: number): string {
if (!millis) return ''
const date = new Date(millis)
const h = String(date.getHours()).padStart(2, '0')
const m = String(date.getMinutes()).padStart(2, '0')
const s = String(date.getSeconds()).padStart(2, '0')
return `[${h}:${m}:${s}]`
}
function formatLog4jLines(event: Log4jEvent): LogLine[] {
const level = mapLog4jLevel(event.level)
const time = formatTimestamp(event.timestamp_millis)
const thread = event.thread_name ?? ''
const levelStr = event.level ?? ''
const message = event.message?.trim() ?? ''
const prefix = time ? `${time} [${thread}/${levelStr}]: ` : `[${thread}/${levelStr}]: `
const messageLines = message.split(/\r?\n/)
const lines: LogLine[] = [{ text: prefix + messageLines[0], level }]
for (let i = 1; i < messageLines.length; i++) {
if (!messageLines[i].trim()) continue
lines.push({ text: messageLines[i], level })
}
if (event.throwable) {
for (const line of event.throwable.split(/\r?\n/)) {
if (!line.trim()) continue
lines.push({ text: line, level: 'error' })
}
}
return lines
}
function textToLogLine(text: string): LogLine {
return { text, level: detectLogLevel(text) }
}
export function createConsoleState() {
const archive = new ColumnarRingBuffer(ARCHIVE_CAPACITY)
const output: Ref<LogLine[]> = shallowRef<LogLine[]>([])
let lineBuffer: LogLine[] = []
let batchTimer: NodeJS.Timeout | null = null
let wrapCount = 0
let lastFlushMs = 0
const flushBuffer = (): void => {
if (lineBuffer.length === 0) return
const t0 = DEBUG_PERF ? performance.now() : 0
const arr = output.value
const lines = groupContinuations(lineBuffer)
const flushedCount = lines.length
let didWrap = false
for (const line of lines) {
if (archive.push(line.text, line.level)) didWrap = true
arr.push(line)
}
if (didWrap) {
const evictedCount = Math.max(0, arr.length - archive.size)
if (evictedCount > 0) {
arr.splice(0, evictedCount)
}
wrapCount++
}
lineBuffer = []
batchTimer = null
triggerRef(output)
if (DEBUG_PERF) {
lastFlushMs = performance.now() - t0
if (arr.length !== archive.size) {
console.error(
`[mr-console] drift: output.length=${arr.length} !== archive.size=${archive.size}`,
)
}
console.debug(
`[mr-console] flush: ${flushedCount} lines in ${lastFlushMs.toFixed(2)}ms` +
` | buffer: ${archive.size} | wrap: ${didWrap}`,
)
}
}
const addLines = (lines: LogLine[]): void => {
if (output.value.length === 0 && lines.length >= initialBatchSize) {
lineBuffer = lines
flushBuffer()
return
}
lineBuffer.push(...lines)
if (!batchTimer) {
batchTimer = setTimeout(flushBuffer, batchTimeout)
}
}
const addLog4jEvent = (event: Log4jEvent): void => {
addLines(formatLog4jLines(event))
}
const addLegacyLog = (message: string): void => {
const logLines = message
.split('\n')
.filter((l) => l.trim())
.map(textToLogLine)
let parentLevel: LogLevel | null = null
for (const line of logLines) {
if (ENTRY_START_RE.test(line.text)) {
parentLevel = line.level
} else if (line.level === null && parentLevel !== null) {
line.level = parentLevel
}
}
addLines(logLines)
}
const clear = (): void => {
const t0 = DEBUG_PERF ? performance.now() : 0
archive.clear()
output.value = []
lineBuffer = []
wrapCount = 0
if (batchTimer) {
clearTimeout(batchTimer)
batchTimer = null
}
if (DEBUG_PERF) {
console.debug(`[mr-console] clear in ${(performance.now() - t0).toFixed(2)}ms`)
}
}
const __debugStats = ():
| { enabled: false }
| {
enabled: true
bufferSize: number
heapEstimate: number
recentFlushMs: number
wrapCount: number
} => {
if (!DEBUG_PERF) return { enabled: false }
const heapEstimate =
archive.texts.reduce<number>((a, s) => a + (s?.length ?? 0) * 2, 0) +
archive.levels.byteLength
return {
enabled: true,
bufferSize: archive.size,
heapEstimate,
recentFlushMs: lastFlushMs,
wrapCount,
}
}
return {
output,
addLines,
addLog4jEvent,
addLegacyLog,
clear,
__debugStats,
}
}
export const useModrinthServersConsole = createGlobalState(createConsoleState)

View File

@@ -0,0 +1,443 @@
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,
}
}

View File

@@ -242,10 +242,18 @@ export const useStripe = (
if (confirmation) {
confirmationToken.value = id
if (result && 'payment_method' in result && result.payment_method) {
// payment_method is a string ID from the API, need to find the full object
const method = paymentMethods.find((x) => x.id === result.payment_method)
if (method) {
inputtedPaymentMethod.value = method
const paymentMethod = (
result as {
payment_method?: string | Stripe.PaymentMethod
}
).payment_method
if (typeof paymentMethod === 'string') {
const method = paymentMethods.find((x) => x.id === paymentMethod)
if (method) {
inputtedPaymentMethod.value = method
}
} else if (paymentMethod) {
inputtedPaymentMethod.value = paymentMethod
}
}
}
@@ -330,31 +338,42 @@ export const useStripe = (
const loadingElements = computed(() => elementsLoaded.value < 2)
async function submitPayment(returnUrl: string) {
async function submitPayment(returnUrl?: string): Promise<boolean> {
if (noPaymentRequired.value) {
completingPurchase.value = false
return true
}
completingPurchase.value = true
const secert = clientSecret.value
const secret = clientSecret.value
if (!secert) {
return handlePaymentError('No client secret')
if (!secret) {
handlePaymentError('No client secret')
return false
}
if (!stripe.value) {
return handlePaymentError('No stripe')
handlePaymentError('No stripe')
return false
}
submittingPayment.value = true
const productPrice = product.value?.prices.find((x) => x.currency_code === currency)
const { error } = await stripe.value.confirmPayment({
clientSecret: secert,
confirmParams: {
confirmation_token: confirmationToken.value,
return_url: `${returnUrl}?priceId=${productPrice?.id}&plan=${interval.value}`,
},
})
const { error } = returnUrl
? await stripe.value.confirmPayment({
clientSecret: secret,
confirmParams: {
confirmation_token: confirmationToken.value,
return_url: `${returnUrl}?priceId=${productPrice?.id}&plan=${interval.value}`,
},
})
: await stripe.value.confirmPayment({
clientSecret: secret,
redirect: 'if_required',
confirmParams: {
confirmation_token: confirmationToken.value,
},
})
if (error) {
handlePaymentError(error.message ?? 'Unknown error submitting payment')

View File

@@ -11,7 +11,7 @@ import {
shallowRef,
} from 'vue'
function getCssVar(name: string, fallback: string): string {
export function getCssVar(name: string, fallback: string): string {
if (typeof document === 'undefined') return fallback
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
return value || fallback
@@ -54,6 +54,7 @@ function buildTerminalTheme() {
scrollbarSliderBackground: surface5,
scrollbarSliderHoverBackground: surface5,
scrollbarSliderActiveBackground: surface5,
overviewRulerBorder: 'transparent',
}
}
@@ -62,6 +63,7 @@ export interface UseTerminalOptions {
options?: ITerminalOptions
scrollback?: number
onReady?: (terminal: Terminal) => void
onResize?: () => void
}
export interface UseTerminalReturn {
@@ -85,6 +87,7 @@ export function useTerminal(options: UseTerminalOptions): UseTerminalReturn {
let resizeObserver: ResizeObserver | null = null
let themeObserver: MutationObserver | null = null
let wheelHandler: ((e: WheelEvent) => void) | null = null
let hasWritten = false
const pendingWrites: Array<{ data: string; newline: boolean }> = []
@@ -126,7 +129,7 @@ export function useTerminal(options: UseTerminalOptions): UseTerminalReturn {
if (!fa || !term) return
const dims = fa.proposeDimensions()
if (dims) {
term.resize(dims.cols, dims.rows + 1)
term.resize(dims.cols, dims.rows)
}
}
@@ -164,12 +167,13 @@ export function useTerminal(options: UseTerminalOptions): UseTerminalReturn {
const term = new Terminal({
disableStdin: true,
scrollback: options.scrollback ?? 10000,
scrollback: options.scrollback ?? Infinity,
convertEol: true,
smoothScrollDuration: 125,
fontFamily: 'monospace',
fontSize: 14,
lineHeight: 1.5,
allowProposedApi: true,
theme: buildTerminalTheme(),
...options.options,
})
@@ -183,12 +187,17 @@ export function useTerminal(options: UseTerminalOptions): UseTerminalReturn {
await nextTick()
const dims = fit.proposeDimensions()
if (dims) {
term.resize(dims.cols, dims.rows + 1)
term.resize(dims.cols, dims.rows)
}
term.options.disableStdin = true
term.write('\x1b[?25l')
wheelHandler = (e: WheelEvent) => {
e.preventDefault()
}
container.addEventListener('wheel', wheelHandler, { passive: false })
term.onScroll(() => checkIfAtBottom())
term.onWriteParsed(() => {
if (isAtBottom.value) {
@@ -212,8 +221,9 @@ export function useTerminal(options: UseTerminalOptions): UseTerminalReturn {
resizeObserver = new ResizeObserver(() => {
const d = fit.proposeDimensions()
if (d) {
term.resize(d.cols, d.rows + 1)
term.resize(d.cols, d.rows)
}
options.onResize?.()
})
resizeObserver.observe(container)
@@ -229,6 +239,10 @@ export function useTerminal(options: UseTerminalOptions): UseTerminalReturn {
})
onBeforeUnmount(() => {
if (wheelHandler && options.container.value) {
options.container.value.removeEventListener('wheel', wheelHandler)
wheelHandler = null
}
resizeObserver?.disconnect()
resizeObserver = null
themeObserver?.disconnect()

View File

@@ -0,0 +1,134 @@
import type { Archon } from '@modrinth/api-client'
import { useQuery } from '@tanstack/vue-query'
import { computed, type ComputedRef, ref } from 'vue'
import { injectModrinthClient } from '#ui/providers'
type UpstreamRef = ComputedRef<Archon.Servers.v0.Server['upstream'] | null | undefined>
type UseServerImageOptions = {
enabled?: ComputedRef<boolean> | boolean
size?: number
includeProjectFallback?: boolean
}
export async function processImageBlob(blob: Blob, size: number): Promise<string> {
return new Promise((resolve) => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')!
const img = new Image()
img.onload = () => {
canvas.width = size
canvas.height = size
ctx.drawImage(img, 0, 0, size, size)
const dataURL = canvas.toDataURL('image/png')
URL.revokeObjectURL(img.src)
resolve(dataURL)
}
img.src = URL.createObjectURL(blob)
})
}
function getStatusCode(error: unknown): number | undefined {
const err = error as { statusCode?: number; response?: { status?: number } }
return err.statusCode ?? err.response?.status
}
function isNotFound(error: unknown): boolean {
return getStatusCode(error) === 404
}
export function useServerImage(
serverId: string,
upstream: UpstreamRef,
options: UseServerImageOptions = {},
) {
const client = injectModrinthClient()
const localImage = ref<string | null | undefined>(undefined)
const iconSize = options.size ?? 512
const includeProjectFallback = options.includeProjectFallback ?? false
const queryKey = computed(
() => ['servers', 'detail', serverId, 'icon', upstream.value?.project_id ?? null] as const,
)
const isEnabled = computed(() => {
const explicitEnabled =
typeof options.enabled === 'boolean' ? options.enabled : options.enabled?.value
return !!serverId && (explicitEnabled ?? true)
})
const { data: remoteImage, refetch } = useQuery({
queryKey,
queryFn: async (): Promise<string | null | undefined> => {
if (!serverId) return undefined
try {
const fsAuth = await client.archon.servers_v0.getFilesystemAuth(serverId)
try {
const blob = await client.kyros.files_v0.downloadFileWithAuth(fsAuth, '/server-icon.png')
return await processImageBlob(blob, iconSize)
} catch (error) {
if (!isNotFound(error)) throw error
}
try {
const blob = await client.kyros.files_v0.downloadFileWithAuth(
fsAuth,
'/server-icon-original.png',
)
return await processImageBlob(blob, iconSize)
} catch (error) {
if (!isNotFound(error)) throw error
}
} catch (error) {
console.debug('Server image fetch failed:', error)
return undefined
}
if (!includeProjectFallback || !upstream.value?.project_id) return undefined
try {
const project = await client.labrinth.projects_v2.get(upstream.value.project_id)
if (!project.icon_url) return undefined
const response = await fetch(project.icon_url)
if (!response.ok) return undefined
const blob = await response.blob()
return await processImageBlob(blob, iconSize)
} catch (error) {
console.debug('Project icon fallback failed:', error)
return undefined
}
},
enabled: isEnabled,
})
const image = computed(() => {
if (localImage.value === null) return undefined
const remote = remoteImage.value
if (remote === null) return undefined
return localImage.value ?? remote
})
function setImage(nextImage: string | null | undefined) {
localImage.value = nextImage
}
function clearImage() {
localImage.value = null
}
function resetLocalOverride() {
localImage.value = undefined
}
return {
image,
queryKey,
refetch,
setImage,
clearImage,
resetLocalOverride,
}
}

View File

@@ -0,0 +1,18 @@
import type { Archon } from '@modrinth/api-client'
import { useQuery } from '@tanstack/vue-query'
import { computed, type ComputedRef } from 'vue'
import { injectModrinthClient } from '#ui/providers'
// TODO: Remove and use v1
export function useServerProject(
upstream: ComputedRef<Archon.Servers.v0.Server['upstream'] | null>,
) {
const client = injectModrinthClient()
return useQuery({
queryKey: computed(() => ['servers', 'project', upstream.value?.project_id ?? null]),
queryFn: () => client.labrinth.projects_v2.get(upstream.value!.project_id!),
enabled: computed(() => !!upstream.value?.project_id),
})
}