Compare commits

..

1 Commits

Author SHA1 Message Date
fetch
11a2ad61b8 Include region in user subscription metadata 2025-06-02 00:26:34 -04:00
294 changed files with 5326 additions and 7587 deletions

View File

@@ -1,6 +1,6 @@
# Windows has stack overflows when calling from Tauri, so we increase the default stack size used by the compiler
# Windows has stack overflows when calling from Tauri, so we increase compiler size
[target.'cfg(windows)']
rustflags = ["-C", "link-args=/STACK:16777220"]
[build]
rustflags = ["--cfg", "tokio_unstable"]
rustflags = ["--cfg", "tokio_unstable"]

View File

@@ -32,16 +32,16 @@ jobs:
- name: Rust setup (mac)
if: startsWith(matrix.platform, 'macos')
uses: actions-rust-lang/setup-rust-toolchain@v1
uses: dtolnay/rust-toolchain@stable
with:
rustflags: ''
target: x86_64-apple-darwin
components: rustfmt, clippy
targets: aarch64-apple-darwin, x86_64-apple-darwin
- name: Rust setup
if: "!startsWith(matrix.platform, 'macos')"
uses: actions-rust-lang/setup-rust-toolchain@v1
uses: dtolnay/rust-toolchain@stable
with:
rustflags: ''
components: rustfmt, clippy
- name: Setup rust cache
uses: actions/cache@v4
@@ -72,10 +72,10 @@ jobs:
restore-keys: |
${{ runner.os }}-rust-target-
- name: Setup Node.js
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
node-version: 20
- name: Install pnpm via corepack
shell: bash

View File

@@ -2,7 +2,7 @@ name: CI
on:
push:
branches: [main]
branches: ['main']
pull_request:
types: [opened, synchronize]
merge_group:
@@ -10,69 +10,71 @@ on:
jobs:
build:
name: Lint and Test
name: Build, Test, and Lint
runs-on: ubuntu-22.04
env:
# Ensure pnpm output is colored in GitHub Actions logs
FORCE_COLOR: 3
# Make cargo nextest successfully ignore projects without tests
NEXTEST_NO_TESTS: pass
steps:
- name: 📥 Check out code
- name: Check out code
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: 🧰 Install build dependencies
- name: Cache turbo build setup
uses: actions/cache@v4
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-
- name: Install build dependencies
run: |
sudo apt-get update
sudo apt-get install -yq libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: 🧰 Install pnpm
uses: pnpm/action-setup@v4
- name: 🧰 Setup Node.js
- name: Setup Node.JS environment
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: pnpm
node-version: 20
- name: 🧰 Setup Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
- name: Install pnpm via corepack
shell: bash
run: |
corepack enable
corepack prepare --activate
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
name: Setup pnpm cache
with:
rustflags: ''
components: clippy, rustfmt
cache: false
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: 🧰 Setup nextest
uses: taiki-e/install-action@nextest
# cargo-binstall does not have pre-built binaries for sqlx-cli, so we fall
# back to a cached cargo install
- name: 🧰 Setup cargo-sqlx
uses: AlexTMjugador/cache-cargo-install-action@feat/features-support
with:
tool: sqlx-cli
locked: false
no-default-features: true
features: rustls,postgres
- name: 💨 Setup Turbo cache
uses: rharkor/caching-for-turbo@v1.8
- name: 🧰 Install dependencies
- name: Install dependencies
run: pnpm install
- name: ⚙️ Start services
run: docker compose up --wait
- name: Build
run: pnpm build
env:
SQLX_OFFLINE: true
- name: ⚙️ Setup Labrinth environment and database
working-directory: apps/labrinth
run: |
cp .env.local .env
sqlx database setup
- name: Lint
run: pnpm lint
env:
SQLX_OFFLINE: true
- name: 🔍 Lint and test
run: pnpm run ci
- name: Start docker compose
run: docker compose up -d
- name: Test
run: pnpm test
env:
SQLX_OFFLINE: true
DATABASE_URL: postgresql://labrinth:labrinth@localhost/postgres

6
.idea/vcs.xml generated
View File

@@ -1,11 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CommitMessageInspectionProfile">
<profile version="1.0">
<inspection_tool class="CommitFormat" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="CommitNamingConvention" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>

1
.nvmrc
View File

@@ -1 +0,0 @@
20.19.2

