feat: ws client & new backups frontend (#4813)

* feat: ws client

* feat: v1 backups endpoints

* feat: migrate backups page to api-client and new DI ctx

* feat: switch to ws client via api-client

* fix: disgust

* fix: stats

* fix: console

* feat: v0 backups api

* feat: migrate backups.vue to page system w/ components to ui pkgs

* feat: polish backups frontend

* feat: pending refactor for ws handling of backups

* fix: vue shit

* fix: cancel logic fix

* fix: qa issues

* fix: alignment issues for backups page

* fix: bar positioning

* feat: finish QA

* fix: icons

* fix: lint & i18n

* fix: clear comment

* lint

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
Calum H.
2025-12-04 02:32:03 +00:00
committed by GitHub
parent e3444a3456
commit 8eff939039
43 changed files with 2466 additions and 1177 deletions

View File

@@ -8,6 +8,7 @@
"fix": "eslint . --fix && prettier --write ."
},
"dependencies": {
"mitt": "^3.0.1",
"ofetch": "^1.4.1"
},
"devDependencies": {

View File

@@ -4,6 +4,7 @@ import type { ClientConfig } from '../types/client'
import type { RequestContext, RequestOptions } from '../types/request'
import type { AbstractFeature } from './abstract-feature'
import type { AbstractModule } from './abstract-module'
import type { AbstractWebSocketClient } from './abstract-websocket'
import { ModrinthApiError, ModrinthServerError } from './errors'
/**
@@ -24,7 +25,7 @@ export abstract class AbstractModrinthClient {
private _moduleNamespaces: Map<string, Record<string, AbstractModule>> = new Map()
public readonly labrinth!: InferredClientModules['labrinth']
public readonly archon!: InferredClientModules['archon']
public readonly archon!: InferredClientModules['archon'] & { sockets: AbstractWebSocketClient }
public readonly kyros!: InferredClientModules['kyros']
public readonly iso3166!: InferredClientModules['iso3166']

View File

@@ -0,0 +1,106 @@
import type mitt from 'mitt'
import type { Archon } from '../modules/archon/types'
export type WebSocketEventHandler<
E extends Archon.Websocket.v0.WSEvent = Archon.Websocket.v0.WSEvent,
> = (event: E) => void
export interface WebSocketConnection {
serverId: string
socket: WebSocket
reconnectAttempts: number
reconnectTimer?: ReturnType<typeof setTimeout>
isReconnecting: boolean
}
export interface WebSocketStatus {
connected: boolean
reconnecting: boolean
reconnectAttempts: number
}
type WSEventMap = {
[K in Archon.Websocket.v0.WSEvent as `${string}:${K['event']}`]: K
}
export abstract class AbstractWebSocketClient {
protected connections = new Map<string, WebSocketConnection>()
protected abstract emitter: ReturnType<typeof mitt<WSEventMap>>
protected readonly MAX_RECONNECT_ATTEMPTS = 10
protected readonly RECONNECT_BASE_DELAY = 1000
protected readonly RECONNECT_MAX_DELAY = 30000
constructor(
protected client: {
archon: {
servers_v0: {
getWebSocketAuth: (serverId: string) => Promise<Archon.Websocket.v0.WSAuth>
}
}
},
) {}
abstract connect(serverId: string, auth: Archon.Websocket.v0.WSAuth): Promise<void>
abstract disconnect(serverId: string): void
abstract disconnectAll(): void
abstract send(serverId: string, message: Archon.Websocket.v0.WSOutgoingMessage): void
async safeConnect(serverId: string, options?: { force?: boolean }): Promise<void> {
const status = this.getStatus(serverId)
if (status?.connected && !options?.force) {
return
}
if (status && !status.connected && !options?.force) {
return
}
if (options?.force && status) {
this.disconnect(serverId)
}
const auth = await this.client.archon.servers_v0.getWebSocketAuth(serverId)
await this.connect(serverId, auth)
}
on<E extends Archon.Websocket.v0.WSEventType>(
serverId: string,
eventType: E,
handler: WebSocketEventHandler<Extract<Archon.Websocket.v0.WSEvent, { event: E }>>,
): () => void {
const eventKey = `${serverId}:${eventType}` as keyof WSEventMap
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.emitter.on(eventKey, handler as any)
return () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.emitter.off(eventKey, handler as any)
}
}
getStatus(serverId: string): WebSocketStatus | null {
const connection = this.connections.get(serverId)
if (!connection) return null
return {
connected: connection.socket.readyState === WebSocket.OPEN,
reconnecting: connection.isReconnecting,
reconnectAttempts: connection.reconnectAttempts,
}
}
protected getReconnectDelay(attempt: number): number {
const delay = Math.min(
this.RECONNECT_BASE_DELAY * Math.pow(2, attempt),
this.RECONNECT_MAX_DELAY,
)
return delay + Math.random() * 1000
}
}

View File

@@ -1,5 +1,11 @@
export { AbstractModrinthClient } from './core/abstract-client'
export { AbstractFeature, type FeatureConfig } from './core/abstract-feature'
export {
AbstractWebSocketClient,
type WebSocketConnection,
type WebSocketEventHandler,
type WebSocketStatus,
} from './core/abstract-websocket'
export { ModrinthApiError, ModrinthServerError } from './core/errors'
export { type AuthConfig, AuthFeature } from './features/auth'
export {

View File

@@ -0,0 +1,95 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Archon } from '../types'
export class ArchonBackupsV0Module extends AbstractModule {
public getModuleID(): string {
return 'archon_backups_v0'
}
/** GET /modrinth/v0/servers/:server_id/backups */
public async list(serverId: string): Promise<Archon.Backups.v1.Backup[]> {
return this.client.request<Archon.Backups.v1.Backup[]>(`/servers/${serverId}/backups`, {
api: 'archon',
version: 'modrinth/v0',
method: 'GET',
})
}
/** GET /modrinth/v0/servers/:server_id/backups/:backup_id */
public async get(serverId: string, backupId: string): Promise<Archon.Backups.v1.Backup> {
return this.client.request<Archon.Backups.v1.Backup>(
`/servers/${serverId}/backups/${backupId}`,
{ api: 'archon', version: 'modrinth/v0', method: 'GET' },
)
}
/** POST /modrinth/v0/servers/:server_id/backups */
public async create(
serverId: string,
request: Archon.Backups.v1.BackupRequest,
): Promise<Archon.Backups.v1.PostBackupResponse> {
return this.client.request<Archon.Backups.v1.PostBackupResponse>(
`/servers/${serverId}/backups`,
{ api: 'archon', version: 'modrinth/v0', method: 'POST', body: request },
)
}
/** POST /modrinth/v0/servers/:server_id/backups/:backup_id/restore */
public async restore(serverId: string, backupId: string): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/backups/${backupId}/restore`, {
api: 'archon',
version: 'modrinth/v0',
method: 'POST',
})
}
/** DELETE /modrinth/v0/servers/:server_id/backups/:backup_id */
public async delete(serverId: string, backupId: string): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/backups/${backupId}`, {
api: 'archon',
version: 'modrinth/v0',
method: 'DELETE',
})
}
/** POST /modrinth/v0/servers/:server_id/backups/:backup_id/lock */
public async lock(serverId: string, backupId: string): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/backups/${backupId}/lock`, {
api: 'archon',
version: 'modrinth/v0',
method: 'POST',
})
}
/** POST /modrinth/v0/servers/:server_id/backups/:backup_id/unlock */
public async unlock(serverId: string, backupId: string): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/backups/${backupId}/unlock`, {
api: 'archon',
version: 'modrinth/v0',
method: 'POST',
})
}
/** POST /modrinth/v0/servers/:server_id/backups/:backup_id/retry */
public async retry(serverId: string, backupId: string): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/backups/${backupId}/retry`, {
api: 'archon',
version: 'modrinth/v0',
method: 'POST',
})
}
/** PATCH /modrinth/v0/servers/:server_id/backups/:backup_id */
public async rename(
serverId: string,
backupId: string,
request: Archon.Backups.v1.PatchBackup,
): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/backups/${backupId}`, {
api: 'archon',
version: 'modrinth/v0',
method: 'PATCH',
body: request,
})
}
}

View File

