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
@@ -176,6 +176,17 @@ export const WithSubLabel: Story = {
},
}
export const LongSelectedLabel: Story = {
args: {
modelValue: 'long',
options: [
{ value: 'short', label: 'Short label' },
{ value: 'long', label: 'A much longer selected value that should truncate cleanly' },
],
triggerClass: 'w-64',
},
}
export const MixedSubLabels: Story = {
args: {
options: [
@@ -203,6 +214,35 @@ export const SearchableNoFilter: Story = {
},
}
export const SearchableMinimumLength: Story = {
args: {
options: [
{ value: 'fetch', label: 'Fetch' },
{ value: 'emma', label: 'Emma' },
{ value: 'boris', label: 'Boris' },
{ value: 'coolbot', label: 'Coolbot' },
],
searchable: true,
searchPlaceholder: 'Enter a username or email',
minSearchLengthToOpen: 2,
noOptionsMessage: 'No matching users found.',
},
}
export const SearchableModpacks: Story = {
args: {
options: [
{ value: 'download', label: 'Download', icon: DownloadIcon },
{ value: 'share', label: 'Share', icon: ShareIcon },
{ value: 'favorite', label: 'Add to favorites', icon: HeartIcon },
{ value: 'settings', label: 'Settings', icon: SettingsIcon },
],
searchable: true,
searchPlaceholder: 'Search modpacks...',
noOptionsMessage: 'No modpacks found',
},
}
export const WithDropdownFooter: StoryObj = {
render: () => ({
components: { Combobox, EyeIcon, EyeOffIcon },
@@ -114,6 +114,37 @@ export const Range: Story = {
}),
}
export const RangeWithTime: Story = {
render: () => ({
components: { DatePicker },
setup() {
const value = ref(['2026-04-13 09:00', '2026-04-29 17:30'])
const datePicker = ref<InstanceType<typeof DatePicker> | null>(null)
onMounted(async () => {
await nextTick()
datePicker.value?.open()
})
return { datePicker, value }
},
template: /* html */ `
<div class="flex h-[500px] max-w-sm flex-col gap-2">
<DatePicker
ref="datePicker"
v-model="value"
wrapperClass="w-[350px]"
mode="range"
enable-time
default-view-date="2026-04-01"
placeholder="Select a date and time range..."
/>
<p class="text-sm text-secondary">Selected value: {{ value?.join(' to ') || 'None' }}</p>
</div>
`,
}),
}
export const SameDayRange: Story = {
render: () => ({
components: { DatePicker },
@@ -183,6 +183,29 @@ export const WithAppliedFilters: Story = {
},
}
export const ImmediateApply: Story = {
render: () => ({
components: { DropdownFilterBar },
setup() {
const selected = ref<Record<string, string[]>>({})
return { categories: defaultCategories, selected }
},
template: /* html */ `
<div class="flex flex-col items-start gap-3">
<div class="flex flex-wrap items-center gap-2">
<DropdownFilterBar v-model="selected" :categories="categories" apply-immediately />
</div>
<pre class="m-0 rounded-lg bg-surface-2 px-3 py-2 text-sm text-secondary">{{ selected }}</pre>
</div>
`,
}),
args: {
modelValue: {},
categories: defaultCategories,
applyImmediately: true,
},
}
export const WithRightCheckmarks: Story = {
render: () => ({
components: { DropdownFilterBar },
@@ -199,6 +199,13 @@ export const WithSelectionIds: StoryObj = {
export const WithSorting: StoryObj = {
args: {},
parameters: {
docs: {
description: {
story: 'Sortable header hover and click targets are scoped to the label and sort icon.',
},
},
},
render: () => ({
components: { Table },
setup() {
@@ -0,0 +1,159 @@
import { MinecraftServerIcon } from '@modrinth/assets'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { NotificationToast } from '../../components/notifications'
const avatarUrl =
'https://cdn.modrinth.com/user/6Qo4A5QT/9d81be1a9fb1afd163b7f2f05a791955e7693c90.png'
const meta = {
title: 'Notifications/Toasts',
parameters: {
layout: 'centered',
},
decorators: [
(story) => ({
components: { story },
template:
'<div class="dark-mode flex w-[436px] flex-col gap-4 bg-surface-1 p-2"><story /></div>',
}),
],
} satisfies Meta
export default meta
type Story = StoryObj<typeof meta>
const noop = () => console.log('Notification action')
export const FigmaExamples: Story = {
render: () => ({
components: { NotificationToast },
setup() {
return {
avatarUrl,
instanceIconUrl: MinecraftServerIcon,
noop,
}
},
template: /* html */ `
<div class="flex flex-col gap-4">
<NotificationToast
type="friend-request"
actor-name="Fetch"
:actor-avatar-url="avatarUrl"
@accept="noop"
@decline="noop"
@dismiss="noop"
/>
<NotificationToast
type="server-invite"
actor-name="Fetch"
:actor-avatar-url="avatarUrl"
entity-name="Modrinth SMP"
@accept="noop"
@decline="noop"
@dismiss="noop"
@open-actor="noop"
/>
<NotificationToast
type="instance-invite"
actor-name="Fetch"
:actor-avatar-url="avatarUrl"
entity-name="New Creation"
:entity-icon-url="instanceIconUrl"
@accept="noop"
@decline="noop"
@dismiss="noop"
@open-actor="noop"
/>
<NotificationToast
type="instance-download"
entity-name="New Creation"
:entity-icon-url="instanceIconUrl"
status-text="Downloading Minecraft..."
:progress="0.5"
@dismiss="noop"
/>
<NotificationToast
type="instance-ready"
entity-name="New Creation"
:entity-icon-url="instanceIconUrl"
:progress="0.75"
@launch="noop"
@open-instance="noop"
@dismiss="noop"
/>
</div>
`,
}),
}
export const ServerInvite: Story = {
render: () => ({
components: { NotificationToast },
setup() {
return {
avatarUrl,
instanceIconUrl: MinecraftServerIcon,
noop,
}
},
template: /* html */ `
<NotificationToast
type="server-invite"
actor-name="Fetch"
:actor-avatar-url="avatarUrl"
entity-name="Modrinth SMP"
@accept="noop"
@decline="noop"
@dismiss="noop"
@open-actor="noop"
/>
`,
}),
}
export const MissingAvatarFallback: Story = {
render: () => ({
components: { NotificationToast },
setup() {
return {
instanceIconUrl: MinecraftServerIcon,
noop,
}
},
template: /* html */ `
<NotificationToast
type="server-invite"
actor-name="Fetch"
entity-name="Modrinth SMP"
@accept="noop"
@decline="noop"
@dismiss="noop"
@open-actor="noop"
/>
`,
}),
}
export const WaitingDownload: Story = {
render: () => ({
components: { NotificationToast },
setup() {
return {
instanceIconUrl: MinecraftServerIcon,
noop,
}
},
template: /* html */ `
<NotificationToast
type="instance-download"
entity-name="New Creation"
:entity-icon-url="instanceIconUrl"
status-text="Preparing files..."
waiting
@dismiss="noop"
/>
`,
}),
}
@@ -0,0 +1,147 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import AccessTable from '../../components/servers/access/AccessTable.vue'
import type {
ServerAccessMember,
ServerAccessRole,
ServerAccessRoleOption,
} from '../../components/servers/access/types'
const roleOptions: ServerAccessRoleOption[] = [
{
value: 'owner',
label: 'Owner',
description: 'Full access including billing, members, and destructive actions.',
},
{
value: 'editor',
label: 'Editor',
description: 'Manage instance content, files, backups, and other settings.',
},
{
value: 'viewer',
label: 'Limited',
description: 'Start, stop, and view the server without making changes.',
},
]
const members: ServerAccessMember[] = [
{
id: 'owner',
user: { id: 'prospector', username: 'Prospector' },
role: 'owner',
joinedAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
isOwner: true,
},
{
id: 'editor',
user: { id: 'geometrically', username: 'Geometrically' },
role: 'editor',
joinedAt: new Date(Date.now() - 21 * 24 * 60 * 60 * 1000).toISOString(),
},
{
id: 'pending',
user: { id: 'imb', username: 'IMB' },
role: 'viewer',
joinedAt: null,
pending: true,
},
]
const meta = {
title: 'Servers/AccessTable',
component: AccessTable,
parameters: {
layout: 'padded',
},
decorators: [
(story) => ({
components: { story },
template: '<div style="max-width: 960px;"><story /></div>',
}),
],
} satisfies Meta<typeof AccessTable>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { AccessTable },
setup() {
const rows = ref([...members])
function updateRole(member: ServerAccessMember, role: ServerAccessRole) {
rows.value = rows.value.map((row) => (row.id === member.id ? { ...row, role } : row))
}
function removeMember(member: ServerAccessMember) {
rows.value = rows.value.filter((row) => row.id !== member.id)
}
return { rows, roleOptions, updateRole, removeMember }
},
template: /* html */ `
<AccessTable
:members="rows"
:roles="roleOptions"
@update-role="updateRole"
@resend-invite="() => {}"
@cancel-invite="removeMember"
@remove-member="removeMember"
/>
`,
}),
}
export const PendingInvite: Story = {
args: {
members: [members[2]],
roles: roleOptions,
},
}
export const PendingInviteCooldown: Story = {
args: {
members: [
{
...members[2],
inviteResendAvailableAt: new Date(Date.now() + 45 * 1000).toISOString(),
},
],
roles: roleOptions,
},
}
export const OwnerFixed: Story = {
args: {
members: [members[0]],
roles: roleOptions,
},
}
export const MobileCompact: Story = {
render: () => ({
components: { AccessTable },
setup() {
const rows = ref([...members])
function updateRole(member: ServerAccessMember, role: ServerAccessRole) {
rows.value = rows.value.map((row) => (row.id === member.id ? { ...row, role } : row))
}
function removeMember(member: ServerAccessMember) {
rows.value = rows.value.filter((row) => row.id !== member.id)
}
return { rows, roleOptions, updateRole, removeMember }
},
template: /* html */ `
<div style="max-width: 390px;">
<AccessTable
:members="rows"
:roles="roleOptions"
@update-role="updateRole"
@resend-invite="() => {}"
@cancel-invite="removeMember"
@remove-member="removeMember"
/>
</div>
`,
}),
}
@@ -0,0 +1,929 @@
import type { Archon } from '@modrinth/api-client'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import DropdownFilterBar from '../../components/base/DropdownFilterBar.vue'
import type {
TimeFrameLastUnit,
TimeFrameMode,
TimeFramePreset,
} from '../../components/base/TimeFramePicker.vue'
import AuditLogTable from '../../components/servers/access/AuditLogTable.vue'
import { type AuditEventLookups, parseAuditEvent } from '../../components/servers/access/events'
import type {
ServerAuditLogEntry,
ServerAuditLogFilters,
} from '../../components/servers/access/types'
import auditLogEntryExamplesJson from './audit-log-entry-examples.json?raw'
const serverId = 'story-server'
const createWorldId = 'create-smp'
const activeWorldId = 'smp-season-4'
const backupId = '00000000-0000-4000-8000-000000000001'
type AuditLogEntryExamplesFixture = Archon.Actions.v1.ActionLogResponse & {
server_id: string
server: {
id: string
name: string
subdomain: string
}
worlds: Array<{
id: string
name: string
}>
backups: Archon.Backups.v1.Backup[]
}
const auditLogEntryExamples = JSON.parse(auditLogEntryExamplesJson) as AuditLogEntryExamplesFixture
const userIds = {
geometrically: 'MpxzqsyW',
modmuss: 'JZA4dW8o',
prospector: 'Dc7EYhxG',
}
const filterBarCategories = [
{
key: 'users',
label: 'Users',
searchable: true,
searchPlaceholder: 'Search users...',
options: [
{ value: userIds.geometrically, label: 'Geometrically' },
{ value: userIds.modmuss, label: 'modmuss50' },
{ value: userIds.prospector, label: 'Prospector' },
],
},
{
key: 'worlds',
label: 'Instances',
options: [
{ value: createWorldId, label: 'Create SMP' },
{ value: activeWorldId, label: 'SMP Season 4' },
],
},
{
key: 'actions',
label: 'Actions',
options: [
{ value: 'server_started', label: 'Started server' },
{ value: 'console_command_executed', label: 'Ran console command' },
{ value: 'backup_restored', label: 'Restored backup' },
],
},
]
const serverFullResponse: Archon.Servers.v1.ServerFull = {
id: serverId,
name: 'Stoneblock Lab',
subdomain: 'stoneblock-lab',
specs: {
cpu: 4,
memory_mb: 8192,
storage_mb: 65536,
swap_mb: 1024,
},
sftp_username: 'story-server',
sftp_password: 'temporary-password',
tags: ['medal'],
location: {
status: 'assigned',
location_metadata: {
region: 'us-east',
region_should_be_user_displayed: true,
hostname: 'ashburn-01',
is_decommissioned_node: false,
},
},
worlds: [
{
id: createWorldId,
name: 'Create SMP',
created_at: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString(),
is_active: false,
backups: [],
content: {
modloader: 'fabric',
modloader_version: '0.16.10',
game_version: '1.21.1',
java_version: 21,
invocation: 'java -Xmx8G -jar server.jar nogui',
original_invocation: 'java -jar server.jar nogui',
},
readiness: {
data_synchronized_fetched: true,
},
},
{
id: activeWorldId,
name: 'SMP Season 4',
created_at: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
is_active: true,
backups: [
{
id: backupId,
physical_id: backupId,
name: 'Before modpack update',
created_at: new Date(Date.now() - 8 * 60 * 60 * 1000).toISOString(),
automated: false,
status: 'done',
interrupted: false,
ongoing: false,
locked: false,
},
],
content: {
modloader: 'fabric',
modloader_version: '0.16.10',
game_version: '1.21.1',
java_version: 21,
invocation: 'java -Xmx8G -jar server.jar nogui',
original_invocation: 'java -jar server.jar nogui',
},
readiness: {
data_synchronized_fetched: true,
},
},
],
}
const auditLogEntryExamplesServerFullResponse: Archon.Servers.v1.ServerFull = {
...serverFullResponse,
id: auditLogEntryExamples.server_id,
name: auditLogEntryExamples.server.name,
subdomain: auditLogEntryExamples.server.subdomain,
worlds: auditLogEntryExamples.worlds.map((world, index) => ({
...serverFullResponse.worlds[Math.min(index, serverFullResponse.worlds.length - 1)],
id: world.id,
name: world.name,
backups: index === 0 ? auditLogEntryExamples.backups : [],
})),
}
const actionLogResponse: Archon.Actions.v1.ActionLogResponse = {
next_offset: null,
users: {
[userIds.geometrically]: {
username: 'Geometrically',
avatar_url:
'https://cdn.modrinth.com/data/MpxzqsyW/eb0038489a55e7e7a188a5b50462f0b10dfc1613_96.webp',
},
[userIds.modmuss]: {
username: 'modmuss50',
avatar_url: 'https://avatars2.githubusercontent.com/u/4324090?v=4',
},
[userIds.prospector]: {
username: 'Prospector',
avatar_url:
'https://cdn.modrinth.com/user/Dc7EYhxG/32e8b1f7d18288262d1ed92cbdf43272d21b4fcd.png',
},
},
addons: {
oWaK0Q19: {
title: 'Create Aeronautics',
slug: 'create-aeronautics',
icon_url:
'https://cdn.modrinth.com/data/oWaK0Q19/f66b5589924884ffd81acb27f3ccb775867a962e_96.webp',
version: null,
},
AANobbMI: {
title: 'Sodium',
slug: 'sodium',
icon_url:
'https://cdn.modrinth.com/data/AANobbMI/295862f4724dc3f78df3447ad6072b2dcd3ef0c9_96.webp',
version: null,
},
P7dR8mSH: {
title: 'Fabric API',
slug: 'fabric-api',
icon_url: 'https://cdn.modrinth.com/data/P7dR8mSH/icon.png',
version: null,
},
'project-modpack-001': {
title: 'Vault Hunters',
slug: 'vault-hunters',
icon_url: null,
version: null,
},
},
versions: {
HY8u0JqC: { name: 'Create Aeronautics 1.0.0', version_number: '1.0.0' },
yaoBL9D9: { name: 'Sodium 0.6.0', version_number: '0.6.0' },
KZS9tylY: { name: 'Fabric API 0.116.0', version_number: '0.116.0' },
'version-modpack-001': { name: 'Vault Hunters 3.15.1', version_number: '3.15.1' },
},
data: [
rawEntry({ action: 'server_created', worldId: null, minutesAgo: 5 }),
rawEntry({
action: 'changed_server_name',
metadata: { name: 'Stoneblock Lab' },
worldId: null,
minutesAgo: 10,
}),
rawEntry({
action: 'changed_server_subdomain',
metadata: { subdomain: 'stoneblock-lab' },
worldId: null,
minutesAgo: 15,
}),
rawEntry({ action: 'server_reallocated', worldId: null, minutesAgo: 20 }),
rawEntry({
action: 'server_plan_changed',
metadata: { new_specs: { cpu: 4, memory_mb: 8192, storage_mb: 65536, swap_mb: 1024 } },
worldId: null,
minutesAgo: 25,
}),
rawEntry({
action: 'user_invited',
metadata: { user_id: userIds.modmuss, permissions: -4611686018427387904 },
worldId: null,
minutesAgo: 30,
}),
rawEntry({
action: 'user_invite_revoked',
metadata: { user_id: userIds.modmuss },
worldId: null,
minutesAgo: 35,
}),
rawEntry({
action: 'user_permission_modified',
metadata: {
user_id: userIds.modmuss,
permissions: -288230376151711744,
},
worldId: null,
minutesAgo: 40,
}),
rawEntry({
action: 'user_removed',
metadata: { user_id: userIds.modmuss },
worldId: null,
minutesAgo: 45,
}),
rawEntry({
action: 'addon_added',
metadata: { addons: [{ addon_id: 'oWaK0Q19', version_id: 'HY8u0JqC' }] },
minutesAgo: 50,
}),
rawEntry({
action: 'addon_uploaded',
metadata: { file_names: ['custom-tweaks.jar', 'server-rules.datapack.zip'] },
minutesAgo: 55,
}),
rawEntry({
action: 'addon_disabled',
metadata: { addons: [{ addon_id: 'AANobbMI', version_id: 'yaoBL9D9' }] },
minutesAgo: 60,
}),
rawEntry({
action: 'addon_enabled',
metadata: { addons: [{ addon_id: 'AANobbMI', version_id: 'yaoBL9D9' }] },
minutesAgo: 65,
}),
rawEntry({
action: 'addon_deleted',
metadata: { addons: [{ addon_id: 'P7dR8mSH', version_id: 'KZS9tylY' }] },
minutesAgo: 70,
}),
rawEntry({
action: 'addon_updated',
metadata: {
addons: [
{ addon_id: 'AANobbMI', version_id: 'YAGZ1cCS' },
{ addon_id: 'P7dR8mSH', version_id: 'EW33COvi' },
],
},
minutesAgo: 75,
}),
rawEntry({
action: 'modpack_changed',
metadata: {
spec: {
platform: 'modrinth',
project_id: 'project-modpack-001',
version_id: 'version-modpack-001',
},
},
minutesAgo: 80,
}),
rawEntry({
action: 'modpack_unlinked',
metadata: {
spec: {
platform: 'modrinth',
project_id: 'project-modpack-001',
version_id: 'version-modpack-001',
},
},
minutesAgo: 85,
}),
rawEntry({ action: 'server_repaired', minutesAgo: 90 }),
rawEntry({ action: 'server_reset', minutesAgo: 95 }),
rawEntry({ action: 'server_started', minutesAgo: 100 }),
rawEntry({ action: 'server_stopped', minutesAgo: 105 }),
rawEntry({ action: 'server_restarted', minutesAgo: 110 }),
rawEntry({ action: 'server_killed', minutesAgo: 115 }),
rawEntry({
action: 'port_allocation_added',
metadata: { port: 25565 },
worldId: null,
minutesAgo: 120,
}),
rawEntry({
action: 'port_allocation_removed',
metadata: { port: 24454 },
worldId: null,
minutesAgo: 125,
}),
rawEntry({
action: 'loader_version_edited',
metadata: { new_loader: 'fabric', new_version: '0.16.10' },
minutesAgo: 130,
}),
rawEntry({
action: 'game_version_edited',
metadata: { new_version: '1.21.1' },
minutesAgo: 135,
}),
rawEntry({
action: 'server_properties_modified',
metadata: { properties: { difficulty: 'hard', 'max-players': '20' } },
minutesAgo: 140,
}),
rawEntry({ action: 'file_uploaded', metadata: { path: '/mods/custom.jar' }, minutesAgo: 145 }),
rawEntry({ action: 'file_deleted', metadata: { path: '/config/old.toml' }, minutesAgo: 150 }),
rawEntry({
action: 'file_renamed',
metadata: { from: '/world/old.dat', to: '/world/new.dat' },
minutesAgo: 155,
}),
rawEntry({ action: 'file_edited', metadata: { path: '/server.properties' }, minutesAgo: 160 }),
rawEntry({ action: 'sftp_login', minutesAgo: 165 }),
rawEntry({
action: 'console_command_executed',
metadata: { command: 'whitelist add Prospector' },
minutesAgo: 170,
}),
rawEntry({ action: 'console_cleared', minutesAgo: 175 }),
rawEntry({
action: 'backup_created',
metadata: { id: backupId },
minutesAgo: 180,
}),
rawEntry({
action: 'backup_renamed',
metadata: {
id: backupId,
from: 'Manual backup 1',
to: 'Before modpack update',
},
minutesAgo: 185,
}),
rawEntry({
action: 'backup_restored',
metadata: { id: backupId },
minutesAgo: 190,
}),
rawEntry({
action: 'backup_deleted',
metadata: { id: '00000000-0000-4000-8000-000000000099' },
minutesAgo: 195,
}),
rawEntry({
action: 'startup_command_modified',
metadata: { command: 'java -Xmx8G -jar server.jar nogui' },
minutesAgo: 200,
}),
rawEntry({
action: 'java_runtime_modified',
metadata: { vendor: 'temurin' },
minutesAgo: 205,
}),
rawEntry({
action: 'java_version_modified',
metadata: { version: 21 },
minutesAgo: 210,
}),
],
}
const missingLookupActionLogResponse: Archon.Actions.v1.ActionLogResponse = {
next_offset: null,
users: {},
addons: {},
versions: {},
data: [
rawEntry({
action: 'user_permission_modified',
metadata: { user_id: 'unknown-user', permissions: -9223372036854775808 },
worldId: 'unknown-world',
minutesAgo: 5,
}),
rawEntry({
action: 'addon_updated',
metadata: { addons: [{ addon_id: 'unknown-addon', version_id: 'unknown-version' }] },
minutesAgo: 10,
}),
rawEntry({
action: 'modpack_changed',
metadata: {
spec: {
platform: 'local_file',
filename: 'example-modpack-v1.20.mrpack',
},
},
minutesAgo: 15,
}),
rawEntry({
action: 'backup_deleted',
metadata: { id: '00000000-0000-4000-8000-000000000099' },
minutesAgo: 20,
}),
rawEntry({
action: 'changed_server_name',
metadata: { bad: true },
minutesAgo: 25,
}),
rawEntry({
action: 'backend_added_this_later',
metadata: { value: true },
actor: { type: 'support', user_id: userIds.prospector },
worldId: null,
minutesAgo: 30,
}),
],
}
const overflowAddonNames = [
"Alpha's Rise",
'Bookshelf',
'Diagonal Fences',
'Dragon Compass',
'Dragon Feed',
'Dragons of the Cosmos',
"Farmer's Delight",
'Fastload',
'Friendly Fire',
'Isle of Berk Addons',
'Jade',
'Kiwi',
"Nature's Compass",
'Off the Grid Dragons',
'Patchouli',
'Puzzles Lib',
'Resourceful Config',
'Waystones',
]
const overflowActionLogResponse: Archon.Actions.v1.ActionLogResponse = {
next_offset: null,
users: actionLogResponse.users,
addons: {
...Object.fromEntries(
overflowAddonNames.map((title, index) => [
`overflow-addon-${index}`,
{
title,
slug: `overflow-addon-${index}`,
icon_url: null,
version: null,
},
]),
),
'overflow-sodium': {
title: 'Sodium',
slug: 'sodium',
icon_url: null,
version: null,
},
'overflow-iris': {
title: 'Iris Shaders',
slug: 'iris',
icon_url: null,
version: null,
},
} as Record<string, Archon.Actions.v1.AddonResp>,
versions: {
...Object.fromEntries(
overflowAddonNames.map((title, index) => [
`overflow-version-${index}`,
{
name: `${title} 1.${index}.0`,
version_number: `1.${index}.0`,
},
]),
),
'overflow-sodium-version': {
name: 'Sodium mc1.21.1-0.6.13-fabric',
version_number: 'mc1.21.1-0.6.13-fabric',
},
'overflow-iris-version': {
name: 'Iris Shaders 1.8.8+1.21.1-fabric',
version_number: '1.8.8+1.21.1-fabric',
},
} as Record<string, Archon.Actions.v1.VersionResp>,
data: [
rawEntry({
action: 'addon_uploaded',
metadata: {
file_names: ['fabric-api-0.116.12+1.21.1.jar', 'lithium-fabric-0.15.3+mc1.21.1.jar'],
},
minutesAgo: 1,
}),
rawEntry({
action: 'addon_added',
metadata: {
addons: [
{ addon_id: 'overflow-sodium', version_id: 'overflow-sodium-version' },
{ addon_id: 'overflow-iris', version_id: 'overflow-iris-version' },
],
},
minutesAgo: 2,
}),
rawEntry({
action: 'addon_enabled',
metadata: {
addons: [
{ addon_id: 'overflow-sodium', version_id: 'overflow-sodium-version' },
{ addon_id: 'overflow-iris', version_id: 'overflow-iris-version' },
],
},
minutesAgo: 3,
}),
rawEntry({
action: 'addon_disabled',
metadata: {
addons: [
{ addon_id: 'overflow-sodium', version_id: 'overflow-sodium-version' },
{ addon_id: 'overflow-iris', version_id: 'overflow-iris-version' },
],
},
minutesAgo: 4,
}),
rawEntry({
action: 'addon_deleted',
metadata: {
addons: overflowAddonNames.map((_, index) => ({
addon_id: `overflow-addon-${index}`,
version_id: `overflow-version-${index}`,
})),
},
minutesAgo: 5,
}),
rawEntry({
action: 'startup_command_modified',
metadata: {
command:
'java -Xmx8192M -XX:+UseG1GC -XX:+UnlockExperimentalVMOptions -jar fabric-server-launch.jar nogui',
},
minutesAgo: 6,
}),
rawEntry({
action: 'server_properties_modified',
metadata: {
properties: {
difficulty: 'hard',
'enforce-whitelist': 'true',
'max-players': '20',
'resource-pack': 'https://cdn.example.invalid/very-long-resource-pack-name.zip',
},
},
minutesAgo: 7,
}),
],
}
type RawEntryInput = {
action: Archon.Actions.v1.ActionName | string
metadata?: unknown
actor?: Archon.Actions.v1.ActionUser
worldId?: string | null
minutesAgo: number
}
function rawEntry(input: RawEntryInput): Archon.Actions.v1.ActionEntry {
return {
actor: input.actor ?? { type: 'user', user_id: userIds.geometrically },
action:
input.metadata === undefined
? { action: input.action }
: { action: input.action, metadata: input.metadata },
server_id: serverId,
world_id: input.worldId ?? activeWorldId,
timestamp: new Date(Date.now() - input.minutesAgo * 60 * 1000).toISOString(),
}
}
function lookupsFromResponse(
response: Archon.Actions.v1.ActionLogResponse,
serverFull: Archon.Servers.v1.ServerFull,
): AuditEventLookups {
const backups = new Map<string, Archon.Backups.v1.Backup>()
for (const world of serverFull.worlds) {
for (const backup of world.backups) {
backups.set(backup.id, backup)
}
}
return {
serverId: serverFull.id,
users: response.users,
addons: response.addons,
worldById: new Map(
serverFull.worlds.map((world) => [world.id, { id: world.id, name: world.name }]),
),
backupById: backups,
versions: response.versions,
}
}
function toAuditEntries(
response: Archon.Actions.v1.ActionLogResponse,
serverFull = serverFullResponse,
): ServerAuditLogEntry[] {
const lookups = lookupsFromResponse(response, serverFull)
return response.data.map((entry, index) => {
const event = parseAuditEvent(entry, lookups)
return {
id: `${entry.timestamp}-${entry.actor.type}-${entry.world_id ?? 'server'}-${index}`,
actor: event.props.actor,
world: event.props.world,
event,
timestamp: entry.timestamp,
}
})
}
const everyActionEntries = toAuditEntries(
auditLogEntryExamples,
auditLogEntryExamplesServerFullResponse,
)
const sampleEntries = toAuditEntries(actionLogResponse)
const permissionBadgeEntries = sampleEntries.filter((entry) =>
['user_invited', 'user_permission_modified'].includes(entry.event.key),
)
const loaderVersionEntries = toAuditEntries({
...actionLogResponse,
data: [
rawEntry({
action: 'loader_version_edited',
metadata: { new_loader: 'fabric', new_version: '0.19.2' },
minutesAgo: 1,
}),
rawEntry({
action: 'loader_version_edited',
metadata: { new_loader: null, new_version: '0.30.0-beta.7' },
minutesAgo: 2,
}),
rawEntry({
action: 'loader_version_edited',
metadata: { new_loader: null, new_version: null },
minutesAgo: 3,
}),
],
})
const fallbackEntries = toAuditEntries(missingLookupActionLogResponse, {
...serverFullResponse,
worlds: [],
})
const overflowEntries = toAuditEntries(overflowActionLogResponse)
const meta = {
title: 'Servers/AuditLogTable',
component: AuditLogTable,
parameters: {
layout: 'padded',
docs: {
description: {
component: 'Audit entries render backend action-log events through typed event components.',
},
},
},
decorators: [
(story) => ({
components: { story },
template: '<div style="max-width: 1120px;"><story /></div>',
}),
],
} satisfies Meta<typeof AuditLogTable>
export default meta
type Story = StoryObj<typeof meta>
function createTimeframeState(
initial: Partial<{
mode: TimeFrameMode
preset: TimeFramePreset
lastAmount: number
lastUnit: TimeFrameLastUnit
customStartDate: string
customEndDate: string
}> = {},
) {
return {
timeframeCustomEndDate: ref(initial.customEndDate ?? ''),
timeframeCustomStartDate: ref(initial.customStartDate ?? ''),
timeframeLastAmount: ref(initial.lastAmount ?? 30),
timeframeLastUnit: ref<TimeFrameLastUnit>(initial.lastUnit ?? 'days'),
timeframeMode: ref<TimeFrameMode>(initial.mode ?? 'preset'),
timeframePreset: ref<TimeFramePreset>(initial.preset ?? 'all_time'),
}
}
function renderStory(entries: ServerAuditLogEntry[], initialQuery = '') {
return () => ({
components: { AuditLogTable },
setup() {
const query = ref(initialQuery)
const filters = ref<ServerAuditLogFilters>({
userId: null,
worldId: null,
})
return { entries, filters, query, ...createTimeframeState() }
},
template: /* html */ `
<AuditLogTable
v-model:query="query"
v-model:timeframe-mode="timeframeMode"
v-model:timeframe-preset="timeframePreset"
v-model:timeframe-last-amount="timeframeLastAmount"
v-model:timeframe-last-unit="timeframeLastUnit"
v-model:timeframe-custom-start-date="timeframeCustomStartDate"
v-model:timeframe-custom-end-date="timeframeCustomEndDate"
v-model:filters="filters"
:entries="entries"
/>
`,
})
}
export const AllEvents: Story = {
render: renderStory(everyActionEntries),
}
export const PermissionBadges: Story = {
render: renderStory(permissionBadgeEntries),
}
export const LoaderVersionEvents: Story = {
render: renderStory(loaderVersionEntries),
}
export const LongOverflowTooltip: Story = {
render: renderStory(overflowEntries),
}
export const MissingLookupsAndFallbacks: Story = {
render: renderStory(fallbackEntries),
}
export const Filtered: Story = {
render: renderStory(sampleEntries, 'Create Aeronautics'),
}
export const WithExternalFilterControls: Story = {
render: () => ({
components: { AuditLogTable, DropdownFilterBar },
setup() {
const query = ref('')
const filters = ref<ServerAuditLogFilters>({
userId: null,
worldId: null,
})
const externalFilters = ref<Record<string, string[]>>({
users: [userIds.geometrically],
worlds: [],
actions: [],
})
return {
categories: filterBarCategories,
entries: sampleEntries,
externalFilters,
filters,
query,
...createTimeframeState(),
}
},
template: /* html */ `
<AuditLogTable
v-model:query="query"
v-model:timeframe-mode="timeframeMode"
v-model:timeframe-preset="timeframePreset"
v-model:timeframe-last-amount="timeframeLastAmount"
v-model:timeframe-last-unit="timeframeLastUnit"
v-model:timeframe-custom-start-date="timeframeCustomStartDate"
v-model:timeframe-custom-end-date="timeframeCustomEndDate"
v-model:filters="filters"
:entries="entries"
has-active-external-filters
>
<template #filters>
<DropdownFilterBar
v-model="externalFilters"
:categories="categories"
add-label="Add filter"
clear-label="Clear"
use-filter-icon
/>
</template>
</AuditLogTable>
`,
}),
}
export const EmptyExternalFilters: Story = {
render: () => ({
components: { AuditLogTable },
setup() {
const query = ref('')
const filters = ref<ServerAuditLogFilters>({
userId: null,
worldId: null,
})
return { entries: [], filters, query, ...createTimeframeState() }
},
template: /* html */ `
<AuditLogTable
v-model:query="query"
v-model:timeframe-mode="timeframeMode"
v-model:timeframe-preset="timeframePreset"
v-model:timeframe-last-amount="timeframeLastAmount"
v-model:timeframe-last-unit="timeframeLastUnit"
v-model:timeframe-custom-start-date="timeframeCustomStartDate"
v-model:timeframe-custom-end-date="timeframeCustomEndDate"
v-model:filters="filters"
:entries="entries"
has-active-external-filters
/>
`,
}),
}
export const MediumAppWindow: Story = {
render: () => ({
components: { AuditLogTable },
setup() {
const query = ref('')
const filters = ref<ServerAuditLogFilters>({
userId: null,
worldId: null,
})
return {
entries: everyActionEntries.slice(0, 8),
filters,
query,
...createTimeframeState(),
}
},
template: /* html */ `
<div style="max-width: 860px;">
<AuditLogTable
v-model:query="query"
v-model:timeframe-mode="timeframeMode"
v-model:timeframe-preset="timeframePreset"
v-model:timeframe-last-amount="timeframeLastAmount"
v-model:timeframe-last-unit="timeframeLastUnit"
v-model:timeframe-custom-start-date="timeframeCustomStartDate"
v-model:timeframe-custom-end-date="timeframeCustomEndDate"
v-model:filters="filters"
:entries="entries"
/>
</div>
`,
}),
}
export const MobileCompact: Story = {
render: () => ({
components: { AuditLogTable },
setup() {
const query = ref('')
const filters = ref<ServerAuditLogFilters>({
userId: null,
worldId: null,
})
return {
entries: everyActionEntries.slice(0, 8),
filters,
query,
...createTimeframeState(),
}
},
template: /* html */ `
<div style="max-width: 390px;">
<AuditLogTable
v-model:query="query"
v-model:timeframe-mode="timeframeMode"
v-model:timeframe-preset="timeframePreset"
v-model:timeframe-last-amount="timeframeLastAmount"
v-model:timeframe-last-unit="timeframeLastUnit"
v-model:timeframe-custom-start-date="timeframeCustomStartDate"
v-model:timeframe-custom-end-date="timeframeCustomEndDate"
v-model:filters="filters"
:entries="entries"
/>
</div>
`,
}),
}
@@ -1,11 +1,14 @@
import type { Archon, UploadState } from '@modrinth/api-client'
import type { Stats } from '@modrinth/utils'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { computed, ref } from 'vue'
import EditServerIcon from '../../components/servers/edit-server-icon/EditServerIcon.vue'
import { provideModrinthServerContext } from '../../providers'
import type { CancelUploadHandler, ModrinthServerContext } from '../../providers/server-context'
import type {
CancelUploadHandler,
ModrinthServerContext,
ServerStats,
} from '../../providers/server-context'
const meta = {
title: 'Servers/EditServerIcon',
@@ -22,7 +25,7 @@ const meta = {
upstream: null,
} as Archon.Servers.v0.Server)
const stats = ref<Stats>({
const stats = ref<ServerStats>({
current: {
cpu_percent: 0,
ram_usage_bytes: 0,
@@ -59,6 +62,8 @@ const meta = {
},
worldId: ref(null),
server,
serverFull: computed(() => null),
currentUserPermissions: computed(() => 0),
isConnected: ref(true),
isWsAuthIncorrect: ref(false),
powerState: ref('running'),
@@ -0,0 +1,98 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import ButtonStyled from '../../components/base/ButtonStyled.vue'
import GrantAccessModal from '../../components/servers/access/GrantAccessModal.vue'
import type {
GrantServerAccessPayload,
ServerAccessMember,
} from '../../components/servers/access/types'
const meta = {
title: 'Servers/GrantAccessModal',
component: GrantAccessModal,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'Role descriptions use the same instance-focused copy as the access page. The username field searches users asynchronously before showing the empty state.',
},
},
},
} satisfies Meta<typeof GrantAccessModal>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { ButtonStyled, GrantAccessModal },
setup() {
const modalRef = ref<InstanceType<typeof GrantAccessModal> | null>(null)
const lastAddedUser = ref('')
const users = [
{ id: 'fetch', username: 'Fetch' },
{ id: 'emma', username: 'Emma' },
]
async function searchUsers(query: string) {
await new Promise((resolve) => setTimeout(resolve, 250))
const normalizedQuery = query.trim().toLowerCase()
return users.filter((user) => user.username.toLowerCase().startsWith(normalizedQuery))
}
function handleGrant(payload: GrantServerAccessPayload) {
lastAddedUser.value = `${payload.target} as ${payload.role}${
payload.addAsFriend ? ' with friend request' : ''
}`
}
return { modalRef, searchUsers, lastAddedUser, handleGrant }
},
template: /* html */ `
<div class="flex flex-col items-center gap-4">
<ButtonStyled color="brand">
<button @click="modalRef?.show($event)">Add user</button>
</ButtonStyled>
<p v-if="lastAddedUser" class="m-0 text-sm text-secondary">Last added: {{ lastAddedUser }}</p>
<GrantAccessModal ref="modalRef" :search-users="searchUsers" @grant="handleGrant" />
</div>
`,
}),
}
export const ExistingMember: Story = {
render: () => ({
components: { ButtonStyled, GrantAccessModal },
setup() {
const modalRef = ref<InstanceType<typeof GrantAccessModal> | null>(null)
const users = [
{ id: 'josh11', username: 'josh11' },
{ id: 'emma', username: 'Emma' },
]
const members: ServerAccessMember[] = [
{
id: 'story-josh11',
user: {
id: 'josh11',
username: 'josh11',
},
role: 'editor',
joinedAt: new Date().toISOString(),
},
]
async function searchUsers(query: string) {
await new Promise((resolve) => setTimeout(resolve, 250))
const normalizedQuery = query.trim().toLowerCase()
return users.filter((user) => user.username.toLowerCase().startsWith(normalizedQuery))
}
return { modalRef, members, searchUsers }
},
template: /* html */ `
<div class="flex flex-col items-center gap-4">
<ButtonStyled color="brand">
<button @click="modalRef?.show($event)">Add existing user</button>
</ButtonStyled>
<GrantAccessModal ref="modalRef" :members="members" :search-users="searchUsers" />
</div>
`,
}),
}
@@ -41,6 +41,16 @@ export const Default: Story = {
},
}
export const SharedWithOwner: Story = {
args: {
...baseMedalServer,
name: 'Medal Co-op Server',
owner: {
username: 'Prospector',
},
},
}
export const ConfiguringNewServer: Story = {
args: {
...baseMedalServer,
@@ -0,0 +1,76 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import ButtonStyled from '../../components/base/ButtonStyled.vue'
import RemoveAccessModal from '../../components/servers/access/RemoveAccessModal.vue'
const meta = {
title: 'Servers/RemoveAccessModal',
component: RemoveAccessModal,
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof RemoveAccessModal>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { ButtonStyled, RemoveAccessModal },
setup() {
const modalRef = ref<InstanceType<typeof RemoveAccessModal> | null>(null)
const removed = ref(false)
function handleRemove() {
removed.value = true
}
return { modalRef, removed, handleRemove }
},
template: /* html */ `
<div class="flex flex-col items-center gap-4">
<ButtonStyled color="red">
<button @click="modalRef?.show()">Remove user</button>
</ButtonStyled>
<p v-if="removed" class="m-0 text-sm text-secondary">User removed</p>
<RemoveAccessModal
ref="modalRef"
username="Geometrically"
role="editor"
:joined-at="new Date(Date.now() - 21 * 24 * 60 * 60 * 1000).toISOString()"
@remove="handleRemove"
/>
</div>
`,
}),
}
export const CancelInvite: Story = {
render: () => ({
components: { ButtonStyled, RemoveAccessModal },
setup() {
const modalRef = ref<InstanceType<typeof RemoveAccessModal> | null>(null)
const cancelled = ref(false)
function handleCancel() {
cancelled.value = true
}
return { modalRef, cancelled, handleCancel }
},
template: /* html */ `
<div class="flex flex-col items-center gap-4">
<ButtonStyled color="red">
<button @click="modalRef?.show()">Cancel invite</button>
</ButtonStyled>
<p v-if="cancelled" class="m-0 text-sm text-secondary">Invite cancelled</p>
<RemoveAccessModal
ref="modalRef"
username="Geometrically"
role="viewer"
:joined-at="null"
pending
should-cancel
@remove="handleCancel"
/>
</div>
`,
}),
}
@@ -50,6 +50,16 @@ export const Default: Story = {
},
}
export const SharedWithOwner: Story = {
args: {
...baseServer,
name: 'Cobbletown',
owner: {
username: 'Prospector',
},
},
}
export const ConfiguringNewServer: Story = {
args: {
...baseServer,
@@ -1,5 +1,4 @@
import type { Archon, UploadState } from '@modrinth/api-client'
import type { Stats } from '@modrinth/utils'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
@@ -8,7 +7,11 @@ import ServerPanelAdmonitions from '../../components/servers/admonitions/ServerP
import { defineMessage } from '../../composables/i18n'
import type { FileOperation } from '../../layouts/shared/files-tab/types'
import { provideModrinthServerContext } from '../../providers'
import type { CancelUploadHandler, ModrinthServerContext } from '../../providers/server-context'
import type {
CancelUploadHandler,
ModrinthServerContext,
ServerStats,
} from '../../providers/server-context'
const meta = {
title: 'Servers/ServerPanelAdmonitions',
@@ -31,7 +34,7 @@ const meta = {
upstream: null,
} as Archon.Servers.v0.Server)
const stats = ref<Stats>({
const stats = ref<ServerStats>({
current: {
cpu_percent: 0,
ram_usage_bytes: 0,
@@ -76,6 +79,8 @@ const meta = {
},
worldId: ref(null),
server,
serverFull: computed(() => null),
currentUserPermissions: computed(() => 0),
isConnected: ref(true),
isWsAuthIncorrect: ref(false),
powerState: ref('running'),
@@ -0,0 +1,930 @@
{
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"server": {
"id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"name": "CaffeineMC Fabric SMP",
"subdomain": "caffeine-smp"
},
"worlds": [
{
"id": "7dd2f1a9-2a94-41fb-a0c7-92653ec980ab",
"name": "Overworld"
},
{
"id": "ba1a5666-96fc-4de5-9b91-977dd7b58d09",
"name": "Creative testing"
}
],
"backups": [
{
"id": "142f5ee6-a239-4824-bc69-5db0e9564c24",
"physical_id": "restic:4f3d5df7/142f5ee6-a239-4824-bc69-5db0e9564c24",
"name": "Pre-1.21.1 upgrade",
"created_at": "2026-05-20T11:40:00Z",
"automated": false,
"status": "done",
"interrupted": false,
"ongoing": false,
"locked": false
},
{
"id": "2f9c9471-baba-45ff-a2c1-23c9962095ec",
"physical_id": "restic:4f3d5df7/2f9c9471-baba-45ff-a2c1-23c9962095ec",
"name": "Before Nether reset",
"created_at": "2026-05-20T13:55:00Z",
"automated": false,
"status": "done",
"interrupted": false,
"ongoing": false,
"locked": false
},
{
"id": "6f2ef8ef-3329-4c77-bb06-8a1b6b1db0f0",
"physical_id": "restic:4f3d5df7/6f2ef8ef-3329-4c77-bb06-8a1b6b1db0f0",
"name": "Rollback after datapack test",
"created_at": "2026-05-20T14:15:00Z",
"automated": false,
"status": "done",
"interrupted": false,
"ongoing": false,
"locked": true
}
],
"next_offset": null,
"data": [
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "server_created"
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": null,
"timestamp": "2026-05-20T09:00:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "changed_server_name",
"metadata": {
"name": "CaffeineMC Fabric SMP"
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": null,
"timestamp": "2026-05-20T09:05:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "changed_server_subdomain",
"metadata": {
"subdomain": "caffeine-smp"
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": null,
"timestamp": "2026-05-20T09:10:00Z"
},
{
"actor": {
"type": "support",
"user_id": "JZA4dW8o"
},
"action": {
"action": "server_reallocated"
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": null,
"timestamp": "2026-05-20T09:15:00Z"
},
{
"actor": {
"type": "support",
"user_id": "JZA4dW8o"
},
"action": {
"action": "server_plan_changed",
"metadata": {
"new_specs": {
"cpu": 4,
"memory_mb": 8192,
"storage_mb": 40960,
"swap_mb": 1024
}
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": null,
"timestamp": "2026-05-20T09:20:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "user_invited",
"metadata": {
"user_id": "JZA4dW8o",
"permissions": "BASE_READ | POWER_ACTIONS"
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": null,
"timestamp": "2026-05-20T09:25:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "user_invite_revoked",
"metadata": {
"user_id": "DzLrfrbK"
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": null,
"timestamp": "2026-05-20T09:30:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "user_permission_modified",
"metadata": {
"user_id": "JZA4dW8o",
"permissions": "BASE_READ | FILES_WRITE | SETUP | BACKUPS"
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": null,
"timestamp": "2026-05-20T09:35:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "user_removed",
"metadata": {
"user_id": "DzLrfrbK"
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": null,
"timestamp": "2026-05-20T09:40:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "addon_added",
"metadata": {
"addons": [
{
"addon_id": "AANobbMI",
"version_id": "u1OEbNKx"
}
]
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": "7dd2f1a9-2a94-41fb-a0c7-92653ec980ab",
"timestamp": "2026-05-20T09:45:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "addon_added",
"metadata": {
"addons": [
{
"addon_id": "P7dR8mSH",
"version_id": "Lwt6YYHL"
},
{
"addon_id": "gvQqBUqZ",
"version_id": "XQJtuOTA"
}
]
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": "7dd2f1a9-2a94-41fb-a0c7-92653ec980ab",
"timestamp": "2026-05-20T09:50:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "addon_uploaded",
"metadata": {
"file_names": ["sodium-fabric-0.6.13+mc1.21.1.jar"]
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": "7dd2f1a9-2a94-41fb-a0c7-92653ec980ab",
"timestamp": "2026-05-20T09:55:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "addon_uploaded",
"metadata": {
"file_names": ["fabric-api-0.116.12+1.21.1.jar", "lithium-fabric-0.15.3+mc1.21.1.jar"]
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": "7dd2f1a9-2a94-41fb-a0c7-92653ec980ab",
"timestamp": "2026-05-20T10:00:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "addon_disabled",
"metadata": {
"addons": [
{
"addon_id": "YL57xq9U",
"version_id": "zsoi0dso"
}
]
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": "7dd2f1a9-2a94-41fb-a0c7-92653ec980ab",
"timestamp": "2026-05-20T10:05:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "addon_disabled",
"metadata": {
"addons": [
{
"addon_id": "AANobbMI",
"version_id": "u1OEbNKx"
},
{
"addon_id": "YL57xq9U",
"version_id": "zsoi0dso"
}
]
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": "7dd2f1a9-2a94-41fb-a0c7-92653ec980ab",
"timestamp": "2026-05-20T10:10:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "addon_enabled",
"metadata": {
"addons": [
{
"addon_id": "YL57xq9U",
"version_id": "zsoi0dso"
}
]
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": "7dd2f1a9-2a94-41fb-a0c7-92653ec980ab",
"timestamp": "2026-05-20T10:15:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "addon_enabled",
"metadata": {
"addons": [
{
"addon_id": "AANobbMI",
"version_id": "u1OEbNKx"
},
{
"addon_id": "YL57xq9U",
"version_id": "zsoi0dso"
}
]
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": "7dd2f1a9-2a94-41fb-a0c7-92653ec980ab",
"timestamp": "2026-05-20T10:20:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "addon_deleted",
"metadata": {
"addons": [
{
"addon_id": "YL57xq9U",
"version_id": "zsoi0dso"
}
]
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": "7dd2f1a9-2a94-41fb-a0c7-92653ec980ab",
"timestamp": "2026-05-20T10:25:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "addon_deleted",
"metadata": {
"addons": [
{
"addon_id": "AANobbMI",
"version_id": "u1OEbNKx"
},
{
"addon_id": "P7dR8mSH",
"version_id": "Lwt6YYHL"
}
]
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": "7dd2f1a9-2a94-41fb-a0c7-92653ec980ab",
"timestamp": "2026-05-20T10:30:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "addon_updated",
"metadata": {
"addons": [
{
"addon_id": "AANobbMI",
"version_id": "u1OEbNKx"
}
]
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": "7dd2f1a9-2a94-41fb-a0c7-92653ec980ab",
"timestamp": "2026-05-20T10:35:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "addon_updated",
"metadata": {
"addons": [
{
"addon_id": "P7dR8mSH",
"version_id": "Lwt6YYHL"
},
{
"addon_id": "gvQqBUqZ",
"version_id": "XQJtuOTA"
}
]
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": "7dd2f1a9-2a94-41fb-a0c7-92653ec980ab",
"timestamp": "2026-05-20T10:40:00Z"
},
{
"actor": {
"type": "user",
"user_id": "uk2lIMmm"
},
"action": {
"action": "modpack_changed",
"metadata": {
"spec": {
"platform": "modrinth",
"project_id": "BYN9yKrV",
"version_id": "w0KHzZ43",
"mrpack_sha1": "6b277e2f6e3a9a1acef3342bd154ade25031ad70"
}
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": "7dd2f1a9-2a94-41fb-a0c7-92653ec980ab",
"timestamp": "2026-05-20T10:45:00Z"
},
{
"actor": {
"type": "user",
"user_id": "uk2lIMmm"
},
"action": {
"action": "modpack_unlinked",
"metadata": {
"spec": {
"platform": "modrinth",
"project_id": "BYN9yKrV",
"version_id": "w0KHzZ43",
"mrpack_sha1": "6b277e2f6e3a9a1acef3342bd154ade25031ad70"
}
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": "7dd2f1a9-2a94-41fb-a0c7-92653ec980ab",
"timestamp": "2026-05-20T10:50:00Z"
},
{
"actor": {
"type": "support",
"user_id": "JZA4dW8o"
},
"action": {
"action": "server_repaired"
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": "7dd2f1a9-2a94-41fb-a0c7-92653ec980ab",
"timestamp": "2026-05-20T10:55:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "server_reset"
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": "ba1a5666-96fc-4de5-9b91-977dd7b58d09",
"timestamp": "2026-05-20T11:00:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "server_started"
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": null,
"timestamp": "2026-05-20T11:05:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "server_stopped"
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": null,
"timestamp": "2026-05-20T11:10:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "server_restarted"
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": null,
"timestamp": "2026-05-20T11:15:00Z"
},
{
"actor": {
"type": "support",
"user_id": "JZA4dW8o"
},
"action": {
"action": "server_killed"
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": null,
"timestamp": "2026-05-20T11:20:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "port_allocation_added",
"metadata": {
"port": 25565
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": "7dd2f1a9-2a94-41fb-a0c7-92653ec980ab",
"timestamp": "2026-05-20T11:25:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "port_allocation_removed",
"metadata": {
"port": 24454
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": "7dd2f1a9-2a94-41fb-a0c7-92653ec980ab",
"timestamp": "2026-05-20T11:30:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "loader_version_edited",
"metadata": {
"new_loader": "fabric",
"new_version": "0.16.14"
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": "7dd2f1a9-2a94-41fb-a0c7-92653ec980ab",
"timestamp": "2026-05-20T11:35:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "game_version_edited",
"metadata": {
"new_version": "1.21.1"
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": "7dd2f1a9-2a94-41fb-a0c7-92653ec980ab",
"timestamp": "2026-05-20T11:40:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "server_properties_modified",
"metadata": {
"properties": {
"difficulty": "hard"
}
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": "7dd2f1a9-2a94-41fb-a0c7-92653ec980ab",
"timestamp": "2026-05-20T11:45:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "server_properties_modified",
"metadata": {
"properties": {
"max-players": "20",
"simulation-distance": "8",
"view-distance": "10"
}
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": "7dd2f1a9-2a94-41fb-a0c7-92653ec980ab",
"timestamp": "2026-05-20T11:50:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "file_uploaded",
"metadata": {
"path": "/mods/lithium-fabric-0.15.3+mc1.21.1.jar"
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": "7dd2f1a9-2a94-41fb-a0c7-92653ec980ab",
"timestamp": "2026-05-20T11:55:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "file_deleted",
"metadata": {
"path": "/mods/old-performance-patch.jar"
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": "7dd2f1a9-2a94-41fb-a0c7-92653ec980ab",
"timestamp": "2026-05-20T12:00:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "file_renamed",
"metadata": {
"from": "/world/datapacks/trial-test.zip",
"to": "/world/datapacks/trial-test.zip.disabled"
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": "7dd2f1a9-2a94-41fb-a0c7-92653ec980ab",
"timestamp": "2026-05-20T12:05:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "file_edited",
"metadata": {
"path": "/server.properties"
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": "7dd2f1a9-2a94-41fb-a0c7-92653ec980ab",
"timestamp": "2026-05-20T12:10:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "sftp_login"
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": null,
"timestamp": "2026-05-20T12:15:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "console_command_executed",
"metadata": {
"command": "say Maintenance in 5 minutes"
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": "7dd2f1a9-2a94-41fb-a0c7-92653ec980ab",
"timestamp": "2026-05-20T12:20:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "console_cleared"
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": "7dd2f1a9-2a94-41fb-a0c7-92653ec980ab",
"timestamp": "2026-05-20T12:25:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "backup_created",
"metadata": {
"id": "142f5ee6-a239-4824-bc69-5db0e9564c24"
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": "7dd2f1a9-2a94-41fb-a0c7-92653ec980ab",
"timestamp": "2026-05-20T12:30:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "backup_renamed",
"metadata": {
"id": "2f9c9471-baba-45ff-a2c1-23c9962095ec",
"from": "Before Nether test",
"to": "Before Nether reset"
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": "7dd2f1a9-2a94-41fb-a0c7-92653ec980ab",
"timestamp": "2026-05-20T12:35:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "backup_restored",
"metadata": {
"id": "6f2ef8ef-3329-4c77-bb06-8a1b6b1db0f0"
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": "7dd2f1a9-2a94-41fb-a0c7-92653ec980ab",
"timestamp": "2026-05-20T12:40:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "backup_deleted",
"metadata": {
"id": "0dd0eb1e-f0e0-46d1-b32f-5245e18bf563"
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": "ba1a5666-96fc-4de5-9b91-977dd7b58d09",
"timestamp": "2026-05-20T12:45:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "startup_command_modified",
"metadata": {
"command": "java -Xms4G -Xmx8G -jar fabric-server-launch.jar nogui"
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": "7dd2f1a9-2a94-41fb-a0c7-92653ec980ab",
"timestamp": "2026-05-20T12:50:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "java_runtime_modified",
"metadata": {
"vendor": "temurin"
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": "7dd2f1a9-2a94-41fb-a0c7-92653ec980ab",
"timestamp": "2026-05-20T12:55:00Z"
},
{
"actor": {
"type": "user",
"user_id": "TEZXhE2U"
},
"action": {
"action": "java_version_modified",
"metadata": {
"version": 21
}
},
"server_id": "4f3d5df7-4e75-4f08-a1f5-fc472fb7b7bd",
"world_id": "7dd2f1a9-2a94-41fb-a0c7-92653ec980ab",
"timestamp": "2026-05-20T13:00:00Z"
}
],
"users": {
"TEZXhE2U": {
"username": "jellysquid3",
"avatar_url": "https://cdn.modrinth.com/user/TEZXhE2U/f4705a5f2388c65029ae2e59f1434b3e6e4de23a.png"
},
"JZA4dW8o": {
"username": "modmuss50",
"avatar_url": "https://avatars2.githubusercontent.com/u/4324090?v=4"
},
"DzLrfrbK": {
"username": "IMS",
"avatar_url": "https://avatars3.githubusercontent.com/u/31803019?v=4"
},
"uk2lIMmm": {
"username": "devin",
"avatar_url": "https://cdn.modrinth.com/user/uk2lIMmm/9ceb45272fef68343dcbc01696eb1c6856acb701.png"
}
},
"addons": {
"AANobbMI": {
"title": "Sodium",
"slug": "sodium",
"icon_url": "https://cdn.modrinth.com/data/AANobbMI/295862f4724dc3f78df3447ad6072b2dcd3ef0c9_96.webp",
"version": null
},
"P7dR8mSH": {
"title": "Fabric API",
"slug": "fabric-api",
"icon_url": "https://cdn.modrinth.com/data/P7dR8mSH/icon.png",
"version": null
},
"YL57xq9U": {
"title": "Iris Shaders",
"slug": "iris",
"icon_url": "https://cdn.modrinth.com/data/YL57xq9U/18d0e7f076d3d6ed5bedd472b853909aac5da202_96.webp",
"version": null
},
"gvQqBUqZ": {
"title": "Lithium",
"slug": "lithium",
"icon_url": "https://cdn.modrinth.com/data/gvQqBUqZ/bcc8686c13af0143adf4285d741256af824f70b7_96.webp",
"version": null
},
"BYN9yKrV": {
"title": "Adrenaline",
"slug": "adrenaline",
"icon_url": "https://cdn.modrinth.com/data/BYN9yKrV/98f14519960b17418d5ba5ebfdc46c85154ad87b_96.webp",
"version": null
}
},
"versions": {
"u1OEbNKx": {
"name": "Sodium 0.6.13 for Fabric 1.21.1",
"version_number": "mc1.21.1-0.6.13-fabric"
},
"Lwt6YYHL": {
"name": "[1.21.1] Fabric API 0.116.12+1.21.1",
"version_number": "0.116.12+1.21.1"
},
"zsoi0dso": {
"name": "Iris 1.8.8 for Fabric 1.21.1",
"version_number": "1.8.8+1.21.1-fabric"
},
"XQJtuOTA": {
"name": "Lithium 0.15.3 for Fabric",
"version_number": "mc1.21.1-0.15.3-fabric"
},
"w0KHzZ43": {
"name": "26.1.4+mc1.21.1.fabric",
"version_number": "26.1.4+mc1.21.1.fabric"
}
}
}