420
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,9 +10,6 @@ members = [
"packages/daedalus",
]
[workspace.package]
edition = "2024"
[workspace.dependencies]
actix-cors = "0.7.1"
actix-files = "0.6.6"
@@ -24,8 +21,7 @@ actix-web-prom = "0.10.0"
actix-ws = "0.3.0"
argon2 = { version = "0.5.3", features = ["std"] }
ariadne = { path = "packages/ariadne" }
async_zip = "0.0.17"
async-compression = { version = "0.4.24", default-features = false }
async-compression = { version = "0.4.23", default-features = false }
async-recursion = "1.1.1"
async-stripe = { version = "0.41.0", default-features = false, features = [
"runtime-tokio-hyper-rustls",
@@ -35,14 +31,14 @@ async-tungstenite = { version = "0.29.1", default-features = false, features = [
"futures-03-sink",
] }
async-walkdir = "2.1.0"
async_zip = "0.0.17"
base64 = "0.22.1"
bitflags = "2.9.1"
bitflags = "2.9.0"
bytes = "1.10.1"
censor = "0.3.0"
chardetng = "0.1.17"
chrono = "0.4.41"
clap = "4.5.40"
clickhouse = "0.13.3"
clap = "4.5.38"
clickhouse = "0.13.2"
color-thief = "0.2.2"
console-subscriber = "0.4.1"
daedalus = { path = "packages/daedalus" }
@@ -54,18 +50,16 @@ dotenv-build = "0.1.1"
dotenvy = "0.15.7"
dunce = "1.0.5"
either = "1.15.0"
encoding_rs = "0.8.35"
enumset = "1.1.6"
flate2 = "1.1.2"
flate2 = "1.1.1"
fs4 = { version = "0.13.1", default-features = false }
futures = { version = "0.3.31", default-features = false }
futures-util = "0.3.31"
heck = "0.5.0"
hex = "0.4.3"
hickory-resolver = "0.25.2"
hmac = "0.12.1"
hyper-tls = "0.6.0"
hyper-util = "0.1.14"
hyper-util = "0.1.11"
iana-time-zone = "0.1.63"
image = { version = "0.25.6", default-features = false, features = ["rayon"] }
indexmap = "2.9.0"
@@ -73,7 +67,7 @@ indicatif = "0.17.11"
itertools = "0.14.0"
jemalloc_pprof = "0.7.0"
json-patch = { version = "4.0.0", default-features = false }
lettre = { version = "0.11.17", default-features = false, features = [
lettre = { version = "0.11.16", default-features = false, features = [
"builder",
"hostname",
"pool",
@@ -95,19 +89,19 @@ quartz_nbt = "0.2.9"
quick-xml = "0.37.5"
rand = "=0.8.5" # Locked on 0.8 until argon2 and p256 update to 0.9
rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9
redis = "=0.31.0" # Locked on 0.31 until deadpool-redis updates to 0.32
redis = "0.31.0"
regex = "1.11.1"
reqwest = { version = "0.12.19", default-features = false }
rust_decimal = { version = "1.37.1", features = [
"serde-with-float",
"serde-with-str",
] }
rust_iso3166 = "0.1.14"
reqwest = { version = "0.12.15", default-features = false }
rust-s3 = { version = "0.35.1", default-features = false, features = [
"fail-on-err",
"tags",
"tokio-rustls-tls",
] }
rust_decimal = { version = "1.37.1", features = [
"serde-with-float",
"serde-with-str",
] }
rust_iso3166 = "0.1.14"
rusty-money = "0.4.1"
sentry = { version = "0.38.1", default-features = false, features = [
"backtrace",
@@ -119,26 +113,26 @@ sentry = { version = "0.38.1", default-features = false, features = [
] }
sentry-actix = "0.38.1"
serde = "1.0.219"
serde-xml-rs = "0.8.0" # Also an XML (de)serializer, consider dropping yaserde in favor of this
serde_bytes = "0.11.17"
serde_cbor = "0.11.2"
serde_ini = "0.2.0"
serde_json = "1.0.140"
serde_with = "3.12.0"
serde-xml-rs = "0.8.1" # Also an XML (de)serializer, consider dropping yaserde in favor of this
sha1 = "0.10.6"
sha1_smol = { version = "1.0.1", features = ["std"] }
sha2 = "0.10.9"
spdx = "0.10.8"
sqlx = { version = "0.8.6", default-features = false }
sysinfo = { version = "0.35.2", default-features = false }
sqlx = { version = "0.8.5", default-features = false }
sysinfo = { version = "0.35.1", default-features = false }
tar = "0.4.44"
tauri = "2.5.1"
tauri-build = "2.2.0"
tauri-plugin-deep-link = "2.3.0"
tauri-plugin-dialog = "2.2.2"
tauri-plugin-opener = "2.2.7"
tauri-plugin-deep-link = "2.2.1"
tauri-plugin-dialog = "2.2.1"
tauri-plugin-opener = "2.2.6"
tauri-plugin-os = "2.2.1"
tauri-plugin-single-instance = "2.2.4"
tauri-plugin-single-instance = "2.2.3"
tauri-plugin-updater = { version = "2.7.1", default-features = false, features = [
"rustls-tls",
"zip",
@@ -149,7 +143,7 @@ theseus = { path = "packages/app-lib" }
thiserror = "2.0.12"
tikv-jemalloc-ctl = "0.6.0"
tikv-jemallocator = "0.6.0"
tokio = "1.45.1"
tokio = "1.45.0"
tokio-stream = "0.1.17"
tokio-util = "0.7.15"
totp-rs = "5.7.0"
@@ -159,14 +153,14 @@ tracing-error = "0.2.1"
tracing-subscriber = "0.3.19"
url = "2.5.4"
urlencoding = "2.1.3"
uuid = "1.17.0"
uuid = "1.16.0"
validator = "0.20.0"
webp = { version = "0.3.0", default-features = false }
whoami = "1.6.0"
winreg = "0.55.0"
woothee = "0.13.0"
yaserde = "0.12.0"
zip = { version = "4.0.0", default-features = false, features = [
zip = { version = "3.0.0", default-features = false, features = [
"bzip2",
"deflate",
"deflate64",
@@ -174,44 +168,6 @@ zip = { version = "4.0.0", default-features = false, features = [
] }
zxcvbn = "3.1.0"
[workspace.lints.clippy]
bool_to_int_with_if = "warn"
borrow_as_ptr = "warn"
cfg_not_test = "warn"
clear_with_drain = "warn"
cloned_instead_of_copied = "warn"
collection_is_never_read = "warn"
dbg_macro = "warn"
default_trait_access = "warn"
explicit_iter_loop = "warn"
filter_map_next = "warn"
flat_map_option = "warn"
format_push_string = "warn"
get_unwrap = "warn"
large_include_file = "warn"
large_stack_arrays = "warn"
manual_assert = "warn"
manual_instant_elapsed = "warn"
manual_is_variant_and = "warn"
manual_let_else = "warn"
map_unwrap_or = "warn"
match_bool = "warn"
needless_collect = "warn"
negative_feature_names = "warn"
non_std_lazy_statics = "warn"
pathbuf_init_then_push = "warn"
read_zero_byte_vec = "warn"
redundant_clone = "warn"
redundant_feature_names = "warn"
redundant_type_annotations = "warn"
todo = "warn"
unnested_or_patterns = "warn"
wildcard_dependencies = "warn"
[workspace.lints.rust]
# Turn warnings into errors by default
warnings = "deny"
[patch.crates-io]
wry = { git = "https://github.com/modrinth/wry", rev = "cafdaa9" }

View File

@@ -9,19 +9,18 @@
"tsc:check": "vue-tsc --noEmit",
"lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write .",
"intl:extract": "formatjs extract \"{,src/components,src/composables,src/helpers,src/pages,src/store}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
"test": "vue-tsc --noEmit"
"intl:extract": "formatjs extract \"{,src/components,src/composables,src/helpers,src/pages,src/store}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace"
},
"dependencies": {
"@geometrically/minecraft-motd-parser": "^1.1.4",
"@modrinth/assets": "workspace:*",
"@modrinth/ui": "workspace:*",
"@modrinth/utils": "workspace:*",
"@sentry/vue": "^8.27.0",
"@geometrically/minecraft-motd-parser": "^1.1.4",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-dialog": "^2.2.1",
"@tauri-apps/plugin-opener": "^2.2.6",
"@tauri-apps/plugin-os": "^2.2.1",
"@tauri-apps/plugin-opener": "^2.2.6",
"@tauri-apps/plugin-updater": "^2.7.1",
"@tauri-apps/plugin-window-state": "^2.2.2",
"@vintl/vintl": "^4.4.1",
@@ -40,12 +39,11 @@
"@eslint/compat": "^1.1.1",
"@formatjs/cli": "^6.2.12",
"@nuxt/eslint-config": "^0.5.6",
"@taijased/vue-render-tracker": "^1.0.7",
"@vitejs/plugin-vue": "^5.0.4",
"autoprefixer": "^10.4.19",
"eslint": "^9.9.1",
"eslint-config-custom": "workspace:*",
"eslint-plugin-turbo": "^2.5.4",
"eslint-plugin-turbo": "^2.1.1",
"postcss": "^8.4.39",
"prettier": "^3.2.5",
"sass": "^1.74.1",
@@ -53,7 +51,8 @@
"tsconfig": "workspace:*",
"typescript": "^5.5.4",
"vite": "^5.4.6",
"vue-tsc": "^2.1.6"
"vue-tsc": "^2.1.6",
"@taijased/vue-render-tracker": "^1.0.7"
},
"packageManager": "pnpm@9.4.0",
"web-types": "../../web-types.json"

View File

@@ -10,12 +10,12 @@
size="36px"
:src="
selectedAccount
? `https://mc-heads.net/avatar/${selectedAccount.profile.id}/128`
? `https://mc-heads.net/avatar/${selectedAccount.id}/128`
: 'https://launcher-files.modrinth.com/assets/steve_head.png'
"
/>
<div class="flex flex-col w-full">
<span>{{ selectedAccount ? selectedAccount.profile.name : 'Select account' }}</span>
<span>{{ selectedAccount ? selectedAccount.username : 'Select account' }}</span>
<span class="text-secondary text-xs">Minecraft account</span>
</div>
<DropdownIcon class="w-5 h-5 shrink-0" />
@@ -28,17 +28,12 @@
:class="{ expanded: mode === 'expanded', isolated: mode === 'isolated' }"
>
<div v-if="selectedAccount" class="selected account">
<Avatar size="xs" :src="`https://mc-heads.net/avatar/${selectedAccount.profile.id}/128`" />
<Avatar size="xs" :src="`https://mc-heads.net/avatar/${selectedAccount.id}/128`" />
<div>
<h4>{{ selectedAccount.profile.name }}</h4>
<h4>{{ selectedAccount.username }}</h4>
<p>Selected</p>
</div>
<Button
v-tooltip="'Log out'"
icon-only
color="raised"
@click="logout(selectedAccount.profile.id)"
>
<Button v-tooltip="'Log out'" icon-only color="raised" @click="logout(selectedAccount.id)">
<TrashIcon />
</Button>
</div>
@@ -49,12 +44,12 @@
</Button>
</div>
<div v-if="displayAccounts.length > 0" class="account-group">
<div v-for="account in displayAccounts" :key="account.profile.id" class="account-row">
<div v-for="account in displayAccounts" :key="account.id" class="account-row">
<Button class="option account" @click="setAccount(account)">
<Avatar :src="`https://mc-heads.net/avatar/${account.profile.id}/128`" class="icon" />
<p>{{ account.profile.name }}</p>
<Avatar :src="`https://mc-heads.net/avatar/${account.id}/128`" class="icon" />
<p>{{ account.username }}</p>
</Button>
<Button v-tooltip="'Log out'" icon-only @click="logout(account.profile.id)">
<Button v-tooltip="'Log out'" icon-only @click="logout(account.id)">
<TrashIcon />
</Button>
</div>
@@ -106,16 +101,16 @@ defineExpose({
await refreshValues()
const displayAccounts = computed(() =>
accounts.value.filter((account) => defaultUser.value !== account.profile.id),
accounts.value.filter((account) => defaultUser.value !== account.id),
)
const selectedAccount = computed(() =>
accounts.value.find((account) => account.profile.id === defaultUser.value),
accounts.value.find((account) => account.id === defaultUser.value),
)
async function setAccount(account) {
defaultUser.value = account.profile.id
await set_default_user(account.profile.id).catch(handleError)
defaultUser.value = account.id
await set_default_user(account.id).catch(handleError)
emit('change')
}

View File

@@ -92,7 +92,7 @@ async function loginMinecraft() {
const loggedIn = await login_flow()
if (loggedIn) {
await set_default_user(loggedIn.profile.id).catch(handleError)
await set_default_user(loggedIn.id).catch(handleError)
}
await trackEvent('AccountLogIn', { source: 'ErrorModal' })

View File

@@ -25,8 +25,9 @@ const editProfileObject = computed(() => {
hooks?: Hooks
} = {}
// When hooks are not overridden per-instance, we want to clear them
editProfile.hooks = overrideHooks.value ? hooks.value : {}
if (overrideHooks.value) {
editProfile.hooks = hooks.value
}
return editProfile
})

View File

@@ -1,8 +1,12 @@
<script setup lang="ts">
import dayjs from 'dayjs'
import type { ServerStatus, ServerWorld, SingleplayerWorld, World } from '@/helpers/worlds.ts'
import { set_world_display_status, getWorldIdentifier } from '@/helpers/worlds.ts'
import { formatNumber, getPingLevel } from '@modrinth/utils'
import type { ServerStatus, ServerWorld, World } from '@/helpers/worlds.ts'
import {
set_world_display_status,
getWorldIdentifier,
showWorldInFolder,
} from '@/helpers/worlds.ts'
import { formatNumber } from '@modrinth/utils'
import {
useRelativeTime,
Avatar,
@@ -45,7 +49,6 @@ const router = useRouter()
const emit = defineEmits<{
(e: 'play' | 'play-instance' | 'update' | 'stop' | 'refresh' | 'edit' | 'delete'): void
(e: 'open-folder', world: SingleplayerWorld): void
}>()
const props = withDefaults(
@@ -105,6 +108,20 @@ const serverIncompatible = computed(
props.serverStatus.version.protocol !== props.currentProtocol,
)
function getPingLevel(ping: number) {
if (ping < 150) {
return 5
} else if (ping < 300) {
return 4
} else if (ping < 600) {
return 3
} else if (ping < 1000) {
return 2
} else {
return 1
}
}
const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked)
const messages = defineMessages({
@@ -377,7 +394,8 @@ const messages = defineMessages({
{
id: 'open-folder',
shown: world.type === 'singleplayer',
action: () => (world.type === 'singleplayer' ? emit('open-folder', world) : {}),
action: () =>
world.type === 'singleplayer' ? showWorldInFolder(instancePath, world.path) : {},
},
{
divider: true,

View File

@@ -56,7 +56,6 @@ function show(world: SingleplayerWorld) {
icon.value = world.icon
displayStatus.value = world.display_status
hideFromHome.value = world.display_status === 'hidden'
removeIcon.value = false
modal.value.show()
}

View File

@@ -32,11 +32,7 @@
<template #actions>
<div class="flex gap-2">
<ButtonStyled
v-if="
['installing', 'pack_installing', 'minecraft_installing'].includes(
instance.install_stage,
)
"
v-if="instance.install_stage.includes('installing')"
color="brand"
size="large"
>

View File

@@ -86,7 +86,6 @@
world.type === 'server' ? editServerModal?.show(world) : editWorldModal?.show(world)
"
@delete="() => promptToRemoveWorld(world)"
@open-folder="(world: SingleplayerWorld) => showWorldInFolder(instance.path, world.path)"
/>
</div>
</div>
@@ -152,7 +151,6 @@ import {
hasQuickPlaySupport,
refreshWorlds,
handleDefaultProfileUpdateEvent,
showWorldInFolder,
} from '@/helpers/worlds.ts'
import AddServerModal from '@/components/ui/world/modal/AddServerModal.vue'
import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue'

View File

@@ -1,7 +1,7 @@
[package]
name = "theseus_playground"
version = "0.0.0"
edition.workspace = true
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -9,6 +9,3 @@ edition.workspace = true
theseus = { workspace = true, features = ["cli"] }
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
enumset.workspace = true
[lints]
workspace = true

View File

@@ -2,9 +2,9 @@
"name": "@modrinth/app-playground",
"scripts": {
"build": "cargo build --release",
"lint": "cargo fmt --check && cargo clippy --all-targets",
"fix": "cargo clippy --all-targets --fix --allow-dirty && cargo fmt",
"lint": "cargo fmt --check && cargo clippy --all-targets --all-features -- -D warnings",
"fix": "cargo fmt && cargo clippy --fix",
"dev": "cargo run",
"test": "cargo nextest run --all-targets --no-fail-fast"
"test": "cargo test"
}
}

View File

@@ -27,10 +27,7 @@ pub async fn authenticate_run() -> theseus::Result<Credentials> {
let credentials = minecraft_auth::finish_login(&input, login).await?;
println!(
"Logged in user {}.",
credentials.maybe_online_profile().await.name
);
println!("Logged in user {}.", credentials.username);
Ok(credentials)
}

View File

@@ -4,7 +4,8 @@ version = "0.9.5"
description = "The Modrinth App is a desktop application for managing your Minecraft mods"
license = "GPL-3.0-only"
repository = "https://github.com/modrinth/code/apps/app/"
edition.workspace = true
edition = "2024"
build = "build.rs"
[build-dependencies]
tauri-build = { workspace = true, features = ["codegen"] }
@@ -55,6 +56,3 @@ default = ["custom-protocol"]
# DO NOT remove this
custom-protocol = ["tauri/custom-protocol"]
updater = []
[lints]
workspace = true

View File

@@ -151,6 +151,7 @@ fn main() {
"profile_update_managed_modrinth_version",
"profile_repair_managed_modrinth",
"profile_run",
"profile_run_credentials",
"profile_kill",
"profile_edit",
"profile_edit_icon",

View File

@@ -1,12 +1,12 @@
{
"name": "@modrinth/app",
"scripts": {
"tauri": "tauri",
"build": "tauri build",
"tauri": "tauri",
"dev": "tauri dev",
"test": "cargo nextest run --all-targets --no-fail-fast",
"lint": "cargo fmt --check && cargo clippy --all-targets",
"fix": "cargo clippy --all-targets --fix --allow-dirty && cargo fmt"
"test": "cargo test",
"lint": "cargo fmt --check && cargo clippy --all-targets -- -D warnings",
"fix": "cargo fmt && cargo clippy --fix"
},
"devDependencies": {
"@tauri-apps/cli": "2.5.0"

View File

@@ -28,6 +28,7 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
profile_update_managed_modrinth_version,
profile_repair_managed_modrinth,
profile_run,
profile_run_credentials,
profile_kill,
profile_edit,
profile_edit_icon,
@@ -255,6 +256,22 @@ pub async fn profile_run(path: &str) -> Result<ProcessMetadata> {
Ok(process)
}
// Run Minecraft using a profile using chosen credentials
// Returns the UUID, which can be used to poll
// for the actual Child in the state.
// invoke('plugin:profile|profile_run_credentials', {path, credentials})')
#[tauri::command]
pub async fn profile_run_credentials(
path: &str,
credentials: Credentials,
) -> Result<ProcessMetadata> {
let process =
profile::run_credentials(path, &credentials, &QuickPlayType::None)
.await?;
Ok(process)
}
#[tauri::command]
pub async fn profile_kill(path: &str) -> Result<()> {
profile::kill(path).await?;

View File

@@ -37,7 +37,6 @@ pub fn get_os() -> OS {
let os = OS::MacOS;
os
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(clippy::enum_variant_names)]
pub enum OS {

View File

@@ -43,7 +43,7 @@ pub async fn get_recent_worlds<R: Runtime>(
display_statuses.unwrap_or(EnumSet::all()),
)
.await?;
for world in &mut result {
for world in result.iter_mut() {
adapt_world_icon(&app_handle, &mut world.world);
}
Ok(result)
@@ -55,7 +55,7 @@ pub async fn get_profile_worlds<R: Runtime>(
path: &str,
) -> Result<Vec<World>> {
let mut result = worlds::get_profile_worlds(path).await?;
for world in &mut result {
for world in result.iter_mut() {
adapt_world_icon(&app_handle, world);
}
Ok(result)

View File

@@ -11,8 +11,7 @@ pub fn get_or_init_payload<R: Runtime, M: Manager<R>>(
manager: &M,
) -> InitialPayload {
let initial_payload = manager.try_state::<InitialPayload>();
if let Some(initial_payload) = initial_payload {
let mtx = if let Some(initial_payload) = initial_payload {
initial_payload.inner().clone()
} else {
tracing::info!("No initial payload found, creating new");
@@ -23,5 +22,7 @@ pub fn get_or_init_payload<R: Runtime, M: Manager<R>>(
manager.manage(payload.clone());
payload
}
};
mtx
}

View File

@@ -197,7 +197,7 @@ fn main() {
{
let payload = macos::deep_link::get_or_init_payload(app);
let mtx_copy = payload.payload;
let mtx_copy = payload.payload.clone();
app.listen("deep-link://new-url", move |url| {
let mtx_copy_copy = mtx_copy.clone();
let request = url.payload().to_owned();
@@ -229,6 +229,7 @@ fn main() {
tauri::async_runtime::spawn(api::utils::handle_command(
payload,
));
dbg!(url);
});
#[cfg(not(target_os = "linux"))]
@@ -272,22 +273,22 @@ fn main() {
match app {
Ok(app) => {
#[allow(unused_variables)]
app.run(|app, event| {
#[cfg(not(target_os = "macos"))]
drop((app, event));
#[cfg(target_os = "macos")]
if let tauri::RunEvent::Opened { urls } = event {
tracing::info!("Handling webview open {urls:?}");
let file = urls
.into_iter()
.find_map(|url| url.to_file_path().ok());
.filter_map(|url| url.to_file_path().ok())
.next();
if let Some(file) = file {
let payload =
macos::deep_link::get_or_init_payload(app);
let mtx_copy = payload.payload;
let mtx_copy = payload.payload.clone();
let request = file.to_string_lossy().to_string();
tauri::async_runtime::spawn(async move {
let mut payload = mtx_copy.lock().await;

View File

@@ -1,14 +0,0 @@
{
"$schema": "../../node_modules/turbo/schema.json",
"extends": ["//"],
"tasks": {
// Running Clippy and tests on a Tauri application requires
// the frontend to be built at least once first
"lint": {
"dependsOn": ["@modrinth/app-frontend#build"]
},
"test": {
"dependsOn": ["@modrinth/app-frontend#build"]
}
}
}

View File

@@ -2,7 +2,7 @@
name = "daedalus_client"
version = "0.2.2"
authors = ["Jai A <jai@modrinth.com>"]
edition.workspace = true
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -28,6 +28,3 @@ tracing-error.workspace = true
tracing.workspace = true
tracing-subscriber = { workspace = true, features = ["env-filter"] }
[lints]
workspace = true

View File

@@ -1,4 +1,4 @@
FROM rust:1.87.0 AS build
FROM rust:1.86.0 AS build
ENV PKG_CONFIG_ALLOW_CROSS=1
WORKDIR /usr/src/daedalus

View File

@@ -2,10 +2,10 @@
"name": "@modrinth/daedalus_client",
"scripts": {
"build": "cargo build --release",
"lint": "cargo fmt --check && cargo clippy --all-targets",
"fix": "cargo clippy --all-targets --fix --allow-dirty && cargo fmt",
"lint": "cargo fmt --check && cargo clippy --all-targets --all-features -- -D warnings",
"fix": "cargo fmt && cargo clippy --fix",
"dev": "cargo run",
"test": "cargo nextest run --all-targets --no-fail-fast"
"test": "cargo test"
},
"dependencies": {
"@modrinth/daedalus": "workspace:*"

View File

@@ -52,7 +52,8 @@ pub async fn fetch(
if modrinth_version
.original_sha1
.as_ref()
.is_some_and(|x| x == &version.sha1)
.map(|x| x == &version.sha1)
.unwrap_or(false)
{
existing_versions.push(modrinth_version);
} else {

View File

@@ -5,8 +5,7 @@
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"lint": "astro check",
"build": "astro build",
"build": "astro check && astro build",
"preview": "astro preview",
"astro": "astro"
},
@@ -19,4 +18,4 @@
"starlight-openapi": "^0.14.0",
"typescript": "^5.8.2"
}
}
}

View File

@@ -10,8 +10,7 @@
"postinstall": "nuxi prepare",
"lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write .",
"intl:extract": "formatjs extract \"{,src/components,src/composables,src/layouts,src/middleware,src/modules,src/pages,src/plugins,src/utils}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" \"src/error.vue\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
"test": "nuxi build"
"intl:extract": "formatjs extract \"{,src/components,src/composables,src/layouts,src/middleware,src/modules,src/pages,src/plugins,src/utils}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" \"src/error.vue\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace"
},
"devDependencies": {
"@formatjs/cli": "^6.2.12",

View File

@@ -1,128 +0,0 @@
<template>
<nav
ref="scrollContainer"
class="card-shadow experimental-styles-within relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
>
<button
v-for="(option, index) in options"
:key="`option-group-${index}`"
ref="optionButtons"
class="button-animation z-[1] flex flex-row items-center gap-2 rounded-full bg-transparent px-4 py-2 font-semibold"
:class="{
'text-button-textSelected': modelValue === option,
'text-primary': modelValue !== option,
}"
@click="setOption(option)"
>
<slot :option="option" :selected="modelValue === option" />
</button>
<div
class="navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full bg-button-bgSelected p-1"
:style="{
left: sliderLeftPx,
top: sliderTopPx,
right: sliderRightPx,
bottom: sliderBottomPx,
opacity: initialized ? 1 : 0,
}"
aria-hidden="true"
></div>
</nav>
</template>
<script setup lang="ts" generic="T">
import { ref, computed, onMounted } from "vue";
const modelValue = defineModel<T>({ required: true });
const props = defineProps<{
options: T[];
}>();
const scrollContainer = ref<HTMLElement | null>(null);
const sliderLeft = ref(4);
const sliderTop = ref(4);
const sliderRight = ref(4);
const sliderBottom = ref(4);
const sliderLeftPx = computed(() => `${sliderLeft.value}px`);
const sliderTopPx = computed(() => `${sliderTop.value}px`);
const sliderRightPx = computed(() => `${sliderRight.value}px`);
const sliderBottomPx = computed(() => `${sliderBottom.value}px`);
const optionButtons = ref();
const initialized = ref(false);
function setOption(option: T) {
modelValue.value = option;
}
watch(modelValue, () => {
startAnimation(props.options.indexOf(modelValue.value));
});
function startAnimation(index: number) {
const el = optionButtons.value[index];
if (!el || !el.offsetParent) return;
const newValues = {
left: el.offsetLeft,
top: el.offsetTop,
right: el.offsetParent.offsetWidth - el.offsetLeft - el.offsetWidth,
bottom: el.offsetParent.offsetHeight - el.offsetTop - el.offsetHeight,
};
if (sliderLeft.value === 4 && sliderRight.value === 4) {
sliderLeft.value = newValues.left;
sliderRight.value = newValues.right;
sliderTop.value = newValues.top;
sliderBottom.value = newValues.bottom;
} else {
const delay = 200;
if (newValues.left < sliderLeft.value) {
sliderLeft.value = newValues.left;
setTimeout(() => {
sliderRight.value = newValues.right;
}, delay);
} else {
sliderRight.value = newValues.right;
setTimeout(() => {
sliderLeft.value = newValues.left;
}, delay);
}
if (newValues.top < sliderTop.value) {
sliderTop.value = newValues.top;
setTimeout(() => {
sliderBottom.value = newValues.bottom;
}, delay);
} else {
sliderBottom.value = newValues.bottom;
setTimeout(() => {
sliderTop.value = newValues.top;
}, delay);
}
}
initialized.value = true;
}
onMounted(() => {
startAnimation(props.options.indexOf(modelValue.value));
});
</script>
<style scoped>
.navtabs-transition {
transition:
all 150ms cubic-bezier(0.4, 0, 0.2, 1),
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
}
.card-shadow {
box-shadow: var(--shadow-card);
}
</style>

View File

@@ -133,7 +133,7 @@ function generateTooltip({ series, seriesIndex, dataPointIndex, w }, props) {
props,
);
} else {
const returnTopN = 15;
const returnTopN = 5;
const listEntries = series
.map((value, index) => [

View File

@@ -18,7 +18,6 @@
<script setup>
import ReportInfo from "~/components/ui/report/ReportInfo.vue";
import { addReportMessage } from "~/helpers/threads.js";
import { asEncodedJsonArray, fetchSegmented } from "~/utils/fetch-helpers.ts";
defineProps({
moderation: {
@@ -54,13 +53,13 @@ const threadIds = [
const [{ data: users }, { data: versions }, { data: threads }] = await Promise.all([
await useAsyncData(`users?ids=${JSON.stringify(userIds)}`, () =>
fetchSegmented(userIds, (ids) => `users?ids=${asEncodedJsonArray(ids)}`),
useBaseFetch(`users?ids=${encodeURIComponent(JSON.stringify(userIds))}`),
),
await useAsyncData(`versions?ids=${JSON.stringify(versionIds)}`, () =>
fetchSegmented(versionIds, (ids) => `versions?ids=${asEncodedJsonArray(ids)}`),
useBaseFetch(`versions?ids=${encodeURIComponent(JSON.stringify(versionIds))}`),
),
await useAsyncData(`threads?ids=${JSON.stringify(threadIds)}`, () =>
fetchSegmented(threadIds, (ids) => `threads?ids=${asEncodedJsonArray(ids)}`),
useBaseFetch(`threads?ids=${encodeURIComponent(JSON.stringify(threadIds))}`),
),
]);
@@ -71,7 +70,7 @@ const versionProjects = versions.value.map((version) => version.project_id);
const projectIds = [...new Set(reportedProjects.concat(versionProjects))];
const { data: projects } = await useAsyncData(`projects?ids=${JSON.stringify(projectIds)}`, () =>
fetchSegmented(projectIds, (ids) => `projects?ids=${asEncodedJsonArray(ids)}`),
useBaseFetch(`projects?ids=${encodeURIComponent(JSON.stringify(projectIds))}`),
);
reports.value = rawReports.map((report) => {

View File

@@ -45,11 +45,9 @@
import { ref, nextTick, computed } from "vue";
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { IssuesIcon, PlusIcon, XIcon } from "@modrinth/assets";
import { ModrinthServersFetchError, type ServerBackup } from "@modrinth/utils";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
const props = defineProps<{
server: ModrinthServer;
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
}>();
const modal = ref<InstanceType<typeof NewModal>>();
@@ -66,7 +64,7 @@ const trimmedName = computed(() => backupName.value.trim());
const nameExists = computed(() => {
if (!props.server.backups?.data) return false;
return props.server.backups.data.some(
(backup: ServerBackup) => backup.name.trim().toLowerCase() === trimmedName.value.toLowerCase(),
(backup) => backup.name.trim().toLowerCase() === trimmedName.value.toLowerCase(),
);
});
@@ -100,7 +98,7 @@ const createBackup = async () => {
hideModal();
await props.server.refresh();
} catch (error) {
if (error instanceof ModrinthServersFetchError && error?.statusCode === 429) {
if (error instanceof PyroFetchError && error.statusCode === 429) {
isRateLimited.value = true;
addNotification({
type: "error",

View File

@@ -20,9 +20,13 @@
<script setup lang="ts">
import { ref } from "vue";
import { ConfirmModal } from "@modrinth/ui";
import type { Backup } from "@modrinth/utils";
import type { Server } from "~/composables/pyroServers";
import BackupItem from "~/components/ui/servers/BackupItem.vue";
defineProps<{
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
}>();
const emit = defineEmits<{
(e: "delete", backup: Backup | undefined): void;
}>();

View File

@@ -17,7 +17,7 @@ import {
import { ButtonStyled, commonMessages, OverflowMenu, ProgressBar } from "@modrinth/ui";
import { defineMessages, useVIntl } from "@vintl/vintl";
import { ref } from "vue";
import type { Backup } from "@modrinth/utils";
import type { Backup } from "~/composables/pyroServers.ts";
const flags = useFeatureFlags();
const { formatMessage } = useVIntl();

View File

@@ -48,11 +48,10 @@
import { ref, nextTick, computed } from "vue";
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { SpinnerIcon, SaveIcon, XIcon, IssuesIcon } from "@modrinth/assets";
import type { Backup } from "@modrinth/utils";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import type { Server } from "~/composables/pyroServers";
const props = defineProps<{
server: ModrinthServer;
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
}>();
const modal = ref<InstanceType<typeof NewModal>>();
@@ -71,7 +70,7 @@ const nameExists = computed(() => {
}
return props.server.backups.data.some(
(backup: Backup) => backup.name.trim().toLowerCase() === trimmedName.value.toLowerCase(),
(backup) => backup.name.trim().toLowerCase() === trimmedName.value.toLowerCase(),
);
});

View File

@@ -19,12 +19,11 @@
<script setup lang="ts">
import { ref } from "vue";
import { ConfirmModal, NewModal } from "@modrinth/ui";
import type { Backup } from "@modrinth/utils";
import type { Server } from "~/composables/pyroServers";
import BackupItem from "~/components/ui/servers/BackupItem.vue";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
const props = defineProps<{
server: ModrinthServer;
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
}>();
const modal = ref<InstanceType<typeof NewModal>>();

View File

@@ -59,10 +59,10 @@
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { XIcon, SaveIcon } from "@modrinth/assets";
import { ref, computed } from "vue";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import type { Server } from "~/composables/pyroServers";
const props = defineProps<{
server: ModrinthServer;
server: Server<["backups"]>;
}>();
const modal = ref<InstanceType<typeof NewModal>>();

View File

@@ -239,7 +239,7 @@ import {
import { Admonition, ButtonStyled, NewModal } from "@modrinth/ui";
import TagItem from "@modrinth/ui/src/components/base/TagItem.vue";
import { ref, computed } from "vue";
import { formatCategory, formatVersionsForDisplay, type Mod, type Version } from "@modrinth/utils";
import { formatCategory, formatVersionsForDisplay, type Version } from "@modrinth/utils";
import Accordion from "~/components/ui/Accordion.vue";
import Checkbox from "~/components/ui/Checkbox.vue";
import ContentVersionFilter, {

View File

@@ -99,7 +99,7 @@
import { FolderOpenIcon, CheckCircleIcon, XCircleIcon } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
import { ref, computed, watch, nextTick } from "vue";
import { FSModule } from "~/composables/servers/modules/fs.ts";
import type { FSModule } from "~/composables/pyroServers.ts";
interface UploadItem {
file: File;

View File

@@ -75,14 +75,13 @@
<script setup lang="ts">
import { ExternalIcon, SpinnerIcon, DownloadIcon, XIcon } from "@modrinth/assets";
import { BackupWarning, ButtonStyled, NewModal } from "@modrinth/ui";
import { ModrinthServersFetchError } from "@modrinth/utils";
import { ref, computed, nextTick } from "vue";
import { handleError, ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import { handleError, type Server } from "~/composables/pyroServers.ts";
const cf = ref(false);
const props = defineProps<{
server: ModrinthServer;
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
}>();
const modal = ref<typeof NewModal>();
@@ -111,18 +110,24 @@ const handleSubmit = async () => {
if (!error.value) {
// hide();
try {
const dry = await props.server.fs.extractFile(trimmedUrl.value, true, true);
const dry = await props.server.fs?.extractFile(trimmedUrl.value, true, true);
if (!cf.value || dry.modpack_name) {
await props.server.fs.extractFile(trimmedUrl.value, true, false, true);
await props.server.fs?.extractFile(trimmedUrl.value, true, false, true);
hide();
} else {
submitted.value = false;
handleError(
new ModrinthServersFetchError(
new ServersError(
"Could not find CurseForge modpack at that URL.",
404,
new Error(`No modpack found at ${url.value}`),
undefined,
undefined,
undefined,
{
context: "Error installing modpack",
error: `url: ${url.value}`,
description: "Could not find CurseForge modpack at that URL.",
},
),
);
}

View File

@@ -63,7 +63,6 @@ const props = defineProps<{
loader: string | null;
loader_version: string | null;
};
ignoreCurrentInstallation?: boolean;
isInstalling?: boolean;
}>();

View File

@@ -0,0 +1,80 @@
<template>
<div
aria-hidden="true"
style="font-variant-numeric: tabular-nums"
class="pointer-events-none h-full w-full select-none"
>
<div class="flex flex-col gap-6">
<div class="flex flex-row items-center gap-6">
<div
class="relative max-h-[156px] min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8"
>
<div class="relative z-10 -ml-3 w-fit rounded-xl px-3 py-1">
<div class="-mb-0.5 mt-0.5 flex flex-row items-center gap-2">
<h2 class="m-0 -ml-0.5 text-3xl font-extrabold text-contrast">0.00%</h2>
<h3 class="relative z-10 text-sm font-normal text-secondary">/ 100%</h3>
</div>
<h3 class="relative z-10 text-base font-normal text-secondary">CPU usage</h3>
</div>
<CPUIcon class="absolute right-10 top-10" />
</div>
<div
class="relative max-h-[156px] min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8"
>
<div class="relative z-10 -ml-3 w-fit rounded-xl px-3 py-1">
<div class="-mb-0.5 mt-0.5 flex flex-row items-center gap-2">
<h2 class="m-0 -ml-0.5 text-3xl font-extrabold text-contrast">0.00%</h2>
<h3 class="relative z-10 text-sm font-normal text-secondary">/ 100%</h3>
</div>
<h3 class="relative z-10 text-base font-normal text-secondary">Memory usage</h3>
</div>
<DBIcon class="absolute right-10 top-10" />
</div>
<div
class="relative isolate min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8 transition-transform duration-100 hover:scale-105 active:scale-100"
>
<div class="flex flex-row items-center gap-2">
<h2 class="m-0 -ml-0.5 mt-1 text-3xl font-extrabold text-contrast">0 B</h2>
</div>
<h3 class="relative z-10 text-base font-normal text-secondary">Storage usage</h3>
<FolderOpenIcon class="absolute right-10 top-10 size-8" />
</div>
</div>
<div
class="relative flex h-full w-full flex-col gap-3 overflow-hidden rounded-2xl bg-bg-raised p-8"
>
<div class="experimental-styles-within flex flex-row items-center">
<div class="flex flex-row items-center gap-4">
<h2 class="m-0 text-3xl font-extrabold text-contrast">Console</h2>
</div>
</div>
<div class="relative w-full">
<input type="text" placeholder="Search logs" class="h-12 !w-full !pl-10 !pr-48" />
<SearchIcon class="absolute left-4 top-1/2 -translate-y-1/2" />
</div>
<div
class="console relative h-full min-h-[516px] w-full overflow-hidden rounded-xl bg-bg text-sm"
></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { CPUIcon, DBIcon, FolderOpenIcon, SearchIcon } from "@modrinth/assets";
</script>
<style scoped>
html.light-mode .console {
background: var(--color-bg);
}
html.dark-mode .console {
background: black;
}
html.oled-mode .console {
background: black;
}
</style>

View File

@@ -59,7 +59,7 @@
<template v-else>
<ButtonStyled v-if="showStopButton" type="transparent">
<button :disabled="!canTakeAction" @click="initiateAction('Stop')">
<button :disabled="!canTakeAction" @click="initiateAction('stop')">
<div class="flex gap-1">
<StopCircleIcon class="h-5 w-5" />
<span>{{ isStoppingState ? "Stopping..." : "Stop" }}</span>
@@ -120,12 +120,14 @@ import {
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { useRouter } from "vue-router";
import { useStorage } from "@vueuse/core";
import type { PowerAction as ServerPowerAction, ServerState } from "@modrinth/utils";
type ServerAction = "start" | "stop" | "restart" | "kill";
type ServerState = "stopped" | "starting" | "running" | "stopping" | "restarting";
const flags = useFeatureFlags();
interface PowerAction {
action: ServerPowerAction;
action: ServerAction;
nextState: ServerState;
}
@@ -140,7 +142,7 @@ const props = defineProps<{
}>();
const emit = defineEmits<{
(e: "action", action: ServerPowerAction): void;
(e: "action", action: ServerAction): void;
}>();
const router = useRouter();
@@ -168,7 +170,7 @@ const isStoppingState = computed(() => serverState.value === "stopping");
const showStopButton = computed(() => isRunning.value || isStoppingState.value);
const primaryActionText = computed(() => {
const states: Partial<Record<ServerState, string>> = {
const states: Record<ServerState, string> = {
starting: "Starting...",
restarting: "Restarting...",
running: "Restart",
@@ -191,7 +193,7 @@ const menuOptions = computed(() => [
id: "kill",
label: "Kill server",
icon: SlashIcon,
action: () => initiateAction("Kill"),
action: () => initiateAction("kill"),
},
]),
{
@@ -219,17 +221,17 @@ async function copyId() {
await navigator.clipboard.writeText(serverId as string);
}
function initiateAction(action: ServerPowerAction) {
function initiateAction(action: ServerAction) {
if (!canTakeAction.value) return;
const stateMap: Record<ServerPowerAction, ServerState> = {
Start: "starting",
Stop: "stopping",
Restart: "restarting",
Kill: "stopping",
const stateMap: Record<ServerAction, ServerState> = {
start: "starting",
stop: "stopping",
restart: "restarting",
kill: "stopping",
};
if (action === "Start") {
if (action === "start") {
emit("action", action);
serverState.value = stateMap[action];
startingDelay.value = true;
@@ -247,7 +249,7 @@ function initiateAction(action: ServerPowerAction) {
}
function handlePrimaryAction() {
initiateAction(isRunning.value ? "Restart" : "Start");
initiateAction(isRunning.value ? "restart" : "start");
}
function executePowerAction() {
@@ -261,7 +263,7 @@ function executePowerAction() {
userPreferences.value.powerDontAskAgain = true;
}
if (action === "Start") {
if (action === "start") {
startingDelay.value = true;
setTimeout(() => (startingDelay.value = false), 5000);
}

View File

@@ -40,7 +40,7 @@
<script setup lang="ts">
import { ref } from "vue";
import type { ServerState } from "@modrinth/utils";
import type { ServerState } from "~/types/servers";
const STATUS_CLASSES = {
running: { main: "bg-brand", bg: "bg-bg-green" },
@@ -49,7 +49,7 @@ const STATUS_CLASSES = {
unknown: { main: "", bg: "" },
} as const;
const STATUS_TEXTS: Partial<Record<ServerState, string>> = {
const STATUS_TEXTS = {
running: "Running",
stopped: "",
crashed: "Crashed",
@@ -63,10 +63,7 @@ defineProps<{
const isExpanded = ref(false);
function getStatusClass(state: ServerState) {
if (state in STATUS_CLASSES) {
return STATUS_CLASSES[state as keyof typeof STATUS_CLASSES];
}
return STATUS_CLASSES.unknown;
return STATUS_CLASSES[state] ?? STATUS_CLASSES.unknown;
}
function getStatusText(state: ServerState) {

View File

@@ -7,17 +7,16 @@
type="text"
placeholder="Search logs"
class="h-12 !w-full !pl-10 !pr-48"
:disabled="loading"
@keydown.escape="clearSearch"
/>
<SearchIcon class="absolute left-4 top-1/2 -translate-y-1/2" />
<ButtonStyled v-if="searchInput && !loading" @click="clearSearch">
<ButtonStyled v-if="searchInput" @click="clearSearch">
<button class="absolute right-2 top-1/2 -translate-y-1/2">
<XIcon class="h-5 w-5" />
</button>
</ButtonStyled>
<span
v-if="pyroConsole.filteredOutput.value.length && searchInput && !loading"
v-if="pyroConsole.filteredOutput.value.length && searchInput"
class="pointer-events-none absolute right-12 top-1/2 -translate-y-1/2 select-none whitespace-pre text-sm"
>
{{ pyroConsole.filteredOutput.value.length }}
@@ -30,13 +29,11 @@
:class="[
'terminal-font console relative z-[1] flex h-full w-full flex-col items-center justify-between overflow-hidden rounded-t-xl px-1 text-sm transition-transform duration-300',
{ 'scale-fullscreen screen-fixed inset-0 z-50 !rounded-none': isFullScreen },
{ 'pointer-events-none': loading },
]"
:aria-hidden="loading"
tabindex="-1"
>
<div
v-if="cosmetics.advancedRendering && !loading"
v-if="cosmetics.advancedRendering"
class="progressive-gradient pointer-events-none absolute -bottom-6 left-0 z-[2] h-[10rem] w-full overflow-hidden rounded-xl"
:style="`--transparency: ${Math.max(0, lerp(100, 0, bottomThreshold * 8))}%`"
aria-hidden="true"
@@ -50,7 +47,7 @@
/>
</div>
<div
v-else-if="!loading"
v-else
class="pointer-events-none absolute bottom-0 left-0 right-0 z-[2] h-[196px] w-full"
:style="
bottomThreshold > 0
@@ -82,7 +79,6 @@
</div>
<div data-pyro-terminal-scroll-root class="relative h-full w-full">
<div
v-if="!loading"
ref="scrollbarTrack"
data-pyro-terminal-scrollbar-track
class="absolute -right-1 bottom-16 top-4 z-[4] w-4 overflow-hidden"
@@ -122,12 +118,7 @@
class="scrollbar-none absolute left-0 top-0 h-full w-full select-text overflow-x-auto overflow-y-auto py-6 pb-[72px]"
@scroll.passive="() => handleListScroll()"
>
<div v-if="loading" class="h-full w-full" />
<div
v-else
data-pyro-terminal-virtual-height-watcher
:style="{ height: `${totalHeight}px` }"
>
<div data-pyro-terminal-virtual-height-watcher :style="{ height: `${totalHeight}px` }">
<ul
class="m-0 list-none p-0"
data-pyro-terminal-virtual-list
@@ -214,7 +205,6 @@
<slot />
</div>
<button
v-if="!loading"
data-pyro-fullscreen
:label="isFullScreen ? 'Exit full screen' : 'Enter full screen'"
class="experimental-styles-within absolute right-4 top-4 z-[3] grid h-12 w-12 place-content-center rounded-full border-[1px] border-solid border-button-border bg-bg-raised text-contrast transition-all duration-200 hover:scale-110 active:scale-95"
@@ -227,7 +217,7 @@
<Transition name="fade">
<div
v-if="(hasSelection || isSingleLineSelected) && !loading"
v-if="hasSelection || isSingleLineSelected"
class="absolute right-20 top-4 z-[3] flex flex-row items-center"
:class="{ '!right-4': searchInput || hasSelection || isSingleLineSelected }"
>
@@ -257,7 +247,7 @@
<Transition name="scroll-to-bottom">
<button
v-if="bottomThreshold > 0 && !isScrolledToBottom && !loading"
v-if="bottomThreshold > 0 && !isScrolledToBottom"
data-pyro-scrolltobottom
label="Scroll to bottom"
class="scroll-to-bottom-btn experimental-styles-within absolute bottom-[4.5rem] right-4 z-[3] grid h-12 w-12 place-content-center rounded-full border-[1px] border-solid border-button-border bg-bg-raised text-contrast transition-all duration-200 hover:scale-110 active:scale-95"
@@ -301,14 +291,13 @@ import { useDebounceFn } from "@vueuse/core";
import { NewModal } from "@modrinth/ui";
import ButtonStyled from "@modrinth/ui/src/components/base/ButtonStyled.vue";
import DOMPurify from "dompurify";
import { useModrinthServersConsole } from "~/store/console.ts";
import { usePyroConsole } from "~/store/console.ts";
const { $cosmetics } = useNuxtApp();
const cosmetics = $cosmetics;
const props = defineProps<{
fullScreen: boolean;
loading?: boolean;
}>();
const BUFFER_SIZE = 5;
@@ -318,8 +307,8 @@ const SEPARATOR_HEIGHT = 32;
const SCROLL_END_DELAY = 150;
const progressiveBlurIterations = ref(8);
const pyroConsole = useModrinthServersConsole();
const consoleOutput = computed(() => (props.loading ? [] : pyroConsole.output.value));
const pyroConsole = usePyroConsole();
const consoleOutput = pyroConsole.output;
const scrollContainer = ref<HTMLElement | null>(null);

View File

@@ -69,11 +69,10 @@
<script setup lang="ts">
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { DownloadIcon, XIcon } from "@modrinth/assets";
import { ModrinthServersFetchError } from "@modrinth/utils";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import type { Server } from "~/composables/pyroServers";
const props = defineProps<{
server: ModrinthServer;
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
project: any;
versions: any[];
currentVersion?: any;
@@ -99,7 +98,8 @@ const handleReinstall = async () => {
try {
const versionId = props.versions.find((v) => v.version_number === selectedVersion.value)?.id;
await props.server.general.reinstall(
await props.server.general?.reinstall(
props.server.serverId,
false,
props.project.id,
versionId,
@@ -110,7 +110,7 @@ const handleReinstall = async () => {
emit("reinstall");
hide();
} catch (error) {
if (error instanceof ModrinthServersFetchError && error.statusCode === 429) {
if (error instanceof PyroFetchError && error.statusCode === 429) {
addNotification({
group: "server",
title: "Cannot reinstall server",

View File

@@ -116,12 +116,11 @@
<script setup lang="ts">
import { BackupWarning, ButtonStyled, NewModal } from "@modrinth/ui";
import { UploadIcon, RightArrowIcon, XIcon, ServerIcon } from "@modrinth/assets";
import { ModrinthServersFetchError } from "@modrinth/utils";
import type { Server } from "~/composables/pyroServers";
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
const props = defineProps<{
server: ModrinthServer;
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
backupInProgress?: BackupInProgressReason;
}>();
@@ -176,7 +175,7 @@ const handleReinstall = async () => {
window.scrollTo(0, 0);
hide();
} catch (error) {
if (error instanceof ModrinthServersFetchError && error?.statusCode === 429) {
if (error instanceof PyroFetchError && error.statusCode === 429) {
addNotification({
group: "server",
title: "Cannot reinstall server",

View File

@@ -127,10 +127,7 @@
</div>
</div>
<div
v-if="!initialSetup"
class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4"
>
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
<div class="flex w-full flex-row items-center justify-between">
<label class="w-full text-lg font-bold text-contrast" for="hard-reset">
Erase all data
@@ -149,10 +146,7 @@
<div class="font-bold">This does not affect your backups, which are stored off-site.</div>
</div>
<BackupWarning
v-if="!initialSetup"
:backup-link="`/servers/manage/${props.server?.serverId}/backups`"
/>
<BackupWarning :backup-link="`/servers/manage/${props.server?.serverId}/backups`" />
</div>
<div class="mt-4 flex justify-start gap-4">
@@ -200,9 +194,9 @@
import { BackupWarning, ButtonStyled, NewModal, Toggle } from "@modrinth/ui";
import { DropdownIcon, RightArrowIcon, ServerIcon, XIcon } from "@modrinth/assets";
import { $fetch } from "ofetch";
import { type Loaders, ModrinthServersFetchError } from "@modrinth/utils";
import type { Server } from "~/composables/pyroServers";
import type { Loaders } from "~/types/servers";
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
const { formatMessage } = useVIntl();
@@ -220,10 +214,9 @@ type VersionMap = Record<string, LoaderVersion[]>;
type VersionCache = Record<string, any>;
const props = defineProps<{
server: ModrinthServer;
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
currentLoader: Loaders | undefined;
backupInProgress?: BackupInProgressReason;
initialSetup?: boolean;
}>();
const emit = defineEmits<{
@@ -320,7 +313,7 @@ const selectedLoaderVersions = computed<string[]>(() => {
const loader = selectedLoader.value.toLowerCase();
if (loader === "paper") {
return paperVersions.value[selectedMCVersion.value]?.map((x) => `${x}`) || [];
return paperVersions.value[selectedMCVersion.value].map((x) => `${x}`) || [];
}
if (loader === "purpur") {
@@ -458,11 +451,12 @@ const handleReinstall = async () => {
try {
await props.server.general?.reinstall(
props.server.serverId,
true,
selectedLoader.value,
selectedMCVersion.value,
selectedLoader.value === "Vanilla" ? "" : selectedLoaderVersion.value,
props.initialSetup ? true : hardReset.value,
hardReset.value,
);
emit("reinstall", {
@@ -473,7 +467,7 @@ const handleReinstall = async () => {
hide();
} catch (error) {
if (error instanceof ModrinthServersFetchError && (error as any)?.statusCode === 429) {
if (error instanceof PyroFetchError && error.statusCode === 429) {
addNotification({
group: "server",
title: "Cannot reinstall server",

View File

@@ -31,7 +31,7 @@
<script setup lang="ts">
import { ButtonStyled } from "@modrinth/ui";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import type { Server } from "~/composables/pyroServers";
const props = defineProps<{
isUpdating: boolean;
@@ -39,7 +39,7 @@ const props = defineProps<{
save: () => void;
reset: () => void;
isVisible: boolean;
server: ModrinthServer;
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
}>();
const saveAndRestart = async () => {

View File

@@ -1,278 +0,0 @@
<template>
<LazyUiServersPlatformVersionSelectModal
ref="versionSelectModal"
:server="props.server"
:current-loader="ignoreCurrentInstallation ? undefined : (data?.loader as Loaders)"
:backup-in-progress="backupInProgress"
:initial-setup="ignoreCurrentInstallation"
@reinstall="emit('reinstall', $event)"
/>
<LazyUiServersPlatformMrpackModal
ref="mrpackModal"
:server="props.server"
@reinstall="emit('reinstall', $event)"
/>
<LazyUiServersPlatformChangeModpackVersionModal
ref="modpackVersionModal"
:server="props.server"
:project="data?.project"
:versions="Array.isArray(versions) ? versions : []"
:current-version="currentVersion"
:current-version-id="data?.upstream?.version_id"
:server-status="data?.status"
@reinstall="emit('reinstall')"
/>
<div class="flex h-full w-full flex-col">
<div v-if="data && versions" class="flex w-full flex-col">
<div class="card flex flex-col gap-4">
<div class="flex select-none flex-col items-center justify-between gap-2 lg:flex-row">
<div class="flex flex-row items-center gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">Modpack</h2>
<div
v-if="updateAvailable"
class="rounded-full bg-bg-orange px-2 py-1 text-xs font-medium text-orange"
>
<span>Update available</span>
</div>
</div>
<div v-if="data.upstream" class="flex gap-4">
<ButtonStyled>
<button
class="!w-full sm:!w-auto"
:disabled="isInstalling"
@click="mrpackModal.show()"
>
<UploadIcon class="size-4" /> Import .mrpack
</button>
</ButtonStyled>
<!-- dumb hack to make a button link not a link -->
<ButtonStyled>
<template v-if="isInstalling">
<button :disabled="isInstalling">
<TransferIcon class="size-4" />
Switch modpack
</button>
</template>
<nuxt-link v-else :to="`/modpacks?sid=${props.server.serverId}`">
<TransferIcon class="size-4" />
Switch modpack
</nuxt-link>
</ButtonStyled>
</div>
</div>
<div v-if="data.upstream" class="flex flex-col gap-2">
<div
v-if="versionsError || currentVersionError"
class="rounded-2xl border border-solid border-red p-4 text-contrast"
>
<p class="m-0 font-bold">Something went wrong while loading your modpack.</p>
<p class="m-0 mb-2 mt-1 text-sm">
{{ versionsError || currentVersionError }}
</p>
<ButtonStyled>
<button :disabled="isInstalling" @click="refreshData">Retry</button>
</ButtonStyled>
</div>
<NewProjectCard
v-if="!versionsError && !currentVersionError"
class="!cursor-default !bg-bg !filter-none"
:project="projectCardData"
:categories="data.project?.categories || []"
>
<template #actions>
<ButtonStyled color="brand">
<button :disabled="isInstalling" @click="modpackVersionModal.show()">
<SettingsIcon class="size-4" />
Change version
</button>
</ButtonStyled>
</template>
</NewProjectCard>
</div>
<div v-else class="flex w-full flex-col items-center gap-2 sm:w-fit sm:flex-row">
<ButtonStyled>
<nuxt-link
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
:class="{ disabled: backupInProgress }"
class="!w-full sm:!w-auto"
:to="`/modpacks?sid=${props.server.serverId}`"
>
<CompassIcon class="size-4" /> Find a modpack
</nuxt-link>
</ButtonStyled>
<span class="hidden sm:block">or</span>
<ButtonStyled>
<button
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
:disabled="!!backupInProgress"
class="!w-full sm:!w-auto"
@click="mrpackModal.show()"
>
<UploadIcon class="size-4" /> Upload .mrpack file
</button>
</ButtonStyled>
</div>
</div>
<div class="card flex flex-col gap-4">
<div class="flex flex-col gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">Platform</h2>
<p class="m-0">Your server's platform is the software that runs mods and plugins.</p>
<div v-if="data.upstream" class="mt-2 flex items-center gap-2">
<InfoIcon class="hidden sm:block" />
<span class="text-sm text-secondary">
The current platform was automatically selected based on your modpack.
</span>
</div>
</div>
<div
class="flex w-full flex-col gap-1 rounded-2xl"
:class="{
'pointer-events-none cursor-not-allowed select-none opacity-50':
props.server.general?.status === 'installing',
}"
:tabindex="props.server.general?.status === 'installing' ? -1 : 0"
>
<UiServersLoaderSelector
:data="
ignoreCurrentInstallation
? {
loader: null,
loader_version: null,
}
: data
"
:is-installing="isInstalling"
@select-loader="selectLoader"
/>
</div>
</div>
</div>
<div v-else />
</div>
</template>
<script setup lang="ts">
import { ButtonStyled, NewProjectCard } from "@modrinth/ui";
import { TransferIcon, UploadIcon, InfoIcon, CompassIcon, SettingsIcon } from "@modrinth/assets";
import type { Loaders } from "@modrinth/utils";
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
const { formatMessage } = useVIntl();
const props = defineProps<{
server: ModrinthServer;
ignoreCurrentInstallation?: boolean;
backupInProgress?: BackupInProgressReason;
}>();
const emit = defineEmits<{
reinstall: [any?];
}>();
const isInstalling = computed(() => props.server.general?.status === "installing");
const versionSelectModal = ref();
const mrpackModal = ref();
const modpackVersionModal = ref();
const data = computed(() => props.server.general);
const {
data: versions,
error: versionsError,
refresh: refreshVersions,
} = await useAsyncData(
`content-loader-versions-${data.value?.upstream?.project_id}`,
async () => {
if (!data.value?.upstream?.project_id) return [];
try {
const result = await useBaseFetch(`project/${data.value.upstream.project_id}/version`);
return result || [];
} catch (e) {
console.error("couldnt fetch all versions:", e);
throw new Error("Failed to load modpack versions.");
}
},
{ default: () => [] },
);
const {
data: currentVersion,
error: currentVersionError,
refresh: refreshCurrentVersion,
} = await useAsyncData(
`content-loader-version-${data.value?.upstream?.version_id}`,
async () => {
if (!data.value?.upstream?.version_id) return null;
try {
const result = await useBaseFetch(`version/${data.value.upstream.version_id}`);
return result || null;
} catch (e) {
console.error("couldnt fetch version:", e);
throw new Error("Failed to load modpack version.");
}
},
{ default: () => null },
);
const projectCardData = computed(() => ({
icon_url: data.value?.project?.icon_url,
title: data.value?.project?.title,
description: data.value?.project?.description,
downloads: data.value?.project?.downloads,
follows: data.value?.project?.followers,
// @ts-ignore
date_modified: currentVersion.value?.date_published || data.value?.project?.updated,
}));
const selectLoader = (loader: string) => {
versionSelectModal.value?.show(loader as Loaders);
};
const refreshData = async () => {
await Promise.all([refreshVersions(), refreshCurrentVersion()]);
};
const updateAvailable = computed(() => {
// so sorry
// @ts-ignore
if (!data.value?.upstream || !versions.value?.length || !currentVersion.value) {
return false;
}
// @ts-ignore
const latestVersion = versions.value[0];
// @ts-ignore
return latestVersion.id !== currentVersion.value.id;
});
watch(
() => props.server.general?.status,
async (newStatus, oldStatus) => {
if (oldStatus === "installing" && newStatus === "available") {
await Promise.all([
refreshVersions(),
refreshCurrentVersion(),
props.server.refresh(["general"]),
]);
}
},
);
</script>
<style scoped>
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
}
.button-base:active {
scale: none !important;
}
</style>

View File

@@ -43,14 +43,7 @@
</div>
<div v-else class="min-h-[20px]"></div>
<div
v-if="isConfiguring"
class="flex min-w-0 items-center gap-2 truncate text-sm font-semibold text-brand"
>
<SparklesIcon class="size-5 shrink-0" /> New server
</div>
<UiServersServerInfoLabels
v-else
:server-data="{ game, mc_version, loader, loader_version, net }"
:show-game-label="showGameLabel"
:show-loader-label="showLoaderLabel"
@@ -80,14 +73,13 @@
</template>
<script setup lang="ts">
import { ChevronRightIcon, LockIcon, SparklesIcon } from "@modrinth/assets";
import type { Project, Server } from "@modrinth/utils";
import { useModrinthServers } from "~/composables/servers/modrinth-servers.ts";
import { ChevronRightIcon, HammerIcon, LockIcon } from "@modrinth/assets";
import type { Project, Server } from "~/types/servers";
const props = defineProps<Partial<Server>>();
if (props.server_id) {
await useModrinthServers(props.server_id, ["general"]);
await usePyroServer(props.server_id, ["general"]);
}
const showGameLabel = computed(() => !!props.game);
@@ -110,9 +102,8 @@ if (props.upstream) {
const image = useState<string | undefined>(`server-icon-${props.server_id}`, () => undefined);
if (import.meta.server && projectData.value?.icon_url) {
await useModrinthServers(props.server_id!, ["general"]);
await usePyroServer(props.server_id!, ["general"]);
}
const iconUrl = computed(() => projectData.value?.icon_url || undefined);
const isConfiguring = computed(() => props.flows?.intro);
</script>

View File

@@ -34,15 +34,15 @@
<script setup lang="ts">
import { RightArrowIcon } from "@modrinth/assets";
import type { RouteLocationNormalized } from "vue-router";
import type { Server } from "~/composables/pyroServers";
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
const emit = defineEmits(["reinstall"]);
defineProps<{
navLinks: { label: string; href: string; icon: Component; external?: boolean }[];
route: RouteLocationNormalized;
server: ModrinthServer;
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
backupInProgress?: BackupInProgressReason;
}>();

View File

@@ -3,8 +3,6 @@
data-pyro-server-stats
style="font-variant-numeric: tabular-nums"
class="flex select-none flex-col items-center gap-6 md:flex-row"
:class="{ 'pointer-events-none': loading }"
:aria-hidden="loading"
>
<div
v-for="(metric, index) in metrics"
@@ -20,7 +18,7 @@
<h3 class="flex items-center gap-2 text-base font-normal text-secondary">
{{ metric.title }}
<IssuesIcon
v-if="metric.warning && !loading"
v-if="metric.warning"
v-tooltip="metric.warning"
class="size-5"
:style="{ color: 'var(--color-orange)' }"
@@ -30,76 +28,51 @@
<div class="absolute -left-8 -top-4 h-28 w-56 rounded-full bg-bg-raised blur-lg" />
</div>
<component
:is="metric.icon"
class="absolute right-10 top-10 z-10 size-8"
style="width: 2rem; height: 2rem"
/>
<div class="chart-space absolute bottom-0 left-0 right-0">
<ClientOnly>
<VueApexCharts
v-if="metric.showGraph && !loading"
type="area"
height="142"
:options="getChartOptions(metric.warning, index)"
:series="[{ name: metric.title, data: metric.data }]"
class="chart"
:class="chartsReady.has(index) ? 'opacity-100' : 'opacity-0'"
/>
</ClientOnly>
</div>
<component :is="metric.icon" class="absolute right-10 top-10 z-10" />
<ClientOnly>
<VueApexCharts
v-if="metric.showGraph"
type="area"
height="142"
:options="getChartOptions(metric.warning)"
:series="[{ name: metric.title, data: metric.data }]"
class="chart absolute bottom-0 left-0 right-0 w-full opacity-0"
/>
</ClientOnly>
</div>
<component
:is="loading ? 'div' : 'NuxtLink'"
:to="loading ? undefined : `/servers/manage/${serverId}/files`"
class="relative isolate min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8"
:class="loading ? '' : 'transition-transform duration-100 hover:scale-105 active:scale-100'"
<NuxtLink
:to="`/servers/manage/${serverId}/files`"
class="relative isolate min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8 transition-transform duration-100 hover:scale-105 active:scale-100"
>
<div class="flex flex-row items-center gap-2">
<h2 class="m-0 -ml-0.5 mt-1 text-3xl font-extrabold text-contrast">
{{ loading ? "0 B" : formatBytes(stats.storage_usage_bytes) }}
{{ formatBytes(stats.storage_usage_bytes) }}
</h2>
</div>
<h3 class="text-base font-normal text-secondary">Storage usage</h3>
<FolderOpenIcon class="absolute right-10 top-10 size-8" />
</component>
</NuxtLink>
</div>
</template>
<script setup lang="ts">
import { ref, computed, shallowRef } from "vue";
import { FolderOpenIcon, CPUIcon, DatabaseIcon, IssuesIcon } from "@modrinth/assets";
import { FolderOpenIcon, CPUIcon, DBIcon, IssuesIcon } from "@modrinth/assets";
import { useStorage } from "@vueuse/core";
import type { Stats } from "@modrinth/utils";
import type { Stats } from "~/types/servers";
const route = useNativeRoute();
const serverId = route.params.id;
const VueApexCharts = defineAsyncComponent(() => import("vue3-apexcharts"));
const chartsReady = ref(new Set<number>());
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
ramAsNumber: false,
});
const props = withDefaults(defineProps<{ data?: Stats; loading?: boolean }>(), {
loading: false,
});
const props = defineProps<{ data: Stats }>();
const stats = shallowRef(
props.data?.current || {
cpu_percent: 0,
ram_usage_bytes: 0,
ram_total_bytes: 1, // Avoid division by zero
storage_usage_bytes: 0,
},
);
const onChartReady = (index: number) => {
chartsReady.value.add(index);
};
const stats = shallowRef(props.data.current);
const formatBytes = (bytes: number) => {
const units = ["B", "KB", "MB", "GB"];
@@ -121,29 +94,6 @@ const updateGraphData = (arr: number[], newValue: number) => {
};
const metrics = computed(() => {
if (props.loading) {
return [
{
title: "CPU usage",
value: "0.00%",
max: "100%",
icon: CPUIcon,
data: cpuData.value,
showGraph: false,
warning: null,
},
{
title: "Memory usage",
value: "0.00%",
max: "100%",
icon: DatabaseIcon,
data: ramData.value,
showGraph: false,
warning: null,
},
];
}
const ramPercent = Math.min(
(stats.value.ram_usage_bytes / stats.value.ram_total_bytes) * 100,
100,
@@ -169,7 +119,7 @@ const metrics = computed(() => {
? formatBytes(stats.value.ram_usage_bytes)
: `${ramPercent.toFixed(2)}%`,
max: userPreferences.value.ramAsNumber ? formatBytes(stats.value.ram_total_bytes) : "100%",
icon: DatabaseIcon,
icon: DBIcon,
data: ramData.value,
showGraph: true,
warning: ramPercent >= 90 ? "Memory usage is very high" : null,
@@ -177,7 +127,7 @@ const metrics = computed(() => {
];
});
const getChartOptions = (hasWarning: string | null, index: number) => ({
const getChartOptions = (hasWarning: string | null) => ({
chart: {
type: "area",
animations: { enabled: false },
@@ -189,10 +139,6 @@ const getChartOptions = (hasWarning: string | null, index: number) => ({
top: 0,
bottom: 0,
},
events: {
mounted: () => onChartReady(index),
updated: () => onChartReady(index),
},
},
stroke: { curve: "smooth", width: 3 },
fill: {
@@ -226,26 +172,24 @@ const getChartOptions = (hasWarning: string | null, index: number) => ({
});
watch(
() => props.data?.current,
() => props.data.current,
(newStats) => {
if (newStats) {
stats.value = newStats;
}
stats.value = newStats;
},
);
</script>
<style scoped>
.chart-space {
height: 142px;
width: calc(100% + 48px);
.chart {
animation: fadeIn 0.2s ease-out 0.2s forwards;
margin-left: -24px;
margin-right: -24px;
width: calc(100% + 48px) !important;
}
.chart {
width: 100% !important;
height: 142px !important;
transition: opacity 0.3s ease-out;
@keyframes fadeIn {
to {
opacity: 1;
}
}
</style>

View File

@@ -224,7 +224,7 @@
<script setup lang="ts">
import { LoaderIcon } from "@modrinth/assets";
import type { Loaders } from "@modrinth/utils";
import type { Loaders } from "~/types/servers";
defineProps<{
loader: Loaders;

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
import { ButtonStyled, ServersSpecs } from "@modrinth/ui";
import { ButtonStyled } from "@modrinth/ui";
import { RightArrowIcon, SparklesIcon, UnknownIcon } from "@modrinth/assets";
import type { MessageDescriptor } from "@vintl/vintl";
import { formatPrice } from "@modrinth/utils";
const { formatMessage, locale } = useVIntl();
const { formatMessage } = useVIntl();
const emit = defineEmits<{
(e: "select" | "scroll-to-faq"): void;
@@ -18,8 +18,8 @@ const plans: Record<
accentText: string;
accentBg: string;
name: MessageDescriptor;
symbol: MessageDescriptor;
description: MessageDescriptor;
mostPopular: boolean;
}
> = {
small: {
@@ -30,11 +30,15 @@ const plans: Record<
id: "servers.plan.small.name",
defaultMessage: "Small",
}),
symbol: defineMessage({
id: "servers.plan.small.symbol",
defaultMessage: "S",
}),
description: defineMessage({
id: "servers.plan.small.description",
defaultMessage: "Perfect for 15 friends with a few light mods.",
defaultMessage:
"Perfect for vanilla multiplayer, small friend groups, SMPs, and light modding.",
}),
mostPopular: false,
},
medium: {
buttonColor: "green",
@@ -44,11 +48,14 @@ const plans: Record<
id: "servers.plan.medium.name",
defaultMessage: "Medium",
}),
symbol: defineMessage({
id: "servers.plan.medium.symbol",
defaultMessage: "M",
}),
description: defineMessage({
id: "servers.plan.medium.description",
defaultMessage: "Great for 615 players and multiple mods.",
defaultMessage: "Great for modded multiplayer and small communities.",
}),
mostPopular: true,
},
large: {
buttonColor: "purple",
@@ -58,11 +65,14 @@ const plans: Record<
id: "servers.plan.large.name",
defaultMessage: "Large",
}),
symbol: defineMessage({
id: "servers.plan.large.symbol",
defaultMessage: "L",
}),
description: defineMessage({
id: "servers.plan.large.description",
defaultMessage: "Ideal for 1525 players, modpacks, or heavy modding.",
defaultMessage: "Ideal for larger communities, modpacks, and heavy modding.",
}),
mostPopular: false,
},
};
@@ -73,30 +83,42 @@ const props = defineProps<{
storage: number;
cpus: number;
price: number;
interval: "monthly" | "quarterly" | "yearly";
currency: string;
isUsa: boolean;
}>();
const outOfStock = computed(() => {
return !props.capacity || props.capacity === 0;
});
const billingMonths = computed(() => {
if (props.interval === "yearly") {
return 12;
} else if (props.interval === "quarterly") {
return 3;
}
return 1;
const lowStock = computed(() => {
return !props.capacity || props.capacity < 8;
});
const formattedRam = computed(() => {
return props.ram / 1024;
});
const formattedStorage = computed(() => {
return props.storage / 1024;
});
const sharedCpus = computed(() => {
return props.cpus / 2;
});
</script>
<template>
<li class="relative flex w-full flex-col justify-between">
<li class="relative flex w-full flex-col justify-between pt-12 lg:w-1/3">
<div
v-if="lowStock"
class="absolute left-0 right-0 top-[-2px] rounded-t-2xl p-4 text-center font-bold"
:class="outOfStock ? 'bg-bg-red' : 'bg-bg-orange'"
>
<template v-if="outOfStock"> Out of stock! </template>
<template v-else> Only {{ capacity }} left in stock! </template>
</div>
<div
:style="
plans[plan].mostPopular
plan === 'medium'
? {
background: `radial-gradient(
86.12% 101.64% at 95.97% 94.07%,
@@ -109,41 +131,55 @@ const billingMonths = computed(() => {
: undefined
"
class="flex w-full flex-col justify-between gap-4 rounded-2xl bg-bg p-8 text-left"
:class="{ '!rounded-t-none': lowStock }"
>
<div class="flex flex-col gap-2">
<div class="flex flex-row flex-wrap items-center gap-3">
<div class="flex flex-col gap-4">
<div class="flex flex-row items-center justify-between">
<h1 class="m-0">{{ formatMessage(plans[plan].name) }}</h1>
<div
v-if="plans[plan].mostPopular"
class="rounded-full bg-brand-highlight px-2 py-1 text-xs font-bold text-brand"
class="grid size-8 place-content-center rounded-full text-xs font-bold"
:class="`${plans[plan].accentBg} ${plans[plan].accentText}`"
>
Most popular
{{ formatMessage(plans[plan].symbol) }}
</div>
</div>
<p class="m-0">{{ formatMessage(plans[plan].description) }}</p>
<div
class="flex flex-row flex-wrap items-center gap-2 text-nowrap text-secondary xl:justify-between"
>
<p class="m-0">{{ formattedRam }} GB RAM</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">{{ formattedStorage }} GB SSD</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">{{ sharedCpus }} Shared CPUs</p>
</div>
<div class="flex items-center gap-2 text-secondary">
<SparklesIcon /> Bursts up to {{ cpus }} CPUs
<nuxt-link
v-tooltip="
`CPU bursting allows your server to temporarily use additional threads to help mitigate TPS spikes. Click for more info.`
"
to="/servers#cpu-burst"
@click="() => emit('scroll-to-faq')"
>
<UnknownIcon class="h-4 w-4 text-secondary opacity-80" />
</nuxt-link>
</div>
<span class="m-0 text-2xl font-bold text-contrast">
{{ formatPrice(locale, price / billingMonths, currency, true) }}
{{ isUsa ? "" : currency }}
<span class="text-lg font-semibold text-secondary">
/ month<template v-if="interval !== 'monthly'">, billed {{ interval }}</template>
</span>
${{ price / 100 }}<span class="text-lg font-semibold text-secondary">/month</span>
</span>
<p class="m-0 max-w-[18rem]">{{ formatMessage(plans[plan].description) }}</p>
</div>
<ButtonStyled
:color="plans[plan].buttonColor"
:type="plans[plan].mostPopular ? 'standard' : 'highlight-colored-text'"
:type="plan === 'medium' ? 'standard' : 'highlight-colored-text'"
size="large"
>
<span v-if="outOfStock" class="button-like disabled"> Out of Stock </span>
<button v-else @click="() => emit('select')">Select plan</button>
<button v-else @click="() => emit('select')">
Get Started
<RightArrowIcon class="shrink-0" />
</button>
</ButtonStyled>
<ServersSpecs
:ram="ram"
:storage="storage"
:cpus="cpus"
:bursting-link="'/servers#cpu-burst'"
@click-bursting-link="() => emit('scroll-to-faq')"
/>
</div>
</li>
</template>

View File

@@ -3,7 +3,7 @@ import { Accordion, ButtonStyled, NewModal, ServerNotice, TagItem } from "@modri
import { PlusIcon, XIcon } from "@modrinth/assets";
import type { ServerNotice as ServerNoticeType } from "@modrinth/utils";
import { ref } from "vue";
import { useServersFetch } from "~/composables/servers/servers-fetch.ts";
import { usePyroFetch } from "~/composables/pyroFetch.ts";
const app = useNuxtApp() as unknown as { $notify: any };
@@ -23,7 +23,7 @@ const assignedNodes = computed(() => assigned.value.filter((n) => n.kind === "no
const inputField = ref("");
async function refresh() {
await useServersFetch("notices").then((res) => {
await usePyroFetch("notices").then((res) => {
const notices = res as ServerNoticeType[];
assigned.value = notices.find((n) => n.id === notice.value?.id)?.assigned ?? [];
});
@@ -33,12 +33,9 @@ async function assign(server: boolean = true) {
const input = inputField.value.trim();
if (input !== "" && notice.value) {
await useServersFetch(
`notices/${notice.value.id}/assign?${server ? "server" : "node"}=${input}`,
{
method: "PUT",
},
).catch((err) => {
await usePyroFetch(`notices/${notice.value.id}/assign?${server ? "server" : "node"}=${input}`, {
method: "PUT",
}).catch((err) => {
app.$notify({
group: "main",
title: "Error assigning notice",
@@ -78,12 +75,9 @@ async function unassignDetect() {
async function unassign(id: string, server: boolean = true) {
if (notice.value) {
await useServersFetch(
`notices/${notice.value.id}/unassign?${server ? "server" : "node"}=${id}`,
{
method: "PUT",
},
).catch((err) => {
await usePyroFetch(`notices/${notice.value.id}/unassign?${server ? "server" : "node"}=${id}`, {
method: "PUT",
}).catch((err) => {
app.$notify({
group: "main",
title: "Error unassigning notice",

View File

@@ -2,8 +2,13 @@
import dayjs from "dayjs";
import { ButtonStyled, commonMessages, CopyCode, ServerNotice, TagItem } from "@modrinth/ui";
import { EditIcon, SettingsIcon, TrashIcon } from "@modrinth/assets";
import type { ServerNotice as ServerNoticeType } from "@modrinth/utils";
import { useRelativeTime, getDismissableMetadata, NOTICE_LEVELS } from "@modrinth/ui";
import { ServerNotice as ServerNoticeType } from "@modrinth/utils";
import { useRelativeTime } from "@modrinth/ui";
import {
DISMISSABLE,
getDismissableMetadata,
NOTICE_LEVELS,
} from "@modrinth/ui/src/utils/notices.ts";
import { useVIntl } from "@vintl/vintl";
const { formatMessage } = useVIntl();

View File

@@ -24,7 +24,6 @@ export const useUserCountry = () => {
if (import.meta.client) {
onMounted(() => {
if (fromServer.value) return;
// @ts-expect-error - ignore TS not knowing about navigator.userLanguage
const lang = navigator.language || navigator.userLanguage || "";
const region = lang.split("-")[1];
if (region) {

View File

@@ -19,7 +19,7 @@ export const useImageUpload = async (file: File, ctx: ImageUploadContext) => {
// Make sure file is less than 1MB
if (file.size > 1024 * 1024) {
throw new Error("File exceeds the 1MiB size limit");
throw new Error("File is too large");
}
const qs = new URLSearchParams();

View File

@@ -0,0 +1,110 @@
import { $fetch, FetchError } from "ofetch";
interface PyroFetchOptions {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
contentType?: string;
body?: Record<string, any>;
version?: number;
override?: {
url?: string;
token?: string;
};
retry?: boolean;
bypassAuth?: boolean;
}
export class PyroFetchError extends Error {
constructor(
message: string,
public statusCode?: number,
public originalError?: Error,
) {
super(message);
this.name = "PyroFetchError";
}
}
export async function usePyroFetch<T>(path: string, options: PyroFetchOptions = {}): Promise<T> {
const config = useRuntimeConfig();
const auth = await useAuth();
const authToken = auth.value?.token;
if (!authToken && !options.bypassAuth) {
throw new PyroFetchError("Cannot pyrofetch without auth", 10000);
}
const { method = "GET", contentType = "application/json", body, version = 0, override } = options;
const base = (import.meta.server ? config.pyroBaseUrl : config.public.pyroBaseUrl)?.replace(
/\/$/,
"",
);
if (!base) {
throw new PyroFetchError(
"Cannot pyrofetch without base url. Make sure to set a PYRO_BASE_URL in environment variables",
10001,
);
}
const fullUrl = override?.url
? `https://${override.url}/${path.replace(/^\//, "")}`
: `${base}/modrinth/v${version}/${path.replace(/^\//, "")}`;
type HeadersRecord = Record<string, string>;
const authHeader: HeadersRecord = options.bypassAuth
? {}
: {
Authorization: `Bearer ${override?.token ?? authToken}`,
"Access-Control-Allow-Headers": "Authorization",
};
const headers: HeadersRecord = {
...authHeader,
"User-Agent": "Pyro/1.0 (https://pyro.host)",
Vary: "Accept, Origin",
"Content-Type": contentType,
};
if (import.meta.client && typeof window !== "undefined") {
headers.Origin = window.location.origin;
}
try {
const response = await $fetch<T>(fullUrl, {
method,
headers,
body: body && contentType === "application/json" ? JSON.stringify(body) : body ?? undefined,
timeout: 10000,
retry: options.retry !== false ? (method === "GET" ? 3 : 0) : 0,
});
return response;
} catch (error) {
console.error("Fetch error:", error);
if (error instanceof FetchError) {
const statusCode = error.response?.status;
const statusText = error.response?.statusText || "Unknown error";
const errorMessages: { [key: number]: string } = {
400: "Bad Request",
401: "Unauthorized",
403: "Forbidden",
404: "Not Found",
405: "Method Not Allowed",
429: "Too Many Requests",
500: "Internal Server Error",
502: "Bad Gateway",
};
const message =
statusCode && statusCode in errorMessages
? errorMessages[statusCode]
: `HTTP Error: ${statusCode || "unknown"} ${statusText}`;
throw new PyroFetchError(`[PYROFETCH][PYRO] ${message}`, statusCode, error);
}
throw new PyroFetchError(
"[PYROFETCH][PYRO] An unexpected error occurred during the fetch operation.",
undefined,
error as Error,
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,265 +0,0 @@
import { ModrinthServerError } from "@modrinth/utils";
import type { JWTAuth, ModuleError, ModuleName } from "@modrinth/utils";
import { useServersFetch } from "./servers-fetch.ts";
import {
GeneralModule,
ContentModule,
BackupsModule,
NetworkModule,
StartupModule,
WSModule,
FSModule,
} from "./modules/index.ts";
export function handleError(err: any) {
if (err instanceof ModrinthServerError && err.v1Error) {
addNotification({
title: err.v1Error?.context ?? `An error occurred`,
type: "error",
text: err.v1Error.description,
errorCode: err.v1Error.error,
});
} else {
addNotification({
title: "An error occurred",
type: "error",
text: err.message ?? (err.data ? err.data.description : err),
});
}
}
export class ModrinthServer {
readonly serverId: string;
private errors: Partial<Record<ModuleName, ModuleError>> = {};
readonly general: GeneralModule;
readonly content: ContentModule;
readonly backups: BackupsModule;
readonly network: NetworkModule;
readonly startup: StartupModule;
readonly ws: WSModule;
readonly fs: FSModule;
constructor(serverId: string) {
this.serverId = serverId;
this.general = new GeneralModule(this);
this.content = new ContentModule(this);
this.backups = new BackupsModule(this);
this.network = new NetworkModule(this);
this.startup = new StartupModule(this);
this.ws = new WSModule(this);
this.fs = new FSModule(this);
}
async createMissingFolders(path: string): Promise<void> {
if (path.startsWith("/")) {
path = path.substring(1);
}
const folders = path.split("/");
let currentPath = "";
for (const folder of folders) {
currentPath += "/" + folder;
try {
await this.fs.createFileOrFolder(currentPath, "directory");
} catch {
// Folder might already exist, ignore error
}
}
}
async fetchConfigFile(fileName: string): Promise<any> {
return await useServersFetch(`servers/${this.serverId}/config/${fileName}`);
}
constructServerProperties(properties: any): string {
let fileContent = `#Minecraft server properties\n#${new Date().toUTCString()}\n`;
for (const [key, value] of Object.entries(properties)) {
if (typeof value === "object") {
fileContent += `${key}=${JSON.stringify(value)}\n`;
} else if (typeof value === "boolean") {
fileContent += `${key}=${value ? "true" : "false"}\n`;
} else {
fileContent += `${key}=${value}\n`;
}
}
return fileContent;
}
async processImage(iconUrl: string | undefined): Promise<string | undefined> {
const sharedImage = useState<string | undefined>(`server-icon-${this.serverId}`);
if (sharedImage.value) {
return sharedImage.value;
}
try {
const auth = await useServersFetch<JWTAuth>(`servers/${this.serverId}/fs`);
try {
const fileData = await useServersFetch(`/download?path=/server-icon-original.png`, {
override: auth,
retry: false,
});
if (fileData instanceof Blob && import.meta.client) {
const dataURL = await new Promise<string>((resolve) => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();
img.onload = () => {
canvas.width = 512;
canvas.height = 512;
ctx?.drawImage(img, 0, 0, 512, 512);
const dataURL = canvas.toDataURL("image/png");
sharedImage.value = dataURL;
resolve(dataURL);
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(fileData);
});
return dataURL;
}
} catch (error) {
if (error instanceof ModrinthServerError && error.statusCode === 404 && iconUrl) {
// Handle external icon processing
try {
const response = await fetch(iconUrl);
if (!response.ok) throw new Error("Failed to fetch icon");
const file = await response.blob();
const originalFile = new File([file], "server-icon-original.png", {
type: "image/png",
});
if (import.meta.client) {
const dataURL = await new Promise<string>((resolve) => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();
img.onload = () => {
canvas.width = 64;
canvas.height = 64;
ctx?.drawImage(img, 0, 0, 64, 64);
canvas.toBlob(async (blob) => {
if (blob) {
const scaledFile = new File([blob], "server-icon.png", { type: "image/png" });
await useServersFetch(`/create?path=/server-icon.png&type=file`, {
method: "POST",
contentType: "application/octet-stream",
body: scaledFile,
override: auth,
});
await useServersFetch(`/create?path=/server-icon-original.png&type=file`, {
method: "POST",
contentType: "application/octet-stream",
body: originalFile,
override: auth,
});
}
}, "image/png");
const dataURL = canvas.toDataURL("image/png");
sharedImage.value = dataURL;
resolve(dataURL);
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(file);
});
return dataURL;
}
} catch (error) {
console.error("Failed to process external icon:", error);
}
}
}
} catch (error) {
console.error("Failed to process server icon:", error);
}
sharedImage.value = undefined;
return undefined;
}
async refresh(
modules: ModuleName[] = [],
options?: {
preserveConnection?: boolean;
preserveInstallState?: boolean;
},
): Promise<void> {
const modulesToRefresh =
modules.length > 0
? modules
: (["general", "content", "backups", "network", "startup", "ws", "fs"] as ModuleName[]);
for (const module of modulesToRefresh) {
try {
switch (module) {
case "general": {
if (options?.preserveConnection) {
const currentImage = this.general.image;
const currentMotd = this.general.motd;
const currentStatus = this.general.status;
await this.general.fetch();
if (currentImage) {
this.general.image = currentImage;
}
if (currentMotd) {
this.general.motd = currentMotd;
}
if (options.preserveInstallState && currentStatus === "installing") {
this.general.status = "installing";
}
} else {
await this.general.fetch();
}
break;
}
case "content":
await this.content.fetch();
break;
case "backups":
await this.backups.fetch();
break;
case "network":
await this.network.fetch();
break;
case "startup":
await this.startup.fetch();
break;
case "ws":
await this.ws.fetch();
break;
case "fs":
await this.fs.fetch();
break;
}
} catch (error) {
this.errors[module] = {
error:
error instanceof ModrinthServerError
? error
: new ModrinthServerError("Unknown error", undefined, error as Error),
timestamp: Date.now(),
};
}
}
}
get moduleErrors() {
return this.errors;
}
}
export const useModrinthServers = async (
serverId: string,
includedModules: ModuleName[] = ["general"],
) => {
const server = new ModrinthServer(serverId);
await server.refresh(includedModules);
return reactive(server);
};

View File

@@ -1,79 +0,0 @@
import type { Backup, AutoBackupSettings } from "@modrinth/utils";
import { useServersFetch } from "../servers-fetch.ts";
import { ServerModule } from "./base.ts";
export class BackupsModule extends ServerModule {
data: Backup[] = [];
async fetch(): Promise<void> {
this.data = await useServersFetch<Backup[]>(`servers/${this.serverId}/backups`, {}, "backups");
}
async create(backupName: string): Promise<string> {
const response = await useServersFetch<{ id: string }>(`servers/${this.serverId}/backups`, {
method: "POST",
body: { name: backupName },
});
await this.fetch(); // Refresh this module
return response.id;
}
async rename(backupId: string, newName: string): Promise<void> {
await useServersFetch(`servers/${this.serverId}/backups/${backupId}/rename`, {
method: "POST",
body: { name: newName },
});
await this.fetch(); // Refresh this module
}
async delete(backupId: string): Promise<void> {
await useServersFetch(`servers/${this.serverId}/backups/${backupId}`, {
method: "DELETE",
});
await this.fetch(); // Refresh this module
}
async restore(backupId: string): Promise<void> {
await useServersFetch(`servers/${this.serverId}/backups/${backupId}/restore`, {
method: "POST",
});
await this.fetch(); // Refresh this module
}
async prepare(backupId: string): Promise<void> {
await useServersFetch(`servers/${this.serverId}/backups/${backupId}/prepare-download`, {
method: "POST",
});
}
async lock(backupId: string): Promise<void> {
await useServersFetch(`servers/${this.serverId}/backups/${backupId}/lock`, {
method: "POST",
});
await this.fetch(); // Refresh this module
}
async unlock(backupId: string): Promise<void> {
await useServersFetch(`servers/${this.serverId}/backups/${backupId}/unlock`, {
method: "POST",
});
await this.fetch(); // Refresh this module
}
async retry(backupId: string): Promise<void> {
await useServersFetch(`servers/${this.serverId}/backups/${backupId}/retry`, {
method: "POST",
});
}
async updateAutoBackup(autoBackup: "enable" | "disable", interval: number): Promise<void> {
await useServersFetch(`servers/${this.serverId}/autobackup`, {
method: "POST",
body: { set: autoBackup, interval },
});
}
async getAutoBackup(): Promise<AutoBackupSettings> {
return await useServersFetch(`servers/${this.serverId}/autobackup`);
}
}

View File

@@ -1,15 +0,0 @@
import type { ModrinthServer } from "../modrinth-servers.ts";
export abstract class ServerModule {
protected server: ModrinthServer;
constructor(server: ModrinthServer) {
this.server = server;
}
protected get serverId(): string {
return this.server.serverId;
}
abstract fetch(): Promise<void>;
}

View File

@@ -1,36 +0,0 @@
import type { Mod, ContentType } from "@modrinth/utils";
import { useServersFetch } from "../servers-fetch.ts";
import { ServerModule } from "./base.ts";
export class ContentModule extends ServerModule {
data: Mod[] = [];
async fetch(): Promise<void> {
const mods = await useServersFetch<Mod[]>(`servers/${this.serverId}/mods`, {}, "content");
this.data = mods.sort((a, b) => (a?.name ?? "").localeCompare(b?.name ?? ""));
}
async install(contentType: ContentType, projectId: string, versionId: string): Promise<void> {
await useServersFetch(`servers/${this.serverId}/mods`, {
method: "POST",
body: {
rinth_ids: { project_id: projectId, version_id: versionId },
install_as: contentType,
},
});
}
async remove(path: string): Promise<void> {
await useServersFetch(`servers/${this.serverId}/deleteMod`, {
method: "POST",
body: { path },
});
}
async reinstall(replace: string, projectId: string, versionId: string): Promise<void> {
await useServersFetch(`servers/${this.serverId}/mods/update`, {
method: "POST",
body: { replace, project_id: projectId, version_id: versionId },
});
}
}

View File

@@ -1,224 +0,0 @@
import type {
FileUploadQuery,
JWTAuth,
DirectoryResponse,
FilesystemOp,
FSQueuedOp,
} from "@modrinth/utils";
import { ModrinthServerError } from "@modrinth/utils";
import { useServersFetch } from "../servers-fetch.ts";
import { ServerModule } from "./base.ts";
export class FSModule extends ServerModule {
auth!: JWTAuth;
ops: FilesystemOp[] = [];
queuedOps: FSQueuedOp[] = [];
opsQueuedForModification: string[] = [];
async fetch(): Promise<void> {
this.auth = await useServersFetch<JWTAuth>(`servers/${this.serverId}/fs`, {}, "fs");
this.ops = [];
this.queuedOps = [];
this.opsQueuedForModification = [];
}
private async retryWithAuth<T>(requestFn: () => Promise<T>): Promise<T> {
try {
return await requestFn();
} catch (error) {
if (error instanceof ModrinthServerError && error.statusCode === 401) {
await this.fetch(); // Refresh auth
return await requestFn();
}
throw error;
}
}
listDirContents(path: string, page: number, pageSize: number): Promise<DirectoryResponse> {
return this.retryWithAuth(async () => {
const encodedPath = encodeURIComponent(path);
return await useServersFetch(`/list?path=${encodedPath}&page=${page}&page_size=${pageSize}`, {
override: this.auth,
retry: false,
});
});
}
createFileOrFolder(path: string, type: "file" | "directory"): Promise<void> {
return this.retryWithAuth(async () => {
const encodedPath = encodeURIComponent(path);
await useServersFetch(`/create?path=${encodedPath}&type=${type}`, {
method: "POST",
contentType: "application/octet-stream",
override: this.auth,
});
});
}
uploadFile(path: string, file: File): FileUploadQuery {
const encodedPath = encodeURIComponent(path);
const progressSubject = new EventTarget();
const abortController = new AbortController();
const uploadPromise = new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) {
const progress = (e.loaded / e.total) * 100;
progressSubject.dispatchEvent(
new CustomEvent("progress", {
detail: { loaded: e.loaded, total: e.total, progress },
}),
);
}
});
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.response);
} else {
reject(new Error(`Upload failed with status ${xhr.status}`));
}
};
xhr.onerror = () => reject(new Error("Upload failed"));
xhr.onabort = () => reject(new Error("Upload cancelled"));
xhr.open("POST", `https://${this.auth.url}/create?path=${encodedPath}&type=file`);
xhr.setRequestHeader("Authorization", `Bearer ${this.auth.token}`);
xhr.setRequestHeader("Content-Type", "application/octet-stream");
xhr.send(file);
abortController.signal.addEventListener("abort", () => xhr.abort());
});
return {
promise: uploadPromise,
onProgress: (
callback: (progress: { loaded: number; total: number; progress: number }) => void,
) => {
progressSubject.addEventListener("progress", ((e: CustomEvent) => {
callback(e.detail);
}) as EventListener);
},
cancel: () => abortController.abort(),
} as FileUploadQuery;
}
renameFileOrFolder(path: string, name: string): Promise<void> {
const pathName = path.split("/").slice(0, -1).join("/") + "/" + name;
return this.retryWithAuth(async () => {
await useServersFetch(`/move`, {
method: "POST",
override: this.auth,
body: { source: path, destination: pathName },
});
});
}
updateFile(path: string, content: string): Promise<void> {
const octetStream = new Blob([content], { type: "application/octet-stream" });
return this.retryWithAuth(async () => {
await useServersFetch(`/update?path=${path}`, {
method: "PUT",
contentType: "application/octet-stream",
body: octetStream,
override: this.auth,
});
});
}
moveFileOrFolder(path: string, newPath: string): Promise<void> {
return this.retryWithAuth(async () => {
await this.server.createMissingFolders(newPath.substring(0, newPath.lastIndexOf("/")));
await useServersFetch(`/move`, {
method: "POST",
override: this.auth,
body: { source: path, destination: newPath },
});
});
}
deleteFileOrFolder(path: string, recursive: boolean): Promise<void> {
const encodedPath = encodeURIComponent(path);
return this.retryWithAuth(async () => {
await useServersFetch(`/delete?path=${encodedPath}&recursive=${recursive}`, {
method: "DELETE",
override: this.auth,
});
});
}
downloadFile(path: string, raw?: boolean): Promise<any> {
return this.retryWithAuth(async () => {
const encodedPath = encodeURIComponent(path);
const fileData = await useServersFetch(`/download?path=${encodedPath}`, {
override: this.auth,
});
if (fileData instanceof Blob) {
return raw ? fileData : await fileData.text();
}
return fileData;
});
}
extractFile(
path: string,
override = true,
dry = false,
silentQueue = false,
): Promise<{ modpack_name: string | null; conflicting_files: string[] }> {
return this.retryWithAuth(async () => {
const encodedPath = encodeURIComponent(path);
if (!silentQueue) {
this.queuedOps.push({ op: "unarchive", src: path });
setTimeout(() => this.removeQueuedOp("unarchive", path), 4000);
}
try {
return await useServersFetch(
`/unarchive?src=${encodedPath}&trg=/&override=${override}&dry=${dry}`,
{
method: "POST",
override: this.auth,
version: 1,
},
undefined,
"Error extracting file",
);
} catch (err) {
this.removeQueuedOp("unarchive", path);
throw err;
}
});
}
modifyOp(id: string, action: "dismiss" | "cancel"): Promise<void> {
return this.retryWithAuth(async () => {
await useServersFetch(
`/ops/${action}?id=${id}`,
{
method: "POST",
override: this.auth,
version: 1,
},
undefined,
`Error ${action === "dismiss" ? "dismissing" : "cancelling"} filesystem operation`,
);
this.opsQueuedForModification = this.opsQueuedForModification.filter((x: string) => x !== id);
this.ops = this.ops.filter((x: FilesystemOp) => x.id !== id);
});
}
removeQueuedOp(op: FSQueuedOp["op"], src: string): void {
this.queuedOps = this.queuedOps.filter((x: FSQueuedOp) => x.op !== op || x.src !== src);
}
clearQueuedOps(): void {
this.queuedOps = [];
}
}

View File

@@ -1,173 +0,0 @@
import { $fetch } from "ofetch";
import type { ServerGeneral, Project, PowerAction, JWTAuth } from "@modrinth/utils";
import { useServersFetch } from "../servers-fetch.ts";
import { ServerModule } from "./base.ts";
export class GeneralModule extends ServerModule implements ServerGeneral {
server_id!: string;
name!: string;
net!: { ip: string; port: number; domain: string };
game!: string;
backup_quota!: number;
used_backup_quota!: number;
status!: string;
suspension_reason!: string;
loader!: string;
loader_version!: string;
mc_version!: string;
upstream!: {
kind: "modpack" | "mod" | "resourcepack";
version_id: string;
project_id: string;
} | null;
motd?: string;
image?: string;
project?: Project;
sftp_username!: string;
sftp_password!: string;
sftp_host!: string;
datacenter?: string;
notices?: any[];
node!: { token: string; instance: string };
flows?: { intro?: boolean };
async fetch(): Promise<void> {
const data = await useServersFetch<ServerGeneral>(`servers/${this.serverId}`, {}, "general");
if (data.upstream?.project_id) {
const project = await $fetch(
`https://api.modrinth.com/v2/project/${data.upstream.project_id}`,
);
data.project = project as Project;
}
if (import.meta.client) {
data.image = (await this.server.processImage(data.project?.icon_url)) ?? undefined;
}
const motd = await this.getMotd();
if (motd === "A Minecraft Server") {
await this.setMotd(
`§b${data.project?.title || data.loader + " " + data.mc_version} §f♦ §aModrinth Servers`,
);
}
data.motd = motd;
// Copy data to this module
Object.assign(this, data);
}
async updateName(newName: string): Promise<void> {
await useServersFetch(`servers/${this.serverId}/name`, {
method: "POST",
body: { name: newName },
});
}
async power(action: PowerAction): Promise<void> {
await useServersFetch(`servers/${this.serverId}/power`, {
method: "POST",
body: { action },
});
await new Promise((resolve) => setTimeout(resolve, 1000));
await this.fetch(); // Refresh this module
}
async reinstall(
loader: boolean,
projectId: string,
versionId?: string,
loaderVersionId?: string,
hardReset: boolean = false,
): Promise<void> {
const hardResetParam = hardReset ? "true" : "false";
if (loader) {
if (projectId.toLowerCase() === "neoforge") {
projectId = "NeoForge";
}
await useServersFetch(`servers/${this.serverId}/reinstall?hard=${hardResetParam}`, {
method: "POST",
body: { loader: projectId, loader_version: loaderVersionId, game_version: versionId },
});
} else {
await useServersFetch(`servers/${this.serverId}/reinstall?hard=${hardResetParam}`, {
method: "POST",
body: { project_id: projectId, version_id: versionId },
});
}
}
async reinstallFromMrpack(mrpack: File, hardReset: boolean = false): Promise<void> {
const hardResetParam = hardReset ? "true" : "false";
const auth = await useServersFetch<JWTAuth>(`servers/${this.serverId}/reinstallFromMrpack`);
const formData = new FormData();
formData.append("file", mrpack);
const response = await fetch(
`https://${auth.url}/reinstallMrpackMultiparted?hard=${hardResetParam}`,
{
method: "POST",
headers: {
Authorization: `Bearer ${auth.token}`,
},
body: formData,
signal: AbortSignal.timeout(30 * 60 * 1000),
},
);
if (!response.ok) {
throw new Error(`[pyroservers] native fetch err status: ${response.status}`);
}
}
async suspend(status: boolean): Promise<void> {
await useServersFetch(`servers/${this.serverId}/suspend`, {
method: "POST",
body: { suspended: status },
});
}
async endIntro(): Promise<void> {
await useServersFetch(`servers/${this.serverId}/flows/intro`, {
method: "DELETE",
version: 1,
});
await this.fetch(); // Refresh this module
}
async getMotd(): Promise<string | undefined> {
try {
const props = await this.server.fs.downloadFile("/server.properties");
if (props) {
const lines = props.split("\n");
for (const line of lines) {
if (line.startsWith("motd=")) {
return line.slice(5);
}
}
}
} catch {
return undefined;
}
return undefined;
}
async setMotd(motd: string): Promise<void> {
const props = (await this.server.fetchConfigFile("ServerProperties")) as any;
if (props) {
props.motd = motd;
const newProps = this.server.constructServerProperties(props);
const octetStream = new Blob([newProps], { type: "application/octet-stream" });
const auth = await useServersFetch<JWTAuth>(`servers/${this.serverId}/fs`);
await useServersFetch(`/update?path=/server.properties`, {
method: "PUT",
contentType: "application/octet-stream",
body: octetStream,
override: auth,
});
}
}
}

View File

@@ -1,8 +0,0 @@
export * from "./base.ts";
export * from "./backups.ts";
export * from "./content.ts";
export * from "./fs.ts";
export * from "./general.ts";
export * from "./network.ts";
export * from "./startup.ts";
export * from "./ws.ts";

View File

@@ -1,47 +0,0 @@
import type { Allocation } from "@modrinth/utils";
import { useServersFetch } from "../servers-fetch.ts";
import { ServerModule } from "./base.ts";
export class NetworkModule extends ServerModule {
allocations: Allocation[] = [];
async fetch(): Promise<void> {
this.allocations = await useServersFetch<Allocation[]>(
`servers/${this.serverId}/allocations`,
{},
"network",
);
}
async reserveAllocation(name: string): Promise<Allocation> {
return await useServersFetch<Allocation>(`servers/${this.serverId}/allocations?name=${name}`, {
method: "POST",
});
}
async updateAllocation(port: number, name: string): Promise<void> {
await useServersFetch(`servers/${this.serverId}/allocations/${port}?name=${name}`, {
method: "PUT",
});
}
async deleteAllocation(port: number): Promise<void> {
await useServersFetch(`servers/${this.serverId}/allocations/${port}`, {
method: "DELETE",
});
}
async checkSubdomainAvailability(subdomain: string): Promise<boolean> {
const result = (await useServersFetch(`subdomains/${subdomain}/isavailable`)) as {
available: boolean;
};
return result.available;
}
async changeSubdomain(subdomain: string): Promise<void> {
await useServersFetch(`servers/${this.serverId}/subdomain`, {
method: "POST",
body: { subdomain },
});
}
}

View File

@@ -1,26 +0,0 @@
import type { Startup, JDKVersion, JDKBuild } from "@modrinth/utils";
import { useServersFetch } from "../servers-fetch.ts";
import { ServerModule } from "./base.ts";
export class StartupModule extends ServerModule implements Startup {
invocation!: string;
original_invocation!: string;
jdk_version!: JDKVersion;
jdk_build!: JDKBuild;
async fetch(): Promise<void> {
const data = await useServersFetch<Startup>(`servers/${this.serverId}/startup`, {}, "startup");
Object.assign(this, data);
}
async update(invocation: string, jdkVersion: JDKVersion, jdkBuild: JDKBuild): Promise<void> {
await useServersFetch(`servers/${this.serverId}/startup`, {
method: "POST",
body: {
invocation: invocation || null,
jdk_version: jdkVersion || null,
jdk_build: jdkBuild || null,
},
});
}
}

View File

@@ -1,13 +0,0 @@
import type { JWTAuth } from "@modrinth/utils";
import { useServersFetch } from "../servers-fetch.ts";
import { ServerModule } from "./base.ts";
export class WSModule extends ServerModule implements JWTAuth {
url!: string;
token!: string;
async fetch(): Promise<void> {
const data = await useServersFetch<JWTAuth>(`servers/${this.serverId}/ws`, {}, "ws");
Object.assign(this, data);
}
}

View File

@@ -1,188 +0,0 @@
import { $fetch, FetchError } from "ofetch";
import { ModrinthServerError, ModrinthServersFetchError } from "@modrinth/utils";
import type { V1ErrorInfo } from "@modrinth/utils";
export interface ServersFetchOptions {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
contentType?: string;
body?: Record<string, any>;
version?: number;
override?: {
url?: string;
token?: string;
};
retry?: number | boolean;
bypassAuth?: boolean;
}
export async function useServersFetch<T>(
path: string,
options: ServersFetchOptions = {},
module?: string,
errorContext?: string,
): Promise<T> {
const config = useRuntimeConfig();
const auth = await useAuth();
const authToken = auth.value?.token;
if (!authToken && !options.bypassAuth) {
const error = new ModrinthServersFetchError(
"[Modrinth Servers] Cannot fetch without auth",
10000,
);
throw new ModrinthServerError("Missing auth token", 401, error, module);
}
const {
method = "GET",
contentType = "application/json",
body,
version = 0,
override,
retry = method === "GET" ? 3 : 0,
} = options;
const base = (import.meta.server ? config.pyroBaseUrl : config.public.pyroBaseUrl)?.replace(
/\/$/,
"",
);
if (!base) {
const error = new ModrinthServersFetchError(
"[Modrinth Servers] Cannot fetch without base url. Make sure to set a PYRO_BASE_URL in environment variables",
10001,
);
throw new ModrinthServerError("Configuration error: Missing PYRO_BASE_URL", 500, error, module);
}
const versionString = `v${version}`;
let newOverrideUrl = override?.url;
if (newOverrideUrl && newOverrideUrl.includes("v0") && version !== 0) {
newOverrideUrl = newOverrideUrl.replace("v0", versionString);
}
const fullUrl = newOverrideUrl
? `https://${newOverrideUrl}/${path.replace(/^\//, "")}`
: version === 0
? `${base}/modrinth/v${version}/${path.replace(/^\//, "")}`
: `${base}/v${version}/${path.replace(/^\//, "")}`;
const headers: Record<string, string> = {
"User-Agent": "Modrinth/1.0 (https://modrinth.com)",
Vary: "Accept, Origin",
};
if (!options.bypassAuth) {
headers.Authorization = `Bearer ${override?.token ?? authToken}`;
headers["Access-Control-Allow-Headers"] = "Authorization";
}
if (contentType !== "none") {
headers["Content-Type"] = contentType;
}
if (import.meta.client && typeof window !== "undefined") {
headers.Origin = window.location.origin;
}
let attempts = 0;
const maxAttempts = (typeof retry === "boolean" ? (retry ? 3 : 1) : retry) + 1;
let lastError: Error | null = null;
while (attempts < maxAttempts) {
try {
const response = await $fetch<T>(fullUrl, {
method,
headers,
body: body && contentType === "application/json" ? JSON.stringify(body) : body ?? undefined,
timeout: 10000,
});
return response;
} catch (error) {
lastError = error as Error;
attempts++;
if (error instanceof FetchError) {
const statusCode = error.response?.status;
const statusText = error.response?.statusText || "Unknown error";
let v1Error: V1ErrorInfo | undefined;
if (error.data?.error && error.data?.description) {
v1Error = {
context: errorContext,
...error.data,
};
}
const errorMessages: { [key: number]: string } = {
400: "Bad Request",
401: "Unauthorized",
403: "Forbidden",
404: "Not Found",
405: "Method Not Allowed",
408: "Request Timeout",
429: "Too Many Requests",
500: "Internal Server Error",
502: "Bad Gateway",
503: "Service Unavailable",
504: "Gateway Timeout",
};
const message =
statusCode && statusCode in errorMessages
? errorMessages[statusCode]
: `HTTP Error: ${statusCode || "unknown"} ${statusText}`;
const isRetryable = statusCode ? [408, 429, 500, 502, 504].includes(statusCode) : true;
if (!isRetryable || attempts >= maxAttempts) {
console.error("Fetch error:", error);
const fetchError = new ModrinthServersFetchError(
`[Modrinth Servers] ${message}`,
statusCode,
error,
);
throw new ModrinthServerError(error.message, statusCode, fetchError, module, v1Error);
}
const delay = Math.min(1000 * Math.pow(2, attempts - 1) + Math.random() * 1000, 10000);
console.warn(`Retrying request in ${delay}ms (attempt ${attempts}/${maxAttempts - 1})`);
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
console.error("Unexpected fetch error:", error);
const fetchError = new ModrinthServersFetchError(
"[Modrinth Servers] An unexpected error occurred during the fetch operation.",
undefined,
error as Error,
);
throw new ModrinthServerError(
"Unexpected error during fetch operation",
undefined,
fetchError,
module,
);
}
}
console.error("All retry attempts failed:", lastError);
if (lastError instanceof FetchError) {
const statusCode = lastError.response?.status;
const pyroError = new ModrinthServersFetchError(
"Maximum retry attempts reached",
statusCode,
lastError,
);
throw new ModrinthServerError("Maximum retry attempts reached", statusCode, pyroError, module);
}
const fetchError = new ModrinthServersFetchError(
"Maximum retry attempts reached",
undefined,
lastError || undefined,
);
throw new ModrinthServerError("Maximum retry attempts reached", undefined, fetchError, module);
}

View File

@@ -159,7 +159,7 @@
"message": "Subscribe to updates about Modrinth"
},
"auth.welcome.description": {
"message": "Youre now part of the awesome community of creators & explorers already building, downloading, and staying up-to-date with awazing mods."
"message": "Youre now part of the awesome community of creators & explorers already building, downloading, and staying up-to-date with amazing mods."
},
"auth.welcome.label.tos": {
"message": "By creating an account, you have agreed to Modrinth's <terms-link>Terms</terms-link> and <privacy-policy-link>Privacy Policy</privacy-policy-link>."
@@ -350,14 +350,11 @@
"layout.banner.add-email.button": {
"message": "Visit account settings"
},
"layout.banner.add-email.description": {
"message": "For security reasons, Modrinth needs you to register an email address to your account."
},
"layout.banner.build-fail.description": {
"message": "This deploy of Modrinth's frontend failed to generate state from the API. This may be due to an outage or an error in configuration. Rebuild when the API is available. Error codes: {errors}; Current API URL is: {url}"
},
"layout.banner.build-fail.title": {
"message": "Error generating state from API when building."
"message": "Error generating state from API when building"
},
"layout.banner.staging.description": {
"message": "The staging environment is completely separate from the production Modrinth database. This is used for testing and debugging purposes, and may be running in-development versions of the Modrinth backend or frontend newer than the production instance."
@@ -368,12 +365,12 @@
"layout.banner.subscription-payment-failed.button": {
"message": "Update billing info"
},
"layout.banner.subscription-payment-failed.title": {
"message": "Billing action required"
},
"layout.banner.subscription-payment-failed.description": {
"message": "One or more subscriptions failed to renew. Please update your payment method to prevent losing access!"
},
"layout.banner.subscription-payment-failed.title": {
"message": "Billing action required."
},
"layout.banner.verify-email.action": {
"message": "Re-send verification email"
},
@@ -1050,23 +1047,32 @@
"message": "No notices"
},
"servers.plan.large.description": {
"message": "Ideal for 1525 players, modpacks, or heavy modding."
"message": "Ideal for larger communities, modpacks, and heavy modding."
},
"servers.plan.large.name": {
"message": "Large"
},
"servers.plan.large.symbol": {
"message": "L"
},
"servers.plan.medium.description": {
"message": "Great for 615 players and multiple mods."
"message": "Great for modded multiplayer and small communities."
},
"servers.plan.medium.name": {
"message": "Medium"
},
"servers.plan.medium.symbol": {
"message": "M"
},
"servers.plan.small.description": {
"message": "Perfect for 15 friends with a few light mods."
"message": "Perfect for vanilla multiplayer, small friend groups, SMPs, and light modding."
},
"servers.plan.small.name": {
"message": "Small"
},
"servers.plan.small.symbol": {
"message": "S"
},
"settings.billing.modal.cancel.action": {
"message": "Cancel subscription"
},

View File

@@ -275,7 +275,7 @@ import { useVIntl } from "@vintl/vintl";
import type { ServerNotice as ServerNoticeType } from "@modrinth/utils";
import { computed } from "vue";
import { NOTICE_LEVELS } from "@modrinth/ui/src/utils/notices.ts";
import { useServersFetch } from "~/composables/servers/servers-fetch.ts";
import { usePyroFetch } from "~/composables/pyroFetch.ts";
import AssignNoticeModal from "~/components/ui/servers/notice/AssignNoticeModal.vue";
const { formatMessage } = useVIntl();
@@ -290,7 +290,7 @@ const assignNoticeModal = ref<InstanceType<typeof AssignNoticeModal>>();
await refreshNotices();
async function refreshNotices() {
await useServersFetch("notices").then((res) => {
await usePyroFetch("notices").then((res) => {
notices.value = res as ServerNoticeType[];
notices.value.sort((a, b) => {
const dateDiff = dayjs(b.announce_at).diff(dayjs(a.announce_at));
@@ -347,7 +347,7 @@ function startEditing(notice: ServerNoticeType, assignments: boolean = false) {
}
async function deleteNotice(notice: ServerNoticeType) {
await useServersFetch(`notices/${notice.id}`, {
await usePyroFetch(`notices/${notice.id}`, {
method: "DELETE",
})
.then(() => {
@@ -401,7 +401,7 @@ async function saveChanges() {
return;
}
await useServersFetch(`notices/${editingNotice.value?.id}`, {
await usePyroFetch(`notices/${editingNotice.value?.id}`, {
method: "PATCH",
body: {
message: newNoticeMessage.value,
@@ -432,7 +432,7 @@ async function createNotice() {
return;
}
await useServersFetch("notices", {
await usePyroFetch("notices", {
method: "POST",
body: {
message: newNoticeMessage.value,

View File

@@ -14,7 +14,7 @@
<template v-if="orgs?.length > 0">
<div class="orgs-grid">
<nuxt-link
v-for="org in sortedOrgs"
v-for="org in orgs"
:key="org.id"
:to="`/organization/${org.slug}`"
class="universal-card button-base recessed org"
@@ -67,8 +67,6 @@ const { data: orgs, error } = useAsyncData("organizations", () => {
});
});
const sortedOrgs = computed(() => orgs.value.sort((a, b) => a.name.localeCompare(b.name)));
const onlyAcceptedMembers = (members) => members.filter((member) => member?.accepted);
if (error.value) {

View File

@@ -115,7 +115,6 @@ import {
import Avatar from "~/components/ui/Avatar.vue";
import Badge from "~/components/ui/Badge.vue";
import { formatProjectType } from "~/plugins/shorthands.js";
import { asEncodedJsonArray, fetchSegmented } from "~/utils/fetch-helpers.ts";
useHead({
title: "Review projects - Modrinth",
@@ -171,6 +170,28 @@ const projectTypes = computed(() => {
return [...set];
});
function segmentData(data, segmentSize = 800) {
return data.reduce((acc, curr, index) => {
const segment = Math.floor(index / segmentSize);
if (!acc[segment]) {
acc[segment] = [];
}
acc[segment].push(curr);
return acc;
}, []);
}
function fetchSegmented(data, createUrl, options = {}) {
return Promise.all(segmentData(data).map((ids) => useBaseFetch(createUrl(ids), options))).then(
(results) => results.flat(),
);
}
function asEncodedJsonArray(data) {
return encodeURIComponent(JSON.stringify(data));
}
if (projects.value) {
const teamIds = projects.value.map((x) => x.team_id);
const orgIds = projects.value.filter((x) => x.organization).map((x) => x.organization);

View File

@@ -334,7 +334,6 @@ import {
ImageIcon,
} from "@modrinth/assets";
import { computed } from "vue";
import { useModrinthServers } from "~/composables/servers/modrinth-servers.ts";
import ProjectCard from "~/components/ui/ProjectCard.vue";
import LogoAnimated from "~/components/brand/LogoAnimated.vue";
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
@@ -389,7 +388,7 @@ async function updateServerContext() {
if (!auth.value.user) {
router.push("/auth/sign-in?redirect=" + encodeURIComponent(route.fullPath));
} else if (route.query.sid !== null) {
server.value = await useModrinthServers(route.query.sid, ["general", "content"], {
server.value = await usePyroServer(route.query.sid, ["general", "content"], {
waitForModules: true,
});
}

View File

@@ -4,28 +4,27 @@
data-pyro
class="servers-hero relative isolate -mt-44 h-full min-h-screen pt-8"
>
<ModrinthServersPurchaseModal
v-if="customer"
:key="`purchase-modal-${customer.id}`"
<PurchaseModal
v-if="showModal && selectedProduct && customer"
:key="selectedProduct.id"
ref="purchaseModal"
:product="selectedProduct"
:country="country"
:custom-server="customServer"
:publishable-key="config.public.stripePublishableKey"
:initiate-payment="
:send-billing-request="
async (body) =>
await useBaseFetch('billing/payment', { internal: true, method: 'POST', body })
"
:available-products="pyroProducts"
:fetch-payment-data="fetchPaymentData"
:on-error="handleError"
:customer="customer"
:payment-methods="paymentMethods"
:currency="selectedCurrency"
:return-url="`${config.public.siteUrl}/servers/manage`"
:server-name="`${auth?.user?.username}'s server`"
:out-of-stock-url="outOfStockUrl"
:fetch-capacity-statuses="fetchCapacityStatuses"
:pings="regionPings"
:regions="regions"
:refresh-payment-methods="fetchPaymentData"
:fetch-stock="fetchStock"
:out-of-stock-url="outOfStockUrl"
@hidden="handleModalHidden"
/>
<section
@@ -443,8 +442,8 @@
Where are Modrinth Servers located? Can I choose a region?
</summary>
<p class="m-0 ml-6 leading-[160%]">
We have servers in both North America in Vint Hill, Virginia, and Europe in Limburg,
Germany. More regions to come in the future!
Currently, Modrinth Servers are located on the east coast of the United States in
Vint Hill, Virginia. More regions to come in the future!
</p>
</details>
@@ -498,6 +497,98 @@
</div>
</section>
<section
v-if="false"
class="relative mt-24 flex flex-col bg-[radial-gradient(65%_50%_at_50%_-10%,var(--color-brand-highlight)_0%,var(--color-accent-contrast)_100%)] px-3 pt-24 md:mt-48 md:pt-48"
>
<div class="faded-brand-line absolute left-0 top-0 h-[1px] w-full"></div>
<div class="mx-auto flex w-full max-w-7xl flex-col gap-8">
<div class="grid grid-cols-1 items-center gap-12 lg:grid-cols-2">
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-4">
<div
class="relative w-fit rounded-full bg-highlight-green px-3 py-1 text-sm font-bold text-brand backdrop-blur-lg"
>
Server Locations
</div>
<h1 class="relative m-0 max-w-2xl text-4xl leading-[120%] md:text-7xl">
Coast-to-Coast Coverage
</h1>
</div>
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3">
<div class="grid size-8 place-content-center rounded-full bg-highlight-green">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-brand"
>
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
<circle cx="12" cy="10" r="3" />
</svg>
</div>
<h2 class="relative m-0 text-xl font-medium leading-[155%] md:text-2xl">
US Coverage
</h2>
</div>
<p
class="relative m-0 max-w-xl text-base font-normal leading-[155%] text-secondary md:text-[18px]"
>
With strategically placed servers in New York, California, Texas, Florida, and
Washington, we ensure low latency connections for players across North America.
Each location is equipped with high-performance hardware and DDoS protection.
</p>
</div>
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3">
<div class="grid size-8 place-content-center rounded-full bg-highlight-blue">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-blue"
>
<path d="M12 2a10 10 0 1 0 10 10" />
<path d="M18 13a6 6 0 0 0-6-6" />
<path d="M13 2.05a10 10 0 0 1 2 2" />
<path d="M19.5 8.5a10 10 0 0 1 2 2" />
</svg>
</div>
<h2 class="relative m-0 text-xl font-medium leading-[155%] md:text-2xl">
Global Expansion
</h2>
</div>
<p
class="relative m-0 max-w-xl text-base font-normal leading-[155%] text-secondary md:text-[18px]"
>
We're expanding to Europe and Asia-Pacific regions soon, bringing Modrinth's
seamless hosting experience worldwide. Join our Discord to stay updated on new
region launches.
</p>
</div>
</div>
</div>
<Globe />
</div>
</div>
</section>
<section
id="plan"
class="relative mt-24 flex flex-col bg-[radial-gradient(65%_50%_at_50%_-10%,var(--color-brand-highlight)_0%,var(--color-accent-contrast)_100%)] px-3 pt-24 md:mt-48 md:pt-48"
@@ -505,35 +596,19 @@
<div class="faded-brand-line absolute left-0 top-0 h-[1px] w-full"></div>
<div class="mx-auto flex w-full max-w-7xl flex-col items-center gap-8 text-center">
<h1 class="relative m-0 text-4xl leading-[120%] md:text-7xl">
There's a server for everyone
Start your server on Modrinth
</h1>
<p class="m-0 flex items-center gap-1">
Available in North America and Europe for wide coverage.
</p>
<h2
class="relative m-0 max-w-xl text-base font-normal leading-[155%] text-secondary md:text-[18px]"
>
{{
isAtCapacity && !loggedOut
? "We are currently at capacity. Please try again later."
: "There's a plan for everyone! Choose the one that fits your needs."
}}
</h2>
<div class="grid grid-cols-[1fr_auto_1fr] items-center gap-3">
<span></span>
<OptionGroup v-slot="{ option }" v-model="billingPeriod" :options="billingPeriods">
<template v-if="option === 'monthly'"> Pay monthly </template>
<span v-else-if="option === 'quarterly'"> Pay quarterly </span>
<span v-else-if="option === 'yearly'"> Pay yearly </span>
</OptionGroup>
<template v-if="billingPeriods.includes('quarterly')">
<button
v-if="billingPeriod !== 'quarterly'"
class="bg-transparent p-0 text-sm font-medium text-brand hover:underline active:scale-95"
@click="billingPeriod = 'quarterly'"
>
Save 16% with quarterly billing!
</button>
<span v-else class="bg-transparent p-0 text-sm font-medium text-brand">
Save 16% with quarterly billing!
</span>
</template>
<span v-else></span>
</div>
<ul class="m-0 flex w-full grid-cols-3 flex-col gap-8 p-0 lg:grid">
<ul class="m-0 mt-8 flex w-full flex-col gap-8 p-0 lg:flex-row">
<ServerPlanSelector
:capacity="capacityStatuses?.small?.available"
plan="small"
@@ -541,12 +616,9 @@
:storage="plans.small.metadata.storage"
:cpus="plans.small.metadata.cpu"
:price="
plans.small?.prices?.find((x) => x.currency_code === selectedCurrency)?.prices
?.intervals?.[billingPeriod]
plans.small?.prices?.find((x) => x.currency_code === 'USD')?.prices?.intervals
?.monthly
"
:interval="billingPeriod"
:currency="selectedCurrency"
:is-usa="country.toLowerCase() === 'us'"
@select="selectProduct('small')"
@scroll-to-faq="scrollToFaq()"
/>
@@ -557,12 +629,9 @@
:storage="plans.medium.metadata.storage"
:cpus="plans.medium.metadata.cpu"
:price="
plans.medium?.prices?.find((x) => x.currency_code === selectedCurrency)?.prices
?.intervals?.[billingPeriod]
plans.medium?.prices?.find((x) => x.currency_code === 'USD')?.prices?.intervals
?.monthly
"
:interval="billingPeriod"
:currency="selectedCurrency"
:is-usa="country.toLowerCase() === 'us'"
@select="selectProduct('medium')"
@scroll-to-faq="scrollToFaq()"
/>
@@ -572,13 +641,10 @@
:storage="plans.large.metadata.storage"
:cpus="plans.large.metadata.cpu"
:price="
plans.large?.prices?.find((x) => x.currency_code === selectedCurrency)?.prices
?.intervals?.[billingPeriod]
plans.large?.prices?.find((x) => x.currency_code === 'USD')?.prices?.intervals
?.monthly
"
:currency="selectedCurrency"
:is-usa="country.toLowerCase() === 'us'"
plan="large"
:interval="billingPeriod"
@select="selectProduct('large')"
@scroll-to-faq="scrollToFaq()"
/>
@@ -588,9 +654,10 @@
class="mb-24 flex w-full flex-col items-start justify-between gap-4 rounded-2xl bg-bg p-8 text-left lg:flex-row lg:gap-0"
>
<div class="flex flex-col gap-4">
<h1 class="m-0">Know exactly what you need?</h1>
<h1 class="m-0">Build your own</h1>
<h2 class="m-0 text-base font-normal text-primary">
Pick a customized plan with just the specs you need.
If you're a more technical server administrator, you can pick your own RAM and storage
options.
</h2>
</div>
@@ -599,7 +666,7 @@
>
<ButtonStyled color="standard" size="large">
<button class="w-full md:w-fit" @click="selectProduct('custom')">
Get started
Build your own
<RightArrowIcon class="shrink-0" />
</button>
</ButtonStyled>
@@ -612,7 +679,7 @@
</template>
<script setup>
import { ButtonStyled, ModrinthServersPurchaseModal } from "@modrinth/ui";
import { ButtonStyled, PurchaseModal } from "@modrinth/ui";
import {
BoxIcon,
GameIcon,
@@ -623,13 +690,9 @@ import {
ServerIcon,
} from "@modrinth/assets";
import { products } from "~/generated/state.json";
import { useServersFetch } from "~/composables/servers/servers-fetch.ts";
import LoaderIcon from "~/components/ui/servers/icons/LoaderIcon.vue";
import Globe from "~/components/ui/servers/Globe.vue";
import ServerPlanSelector from "~/components/ui/servers/marketing/ServerPlanSelector.vue";
import OptionGroup from "~/components/ui/OptionGroup.vue";
const billingPeriods = ref(["monthly", "quarterly"]);
const billingPeriod = ref(billingPeriods.value.includes("quarterly") ? "quarterly" : "monthly");
const pyroProducts = products.filter((p) => p.metadata.type === "pyro");
const pyroPlanProducts = pyroProducts.filter(
@@ -648,6 +711,16 @@ useSeoMeta({
ogDescription: description,
});
useHead({
script: [
{
src: "https://js.stripe.com/v3/",
defer: true,
async: true,
},
],
});
const auth = await useAuth();
const data = useNuxtApp();
const config = useRuntimeConfig();
@@ -667,7 +740,6 @@ const isDeleting = ref(false);
const typingSpeed = 75;
const deletingSpeed = 25;
const pauseTime = 2000;
const selectedCurrency = ref("USD");
const loggedOut = computed(() => !auth.value.user);
const outOfStockUrl = "https://discord.modrinth.com";
@@ -675,23 +747,13 @@ const outOfStockUrl = "https://discord.modrinth.com";
const { data: hasServers } = await useAsyncData("ServerListCountCheck", async () => {
try {
if (!auth.value.user) return false;
const response = await useServersFetch("servers");
const response = await usePyroFetch("servers");
return response.servers && response.servers.length > 0;
} catch {
return false;
}
});
function fetchStock(region, request) {
return useServersFetch(`stock?region=${region.shortcode}`, {
method: "POST",
body: {
...request,
},
bypassAuth: true,
}).then((res) => res.available);
}
async function fetchCapacityStatuses(customProduct = null) {
try {
const productsToCheck = customProduct?.metadata
@@ -703,7 +765,7 @@ async function fetchCapacityStatuses(customProduct = null) {
),
];
const capacityChecks = productsToCheck.map((product) =>
useServersFetch("stock", {
usePyroFetch("stock", {
method: "POST",
body: {
cpu: product.metadata.cpu,
@@ -779,6 +841,23 @@ const handleError = (err) => {
});
};
const handleModalHidden = () => {
showModal.value = false;
};
watch(selectedProduct, async (newProduct) => {
if (newProduct) {
showModal.value = false;
await nextTick();
showModal.value = true;
modalKey.value++;
await nextTick();
if (purchaseModal.value && purchaseModal.value.show) {
purchaseModal.value.show();
}
}
});
async function fetchPaymentData() {
if (!auth.value.user) return;
try {
@@ -875,10 +954,8 @@ const selectProduct = async (product) => {
modalKey.value++;
await nextTick();
if (product === "custom") {
purchaseModal.value?.show(billingPeriod.value);
} else {
purchaseModal.value?.show(billingPeriod.value, selectedProduct.value);
if (purchaseModal.value && purchaseModal.value.show) {
purchaseModal.value.show();
}
};
@@ -889,82 +966,9 @@ const planQuery = () => {
}
};
const regions = ref([]);
const regionPings = ref([]);
function pingRegions() {
useServersFetch("regions", {
method: "GET",
version: 1,
bypassAuth: true,
}).then((res) => {
regions.value = res;
regions.value.forEach((region) => {
runPingTest(region);
});
});
}
const PING_COUNT = 20;
const PING_INTERVAL = 200;
const MAX_PING_TIME = 1000;
function runPingTest(region, index = 1) {
if (index > 10) {
regionPings.value.push({
region: region.shortcode,
ping: -1,
});
return;
}
const wsUrl = `wss://${region.shortcode}${index}.${region.zone}/pingtest`;
try {
const socket = new WebSocket(wsUrl);
const pings = [];
socket.onopen = () => {
for (let i = 0; i < PING_COUNT; i++) {
setTimeout(() => {
socket.send(performance.now());
}, i * PING_INTERVAL);
}
setTimeout(
() => {
socket.close();
const median = Math.round([...pings].sort((a, b) => a - b)[Math.floor(pings.length / 2)]);
if (median) {
regionPings.value.push({
region: region.shortcode,
ping: median,
});
}
},
PING_COUNT * PING_INTERVAL + MAX_PING_TIME,
);
};
socket.onmessage = (event) => {
pings.push(performance.now() - event.data);
};
socket.onerror = (event) => {
console.error(
`Failed to connect pingtest WebSocket with ${wsUrl}, trying index ${index + 1}:`,
event,
);
runPingTest(region, index + 1);
};
} catch (error) {
console.error(`Failed to connect pingtest WebSocket with ${wsUrl}:`, error);
}
}
onMounted(() => {
startTyping();
planQuery();
pingRegions();
});
watch(customer, (newCustomer) => {

View File

@@ -63,8 +63,8 @@
</div>
<div
v-else-if="
server.moduleErrors?.general?.error.statusCode === 403 ||
server.moduleErrors?.general?.error.statusCode === 404
server.general?.error?.error.statusCode === 403 ||
server.general?.error?.error.statusCode === 404
"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
@@ -81,7 +81,7 @@
is an error, please contact Modrinth Support.
</p>
</div>
<UiCopyCode :text="JSON.stringify(server.moduleErrors?.general?.error)" />
<UiCopyCode :text="JSON.stringify(server.general?.error)" />
<ButtonStyled size="large" color="brand" @click="() => router.push('/servers/manage')">
<button class="mt-6 !w-full">Go back to all servers</button>
@@ -89,7 +89,7 @@
</div>
</div>
<div
v-else-if="server.moduleErrors?.general?.error.statusCode === 503"
v-else-if="server.general?.error?.error.statusCode === 503"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
@@ -141,7 +141,7 @@
</div>
</div>
<div
v-else-if="server.moduleErrors?.general?.error"
v-else-if="server.general?.error"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
@@ -162,7 +162,7 @@
temporary network issue. You'll be reconnected automatically.
</p>
</div>
<UiCopyCode :text="JSON.stringify(server.moduleErrors?.general?.error)" />
<UiCopyCode :text="JSON.stringify(server.general?.error)" />
<ButtonStyled
:disabled="formattedTime !== '00'"
size="large"
@@ -207,7 +207,6 @@
class="server-action-buttons-anim flex w-fit flex-shrink-0"
>
<UiServersPanelServerActionButton
v-if="!serverData.flows?.intro"
class="flex-shrink-0"
:is-online="isServerRunning"
:is-actioning="isActioning"
@@ -221,14 +220,7 @@
</div>
</div>
<div
v-if="serverData.flows?.intro"
class="flex items-center gap-2 font-semibold text-secondary"
>
<SettingsIcon /> Configuring server...
</div>
<UiServersServerInfoLabels
v-else
:server-data="serverData"
:show-game-label="showGameLabel"
:show-loader-label="showLoaderLabel"
@@ -239,189 +231,149 @@
</div>
</div>
<template v-if="serverData.flows?.intro">
<div
data-pyro-navigation
class="isolate flex w-full select-none flex-col justify-between gap-4 overflow-auto md:flex-row md:items-center"
>
<UiNavTabs :links="navLinks" />
</div>
<div data-pyro-mount class="h-full w-full flex-1">
<div
v-if="serverData?.status === 'installing'"
class="w-50 h-50 flex items-center justify-center gap-2 text-center text-lg font-bold"
v-if="error"
class="mx-auto mb-4 flex justify-between gap-2 rounded-2xl border-2 border-solid border-red bg-bg-red p-4 font-semibold text-contrast"
>
<LazyUiServersPanelSpinner class="size-10 animate-spin" /> Setting up your server...
</div>
<div v-else>
<h2 class="my-4 text-xl font-extrabold">
What would you like to install on your new server?
</h2>
<div class="flex flex-row gap-4">
<IssuesIcon class="hidden h-8 w-8 shrink-0 text-red sm:block" />
<div class="flex flex-col gap-2 leading-[150%]">
<div class="flex items-center gap-3">
<IssuesIcon class="flex h-8 w-8 shrink-0 text-red sm:hidden" />
<div class="flex gap-2 text-2xl font-bold">{{ errorTitle }}</div>
</div>
<ServerInstallation
:server="server as ModrinthServer"
:backup-in-progress="backupInProgress"
ignore-current-installation
@reinstall="onReinstall"
/>
</div>
</template>
<template v-else>
<div
data-pyro-navigation
class="isolate flex w-full select-none flex-col justify-between gap-4 overflow-auto md:flex-row md:items-center"
>
<UiNavTabs :links="navLinks" />
</div>
<div data-pyro-mount class="h-full w-full flex-1">
<div
v-if="error"
class="mx-auto mb-4 flex justify-between gap-2 rounded-2xl border-2 border-solid border-red bg-bg-red p-4 font-semibold text-contrast"
>
<div class="flex flex-row gap-4">
<IssuesIcon class="hidden h-8 w-8 shrink-0 text-red sm:block" />
<div class="flex flex-col gap-2 leading-[150%]">
<div class="flex items-center gap-3">
<IssuesIcon class="flex h-8 w-8 shrink-0 text-red sm:hidden" />
<div class="flex gap-2 text-2xl font-bold">{{ errorTitle }}</div>
<div v-if="errorTitle.toLocaleLowerCase() === 'installation error'" class="font-normal">
<div
v-if="errorMessage.toLocaleLowerCase() === 'the specified version may be incorrect'"
>
An invalid loader or Minecraft version was specified and could not be installed.
<ul class="m-0 mt-4 p-0 pl-4">
<li>
If this version of Minecraft was released recently, please check if Modrinth
Servers supports it.
</li>
<li>
If you've installed a modpack, it may have been packaged incorrectly or may not
be compatible with the loader.
</li>
<li>
Your server may need to be reinstalled with a valid mod loader and version. You
can change the loader by clicking the "Change Loader" button.
</li>
<li>
If you're stuck, please contact Modrinth Support with the information below:
</li>
</ul>
<ButtonStyled>
<button class="mt-2" @click="copyServerDebugInfo">
<CopyIcon v-if="!copied" />
<CheckIcon v-else />
Copy Debug Info
</button>
</ButtonStyled>
</div>
<div v-if="errorMessage.toLocaleLowerCase() === 'internal error'">
An internal error occurred while installing your server. Don't fret try
reinstalling your server, and if the problem persists, please contact Modrinth
support with your server's debug information.
</div>
<div v-if="errorMessage.toLocaleLowerCase() === 'this version is not yet supported'">
An error occurred while installing your server because Modrinth Servers does not
support the version of Minecraft or the loader you specified. Try reinstalling your
server with a different version or loader, and if the problem persists, please
contact Modrinth Support with your server's debug information.
</div>
<div
v-if="errorTitle.toLocaleLowerCase() === 'installation error'"
class="font-normal"
v-if="errorTitle === 'Installation error'"
class="mt-2 flex flex-col gap-4 sm:flex-row"
>
<div
v-if="
errorMessage.toLocaleLowerCase() === 'the specified version may be incorrect'
"
>
An invalid loader or Minecraft version was specified and could not be installed.
<ul class="m-0 mt-4 p-0 pl-4">
<li>
If this version of Minecraft was released recently, please check if Modrinth
Servers supports it.
</li>
<li>
If you've installed a modpack, it may have been packaged incorrectly or may
not be compatible with the loader.
</li>
<li>
Your server may need to be reinstalled with a valid mod loader and version.
You can change the loader by clicking the "Change Loader" button.
</li>
<li>
If you're stuck, please contact Modrinth Support with the information below:
</li>
</ul>
<ButtonStyled>
<button class="mt-2" @click="copyServerDebugInfo">
<CopyIcon v-if="!copied" />
<CheckIcon v-else />
Copy Debug Info
</button>
</ButtonStyled>
</div>
<div v-if="errorMessage.toLocaleLowerCase() === 'internal error'">
An internal error occurred while installing your server. Don't fret — try
reinstalling your server, and if the problem persists, please contact Modrinth
support with your server's debug information.
</div>
<div
v-if="errorMessage.toLocaleLowerCase() === 'this version is not yet supported'"
>
An error occurred while installing your server because Modrinth Servers does not
support the version of Minecraft or the loader you specified. Try reinstalling
your server with a different version or loader, and if the problem persists,
please contact Modrinth Support with your server's debug information.
</div>
<div
v-if="errorTitle === 'Installation error'"
class="mt-2 flex flex-col gap-4 sm:flex-row"
>
<ButtonStyled v-if="errorLog">
<button @click="openInstallLog"><FileIcon />Open Installation Log</button>
</ButtonStyled>
<ButtonStyled>
<button @click="copyServerDebugInfo">
<CopyIcon v-if="!copied" />
<CheckIcon v-else />
Copy Debug Info
</button>
</ButtonStyled>
<ButtonStyled color="red" type="standard">
<NuxtLink
class="whitespace-pre"
:to="`/servers/manage/${serverId}/options/loader`"
>
<RightArrowIcon />
Change Loader
</NuxtLink>
</ButtonStyled>
</div>
<ButtonStyled v-if="errorLog">
<button @click="openInstallLog"><FileIcon />Open Installation Log</button>
</ButtonStyled>
<ButtonStyled>
<button @click="copyServerDebugInfo">
<CopyIcon v-if="!copied" />
<CheckIcon v-else />
Copy Debug Info
</button>
</ButtonStyled>
<ButtonStyled color="red" type="standard">
<NuxtLink
class="whitespace-pre"
:to="`/servers/manage/${serverId}/options/loader`"
>
<RightArrowIcon />
Change Loader
</NuxtLink>
</ButtonStyled>
</div>
</div>
</div>
</div>
</div>
<div
v-if="!isConnected && !isReconnecting && !isLoading"
data-pyro-server-ws-error
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-red p-4 text-contrast"
>
<IssuesIcon class="size-5 text-red" />
Something went wrong...
</div>
<div
v-if="!isConnected && !isReconnecting && !isLoading"
data-pyro-server-ws-error
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-red p-4 text-contrast"
>
<IssuesIcon class="size-5 text-red" />
Something went wrong...
</div>
<div
v-if="isReconnecting"
data-pyro-server-ws-reconnecting
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-orange p-4 text-sm text-contrast"
>
<UiServersPanelSpinner />
Hang on, we're reconnecting to your server.
</div>
<div
v-if="isReconnecting"
data-pyro-server-ws-reconnecting
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-orange p-4 text-sm text-contrast"
>
<UiServersPanelSpinner />
Hang on, we're reconnecting to your server.
</div>
<div
v-if="serverData.status === 'installing'"
data-pyro-server-installing
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-blue p-4 text-sm text-contrast"
>
<UiServersServerIcon :image="serverData.image" class="!h-10 !w-10" />
<div
v-if="serverData.status === 'installing'"
data-pyro-server-installing
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-blue p-4 text-sm text-contrast"
>
<UiServersServerIcon :image="serverData.image" class="!h-10 !w-10" />
<div class="flex flex-col gap-1">
<span class="text-lg font-bold"> We're preparing your server! </span>
<div class="flex flex-row items-center gap-2">
<UiServersPanelSpinner class="!h-3 !w-3" /> <LazyUiServersInstallingTicker />
</div>
<div class="flex flex-col gap-1">
<span class="text-lg font-bold"> We're preparing your server! </span>
<div class="flex flex-row items-center gap-2">
<UiServersPanelSpinner class="!h-3 !w-3" /> <LazyUiServersInstallingTicker />
</div>
</div>
<NuxtPage
:route="route"
:is-connected="isConnected"
:is-ws-auth-incorrect="isWSAuthIncorrect"
:is-server-running="isServerRunning"
:stats="stats"
:server-power-state="serverPowerState"
:power-state-details="powerStateDetails"
:socket="socket"
:server="server"
:backup-in-progress="backupInProgress"
@reinstall="onReinstall"
/>
</div>
</template>
</div>
<div
v-if="flags.advancedDebugInfo"
class="experimental-styles-within relative mx-auto mt-6 box-border w-full min-w-0 max-w-[1280px] px-6"
>
<h2 class="m-0 text-lg font-extrabold text-contrast">Server data</h2>
<pre class="markdown-body w-full overflow-auto rounded-2xl bg-bg-raised p-4 text-sm">{{
JSON.stringify(server, null, " ")
}}</pre>
<NuxtPage
:route="route"
:is-connected="isConnected"
:is-ws-auth-incorrect="isWSAuthIncorrect"
:is-server-running="isServerRunning"
:stats="stats"
:server-power-state="serverPowerState"
:power-state-details="powerStateDetails"
:socket="socket"
:server="server"
:backup-in-progress="backupInProgress"
@reinstall="onReinstall"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, type Reactive } from "vue";
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import {
SettingsIcon,
CopyIcon,
IssuesIcon,
LeftArrowIcon,
@@ -434,20 +386,12 @@ import {
import DOMPurify from "dompurify";
import { ButtonStyled, ServerNotice } from "@modrinth/ui";
import { Intercom, shutdown } from "@intercom/messenger-js-sdk";
import type { MessageDescriptor } from "@vintl/vintl";
import type {
ServerState,
Stats,
WSEvent,
WSInstallationResultEvent,
Backup,
PowerAction,
} from "@modrinth/utils";
import { reloadNuxtApp, navigateTo } from "#app";
import { useModrinthServersConsole } from "~/store/console.ts";
import { useServersFetch } from "~/composables/servers/servers-fetch.ts";
import { ModrinthServer, useModrinthServers } from "~/composables/servers/modrinth-servers.ts";
import ServerInstallation from "~/components/ui/servers/ServerInstallation.vue";
import type { MessageDescriptor } from "@vintl/vintl";
import type { ServerState, Stats, WSEvent, WSInstallationResultEvent } from "~/types/servers";
import { usePyroConsole } from "~/store/console.ts";
import { type Backup } from "~/composables/pyroServers.ts";
import { usePyroFetch } from "~/composables/pyroFetch.ts";
const app = useNuxtApp() as unknown as { $notify: any };
@@ -457,7 +401,6 @@ const isLoading = ref(true);
const reconnectInterval = ref<ReturnType<typeof setInterval> | null>(null);
const isFirstMount = ref(true);
const isMounted = ref(true);
const flags = useFeatureFlags();
const INTERCOM_APP_ID = ref("ykeritl9");
const auth = (await useAuth()) as unknown as {
@@ -474,19 +417,19 @@ const route = useNativeRoute();
const router = useRouter();
const serverId = route.params.id as string;
const server: Reactive<ModrinthServer> = await useModrinthServers(serverId, ["general", "ws"]);
const server = await usePyroServer(serverId, ["general", "ws"]);
const loadModulesPromise = Promise.resolve().then(() => {
if (server.general?.status === "suspended") {
return;
}
return server.refresh(["content", "backups", "network", "startup", "fs"]);
return server.loadModules(["content", "backups", "network", "startup", "fs"]);
});
provide("modulesLoaded", loadModulesPromise);
watch(
() => [server.moduleErrors?.general, server.moduleErrors?.ws],
() => [server.general?.error, server.ws?.error],
([generalError, wsError]) => {
if (server.general?.status === "suspended") return;
@@ -504,7 +447,7 @@ const errorLogFile = ref("");
const serverData = computed(() => server.general);
const isConnected = ref(false);
const isWSAuthIncorrect = ref(false);
const modrinthServersConsole = useModrinthServersConsole();
const pyroConsole = usePyroConsole();
const cpuData = ref<number[]>([]);
const ramData = ref<number[]>([]);
const isActioning = ref(false);
@@ -678,7 +621,7 @@ const connectWebSocket = () => {
return;
}
modrinthServersConsole.clear();
pyroConsole.clear();
socket.value?.send(JSON.stringify({ event: "auth", jwt: wsAuth.value?.token }));
isConnected.value = true;
isReconnecting.value = false;
@@ -686,7 +629,7 @@ const connectWebSocket = () => {
if (firstConnect.value) {
for (let i = 0; i < initialConsoleMessage.length; i++) {
modrinthServersConsole.addLine(initialConsoleMessage[i]);
pyroConsole.addLine(initialConsoleMessage[i]);
}
}
@@ -709,9 +652,7 @@ const connectWebSocket = () => {
socket.value.onclose = () => {
if (isMounted.value) {
modrinthServersConsole.addLine(
"\nSomething went wrong with the connection, we're reconnecting...",
);
pyroConsole.addLine("\nSomething went wrong with the connection, we're reconnecting...");
isConnected.value = false;
scheduleReconnect();
}
@@ -769,7 +710,7 @@ const handleWebSocketMessage = (data: WSEvent) => {
case "log":
// eslint-disable-next-line no-case-declarations
const log = data.message.split("\n").filter((l) => l.trim());
modrinthServersConsole.addLines(log);
pyroConsole.addLines(log);
break;
case "stats":
updateStats(data);
@@ -871,10 +812,6 @@ const newLoaderVersion = ref<string | null>(null);
const newMCVersion = ref<string | null>(null);
const onReinstall = (potentialArgs: any) => {
if (serverData.value?.flows?.intro) {
server.general?.endIntro();
}
if (!serverData.value) return;
serverData.value.status = "installing";
@@ -1030,11 +967,11 @@ const toAdverb = (word: string) => {
return word + "ing";
};
const sendPowerAction = async (action: PowerAction) => {
const sendPowerAction = async (action: "restart" | "start" | "stop" | "kill") => {
const actionName = action.charAt(0).toUpperCase() + action.slice(1);
try {
isActioning.value = true;
await server.general?.power(action);
await server.general?.power(actionName);
} catch (error) {
console.error(`Error ${toAdverb(actionName)} server:`, error);
notifyError(
@@ -1167,7 +1104,7 @@ const cleanup = () => {
};
async function dismissNotice(noticeId: number) {
await useServersFetch(`servers/${serverId}/notices/${noticeId}/dismiss`, {
await usePyroFetch(`servers/${serverId}/notices/${noticeId}/dismiss`, {
method: "POST",
}).catch((err) => {
app.$notify({
@@ -1186,18 +1123,14 @@ onMounted(() => {
isLoading.value = false;
return;
}
if (server.moduleErrors.general?.error) {
if (!server.moduleErrors.general?.error?.message?.includes("Forbidden")) {
if (server.error) {
if (!server.error.message.includes("Forbidden")) {
startPolling();
}
} else {
connectWebSocket();
}
if (server.general?.flows?.intro && server.general?.project) {
server.general?.endIntro();
}
if (username.value && email.value && userId.value && createdAt.value) {
const currentUser = auth.value?.user as any;
const matches =

View File

@@ -1,6 +1,6 @@
<template>
<div
v-if="server.moduleErrors.backups"
v-if="server.backups?.error"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
@@ -15,9 +15,7 @@
We couldn't load your server's backups. Here's what went wrong:
</p>
<p>
<span class="break-all font-mono">{{
JSON.stringify(server.moduleErrors.backups.error)
}}</span>
<span class="break-all font-mono">{{ JSON.stringify(server.backups.error) }}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['backups'])">
<button class="mt-6 !w-full">Retry</button>
@@ -154,17 +152,16 @@ import { ButtonStyled, TagItem } from "@modrinth/ui";
import { useStorage } from "@vueuse/core";
import { SpinnerIcon, PlusIcon, DownloadIcon, SettingsIcon, IssuesIcon } from "@modrinth/assets";
import { ref, computed } from "vue";
import type { Backup } from "@modrinth/utils";
import type { Server } from "~/composables/pyroServers";
import BackupItem from "~/components/ui/servers/BackupItem.vue";
import BackupRenameModal from "~/components/ui/servers/BackupRenameModal.vue";
import BackupCreateModal from "~/components/ui/servers/BackupCreateModal.vue";
import BackupRestoreModal from "~/components/ui/servers/BackupRestoreModal.vue";
import BackupDeleteModal from "~/components/ui/servers/BackupDeleteModal.vue";
import BackupSettingsModal from "~/components/ui/servers/BackupSettingsModal.vue";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
const props = defineProps<{
server: ModrinthServer;
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
isServerRunning: boolean;
}>();

View File

@@ -5,12 +5,12 @@
</template>
<script setup lang="ts">
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import type { Server } from "~/composables/pyroServers";
const route = useNativeRoute();
const props = defineProps<{
server: ModrinthServer;
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
}>();
const data = computed(() => props.server.general);

View File

@@ -11,7 +11,7 @@
/>
<div
v-if="server.moduleErrors.content"
v-if="server.content?.error"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
@@ -24,9 +24,7 @@
</div>
<p class="text-lg text-secondary">
We couldn't load your server's {{ type.toLowerCase() }}s. Here's what we know:
<span class="break-all font-mono">{{
JSON.stringify(server.moduleErrors.content.error)
}}</span>
<span class="break-all font-mono">{{ JSON.stringify(server.content.error) }}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['content'])">
<button class="mt-6 !w-full">Retry</button>
@@ -351,14 +349,13 @@ import {
} from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
import type { Mod } from "@modrinth/utils";
import FilesUploadDragAndDrop from "~/components/ui/servers/FilesUploadDragAndDrop.vue";
import FilesUploadDropdown from "~/components/ui/servers/FilesUploadDropdown.vue";
import type { Server } from "~/composables/pyroServers";
import { acceptFileFromProjectType } from "~/helpers/fileUtils.js";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
const props = defineProps<{
server: ModrinthServer;
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
}>();
const type = computed(() => {

View File

@@ -278,11 +278,16 @@ import {
} from "@modrinth/assets";
import { computed } from "vue";
import { ButtonStyled, ProgressBar } from "@modrinth/ui";
import { formatBytes, ModrinthServersFetchError } from "@modrinth/utils";
import type { FilesystemOp, FSQueuedOp, DirectoryItem, DirectoryResponse } from "@modrinth/utils";
import { handleError, ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import { formatBytes } from "@modrinth/utils";
import {
type DirectoryResponse,
type DirectoryItem,
type Server,
handleError,
} from "~/composables/pyroServers.ts";
import FilesUploadDragAndDrop from "~/components/ui/servers/FilesUploadDragAndDrop.vue";
import FilesUploadDropdown from "~/components/ui/servers/FilesUploadDropdown.vue";
import type { FilesystemOp, FSQueuedOp } from "~/types/servers.ts";
import FilesUploadZipUrlModal from "~/components/ui/servers/FilesUploadZipUrlModal.vue";
import FilesUploadConflictModal from "~/components/ui/servers/FilesUploadConflictModal.vue";
@@ -311,7 +316,7 @@ interface RenameOperation extends BaseOperation {
type Operation = MoveOperation | RenameOperation;
const props = defineProps<{
server: ModrinthServer;
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
}>();
const modulesLoaded = inject<Promise<void>>("modulesLoaded");
@@ -397,7 +402,7 @@ const fetchDirectoryContents = async (): Promise<DirectoryResponse> => {
};
} catch (error) {
console.error("Error fetching directory contents:", error);
if (error instanceof ModrinthServersFetchError && error.statusCode === 400) {
if (error instanceof PyroFetchError && error.statusCode === 400) {
return directoryData.value || { items: [], total: 0 };
}
throw error;
@@ -556,7 +561,7 @@ const handleRenameItem = async (newName: string) => {
});
} catch (error) {
console.error("Error renaming item:", error);
if (error instanceof ModrinthServersFetchError) {
if (error instanceof PyroFetchError) {
if (error.statusCode === 400) {
addNotification({
group: "files",
@@ -714,7 +719,7 @@ const showDeleteModal = (item: any) => {
const handleCreateError = (error: any) => {
console.error("Error creating item:", error);
if (error instanceof ModrinthServersFetchError) {
if (error instanceof PyroFetchError) {
if (error.statusCode === 400) {
addNotification({
group: "files",

View File

@@ -1,7 +1,11 @@
<template>
<div class="relative flex select-none flex-col gap-6" data-pyro-server-manager-root>
<div
v-if="isConnected && !isWsAuthIncorrect"
class="relative flex select-none flex-col gap-6"
data-pyro-server-manager-root
>
<div
v-if="inspectingError && isConnected && !isWsAuthIncorrect"
v-if="inspectingError"
data-pyro-servers-inspecting-error
class="flex justify-between rounded-2xl border-2 border-solid border-red bg-bg-red p-4 font-semibold text-contrast"
>
@@ -73,34 +77,26 @@
</ButtonStyled>
</div>
</div>
<div class="flex flex-col-reverse gap-6 md:flex-col">
<UiServersServerStats
:data="isConnected && !isWsAuthIncorrect ? stats : undefined"
:loading="!isConnected || isWsAuthIncorrect"
/>
<UiServersServerStats :data="stats" />
<div
class="relative flex h-[700px] w-full flex-col gap-3 overflow-hidden rounded-2xl border border-divider bg-bg-raised p-4 transition-all duration-300 ease-in-out md:p-8"
:class="{ 'border-0': !isConnected || isWsAuthIncorrect }"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<h2 class="m-0 text-3xl font-extrabold text-contrast">Console</h2>
<UiServersPanelServerStatus
v-if="isConnected && !isWsAuthIncorrect"
:state="serverPowerState"
/>
<UiServersPanelServerStatus :state="serverPowerState" />
</div>
</div>
<UiServersPanelTerminal
:full-screen="fullScreen"
:loading="!isConnected || isWsAuthIncorrect"
>
<!-- <div class="flex flex-row items-center gap-2 text-sm font-medium">
<InfoIcon class="hidden sm:block" />
Click and drag to select lines, then CMD+C to copy
</div> -->
<UiServersPanelTerminal :full-screen="fullScreen">
<div class="relative w-full px-4 pt-4">
<ul
v-if="suggestions.length && isConnected && !isWsAuthIncorrect"
v-if="suggestions.length"
id="command-suggestions"
ref="suggestionsList"
class="mt-1 max-h-60 w-full list-none overflow-auto rounded-md border border-divider bg-bg-raised p-0 shadow-lg"
@@ -124,7 +120,7 @@
</ul>
<div class="relative flex items-center">
<span
v-if="bestSuggestion && isConnected && !isWsAuthIncorrect"
v-if="bestSuggestion"
class="pointer-events-none absolute left-[26px] transform select-none text-gray-400"
>
<span class="ml-[23.5px] whitespace-pre">{{
@@ -146,7 +142,7 @@
<TerminalSquareIcon class="ml-3 h-5 w-5" />
</div>
<input
v-if="isServerRunning && isConnected && !isWsAuthIncorrect"
v-if="isServerRunning"
v-model="commandInput"
type="text"
placeholder="Send a command"
@@ -172,25 +168,29 @@
</UiServersPanelTerminal>
</div>
</div>
<div
v-if="isWsAuthIncorrect"
class="absolute inset-0 flex flex-col items-center justify-center bg-bg"
>
<h2>Could not connect to the server.</h2>
<p>
An error occurred while attempting to connect to your server. Please try refreshing the
page. (WebSocket Authentication Failed)
</p>
</div>
</div>
<UiServersOverviewLoading v-else-if="!isConnected && !isWsAuthIncorrect" />
<div v-else-if="isWsAuthIncorrect" class="flex flex-col">
<h2>Could not connect to the server.</h2>
<p>
An error occurred while attempting to connect to your server. Please try refreshing the page.
(WebSocket Authentication Failed)
</p>
</div>
<div v-else class="flex flex-col">
<h2>Could not connect to the server.</h2>
<p>
An error occurred while attempting to connect to your server. Please try refreshing the page.
(No further information)
</p>
</div>
</template>
<script setup lang="ts">
import { TerminalSquareIcon, XIcon, IssuesIcon } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
import type { ServerState, Stats } from "@modrinth/utils";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import type { ServerState, Stats } from "~/types/servers";
import type { Server } from "~/composables/pyroServers";
type ServerProps = {
socket: WebSocket | null;
@@ -203,7 +203,7 @@ type ServerProps = {
exit_code?: number;
};
isServerRunning: boolean;
server: ModrinthServer;
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
};
const props = defineProps<ServerProps>();

View File

@@ -17,14 +17,14 @@ import {
UserIcon,
WrenchIcon,
} from "@modrinth/assets";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import type { Server } from "~/composables/pyroServers";
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
const route = useRoute();
const serverId = route.params.id as string;
const props = defineProps<{
server: ModrinthServer;
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
backupInProgress?: BackupInProgressReason;
}>();

View File

@@ -114,10 +114,11 @@
<script setup lang="ts">
import { EditIcon, TransferIcon } from "@modrinth/assets";
import ButtonStyled from "@modrinth/ui/src/components/base/ButtonStyled.vue";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import type { Server } from "~/composables/pyroServers";
const props = defineProps<{
server: ModrinthServer;
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
}>();
const data = computed(() => props.server.general);

View File

@@ -117,13 +117,13 @@
<script setup lang="ts">
import { ButtonStyled } from "@modrinth/ui";
import { CopyIcon, ExternalIcon, EyeIcon, EyeOffIcon } from "@modrinth/assets";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import type { Server } from "~/composables/pyroServers";
const route = useNativeRoute();
const serverId = route.params.id as string;
const props = defineProps<{
server: ModrinthServer;
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
}>();
const data = computed(() => props.server.general);

View File

@@ -1,22 +1,269 @@
<template>
<ServerInstallation
<LazyUiServersPlatformVersionSelectModal
ref="versionSelectModal"
:server="props.server"
:backup-in-progress="props.backupInProgress"
:current-loader="data?.loader as Loaders"
:backup-in-progress="backupInProgress"
@reinstall="emit('reinstall', $event)"
/>
<LazyUiServersPlatformMrpackModal
ref="mrpackModal"
:server="props.server"
@reinstall="emit('reinstall', $event)"
/>
<LazyUiServersPlatformChangeModpackVersionModal
ref="modpackVersionModal"
:server="props.server"
:project="data?.project"
:versions="Array.isArray(versions) ? versions : []"
:current-version="currentVersion"
:current-version-id="data?.upstream?.version_id"
:server-status="data?.status"
@reinstall="emit('reinstall')"
/>
<div class="flex h-full w-full flex-col">
<div v-if="data && versions" class="flex w-full flex-col">
<div class="card flex flex-col gap-4">
<div class="flex select-none flex-col items-center justify-between gap-2 lg:flex-row">
<div class="flex flex-row items-center gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">Modpack</h2>
<div
v-if="updateAvailable"
class="rounded-full bg-bg-orange px-2 py-1 text-xs font-medium text-orange"
>
<span>Update available</span>
</div>
</div>
<div v-if="data.upstream" class="flex gap-4">
<ButtonStyled>
<button
class="!w-full sm:!w-auto"
:disabled="isInstalling"
@click="mrpackModal.show()"
>
<UploadIcon class="size-4" /> Import .mrpack
</button>
</ButtonStyled>
<!-- dumb hack to make a button link not a link -->
<ButtonStyled>
<template v-if="isInstalling">
<button :disabled="isInstalling">
<TransferIcon class="size-4" />
Switch modpack
</button>
</template>
<nuxt-link v-else :to="`/modpacks?sid=${props.server.serverId}`">
<TransferIcon class="size-4" />
Switch modpack
</nuxt-link>
</ButtonStyled>
</div>
</div>
<div v-if="data.upstream" class="flex flex-col gap-2">
<div
v-if="versionsError || currentVersionError"
class="rounded-2xl border border-solid border-red p-4 text-contrast"
>
<p class="m-0 font-bold">Something went wrong while loading your modpack.</p>
<p class="m-0 mb-2 mt-1 text-sm">
{{ versionsError || currentVersionError }}
</p>
<ButtonStyled>
<button :disabled="isInstalling" @click="refreshData">Retry</button>
</ButtonStyled>
</div>
<NewProjectCard
v-if="!versionsError && !currentVersionError"
class="!cursor-default !bg-bg !filter-none"
:project="projectCardData"
:categories="data.project?.categories || []"
>
<template #actions>
<ButtonStyled color="brand">
<button :disabled="isInstalling" @click="modpackVersionModal.show()">
<SettingsIcon class="size-4" />
Change version
</button>
</ButtonStyled>
</template>
</NewProjectCard>
</div>
<div v-else class="flex w-full flex-col items-center gap-2 sm:w-fit sm:flex-row">
<ButtonStyled>
<nuxt-link
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
:class="{ disabled: backupInProgress }"
class="!w-full sm:!w-auto"
:to="`/modpacks?sid=${props.server.serverId}`"
>
<CompassIcon class="size-4" /> Find a modpack
</nuxt-link>
</ButtonStyled>
<span class="hidden sm:block">or</span>
<ButtonStyled>
<button
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
:disabled="!!backupInProgress"
class="!w-full sm:!w-auto"
@click="mrpackModal.show()"
>
<UploadIcon class="size-4" /> Upload .mrpack file
</button>
</ButtonStyled>
</div>
</div>
<div class="card flex flex-col gap-4">
<div class="flex flex-col gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">Platform</h2>
<p class="m-0">Your server's platform is the software that runs mods and plugins.</p>
<div v-if="data.upstream" class="mt-2 flex items-center gap-2">
<InfoIcon class="hidden sm:block" />
<span class="text-sm text-secondary">
The current platform was automatically selected based on your modpack.
</span>
</div>
</div>
<div
class="flex w-full flex-col gap-1 rounded-2xl"
:class="{
'pointer-events-none cursor-not-allowed select-none opacity-50':
props.server.general?.status === 'installing',
}"
:tabindex="props.server.general?.status === 'installing' ? -1 : 0"
>
<UiServersLoaderSelector
:data="data"
:is-installing="isInstalling"
@select-loader="selectLoader"
/>
</div>
</div>
</div>
<div v-else />
</div>
</template>
<script setup lang="ts">
import { ButtonStyled, NewProjectCard } from "@modrinth/ui";
import { TransferIcon, UploadIcon, InfoIcon, CompassIcon, SettingsIcon } from "@modrinth/assets";
import type { Server } from "~/composables/pyroServers";
import type { Loaders } from "~/types/servers";
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
import ServerInstallation from "~/components/ui/servers/ServerInstallation.vue";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
const { formatMessage } = useVIntl();
const props = defineProps<{
server: ModrinthServer;
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
backupInProgress?: BackupInProgressReason;
}>();
const emit = defineEmits<{
reinstall: [any?];
}>();
const isInstalling = computed(() => props.server.general?.status === "installing");
const versionSelectModal = ref();
const mrpackModal = ref();
const modpackVersionModal = ref();
const data = computed(() => props.server.general);
const {
data: versions,
error: versionsError,
refresh: refreshVersions,
} = await useAsyncData(
`content-loader-versions-${data.value?.upstream?.project_id}`,
async () => {
if (!data.value?.upstream?.project_id) return [];
try {
const result = await useBaseFetch(`project/${data.value.upstream.project_id}/version`);
return result || [];
} catch (e) {
console.error("couldnt fetch all versions:", e);
throw new Error("Failed to load modpack versions.");
}
},
{ default: () => [] },
);
const {
data: currentVersion,
error: currentVersionError,
refresh: refreshCurrentVersion,
} = await useAsyncData(
`content-loader-version-${data.value?.upstream?.version_id}`,
async () => {
if (!data.value?.upstream?.version_id) return null;
try {
const result = await useBaseFetch(`version/${data.value.upstream.version_id}`);
return result || null;
} catch (e) {
console.error("couldnt fetch version:", e);
throw new Error("Failed to load modpack version.");
}
},
{ default: () => null },
);
const projectCardData = computed(() => ({
icon_url: data.value?.project?.icon_url,
title: data.value?.project?.title,
description: data.value?.project?.description,
downloads: data.value?.project?.downloads,
follows: data.value?.project?.followers,
// @ts-ignore
date_modified: currentVersion.value?.date_published || data.value?.project?.updated,
}));
const selectLoader = (loader: string) => {
versionSelectModal.value?.show(loader as Loaders);
};
const refreshData = async () => {
await Promise.all([refreshVersions(), refreshCurrentVersion()]);
};
const updateAvailable = computed(() => {
// so sorry
// @ts-ignore
if (!data.value?.upstream || !versions.value?.length || !currentVersion.value) {
return false;
}
// @ts-ignore
const latestVersion = versions.value[0];
// @ts-ignore
return latestVersion.id !== currentVersion.value.id;
});
watch(
() => props.server.general?.status,
async (newStatus, oldStatus) => {
if (oldStatus === "installing" && newStatus === "available") {
await Promise.all([
refreshVersions(),
refreshCurrentVersion(),
props.server.refresh(["general"]),
]);
}
},
);
</script>
<style scoped>
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
}
.button-base:active {
scale: none !important;
}
</style>

View File

@@ -60,7 +60,7 @@
<div class="relative h-full w-full overflow-y-auto">
<div
v-if="server.moduleErrors.network"
v-if="server.network?.error"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
@@ -73,9 +73,7 @@
</div>
<p class="text-lg text-secondary">
We couldn't load your server's network settings. Here's what we know:
<span class="break-all font-mono">{{
JSON.stringify(server.moduleErrors.network.error)
}}</span>
<span class="break-all font-mono">{{ JSON.stringify(server.network.error) }}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['network'])">
<button class="mt-6 !w-full">Retry</button>
@@ -275,10 +273,10 @@ import {
} from "@modrinth/assets";
import { ButtonStyled, NewModal, ConfirmModal } from "@modrinth/ui";
import { ref, computed, nextTick } from "vue";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import type { Server } from "~/composables/pyroServers";
const props = defineProps<{
server: ModrinthServer;
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
}>();
const isUpdating = ref(false);

View File

@@ -43,13 +43,13 @@
<script setup lang="ts">
import { useStorage } from "@vueuse/core";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import type { Server } from "~/composables/pyroServers";
const route = useNativeRoute();
const serverId = route.params.id as string;
const props = defineProps<{
server: ModrinthServer;
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
}>();
const preferences = {

View File

@@ -1,9 +1,6 @@
<template>
<div class="relative h-full w-full select-none overflow-y-auto">
<div
v-if="server.moduleErrors.fs"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div v-if="server.fs?.error" class="flex w-full flex-col items-center justify-center gap-4 p-4">
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
@@ -14,9 +11,7 @@
</div>
<p class="text-lg text-secondary">
We couldn't access your server's properties. Here's what we know:
<span class="break-all font-mono">{{
JSON.stringify(server.moduleErrors.fs.error)
}}</span>
<span class="break-all font-mono">{{ JSON.stringify(server.fs.error) }}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['fs'])">
<button class="mt-6 !w-full">Retry</button>
@@ -146,10 +141,10 @@
import { ref, watch, computed, inject } from "vue";
import { EyeIcon, SearchIcon, IssuesIcon } from "@modrinth/assets";
import Fuse from "fuse.js";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import type { Server } from "~/composables/pyroServers";
const props = defineProps<{
server: ModrinthServer;
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
}>();
const tags = useTags();

View File

@@ -1,7 +1,7 @@
<template>
<div class="relative h-full w-full">
<div
v-if="server.moduleErrors.startup"
v-if="server.startup?.error"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
@@ -16,9 +16,7 @@
We couldn't load your server's startup settings. Here's what we know:
</p>
<p>
<span class="break-all font-mono">{{
JSON.stringify(server.moduleErrors.startup.error)
}}</span>
<span class="break-all font-mono">{{ JSON.stringify(server.startup.error) }}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['startup'])">
<button class="mt-6 !w-full">Retry</button>
@@ -114,10 +112,10 @@
<script setup lang="ts">
import { UpdatedIcon, IssuesIcon } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import type { Server } from "~/composables/pyroServers";
const props = defineProps<{
server: ModrinthServer;
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
}>();
const data = computed(() => props.server.general);

View File

@@ -1,7 +1,7 @@
<template>
<div
data-pyro-server-list-root
class="experimental-styles-within relative mx-auto mb-6 flex min-h-screen w-full max-w-[1280px] flex-col px-6"
class="experimental-styles-within relative mx-auto flex min-h-screen w-full max-w-[1280px] flex-col px-6"
>
<div
v-if="hasError || fetchError"
@@ -36,7 +36,7 @@
<li v-if="fetchError" class="text-red">
<p>Error details:</p>
<UiCopyCode
:text="(fetchError as ModrinthServersFetchError).message || 'Unknown error'"
:text="(fetchError as PyroFetchError).message || 'Unknown error'"
:copyable="false"
:selectable="false"
:language="'json'"
@@ -89,10 +89,7 @@
</div>
</div>
<ul
v-if="filteredData.length > 0 || isPollingForNewServers"
class="m-0 flex flex-col gap-4 p-0"
>
<ul v-if="filteredData.length > 0" class="m-0 flex flex-col gap-4 p-0">
<UiServersServerListing
v-for="server in filteredData"
:key="server.server_id"
@@ -105,7 +102,6 @@
:mc_version="server.mc_version"
:upstream="server.upstream"
:net="server.net"
:flows="server.flows"
/>
<LazyUiServersServerListingSkeleton v-if="isPollingForNewServers" />
</ul>
@@ -121,9 +117,9 @@ import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import Fuse from "fuse.js";
import { HammerIcon, PlusIcon, SearchIcon } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
import type { Server, ModrinthServersFetchError } from "@modrinth/utils";
import { reloadNuxtApp } from "#app";
import { useServersFetch } from "~/composables/servers/servers-fetch.ts";
import type { PyroFetchError } from "~/composables/pyroFetch";
import type { Server } from "~/types/servers";
definePageMeta({
middleware: "auth",
@@ -137,7 +133,6 @@ interface ServerResponse {
servers: Server[];
}
const router = useRouter();
const route = useRoute();
const hasError = ref(false);
const isPollingForNewServers = ref(false);
@@ -146,9 +141,7 @@ const {
data: serverResponse,
error: fetchError,
refresh,
} = await useAsyncData<ServerResponse>("ServerList", () =>
useServersFetch<ServerResponse>("servers"),
);
} = await useAsyncData<ServerResponse>("ServerList", () => usePyroFetch<ServerResponse>("servers"));
watch([fetchError, serverResponse], ([error, response]) => {
hasError.value = !!error || !response;
@@ -170,19 +163,11 @@ const fuse = computed(() => {
});
});
function introToTop(array: Server[]): Server[] {
return array.slice().sort((a, b) => {
return Number(b.flows?.intro) - Number(a.flows?.intro);
});
}
const filteredData = computed(() => {
if (!searchInput.value.trim()) {
return introToTop(serverList.value);
return serverList.value;
}
return fuse.value
? introToTop(fuse.value.search(searchInput.value).map((result) => result.item))
: [];
return fuse.value ? fuse.value.search(searchInput.value).map((result) => result.item) : [];
});
const previousServerList = ref<Server[]>([]);
@@ -194,7 +179,6 @@ const checkForNewServers = async () => {
if (JSON.stringify(previousServerList.value) !== JSON.stringify(serverList.value)) {
isPollingForNewServers.value = false;
clearInterval(intervalId);
router.replace({ query: {} });
} else if (refreshCount.value >= 5) {
isPollingForNewServers.value = false;
clearInterval(intervalId);

View File

@@ -444,13 +444,39 @@
:return-url="`${config.public.siteUrl}/servers/manage`"
:server-name="`${auth?.user?.username}'s server`"
/>
<AddPaymentMethodModal
ref="addPaymentMethodModal"
:publishable-key="config.public.stripePublishableKey"
:return-url="`${config.public.siteUrl}/settings/billing`"
:create-setup-intent="createSetupIntent"
:on-error="handleError"
/>
<NewModal ref="addPaymentMethodModal">
<template #title>
<span class="text-lg font-extrabold text-contrast">
{{ formatMessage(messages.paymentMethodTitle) }}
</span>
</template>
<div class="min-h-[16rem] md:w-[600px]">
<div
v-show="loadingPaymentMethodModal !== 2"
class="flex min-h-[16rem] items-center justify-center"
>
<AnimatedLogo class="w-[80px]" />
</div>
<div v-show="loadingPaymentMethodModal === 2" class="min-h-[16rem] p-1">
<div id="address-element"></div>
<div id="payment-element" class="mt-4"></div>
</div>
<div v-show="loadingPaymentMethodModal === 2" class="input-group mt-auto pt-4">
<ButtonStyled color="brand">
<button :disabled="loadingAddMethod" @click="submit">
<PlusIcon />
{{ formatMessage(messages.paymentMethodAdd) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="$refs.addPaymentMethodModal.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<div class="header__row">
<div class="header__title">
<h2 class="text-2xl">{{ formatMessage(messages.paymentMethodTitle) }}</h2>
@@ -564,8 +590,9 @@
<script setup>
import {
ConfirmModal,
AddPaymentMethodModal,
NewModal,
OverflowMenu,
AnimatedLogo,
PurchaseModal,
ButtonStyled,
CopyCode,
@@ -590,9 +617,8 @@ import {
UpdatedIcon,
HistoryIcon,
} from "@modrinth/assets";
import { calculateSavings, formatPrice, getCurrency } from "@modrinth/utils";
import { calculateSavings, formatPrice, createStripeElements, getCurrency } from "@modrinth/utils";
import { ref, computed } from "vue";
import { useServersFetch } from "~/composables/servers/servers-fetch.ts";
import { products } from "~/generated/state.json";
definePageMeta({
@@ -728,6 +754,19 @@ const paymentMethodTypes = defineMessages({
},
});
let stripe = null;
let elements = null;
function loadStripe() {
try {
if (!stripe) {
stripe = Stripe(config.public.stripePublishableKey);
}
} catch (error) {
console.error("Error loading Stripe:", error);
}
}
const [
{ data: paymentMethods, refresh: refreshPaymentMethods },
{ data: charges, refresh: refreshCharges },
@@ -745,7 +784,7 @@ const [
useBaseFetch("billing/subscriptions", { internal: true }),
),
useAsyncData("billing/products", () => useBaseFetch("billing/products", { internal: true })),
useAsyncData("servers", () => useServersFetch("servers")),
useAsyncData("servers", () => usePyroFetch("servers")),
]);
const midasProduct = ref(products.find((x) => x.metadata?.type === "midas"));
@@ -803,16 +842,69 @@ const primaryPaymentMethodId = computed(() => {
});
const addPaymentMethodModal = ref();
const loadingPaymentMethodModal = ref(0);
async function addPaymentMethod() {
try {
loadingPaymentMethodModal.value = 0;
addPaymentMethodModal.value.show();
function addPaymentMethod() {
addPaymentMethodModal.value.show(paymentMethods.value);
const result = await useBaseFetch("billing/payment_method", {
internal: true,
method: "POST",
});
loadStripe();
const {
elements: elementsVal,
addressElement,
paymentElement,
} = createStripeElements(stripe, paymentMethods.value, {
clientSecret: result.client_secret,
});
elements = elementsVal;
paymentElement.on("ready", () => {
loadingPaymentMethodModal.value += 1;
});
addressElement.on("ready", () => {
loadingPaymentMethodModal.value += 1;
});
} catch (err) {
data.$notify({
group: "main",
title: "An error occurred",
text: err.data ? err.data.description : err,
type: "error",
});
}
}
async function createSetupIntent() {
return await useBaseFetch("billing/payment_method", {
internal: true,
method: "POST",
const loadingAddMethod = ref(false);
async function submit() {
startLoading();
loadingAddMethod.value = true;
loadStripe();
const { error } = await stripe.confirmSetup({
elements,
confirmParams: {
return_url: `${config.public.siteUrl}/settings/billing`,
},
});
if (error && error.type !== "validation_error") {
data.$notify({
group: "main",
title: "An error occurred",
text: error.message,
type: "error",
});
} else if (!error) {
await refresh();
addPaymentMethodModal.value.close();
}
loadingAddMethod.value = false;
stopLoading();
}
const removePaymentMethodIndex = ref();
@@ -985,7 +1077,7 @@ async function fetchCapacityStatuses(serverId, product) {
if (product) {
try {
return {
custom: await useServersFetch(`servers/${serverId}/upgrade-stock`, {
custom: await usePyroFetch(`servers/${serverId}/upgrade-stock`, {
method: "POST",
body: {
cpu: product.metadata.cpu,

View File

@@ -303,7 +303,7 @@
<h2 class="text-lg text-contrast">{{ formatMessage(messages.profileOrganizations) }}</h2>
<div class="flex flex-wrap gap-2">
<nuxt-link
v-for="org in sortedOrgs"
v-for="org in organizations"
:key="org.id"
v-tooltip="org.name"
class="organization"
@@ -516,8 +516,6 @@ try {
});
}
const sortedOrgs = computed(() => organizations.value.sort((a, b) => a.name.localeCompare(b.name)));
if (!user.value) {
throw createError({
fatal: true,

Some files were not shown because too many files have changed in this diff Show More