From c1e913c3b41d62035da677f7438904623cffe30b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-X=2E=20T=2E?= Date: Wed, 28 Oct 2026 16:25:26 -0400 Subject: [PATCH 1/6] feat: suspension control --- change.diff | 822 ++++++++++++++++++ .../src/modules/archon/servers/internal.ts | 31 + .../api-client/src/modules/archon/types.ts | 25 + .../components/base/ErrorInformationCard.vue | 2 + .../servers/LiftSuspensionModal.vue | 222 +++++ .../servers/access/events/SuspensionEvent.vue | 75 ++ .../components/servers/access/events/index.ts | 1 + .../servers/access/events/parser.ts | 28 + .../shared/server-settings/pages/support.vue | 78 ++ .../manage/[id]/access/audit-log-utils.ts | 8 + .../hosting/manage/[id]/access/messages.ts | 16 + .../layouts/wrapped/hosting/manage/root.vue | 28 +- packages/ui/src/locales/en-US/index.json | 30 + .../stories/servers/AuditLogTable.stories.ts | 23 + 14 files changed, 1388 insertions(+), 1 deletion(-) create mode 100644 change.diff create mode 100644 packages/ui/src/components/servers/LiftSuspensionModal.vue create mode 100644 packages/ui/src/components/servers/access/events/SuspensionEvent.vue diff --git a/change.diff b/change.diff new file mode 100644 index 00000000000..f6d34f1e8d2 --- /dev/null +++ b/change.diff @@ -0,0 +1,822 @@ +diff --git a/packages/api-client/src/modules/archon/servers/internal.ts b/packages/api-client/src/modules/archon/servers/internal.ts +index 44911c2..cc33a3c 100644 +--- a/packages/api-client/src/modules/archon/servers/internal.ts ++++ b/packages/api-client/src/modules/archon/servers/internal.ts +@@ -75,4 +75,35 @@ export class ArchonServersInternalModule extends AbstractModule { + method: 'DELETE', + }) + } ++ ++ /** ++ * Temporarily lift a suspended server's suspension, or update its current temporary lift. ++ * POST /_internal/suspensions/server/:server_id/lift ++ */ ++ public async liftSuspension( ++ serverId: string, ++ request: Archon.Servers.Internal.LiftSuspensionRequest, ++ ): Promise { ++ return this.client.request( ++ `/suspensions/server/${serverId}/lift`, ++ { ++ api: 'archon', ++ version: 'internal', ++ method: 'POST', ++ body: request, ++ }, ++ ) ++ } ++ ++ /** ++ * End the temporary lift of a server's suspension before it expires. ++ * POST /_internal/suspensions/server/:server_id/resuspend ++ */ ++ public async resuspend(serverId: string): Promise { ++ return this.client.request(`/suspensions/server/${serverId}/resuspend`, { ++ api: 'archon', ++ version: 'internal', ++ method: 'POST', ++ }) ++ } + } +diff --git a/packages/api-client/src/modules/archon/types.ts b/packages/api-client/src/modules/archon/types.ts +index 7492705..228f16e 100644 +--- a/packages/api-client/src/modules/archon/types.ts ++++ b/packages/api-client/src/modules/archon/types.ts +@@ -111,6 +111,10 @@ export namespace Archon { + | 'changed_server_subdomain' + | 'server_reallocated' + | 'server_plan_changed' ++ | 'server_suspended' ++ | 'server_unsuspended' ++ | 'server_suspension_lifted' ++ | 'server_resuspended' + | 'user_invited' + | 'user_invite_revoked' + | 'user_permission_modified' +@@ -589,6 +593,18 @@ export namespace Archon { + export type LockServerRequest = { + reason: string + } ++ ++ export type LiftSuspensionRequest = { ++ /** How long to lift the suspension for, in minutes from now. */ ++ minutes: number ++ /** Whether to prevent the server from starting while the suspension is lifted. */ ++ restrict_power: boolean ++ } ++ ++ export type LiftSuspensionResponse = { ++ lifted_until: string ++ restrict_power: boolean ++ } + } + + export namespace v0 { +@@ -644,6 +660,8 @@ export namespace Archon { + used_backup_quota: number + status: Status + suspension_reason: SuspensionReason | null ++ /** Set while support temporarily lifts the server's suspension. */ ++ suspension_lift?: SuspensionLift | null + loader: Loader | null + loader_version: string | null + mc_version: string | null +@@ -662,6 +680,13 @@ export namespace Archon { + medal_expires?: string + } + ++ export type SuspensionLift = { ++ /** When the server is suspended again. */ ++ until: string ++ /** Whether the server is prevented from starting while the suspension is lifted. */ ++ restrict_power: boolean ++ } ++ + export type UserScope = number + + export type Net = { +diff --git a/packages/ui/src/components/base/ErrorInformationCard.vue b/packages/ui/src/components/base/ErrorInformationCard.vue +index f183cca..485c8ac 100644 +--- a/packages/ui/src/components/base/ErrorInformationCard.vue ++++ b/packages/ui/src/components/base/ErrorInformationCard.vue +@@ -64,6 +64,8 @@ + {{ action.label }} + + ++ ++ + ++ ++ Suspended again {{ liftEndsIn }} ++ ++ ++ ++ {{ ++ suspensionLift.restrict_power ++ ? 'Power actions are blocked while the suspension is lifted.' ++ : 'Power actions are allowed while the suspension is lifted.' ++ }} ++ ++ ++ +
+ + {{ formatMessage(messages.resetToOnboardingButton) }} +@@ -78,11 +112,14 @@ + diff --git a/packages/ui/src/components/servers/access/events/SuspensionEvent.vue b/packages/ui/src/components/servers/access/events/SuspensionEvent.vue new file mode 100644 index 00000000000..baee8927266 --- /dev/null +++ b/packages/ui/src/components/servers/access/events/SuspensionEvent.vue @@ -0,0 +1,75 @@ + + + diff --git a/packages/ui/src/components/servers/access/events/index.ts b/packages/ui/src/components/servers/access/events/index.ts index 5c1e132022b..64fd50f7a87 100644 --- a/packages/ui/src/components/servers/access/events/index.ts +++ b/packages/ui/src/components/servers/access/events/index.ts @@ -11,6 +11,7 @@ export { default as ModpackEvent } from './ModpackEvent.vue' export { default as NetworkEvent } from './NetworkEvent.vue' export * from './parser' export { default as ServerMetaEvent } from './ServerMetaEvent.vue' +export { default as SuspensionEvent } from './SuspensionEvent.vue' export * from './types' export { default as UnknownEvent } from './UnknownEvent.vue' export { default as UserAccessEvent } from './UserAccessEvent.vue' diff --git a/packages/ui/src/components/servers/access/events/parser.ts b/packages/ui/src/components/servers/access/events/parser.ts index a03149bcec2..0cb405311c0 100644 --- a/packages/ui/src/components/servers/access/events/parser.ts +++ b/packages/ui/src/components/servers/access/events/parser.ts @@ -11,6 +11,7 @@ import FileEvent from './FileEvent.vue' import ModpackEvent from './ModpackEvent.vue' import NetworkEvent from './NetworkEvent.vue' import ServerMetaEvent from './ServerMetaEvent.vue' +import SuspensionEvent from './SuspensionEvent.vue' import type { AuditActor, AuditAddonEventItem, @@ -81,6 +82,33 @@ export function parseAuditEvent( ...Object.values(newSpecs).map(String), ]) } + case 'server_suspended': { + const reason = stringField(metadataRecord(metadata), 'reason') + return parsed(SuspensionEvent, base, { kind: 'suspended', reason }, [ + ...actionSearchParts(action), + reason, + ]) + } + case 'server_unsuspended': + return parsed(SuspensionEvent, base, { kind: 'unsuspended' }, actionSearchParts(action)) + case 'server_suspension_lifted': { + const record = metadataRecord(metadata) + const until = stringField(record, 'until') + if (!until) return unknown(base, action) + return parsed( + SuspensionEvent, + base, + { kind: 'lifted', until, restrictPower: record?.restrict_power === true }, + actionSearchParts(action), + ) + } + case 'server_resuspended': + return parsed( + SuspensionEvent, + base, + { kind: 'resuspended', expired: metadataRecord(metadata)?.expired === true }, + actionSearchParts(action), + ) case 'user_invited': case 'user_permission_modified': { const actionMetadata = userPermissionsActionMetadata(metadataRecord(metadata)) diff --git a/packages/ui/src/layouts/shared/server-settings/pages/support.vue b/packages/ui/src/layouts/shared/server-settings/pages/support.vue index 5f3dc4d08bd..04363ee6d28 100644 --- a/packages/ui/src/layouts/shared/server-settings/pages/support.vue +++ b/packages/ui/src/layouts/shared/server-settings/pages/support.vue @@ -18,6 +18,14 @@ @proceed="confirmResetToOnboarding" /> +
@@ -53,6 +61,32 @@ +
+ Suspension lifted +
+ + + Suspended again {{ liftEndsIn }} + +
+ + {{ + suspensionLift.restrict_power + ? 'Power actions are blocked while the suspension is lifted.' + : 'Power actions are allowed while the suspension is lifted.' + }} + +
+
{{ formatMessage(messages.resetToOnboardingButton) }} @@ -78,11 +112,14 @@ -diff --git a/packages/ui/src/components/servers/access/events/SuspensionEvent.vue b/packages/ui/src/components/servers/access/events/SuspensionEvent.vue -new file mode 100644 -index 0000000..baee892 ---- /dev/null -+++ b/packages/ui/src/components/servers/access/events/SuspensionEvent.vue -@@ -0,0 +1,75 @@ -+ -+ -+ -diff --git a/packages/ui/src/components/servers/access/events/index.ts b/packages/ui/src/components/servers/access/events/index.ts -index 5c1e132..64fd50f 100644 ---- a/packages/ui/src/components/servers/access/events/index.ts -+++ b/packages/ui/src/components/servers/access/events/index.ts -@@ -11,6 +11,7 @@ export { default as ModpackEvent } from './ModpackEvent.vue' - export { default as NetworkEvent } from './NetworkEvent.vue' - export * from './parser' - export { default as ServerMetaEvent } from './ServerMetaEvent.vue' -+export { default as SuspensionEvent } from './SuspensionEvent.vue' - export * from './types' - export { default as UnknownEvent } from './UnknownEvent.vue' - export { default as UserAccessEvent } from './UserAccessEvent.vue' -diff --git a/packages/ui/src/components/servers/access/events/parser.ts b/packages/ui/src/components/servers/access/events/parser.ts -index a03149b..0cb4053 100644 ---- a/packages/ui/src/components/servers/access/events/parser.ts -+++ b/packages/ui/src/components/servers/access/events/parser.ts -@@ -11,6 +11,7 @@ import FileEvent from './FileEvent.vue' - import ModpackEvent from './ModpackEvent.vue' - import NetworkEvent from './NetworkEvent.vue' - import ServerMetaEvent from './ServerMetaEvent.vue' -+import SuspensionEvent from './SuspensionEvent.vue' - import type { - AuditActor, - AuditAddonEventItem, -@@ -81,6 +82,33 @@ export function parseAuditEvent( - ...Object.values(newSpecs).map(String), - ]) - } -+ case 'server_suspended': { -+ const reason = stringField(metadataRecord(metadata), 'reason') -+ return parsed(SuspensionEvent, base, { kind: 'suspended', reason }, [ -+ ...actionSearchParts(action), -+ reason, -+ ]) -+ } -+ case 'server_unsuspended': -+ return parsed(SuspensionEvent, base, { kind: 'unsuspended' }, actionSearchParts(action)) -+ case 'server_suspension_lifted': { -+ const record = metadataRecord(metadata) -+ const until = stringField(record, 'until') -+ if (!until) return unknown(base, action) -+ return parsed( -+ SuspensionEvent, -+ base, -+ { kind: 'lifted', until, restrictPower: record?.restrict_power === true }, -+ actionSearchParts(action), -+ ) -+ } -+ case 'server_resuspended': -+ return parsed( -+ SuspensionEvent, -+ base, -+ { kind: 'resuspended', expired: metadataRecord(metadata)?.expired === true }, -+ actionSearchParts(action), -+ ) - case 'user_invited': - case 'user_permission_modified': { - const actionMetadata = userPermissionsActionMetadata(metadataRecord(metadata)) -diff --git a/packages/ui/src/layouts/shared/server-settings/pages/support.vue b/packages/ui/src/layouts/shared/server-settings/pages/support.vue -index 5f3dc4d..04363ee 100644 ---- a/packages/ui/src/layouts/shared/server-settings/pages/support.vue -+++ b/packages/ui/src/layouts/shared/server-settings/pages/support.vue -@@ -18,6 +18,14 @@ - @proceed="confirmResetToOnboarding" - /> - -+ -
- - -@@ -53,6 +61,32 @@ - - - -+
-+ Suspension lifted -+
-+ -+ -+ Suspended again {{ liftEndsIn }} -+ -+
-+ -+ {{ -+ suspensionLift.restrict_power -+ ? 'Power actions are blocked while the suspension is lifted.' -+ : 'Power actions are allowed while the suspension is lifted.' -+ }} -+ -+
-+ -
- - {{ formatMessage(messages.resetToOnboardingButton) }} -@@ -78,11 +112,14 @@ - diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/backups/index.vue b/packages/ui/src/layouts/wrapped/hosting/manage/backups/index.vue index c5fc30f5cf0..d458d4c9307 100644 --- a/packages/ui/src/layouts/wrapped/hosting/manage/backups/index.vue +++ b/packages/ui/src/layouts/wrapped/hosting/manage/backups/index.vue @@ -112,6 +112,13 @@ @delete="(backup) => deleteModal?.show(backup)" /> + +
serverId), - worldId, - ) +const { + query, + backups, + deletedBackups, + options, + nextAutoBackupAt, + hasActiveCreate, + hasActiveRestore, + invalidate, +} = useServerBackupsQueue( + computed(() => serverId), + worldId, +) const error = computed(() => query.error.value) const readyPending = computed( @@ -345,6 +361,14 @@ const filteredBackups = computed(() => { }) const selectedIds = ref([]) +const selectedDeletedIds = ref([]) + +watch(selectedIds, (ids) => { + if (ids.length > 0) selectedDeletedIds.value = [] +}) +watch(selectedDeletedIds, (ids) => { + if (ids.length > 0) selectedIds.value = [] +}) const selectedBackups = computed(() => completedBackups.value.filter((backup) => selectedIds.value.includes(backup.id)), ) diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/root.vue b/packages/ui/src/layouts/wrapped/hosting/manage/root.vue index ee57eefae47..5bb7e0f7955 100644 --- a/packages/ui/src/layouts/wrapped/hosting/manage/root.vue +++ b/packages/ui/src/layouts/wrapped/hosting/manage/root.vue @@ -663,6 +663,7 @@ const { server: serverData, serverFull, content: serverContent, + isSiteAdmin, extraBusyReasons: backupsBusy, setDisconnectedOnAuthIncorrect: false, syncUptimeFromState: true, diff --git a/packages/ui/src/locales/en-US/index.json b/packages/ui/src/locales/en-US/index.json index 54ea3903342..104ed9fd8b9 100644 --- a/packages/ui/src/locales/en-US/index.json +++ b/packages/ui/src/locales/en-US/index.json @@ -5579,6 +5579,84 @@ "servers.backups.delete.error": { "defaultMessage": "Failed to delete {count, plural, one {backup} other {# backups}}" }, + "servers.backups.deleted.auto-badge": { + "defaultMessage": "Auto" + }, + "servers.backups.deleted.bulk-bar.none-recoverable": { + "defaultMessage": "None of the selected backups can be undeleted" + }, + "servers.backups.deleted.bulk-bar.selected-count": { + "defaultMessage": "{count, plural, one {# backup selected} other {# backups selected}}" + }, + "servers.backups.deleted.bulk-bar.some-unrecoverable": { + "defaultMessage": "Only {recoverable} of {count, plural, one {# selected backup} other {# selected backups}} can be undeleted" + }, + "servers.backups.deleted.column.created": { + "defaultMessage": "Created" + }, + "servers.backups.deleted.column.name": { + "defaultMessage": "Name" + }, + "servers.backups.deleted.column.reason": { + "defaultMessage": "Reason" + }, + "servers.backups.deleted.column.recoverable-until": { + "defaultMessage": "Recoverable until" + }, + "servers.backups.deleted.filter.add": { + "defaultMessage": "Add filter" + }, + "servers.backups.deleted.filter.clear": { + "defaultMessage": "Clear filters" + }, + "servers.backups.deleted.filter.reason": { + "defaultMessage": "Reason" + }, + "servers.backups.deleted.filter.recoverable": { + "defaultMessage": "Recoverability" + }, + "servers.backups.deleted.filter.recoverable.yes": { + "defaultMessage": "Recoverable" + }, + "servers.backups.deleted.filter.type": { + "defaultMessage": "Type" + }, + "servers.backups.deleted.filter.type.auto": { + "defaultMessage": "Auto" + }, + "servers.backups.deleted.filter.type.manual": { + "defaultMessage": "Manual" + }, + "servers.backups.deleted.no-results": { + "defaultMessage": "No deleted backups match your search." + }, + "servers.backups.deleted.reason.deleted": { + "defaultMessage": "Manually deleted" + }, + "servers.backups.deleted.reason.suspension": { + "defaultMessage": "Suspension" + }, + "servers.backups.deleted.search-label": { + "defaultMessage": "Search deleted backups" + }, + "servers.backups.deleted.search-placeholder": { + "defaultMessage": "Search {count, plural, one {# deleted backup} other {# deleted backups}}..." + }, + "servers.backups.deleted.title": { + "defaultMessage": "Deleted backups" + }, + "servers.backups.deleted.undelete": { + "defaultMessage": "Undelete" + }, + "servers.backups.deleted.undelete.error": { + "defaultMessage": "Failed to undelete {count, plural, one {backup} other {# backups}}" + }, + "servers.backups.deleted.undelete.success": { + "defaultMessage": "{count, plural, one {Backup undeleted} other {# backups undeleted}}" + }, + "servers.backups.deleted.unrecoverable": { + "defaultMessage": "Unrecoverable" + }, "servers.backups.empty.description": { "defaultMessage": "Create your first backup" }, diff --git a/packages/ui/src/providers/server-context.ts b/packages/ui/src/providers/server-context.ts index 334df47dac9..ba2699fd78a 100644 --- a/packages/ui/src/providers/server-context.ts +++ b/packages/ui/src/providers/server-context.ts @@ -44,6 +44,8 @@ export interface ModrinthServerContext { readonly server: Ref readonly serverFull: ComputedRef readonly currentUserPermissions: ComputedRef + /** Whether the viewer is a Modrinth admin, same as the server settings support tab. */ + readonly isSiteAdmin: ComputedRef // Websocket state readonly isConnected: Ref diff --git a/packages/ui/src/stories/servers/EditServerIcon.stories.ts b/packages/ui/src/stories/servers/EditServerIcon.stories.ts index acdb0703531..d01f8b44cbb 100644 --- a/packages/ui/src/stories/servers/EditServerIcon.stories.ts +++ b/packages/ui/src/stories/servers/EditServerIcon.stories.ts @@ -64,6 +64,7 @@ const meta = { server, serverFull: computed(() => null), currentUserPermissions: computed(() => 0), + isSiteAdmin: computed(() => false), isConnected: ref(true), isWsAuthIncorrect: ref(false), powerState: ref('running'), diff --git a/packages/ui/src/stories/servers/InstallingBanner.stories.ts b/packages/ui/src/stories/servers/InstallingBanner.stories.ts index 4921477280c..9654197e327 100644 --- a/packages/ui/src/stories/servers/InstallingBanner.stories.ts +++ b/packages/ui/src/stories/servers/InstallingBanner.stories.ts @@ -27,6 +27,7 @@ function renderInstallation(state: ServerInstallationState) { } as Archon.Servers.v0.Server), serverFull: computed(() => null), currentUserPermissions: computed(() => 0), + isSiteAdmin: computed(() => false), isConnected: ref(true), isWsAuthIncorrect: ref(false), powerState: ref('stopped'), diff --git a/packages/ui/src/stories/servers/ServerPanelAdmonitions.stories.ts b/packages/ui/src/stories/servers/ServerPanelAdmonitions.stories.ts index 22da1a80c15..f5111264c63 100644 --- a/packages/ui/src/stories/servers/ServerPanelAdmonitions.stories.ts +++ b/packages/ui/src/stories/servers/ServerPanelAdmonitions.stories.ts @@ -90,6 +90,7 @@ const meta = { server, serverFull: computed(() => null), currentUserPermissions: computed(() => 0), + isSiteAdmin: computed(() => false), isConnected: ref(true), isWsAuthIncorrect: ref(false), powerState: ref('running'), From 31ee332e6765d338a9378c6f8874b3d50b183f01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-X=2E=20T=2E?= Date: Thu, 29 Oct 2026 13:58:24 -0400 Subject: [PATCH 5/6] style: format deleted backups sort --- .../layouts/wrapped/hosting/manage/backups/deleted-backups.vue | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/backups/deleted-backups.vue b/packages/ui/src/layouts/wrapped/hosting/manage/backups/deleted-backups.vue index a58c50781ed..a2c3a5cc89f 100644 --- a/packages/ui/src/layouts/wrapped/hosting/manage/backups/deleted-backups.vue +++ b/packages/ui/src/layouts/wrapped/hosting/manage/backups/deleted-backups.vue @@ -413,8 +413,7 @@ function compareBackups(a: DeletedBackup, b: DeletedBackup) { const sortedBackups = computed(() => { const direction = sortDirection.value === 'asc' ? 1 : -1 return [...filteredBackups.value].sort( - (a, b) => - compareBackups(a, b) * direction || timeOf(b.created_at) - timeOf(a.created_at), + (a, b) => compareBackups(a, b) * direction || timeOf(b.created_at) - timeOf(a.created_at), ) }) From a4601e62d3b1c2f236e74af245576d5690d37f98 Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Thu, 29 Oct 2026 18:02:42 +0000 Subject: [PATCH 6/6] fix: rev --- .../servers/LiftSuspensionModal.vue | 175 +++++++++++++----- .../shared/server-settings/pages/support.vue | 82 ++++++-- .../manage/backups/deleted-backups.vue | 12 +- .../layouts/wrapped/hosting/manage/root.vue | 42 ++++- packages/ui/src/locales/en-US/index.json | 93 ++++++++++ 5 files changed, 323 insertions(+), 81 deletions(-) diff --git a/packages/ui/src/components/servers/LiftSuspensionModal.vue b/packages/ui/src/components/servers/LiftSuspensionModal.vue index c0c515f1bce..29d88339e95 100644 --- a/packages/ui/src/components/servers/LiftSuspensionModal.vue +++ b/packages/ui/src/components/servers/LiftSuspensionModal.vue @@ -1,29 +1,32 @@ @@ -84,6 +87,7 @@