@@ -0,0 +1,132 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Archon } from '../types'
/**
* Default world ID - Uuid::nil() which the backend treats as "first/active world"
* See: apps/archon/src/routes/v1/servers/worlds/mod.rs - world_id_nullish()
* TODO:
* - Make sure world ID is being passed before we ship worlds.
* - The schema will change when Backups v4 (routes stay as v1) so remember to do that.
*/
const DEFAULT_WORLD_ID: string = '00000000-0000-0000-0000-000000000000' as const
export class ArchonBackupsV1Module extends AbstractModule {
public getModuleID(): string {
return 'archon_backups_v1'
}
/** GET /v1/:server_id/worlds/:world_id/backups */
public async list(
serverId: string,
worldId: string = DEFAULT_WORLD_ID,
): Promise<Archon.Backups.v1.Backup[]> {
return this.client.request<Archon.Backups.v1.Backup[]>(
`/${serverId}/worlds/${worldId}/backups`,
{ api: 'archon', version: 1, method: 'GET' },
)
}
/** GET /v1/:server_id/worlds/:world_id/backups/:backup_id */
public async get(
serverId: string,
backupId: string,
worldId: string = DEFAULT_WORLD_ID,
): Promise<Archon.Backups.v1.Backup> {
return this.client.request<Archon.Backups.v1.Backup>(
`/${serverId}/worlds/${worldId}/backups/${backupId}`,
{ api: 'archon', version: 1, method: 'GET' },
)
}
/** POST /v1/:server_id/worlds/:world_id/backups */
public async create(
serverId: string,
request: Archon.Backups.v1.BackupRequest,
worldId: string = DEFAULT_WORLD_ID,
): Promise<Archon.Backups.v1.PostBackupResponse> {
return this.client.request<Archon.Backups.v1.PostBackupResponse>(
`/${serverId}/worlds/${worldId}/backups`,
{ api: 'archon', version: 1, method: 'POST', body: request },
)
}
/** POST /v1/:server_id/worlds/:world_id/backups/:backup_id/restore */
public async restore(
serverId: string,
backupId: string,
worldId: string = DEFAULT_WORLD_ID,
): Promise<void> {
await this.client.request<void>(`/${serverId}/worlds/${worldId}/backups/${backupId}/restore`, {
api: 'archon',
version: 1,
method: 'POST',
})
}
/** DELETE /v1/:server_id/worlds/:world_id/backups/:backup_id */
public async delete(
serverId: string,
backupId: string,
worldId: string = DEFAULT_WORLD_ID,
): Promise<void> {
await this.client.request<void>(`/${serverId}/worlds/${worldId}/backups/${backupId}`, {
api: 'archon',
version: 1,
method: 'DELETE',
})
}
/** POST /v1/:server_id/worlds/:world_id/backups/:backup_id/lock */
public async lock(
serverId: string,
backupId: string,
worldId: string = DEFAULT_WORLD_ID,
): Promise<void> {
await this.client.request<void>(`/${serverId}/worlds/${worldId}/backups/${backupId}/lock`, {
api: 'archon',
version: 1,
method: 'POST',
})
}
/** POST /v1/:server_id/worlds/:world_id/backups/:backup_id/unlock */
public async unlock(
serverId: string,
backupId: string,
worldId: string = DEFAULT_WORLD_ID,
): Promise<void> {
await this.client.request<void>(`/${serverId}/worlds/${worldId}/backups/${backupId}/unlock`, {
api: 'archon',
version: 1,
method: 'POST',
})
}
/** POST /v1/:server_id/worlds/:world_id/backups/:backup_id/retry */
public async retry(
serverId: string,
backupId: string,
worldId: string = DEFAULT_WORLD_ID,
): Promise<void> {
await this.client.request<void>(`/${serverId}/worlds/${worldId}/backups/${backupId}/retry`, {
api: 'archon',
version: 1,
method: 'POST',
})
}
/** PATCH /v1/:server_id/worlds/:world_id/backups/:backup_id */
public async rename(
serverId: string,
backupId: string,
request: Archon.Backups.v1.PatchBackup,
worldId: string = DEFAULT_WORLD_ID,
): Promise<void> {
await this.client.request<void>(`/${serverId}/worlds/${worldId}/backups/${backupId}`, {
api: 'archon',
version: 1,
method: 'PATCH',
body: request,
})
}
}

View File

@@ -1,3 +1,5 @@
export * from './servers/types'
export * from './backups/v0'
export * from './backups/v1'
export * from './servers/v0'
export * from './servers/v1'
export * from './types'

View File

@@ -6,6 +6,18 @@ export class ArchonServersV0Module extends AbstractModule {
return 'archon_servers_v0'
}
/**
* Get a specific server by ID
* GET /modrinth/v0/servers/:id
*/
public async get(serverId: string): Promise<Archon.Servers.v0.Server> {
return this.client.request<Archon.Servers.v0.Server>(`/servers/${serverId}`, {
api: 'archon',
method: 'GET',
version: 'modrinth/v0',
})
}
/**
* Get list of servers for the authenticated user
* GET /modrinth/v0/servers
@@ -54,4 +66,16 @@ export class ArchonServersV0Module extends AbstractModule {
method: 'GET',
})
}
/**
* Get WebSocket authentication credentials for a server
* GET /modrinth/v0/servers/:id/ws
*/
public async getWebSocketAuth(serverId: string): Promise<Archon.Websocket.v0.WSAuth> {
return this.client.request<Archon.Websocket.v0.WSAuth>(`/servers/${serverId}/ws`, {
api: 'archon',
version: 'modrinth/v0',
method: 'GET',
})
}
}

View File

@@ -125,4 +125,187 @@ export namespace Archon {
}
}
}
export namespace Backups {
export namespace v1 {
export type BackupState = 'ongoing' | 'done' | 'failed' | 'cancelled' | 'unchanged'
export type BackupTask = 'file' | 'create' | 'restore'
export type BackupTaskProgress = {
progress: number // 0.0 to 1.0
state: BackupState
}
export type Backup = {
id: string
name: string
created_at: string
locked: boolean
automated: boolean
interrupted: boolean
ongoing: boolean
task?: {
file?: BackupTaskProgress
create?: BackupTaskProgress
restore?: BackupTaskProgress
}
// TODO: Uncomment when API supports these fields
// size?: number // bytes
// creator_id?: string // user ID, or 'auto' for automated backups
}
export type BackupRequest = {
name: string
}
export type PatchBackup = {
name?: string
}
export type PostBackupResponse = {
id: string
}
}
}
export namespace Websocket {
export namespace v0 {
export type WSAuth = {
url: string
token: string
}
export type BackupState = 'ongoing' | 'done' | 'failed' | 'cancelled' | 'unchanged'
export type BackupTask = 'file' | 'create' | 'restore'
export type WSBackupProgressEvent = {
event: 'backup-progress'
id: string
task: BackupTask
state: BackupState
progress: number
}
export type WSLogEvent = {
event: 'log'
stream: 'stdout' | 'stderr'
message: string
}
export type WSStatsEvent = {
event: 'stats'
cpu_percent: number
ram_usage_bytes: number
ram_total_bytes: number
storage_usage_bytes: number
storage_total_bytes: number
net_tx_bytes: number
net_rx_bytes: number
}
export type PowerState = 'running' | 'stopped' | 'starting' | 'stopping' | 'crashed'
export type WSPowerStateEvent = {
event: 'power-state'
state: PowerState
oom_killed?: boolean
exit_code?: number
}
export type WSAuthExpiringEvent = {
event: 'auth-expiring'
}
export type WSAuthIncorrectEvent = {
event: 'auth-incorrect'
}
export type WSAuthOkEvent = {
event: 'auth-ok'
}
export type WSInstallationResultEvent =
| WSInstallationResultOkEvent
| WSInstallationResultErrEvent
export type WSInstallationResultOkEvent = {
event: 'installation-result'
result: 'ok'
}
export type WSInstallationResultErrEvent = {
event: 'installation-result'
result: 'err'
reason?: string
}
export type WSUptimeEvent = {
event: 'uptime'
uptime: number
}
export type WSNewModEvent = {
event: 'new-mod'
project_id: string
version_id: string
}
export type FilesystemOpKind = 'unarchive'
export type FilesystemOpState =
| 'queued'
| 'ongoing'
| 'done'
| 'cancelled'
| 'failure-corrupted'
| 'failure-invalid-path'
export type FilesystemOperation = {
op: FilesystemOpKind
id: string
progress: number
bytes_processed: number
files_processed: number
state: FilesystemOpState
mime: string
current_file?: string
invalid_path?: string
src: string
started: string
}
export type WSFilesystemOpsEvent = {
event: 'filesystem-ops'
all: FilesystemOperation[]
}
// Outgoing messages (client -> server)
export type WSOutgoingMessage = WSAuthMessage | WSCommandMessage
export type WSAuthMessage = {
event: 'auth'
jwt: string
}
export type WSCommandMessage = {
event: 'command'
cmd: string
}
export type WSEvent =
| WSBackupProgressEvent
| WSLogEvent
| WSStatsEvent
| WSPowerStateEvent
| WSAuthExpiringEvent
| WSAuthIncorrectEvent
| WSAuthOkEvent
| WSInstallationResultEvent
| WSUptimeEvent
| WSNewModEvent
| WSFilesystemOpsEvent
export type WSEventType = WSEvent['event']
}
}
}

View File

