feat: hosting access tab (#5995)

* feat: implement access tab with dummy data

* fix: spacing

* feat: qa

* feat: implement backend

* qa: qa pass

* feat: fix user "search"

* fix: lint

* feat: change to bitfield

* feat: fix fields

* fix: lint

* fix: lint

* feat: hook up api

* feat: fix permissions

* feat: audit log table event start

* feat: better mobile mode for audit log table

* feat: i18n

* feat: qa

* feat: enforce permissions

* feat: email template start

* feat: qa

* fix: tooltip bug

* feat: qa

* impl: sse support in api-client

* feat: sse impl

* fix: desync path

* feat: time frame picker from analytics

* feat: QA

* fix: spacing

* fix: permisison audit log entries

* fix: hosting manage page shared server detection

* fix: lint

* feat: qa + lint

* feat: audit log table sort by time

* feat: finish frontend panel stuff

* fix: lint

* fix: backend alignment

* fix: lint

* fix: supress friend errors

* feat: qa

* fix: qa

* fix: lint

* fix: utils barrel

* fix: safari cookies in dev

* fix: pin nuxt

* feat: fixes + notif fix

* fix: notifications

* feat: qa

* fix: notification sync not happening immediately

* fix: qa

* fix: qa

* feat: qa

* blog + prepr

* feat: toast shit

* blog images

* thumbnail update one last time

* prepr

* feat: use reinvite route

* update images

* fix: reinvite stuff

* fix: lint

* fix: alignment of save bar

* fix: notif sizing

* fix: split up access

* fix: lint

* fix: lint

* fix: link

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-06-04 15:58:01 +00:00
committed by GitHub
co-authored by Prospector
parent 58ad58f958
commit bd97ace974
227 changed files with 15578 additions and 2153 deletions
@@ -0,0 +1,644 @@
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2 md:flex-row">
<StyledInput
v-model="memberSearch"
:icon="SearchIcon"
:placeholder="formatMessage(messages.searchUsersPlaceholder, { count: members.length })"
wrapper-class="min-w-0 flex-1"
input-class="!h-10"
clearable
/>
<div class="flex shrink-0 items-center gap-2 flex-wrap md:flex-nowrap">
<Combobox
v-model="roleFilter"
:options="roleFilterOptions"
:display-value="selectedRoleFilterLabel"
trigger-class="min-w-[225px] !h-10 !min-h-10 !py-0"
>
<template #prefix>
<FilterIcon class="size-5 text-secondary" aria-hidden="true" />
</template>
</Combobox>
<ButtonStyled color="brand">
<button
v-tooltip="manageUsersActionTooltip"
class="!h-10 w-full md:w-fit"
:disabled="!canManageUsers"
@click="grantAccessModal?.show($event)"
>
<UserPlusIcon aria-hidden="true" />
{{ formatMessage(messages.inviteFriends) }}
</button>
</ButtonStyled>
</div>
</div>
<AccessTable
:members="filteredMembers"
:roles="roleOptions"
:can-manage-users="canManageUsers"
:permission-denied-message="permissionDeniedMessage"
@update-role="updateMemberRole"
@resend-invite="resendInvite"
@cancel-invite="requestCancelInvite"
@remove-member="requestRemoveMember"
/>
<div class="flex flex-col gap-4">
<span class="m-0 text-2xl font-semibold text-contrast">
{{ formatMessage(messages.activityLogTitle) }}
</span>
<AuditLogTable
v-model:sort-direction="auditLogSortDirection"
v-model:timeframe-mode="auditLogTimeframeMode"
v-model:timeframe-preset="auditLogTimeframePreset"
v-model:timeframe-last-amount="auditLogTimeframeLastAmount"
v-model:timeframe-last-unit="auditLogTimeframeLastUnit"
v-model:timeframe-custom-start-date="auditLogTimeframeCustomStartDate"
v-model:timeframe-custom-end-date="auditLogTimeframeCustomEndDate"
:entries="auditEntries"
:has-active-external-filters="hasActiveAuditLogFilters"
:has-more="hasMoreActionLogEntries"
:loading="isActionLogFiltering"
:loading-more="isLoadingMoreActionLogEntries"
:show-world-column="showAuditLogInstances"
:suppress-row-transitions="isActionLogSortTransitioning"
@load-more="loadMoreActionLogEntries"
>
<template #filters>
<DropdownFilterBar
v-model="auditLogFilters"
:categories="auditLogFilterCategories"
:add-label="formatMessage(messages.addFilter)"
:clear-label="formatMessage(messages.clearFilters)"
:empty-options-label="formatMessage(messages.emptyFilterOptions)"
:empty-search-label="formatMessage(messages.emptyFilterSearch)"
apply-immediately
use-filter-icon
checkbox-position="right"
/>
</template>
</AuditLogTable>
</div>
<GrantAccessModal
ref="grantAccessModal"
:members="members"
:friend-ids="friendIds"
:search-users="searchInviteUsers"
:can-grant="canManageUsers"
:permission-denied-message="permissionDeniedMessage"
@grant="grantAccess"
/>
<RemoveAccessModal
ref="removeMemberConfirmModal"
:username="pendingRemovalMember?.user.username ?? ''"
:avatar-url="pendingRemovalMember?.user.avatarUrl"
:role="pendingRemovalMember?.role"
:joined-at="pendingRemovalMember?.joinedAt"
:pending="pendingRemovalMember?.pending"
:should-cancel="shouldCancelInvite"
:can-remove="canManageUsers"
:permission-denied-message="permissionDeniedMessage"
@remove="confirmAccessRemoval"
/>
</div>
</template>
<script setup lang="ts">
import type { Archon, Labrinth } from '@modrinth/api-client'
import { FilterIcon, SearchIcon, UserPlusIcon } from '@modrinth/assets'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref, watch } from 'vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import Combobox, { type ComboboxOption } from '#ui/components/base/Combobox.vue'
import DropdownFilterBar from '#ui/components/base/DropdownFilterBar.vue'
import StyledInput from '#ui/components/base/StyledInput.vue'
import {
AccessTable,
apiPermissionsToAccessRole,
AuditLogTable,
GrantAccessModal,
type GrantServerAccessPayload,
RemoveAccessModal,
type ServerAccessInviteSuggestion,
type ServerAccessMember,
type ServerAccessRole,
type ServerAccessRoleOption,
} from '#ui/components/servers/access'
import { useVIntl } from '#ui/composables/i18n'
import { useServerPermissions } from '#ui/composables/server-permissions'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '#ui/providers'
import { useAccessAuditLog } from './audit-log'
import { accessMessages } from './messages'
type RoleFilter = ServerAccessRole | 'all'
const props = withDefaults(
defineProps<{
showAuditLogInstances?: boolean
}>(),
{
showAuditLogInstances: false,
},
)
const showAuditLogInstances = computed(() => props.showAuditLogInstances)
const INVITE_RESEND_COOLDOWN_SECONDS = 2 * 60
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const { serverId, serverFull } = injectModrinthServerContext()
const { addNotification } = injectNotificationManager()
const queryClient = useQueryClient()
const grantAccessModal = ref<InstanceType<typeof GrantAccessModal> | null>(null)
const removeMemberConfirmModal = ref<InstanceType<typeof RemoveAccessModal> | null>(null)
const pendingRemovalMember = ref<ServerAccessMember | null>(null)
const shouldCancelInvite = ref(false)
const reinviteCooldownUntilByUserId = ref<Record<string, number | undefined>>({})
const editorScopes = [
'BASE_READ',
'POWER_ACTIONS',
'EXEC_COMMANDS',
'FILES_WRITE',
'SETUP',
'BACKUPS',
'ADVANCED',
] as const
const viewerScopes = ['BASE_READ', 'POWER_ACTIONS'] as const
const { canManageUsers, permissionDeniedMessage } = useServerPermissions()
const manageUsersActionTooltip = computed(() =>
canManageUsers.value ? undefined : permissionDeniedMessage.value,
)
const messages = accessMessages
const roleOptions = computed<ServerAccessRoleOption[]>(() => [
{
value: 'owner',
label: formatMessage(messages.ownerRole),
description: formatMessage(messages.ownerDescription),
},
{
value: 'editor',
label: formatMessage(messages.editorRole),
description: formatMessage(messages.editorDescription),
},
{
value: 'viewer',
label: formatMessage(messages.viewerRole),
description: formatMessage(messages.viewerDescription),
},
])
const roleFilterOptions = computed<ComboboxOption<RoleFilter>[]>(() => [
{ value: 'all', label: formatMessage(messages.allRoles) },
...roleOptions.value.map((role) => ({
value: role.value,
label: role.label,
})),
])
const selectedRoleFilterLabel = computed(() =>
formatMessage(messages.selectedRoleFilter, {
role:
roleFilterOptions.value.find((option) => option.value === roleFilter.value)?.label ??
formatMessage(messages.allRoles),
}),
)
const serverUsersQueryKey = ['servers', 'users', 'v1', serverId]
const serverUsersQuery = useQuery({
queryKey: serverUsersQueryKey,
queryFn: () => client.archon.server_users_v1.list(serverId),
})
const friendsQueryKey = ['user', 'friends', 'v3']
const friendsQuery = useQuery({
queryKey: friendsQueryKey,
queryFn: () => client.labrinth.friends_v3.list(),
staleTime: 30_000,
})
const friendIds = computed(() => getFriendRelationshipUserIds(friendsQuery.data.value ?? []))
const members = computed<ServerAccessMember[]>(() =>
(serverUsersQuery.data.value ?? [])
.map((serverUser) => {
const userId = serverUser.user.id
const username = serverUser.user.username || userId
const role = apiPermissionsToAccessRole(serverUser.permissions)
const nowReinviteAvailableAt = reinviteCooldownUntilByUserId.value[userId]
const apiReinviteAvailableAt = getInviteResendAvailableAt(serverUser.last_invite_sent)
const reinviteAvailableAt = [nowReinviteAvailableAt, apiReinviteAvailableAt].reduce(
(candidate, current) =>
candidate === undefined || (current !== undefined && current > candidate)
? current
: candidate,
)
return {
id: `${serverId}-${userId}`,
user: {
id: userId,
username,
avatarUrl: serverUser.user.avatar_url || undefined,
},
role,
joinedAt: serverUser.added_on ?? null,
pending: !serverUser.added_on,
inviteResendAvailableAt: reinviteAvailableAt
? new Date(reinviteAvailableAt).toISOString()
: undefined,
isOwner: role === 'owner',
}
})
.sort((a, b) => {
const ownerSort = Number(b.isOwner) - Number(a.isOwner)
return ownerSort === 0 ? a.user.username.localeCompare(b.user.username) : ownerSort
}),
)
const memberSearch = ref('')
const roleFilter = ref<RoleFilter>('all')
const {
auditEntries,
auditLogFilterCategories,
auditLogFilters,
auditLogSortDirection,
auditLogTimeframeCustomEndDate,
auditLogTimeframeCustomStartDate,
auditLogTimeframeLastAmount,
auditLogTimeframeLastUnit,
auditLogTimeframeMode,
auditLogTimeframePreset,
hasActiveAuditLogFilters,
hasMoreActionLogEntries,
invalidateActionLog,
isActionLogFiltering,
isActionLogSortTransitioning,
isLoadingMoreActionLogEntries,
loadMoreActionLogEntries,
} = useAccessAuditLog({
client,
serverId,
serverFull,
showAuditLogInstances,
addNotification,
})
const filteredMembers = computed(() => {
const normalizedSearch = memberSearch.value.trim().toLowerCase()
return members.value.filter((member) => {
if (roleFilter.value !== 'all' && member.role !== roleFilter.value) return false
if (!normalizedSearch) return true
const roleLabel = formatRole(member.role)
const pendingLabel = member.pending ? 'pending' : ''
return [member.user.username, roleLabel, pendingLabel].some((value) =>
value.toLowerCase().includes(normalizedSearch),
)
})
})
function formatRole(role: ServerAccessRole) {
return roleOptions.value.find((option) => option.value === role)?.label ?? role
}
const hasShownLoadError = ref(false)
watch(
() => serverUsersQuery.error.value,
(serverUsersError) => {
if (hasShownLoadError.value || !serverUsersError) return
hasShownLoadError.value = true
addNotification({
type: 'error',
title: formatMessage(messages.loadFailedTitle),
text: formatErrorMessage(serverUsersError) ?? formatMessage(messages.loadFailedText),
})
},
)
function accessRoleToApiRole(
role: Exclude<ServerAccessRole, 'owner'>,
): Archon.ServerUsers.v1.AssignableServerUserRole {
switch (role) {
case 'editor':
return 'Editor'
case 'viewer':
return 'Viewer'
}
}
function accessRoleToApiPermissions(role: Exclude<ServerAccessRole, 'owner'>) {
switch (role) {
case 'editor':
return serializeUserScope(editorScopes)
case 'viewer':
return serializeUserScope(viewerScopes)
}
}
function serializeUserScope(scopes: readonly string[]): Archon.ServerUsers.v1.UserScope {
return scopes.join(' | ')
}
function formatErrorMessage(error: unknown): string | undefined {
return error instanceof Error ? error.message : undefined
}
function isSuppressedFriendRequestError(error: unknown) {
return getErrorMessageParts(error).some((message) => {
const normalizedMessage = message.toLowerCase()
return (
normalizedMessage.includes('you are already friends with this user') ||
normalizedMessage.includes('you cannot add yourself as a friend') ||
normalizedMessage.includes('you cannot accept your own friend request')
)
})
}
function getErrorMessageParts(error: unknown): string[] {
const errorMessages: string[] = []
if (error instanceof Error) {
errorMessages.push(error.message)
}
if (!error || typeof error !== 'object') return errorMessages
const record = error as Record<string, unknown>
pushErrorDescription(errorMessages, record.responseData)
pushErrorDescription(errorMessages, record.v1Error)
return errorMessages
}
function pushErrorDescription(errorMessages: string[], value: unknown) {
if (!value || typeof value !== 'object') return
const record = value as Record<string, unknown>
if (typeof record.description === 'string') {
errorMessages.push(record.description)
}
}
async function invalidateServerUsers() {
await queryClient.invalidateQueries({ queryKey: serverUsersQueryKey })
}
function setCachedMemberRole(member: ServerAccessMember, role: Exclude<ServerAccessRole, 'owner'>) {
const normalizedUserId = member.user.id.toLowerCase()
const normalizedUsername = member.user.username.toLowerCase()
queryClient.setQueryData<Archon.ServerUsers.v1.ServerUser[]>(serverUsersQueryKey, (serverUsers) =>
serverUsers?.map((serverUser) => {
const isTargetUser =
serverUser.user.id.toLowerCase() === normalizedUserId ||
serverUser.user.username.toLowerCase() === normalizedUsername
return isTargetUser
? { ...serverUser, permissions: accessRoleToApiPermissions(role) }
: serverUser
}),
)
}
function findMemberByTarget(target: string) {
const normalizedTarget = target.trim().toLowerCase()
return members.value.find(
(member) =>
member.user.username.toLowerCase() === normalizedTarget ||
member.user.id.toLowerCase() === normalizedTarget,
)
}
async function searchInviteUsers(query: string): Promise<ServerAccessInviteSuggestion[]> {
const users = await client.labrinth.users_v3.search(query)
return users.map((user) => ({
id: user.id,
username: user.username,
avatarUrl: user.avatar_url || undefined,
}))
}
function resolveMemberUserId(member: ServerAccessMember): string {
return member.user.id
}
function getInviteResendAvailableAt(lastInviteSent: string | null | undefined): number | undefined {
if (!lastInviteSent) return undefined
const lastInviteSentAt = new Date(lastInviteSent).getTime()
if (Number.isNaN(lastInviteSentAt)) return undefined
return lastInviteSentAt + INVITE_RESEND_COOLDOWN_SECONDS * 1000
}
function setReinviteCooldown(member: ServerAccessMember, cooldownSeconds: number | null) {
if (!cooldownSeconds) {
reinviteCooldownUntilByUserId.value[member.user.id] = undefined
return
}
reinviteCooldownUntilByUserId.value[member.user.id] = Date.now() + cooldownSeconds * 1000
}
async function updateMemberRole(member: ServerAccessMember, role: ServerAccessRole) {
if (!canManageUsers.value || member.isOwner || member.role === role || role === 'owner') return
const previousRole = member.role
if (previousRole === 'owner') return
await queryClient.cancelQueries({ queryKey: serverUsersQueryKey })
setCachedMemberRole(member, role)
try {
const userId = await resolveMemberUserId(member)
await client.archon.server_users_v1.update(serverId, userId, accessRoleToApiRole(role))
} catch (error) {
setCachedMemberRole(member, previousRole)
addNotification({
type: 'error',
title: formatMessage(messages.roleUpdateFailedTitle),
text: formatErrorMessage(error),
})
return
}
await invalidateServerUsers()
await invalidateActionLog()
}
async function resendInvite(member: ServerAccessMember) {
if (!canManageUsers.value || !member.pending || member.role === 'owner') return
try {
const result = await client.archon.server_users_v1.reinvite(serverId, member.user.id)
setReinviteCooldown(member, result.cooldown_seconds)
if (!result.sent) return
await invalidateServerUsers()
await invalidateActionLog()
addNotification({
type: 'success',
title: formatMessage(messages.inviteResentTitle),
text: formatMessage(messages.inviteResentText, {
target: member.user.username,
}),
})
} catch (error) {
addNotification({
type: 'error',
title: formatMessage(messages.inviteFailedTitle),
text: formatErrorMessage(error),
})
}
}
async function cancelInvite(member: ServerAccessMember) {
await removeMemberAccess(member, true)
}
function requestRemoveMember(member: ServerAccessMember) {
if (!canManageUsers.value) return
pendingRemovalMember.value = member
shouldCancelInvite.value = false
removeMemberConfirmModal.value?.show()
}
function requestCancelInvite(member: ServerAccessMember) {
if (!canManageUsers.value) return
pendingRemovalMember.value = member
shouldCancelInvite.value = true
removeMemberConfirmModal.value?.show()
}
async function confirmAccessRemoval() {
const member = pendingRemovalMember.value
const shouldCancel = shouldCancelInvite.value
pendingRemovalMember.value = null
shouldCancelInvite.value = false
if (!member) return
if (!canManageUsers.value) return
if (shouldCancel) {
await cancelInvite(member)
return
}
await removeMember(member)
}
async function removeMember(member: ServerAccessMember) {
await removeMemberAccess(member, false)
}
async function removeMemberAccess(member: ServerAccessMember, shouldCancel: boolean) {
if (!canManageUsers.value) return
try {
const userId = await resolveMemberUserId(member)
await client.archon.server_users_v1.delete(serverId, userId)
await invalidateServerUsers()
await invalidateActionLog()
addNotification({
type: 'success',
title: formatMessage(
shouldCancel ? messages.inviteCancelledTitle : messages.memberRemovedTitle,
),
text: formatMessage(
shouldCancel ? messages.inviteCancelledText : messages.memberRemovedText,
{
target: member.user.username,
},
),
})
} catch (error) {
addNotification({
type: 'error',
title: formatMessage(messages.removeFailedTitle),
text: formatErrorMessage(error),
})
}
}
async function grantAccess(payload: GrantServerAccessPayload) {
if (!canManageUsers.value) return
const target = payload.target.trim()
if (!target) return
const user = payload.user
const existingMember =
findMemberByTarget(user.id) ?? findMemberByTarget(user.username) ?? findMemberByTarget(target)
if (existingMember) {
await updateMemberRole(existingMember, payload.role)
if (payload.addAsFriend) {
await sendFriendRequest(user.id)
}
return
}
try {
await client.archon.server_users_v1.add(serverId, {
user_id: user.id,
role: accessRoleToApiRole(payload.role),
})
await invalidateServerUsers()
await invalidateActionLog()
addNotification({
type: 'success',
title: formatMessage(messages.inviteSentTitle),
text: formatMessage(messages.inviteSentText, {
target: user.username,
role: formatRole(payload.role),
}),
})
if (payload.addAsFriend) await sendFriendRequest(user.id)
} catch (error) {
addNotification({
type: 'error',
title: formatMessage(messages.inviteFailedTitle),
text: formatErrorMessage(error),
})
}
}
async function sendFriendRequest(userIdOrUsername: string) {
const friends = await queryClient.ensureQueryData({
queryKey: friendsQueryKey,
queryFn: () => client.labrinth.friends_v3.list(),
})
if (hasFriendRelationship(friends, userIdOrUsername)) return
try {
await client.labrinth.friends_v3.add(userIdOrUsername)
await queryClient.invalidateQueries({ queryKey: friendsQueryKey })
} catch (error) {
if (isSuppressedFriendRequestError(error)) return
addNotification({
type: 'error',
title: formatMessage(messages.friendRequestFailedTitle),
text: formatErrorMessage(error),
})
}
}
function hasFriendRelationship(friends: Labrinth.Friends.v3.UserFriend[], userId: string) {
return friends.some((friend) => friend.id === userId || friend.friend_id === userId)
}
function getFriendRelationshipUserIds(friends: Labrinth.Friends.v3.UserFriend[]) {
return [...new Set(friends.flatMap((friend) => [friend.id, friend.friend_id]))]
}
</script>
@@ -0,0 +1,348 @@
import type { Archon } from '@modrinth/api-client'
import type { IconComponent } from '@modrinth/assets'
import {
DatabaseBackupIcon,
FileIcon,
PackageIcon,
PowerIcon,
ServerIcon,
SettingsIcon,
UsersIcon,
} from '@modrinth/assets'
import type { DropdownFilterBarOption } from '#ui/components/base/DropdownFilterBar.vue'
import type {
TimeFrameLastUnit,
TimeFrameMode,
TimeFramePreset,
} from '#ui/components/base/TimeFramePicker.vue'
import { defineMessage, type MessageDescriptor } from '#ui/composables/i18n'
export const SUPPORT_ACTION_LOG_USER_FILTER = 'support'
export const SERVER_SCOPED_ACTION_LOG_WORLD_FILTER = '__server_scoped__'
export const actionLogActionNames = [
'server_created',
'changed_server_name',
'changed_server_subdomain',
'server_reallocated',
'server_plan_changed',
'user_invited',
'user_invite_revoked',
'user_permission_modified',
'user_removed',
'addon_added',
'addon_uploaded',
'addon_disabled',
'addon_enabled',
'addon_deleted',
'addon_updated',
'modpack_changed',
'modpack_unlinked',
'server_repaired',
'server_reset',
'server_started',
'server_stopped',
'server_restarted',
'server_killed',
'port_allocation_added',
'port_allocation_removed',
'loader_version_edited',
'game_version_edited',
'server_properties_modified',
'file_uploaded',
'file_deleted',
'file_renamed',
'file_edited',
'sftp_login',
'console_command_executed',
'console_cleared',
'backup_created',
'backup_renamed',
'backup_restored',
'backup_deleted',
'startup_command_modified',
'java_runtime_modified',
'java_version_modified',
] as const satisfies readonly Archon.Actions.v1.ActionName[]
export type ActionLogFilterActionName = (typeof actionLogActionNames)[number]
const actionLogActionNameSet = new Set<string>(actionLogActionNames)
export const actionLogActionGroups = [
{
key: 'server',
label: defineMessage({
id: 'servers.access-page.activity-log-filter.action-group.server',
defaultMessage: 'Server',
}),
icon: ServerIcon,
actions: [
'server_created',
'server_reallocated',
'server_plan_changed',
'server_repaired',
'server_reset',
],
},
{
key: 'power-console',
label: defineMessage({
id: 'servers.access-page.activity-log-filter.action-group.power-console',
defaultMessage: 'Power and console',
}),
icon: PowerIcon,
actions: [
'server_started',
'server_stopped',
'server_restarted',
'server_killed',
'console_command_executed',
'console_cleared',
],
},
{
key: 'users',
label: defineMessage({
id: 'servers.access-page.activity-log-filter.action-group.users',
defaultMessage: 'Users and invites',
}),
icon: UsersIcon,
actions: ['user_invited', 'user_invite_revoked', 'user_permission_modified', 'user_removed'],
},
{
key: 'content',
label: defineMessage({
id: 'servers.access-page.activity-log-filter.action-group.content',
defaultMessage: 'Content and modpack',
}),
icon: PackageIcon,
actions: [
'addon_added',
'addon_uploaded',
'addon_disabled',
'addon_enabled',
'addon_updated',
'addon_deleted',
'modpack_changed',
'modpack_unlinked',
],
},
{
key: 'files',
label: defineMessage({
id: 'servers.access-page.activity-log-filter.action-group.files',
defaultMessage: 'Files and SFTP',
}),
icon: FileIcon,
actions: ['file_uploaded', 'file_edited', 'file_renamed', 'file_deleted', 'sftp_login'],
},
{
key: 'backups',
label: defineMessage({
id: 'servers.access-page.activity-log-filter.action-group.backups',
defaultMessage: 'Backups',
}),
icon: DatabaseBackupIcon,
actions: ['backup_created', 'backup_renamed', 'backup_restored', 'backup_deleted'],
},
{
key: 'settings',
label: defineMessage({
id: 'servers.access-page.activity-log-filter.action-group.settings',
defaultMessage: 'Settings and runtime',
}),
icon: SettingsIcon,
actions: [
'changed_server_name',
'changed_server_subdomain',
'port_allocation_added',
'port_allocation_removed',
'loader_version_edited',
'game_version_edited',
'server_properties_modified',
'startup_command_modified',
'java_runtime_modified',
'java_version_modified',
],
},
] as const satisfies readonly {
key: string
label: MessageDescriptor
icon: IconComponent
actions: readonly ActionLogFilterActionName[]
}[]
export type AuditLogTimeframeSelection = {
mode: TimeFrameMode
preset: TimeFramePreset
lastAmount: number
lastUnit: TimeFrameLastUnit
customStartDate: string
customEndDate: string
}
export function isActionLogActionName(action: string): action is ActionLogFilterActionName {
return actionLogActionNameSet.has(action)
}
export function compareFilterOptions(
left: DropdownFilterBarOption,
right: DropdownFilterBarOption,
) {
return left.label.localeCompare(right.label)
}
export function getAuditLogTimeframeRange(
selection: AuditLogTimeframeSelection,
): { start: Date; end: Date } | null {
const now = getRoundedNow()
if (selection.mode === 'last') {
return getLastAuditLogTimeframeRange(selection.lastAmount, selection.lastUnit, now)
}
if (selection.mode === 'custom_range') {
const startDate = parseDateInputValue(selection.customStartDate)
const endDate = parseDateInputValue(selection.customEndDate)
if (!startDate || !endDate) return null
const [minDate, maxDate] =
startDate.getTime() > endDate.getTime() ? [endDate, startDate] : [startDate, endDate]
return {
start: startOfDay(minDate),
end: endOfDay(maxDate),
}
}
if (selection.mode !== 'preset') {
return null
}
return getPresetAuditLogTimeframeRange(selection.preset, now)
}
export function getActionLogEntryId(entry: Archon.Actions.v1.ActionEntry) {
return JSON.stringify([
entry.timestamp,
entry.actor.type,
entry.actor.type === 'user' ? entry.actor.user_id : (entry.actor.user_id ?? 'support'),
entry.server_id,
entry.world_id ?? null,
entry.action.action,
stableStringify(entry.action.metadata),
])
}
function parseDateInputValue(value: string) {
const [yearValue, monthValue, dayValue] = value.split('-').map(Number)
if (!yearValue || !monthValue || !dayValue) return null
const date = new Date(yearValue, monthValue - 1, dayValue)
if (
date.getFullYear() !== yearValue ||
date.getMonth() !== monthValue - 1 ||
date.getDate() !== dayValue
) {
return null
}
return date
}
function addDays(date: Date, days: number) {
const nextDate = new Date(date)
nextDate.setDate(nextDate.getDate() + days)
return nextDate
}
function subtractCalendarMonths(date: Date, months: number) {
const nextDate = new Date(date)
const day = nextDate.getDate()
nextDate.setDate(1)
nextDate.setMonth(nextDate.getMonth() - months)
const daysInMonth = new Date(nextDate.getFullYear(), nextDate.getMonth() + 1, 0).getDate()
nextDate.setDate(Math.min(day, daysInMonth))
return nextDate
}
function getRoundedNow() {
const now = Date.now()
return new Date(Math.floor(now / 60000) * 60000)
}
function getPresetAuditLogTimeframeRange(
preset: TimeFramePreset,
now: Date,
): { start: Date; end: Date } | null {
switch (preset) {
case 'today':
return { start: startOfDay(now), end: endOfDay(now) }
case 'yesterday': {
const yesterday = addDays(now, -1)
return { start: startOfDay(yesterday), end: endOfDay(yesterday) }
}
case 'last_7_days':
return { start: startOfDay(addDays(now, -6)), end: endOfDay(now) }
case 'last_14_days':
return { start: startOfDay(addDays(now, -13)), end: endOfDay(now) }
case 'last_30_days':
return { start: startOfDay(addDays(now, -29)), end: endOfDay(now) }
case 'last_90_days':
return { start: startOfDay(addDays(now, -89)), end: endOfDay(now) }
case 'last_180_days':
return { start: startOfDay(addDays(now, -179)), end: endOfDay(now) }
case 'year_to_date':
return { start: new Date(now.getFullYear(), 0, 1), end: endOfDay(now) }
case 'all_time':
return null
}
}
function getLastAuditLogTimeframeRange(
amountValue: number,
unit: TimeFrameLastUnit,
now: Date,
): { start: Date; end: Date } {
const amount = Math.max(1, Math.floor(amountValue))
switch (unit) {
case 'hours':
return { start: new Date(now.getTime() - amount * 60 * 60 * 1000), end: now }
case 'days':
return { start: new Date(now.getTime() - amount * 24 * 60 * 60 * 1000), end: now }
case 'weeks':
return { start: new Date(now.getTime() - amount * 7 * 24 * 60 * 60 * 1000), end: now }
case 'months':
return { start: subtractCalendarMonths(now, amount), end: now }
}
}
function startOfDay(date: Date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate())
}
function endOfDay(date: Date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59, 999)
}
function stableStringify(value: unknown): string {
if (value === undefined) {
return 'undefined'
}
if (value === null || typeof value !== 'object') {
return JSON.stringify(value) ?? String(value)
}
if (Array.isArray(value)) {
return `[${value.map((item) => stableStringify(item)).join(',')}]`
}
return `{${Object.entries(value as Record<string, unknown>)
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
.join(',')}}`
}
@@ -0,0 +1,496 @@
import type { AbstractModrinthClient, Archon } from '@modrinth/api-client'
import { useInfiniteQuery, useQueryClient } from '@tanstack/vue-query'
import type { ComputedRef } from 'vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import type {
DropdownFilterBarCategory,
DropdownFilterBarItem,
DropdownFilterBarOption,
} from '#ui/components/base/DropdownFilterBar.vue'
import type {
TimeFrameLastUnit,
TimeFrameMode,
TimeFramePreset,
} from '#ui/components/base/TimeFramePicker.vue'
import type { ServerAuditLogEntry } from '#ui/components/servers/access'
import { parseAuditEvent } from '#ui/components/servers/access/events'
import { useVIntl } from '#ui/composables/i18n'
import type { AbstractWebNotificationManager } from '#ui/providers/web-notifications'
import {
actionLogActionGroups,
type ActionLogFilterActionName,
compareFilterOptions,
getActionLogEntryId,
getAuditLogTimeframeRange,
isActionLogActionName,
SERVER_SCOPED_ACTION_LOG_WORLD_FILTER,
SUPPORT_ACTION_LOG_USER_FILTER,
} from './audit-log-utils'
import { accessMessages, actionLogActionMessages } from './messages'
type AuditLogFilterKey = 'users' | 'worlds' | 'actions'
type UseAccessAuditLogOptions = {
client: AbstractModrinthClient
serverId: string
serverFull: ComputedRef<Archon.Servers.v1.ServerFull | null>
showAuditLogInstances: ComputedRef<boolean>
addNotification: AbstractWebNotificationManager['addNotification']
}
const ACTION_LOG_PAGE_SIZE = 200
const ACTION_LOG_FILTER_OVERLAY_MS = 750
export function useAccessAuditLog({
client,
serverId,
serverFull,
showAuditLogInstances,
addNotification,
}: UseAccessAuditLogOptions) {
const { formatMessage } = useVIntl()
const queryClient = useQueryClient()
const auditLogFilters = ref<Record<string, string[]>>({
users: [],
worlds: [],
actions: [],
})
const auditLogTimeframeMode = ref<TimeFrameMode>('preset')
const auditLogTimeframePreset = ref<TimeFramePreset>('last_7_days')
const auditLogTimeframeLastAmount = ref(30)
const auditLogTimeframeLastUnit = ref<TimeFrameLastUnit>('days')
const auditLogTimeframeCustomStartDate = ref('')
const auditLogTimeframeCustomEndDate = ref('')
const auditLogSortDirection = ref<Archon.Actions.v1.SortOrder>('desc')
const worldOptions = computed(
() => serverFull.value?.worlds.map((world) => ({ id: world.id, name: world.name })) ?? [],
)
const isAuditLogWorldFilterVisible = computed(
() => showAuditLogInstances.value && worldOptions.value.length > 0,
)
const worldById = computed(
() => new Map(worldOptions.value.map((world) => [world.id, world] as const)),
)
const backupById = computed(() => {
const backups = new Map<string, Archon.Backups.v1.Backup>()
for (const world of serverFull.value?.worlds ?? []) {
for (const backup of world.backups ?? []) {
backups.set(backup.id, backup)
}
}
return backups
})
const actionLogDateFilter = computed(() => {
const range = getAuditLogTimeframeRange({
mode: auditLogTimeframeMode.value,
preset: auditLogTimeframePreset.value,
lastAmount: auditLogTimeframeLastAmount.value,
lastUnit: auditLogTimeframeLastUnit.value,
customStartDate: auditLogTimeframeCustomStartDate.value,
customEndDate: auditLogTimeframeCustomEndDate.value,
})
return {
min_datetime: range?.start.toISOString(),
max_datetime: range?.end.toISOString(),
}
})
const actionLogEndpointFilter = computed<Archon.Actions.v1.ActionLogFilter | undefined>(() => {
const users = selectedAuditLogFilterValues('users')
const worlds = isAuditLogWorldFilterVisible.value ? selectedAuditLogWorldFilterValues() : []
const actions = selectedAuditLogFilterValues('actions').filter(isActionLogActionName)
const filter: Archon.Actions.v1.ActionLogFilter = {}
if (users.length > 0) filter.users = users
if (worlds.length > 0) filter.worlds = worlds
if (actions.length > 0) filter.actions = actions
return Object.keys(filter).length > 0 ? filter : undefined
})
const actionLogBaseQueryKey = ['servers', 'action-log', 'v1', 'infinite', serverId] as const
const actionLogQueryKey = computed(() => {
const filter = actionLogEndpointFilter.value
const dateFilter = actionLogDateFilter.value
return [
...actionLogBaseQueryKey,
filter ?? null,
dateFilter.min_datetime ?? null,
dateFilter.max_datetime ?? null,
auditLogSortDirection.value,
]
})
const actionLogQuery = useInfiniteQuery({
queryKey: actionLogQueryKey,
queryFn: ({ pageParam = 0 }) => {
const offset = typeof pageParam === 'number' ? pageParam : 0
return client.archon.actions_v1.list(serverId, {
limit: ACTION_LOG_PAGE_SIZE,
offset,
order: auditLogSortDirection.value,
filter: actionLogEndpointFilter.value,
...actionLogDateFilter.value,
})
},
getNextPageParam: (lastPage) =>
typeof lastPage.next_offset === 'number' ? lastPage.next_offset : undefined,
initialPageParam: 0,
placeholderData: (previousData) => previousData,
staleTime: 30_000,
})
const actionLogFilterSignature = computed(() =>
JSON.stringify([
actionLogEndpointFilter.value ?? null,
actionLogDateFilter.value.min_datetime ?? null,
actionLogDateFilter.value.max_datetime ?? null,
]),
)
const isActionLogFilterTransitioning = ref(false)
const isActionLogSortTransitioning = ref(false)
let actionLogFilterTransitionTimeout: ReturnType<typeof setTimeout> | null = null
let actionLogSortTransitionTimeout: ReturnType<typeof setTimeout> | null = null
watch(actionLogFilterSignature, (_signature, previousSignature) => {
if (previousSignature === undefined) return
startActionLogFilterTransition()
})
watch(
auditLogSortDirection,
(_direction, previousDirection) => {
if (previousDirection === undefined) return
startActionLogSortTransition()
},
{ flush: 'sync' },
)
watch(
() => actionLogQuery.isFetching.value,
(isFetching) => {
if (!isFetching && isActionLogSortTransitioning.value) {
finishActionLogSortTransition()
}
},
{ flush: 'post' },
)
onBeforeUnmount(() => {
if (actionLogFilterTransitionTimeout) {
clearTimeout(actionLogFilterTransitionTimeout)
}
if (actionLogSortTransitionTimeout) {
clearTimeout(actionLogSortTransitionTimeout)
}
})
const auditEntries = computed<ServerAuditLogEntry[]>(() => {
const pages = actionLogQuery.data.value?.pages ?? []
const entryIdCounts = new Map<string, number>()
return pages.flatMap((actionLog) =>
actionLog.data.map((entry) => {
const entryId = getActionLogEntryId(entry)
const entryIdCount = entryIdCounts.get(entryId) ?? 0
entryIdCounts.set(entryId, entryIdCount + 1)
return apiActionLogEntryToAuditEntry(
entry,
actionLog,
entryIdCount === 0 ? entryId : `${entryId}-${entryIdCount}`,
)
}),
)
})
const hasShownActionLogLoadError = ref(false)
const hasMoreActionLogEntries = computed(
() => !actionLogQuery.isPlaceholderData.value && actionLogQuery.hasNextPage.value,
)
const isLoadingMoreActionLogEntries = computed(() => actionLogQuery.isFetchingNextPage.value)
const isActionLogFiltering = computed(() => isActionLogFilterTransitioning.value)
const initialAuditLogUserFilterOptions = ref<DropdownFilterBarOption[]>([])
watch(
() => actionLogQuery.data.value?.pages ?? [],
(pages) => {
if (actionLogEndpointFilter.value) return
initialAuditLogUserFilterOptions.value = mergeAuditLogUserFilterOptions(
initialAuditLogUserFilterOptions.value,
extractAuditLogUserFilterOptions(pages),
)
},
{ immediate: true },
)
const auditLogUserFilterOptions = computed<DropdownFilterBarOption[]>(() => {
if (initialAuditLogUserFilterOptions.value.length > 0) {
return initialAuditLogUserFilterOptions.value
}
return extractAuditLogUserFilterOptions(actionLogQuery.data.value?.pages ?? [])
})
const auditLogWorldFilterOptions = computed<DropdownFilterBarOption[]>(() => [
{
value: SERVER_SCOPED_ACTION_LOG_WORLD_FILTER,
label: formatMessage(accessMessages.serverScopedInstance),
searchTerms: [
SERVER_SCOPED_ACTION_LOG_WORLD_FILTER,
formatMessage(accessMessages.serverScopedInstance),
],
},
...worldOptions.value.map((world) => ({
value: world.id,
label: world.name,
searchTerms: [world.id, world.name],
})),
])
const auditLogActionFilterOptions = computed<DropdownFilterBarItem[]>(() =>
actionLogActionGroups.flatMap((group) => [
{
type: 'section-header' as const,
key: group.key,
label: formatMessage(group.label),
icon: group.icon,
},
...group.actions.map((action) => ({
value: action,
label: formatActionLogAction(action),
searchTerms: [action, action.replaceAll('_', ' ')],
})),
]),
)
const auditLogFilterCategories = computed<DropdownFilterBarCategory[]>(() => {
const categories: DropdownFilterBarCategory[] = [
{
key: 'users',
label: formatMessage(accessMessages.userFilter),
options: auditLogUserFilterOptions.value,
},
]
if (isAuditLogWorldFilterVisible.value) {
categories.push({
key: 'worlds',
label: formatMessage(accessMessages.instanceFilter),
options: auditLogWorldFilterOptions.value,
})
}
categories.push({
key: 'actions',
label: formatMessage(accessMessages.actionTypeFilter),
options: auditLogActionFilterOptions.value,
searchable: true,
searchPlaceholder: formatMessage(accessMessages.actionTypeFilterSearch),
submenuClass: 'w-[22rem]',
previewDropdownMinWidth: '20rem',
})
return categories
})
const hasActiveAuditLogDateFilter = computed(
() => !!actionLogDateFilter.value.min_datetime || !!actionLogDateFilter.value.max_datetime,
)
const hasActiveAuditLogFilters = computed(
() =>
hasActiveAuditLogDateFilter.value ||
(isAuditLogWorldFilterVisible.value
? (['users', 'worlds', 'actions'] satisfies AuditLogFilterKey[])
: (['users', 'actions'] satisfies AuditLogFilterKey[])
).some((key) => selectedAuditLogFilterValues(key).length > 0),
)
watch(
() => actionLogQuery.error.value,
(actionLogError) => {
if (hasShownActionLogLoadError.value || !actionLogError) return
hasShownActionLogLoadError.value = true
addNotification({
type: 'error',
title: formatMessage(accessMessages.loadFailedTitle),
text: formatErrorMessage(actionLogError) ?? formatMessage(accessMessages.loadFailedText),
})
},
)
function selectedAuditLogFilterValues(key: AuditLogFilterKey): string[] {
const values = auditLogFilters.value[key]
return values ? [...values] : []
}
function selectedAuditLogWorldFilterValues(): Array<string | null> {
return selectedAuditLogFilterValues('worlds').map((world) =>
world === SERVER_SCOPED_ACTION_LOG_WORLD_FILTER ? null : world,
)
}
function extractAuditLogUserFilterOptions(
pages: Archon.Actions.v1.ActionLogResponse[],
): DropdownFilterBarOption[] {
const options = new Map<string, DropdownFilterBarOption>()
for (const page of pages) {
for (const entry of page.data) {
if (entry.actor.type === 'support') {
const userId = entry.actor.user_id ?? null
const user = userId ? page.users[userId] : undefined
if (!options.has(SUPPORT_ACTION_LOG_USER_FILTER)) {
options.set(SUPPORT_ACTION_LOG_USER_FILTER, {
value: SUPPORT_ACTION_LOG_USER_FILTER,
label: formatMessage(accessMessages.supportActor),
searchTerms: [
SUPPORT_ACTION_LOG_USER_FILTER,
formatMessage(accessMessages.supportActor),
userId,
user?.username,
].filter(Boolean) as string[],
})
}
continue
}
const id = entry.actor.user_id
const user = page.users[id]
if (!options.has(id)) {
options.set(id, {
value: id,
label: user?.username ?? id,
searchTerms: [id, user?.username].filter(Boolean) as string[],
})
}
}
}
return [...options.values()].sort(compareFilterOptions)
}
function mergeAuditLogUserFilterOptions(
existingOptions: DropdownFilterBarOption[],
nextOptions: DropdownFilterBarOption[],
): DropdownFilterBarOption[] {
const options = new Map(existingOptions.map((option) => [option.value, option] as const))
for (const option of nextOptions) {
options.set(option.value, option)
}
return [...options.values()].sort(compareFilterOptions)
}
function formatActionLogAction(action: ActionLogFilterActionName): string {
return formatMessage(actionLogActionMessages[action])
}
function loadMoreActionLogEntries() {
if (
isActionLogFilterTransitioning.value ||
actionLogQuery.isPlaceholderData.value ||
!actionLogQuery.hasNextPage.value ||
actionLogQuery.isFetchingNextPage.value
) {
return
}
void actionLogQuery.fetchNextPage()
}
function startActionLogFilterTransition() {
isActionLogFilterTransitioning.value = true
if (actionLogFilterTransitionTimeout) {
clearTimeout(actionLogFilterTransitionTimeout)
}
actionLogFilterTransitionTimeout = setTimeout(() => {
isActionLogFilterTransitioning.value = false
actionLogFilterTransitionTimeout = null
}, ACTION_LOG_FILTER_OVERLAY_MS)
}
function startActionLogSortTransition() {
isActionLogSortTransitioning.value = true
if (actionLogSortTransitionTimeout) {
clearTimeout(actionLogSortTransitionTimeout)
}
actionLogSortTransitionTimeout = setTimeout(() => {
isActionLogSortTransitioning.value = false
actionLogSortTransitionTimeout = null
}, 2500)
}
function finishActionLogSortTransition() {
if (actionLogSortTransitionTimeout) {
clearTimeout(actionLogSortTransitionTimeout)
}
actionLogSortTransitionTimeout = setTimeout(() => {
isActionLogSortTransitioning.value = false
actionLogSortTransitionTimeout = null
}, 120)
}
function apiActionLogEntryToAuditEntry(
entry: Archon.Actions.v1.ActionEntry,
actionLog: Archon.Actions.v1.ActionLogResponse,
id: string,
): ServerAuditLogEntry {
const event = parseAuditEvent(entry, {
serverId,
users: actionLog.users,
addons: actionLog.addons,
worldById: worldById.value,
backupById: backupById.value,
versions: actionLog.versions ?? {},
})
return {
id,
actor: event.props.actor,
world: event.props.world,
event,
timestamp: entry.timestamp,
}
}
async function invalidateActionLog() {
await queryClient.invalidateQueries({ queryKey: actionLogBaseQueryKey })
}
return {
auditEntries,
auditLogFilterCategories,
auditLogFilters,
auditLogSortDirection,
auditLogTimeframeCustomEndDate,
auditLogTimeframeCustomStartDate,
auditLogTimeframeLastAmount,
auditLogTimeframeLastUnit,
auditLogTimeframeMode,
auditLogTimeframePreset,
hasActiveAuditLogFilters,
hasMoreActionLogEntries,
invalidateActionLog,
isActionLogFiltering,
isActionLogSortTransitioning,
isLoadingMoreActionLogEntries,
loadMoreActionLogEntries,
}
}
function formatErrorMessage(error: unknown): string | undefined {
return error instanceof Error ? error.message : undefined
}
@@ -0,0 +1,315 @@
import { defineMessages } from '#ui/composables/i18n'
export const accessMessages = defineMessages({
searchUsersPlaceholder: {
id: 'servers.access-page.search-users-placeholder',
defaultMessage: 'Search {count} {count, plural, one {user} other {users}}...',
},
inviteFriends: {
id: 'servers.access-page.invite-friends',
defaultMessage: 'Add user',
},
activityLogTitle: {
id: 'servers.access-page.activity-log-title',
defaultMessage: 'Activity log',
},
addFilter: {
id: 'servers.access-page.activity-log-filter.add',
defaultMessage: 'Add filter',
},
clearFilters: {
id: 'servers.access-page.activity-log-filter.clear',
defaultMessage: 'Clear filters',
},
emptyFilterOptions: {
id: 'servers.access-page.activity-log-filter.empty-options',
defaultMessage: 'No options available.',
},
emptyFilterSearch: {
id: 'servers.access-page.activity-log-filter.empty-search',
defaultMessage: 'No options found.',
},
userFilter: {
id: 'servers.access-page.activity-log-filter.users',
defaultMessage: 'User',
},
supportActor: {
id: 'servers.access-page.activity-log-filter.support-actor',
defaultMessage: 'Support',
},
instanceFilter: {
id: 'servers.access-page.activity-log-filter.instances',
defaultMessage: 'Instances',
},
serverScopedInstance: {
id: 'servers.access-page.activity-log-filter.server-scoped-instance',
defaultMessage: 'Server',
},
actionTypeFilter: {
id: 'servers.access-page.activity-log-filter.action-types',
defaultMessage: 'Actions',
},
actionTypeFilterSearch: {
id: 'servers.access-page.activity-log-filter.action-types-search',
defaultMessage: 'Search actions...',
},
allRoles: {
id: 'servers.access-page.role-filter.all',
defaultMessage: 'All',
},
selectedRoleFilter: {
id: 'servers.access-page.role-filter.selected',
defaultMessage: 'Role: {role}',
},
ownerRole: {
id: 'servers.access-page.role.owner',
defaultMessage: 'Owner',
},
ownerDescription: {
id: 'servers.access-page.role.owner-description',
defaultMessage: 'Full access including billing, members, and destructive actions.',
},
editorRole: {
id: 'servers.access-page.role.editor',
defaultMessage: 'Editor',
},
editorDescription: {
id: 'servers.access-page.role.editor-description',
defaultMessage: 'Manage instance content, files, backups, and other settings.',
},
viewerRole: {
id: 'servers.access-page.role.viewer',
defaultMessage: 'Limited',
},
viewerDescription: {
id: 'servers.access-page.role.viewer-description',
defaultMessage: 'Start, stop, and view the server without making changes.',
},
inviteSentTitle: {
id: 'servers.access-page.notification.invite-sent.title',
defaultMessage: 'Invite sent',
},
inviteSentText: {
id: 'servers.access-page.notification.invite-sent.text',
defaultMessage: 'Invited {target} as {role}.',
},
inviteResentTitle: {
id: 'servers.access-page.notification.invite-resent.title',
defaultMessage: 'Invite resent',
},
inviteResentText: {
id: 'servers.access-page.notification.invite-resent.text',
defaultMessage: 'Sent another invite to {target}.',
},
inviteCancelledTitle: {
id: 'servers.access-page.notification.invite-cancelled.title',
defaultMessage: 'Invite cancelled',
},
inviteCancelledText: {
id: 'servers.access-page.notification.invite-cancelled.text',
defaultMessage: 'Cancelled the invite for {target}.',
},
memberRemovedTitle: {
id: 'servers.access-page.notification.member-removed.title',
defaultMessage: 'Access removed',
},
memberRemovedText: {
id: 'servers.access-page.notification.member-removed.text',
defaultMessage: 'Removed {target} from this server.',
},
loadFailedTitle: {
id: 'servers.access-page.notification.load-failed.title',
defaultMessage: 'Access could not be loaded',
},
loadFailedText: {
id: 'servers.access-page.notification.load-failed.text',
defaultMessage: 'Refresh the page to try again.',
},
inviteFailedTitle: {
id: 'servers.access-page.notification.invite-failed.title',
defaultMessage: 'Invite could not be sent',
},
friendRequestFailedTitle: {
id: 'servers.access-page.notification.friend-request-failed.title',
defaultMessage: 'Friend request could not be sent',
},
removeFailedTitle: {
id: 'servers.access-page.notification.remove-failed.title',
defaultMessage: 'Access could not be removed',
},
roleUpdateFailedTitle: {
id: 'servers.access-page.notification.role-update-failed.title',
defaultMessage: 'Role could not be updated',
},
})
export const actionLogActionMessages = defineMessages({
server_created: {
id: 'servers.access-page.activity-log-filter.action.server-created',
defaultMessage: 'Created server',
},
changed_server_name: {
id: 'servers.access-page.activity-log-filter.action.changed-server-name',
defaultMessage: 'Changed server name',
},
changed_server_subdomain: {
id: 'servers.access-page.activity-log-filter.action.changed-server-subdomain',
defaultMessage: 'Changed server subdomain',
},
server_reallocated: {
id: 'servers.access-page.activity-log-filter.action.server-reallocated',
defaultMessage: 'Reallocated server',
},
server_plan_changed: {
id: 'servers.access-page.activity-log-filter.action.server-plan-changed',
defaultMessage: 'Changed plan',
},
user_invited: {
id: 'servers.access-page.activity-log-filter.action.user-invited',
defaultMessage: 'Invited user',
},
user_invite_revoked: {
id: 'servers.access-page.activity-log-filter.action.user-invite-revoked',
defaultMessage: 'Revoked user invite',
},
user_permission_modified: {
id: 'servers.access-page.activity-log-filter.action.user-permission-modified',
defaultMessage: 'Changed user permissions',
},
user_removed: {
id: 'servers.access-page.activity-log-filter.action.user-removed',
defaultMessage: 'Removed user',
},
addon_added: {
id: 'servers.access-page.activity-log-filter.action.addon-added',
defaultMessage: 'Added content',
},
addon_uploaded: {
id: 'servers.access-page.activity-log-filter.action.addon-uploaded',
defaultMessage: 'Uploaded content',
},
addon_disabled: {
id: 'servers.access-page.activity-log-filter.action.addon-disabled',
defaultMessage: 'Disabled content',
},
addon_enabled: {
id: 'servers.access-page.activity-log-filter.action.addon-enabled',
defaultMessage: 'Enabled content',
},
addon_deleted: {
id: 'servers.access-page.activity-log-filter.action.addon-deleted',
defaultMessage: 'Deleted content',
},
addon_updated: {
id: 'servers.access-page.activity-log-filter.action.addon-updated',
defaultMessage: 'Updated content',
},
modpack_changed: {
id: 'servers.access-page.activity-log-filter.action.modpack-changed',
defaultMessage: 'Changed modpack',
},
modpack_unlinked: {
id: 'servers.access-page.activity-log-filter.action.modpack-unlinked',
defaultMessage: 'Unlinked modpack',
},
server_repaired: {
id: 'servers.access-page.activity-log-filter.action.server-repaired',
defaultMessage: 'Repaired server',
},
server_reset: {
id: 'servers.access-page.activity-log-filter.action.server-reset',
defaultMessage: 'Reset server',
},
server_started: {
id: 'servers.access-page.activity-log-filter.action.server-started',
defaultMessage: 'Started server',
},
server_stopped: {
id: 'servers.access-page.activity-log-filter.action.server-stopped',
defaultMessage: 'Stopped server',
},
server_restarted: {
id: 'servers.access-page.activity-log-filter.action.server-restarted',
defaultMessage: 'Restarted server',
},
server_killed: {
id: 'servers.access-page.activity-log-filter.action.server-killed',
defaultMessage: 'Killed server',
},
port_allocation_added: {
id: 'servers.access-page.activity-log-filter.action.port-allocation-added',
defaultMessage: 'Added port allocation',
},
port_allocation_removed: {
id: 'servers.access-page.activity-log-filter.action.port-allocation-removed',
defaultMessage: 'Removed port allocation',
},
loader_version_edited: {
id: 'servers.access-page.activity-log-filter.action.loader-version-edited',
defaultMessage: 'Changed loader version',
},
game_version_edited: {
id: 'servers.access-page.activity-log-filter.action.game-version-edited',
defaultMessage: 'Changed Minecraft version',
},
server_properties_modified: {
id: 'servers.access-page.activity-log-filter.action.server-properties-modified',
defaultMessage: 'Modified server properties',
},
file_uploaded: {
id: 'servers.access-page.activity-log-filter.action.file-uploaded',
defaultMessage: 'Uploaded file',
},
file_deleted: {
id: 'servers.access-page.activity-log-filter.action.file-deleted',
defaultMessage: 'Deleted file',
},
file_renamed: {
id: 'servers.access-page.activity-log-filter.action.file-renamed',
defaultMessage: 'Renamed file',
},
file_edited: {
id: 'servers.access-page.activity-log-filter.action.file-edited',
defaultMessage: 'Edited file',
},
sftp_login: {
id: 'servers.access-page.activity-log-filter.action.sftp-login',
defaultMessage: 'Logged in via SFTP',
},
console_command_executed: {
id: 'servers.access-page.activity-log-filter.action.console-command-executed',
defaultMessage: 'Ran console command',
},
console_cleared: {
id: 'servers.access-page.activity-log-filter.action.console-cleared',
defaultMessage: 'Cleared console',
},
backup_created: {
id: 'servers.access-page.activity-log-filter.action.backup-created',
defaultMessage: 'Created backup',
},
backup_renamed: {
id: 'servers.access-page.activity-log-filter.action.backup-renamed',
defaultMessage: 'Renamed backup',
},
backup_restored: {
id: 'servers.access-page.activity-log-filter.action.backup-restored',
defaultMessage: 'Restored backup',
},
backup_deleted: {
id: 'servers.access-page.activity-log-filter.action.backup-deleted',
defaultMessage: 'Deleted backup',
},
startup_command_modified: {
id: 'servers.access-page.activity-log-filter.action.startup-command-modified',
defaultMessage: 'Changed startup command',
},
java_runtime_modified: {
id: 'servers.access-page.activity-log-filter.action.java-runtime-modified',
defaultMessage: 'Changed Java runtime',
},
java_version_modified: {
id: 'servers.access-page.activity-log-filter.action.java-version-modified',
defaultMessage: 'Changed Java version',
},
})
@@ -49,7 +49,12 @@
</button>
</ButtonStyled>
<ButtonStyled v-else color="brand" size="large">
<button class="ml-auto" @click="openModal">
<button
v-tooltip="!canSetup ? permissionDeniedMessage : undefined"
class="ml-auto"
:disabled="!canSetup"
@click="openModal"
>
{{ formatMessage(messages.setupServerButton) }} <RightArrowIcon />
</button>
</ButtonStyled>
@@ -62,6 +67,8 @@
:show-snapshot-toggle="true"
:search-modpacks="searchModpacks"
:get-project-versions="getProjectVersions"
:finish-disabled="!canSetup"
:finish-disabled-tooltip="!canSetup ? permissionDeniedMessage : undefined"
@hide="() => {}"
@browse-modpacks="onBrowseModpacks"
@create="onCreate"
@@ -77,6 +84,7 @@ import {
defineMessages,
injectModrinthClient,
injectNotificationManager,
useServerPermissions,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
@@ -90,6 +98,7 @@ import { injectModrinthServerContext } from '#ui/providers'
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const { canSetup, permissionDeniedMessage } = useServerPermissions()
const messages = defineMessages({
welcomeTitle: {
@@ -196,11 +205,16 @@ const uploadPercent = computed(() =>
totalBytes.value > 0 ? Math.round((uploadedBytes.value / totalBytes.value) * 100) : 0,
)
const openModal = () => modalRef.value?.show()
const openModal = () => {
if (!canSetup.value) return
modalRef.value?.show()
}
onBeforeUnmount(() => modalRef.value?.hide())
function onBrowseModpacks() {
if (!canSetup.value) return
if (props.browseModpacks) {
props.browseModpacks({
serverId,
@@ -217,6 +231,11 @@ function onBrowseModpacks() {
}
onMounted(async () => {
if (!canSetup.value && route.query.resumeModal) {
router.replace({ query: {} })
return
}
if (route.query.resumeModal === 'setup-type') {
router.replace({ query: {} })
openModal()
@@ -263,6 +282,11 @@ function toApiLoader(loader: string): Archon.Content.v1.Modloader {
}
const onCreate = async (config: CreationFlowContextValue) => {
if (!canSetup.value) {
config.loading.value = false
return
}
// Handle mrpack file upload
if (config.setupType.value === 'modpack' && config.modpackFile.value) {
modalRef.value?.hide()
@@ -28,11 +28,27 @@
<div v-else key="content" class="contents">
<ReadyTransition :pending="backupsReadyPending">
<BackupCreateModal ref="createBackupModal" :backups="completedBackups" />
<BackupRenameModal ref="renameBackupModal" :backups="completedBackups" />
<BackupRestoreModal ref="restoreBackupModal" />
<BackupCreateModal
ref="createBackupModal"
:backups="completedBackups"
:can-create="canManageBackups"
:permission-denied-message="permissionDeniedMessage"
/>
<BackupRenameModal
ref="renameBackupModal"
:backups="completedBackups"
:can-rename="canManageBackups"
:permission-denied-message="permissionDeniedMessage"
/>
<BackupRestoreModal
ref="restoreBackupModal"
:can-restore="canManageBackups"
:permission-denied-message="permissionDeniedMessage"
/>
<BackupDeleteModal
ref="deleteBackupModal"
:can-delete="canManageBackups"
:permission-denied-message="permissionDeniedMessage"
@delete="deleteBackup"
@bulk-delete="bulkDelete"
/>
@@ -122,6 +138,7 @@
v-for="(backup, backupIndex) in group.backups"
:key="`backup-${backup.id}`"
class="flex gap-2"
:data-backup-id="backup.id"
>
<div class="flex w-5 flex-col items-center">
<div
@@ -140,17 +157,20 @@
class="my-1.5 min-w-0 flex-1"
:backup="backup"
:selected="selectedIds.has(backup.id)"
:highlighted="highlightedBackupId === backup.id"
:restore-disabled="backupRestoreDisabled"
:write-disabled="!canManageBackups"
:write-disabled-tooltip="permissionDeniedMessage"
:kyros-url="server.node?.instance"
:jwt="server.node?.token"
:show-copy-id-action="showCopyIdAction"
:show-debug-info="showDebugInfo"
@download="() => triggerDownloadAnimation()"
@rename="() => renameBackupModal?.show(backup)"
@restore="() => restoreBackupModal?.show(backup)"
@rename="() => showRenameBackupModal(backup)"
@restore="() => showRestoreBackupModal(backup)"
@delete="
(skipConfirmation?: boolean) =>
skipConfirmation ? deleteBackup(backup) : deleteBackupModal?.show(backup)
skipConfirmation ? deleteBackup(backup) : showDeleteBackupModal(backup)
"
/>
</div>
@@ -191,7 +211,12 @@
<div v-if="!isBulkOperating" class="ml-auto flex items-center gap-0.5">
<ButtonStyled type="transparent" color="red" hover-color-fill="background">
<button type="button" @click="confirmBulkDelete">
<button
v-tooltip="!canManageBackups ? permissionDeniedMessage : undefined"
type="button"
:disabled="!canManageBackups"
@click="confirmBulkDelete"
>
<TrashIcon />
<span class="bar-label">{{ formatMessage(commonMessages.deleteLabel) }}</span>
</button>
@@ -244,7 +269,7 @@ import { CalendarIcon, DownloadIcon, IssuesIcon, PlusIcon, TrashIcon } from '@mo
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import dayjs from 'dayjs'
import type { Component } from 'vue'
import { computed, ref } from 'vue'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
@@ -261,6 +286,7 @@ import BackupRestoreModal from '#ui/components/servers/backups/BackupRestoreModa
import { useBackupsSelection } from '#ui/composables/hosting/backups-selection'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { useServerBackupsQueue } from '#ui/composables/server-backups-queue'
import { useServerPermissions } from '#ui/composables/server-permissions'
import { useBulkOperation } from '#ui/layouts/shared/content-tab/composables/bulk-operations'
import {
injectModrinthClient,
@@ -327,6 +353,7 @@ const messages = defineMessages({
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const { canManageBackups, permissionDeniedMessage } = useServerPermissions()
const filterPillOptions = computed<FilterPillOption[]>(() => [
{ id: 'manual', label: formatMessage(messages.filterManual) },
@@ -344,6 +371,7 @@ const props = defineProps<{
const route = useRoute()
const serverId = route.params.id as string
const BACKUP_HIGHLIGHT_DURATION_MS = 5_000
defineEmits(['onDownload'])
@@ -476,6 +504,72 @@ const groupedBackups = computed((): BackupGroup[] => {
})
const displayOrderedBackups = computed(() => groupedBackups.value.flatMap((g) => g.backups))
const focusedBackupId = computed(() =>
typeof route.query.backup === 'string' ? route.query.backup : null,
)
const highlightedBackupId = ref<string | null>(null)
let highlightedBackupTimeout: ReturnType<typeof setTimeout> | null = null
let lastHighlightedFocusedBackupId: string | null = null
let lastScrolledFocusedBackupId: string | null = null
watch(
[focusedBackupId, displayOrderedBackups],
async ([backupId]) => {
if (!backupId) {
lastHighlightedFocusedBackupId = null
lastScrolledFocusedBackupId = null
clearHighlightedBackup()
return
}
if (!displayOrderedBackups.value.some((backup) => backup.id === backupId)) return
if (lastHighlightedFocusedBackupId !== backupId) {
lastHighlightedFocusedBackupId = backupId
highlightBackup(backupId)
}
if (lastScrolledFocusedBackupId === backupId) return
if (typeof document === 'undefined') return
lastScrolledFocusedBackupId = backupId
await nextTick()
const escapedBackupId =
typeof CSS !== 'undefined' && CSS.escape
? CSS.escape(backupId)
: backupId.replaceAll('"', '\\"')
document
.querySelector(`[data-backup-id="${escapedBackupId}"]`)
?.scrollIntoView({ block: 'center', behavior: 'smooth' })
},
{ immediate: true },
)
onBeforeUnmount(() => {
if (highlightedBackupTimeout) {
clearTimeout(highlightedBackupTimeout)
}
})
function highlightBackup(backupId: string) {
highlightedBackupId.value = backupId
if (highlightedBackupTimeout) {
clearTimeout(highlightedBackupTimeout)
}
highlightedBackupTimeout = setTimeout(() => {
highlightedBackupId.value = null
highlightedBackupTimeout = null
}, BACKUP_HIGHLIGHT_DURATION_MS)
}
function clearHighlightedBackup() {
highlightedBackupId.value = null
if (highlightedBackupTimeout) {
clearTimeout(highlightedBackupTimeout)
highlightedBackupTimeout = null
}
}
const {
selectedIds,
@@ -496,6 +590,9 @@ const restoreBackupModal = ref<InstanceType<typeof BackupRestoreModal>>()
const deleteBackupModal = ref<InstanceType<typeof BackupDeleteModal>>()
const backupRestoreDisabled = computed(() => {
if (!canManageBackups.value) {
return permissionDeniedMessage.value
}
if (props.isServerRunning) {
return 'Cannot restore backup while server is running'
}
@@ -509,6 +606,9 @@ const backupRestoreDisabled = computed(() => {
})
const backupCreationDisabled = computed(() => {
if (!canManageBackups.value) {
return permissionDeniedMessage.value
}
const quota = server.value.backup_quota
if (quota !== undefined) {
const usedCount = backups.value.length ?? server.value.used_backup_quota ?? 0
@@ -526,19 +626,37 @@ const backupCreationDisabled = computed(() => {
})
const showCreateModel = () => {
if (backupCreationDisabled.value) return
createBackupModal.value?.show()
}
function showRenameBackupModal(backup: Archon.BackupsQueue.v1.BackupQueueBackup) {
if (!canManageBackups.value) return
renameBackupModal.value?.show(backup)
}
function showRestoreBackupModal(backup: Archon.BackupsQueue.v1.BackupQueueBackup) {
if (backupRestoreDisabled.value) return
restoreBackupModal.value?.show(backup)
}
function showDeleteBackupModal(backup: Archon.BackupsQueue.v1.BackupQueueBackup) {
if (!canManageBackups.value) return
deleteBackupModal.value?.show(backup)
}
function clearBackupFilters() {
selectedFilters.value = []
}
function confirmBulkDelete() {
if (!canManageBackups.value) return
if (!selectedBackups.value.length) return
deleteBackupModal.value?.showBulk(selectedBackups.value)
}
async function bulkDelete(toRemove: Archon.BackupsQueue.v1.BackupQueueBackup[]) {
if (!canManageBackups.value) return
if (!toRemove.length) return
isBulkOperating.value = true
@@ -569,6 +687,7 @@ function useQueueDeleteFor(backup: Archon.BackupsQueue.v1.BackupQueueBackup) {
}
function deleteBackup(backup?: Archon.BackupsQueue.v1.BackupQueueBackup) {
if (!canManageBackups.value) return
if (!backup) {
addNotification({
type: 'error',
@@ -9,6 +9,7 @@ import { useRoute, useRouter } from 'vue-router'
import ReadyTransition from '#ui/components/base/ReadyTransition.vue'
import { useUploadSessionUpload } from '#ui/composables/hosting/kyros-session-upload'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { useServerPermissions } from '#ui/composables/server-permissions'
import {
injectModrinthClient,
injectModrinthServerContext,
@@ -123,6 +124,7 @@ const contentUploadSession = useUploadSessionUpload({
})
const { addNotification } = injectNotificationManager()
const { openServerSettings, browseServerContent } = injectServerSettingsModal()
const { canSetup, permissionDeniedMessage } = useServerPermissions()
const route = useRoute()
const router = useRouter()
const queryClient = useQueryClient()
@@ -136,6 +138,7 @@ const type = computed(() => {
})
const queryKey = computed(() => ['content', 'list', 'v1', serverId])
const modpackContentQueryKey = computed(() => ['content', 'list', 'v1', serverId, 'modpack'])
function getContentOwnerAvatarUrl(owner: ContentOwnerAvatarSource) {
const ownerId = owner.type === 'user' ? owner.name || owner.id : owner.id
@@ -150,6 +153,44 @@ const contentQuery = useQuery({
staleTime: 0,
})
const isModpackContentModalOpen = ref(false)
const modpackContentQuery = useQuery({
queryKey: modpackContentQueryKey,
queryFn: () =>
client.archon.content_v1.getAddons(serverId, worldId.value!, {
from_modpack: true,
}),
enabled: computed(() => isModpackContentModalOpen.value && worldId.value !== null),
staleTime: 0,
})
const setupActionDisabled = computed(() => !canSetup.value || busyReasons.value.length > 0)
const setupActionBusyMessage = computed(() => {
if (!canSetup.value) return permissionDeniedMessage.value
const bannerCoversInstalling =
server.value?.status === 'installing' ||
isSyncingContent.value ||
busyReasons.value.some(
(r) =>
r.reason.id === 'servers.busy.installing' || r.reason.id === 'servers.busy.syncing-content',
)
const filteredReasons = busyReasons.value.filter((r) => {
if (
bannerCoversInstalling &&
(r.reason.id === 'servers.busy.installing' || r.reason.id === 'servers.busy.syncing-content')
)
return false
if (
r.reason.id === 'servers.busy.backup-creating' ||
r.reason.id === 'servers.busy.backup-restoring'
)
return false
return true
})
return filteredReasons.length > 0 ? formatMessage(filteredReasons[0].reason) : null
})
const modpackProjectId = computed(() => {
const spec = contentQuery.data.value?.modpack?.spec
return spec?.platform === 'modrinth' ? spec.project_id : null
@@ -688,12 +729,14 @@ const toggleMutation = useMutation({
})
async function handleToggleEnabled(item: ContentItem) {
if (setupActionDisabled.value) return
const addon = addonLookup.value.get(item.file_name)
if (!addon) return
await toggleMutation.mutateAsync({ addon })
}
async function handleDeleteItem(item: ContentItem) {
if (setupActionDisabled.value) return
const addon = addonLookup.value.get(item.file_name)
if (!addon) return
await deleteMutation.mutateAsync({ addon })
@@ -708,6 +751,7 @@ function itemsToAddonRequests(items: ContentItem[]): Archon.Content.v1.RemoveAdd
}
async function handleBulkDelete(items: ContentItem[]) {
if (setupActionDisabled.value) return
const requests = itemsToAddonRequests(items)
if (requests.length === 0) return
try {
@@ -723,6 +767,7 @@ async function handleBulkDelete(items: ContentItem[]) {
}
async function handleBulkEnable(items: ContentItem[]) {
if (setupActionDisabled.value) return
const requests = itemsToAddonRequests(items)
if (requests.length === 0) return
try {
@@ -738,6 +783,7 @@ async function handleBulkEnable(items: ContentItem[]) {
}
async function handleBulkDisable(items: ContentItem[]) {
if (setupActionDisabled.value) return
const requests = itemsToAddonRequests(items)
if (requests.length === 0) return
try {
@@ -760,6 +806,15 @@ const updatingProject = ref<ContentItem | null>(null)
const updatingModpack = ref(false)
const loadingChangelog = ref(false)
watch(
() => modpackContentQuery.data.value?.addons,
(addons) => {
if (!isModpackContentModalOpen.value || !addons) return
modpackAddons.value = addons
modpackContentModal.value?.setItems(addons.map(addonToContentItem))
},
)
const updatingProjectId = computed(() => updatingProject.value?.project?.id ?? null)
const projectVersionsQuery = useQuery({
@@ -797,6 +852,7 @@ const currentLoader = computed(
)
function handleBrowseContent() {
if (setupActionDisabled.value) return
const contentType = type.value
if (browseServerContent && ['mod', 'plugin', 'datapack'].includes(contentType)) {
browseServerContent({
@@ -814,6 +870,7 @@ function handleBrowseContent() {
}
function handleUploadFiles() {
if (setupActionDisabled.value) return
const input = document.createElement('input')
input.type = 'file'
input.multiple = true
@@ -876,15 +933,16 @@ function addonToContentItem(addon: AddonWithUiState): ContentItem {
}
async function handleViewModpackContent() {
isModpackContentModalOpen.value = true
modpackContentModal.value?.showLoading()
try {
const data = await client.archon.content_v1.getAddons(serverId, worldId.value!, {
from_modpack: true,
})
const { data } = await modpackContentQuery.refetch()
if (!data) throw new Error('Failed to load modpack content')
modpackAddons.value = data.addons ?? []
const items = (data.addons ?? []).map(addonToContentItem)
modpackContentModal.value?.show(items)
} catch (err) {
isModpackContentModalOpen.value = false
modpackContentModal.value?.hide()
addNotification({
type: 'error',
@@ -895,6 +953,7 @@ async function handleViewModpackContent() {
}
async function handleModpackContentToggle(item: ContentItem) {
if (setupActionDisabled.value) return
const addon = addonLookup.value.get(item.file_name)
if (!addon) return
modpackContentModal.value?.updateItem(item.file_name, { disabled: true })
@@ -903,6 +962,18 @@ async function handleModpackContentToggle(item: ContentItem) {
modpackAddons.value = modpackAddons.value.map((a) =>
a.filename === addon.filename ? { ...a, disabled: !addon.disabled } : a,
)
queryClient.setQueryData(
modpackContentQueryKey.value,
(oldData: Archon.Content.v1.Addons | undefined) =>
oldData
? {
...oldData,
addons: (oldData.addons ?? []).map((a) =>
a.filename === addon.filename ? { ...a, disabled: !addon.disabled } : a,
),
}
: oldData,
)
modpackContentModal.value?.updateItem(item.file_name, {
enabled: !item.enabled,
disabled: false,
@@ -913,6 +984,7 @@ async function handleModpackContentToggle(item: ContentItem) {
}
async function handleModpackBulkToggle(items: ContentItem[], enable: boolean) {
if (setupActionDisabled.value) return
const requests = itemsToAddonRequests(items)
if (requests.length === 0) return
@@ -930,6 +1002,20 @@ async function handleModpackBulkToggle(items: ContentItem[], enable: boolean) {
} else {
await client.archon.content_v1.disableAddons(serverId, worldId.value!, requests)
}
queryClient.setQueryData(
modpackContentQueryKey.value,
(oldData: Archon.Content.v1.Addons | undefined) =>
oldData
? {
...oldData,
addons: (oldData.addons ?? []).map((addon) =>
items.some((item) => item.file_name === addon.filename)
? { ...addon, disabled: !enable }
: addon,
),
}
: oldData,
)
await queryClient.invalidateQueries({ queryKey: queryKey.value })
} catch (err) {
for (const item of items) {
@@ -951,6 +1037,7 @@ function handleModpackUnlink() {
}
async function handleModpackUnlinkConfirm() {
if (setupActionDisabled.value) return
try {
await client.archon.content_v1.unlinkModpack(serverId, worldId.value!)
await contentQuery.refetch()
@@ -964,6 +1051,7 @@ async function handleModpackUnlinkConfirm() {
}
async function handleBulkUpdate(items: ContentItem[]) {
if (setupActionDisabled.value) return
const addons = items
.filter((item) => item.has_update)
.map((item) => ({
@@ -1063,6 +1151,7 @@ function resetUpdateState() {
}
function handleModalUpdate(selectedVersion: Labrinth.Versions.v2.Version, event?: MouseEvent) {
if (setupActionDisabled.value) return
if (updatingModpack.value) {
pendingModpackUpdateVersion.value = selectedVersion
@@ -1100,6 +1189,7 @@ function setAddonInstalling(filename: string, installing: boolean) {
}
async function performUpdate(selectedVersion: Labrinth.Versions.v2.Version) {
if (setupActionDisabled.value) return
const item = updatingProject.value
if (item) {
setAddonInstalling(item.file_name, true)
@@ -1142,6 +1232,7 @@ async function performUpdate(selectedVersion: Labrinth.Versions.v2.Version) {
}
function handleModpackUpdateConfirm() {
if (setupActionDisabled.value) return
if (pendingModpackUpdateVersion.value) {
contentUpdaterModal.value?.hide()
performUpdate(pendingModpackUpdateVersion.value)
@@ -1177,32 +1268,10 @@ provideContentManager({
error: computed(() => contentQuery.error.value ?? null),
modpack,
isPackLocked: ref(false),
isBusy: computed(() => busyReasons.value.length > 0),
busyMessage: computed(() => {
const bannerCoversInstalling =
server.value?.status === 'installing' ||
isSyncingContent.value ||
busyReasons.value.some(
(r) =>
r.reason.id === 'servers.busy.installing' ||
r.reason.id === 'servers.busy.syncing-content',
)
const filteredReasons = busyReasons.value.filter((r) => {
if (
bannerCoversInstalling &&
(r.reason.id === 'servers.busy.installing' ||
r.reason.id === 'servers.busy.syncing-content')
)
return false
if (
r.reason.id === 'servers.busy.backup-creating' ||
r.reason.id === 'servers.busy.backup-restoring'
)
return false
return true
})
return filteredReasons.length > 0 ? formatMessage(filteredReasons[0].reason) : null
}),
isBusy: setupActionDisabled,
busyMessage: setupActionBusyMessage,
disableAddContent: computed(() => !canSetup.value),
disableAddContentTooltip: permissionDeniedMessage.value,
contentTypeLabel: type,
toggleEnabled: handleToggleEnabled,
deleteItem: handleDeleteItem,
@@ -1253,15 +1322,24 @@ provideContentManager({
<ReadyTransition :pending="contentReadyPending">
<ContentPageLayout :bottom-padding="false">
<template #modals>
<ConfirmUnlinkModal ref="modpackUnlinkModal" server @unlink="handleModpackUnlinkConfirm" />
<ConfirmUnlinkModal
ref="modpackUnlinkModal"
server
:action-disabled="setupActionDisabled"
:action-disabled-tooltip="setupActionBusyMessage ?? undefined"
@unlink="handleModpackUnlinkConfirm"
/>
<ModpackContentModal
ref="modpackContentModal"
:modpack-name="modpack?.project.title"
:modpack-icon-url="modpack?.project.icon_url"
enable-toggle
:action-disabled="setupActionDisabled"
:action-disabled-tooltip="setupActionBusyMessage ?? undefined"
@update:enabled="handleModpackContentToggle"
@bulk:enable="handleModpackBulkToggle($event, true)"
@bulk:disable="handleModpackBulkToggle($event, false)"
@hide="isModpackContentModalOpen = false"
/>
<ContentUpdaterModal
v-if="updatingProject || updatingModpack"
@@ -1288,6 +1366,8 @@ provideContentManager({
"
:loading="loadingVersions"
:loading-changelog="loadingChangelog"
:action-disabled="setupActionDisabled"
:action-disabled-tooltip="setupActionBusyMessage ?? undefined"
@update="handleModalUpdate"
@cancel="resetUpdateState"
@version-select="handleVersionSelect"
@@ -1305,6 +1385,8 @@ provideContentManager({
.join(' ')
"
server
:action-disabled="setupActionDisabled"
:action-disabled-tooltip="setupActionBusyMessage ?? undefined"
@confirm="handleModpackUpdateConfirm"
@cancel="handleModpackUpdateCancel"
/>
@@ -8,6 +8,7 @@ import ReadyTransition from '#ui/components/base/ReadyTransition.vue'
import { useReadyState } from '#ui/composables'
import { useUploadSessionUpload } from '#ui/composables/hosting/kyros-session-upload'
import { useVIntl } from '#ui/composables/i18n'
import { useServerPermissions } from '#ui/composables/server-permissions'
import {
injectModrinthClient,
injectModrinthServerContext,
@@ -43,6 +44,7 @@ const fileUploadSession = useUploadSessionUpload({
})
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const { canWriteFiles, canUsePowerActions, permissionDeniedMessage } = useServerPermissions()
const route = useRoute()
const router = useRouter()
@@ -52,6 +54,10 @@ const serverBusy = computed(() => busyReasons.value.length > 0)
const busyTooltip = computed(() =>
busyReasons.value.length > 0 ? formatMessage(busyReasons.value[0].reason) : undefined,
)
const fileWriteDisabled = computed(() => !canWriteFiles.value || serverBusy.value)
const fileWriteDisabledTooltip = computed(() =>
canWriteFiles.value ? busyTooltip.value : permissionDeniedMessage.value,
)
const nonBackupBusyReasons = computed(() =>
busyReasons.value.filter(
(r) =>
@@ -325,6 +331,7 @@ const createMutation = useMutation({
// Extraction
async function extractFile(path: string, override: boolean, dry: boolean) {
if (fileWriteDisabled.value) return
if (dry) {
return await client.kyros.files_v0.extractFile(path, override, true)
}
@@ -346,6 +353,7 @@ async function readFileAsBlob(path: string): Promise<Blob> {
}
async function writeFile(path: string, content: string): Promise<void> {
if (fileWriteDisabled.value) return
await client.kyros.files_v0.updateFile(path, content)
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
}
@@ -383,6 +391,7 @@ onMounted(async () => {
// Restart
async function restartServer() {
if (!canUsePowerActions.value) return
await client.archon.servers_v0.power(serverId, 'Restart')
}
@@ -392,7 +401,7 @@ function getSessionUploadFilename(fileName: string) {
}
async function uploadFiles(files: File[]) {
if (files.length === 0) return
if (fileWriteDisabled.value || files.length === 0) return
try {
const result = await fileUploadSession.uploadFiles(
@@ -426,16 +435,20 @@ provideFileManager({
startEditing,
stopEditing,
createItem: async (name, type) => {
if (fileWriteDisabled.value) return
const path = `${currentPath.value}/${name}`.replace('//', '/')
await createMutation.mutateAsync({ path, type })
},
renameItem: async (path, newName) => {
if (fileWriteDisabled.value) return
await renameMutation.mutateAsync({ path, newName })
},
moveItem: async (source, destination) => {
if (fileWriteDisabled.value) return
await moveMutation.mutateAsync({ source, destination })
},
deleteItem: async (path, recursive) => {
if (fileWriteDisabled.value) return
await deleteMutation.mutateAsync({ path, recursive })
},
readFile,
@@ -446,14 +459,14 @@ provideFileManager({
cancelUpload,
uploadState,
refresh: refreshList,
isBusy: serverBusy,
busyTooltip,
isBusy: fileWriteDisabled,
busyTooltip: fileWriteDisabledTooltip,
busyWarning,
extractFile,
prefetchDirectory,
prefetchFile,
showInstallFromUrl: true,
canRestart: true,
canRestart: canUsePowerActions.value,
restartServer,
canShareToMclogs: true,
})
@@ -68,7 +68,7 @@
<li v-if="fetchError" class="text-red">
<p>{{ formatMessage(messages.errorDetails) }}</p>
<CopyCode
:text="(fetchError as ModrinthServersFetchError).message || 'Unknown error'"
:text="formatFetchError(fetchError)"
:copyable="false"
:selectable="false"
:language="'json'"
@@ -143,7 +143,7 @@
/>
</div>
<div v-else key="list">
<div v-else key="list" class="flex flex-col gap-6">
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 max-h-0"
@@ -161,29 +161,67 @@
</div>
</Transition>
<TransitionGroup
v-if="filteredData.length > 0 || isPollingForNewServers"
name="list"
tag="ul"
class="m-0 flex flex-col gap-3 p-0"
>
<MedalServerListing
v-for="server in filteredData.filter((s) => s.is_medal)"
:key="server.server_id"
v-bind="server"
@upgrade="openMedalUpgradeModal"
/>
<ServerListing
v-for="server in filteredData.filter((s) => !s.is_medal)"
:key="server.server_id"
v-bind="server"
:cancellation-date="serverBillingMap.get(server.server_id)?.cancellationDate"
:is-provisioning="serverBillingMap.get(server.server_id)?.isProvisioning"
:on-resubscribe="serverBillingMap.get(server.server_id)?.onResubscribe"
:on-download-backup="serverBillingMap.get(server.server_id)?.onDownloadBackup"
/>
</TransitionGroup>
<div v-else>{{ formatMessage(messages.noServersFound) }}</div>
<section v-if="ownedServerList.length > 0" class="flex flex-col gap-3">
<h2 class="m-0 text-xl font-semibold text-primary">
{{ formatMessage(messages.yourServersTitle) }}
</h2>
<TransitionGroup
v-if="ownedFilteredData.length > 0"
name="list"
tag="ul"
class="m-0 flex flex-col gap-3 p-0"
>
<MedalServerListing
v-for="server in ownedFilteredData.filter((s) => s.is_medal)"
:key="`owned-medal-${server.server_id}`"
v-bind="server"
@upgrade="openMedalUpgradeModal"
/>
<ServerListing
v-for="server in ownedFilteredData.filter((s) => !s.is_medal)"
:key="`owned-${server.server_id}`"
v-bind="server"
:cancellation-date="serverBillingMap.get(server.server_id)?.cancellationDate"
:is-provisioning="serverBillingMap.get(server.server_id)?.isProvisioning"
:on-resubscribe="serverBillingMap.get(server.server_id)?.onResubscribe"
:on-download-backup="serverBillingMap.get(server.server_id)?.onDownloadBackup"
/>
</TransitionGroup>
<div v-else class="text-secondary">
{{ formatMessage(messages.noOwnedServersFound) }}
</div>
</section>
<section v-if="sharedServerList.length > 0" class="flex flex-col gap-3">
<h2 class="m-0 text-xl font-semibold text-primary">
{{ formatMessage(messages.sharedServersTitle) }}
</h2>
<TransitionGroup
v-if="sharedFilteredData.length > 0"
name="list"
tag="ul"
class="m-0 flex flex-col gap-3 p-0"
>
<MedalServerListing
v-for="server in sharedFilteredData.filter((s) => s.is_medal)"
:key="`shared-medal-${server.server_id}`"
v-bind="server"
@upgrade="openMedalUpgradeModal"
/>
<ServerListing
v-for="server in sharedFilteredData.filter((s) => !s.is_medal)"
:key="`shared-${server.server_id}`"
v-bind="server"
/>
</TransitionGroup>
<div v-else class="text-secondary">
{{ formatMessage(messages.noSharedServersFound) }}
</div>
</section>
<div v-if="filteredData.length === 0 && !isPollingForNewServers">
{{ formatMessage(messages.noServersFound) }}
</div>
</div>
</Transition>
</template>
@@ -210,7 +248,6 @@ import {
useServerBackupDownload,
useVIntl,
} from '@modrinth/ui'
import type { ModrinthServersFetchError } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { useIntervalFn } from '@vueuse/core'
import dayjs from 'dayjs'
@@ -220,6 +257,7 @@ import { type ComponentPublicInstance, computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ServersUpgradeModalWrapper from '#ui/components/billing/ServersUpgradeModalWrapper.vue'
import type { ServerListingOwner } from '#ui/components/servers/access'
import MedalServerListing from '#ui/components/servers/marketing/MedalServerListing.vue'
import ServerListing from '#ui/components/servers/ServerListing.vue'
import { createHostingPurchaseIntentContext, provideHostingPurchaseIntent } from '#ui/providers'
@@ -270,11 +308,27 @@ const messages = defineMessages({
defaultMessage: 'Search {count} {count, plural, one {server} other {servers}}...',
},
newServerButton: { id: 'servers.manage.new-server-button', defaultMessage: 'New server' },
yourServersTitle: {
id: 'servers.manage.your-servers-title',
defaultMessage: 'Your servers',
},
sharedServersTitle: {
id: 'servers.manage.shared-servers-title',
defaultMessage: 'Shared servers',
},
checkingForNewServers: {
id: 'servers.manage.checking-for-new-servers',
defaultMessage: 'Checking for new servers...',
},
noServersFound: { id: 'servers.manage.no-servers-found', defaultMessage: 'No servers found.' },
noOwnedServersFound: {
id: 'servers.manage.no-owned-servers-found',
defaultMessage: 'No servers you own match your search.',
},
noSharedServersFound: {
id: 'servers.manage.no-shared-servers-found',
defaultMessage: 'No shared servers match your search.',
},
handleErrorTitle: {
id: 'servers.manage.handle-error.title',
defaultMessage: 'An error occurred',
@@ -399,6 +453,14 @@ const { data: regions, isLoading: regionsLoading } = useQuery({
enabled: loggedIn,
})
const PING_COUNT = 20
const PING_INTERVAL = 200
const MAX_PING_TIME = 1000
const initialIndex = {
'eu-lim': 31,
}
watch(
regions,
(newRegions) => {
@@ -424,14 +486,6 @@ async function fetchStock(
return result.available
}
const PING_COUNT = 20
const PING_INTERVAL = 200
const MAX_PING_TIME = 1000
const initialIndex = {
'eu-lim': 31,
}
function runPingTest(
region: Archon.Servers.v1.Region,
index = initialIndex[region.shortcode] ?? 1,
@@ -568,19 +622,15 @@ const serverList = computed<Archon.Servers.v0.Server[]>(() => {
const showEmptyState = computed(
() =>
!showServersListLoading.value && serverList.value.length === 0 && !isPollingForNewServers.value,
!showServersListLoading.value &&
ownedServerList.value.length === 0 &&
sharedServerList.value.length === 0 &&
!isPollingForNewServers.value,
)
const searchInput = ref('')
const fuse = computed(() => {
if (serverList.value.length === 0) return null
return new Fuse(serverList.value, {
keys: ['name', 'loader', 'mc_version', 'game', 'state'],
includeScore: true,
threshold: 0.4,
})
})
type ServerWithOwner = Archon.Servers.v0.Server & { owner?: ServerListingOwner }
function isSetToCancel(server: Archon.Servers.v0.Server): boolean {
return (
@@ -617,14 +667,56 @@ function filesExpired(server: Archon.Servers.v0.Server): boolean {
return new Date() > thirtyDaysLater
}
const filteredData = computed<Archon.Servers.v0.Server[]>(() => {
const base = !searchInput.value.trim()
? sortServers(serverList.value)
: fuse.value
? sortServers(fuse.value.search(searchInput.value).map((result) => result.item))
: []
return base.filter((server) => !filesExpired(server))
})
function isServerOwnedByCurrentUser(server: Archon.Servers.v0.Server): boolean {
return server.owner_id === auth.user.value?.id
}
function getServerOwner(server: Archon.Servers.v0.Server): ServerListingOwner | undefined {
const owner = serverResponse.value?.users?.[server.owner_id]
if (!owner) return undefined
return {
username: owner.username,
avatarUrl: owner.avatar_url ?? undefined,
}
}
const ownedServerList = computed<ServerWithOwner[]>(() =>
serverList.value.filter((server) => !filesExpired(server) && isServerOwnedByCurrentUser(server)),
)
const sharedServerList = computed<ServerWithOwner[]>(() =>
serverList.value
.filter((server) => !filesExpired(server) && !isServerOwnedByCurrentUser(server))
.map((server) => ({
...server,
owner: getServerOwner(server),
})),
)
function filterServersBySearch(servers: ServerWithOwner[]): ServerWithOwner[] {
const normalizedSearch = searchInput.value.trim()
if (!normalizedSearch) return sortServers(servers) as ServerWithOwner[]
const fuse = new Fuse(servers, {
keys: ['name', 'loader', 'mc_version', 'game', 'state', 'owner.username'],
includeScore: true,
threshold: 0.4,
})
return sortServers(
fuse.search(normalizedSearch).map((result) => result.item),
) as ServerWithOwner[]
}
const ownedFilteredData = computed<ServerWithOwner[]>(() =>
filterServersBySearch(ownedServerList.value),
)
const sharedFilteredData = computed<ServerWithOwner[]>(() =>
filterServersBySearch(sharedServerList.value),
)
const filteredData = computed<ServerWithOwner[]>(() => [
...ownedFilteredData.value,
...sharedFilteredData.value,
])
// Start polling only after initial data is available so the baseline is correct
watch(serverResponse, (response) => {
@@ -688,6 +780,10 @@ function handleError(err: unknown) {
})
}
function formatFetchError(error: unknown) {
return error instanceof Error && error.message ? error.message : 'Unknown error'
}
function handleSignIn() {
void auth.requestSignIn('/hosting/manage')
}
@@ -41,6 +41,7 @@ import { computed, ref, watch } from 'vue'
import ServerManageStats from '#ui/components/servers/ServerManageStats.vue'
import { useModrinthServersConsole } from '#ui/composables'
import { useServerPermissions } from '#ui/composables/server-permissions'
import { ConsolePageLayout, provideConsoleManager } from '#ui/layouts/shared/console'
import { injectModrinthClient, injectModrinthServerContext } from '#ui/providers'
@@ -64,6 +65,7 @@ const {
powerStateDetails: _powerStateDetails,
} = injectModrinthServerContext()
const modrinthServersConsole = useModrinthServersConsole()
const { canUsePowerActions, permissionDeniedMessage } = useServerPermissions()
watch(
() => props.showAdvancedDebugInfo,
@@ -107,6 +109,7 @@ const dismissCrash = () => {
provideConsoleManager({
logLines: modrinthServersConsole.output,
sendCommand: (cmd: string) => {
if (!canUsePowerActions.value) return
try {
client.archon.sockets.send(serverId, { event: 'command', cmd })
} catch (error) {
@@ -114,7 +117,12 @@ provideConsoleManager({
}
},
showCommandInput: true,
disableCommandInput: computed(() => serverPowerState.value !== 'running'),
disableCommandInput: computed(
() => !canUsePowerActions.value || serverPowerState.value !== 'running',
),
disableCommandInputTooltip: computed(() =>
canUsePowerActions.value ? undefined : permissionDeniedMessage.value,
),
loading: computed(
() =>
!isConnected.value ||
@@ -122,6 +130,7 @@ provideConsoleManager({
isWsAuthIncorrect.value,
),
onClear: async () => {
if (!canUsePowerActions.value) return
modrinthServersConsole.clear()
try {
await client.kyros.logs_v1.clear()
@@ -129,6 +138,10 @@ provideConsoleManager({
console.error('Failed to clear server logs:', error)
}
},
clearDisabled: computed(() => !canUsePowerActions.value),
clearDisabledTooltip: computed(() =>
canUsePowerActions.value ? undefined : permissionDeniedMessage.value,
),
shareDisabled: computed(() => !isConnected.value),
emptyStateType: 'server',
crashAnalysis,
@@ -3,7 +3,7 @@
v-if="filteredNotices.length > 0"
class="relative mx-auto mb-4 flex w-full min-w-0 flex-col gap-3 px-6"
:class="{
'max-w-[1280px]': isNuxt,
'max-w-[1280px]': constrainWidth,
}"
>
<ServerNotice
@@ -107,7 +107,7 @@
}"
:class="[
'server-panel-' + revealState,
isNuxt ? 'min-h-[100svh] max-w-[1280px] pb-16' : 'min-h-[calc(100svh-100px)] pb-6',
constrainWidth ? 'min-h-[100svh] max-w-[1280px] pb-16' : 'min-h-[calc(100svh-100px)] pb-6',
]"
>
<template v-if="revealState !== 'pending' || isOnboarding">
@@ -344,7 +344,7 @@
<script setup lang="ts">
import type { Archon, Labrinth } from '@modrinth/api-client'
import { ModrinthApiError, NuxtModrinthClient } from '@modrinth/api-client'
import { ModrinthApiError } from '@modrinth/api-client'
import {
BoxesIcon,
CheckIcon,
@@ -360,9 +360,9 @@ import {
SettingsIcon,
TransferIcon,
TriangleAlertIcon,
UsersIcon,
XIcon,
} from '@modrinth/assets'
import type { Stats } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { useStorage, useTimeoutFn } from '@vueuse/core'
import DOMPurify from 'dompurify'
@@ -384,6 +384,7 @@ import {
} from '#ui/components/servers/server-header'
import ServerSettingsModal from '#ui/components/servers/ServerSettingsModal.vue'
import {
hasServerPermission,
useDebugLogger,
useLoadingBarToken,
useModrinthServersConsole,
@@ -394,6 +395,7 @@ import {
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { useServerBackupsQueue } from '#ui/composables/server-backups-queue'
import { useServerManageCoreRuntime } from '#ui/composables/server-manage-core-runtime'
import { useServerPanelSync } from '#ui/composables/server-panel-sync'
import type { LogLine } from '#ui/layouts/shared/console'
import type { ServerSettingsTabId } from '#ui/layouts/shared/server-settings'
import {
@@ -401,6 +403,8 @@ import {
injectNotificationManager,
provideServerSettingsModal,
} from '#ui/providers'
import type { ServerStats } from '#ui/providers/server-context'
import { commonMessages } from '#ui/utils/common-messages'
import { formatLoaderLabel } from '#ui/utils/loaders'
import {
pendingServerContentInstallsEvent,
@@ -442,6 +446,7 @@ const props = withDefaults(
worldId: string | null
type: 'mod' | 'plugin' | 'datapack'
}) => void | Promise<void>
constrainWidth?: boolean
}>(),
{
showCopyIdAction: false,
@@ -456,6 +461,7 @@ const props = withDefaults(
navigateToServers: undefined,
browseModpacks: undefined,
browseContent: undefined,
constrainWidth: false,
},
)
@@ -492,7 +498,7 @@ const DISABLE_LOADING_ANIM = true
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const isNuxt = computed(() => client instanceof NuxtModrinthClient)
const constrainWidth = computed(() => props.constrainWidth)
const queryClient = useQueryClient()
const route = useRoute()
const router = useRouter()
@@ -561,6 +567,11 @@ const { handleWsBackupProgress, busyReasons: backupsBusy } = useServerBackupsQue
worldId,
)
const { disconnect: disconnectPanelSync } = useServerPanelSync({
serverId: computed(() => props.serverId),
worldId,
})
const { image: serverImage } = useServerImage(
props.serverId,
computed(() => serverData.value?.upstream ?? null),
@@ -672,6 +683,7 @@ const {
serverId: computed(() => props.serverId),
worldId,
server: serverData,
serverFull,
isSyncingContent,
extraBusyReasons: backupsBusy,
setDisconnectedOnAuthIncorrect: false,
@@ -682,6 +694,10 @@ const {
})
const isUploading = computed(() => uploadState.value.isUploading)
const canSetup = computed(() =>
hasServerPermission(serverData.value?.current_user_permissions ?? 0, 'SETUP'),
)
const permissionDeniedMessage = computed(() => formatMessage(commonMessages.noPermissionAction))
function handleBeforeUnload(e: BeforeUnloadEvent) {
if (isUploading.value) {
@@ -714,7 +730,7 @@ if (typeof window !== 'undefined') {
}
type CachedWsState = {
stats: Stats
stats: ServerStats
cpuData: number[]
ramData: number[]
powerState: Archon.Websocket.v0.PowerState
@@ -822,6 +838,12 @@ const navLinks = computed<Tab[]>(() => [
icon: DatabaseBackupIcon,
subpages: [],
},
{
label: 'Access',
href: `/hosting/manage/${props.serverId}/access`,
icon: UsersIcon,
subpages: [],
},
...props.additionalTabs,
])
@@ -932,6 +954,13 @@ function loadTallyScript() {
async function handleContentRetry() {
if (!worldId.value) return
if (!canSetup.value) {
addNotification({
type: 'error',
text: permissionDeniedMessage.value,
})
return
}
try {
await client.archon.content_v1.repair(props.serverId, worldId.value)
} catch (err) {
@@ -1361,6 +1390,7 @@ const cleanup = () => {
saveWsStateToCache()
cleanupCoreRuntime(props.serverId)
disconnectPanelSync()
isReconnecting.value = false
isLoading.value = true