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

@@ -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,