@@ -1,5 +1,7 @@
import type { AbstractModrinthClient } from '../core/abstract-client'
import type { AbstractModule } from '../core/abstract-module'
import { ArchonBackupsV0Module } from './archon/backups/v0'
import { ArchonBackupsV1Module } from './archon/backups/v1'
import { ArchonServersV0Module } from './archon/servers/v0'
import { ArchonServersV1Module } from './archon/servers/v1'
import { ISO3166Module } from './iso3166'
@@ -21,6 +23,8 @@ type ModuleConstructor = new (client: AbstractModrinthClient) => AbstractModule
* TODO: Better way? Probably not
*/
export const MODULE_REGISTRY = {
archon_backups_v0: ArchonBackupsV0Module,
archon_backups_v1: ArchonBackupsV1Module,
archon_servers_v0: ArchonServersV0Module,
archon_servers_v1: ArchonServersV1Module,
iso3166_data: ISO3166Module,

View File

@@ -2,7 +2,9 @@ import { $fetch, FetchError } from 'ofetch'
import { AbstractModrinthClient } from '../core/abstract-client'
import type { ModrinthApiError } from '../core/errors'
import type { ClientConfig } from '../types/client'
import type { RequestOptions } from '../types/request'
import { GenericWebSocketClient } from './websocket-generic'
/**
* Generic platform client using ofetch
@@ -23,6 +25,17 @@ import type { RequestOptions } from '../types/request'
* ```
*/
export class GenericModrinthClient extends AbstractModrinthClient {
constructor(config: ClientConfig) {
super(config)
Object.defineProperty(this.archon, 'sockets', {
value: new GenericWebSocketClient(this),
writable: false,
enumerable: true,
configurable: false,
})
}
protected async executeRequest<T>(url: string, options: RequestOptions): Promise<T> {
try {
const response = await $fetch<T>(url, {

View File

@@ -5,6 +5,7 @@ import type { ModrinthApiError } from '../core/errors'
import type { CircuitBreakerState, CircuitBreakerStorage } from '../features/circuit-breaker'
import type { ClientConfig } from '../types/client'
import type { RequestOptions } from '../types/request'
import { GenericWebSocketClient } from './websocket-generic'
/**
* Circuit breaker storage using Nuxt's useState
@@ -72,6 +73,17 @@ export interface NuxtClientConfig extends ClientConfig {
export class NuxtModrinthClient extends AbstractModrinthClient {
protected declare config: NuxtClientConfig
constructor(config: NuxtClientConfig) {
super(config)
Object.defineProperty(this.archon, 'sockets', {
value: new GenericWebSocketClient(this),
writable: false,
enumerable: true,
configurable: false,
})
}
protected async executeRequest<T>(url: string, options: RequestOptions): Promise<T> {
try {
// @ts-expect-error - $fetch is provided by Nuxt runtime

View File

@@ -2,6 +2,7 @@ import { AbstractModrinthClient } from '../core/abstract-client'
import type { ModrinthApiError } from '../core/errors'
import type { ClientConfig } from '../types/client'
import type { RequestOptions } from '../types/request'
import { GenericWebSocketClient } from './websocket-generic'
/**
* Tauri-specific configuration
@@ -38,6 +39,17 @@ interface HttpError extends Error {
export class TauriModrinthClient extends AbstractModrinthClient {
protected declare config: TauriClientConfig
constructor(config: TauriClientConfig) {
super(config)
Object.defineProperty(this.archon, 'sockets', {
value: new GenericWebSocketClient(this),
writable: false,
enumerable: true,
configurable: false,
})
}
protected async executeRequest<T>(url: string, options: RequestOptions): Promise<T> {
try {
// Dynamically import Tauri HTTP plugin

View File

@@ -0,0 +1,147 @@
import mitt from 'mitt'
import { AbstractWebSocketClient, type WebSocketConnection } from '../core/abstract-websocket'
import type { Archon } from '../modules/archon/types'
type WSEventMap = {
[K in Archon.Websocket.v0.WSEvent as `${string}:${K['event']}`]: K
}
export class GenericWebSocketClient extends AbstractWebSocketClient {
protected emitter = mitt<WSEventMap>()
async connect(serverId: string, auth: Archon.Websocket.v0.WSAuth): Promise<void> {
if (this.connections.has(serverId)) {
this.disconnect(serverId)
}
return new Promise((resolve, reject) => {
try {
const ws = new WebSocket(`wss://${auth.url}`)
const connection: WebSocketConnection = {
serverId,
socket: ws,
reconnectAttempts: 0,
reconnectTimer: undefined,
isReconnecting: false,
}
this.connections.set(serverId, connection)
ws.onopen = () => {
ws.send(JSON.stringify({ event: 'auth', jwt: auth.token }))
connection.reconnectAttempts = 0
connection.isReconnecting = false
resolve()
}
ws.onmessage = (messageEvent) => {
try {
const data = JSON.parse(messageEvent.data) as Archon.Websocket.v0.WSEvent
const eventKey = `${serverId}:${data.event}` as keyof WSEventMap
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.emitter.emit(eventKey, data as any)
if (data.event === 'auth-expiring' || data.event === 'auth-incorrect') {
this.handleAuthExpiring(serverId).catch(console.error)
}
} catch (error) {
console.error('[WebSocket] Failed to parse message:', error)
}
}
ws.onclose = (event) => {
if (event.code !== 1000) {
this.scheduleReconnect(serverId, auth)
}
}
ws.onerror = (error) => {
console.error(`[WebSocket] Error for server ${serverId}:`, error)
reject(new Error(`WebSocket connection failed for server ${serverId}`))
}
} catch (error) {
reject(error)
}
})
}
disconnect(serverId: string): void {
const connection = this.connections.get(serverId)
if (!connection) return
if (connection.reconnectTimer) {
clearTimeout(connection.reconnectTimer)
connection.reconnectTimer = undefined
}
if (
connection.socket.readyState === WebSocket.OPEN ||
connection.socket.readyState === WebSocket.CONNECTING
) {
connection.socket.close(1000, 'Client disconnecting')
}
this.emitter.all.forEach((_handlers, type) => {
if (type.toString().startsWith(`${serverId}:`)) {
this.emitter.all.delete(type)
}
})
this.connections.delete(serverId)
}
disconnectAll(): void {
for (const serverId of this.connections.keys()) {
this.disconnect(serverId)
}
}
send(serverId: string, message: Archon.Websocket.v0.WSOutgoingMessage): void {
const connection = this.connections.get(serverId)
if (!connection || connection.socket.readyState !== WebSocket.OPEN) {
console.warn(`Cannot send message: WebSocket not connected for server ${serverId}`)
return
}
connection.socket.send(JSON.stringify(message))
}
private scheduleReconnect(serverId: string, auth: Archon.Websocket.v0.WSAuth): void {
const connection = this.connections.get(serverId)
if (!connection) return
if (connection.reconnectAttempts >= this.MAX_RECONNECT_ATTEMPTS) {
this.disconnect(serverId)
return
}
connection.isReconnecting = true
connection.reconnectAttempts++
const delay = this.getReconnectDelay(connection.reconnectAttempts)
connection.reconnectTimer = setTimeout(() => {
this.connect(serverId, auth).catch((error) => {
console.error(`[WebSocket] Reconnection failed for server ${serverId}:`, error)
})
}, delay)
}
private async handleAuthExpiring(serverId: string): Promise<void> {
try {
const newAuth = await this.client.archon.servers_v0.getWebSocketAuth(serverId)
const connection = this.connections.get(serverId)
if (connection && connection.socket.readyState === WebSocket.OPEN) {
connection.socket.send(JSON.stringify({ event: 'auth', jwt: newAuth.token }))
}
} catch (error) {
console.error(`[WebSocket] Failed to refresh auth for server ${serverId}:`, error)
this.disconnect(serverId)
}
}
}

View File

@@ -6,6 +6,7 @@ export type {
CircuitBreakerStorage,
} from '../features/circuit-breaker'
export type { BackoffStrategy, RetryConfig } from '../features/retry'
export type { Archon } from '../modules/archon/types'
export type { ClientConfig, RequestHooks } from './client'
export type { ApiErrorData, ModrinthErrorResponse } from './errors'
export { isModrinthErrorResponse } from './errors'

View File

@@ -203,6 +203,7 @@ import _UploadIcon from './icons/upload.svg?component'
import _UserIcon from './icons/user.svg?component'
import _UserCogIcon from './icons/user-cog.svg?component'
import _UserPlusIcon from './icons/user-plus.svg?component'
import _UserRoundIcon from './icons/user-round.svg?component'
import _UserSearchIcon from './icons/user-search.svg?component'
import _UserXIcon from './icons/user-x.svg?component'
import _UsersIcon from './icons/users.svg?component'
@@ -417,6 +418,7 @@ export const UpdatedIcon = _UpdatedIcon
export const UploadIcon = _UploadIcon
export const UserCogIcon = _UserCogIcon
export const UserPlusIcon = _UserPlusIcon
export const UserRoundIcon = _UserRoundIcon
export const UserSearchIcon = _UserSearchIcon
export const UserXIcon = _UserXIcon
export const UserIcon = _UserIcon

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="lucide lucide-user-round-icon lucide-user-round">
<circle cx="12" cy="8" r="5" />
<path d="M20 21a8 8 0 0 0-16 0" />
</svg>

After

Width:  |  Height:  |  Size: 316 B

View File

@@ -43,6 +43,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
})
const accentedButton = computed(() =>
@@ -69,6 +73,7 @@ const classes = computed(() => {
'btn-hover-filled-only': props.hoverFilledOnly,
'btn-outline': props.outline,
'color-accent-contrast': accentedButton,
disabled: props.disabled,
}
})
</script>
@@ -78,10 +83,14 @@ const classes = computed(() => {
v-if="link && link.startsWith('/')"
class="btn"
:class="classes"
:to="link"
:to="disabled ? '' : link"
:target="external ? '_blank' : '_self'"
@click="
(event) => {
if (disabled) {
event.preventDefault()
return
}
if (action) {
action(event)
}
@@ -96,10 +105,14 @@ const classes = computed(() => {
v-else-if="link"
class="btn"
:class="classes"
:href="link"
:href="disabled ? undefined : link"
:target="external ? '_blank' : '_self'"
@click="
(event) => {
if (disabled) {
event.preventDefault()
return
}
if (action) {
action(event)
}
@@ -110,7 +123,7 @@ const classes = computed(() => {
<ExternalIcon v-if="external && !iconOnly" class="external-icon" />
<UnknownIcon v-if="!$slots.default" />
</a>
<button v-else class="btn" :class="classes" @click="action">
<button v-else class="btn" :class="classes" :disabled="disabled" @click="action">
<slot />
<UnknownIcon v-if="!$slots.default" />
</button>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import SpinnerIcon from '@modrinth/assets/icons/spinner.svg'
import { computed } from 'vue'
const props = withDefaults(
@@ -7,11 +8,21 @@ const props = withDefaults(
max?: number
color?: 'brand' | 'green' | 'red' | 'orange' | 'blue' | 'purple' | 'gray'
waiting?: boolean
fullWidth?: boolean
striped?: boolean
gradientBorder?: boolean
label?: string
labelClass?: string
showProgress?: boolean
}>(),
{
max: 1,
color: 'brand',
waiting: false,
fullWidth: false,
striped: false,
gradientBorder: true,
showProgress: false,
},
)
@@ -49,15 +60,28 @@ const colors = {
const percent = computed(() => props.progress / props.max)
</script>
<template>
<div
class="flex w-full max-w-[15rem] h-1 rounded-full overflow-hidden"
:class="colors[props.color].bg"
>
<div
class="rounded-full progress-bar"
:class="[colors[props.color].fg, { 'progress-bar--waiting': waiting }]"
:style="!waiting ? { width: `${percent * 100}%` } : {}"
></div>
<div class="flex w-full flex-col gap-2" :class="fullWidth ? '' : 'max-w-[15rem]'">
<div v-if="label || showProgress" class="flex items-center justify-between">
<span v-if="label" :class="labelClass">{{ label }}</span>
<div v-if="showProgress" class="flex items-center gap-1 text-sm text-secondary">
<span>{{ Math.round(percent * 100) }}%</span>
<slot name="progress-icon">
<SpinnerIcon class="size-5 animate-spin" />
</slot>
</div>
</div>
<div class="flex h-2 w-full overflow-hidden rounded-full" :class="[colors[props.color].bg]">
<div
class="rounded-full progress-bar"
:class="[
colors[props.color].fg,
{ 'progress-bar--waiting': waiting },
{ 'progress-bar--gradient-border': gradientBorder },
striped ? `progress-bar--striped--${color}` : '',
]"
:style="!waiting ? { width: `${percent * 100}%` } : {}"
></div>
</div>
</div>
</template>
<style scoped lang="scss">
@@ -83,4 +107,76 @@ const percent = computed(() => props.progress / props.max)
width: 20%;
}
}
.progress-bar--gradient-border {
position: relative;
&::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.3), transparent);
border-radius: inherit;
mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask-composite: xor;
padding: 2px;
pointer-events: none;
}
}
%progress-bar--striped-common {
background-attachment: scroll;
background-position: 0 0;
background-size: 9.38px 9.38px;
}
@mixin striped-background($color-variable) {
background-image: linear-gradient(
135deg,
$color-variable 11.54%,
transparent 11.54%,
transparent 50%,
$color-variable 50%,
$color-variable 61.54%,
transparent 61.54%,
transparent 100%
);
}
.progress-bar--striped--brand {
@include striped-background(var(--color-brand));
@extend %progress-bar--striped-common;
}
.progress-bar--striped--green {
@include striped-background(var(--color-green));
@extend %progress-bar--striped-common;
}
.progress-bar--striped--red {
@include striped-background(var(--color-red));
@extend %progress-bar--striped-common;
}
.progress-bar--striped--orange {
@include striped-background(var(--color-orange));
@extend %progress-bar--striped-common;
}
.progress-bar--striped--blue {
@include striped-background(var(--color-blue));
@extend %progress-bar--striped-common;
}
.progress-bar--striped--purple {
@include striped-background(var(--color-purple));
@extend %progress-bar--striped-common;
}
.progress-bar--striped--gray {
@include striped-background(var(--color-divider-dark));
@extend %progress-bar--striped-common;
}
</style>

View File

@@ -127,6 +127,11 @@ export { default as ThemeSelector } from './settings/ThemeSelector.vue'
// Servers
export { default as ServersSpecs } from './billing/ServersSpecs.vue'
export { default as BackupCreateModal } from './servers/backups/BackupCreateModal.vue'
export { default as BackupDeleteModal } from './servers/backups/BackupDeleteModal.vue'
export { default as BackupItem } from './servers/backups/BackupItem.vue'
export { default as BackupRenameModal } from './servers/backups/BackupRenameModal.vue'
export { default as BackupRestoreModal } from './servers/backups/BackupRestoreModal.vue'
export { default as BackupWarning } from './servers/backups/BackupWarning.vue'
export { default as LoaderIcon } from './servers/icons/LoaderIcon.vue'
export { default as ServerIcon } from './servers/icons/ServerIcon.vue'

View File

@@ -10,12 +10,14 @@
@click="() => (closeOnClickOutside && closable ? hide() : {})"
/>
<div
:class="{
shown: visible,
noblur: props.noblur,
danger: danger,
}"
class="modal-overlay"
:class="[
'modal-overlay',
{
shown: visible,
noblur: props.noblur,
},
computedFade,
]"
@click="() => (closeOnClickOutside && closable ? hide() : {})"
/>
<div class="modal-container experimental-styles-within" :class="{ shown: visible }">
@@ -106,7 +108,7 @@
<script setup lang="ts">
import { XIcon } from '@modrinth/assets'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useScrollIndicator } from '../../composables/scroll-indicator'
import ButtonStyled from '../base/ButtonStyled.vue'
@@ -115,7 +117,9 @@ const props = withDefaults(
defineProps<{
noblur?: boolean
closable?: boolean
/** @deprecated Use `fade="danger"` instead */
danger?: boolean
fade?: 'standard' | 'warning' | 'danger'
closeOnEsc?: boolean
closeOnClickOutside?: boolean
warnOnClose?: boolean
@@ -131,6 +135,7 @@ const props = withDefaults(
type: true,
closable: true,
danger: false,
fade: undefined,
closeOnClickOutside: true,
closeOnEsc: true,
warnOnClose: false,
@@ -145,6 +150,12 @@ const props = withDefaults(
},
)
const computedFade = computed(() => {
if (props.fade) return props.fade
if (props.danger) return 'danger'
return 'standard'
})
const open = ref(false)
const visible = ref(false)
@@ -225,7 +236,6 @@ function handleKeyDown(event: KeyboardEvent) {
z-index: 19;
opacity: 0;
transition: all 0.2s ease-out;
background: linear-gradient(to bottom, rgba(29, 48, 43, 0.52) 0%, rgba(14, 21, 26, 0.95) 100%);
//transform: translate(
// calc((-50vw + var(--_mouse-x, 50vw) * 1px) / 2),
// calc((-50vh + var(--_mouse-y, 50vh) * 1px) / 2)
@@ -234,6 +244,19 @@ function handleKeyDown(event: KeyboardEvent) {
border-radius: 180px;
//filter: blur(5px);
// Fade variants
&.standard {
background: linear-gradient(to bottom, rgba(29, 48, 43, 0.52) 0%, rgba(14, 21, 26, 0.95) 100%);
}
&.warning {
background: linear-gradient(to bottom, rgba(48, 38, 29, 0.52) 0%, rgba(26, 20, 14, 0.95) 100%);
}
&.danger {
background: linear-gradient(to bottom, rgba(43, 18, 26, 0.52) 0%, rgba(49, 10, 15, 0.95) 100%);
}
@media (prefers-reduced-motion) {
transition: none !important;
}
@@ -248,10 +271,6 @@ function handleKeyDown(event: KeyboardEvent) {
backdrop-filter: none;
filter: none;
}
&.danger {
background: linear-gradient(to bottom, rgba(43, 18, 26, 0.52) 0%, rgba(49, 10, 15, 0.95) 100%);
}
}
.modrinth-parent__no-modal-blurs {

View File

@@ -113,7 +113,7 @@
</template>
<script setup lang="ts">
import type { Archon, ProjectV2 } from '@modrinth/api-client'
import type { Archon } from '@modrinth/api-client'
import {
ChevronRightIcon,
LoaderCircleIcon,
@@ -144,7 +144,22 @@ export type PendingChange = {
verb: string
}
const props = defineProps<Partial<Archon.Servers.v0.Server> & { pendingChange?: PendingChange }>()
type ServerListingProps = {
server_id: string
name: string
status: Archon.Servers.v0.Status
suspension_reason?: Archon.Servers.v0.SuspensionReason | null
game?: Archon.Servers.v0.Game
mc_version?: string | null
loader?: Archon.Servers.v0.Loader | null
loader_version?: string | null
net?: Archon.Servers.v0.Net
upstream?: Archon.Servers.v0.Upstream | null
flows?: Archon.Servers.v0.Flows
pendingChange?: PendingChange
}
const props = defineProps<ServerListingProps>()
const { archon, kyros, labrinth } = injectModrinthClient()
@@ -153,7 +168,7 @@ const showLoaderLabel = computed(() => !!props.loader)
const { data: projectData } = useQuery({
queryKey: ['project', props.upstream?.project_id] as const,
queryFn: async (): Promise<ProjectV2 | null> => {
queryFn: async () => {
if (!props.upstream?.project_id) return null
return await labrinth.projects_v2.get(props.upstream.project_id)
},

View File

@@ -0,0 +1,158 @@
<template>
<NewModal ref="modal" header="Create backup" @show="focusInput">
<div class="flex flex-col gap-2 md:w-[600px] -mb-2">
<label for="backup-name-input">
<span class="text-lg font-semibold text-contrast">Name</span>
</label>
<input
id="backup-name-input"
ref="input"
v-model="backupName"
type="text"
class="w-full rounded-lg bg-bg-input p-4"
:placeholder="`Backup #${newBackupAmount}`"
maxlength="48"
/>
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-20"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-20"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="nameExists && !createMutation.isPending.value"
class="flex items-center gap-1 mt-2 overflow-hidden"
>
<IssuesIcon class="hidden text-orange sm:block" />
<span class="text-sm text-orange">
You already have a backup named '<span class="font-semibold">{{ trimmedName }}</span
>'
</span>
</div>
</Transition>
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-20"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-20"
leave-to-class="opacity-0 max-h-0"
>
<div v-if="isRateLimited" class="overflow-hidden text-sm text-red">
You're creating backups too fast. Please wait a moment before trying again.
</div>
</Transition>
</div>
<template #actions>
<div class="w-full flex flex-row gap-2 justify-end">
<ButtonStyled type="outlined">
<button class="!border-[1px] !border-surface-4" @click="hideModal">
<XIcon />
Cancel
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="createMutation.isPending.value || nameExists" @click="createBackup">
<PlusIcon />
Create backup
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { IssuesIcon, PlusIcon, XIcon } from '@modrinth/assets'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, ref } from 'vue'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '../../../providers'
import ButtonStyled from '../../base/ButtonStyled.vue'
import NewModal from '../../modal/NewModal.vue'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const ctx = injectModrinthServerContext()
const props = defineProps<{
backups?: Archon.Backups.v1.Backup[]
}>()
const backupsQueryKey = ['backups', 'list', ctx.serverId]
const createMutation = useMutation({
mutationFn: (name: string) => client.archon.backups_v0.create(ctx.serverId, { name }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
})
const modal = ref<InstanceType<typeof NewModal>>()
const input = ref<HTMLInputElement>()
const isRateLimited = ref(false)
const backupName = ref('')
const newBackupAmount = computed(() => (props.backups?.length ?? 0) + 1)
const trimmedName = computed(() => backupName.value.trim())
const nameExists = computed(() => {
if (!props.backups) return false
return props.backups.some(
(backup) => backup.name.trim().toLowerCase() === trimmedName.value.toLowerCase(),
)
})
const focusInput = () => {
nextTick(() => {
setTimeout(() => {
input.value?.focus()
}, 100)
})
}
function show() {
backupName.value = ''
isRateLimited.value = false
modal.value?.show()
}
const hideModal = () => {
modal.value?.hide()
}
const createBackup = () => {
const name = trimmedName.value || `Backup #${newBackupAmount.value}`
isRateLimited.value = false
createMutation.mutate(name, {
onSuccess: () => {
hideModal()
},
onError: (error) => {
if (error instanceof Error && error.message.includes('429')) {
isRateLimited.value = true
addNotification({
type: 'error',
title: 'Error creating backup',
text: "You're creating backups too fast.",
})
} else {
const message = error instanceof Error ? error.message : String(error)
addNotification({ type: 'error', title: 'Error creating backup', text: message })
}
},
})
}
defineExpose({
show,
hide: hideModal,
})
</script>

View File

@@ -0,0 +1,67 @@
<template>
<NewModal ref="modal" header="Delete backup" fade="danger">
<div class="flex flex-col gap-6">
<Admonition type="critical" header="Delete warning">
This backup will be permanently deleted. This action cannot be undone.
</Admonition>
<div v-if="currentBackup" class="flex flex-col gap-2">
<span class="font-semibold text-contrast">Backup</span>
<BackupItem
:backup="currentBackup"
preview
class="!bg-surface-2 border-solid border-[1px] border-surface-5"
/>
</div>
</div>
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled>
<button @click="modal?.hide()">
<XIcon />
Cancel
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="deleteBackup">
<TrashIcon />
Delete backup
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { TrashIcon, XIcon } from '@modrinth/assets'
import { ref } from 'vue'
import Admonition from '../../base/Admonition.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import NewModal from '../../modal/NewModal.vue'
import BackupItem from './BackupItem.vue'
const emit = defineEmits<{
(e: 'delete', backup: Archon.Backups.v1.Backup | undefined): void
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const currentBackup = ref<Archon.Backups.v1.Backup>()
function show(backup: Archon.Backups.v1.Backup) {
currentBackup.value = backup
modal.value?.show()
}
function deleteBackup() {
modal.value?.hide()
emit('delete', currentBackup.value)
}
defineExpose({
show,
})
</script>

View File

@@ -0,0 +1,363 @@
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import {
ClockIcon,
DownloadIcon,
EditIcon,
LockIcon,
LockOpenIcon,
MoreVerticalIcon,
RotateCounterClockwiseIcon,
ShieldIcon,
TrashIcon,
UserRoundIcon,
XIcon,
} from '@modrinth/assets'
import { defineMessages, useVIntl } from '@vintl/vintl'
import dayjs from 'dayjs'
import { computed } from 'vue'
import { commonMessages } from '../../../utils'
import ButtonStyled from '../../base/ButtonStyled.vue'
import OverflowMenu, { type Option as OverflowOption } from '../../base/OverflowMenu.vue'
import ProgressBar from '../../base/ProgressBar.vue'
const { formatMessage } = useVIntl()
const emit = defineEmits<{
(e: 'download' | 'rename' | 'restore' | 'lock' | 'retry'): void
(e: 'delete', skipConfirmation?: boolean): void
}>()
const props = withDefaults(
defineProps<{
backup: Archon.Backups.v1.Backup
preview?: boolean
kyrosUrl?: string
jwt?: string
showDebugInfo?: boolean
disabled?: string
}>(),
{
preview: false,
kyrosUrl: undefined,
jwt: undefined,
showDebugInfo: false,
disabled: undefined,
},
)
const backupQueued = computed(
() =>
props.backup.task?.create?.progress === 0 ||
(props.backup.ongoing && !props.backup.task?.create),
)
// const automated = computed(() => props.backup.automated)
const failedToCreate = computed(() => props.backup.interrupted)
const inactiveStates = ['failed', 'cancelled', 'done']
const creating = computed(() => {
const task = props.backup.task?.create
if (task && task.progress < 1 && !inactiveStates.includes(task.state)) {
return task
}
if (props.backup.ongoing && !props.backup.task?.restore) {
return {
progress: 0,
state: 'ongoing',
}
}
return undefined
})
const restoring = computed(() => {
const task = props.backup.task?.restore
if (task && task.progress < 1 && !inactiveStates.includes(task.state)) {
return task
}
if (props.backup.ongoing && props.backup.task?.restore) {
return {
progress: 0,
state: 'ongoing',
}
}
return undefined
})
const restoreQueued = computed(() => restoring.value?.progress === 0)
const failedToRestore = computed(() => props.backup.task?.restore?.state === 'failed')
const backupIcon = computed(() => {
if (props.backup.automated) {
return props.backup.locked ? ShieldIcon : ClockIcon
}
return UserRoundIcon
})
const overflowMenuOptions = computed<OverflowOption[]>(() => {
const options: OverflowOption[] = []
// Download only available when not creating
if (!creating.value) {
options.push({
id: 'download',
action: () => emit('download'),
link: `https://${props.kyrosUrl}/modrinth/v0/backups/${props.backup.id}/download?auth=${props.jwt}`,
disabled: !props.kyrosUrl || !props.jwt,
})
}
options.push({ id: 'rename', action: () => emit('rename') })
options.push({ id: 'lock', action: () => emit('lock') })
// Delete only available when not creating (has separate Cancel button)
if (!creating.value) {
options.push({ divider: true })
options.push({
id: 'delete',
color: 'red',
action: () => emit('delete'),
disabled: !!props.disabled,
})
}
return options
})
// TODO: Uncomment when API supports size field
// const formatBytes = (bytes?: number) => {
// if (!bytes) return ''
// const mb = bytes / (1024 * 1024)
// return `${mb.toFixed(0)} MiB`
// }
const messages = defineMessages({
locked: {
id: 'servers.backups.item.locked',
defaultMessage: 'Locked',
},
lock: {
id: 'servers.backups.item.lock',
defaultMessage: 'Lock',
},
unlock: {
id: 'servers.backups.item.unlock',
defaultMessage: 'Unlock',
},
restore: {
id: 'servers.backups.item.restore',
defaultMessage: 'Restore',
},
rename: {
id: 'servers.backups.item.rename',
defaultMessage: 'Rename',
},
queuedForBackup: {
id: 'servers.backups.item.queued-for-backup',
defaultMessage: 'Backup queued',
},
queuedForRestore: {
id: 'servers.backups.item.queued-for-restore',
defaultMessage: 'Restore queued',
},
creatingBackup: {
id: 'servers.backups.item.creating-backup',
defaultMessage: 'Creating backup...',
},
restoringBackup: {
id: 'servers.backups.item.restoring-backup',
defaultMessage: 'Restoring from backup...',
},
failedToCreateBackup: {
id: 'servers.backups.item.failed-to-create-backup',
defaultMessage: 'Failed to create backup',
},
failedToRestoreBackup: {
id: 'servers.backups.item.failed-to-restore-backup',
defaultMessage: 'Failed to restore from backup',
},
auto: {
id: 'servers.backups.item.auto',
defaultMessage: 'Auto',
},
backupSchedule: {
id: 'servers.backups.item.backup-schedule',
defaultMessage: 'Backup schedule',
},
manualBackup: {
id: 'servers.backups.item.manual-backup',
defaultMessage: 'Manual backup',
},
retry: {
id: 'servers.backups.item.retry',
defaultMessage: 'Retry',
},
})
</script>
<template>
<div
class="grid items-center gap-4 rounded-2xl bg-bg-raised p-4 shadow-md"
:class="preview ? 'grid-cols-2' : 'grid-cols-[auto_1fr_auto] md:grid-cols-[1fr_400px_1fr]'"
>
<div class="flex flex-row gap-4 items-center">
<div
class="flex size-12 shrink-0 items-center justify-center rounded-2xl border-solid border-[1px] border-surface-5 bg-surface-4 md:size-16"
>
<component :is="backupIcon" class="size-7 text-secondary md:size-10" />
</div>
<div class="flex min-w-0 flex-col gap-1.5">
<div class="flex flex-wrap items-center gap-2">
<span class="truncate font-semibold text-contrast max-w-[400px]">{{ backup.name }}</span>
<span
v-if="backup.automated"
class="rounded-full border-solid border-[1px] border-surface-5 bg-surface-4 px-2.5 py-1 text-sm text-secondary"
>
{{ formatMessage(messages.auto) }}
</span>
<span v-if="backup.locked" class="flex items-center gap-1 text-sm text-secondary">
<LockIcon class="size-4" />
</span>
</div>
<div class="flex items-center gap-1.5 text-sm text-secondary">
<template v-if="failedToCreate || failedToRestore">
<XIcon class="size-4 text-red" />
<span class="text-red">
{{
formatMessage(
failedToCreate ? messages.failedToCreateBackup : messages.failedToRestoreBackup,
)
}}
</span>
</template>
<template v-else>
<!-- TODO: Uncomment when API supports creator_id field -->
<!-- <template v-if="backup.creator_id && backup.creator_id !== 'auto'">
<Avatar ... class="size-6 rounded-full" />
<span>{{ creatorName }}</span>
</template>
<template v-else> -->
<span>
{{
formatMessage(backup.automated ? messages.backupSchedule : messages.manualBackup)
}}
</span>
<!-- </template> -->
</template>
</div>
</div>
</div>
<div
class="col-span-full row-start-2 flex flex-col gap-2 md:col-span-1 md:row-start-auto md:items-center"
>
<template v-if="creating || restoring">
<ProgressBar
:progress="(creating || restoring)!.progress"
:color="creating ? 'brand' : 'purple'"
:waiting="(creating || restoring)!.progress === 0"
:label="
formatMessage(
creating
? backupQueued
? messages.queuedForBackup
: messages.creatingBackup
: restoreQueued
? messages.queuedForRestore
: messages.restoringBackup,
)
"
:label-class="creating ? 'text-contrast' : 'text-purple'"
show-progress
full-width
/>
</template>
<template v-else>
<span class="w-full font-medium text-contrast md:text-center">
{{ dayjs(backup.created_at).format('MMMM Do YYYY, h:mm A') }}
</span>
<!-- TODO: Uncomment when API supports size field -->
<!-- <span class="text-secondary">{{ formatBytes(backup.size) }}</span> -->
</template>
</div>
<div v-if="!preview" class="flex shrink-0 items-center gap-2 md:justify-self-end">
<template v-if="failedToCreate">
<ButtonStyled>
<button @click="() => emit('retry')">
<RotateCounterClockwiseIcon class="size-5" />
{{ formatMessage(messages.retry) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="() => emit('delete', true)">
<TrashIcon class="size-5" />
{{ formatMessage(commonMessages.deleteLabel) }}
</button>
</ButtonStyled>
</template>
<template v-else-if="creating">
<ButtonStyled type="outlined">
<button class="!border-[1px] !border-surface-5" @click="() => emit('delete')">
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu :options="overflowMenuOptions">
<MoreVerticalIcon class="size-5" />
<template #rename>
<EditIcon class="size-5" /> {{ formatMessage(messages.rename) }}
</template>
<template v-if="backup.locked" #lock>
<LockOpenIcon class="size-5" /> {{ formatMessage(messages.unlock) }}
</template>
<template v-else #lock>
<LockIcon class="size-5" /> {{ formatMessage(messages.lock) }}
</template>
</OverflowMenu>
</ButtonStyled>
</template>
<template v-else>
<ButtonStyled color="brand" type="outlined">
<button
v-tooltip="props.disabled"
class="!border-[1px]"
:disabled="!!props.disabled"
@click="() => emit('restore')"
>
<RotateCounterClockwiseIcon class="size-5" />
{{ formatMessage(messages.restore) }}
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu :options="overflowMenuOptions">
<MoreVerticalIcon class="size-5" />
<template #download>
<DownloadIcon class="size-5" /> {{ formatMessage(commonMessages.downloadButton) }}
</template>
<template #rename>
<EditIcon class="size-5" /> {{ formatMessage(messages.rename) }}
</template>
<template v-if="backup.locked" #lock>
<LockOpenIcon class="size-5" /> {{ formatMessage(messages.unlock) }}
</template>
<template v-else #lock>
<LockIcon class="size-5" /> {{ formatMessage(messages.lock) }}
</template>
<template #delete>
<TrashIcon class="size-5" /> {{ formatMessage(commonMessages.deleteLabel) }}
</template>
</OverflowMenu>
</ButtonStyled>
</template>
</div>
<pre v-if="!preview && showDebugInfo" class="w-full rounded-xl bg-surface-4 p-2 text-xs">{{
backup
}}</pre>
</div>
</template>

View File

@@ -0,0 +1,163 @@
<template>
<NewModal ref="modal" header="Renaming backup" @show="focusInput">
<div class="flex flex-col gap-2 md:w-[600px]">
<label for="backup-name-input">
<span class="text-lg font-semibold text-contrast"> Name </span>
</label>
<input
id="backup-name-input"
ref="input"
v-model="backupName"
type="text"
class="bg-bg-input w-full rounded-lg p-4"
:placeholder="`Backup #${backupNumber}`"
maxlength="48"
/>
<div v-if="nameExists" class="flex items-center gap-1">
<IssuesIcon class="hidden text-orange sm:block" />
<span class="text-sm text-orange">
You already have a backup named '<span class="font-semibold">{{ trimmedName }}</span
>'
</span>
</div>
</div>
<div class="mt-2 flex justify-start gap-2">
<ButtonStyled color="brand">
<button :disabled="renameMutation.isPending.value || nameExists" @click="renameBackup">
<template v-if="renameMutation.isPending.value">
<SpinnerIcon class="animate-spin" />
Renaming...
</template>
<template v-else>
<SaveIcon />
Save changes
</template>
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="hide">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</NewModal>
</template>
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { IssuesIcon, SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, ref } from 'vue'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '../../../providers'
import ButtonStyled from '../../base/ButtonStyled.vue'
import NewModal from '../../modal/NewModal.vue'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const ctx = injectModrinthServerContext()
const props = defineProps<{
backups?: Archon.Backups.v1.Backup[]
}>()
const backupsQueryKey = ['backups', 'list', ctx.serverId]
const renameMutation = useMutation({
mutationFn: ({ backupId, name }: { backupId: string; name: string }) =>
client.archon.backups_v0.rename(ctx.serverId, backupId, { name }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
})
const modal = ref<InstanceType<typeof NewModal>>()
const input = ref<HTMLInputElement>()
const backupName = ref('')
const originalName = ref('')
const currentBackup = ref<Archon.Backups.v1.Backup | null>(null)
const trimmedName = computed(() => backupName.value.trim())
const nameExists = computed(() => {
if (
!props.backups ||
trimmedName.value === originalName.value ||
renameMutation.isPending.value
) {
return false
}
return props.backups.some(
(backup) => backup.name.trim().toLowerCase() === trimmedName.value.toLowerCase(),
)
})
const backupNumber = computed(
() => (props.backups?.findIndex((b) => b.id === currentBackup.value?.id) ?? 0) + 1,
)
const focusInput = () => {
nextTick(() => {
setTimeout(() => {
input.value?.focus()
}, 100)
})
}
function show(backup: Archon.Backups.v1.Backup) {
currentBackup.value = backup
backupName.value = backup.name
originalName.value = backup.name
modal.value?.show()
}
function hide() {
modal.value?.hide()
}
const renameBackup = () => {
if (!currentBackup.value) {
addNotification({
type: 'error',
title: 'Error renaming backup',
text: 'Current backup is null',
})
return
}
if (trimmedName.value === originalName.value) {
hide()
return
}
let newName = trimmedName.value
if (newName.length === 0) {
newName = `Backup #${backupNumber.value}`
}
renameMutation.mutate(
{ backupId: currentBackup.value.id, name: newName },
{
onSuccess: () => {
hide()
},
onError: (error) => {
const message = error instanceof Error ? error.message : String(error)
addNotification({ type: 'error', title: 'Error renaming backup', text: message })
hide()
},
},
)
}
defineExpose({
show,
hide,
})
</script>

View File

@@ -0,0 +1,108 @@
<template>
<NewModal ref="modal" header="Restore backup" fade="warning">
<div class="flex flex-col gap-6 max-w-[600px]">
<Admonition v-if="ctx.isServerRunning.value" type="critical" header="Server is running">
Stop the server before restoring a backup.
</Admonition>
<!-- TODO: Worlds: Replace "server" with "world" -->
<Admonition v-else type="warning" header="Restore warning">
This will overwrite all files in the server and replace them with the files from the backup.
</Admonition>
<div v-if="currentBackup" class="flex flex-col gap-2">
<span class="font-semibold text-contrast">Backup</span>
<BackupItem :backup="currentBackup" preview class="!bg-surface-2" />
</div>
</div>
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled>
<button @click="modal?.hide()">
<XIcon />
Cancel
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button :disabled="isRestoring || ctx.isServerRunning.value" @click="restoreBackup">
<SpinnerIcon v-if="isRestoring" class="animate-spin" />
<RotateCounterClockwiseIcon v-else />
{{ isRestoring ? 'Restoring...' : 'Restore backup' }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { RotateCounterClockwiseIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { ref } from 'vue'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '../../../providers'
import Admonition from '../../base/Admonition.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import NewModal from '../../modal/NewModal.vue'
import BackupItem from './BackupItem.vue'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const ctx = injectModrinthServerContext()
const backupsQueryKey = ['backups', 'list', ctx.serverId]
const restoreMutation = useMutation({
mutationFn: (backupId: string) => client.archon.backups_v0.restore(ctx.serverId, backupId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
})
const modal = ref<InstanceType<typeof NewModal>>()
const currentBackup = ref<Archon.Backups.v1.Backup | null>(null)
const isRestoring = ref(false)
function show(backup: Archon.Backups.v1.Backup) {
currentBackup.value = backup
modal.value?.show()
}
const restoreBackup = () => {
if (!currentBackup.value || isRestoring.value) {
if (!currentBackup.value) {
addNotification({
type: 'error',
title: 'Failed to restore backup',
text: 'Current backup is null',
})
}
return
}
isRestoring.value = true
restoreMutation.mutate(currentBackup.value.id, {
onSuccess: () => {
// Optimistically update backupsState to show restore in progress immediately
ctx.backupsState.set(currentBackup.value!.id, {
restore: { progress: 0, state: 'ongoing' },
})
modal.value?.hide()
},
onError: (error) => {
const message = error instanceof Error ? error.message : String(error)
addNotification({ type: 'error', title: 'Failed to restore backup', text: message })
},
onSettled: () => {
isRestoring.value = false
},
})
}
defineExpose({
show,
})
</script>

View File

@@ -0,0 +1,6 @@
export { default as BackupCreateModal } from './BackupCreateModal.vue'
export { default as BackupDeleteModal } from './BackupDeleteModal.vue'
export { default as BackupItem } from './BackupItem.vue'
export { default as BackupRenameModal } from './BackupRenameModal.vue'
export { default as BackupRestoreModal } from './BackupRestoreModal.vue'
export { default as BackupWarning } from './BackupWarning.vue'

View File

@@ -128,7 +128,8 @@
</template>
<script setup lang="ts">
import { type Archon, NuxtModrinthClient } from '@modrinth/api-client'
import type { Archon } from '@modrinth/api-client'
import { NuxtModrinthClient } from '@modrinth/api-client'
import {
ChevronRightIcon,
LoaderCircleIcon,
@@ -152,7 +153,23 @@ import MedalBackgroundImage from './MedalBackgroundImage.vue'
dayjs.extend(dayjsDuration)
const props = defineProps<Partial<Archon.Servers.v0.Server>>()
type MedalServerListingProps = {
server_id: string
name: string
status: Archon.Servers.v0.Status
suspension_reason?: Archon.Servers.v0.SuspensionReason | null
game?: Archon.Servers.v0.Game
mc_version?: string | null
loader?: Archon.Servers.v0.Loader | null
loader_version?: string | null
net?: Archon.Servers.v0.Net
upstream?: Archon.Servers.v0.Upstream | null
flows?: Archon.Servers.v0.Flows
medal_expires?: string
}
const props = defineProps<MedalServerListingProps>()
const emit = defineEmits<{ (e: 'upgrade'): void }>()
const client = injectModrinthClient()

View File

@@ -842,6 +842,51 @@
"search.filter_type.shader_loader": {
"defaultMessage": "Loader"
},
"servers.backups.item.auto": {
"defaultMessage": "Auto"
},
"servers.backups.item.backup-schedule": {
"defaultMessage": "Backup schedule"
},
"servers.backups.item.creating-backup": {
"defaultMessage": "Creating backup..."
},
"servers.backups.item.failed-to-create-backup": {
"defaultMessage": "Failed to create backup"
},
"servers.backups.item.failed-to-restore-backup": {
"defaultMessage": "Failed to restore from backup"
},
"servers.backups.item.lock": {
"defaultMessage": "Lock"
},
"servers.backups.item.locked": {
"defaultMessage": "Locked"
},
"servers.backups.item.manual-backup": {
"defaultMessage": "Manual backup"
},
"servers.backups.item.queued-for-backup": {
"defaultMessage": "Backup queued"
},
"servers.backups.item.queued-for-restore": {
"defaultMessage": "Restore queued"
},
"servers.backups.item.rename": {
"defaultMessage": "Rename"
},
"servers.backups.item.restore": {
"defaultMessage": "Restore"
},
"servers.backups.item.restoring-backup": {
"defaultMessage": "Restoring from backup..."
},
"servers.backups.item.retry": {
"defaultMessage": "Retry"
},
"servers.backups.item.unlock": {
"defaultMessage": "Unlock"
},
"servers.notice.dismiss": {
"defaultMessage": "Dismiss"
},

View File

@@ -0,0 +1,460 @@
<template>
<Transition name="fade" mode="out-in">
<div
v-if="error"
key="error"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<IssuesIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load backups</h1>
</div>
<p class="text-lg text-secondary">
We couldn't load your server's backups. Here's what went wrong:
</p>
<p>
<span class="break-all font-mono">{{ error.message }}</span>
</p>
<ButtonStyled size="large" color="brand" @click="refetch">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
</div>
</div>
</div>
<div v-else key="content" class="contents">
<BackupCreateModal ref="createBackupModal" :backups="backupsData ?? []" />
<BackupRenameModal ref="renameBackupModal" :backups="backupsData ?? []" />
<BackupRestoreModal ref="restoreBackupModal" />
<BackupDeleteModal ref="deleteBackupModal" @delete="deleteBackup" />
<div class="mb-2 flex items-center align-middle justify-between">
<span class="text-2xl font-semibold text-contrast">Backups</span>
<ButtonStyled color="brand">
<button
v-tooltip="backupCreationDisabled"
:disabled="!!backupCreationDisabled"
@click="showCreateModel"
>
<PlusIcon class="size-5" />
Create backup
</button>
</ButtonStyled>
</div>
<div class="flex w-full flex-col gap-1.5">
<Transition name="fade" mode="out-in">
<div
v-if="groupedBackups.length === 0"
key="empty"
class="mt-6 flex items-center justify-center gap-2 text-center text-secondary"
>
<template v-if="server.used_backup_quota">
<SpinnerIcon class="animate-spin" />
Loading backups...
</template>
<template v-else>You don't have any backups yet.</template>
</div>
<div v-else key="list" class="flex flex-col gap-1.5">
<template v-for="group in groupedBackups" :key="group.label">
<div class="flex items-center gap-2">
<component :is="group.icon" v-if="group.icon" class="size-6 text-secondary" />
<span class="text-lg font-semibold text-secondary">{{ group.label }}</span>
</div>
<div class="flex gap-2">
<div class="flex w-5 justify-center">
<div class="h-full w-px bg-surface-5" />
</div>
<TransitionGroup name="list" tag="div" class="flex flex-1 flex-col gap-3 py-3">
<BackupItem
v-for="backup in group.backups"
:key="`backup-${backup.id}`"
:backup="backup"
:disabled="backupOperationDisabled"
:kyros-url="server.node?.instance"
:jwt="server.node?.token"
:show-debug-info="showDebugInfo"
@download="() => triggerDownloadAnimation()"
@rename="() => renameBackupModal?.show(backup)"
@restore="() => restoreBackupModal?.show(backup)"
@lock="
() => {
if (backup.locked) {
unlockBackup(backup.id)
} else {
lockBackup(backup.id)
}
}
"
@delete="
(skipConfirmation?: boolean) =>
!skipConfirmation ? deleteBackup(backup) : deleteBackupModal?.show(backup)
"
@retry="() => retryBackup(backup.id)"
/>
</TransitionGroup>
</div>
</template>
</div>
</Transition>
</div>
<div
class="over-the-top-download-animation"
:class="{ 'animation-hidden': !overTheTopDownloadAnimation }"
>
<div>
<div
class="animation-ring-3 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-40"
></div>
<div
class="animation-ring-2 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-60"
></div>
<div
class="animation-ring-1 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight"
>
<DownloadIcon class="h-20 w-20 text-contrast" />
</div>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { CalendarIcon, DownloadIcon, IssuesIcon, PlusIcon, SpinnerIcon } from '@modrinth/assets'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import dayjs from 'dayjs'
import type { Component } from 'vue'
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import ButtonStyled from '../../../components/base/ButtonStyled.vue'
import BackupCreateModal from '../../../components/servers/backups/BackupCreateModal.vue'
import BackupDeleteModal from '../../../components/servers/backups/BackupDeleteModal.vue'
import BackupItem from '../../../components/servers/backups/BackupItem.vue'
import BackupRenameModal from '../../../components/servers/backups/BackupRenameModal.vue'
import BackupRestoreModal from '../../../components/servers/backups/BackupRestoreModal.vue'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '../../../providers'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const { server, backupsState, markBackupCancelled } = injectModrinthServerContext()
const props = defineProps<{
isServerRunning: boolean
showDebugInfo?: boolean
}>()
const route = useRoute()
const serverId = route.params.id as string
defineEmits(['onDownload'])
const backupsQueryKey = ['backups', 'list', serverId]
const {
data: backupsData,
error,
refetch,
} = useQuery({
queryKey: backupsQueryKey,
queryFn: () => client.archon.backups_v0.list(serverId),
})
const deleteMutation = useMutation({
mutationFn: (backupId: string) => client.archon.backups_v0.delete(serverId, backupId),
onSuccess: (_data, backupId) => {
markBackupCancelled(backupId)
backupsState.delete(backupId)
queryClient.invalidateQueries({ queryKey: backupsQueryKey })
},
})
const lockMutation = useMutation({
mutationFn: (backupId: string) => client.archon.backups_v0.lock(serverId, backupId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
})
const unlockMutation = useMutation({
mutationFn: (backupId: string) => client.archon.backups_v0.unlock(serverId, backupId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
})
const retryMutation = useMutation({
mutationFn: (backupId: string) => client.archon.backups_v0.retry(serverId, backupId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
})
const backups = computed(() => {
if (!backupsData.value) return []
const merged = backupsData.value.map((backup) => {
const progressState = backupsState.get(backup.id)
if (progressState) {
const hasOngoingTask = Object.values(progressState).some((task) => task?.state === 'ongoing')
const hasCompletedTask = Object.values(progressState).some((task) => task?.state === 'done')
return {
...backup,
task: {
...backup.task,
...progressState,
},
ongoing: hasOngoingTask || (backup.ongoing && !hasCompletedTask),
}
}
return backup
})
return merged.sort((a, b) => {
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
})
})
type BackupGroup = {
label: string
icon: Component | null
backups: Archon.Backups.v1.Backup[]
}
const groupedBackups = computed((): BackupGroup[] => {
if (!backups.value.length) return []
const now = dayjs()
const groups: BackupGroup[] = []
const addToGroup = (label: string, icon: Component | null, backup: Archon.Backups.v1.Backup) => {
let group = groups.find((g) => g.label === label)
if (!group) {
group = { label, icon, backups: [] }
groups.push(group)
}
group.backups.push(backup)
}
for (const backup of backups.value) {
const created = dayjs(backup.created_at)
const diffMinutes = now.diff(created, 'minute')
const isToday = created.isSame(now, 'day')
const isYesterday = created.isSame(now.subtract(1, 'day'), 'day')
const diffDays = now.diff(created, 'day')
if (diffMinutes < 30 && isToday) {
addToGroup('Just now', null, backup)
} else if (isToday) {
addToGroup('Earlier today', CalendarIcon, backup)
} else if (isYesterday) {
addToGroup('Yesterday', CalendarIcon, backup)
} else if (diffDays <= 14) {
addToGroup('Last 2 weeks', CalendarIcon, backup)
} else {
addToGroup('Older', CalendarIcon, backup)
}
}
return groups
})
const overTheTopDownloadAnimation = ref()
const createBackupModal = ref<InstanceType<typeof BackupCreateModal>>()
const renameBackupModal = ref<InstanceType<typeof BackupRenameModal>>()
const restoreBackupModal = ref<InstanceType<typeof BackupRestoreModal>>()
const deleteBackupModal = ref<InstanceType<typeof BackupDeleteModal>>()
// const backupSettingsModal = ref<InstanceType<typeof BackupSettingsModal>>()
const backupOperationDisabled = computed(() => {
if (props.isServerRunning) {
return 'Cannot perform backup operations while server is running'
}
for (const entry of backupsState.values()) {
if (entry.create?.state === 'ongoing') {
return 'Cannot perform backup operations while a backup is being created'
}
if (entry.restore?.state === 'ongoing') {
return 'Cannot perform backup operations while a backup is being restored'
}
}
return undefined
})
const backupCreationDisabled = computed(() => {
if (props.isServerRunning) {
return 'Cannot create backup while server is running'
}
if (
server.value.used_backup_quota !== undefined &&
server.value.backup_quota !== undefined &&
server.value.used_backup_quota >= server.value.backup_quota
) {
return `All ${server.value.backup_quota} of your backup slots are in use`
}
for (const entry of backupsState.values()) {
if (entry.create?.state === 'ongoing') {
return 'A backup is already in progress'
}
if (entry.restore?.state === 'ongoing') {
return 'Cannot create backup while a restore is in progress'
}
}
if (server.value.status === 'installing') {
return 'Cannot create backup while server is installing'
}
return undefined
})
const showCreateModel = () => {
createBackupModal.value?.show()
}
// const showbackupSettingsModal = () => {
// backupSettingsModal.value?.show()
// }
function triggerDownloadAnimation() {
overTheTopDownloadAnimation.value = true
setTimeout(() => (overTheTopDownloadAnimation.value = false), 500)
}
const lockBackup = (backupId: string) => {
lockMutation.mutate(backupId, {
onError: (err) => {
console.error('Failed to lock backup:', err)
},
})
}
const unlockBackup = (backupId: string) => {
unlockMutation.mutate(backupId, {
onError: (err) => {
console.error('Failed to unlock backup:', err)
},
})
}
const retryBackup = (backupId: string) => {
retryMutation.mutate(backupId, {
onError: (err) => {
console.error('Failed to retry backup:', err)
},
})
}
function deleteBackup(backup?: Archon.Backups.v1.Backup) {
if (!backup) {
addNotification({
type: 'error',
title: 'Error deleting backup',
text: 'Backup is null',
})
return
}
deleteMutation.mutate(backup.id, {
onError: (err) => {
const message = err instanceof Error ? err.message : String(err)
addNotification({
type: 'error',
title: 'Error deleting backup',
text: message,
})
},
})
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition:
opacity 300ms ease-in-out,
transform 300ms ease-in-out;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: scale(0.98);
}
.list-enter-active,
.list-leave-active {
transition: all 200ms ease-in-out;
}
.list-enter-from {
opacity: 0;
transform: translateY(-10px);
}
.list-leave-to {
opacity: 0;
transform: translateY(10px);
}
.list-move {
transition: transform 200ms ease-in-out;
}
.over-the-top-download-animation {
position: fixed;
z-index: 100;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
pointer-events: none;
scale: 0.5;
transition: all 0.5s ease-out;
opacity: 1;
&.animation-hidden {
scale: 0.8;
opacity: 0;
.animation-ring-1 {
width: 25rem;
height: 25rem;
}
.animation-ring-2 {
width: 50rem;
height: 50rem;
}
.animation-ring-3 {
width: 100rem;
height: 100rem;
}
}
> div {
position: relative;
display: flex;
justify-content: center;
align-items: center;
width: fit-content;
height: fit-content;
> * {
position: absolute;
scale: 1;
transition: all 0.2s ease-out;
width: 20rem;
height: 20rem;
}
}
}
</style>

View File

@@ -1 +1,2 @@
export { default as ServersManageBackupsPage } from './hosting/manage/backups.vue'
export { default as ServersManagePageIndex } from './hosting/manage/index.vue'

View File

@@ -80,4 +80,5 @@ export function createContext<ContextValue>(
export * from './api-client'
export * from './project-page'
export * from './server-context'
export * from './web-notifications'

View File

@@ -0,0 +1,32 @@
import type { Archon } from '@modrinth/api-client'
import type { ComputedRef, Reactive, Ref } from 'vue'
import { createContext } from '.'
export type BackupTaskState = {
progress: number
state: Archon.Backups.v1.BackupState
}
export type BackupProgressEntry = {
file?: BackupTaskState
create?: BackupTaskState
restore?: BackupTaskState
}
export type BackupsState = Map<string, BackupProgressEntry>
export interface ModrinthServerContext {
readonly serverId: string
readonly server: Ref<Archon.Servers.v0.Server>
// Websocket state
readonly isConnected: Ref<boolean>
readonly powerState: Ref<Archon.Websocket.v0.PowerState>
readonly isServerRunning: ComputedRef<boolean>
readonly backupsState: Reactive<BackupsState>
markBackupCancelled: (backupId: string) => void
}
export const [injectModrinthServerContext, provideModrinthServerContext] =
createContext<ModrinthServerContext>('[id].vue', 'modrinthServerContext')