Compare commits

..

23 Commits

Author SHA1 Message Date
Alejandro González
f07bc86711 chore: remove dead profile_run_credentials plugin command 2025-06-14 16:17:44 +02:00
Alejandro González
23f0c1dbf7 chore: document differences between similar Credentials methods 2025-06-14 16:16:24 +02:00
Alejandro González
8abbc021ea chore: fix new prettier lints 2025-06-14 16:16:24 +02:00
Alejandro González
ed8ff79809 refactor(theseus): extend auth subsystem to fetch complete user profiles 2025-06-14 16:16:22 +02:00
Alejandro González
f210de563d chore: typo fix and formatting tidyups 2025-06-14 16:14:29 +02:00
IMB11
e410a07cac fix: usePyroServers -> useModrinthServers (#3788) 2025-06-14 11:27:38 +00:00
Prospector
9f93cd8705 Update changelog 2025-06-13 19:15:44 -07:00
Prospector
dd391be095 Fix lint 2025-06-13 18:59:28 -07:00
Alejandro González
f84f8c1c2b chore(clippy): enable and fix many stricter lints (#3783)
* chore(clippy): enable and fix many stricter lints

These ensure that the codebase uses more idiomatic, performant, and
concise language constructions.

* chore: make non-Clippy compiler warnings also deny by default
2025-06-14 00:10:12 +00:00
Alejandro González
301967d204 refactor: inherit Clippy lint config and Rust edition from workspace (#3782)
* refactor: inherit Clippy lint config and Rust edition from workspace

This also ensures developers running `clippy lint` locally get the same
lints as during CI, especially when the Rust toolchain version is fixed
through a `rust-toolchain.toml` file.

* chore(clippy.toml): bump MSRV to 1.87
2025-06-13 23:16:48 +00:00
Alejandro González
c9b98a6154 Small CI flakiness fix and performance tweak (#3780)
* perf(ci): use Turbo to schedule both `lint` and `test` tasks at once

* fix(ci): wait until service containers are initialized for tests

This is achieved by adding a health check to the containers, and
instructing the CI workflow to wait until the containers are healthy.
Not doing this wait risks spurious CI failures due to DB migrations
being applied before the DB even starts.

* chore(turbo): use locally installed schema in new Turbo override file

On the latest versions of Turbo, this ensures that the used schema is
always in sync with what's available in the installed Turbo version,
which is something that has already caused confusion to me before.
2025-06-13 21:34:40 +00:00
Alejandro González
ab8e474339 Update Rust and Turbo versions (#3781)
* chore: bump Rust version from 1.86 to 1.87

* chore: update Turbo

* chore(.cargo/config.toml): minor comment tweak
2025-06-13 21:03:35 +00:00
Alejandro González
8a26011e76 fix(app): make per-instance launch hooks clearable (#3757)
* fix(app): make per-instance launch hooks clearable

* chore(apps/app-frontend): fix Prettier lints
2025-06-13 20:53:47 +00:00
Alejandro González
d4de1dc9a1 fix(app): make instances with non-UTF8 text file encodings launcheable and importable (#3721)
Previous to these changes, the app always assumed that Minecraft and
other launchers always use UTF-8, which is not necessarily always true.
2025-06-13 20:52:57 +00:00
Alejandro González
4e3bd4e282 enh(ci): optimize Turbo CI check workflow, track Rust and Node toolchain versions in well-known files (#3776)
* enh(ci): optimize Turbo CI check workflow, track Rust and Node toolchain versions in well-known files

* fix(ci): build `sqlx-cli` with `rustls` to fix Postgres TLS failures
2025-06-12 16:47:28 -07:00
IThundxr
d24528f6a6 frontend: Improve file too large error (#3774)
* Improve file too large error

Signed-off-by: IThundxr <me@ithundxr.dev>

* MB -> MiB

Signed-off-by: Prospector <6166773+Prospector@users.noreply.github.com>

---------

Signed-off-by: IThundxr <me@ithundxr.dev>
Signed-off-by: Prospector <6166773+Prospector@users.noreply.github.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2025-06-12 02:05:30 +00:00
IMB11
1b1d41605b refactor: Huge pyro servers composable cleanup (#3745)
* refactor: start refactor of pyro servers module-based class

* refactor: finish modules

* refactor: start on type checking + matching api

* refactor: finish pyro servers composable refactor

* refactor: pyro -> modrinth

* fix: import not refactored

* fix: broken power action enums

* fix: remove pyro mentions

* fix: lint

* refactor: fix option pages

* fix: error renames

* remove empty pyro-servers.ts file

---------

Signed-off-by: IMB11 <hendersoncal117@gmail.com>
Co-authored-by: Prospector <prospectordev@gmail.com>
2025-06-11 22:32:39 +00:00
Magnus Jensen
6955731def fix: undefined instance path by using emitted event instead when opening world folder (#3746)
* fix: undefined instance path by using emitted event instead

* fix: linting
2025-06-11 22:25:20 +00:00
worldwidepixel
4386891716 feat(frontend): Organisations are now sorted alphabetically in dashboard and on user pages (#3755)
* feat: Organisations are now sorted alphabetically in dashboard and on user pages

* Use computed ref

---------

Co-authored-by: Prospector <prospectordev@gmail.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2025-06-11 22:24:04 +00:00
Prospector
6741aba880 Add segmentation to reports list to fix it (#3772) 2025-06-11 22:22:47 +00:00
Alejandro González
ee8ee7af82 feat(labrinth): ignore email case differences in password recovery flow (#3771)
* feat(labrinth): ignore email case differences in password recovery flow

* chore(labrinth): run `sqlx prepare`
2025-06-11 21:59:21 +00:00
IMB11
a2e323c9ee fix: MOD-292 repair button showing during installation (#3734)
* fix: MOD-292 repair button showing during installation

* fix: lint

* Update apps/app-frontend/src/pages/instance/Index.vue

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: IMB11 <hendersoncal117@gmail.com>

* fix: lint issues

---------

Signed-off-by: IMB11 <hendersoncal117@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-06-11 21:54:08 +00:00
IMB11
f8fb23e05f fix: hydration issues caused by duplicate components on servers panel (#3753)
* fix: server stats icons

* fix: fix chart jumping

* refactor: iconComponent -> icon

* fix: panel hydration issues

* fix: apply requested changes
2025-06-11 21:30:24 +00:00
245 changed files with 4172 additions and 4267 deletions

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ name: CI
on: on:
push: push:
branches: ['main'] branches: [main]
pull_request: pull_request:
types: [opened, synchronize] types: [opened, synchronize]
merge_group: merge_group:
@@ -10,71 +10,69 @@ on:
jobs: jobs:
build: build:
name: Build, Test, and Lint name: Lint and Test
runs-on: ubuntu-22.04 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: steps:
- name: Check out code - name: 📥 Check out code
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 2 fetch-depth: 2
- name: Cache turbo build setup - name: 🧰 Install build dependencies
uses: actions/cache@v4
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-
- name: Install build dependencies
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf sudo apt-get install -yq libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
- name: Setup Node.JS environment - name: 🧰 Install pnpm
uses: pnpm/action-setup@v4
- name: 🧰 Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version-file: .nvmrc
cache: pnpm
- name: Install pnpm via corepack - name: 🧰 Setup Rust toolchain
shell: bash uses: actions-rust-lang/setup-rust-toolchain@v1
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: with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} rustflags: ''
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} components: clippy, rustfmt
restore-keys: | cache: false
${{ runner.os }}-pnpm-store-
- name: Install dependencies - 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
run: pnpm install run: pnpm install
- name: Build - name: ⚙️ Start services
run: pnpm build run: docker compose up --wait
env:
SQLX_OFFLINE: true
- name: Lint - name: ⚙️ Setup Labrinth environment and database
run: pnpm lint working-directory: apps/labrinth
env: run: |
SQLX_OFFLINE: true cp .env.local .env
sqlx database setup
- name: Start docker compose - name: 🔍 Lint and test
run: docker compose up -d run: pnpm run ci
- name: Test
run: pnpm test
env:
SQLX_OFFLINE: true
DATABASE_URL: postgresql://labrinth:labrinth@localhost/postgres

6
.idea/vcs.xml generated
View File

@@ -1,5 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <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"> <component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" /> <mapping directory="" vcs="Git" />
</component> </component>

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
20.19.2

15
Cargo.lock generated
View File

@@ -1379,6 +1379,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chardetng"
version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14b8f0b65b7b08ae3c8187e8d77174de20cb6777864c6b832d8ad365999cf1ea"
dependencies = [
"cfg-if",
"encoding_rs",
"memchr",
]
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.41" version = "0.4.41"
@@ -8865,6 +8876,7 @@ dependencies = [
"async_zip", "async_zip",
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
"chardetng",
"chrono", "chrono",
"daedalus", "daedalus",
"dashmap", "dashmap",
@@ -8872,10 +8884,12 @@ dependencies = [
"discord-rich-presence", "discord-rich-presence",
"dunce", "dunce",
"either", "either",
"encoding_rs",
"enumset", "enumset",
"flate2", "flate2",
"fs4", "fs4",
"futures", "futures",
"heck 0.5.0",
"hickory-resolver", "hickory-resolver",
"indicatif", "indicatif",
"notify", "notify",
@@ -8890,6 +8904,7 @@ dependencies = [
"serde", "serde",
"serde_ini", "serde_ini",
"serde_json", "serde_json",
"serde_with",
"sha1_smol", "sha1_smol",
"sha2", "sha2",
"sqlx", "sqlx",

View File

@@ -10,6 +10,9 @@ members = [
"packages/daedalus", "packages/daedalus",
] ]
[workspace.package]
edition = "2024"
[workspace.dependencies] [workspace.dependencies]
actix-cors = "0.7.1" actix-cors = "0.7.1"
actix-files = "0.6.6" actix-files = "0.6.6"
@@ -21,6 +24,7 @@ actix-web-prom = "0.10.0"
actix-ws = "0.3.0" actix-ws = "0.3.0"
argon2 = { version = "0.5.3", features = ["std"] } argon2 = { version = "0.5.3", features = ["std"] }
ariadne = { path = "packages/ariadne" } ariadne = { path = "packages/ariadne" }
async_zip = "0.0.17"
async-compression = { version = "0.4.24", default-features = false } async-compression = { version = "0.4.24", default-features = false }
async-recursion = "1.1.1" async-recursion = "1.1.1"
async-stripe = { version = "0.41.0", default-features = false, features = [ async-stripe = { version = "0.41.0", default-features = false, features = [
@@ -31,11 +35,11 @@ async-tungstenite = { version = "0.29.1", default-features = false, features = [
"futures-03-sink", "futures-03-sink",
] } ] }
async-walkdir = "2.1.0" async-walkdir = "2.1.0"
async_zip = "0.0.17"
base64 = "0.22.1" base64 = "0.22.1"
bitflags = "2.9.1" bitflags = "2.9.1"
bytes = "1.10.1" bytes = "1.10.1"
censor = "0.3.0" censor = "0.3.0"
chardetng = "0.1.17"
chrono = "0.4.41" chrono = "0.4.41"
clap = "4.5.40" clap = "4.5.40"
clickhouse = "0.13.3" clickhouse = "0.13.3"
@@ -50,11 +54,13 @@ dotenv-build = "0.1.1"
dotenvy = "0.15.7" dotenvy = "0.15.7"
dunce = "1.0.5" dunce = "1.0.5"
either = "1.15.0" either = "1.15.0"
encoding_rs = "0.8.35"
enumset = "1.1.6" enumset = "1.1.6"
flate2 = "1.1.2" flate2 = "1.1.2"
fs4 = { version = "0.13.1", default-features = false } fs4 = { version = "0.13.1", default-features = false }
futures = { version = "0.3.31", default-features = false } futures = { version = "0.3.31", default-features = false }
futures-util = "0.3.31" futures-util = "0.3.31"
heck = "0.5.0"
hex = "0.4.3" hex = "0.4.3"
hickory-resolver = "0.25.2" hickory-resolver = "0.25.2"
hmac = "0.12.1" hmac = "0.12.1"
@@ -89,19 +95,19 @@ quartz_nbt = "0.2.9"
quick-xml = "0.37.5" quick-xml = "0.37.5"
rand = "=0.8.5" # Locked on 0.8 until argon2 and p256 update to 0.9 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 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" # Locked on 0.31 until deadpool-redis updates to 0.32
regex = "1.11.1" regex = "1.11.1"
reqwest = { version = "0.12.19", default-features = false } reqwest = { version = "0.12.19", 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 = [ rust_decimal = { version = "1.37.1", features = [
"serde-with-float", "serde-with-float",
"serde-with-str", "serde-with-str",
] } ] }
rust_iso3166 = "0.1.14" rust_iso3166 = "0.1.14"
rust-s3 = { version = "0.35.1", default-features = false, features = [
"fail-on-err",
"tags",
"tokio-rustls-tls",
] }
rusty-money = "0.4.1" rusty-money = "0.4.1"
sentry = { version = "0.38.1", default-features = false, features = [ sentry = { version = "0.38.1", default-features = false, features = [
"backtrace", "backtrace",
@@ -113,12 +119,12 @@ sentry = { version = "0.38.1", default-features = false, features = [
] } ] }
sentry-actix = "0.38.1" sentry-actix = "0.38.1"
serde = "1.0.219" serde = "1.0.219"
serde-xml-rs = "0.8.1" # Also an XML (de)serializer, consider dropping yaserde in favor of this
serde_bytes = "0.11.17" serde_bytes = "0.11.17"
serde_cbor = "0.11.2" serde_cbor = "0.11.2"
serde_ini = "0.2.0" serde_ini = "0.2.0"
serde_json = "1.0.140" serde_json = "1.0.140"
serde_with = "3.12.0" 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 = "0.10.6"
sha1_smol = { version = "1.0.1", features = ["std"] } sha1_smol = { version = "1.0.1", features = ["std"] }
sha2 = "0.10.9" sha2 = "0.10.9"
@@ -168,6 +174,44 @@ zip = { version = "4.0.0", default-features = false, features = [
] } ] }
zxcvbn = "3.1.0" 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] [patch.crates-io]
wry = { git = "https://github.com/modrinth/wry", rev = "cafdaa9" } wry = { git = "https://github.com/modrinth/wry", rev = "cafdaa9" }

View File

@@ -9,18 +9,19 @@
"tsc:check": "vue-tsc --noEmit", "tsc:check": "vue-tsc --noEmit",
"lint": "eslint . && prettier --check .", "lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write .", "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" "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"
}, },
"dependencies": { "dependencies": {
"@geometrically/minecraft-motd-parser": "^1.1.4",
"@modrinth/assets": "workspace:*", "@modrinth/assets": "workspace:*",
"@modrinth/ui": "workspace:*", "@modrinth/ui": "workspace:*",
"@modrinth/utils": "workspace:*", "@modrinth/utils": "workspace:*",
"@sentry/vue": "^8.27.0", "@sentry/vue": "^8.27.0",
"@geometrically/minecraft-motd-parser": "^1.1.4",
"@tauri-apps/api": "^2.5.0", "@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-dialog": "^2.2.1", "@tauri-apps/plugin-dialog": "^2.2.1",
"@tauri-apps/plugin-os": "^2.2.1",
"@tauri-apps/plugin-opener": "^2.2.6", "@tauri-apps/plugin-opener": "^2.2.6",
"@tauri-apps/plugin-os": "^2.2.1",
"@tauri-apps/plugin-updater": "^2.7.1", "@tauri-apps/plugin-updater": "^2.7.1",
"@tauri-apps/plugin-window-state": "^2.2.2", "@tauri-apps/plugin-window-state": "^2.2.2",
"@vintl/vintl": "^4.4.1", "@vintl/vintl": "^4.4.1",
@@ -39,11 +40,12 @@
"@eslint/compat": "^1.1.1", "@eslint/compat": "^1.1.1",
"@formatjs/cli": "^6.2.12", "@formatjs/cli": "^6.2.12",
"@nuxt/eslint-config": "^0.5.6", "@nuxt/eslint-config": "^0.5.6",
"@taijased/vue-render-tracker": "^1.0.7",
"@vitejs/plugin-vue": "^5.0.4", "@vitejs/plugin-vue": "^5.0.4",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"eslint": "^9.9.1", "eslint": "^9.9.1",
"eslint-config-custom": "workspace:*", "eslint-config-custom": "workspace:*",
"eslint-plugin-turbo": "^2.1.1", "eslint-plugin-turbo": "^2.5.4",
"postcss": "^8.4.39", "postcss": "^8.4.39",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"sass": "^1.74.1", "sass": "^1.74.1",
@@ -51,8 +53,7 @@
"tsconfig": "workspace:*", "tsconfig": "workspace:*",
"typescript": "^5.5.4", "typescript": "^5.5.4",
"vite": "^5.4.6", "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", "packageManager": "pnpm@9.4.0",
"web-types": "../../web-types.json" "web-types": "../../web-types.json"

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import dayjs from 'dayjs' import dayjs from 'dayjs'
import type { ServerStatus, ServerWorld, World } from '@/helpers/worlds.ts' import type { ServerStatus, ServerWorld, SingleplayerWorld, World } from '@/helpers/worlds.ts'
import { import { set_world_display_status, getWorldIdentifier } from '@/helpers/worlds.ts'
set_world_display_status,
getWorldIdentifier,
showWorldInFolder,
} from '@/helpers/worlds.ts'
import { formatNumber, getPingLevel } from '@modrinth/utils' import { formatNumber, getPingLevel } from '@modrinth/utils'
import { import {
useRelativeTime, useRelativeTime,
@@ -49,6 +45,7 @@ const router = useRouter()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'play' | 'play-instance' | 'update' | 'stop' | 'refresh' | 'edit' | 'delete'): void (e: 'play' | 'play-instance' | 'update' | 'stop' | 'refresh' | 'edit' | 'delete'): void
(e: 'open-folder', world: SingleplayerWorld): void
}>() }>()
const props = withDefaults( const props = withDefaults(
@@ -380,8 +377,7 @@ const messages = defineMessages({
{ {
id: 'open-folder', id: 'open-folder',
shown: world.type === 'singleplayer', shown: world.type === 'singleplayer',
action: () => action: () => (world.type === 'singleplayer' ? emit('open-folder', world) : {}),
world.type === 'singleplayer' ? showWorldInFolder(instancePath, world.path) : {},
}, },
{ {
divider: true, divider: true,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,7 +28,6 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
profile_update_managed_modrinth_version, profile_update_managed_modrinth_version,
profile_repair_managed_modrinth, profile_repair_managed_modrinth,
profile_run, profile_run,
profile_run_credentials,
profile_kill, profile_kill,
profile_edit, profile_edit,
profile_edit_icon, profile_edit_icon,
@@ -256,22 +255,6 @@ pub async fn profile_run(path: &str) -> Result<ProcessMetadata> {
Ok(process) 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] #[tauri::command]
pub async fn profile_kill(path: &str) -> Result<()> { pub async fn profile_kill(path: &str) -> Result<()> {
profile::kill(path).await?; profile::kill(path).await?;

View File

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

View File

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

View File

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

View File

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

14
apps/app/turbo.jsonc Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,8 @@
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"start": "astro dev", "start": "astro dev",
"build": "astro check && astro build", "lint": "astro check",
"build": "astro build",
"preview": "astro preview", "preview": "astro preview",
"astro": "astro" "astro": "astro"
}, },

View File

@@ -10,7 +10,8 @@
"postinstall": "nuxi prepare", "postinstall": "nuxi prepare",
"lint": "eslint . && prettier --check .", "lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write .", "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" "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"
}, },
"devDependencies": { "devDependencies": {
"@formatjs/cli": "^6.2.12", "@formatjs/cli": "^6.2.12",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -200,9 +200,9 @@
import { BackupWarning, ButtonStyled, NewModal, Toggle } from "@modrinth/ui"; import { BackupWarning, ButtonStyled, NewModal, Toggle } from "@modrinth/ui";
import { DropdownIcon, RightArrowIcon, ServerIcon, XIcon } from "@modrinth/assets"; import { DropdownIcon, RightArrowIcon, ServerIcon, XIcon } from "@modrinth/assets";
import { $fetch } from "ofetch"; import { $fetch } from "ofetch";
import type { Server } from "~/composables/pyroServers"; import { type Loaders, ModrinthServersFetchError } from "@modrinth/utils";
import type { Loaders } from "~/types/servers";
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue"; import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
const { formatMessage } = useVIntl(); const { formatMessage } = useVIntl();
@@ -220,7 +220,7 @@ type VersionMap = Record<string, LoaderVersion[]>;
type VersionCache = Record<string, any>; type VersionCache = Record<string, any>;
const props = defineProps<{ const props = defineProps<{
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>; server: ModrinthServer;
currentLoader: Loaders | undefined; currentLoader: Loaders | undefined;
backupInProgress?: BackupInProgressReason; backupInProgress?: BackupInProgressReason;
initialSetup?: boolean; initialSetup?: boolean;
@@ -458,7 +458,6 @@ const handleReinstall = async () => {
try { try {
await props.server.general?.reinstall( await props.server.general?.reinstall(
props.server.serverId,
true, true,
selectedLoader.value, selectedLoader.value,
selectedMCVersion.value, selectedMCVersion.value,
@@ -474,7 +473,7 @@ const handleReinstall = async () => {
hide(); hide();
} catch (error) { } catch (error) {
if (error instanceof PyroFetchError && error.statusCode === 429) { if (error instanceof ModrinthServersFetchError && (error as any)?.statusCode === 429) {
addNotification({ addNotification({
group: "server", group: "server",
title: "Cannot reinstall server", title: "Cannot reinstall server",

View File

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

View File

@@ -160,14 +160,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { ButtonStyled, NewProjectCard } from "@modrinth/ui"; import { ButtonStyled, NewProjectCard } from "@modrinth/ui";
import { TransferIcon, UploadIcon, InfoIcon, CompassIcon, SettingsIcon } from "@modrinth/assets"; import { TransferIcon, UploadIcon, InfoIcon, CompassIcon, SettingsIcon } from "@modrinth/assets";
import type { Server } from "~/composables/pyroServers"; import type { Loaders } from "@modrinth/utils";
import type { Loaders } from "~/types/servers";
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue"; import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
const { formatMessage } = useVIntl(); const { formatMessage } = useVIntl();
const props = defineProps<{ const props = defineProps<{
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>; server: ModrinthServer;
ignoreCurrentInstallation?: boolean; ignoreCurrentInstallation?: boolean;
backupInProgress?: BackupInProgressReason; backupInProgress?: BackupInProgressReason;
}>(); }>();

View File

@@ -81,12 +81,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { ChevronRightIcon, LockIcon, SparklesIcon } from "@modrinth/assets"; import { ChevronRightIcon, LockIcon, SparklesIcon } from "@modrinth/assets";
import type { Project, Server } from "~/types/servers"; import type { Project, Server } from "@modrinth/utils";
import { useModrinthServers } from "~/composables/servers/modrinth-servers.ts";
const props = defineProps<Partial<Server>>(); const props = defineProps<Partial<Server>>();
if (props.server_id) { if (props.server_id) {
await usePyroServer(props.server_id, ["general"]); await useModrinthServers(props.server_id, ["general"]);
} }
const showGameLabel = computed(() => !!props.game); const showGameLabel = computed(() => !!props.game);
@@ -109,7 +110,7 @@ if (props.upstream) {
const image = useState<string | undefined>(`server-icon-${props.server_id}`, () => undefined); const image = useState<string | undefined>(`server-icon-${props.server_id}`, () => undefined);
if (import.meta.server && projectData.value?.icon_url) { if (import.meta.server && projectData.value?.icon_url) {
await usePyroServer(props.server_id!, ["general"]); await useModrinthServers(props.server_id!, ["general"]);
} }
const iconUrl = computed(() => projectData.value?.icon_url || undefined); const iconUrl = computed(() => projectData.value?.icon_url || undefined);

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ButtonStyled, ServersSpecs } from "@modrinth/ui"; import { ButtonStyled, ServersSpecs } from "@modrinth/ui";
import type { MessageDescriptor } from "@vintl/vintl"; import type { MessageDescriptor } from "@vintl/vintl";
import { formatPrice } from "../../../../../../../packages/utils"; import { formatPrice } from "@modrinth/utils";
const { formatMessage, locale } = useVIntl(); const { formatMessage, locale } = useVIntl();

View File

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

View File

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

View File

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

View File

@@ -1,112 +0,0 @@
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(/^\//, "")}`
: version === 0
? `${base}/modrinth/v${version}/${path.replace(/^\//, "")}`
: `${base}/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

@@ -0,0 +1,265 @@
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

@@ -0,0 +1,79 @@
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

@@ -0,0 +1,15 @@
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

@@ -0,0 +1,36 @@
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

@@ -0,0 +1,224 @@
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

@@ -0,0 +1,173 @@
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

@@ -0,0 +1,8 @@
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

@@ -0,0 +1,47 @@
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

@@ -0,0 +1,26 @@
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

@@ -0,0 +1,13 @@
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

@@ -0,0 +1,188 @@
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

@@ -77,9 +77,6 @@ const errorMessages = computed(
const route = useRoute(); const route = useRoute();
// TODO: REMOVE BEFORE MERGE
console.log(props.error);
watch(route, () => { watch(route, () => {
console.log(route); console.log(route);
}); });

View File

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

View File

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

View File

@@ -115,6 +115,7 @@ import {
import Avatar from "~/components/ui/Avatar.vue"; import Avatar from "~/components/ui/Avatar.vue";
import Badge from "~/components/ui/Badge.vue"; import Badge from "~/components/ui/Badge.vue";
import { formatProjectType } from "~/plugins/shorthands.js"; import { formatProjectType } from "~/plugins/shorthands.js";
import { asEncodedJsonArray, fetchSegmented } from "~/utils/fetch-helpers.ts";
useHead({ useHead({
title: "Review projects - Modrinth", title: "Review projects - Modrinth",
@@ -170,28 +171,6 @@ const projectTypes = computed(() => {
return [...set]; 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) { if (projects.value) {
const teamIds = projects.value.map((x) => x.team_id); const teamIds = projects.value.map((x) => x.team_id);
const orgIds = projects.value.filter((x) => x.organization).map((x) => x.organization); const orgIds = projects.value.filter((x) => x.organization).map((x) => x.organization);

View File

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

View File

@@ -623,6 +623,7 @@ import {
ServerIcon, ServerIcon,
} from "@modrinth/assets"; } from "@modrinth/assets";
import { products } from "~/generated/state.json"; import { products } from "~/generated/state.json";
import { useServersFetch } from "~/composables/servers/servers-fetch.ts";
import LoaderIcon from "~/components/ui/servers/icons/LoaderIcon.vue"; import LoaderIcon from "~/components/ui/servers/icons/LoaderIcon.vue";
import ServerPlanSelector from "~/components/ui/servers/marketing/ServerPlanSelector.vue"; import ServerPlanSelector from "~/components/ui/servers/marketing/ServerPlanSelector.vue";
import OptionGroup from "~/components/ui/OptionGroup.vue"; import OptionGroup from "~/components/ui/OptionGroup.vue";
@@ -674,7 +675,7 @@ const outOfStockUrl = "https://discord.modrinth.com";
const { data: hasServers } = await useAsyncData("ServerListCountCheck", async () => { const { data: hasServers } = await useAsyncData("ServerListCountCheck", async () => {
try { try {
if (!auth.value.user) return false; if (!auth.value.user) return false;
const response = await usePyroFetch("servers"); const response = await useServersFetch("servers");
return response.servers && response.servers.length > 0; return response.servers && response.servers.length > 0;
} catch { } catch {
return false; return false;
@@ -682,7 +683,7 @@ const { data: hasServers } = await useAsyncData("ServerListCountCheck", async ()
}); });
function fetchStock(region, request) { function fetchStock(region, request) {
return usePyroFetch(`stock?region=${region.shortcode}`, { return useServersFetch(`stock?region=${region.shortcode}`, {
method: "POST", method: "POST",
body: { body: {
...request, ...request,
@@ -702,7 +703,7 @@ async function fetchCapacityStatuses(customProduct = null) {
), ),
]; ];
const capacityChecks = productsToCheck.map((product) => const capacityChecks = productsToCheck.map((product) =>
usePyroFetch("stock", { useServersFetch("stock", {
method: "POST", method: "POST",
body: { body: {
cpu: product.metadata.cpu, cpu: product.metadata.cpu,
@@ -892,7 +893,7 @@ const regions = ref([]);
const regionPings = ref([]); const regionPings = ref([]);
function pingRegions() { function pingRegions() {
usePyroFetch("regions", { useServersFetch("regions", {
method: "GET", method: "GET",
version: 1, version: 1,
bypassAuth: true, bypassAuth: true,

View File

@@ -63,8 +63,8 @@
</div> </div>
<div <div
v-else-if=" v-else-if="
server.general?.error?.error.statusCode === 403 || server.moduleErrors?.general?.error.statusCode === 403 ||
server.general?.error?.error.statusCode === 404 server.moduleErrors?.general?.error.statusCode === 404
" "
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast" class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
> >
@@ -81,7 +81,7 @@
is an error, please contact Modrinth Support. is an error, please contact Modrinth Support.
</p> </p>
</div> </div>
<UiCopyCode :text="JSON.stringify(server.general?.error)" /> <UiCopyCode :text="JSON.stringify(server.moduleErrors?.general?.error)" />
<ButtonStyled size="large" color="brand" @click="() => router.push('/servers/manage')"> <ButtonStyled size="large" color="brand" @click="() => router.push('/servers/manage')">
<button class="mt-6 !w-full">Go back to all servers</button> <button class="mt-6 !w-full">Go back to all servers</button>
@@ -89,7 +89,7 @@
</div> </div>
</div> </div>
<div <div
v-else-if="server.general?.error?.error.statusCode === 503" v-else-if="server.moduleErrors?.general?.error.statusCode === 503"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast" 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"> <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> </div>
<div <div
v-else-if="server.general?.error" v-else-if="server.moduleErrors?.general?.error"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast" 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"> <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. temporary network issue. You'll be reconnected automatically.
</p> </p>
</div> </div>
<UiCopyCode :text="JSON.stringify(server.general?.error)" /> <UiCopyCode :text="JSON.stringify(server.moduleErrors?.general?.error)" />
<ButtonStyled <ButtonStyled
:disabled="formattedTime !== '00'" :disabled="formattedTime !== '00'"
size="large" size="large"
@@ -252,7 +252,7 @@
</h2> </h2>
<ServerInstallation <ServerInstallation
:server="server" :server="server as ModrinthServer"
:backup-in-progress="backupInProgress" :backup-in-progress="backupInProgress"
ignore-current-installation ignore-current-installation
@reinstall="onReinstall" @reinstall="onReinstall"
@@ -419,7 +419,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from "vue"; import { ref, computed, onMounted, onUnmounted, watch, type Reactive } from "vue";
import { import {
SettingsIcon, SettingsIcon,
CopyIcon, CopyIcon,
@@ -434,12 +434,19 @@ import {
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { ButtonStyled, ServerNotice } from "@modrinth/ui"; import { ButtonStyled, ServerNotice } from "@modrinth/ui";
import { Intercom, shutdown } from "@intercom/messenger-js-sdk"; import { Intercom, shutdown } from "@intercom/messenger-js-sdk";
import { reloadNuxtApp, navigateTo } from "#app";
import type { MessageDescriptor } from "@vintl/vintl"; import type { MessageDescriptor } from "@vintl/vintl";
import type { ServerState, Stats, WSEvent, WSInstallationResultEvent } from "~/types/servers"; import type {
import { usePyroConsole } from "~/store/console.ts"; ServerState,
import { type Backup } from "~/composables/pyroServers.ts"; Stats,
import { usePyroFetch } from "~/composables/pyroFetch.ts"; 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 ServerInstallation from "~/components/ui/servers/ServerInstallation.vue";
const app = useNuxtApp() as unknown as { $notify: any }; const app = useNuxtApp() as unknown as { $notify: any };
@@ -467,19 +474,19 @@ const route = useNativeRoute();
const router = useRouter(); const router = useRouter();
const serverId = route.params.id as string; const serverId = route.params.id as string;
const server = await usePyroServer(serverId, ["general", "ws"]); const server: Reactive<ModrinthServer> = await useModrinthServers(serverId, ["general", "ws"]);
const loadModulesPromise = Promise.resolve().then(() => { const loadModulesPromise = Promise.resolve().then(() => {
if (server.general?.status === "suspended") { if (server.general?.status === "suspended") {
return; return;
} }
return server.loadModules(["content", "backups", "network", "startup", "fs"]); return server.refresh(["content", "backups", "network", "startup", "fs"]);
}); });
provide("modulesLoaded", loadModulesPromise); provide("modulesLoaded", loadModulesPromise);
watch( watch(
() => [server.general?.error, server.ws?.error], () => [server.moduleErrors?.general, server.moduleErrors?.ws],
([generalError, wsError]) => { ([generalError, wsError]) => {
if (server.general?.status === "suspended") return; if (server.general?.status === "suspended") return;
@@ -497,7 +504,7 @@ const errorLogFile = ref("");
const serverData = computed(() => server.general); const serverData = computed(() => server.general);
const isConnected = ref(false); const isConnected = ref(false);
const isWSAuthIncorrect = ref(false); const isWSAuthIncorrect = ref(false);
const pyroConsole = usePyroConsole(); const modrinthServersConsole = useModrinthServersConsole();
const cpuData = ref<number[]>([]); const cpuData = ref<number[]>([]);
const ramData = ref<number[]>([]); const ramData = ref<number[]>([]);
const isActioning = ref(false); const isActioning = ref(false);
@@ -671,7 +678,7 @@ const connectWebSocket = () => {
return; return;
} }
pyroConsole.clear(); modrinthServersConsole.clear();
socket.value?.send(JSON.stringify({ event: "auth", jwt: wsAuth.value?.token })); socket.value?.send(JSON.stringify({ event: "auth", jwt: wsAuth.value?.token }));
isConnected.value = true; isConnected.value = true;
isReconnecting.value = false; isReconnecting.value = false;
@@ -679,7 +686,7 @@ const connectWebSocket = () => {
if (firstConnect.value) { if (firstConnect.value) {
for (let i = 0; i < initialConsoleMessage.length; i++) { for (let i = 0; i < initialConsoleMessage.length; i++) {
pyroConsole.addLine(initialConsoleMessage[i]); modrinthServersConsole.addLine(initialConsoleMessage[i]);
} }
} }
@@ -702,7 +709,9 @@ const connectWebSocket = () => {
socket.value.onclose = () => { socket.value.onclose = () => {
if (isMounted.value) { if (isMounted.value) {
pyroConsole.addLine("\nSomething went wrong with the connection, we're reconnecting..."); modrinthServersConsole.addLine(
"\nSomething went wrong with the connection, we're reconnecting...",
);
isConnected.value = false; isConnected.value = false;
scheduleReconnect(); scheduleReconnect();
} }
@@ -760,7 +769,7 @@ const handleWebSocketMessage = (data: WSEvent) => {
case "log": case "log":
// eslint-disable-next-line no-case-declarations // eslint-disable-next-line no-case-declarations
const log = data.message.split("\n").filter((l) => l.trim()); const log = data.message.split("\n").filter((l) => l.trim());
pyroConsole.addLines(log); modrinthServersConsole.addLines(log);
break; break;
case "stats": case "stats":
updateStats(data); updateStats(data);
@@ -1021,11 +1030,11 @@ const toAdverb = (word: string) => {
return word + "ing"; return word + "ing";
}; };
const sendPowerAction = async (action: "restart" | "start" | "stop" | "kill") => { const sendPowerAction = async (action: PowerAction) => {
const actionName = action.charAt(0).toUpperCase() + action.slice(1); const actionName = action.charAt(0).toUpperCase() + action.slice(1);
try { try {
isActioning.value = true; isActioning.value = true;
await server.general?.power(actionName); await server.general?.power(action);
} catch (error) { } catch (error) {
console.error(`Error ${toAdverb(actionName)} server:`, error); console.error(`Error ${toAdverb(actionName)} server:`, error);
notifyError( notifyError(
@@ -1158,7 +1167,7 @@ const cleanup = () => {
}; };
async function dismissNotice(noticeId: number) { async function dismissNotice(noticeId: number) {
await usePyroFetch(`servers/${serverId}/notices/${noticeId}/dismiss`, { await useServersFetch(`servers/${serverId}/notices/${noticeId}/dismiss`, {
method: "POST", method: "POST",
}).catch((err) => { }).catch((err) => {
app.$notify({ app.$notify({
@@ -1177,8 +1186,8 @@ onMounted(() => {
isLoading.value = false; isLoading.value = false;
return; return;
} }
if (server.error) { if (server.moduleErrors.general?.error) {
if (!server.error.message.includes("Forbidden")) { if (!server.moduleErrors.general?.error?.message?.includes("Forbidden")) {
startPolling(); startPolling();
} }
} else { } else {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,12 +7,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Server } from "~/composables/pyroServers";
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue"; import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
import ServerInstallation from "~/components/ui/servers/ServerInstallation.vue"; import ServerInstallation from "~/components/ui/servers/ServerInstallation.vue";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
const props = defineProps<{ const props = defineProps<{
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>; server: ModrinthServer;
backupInProgress?: BackupInProgressReason; backupInProgress?: BackupInProgressReason;
}>(); }>();

View File

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

View File

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

View File

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

View File

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

View File

@@ -36,7 +36,7 @@
<li v-if="fetchError" class="text-red"> <li v-if="fetchError" class="text-red">
<p>Error details:</p> <p>Error details:</p>
<UiCopyCode <UiCopyCode
:text="(fetchError as PyroFetchError).message || 'Unknown error'" :text="(fetchError as ModrinthServersFetchError).message || 'Unknown error'"
:copyable="false" :copyable="false"
:selectable="false" :selectable="false"
:language="'json'" :language="'json'"
@@ -121,9 +121,9 @@ import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import Fuse from "fuse.js"; import Fuse from "fuse.js";
import { HammerIcon, PlusIcon, SearchIcon } from "@modrinth/assets"; import { HammerIcon, PlusIcon, SearchIcon } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui"; import { ButtonStyled } from "@modrinth/ui";
import type { Server, ModrinthServersFetchError } from "@modrinth/utils";
import { reloadNuxtApp } from "#app"; import { reloadNuxtApp } from "#app";
import type { PyroFetchError } from "~/composables/pyroFetch"; import { useServersFetch } from "~/composables/servers/servers-fetch.ts";
import type { Server } from "~/types/servers";
definePageMeta({ definePageMeta({
middleware: "auth", middleware: "auth",
@@ -146,7 +146,9 @@ const {
data: serverResponse, data: serverResponse,
error: fetchError, error: fetchError,
refresh, refresh,
} = await useAsyncData<ServerResponse>("ServerList", () => usePyroFetch<ServerResponse>("servers")); } = await useAsyncData<ServerResponse>("ServerList", () =>
useServersFetch<ServerResponse>("servers"),
);
watch([fetchError, serverResponse], ([error, response]) => { watch([fetchError, serverResponse], ([error, response]) => {
hasError.value = !!error || !response; hasError.value = !!error || !response;

View File

@@ -353,21 +353,6 @@
Upgrade Upgrade
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled
v-if="
getPyroCharge(subscription) &&
getPyroCharge(subscription).status !== 'cancelled' &&
getPyroCharge(subscription).status !== 'failed'
"
color="purple"
color-fill="text"
>
<button @click="showPyroIntervalChange(subscription)">
<TransferIcon />
<!-- TODO: Make this attractive af for monthly subscribers -->
Change billing interval
</button>
</ButtonStyled>
<ButtonStyled <ButtonStyled
v-else-if=" v-else-if="
getPyroCharge(subscription) && getPyroCharge(subscription) &&
@@ -427,31 +412,6 @@
:payment-methods="paymentMethods" :payment-methods="paymentMethods"
:return-url="`${config.public.siteUrl}/settings/billing`" :return-url="`${config.public.siteUrl}/settings/billing`"
/> />
<PurchaseModal
v-if="currentProduct"
ref="pyroIntervalModal"
:product="[currentProduct]"
:country="country"
custom-server
interval-change-only
:existing-subscription="currentSubscription"
:existing-plan="currentProduct"
:publishable-key="config.public.stripePublishableKey"
:send-billing-request="
async (body) => {
await useBaseFetch(`billing/subscription/${currentSubscription.id}`, {
internal: true,
method: 'PATCH',
body,
});
}
"
:renewal-date="currentSubRenewalDate"
:on-error="handleError"
:customer="customer"
:payment-methods="paymentMethods"
:return-url="`${config.public.siteUrl}/settings/billing`"
/>
<PurchaseModal <PurchaseModal
ref="pyroPurchaseModal" ref="pyroPurchaseModal"
:product="upgradeProducts" :product="upgradeProducts"
@@ -632,6 +592,7 @@ import {
} from "@modrinth/assets"; } from "@modrinth/assets";
import { calculateSavings, formatPrice, getCurrency } from "@modrinth/utils"; import { calculateSavings, formatPrice, getCurrency } from "@modrinth/utils";
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import { useServersFetch } from "~/composables/servers/servers-fetch.ts";
import { products } from "~/generated/state.json"; import { products } from "~/generated/state.json";
definePageMeta({ definePageMeta({
@@ -784,7 +745,7 @@ const [
useBaseFetch("billing/subscriptions", { internal: true }), useBaseFetch("billing/subscriptions", { internal: true }),
), ),
useAsyncData("billing/products", () => useBaseFetch("billing/products", { internal: true })), useAsyncData("billing/products", () => useBaseFetch("billing/products", { internal: true })),
useAsyncData("servers", () => usePyroFetch("servers")), useAsyncData("servers", () => useServersFetch("servers")),
]); ]);
const midasProduct = ref(products.find((x) => x.metadata?.type === "midas")); const midasProduct = ref(products.find((x) => x.metadata?.type === "midas"));
@@ -862,18 +823,6 @@ const oppositeInterval = computed(() =>
midasCharge.value?.subscription_interval === "yearly" ? "monthly" : "yearly", midasCharge.value?.subscription_interval === "yearly" ? "monthly" : "yearly",
); );
async function showPyroIntervalChange(subscription) {
currentSubscription.value = subscription;
currentSubRenewalDate.value = getPyroCharge(subscription).due;
currentProduct.value = getPyroProduct(subscription);
upgradeProducts.value = [currentProduct.value];
upgradeProducts.value.metadata = { type: "pyro" };
await nextTick();
pyroIntervalModal.value.show();
}
async function switchMidasInterval(interval) { async function switchMidasInterval(interval) {
changingInterval.value = true; changingInterval.value = true;
startLoading(); startLoading();
@@ -993,7 +942,6 @@ const getProductPrice = (product, interval) => {
const modalCancel = ref(null); const modalCancel = ref(null);
const pyroPurchaseModal = ref(); const pyroPurchaseModal = ref();
const pyroIntervalModal = ref();
const currentSubscription = ref(null); const currentSubscription = ref(null);
const currentProduct = ref(null); const currentProduct = ref(null);
const upgradeProducts = ref([]); const upgradeProducts = ref([]);
@@ -1037,7 +985,7 @@ async function fetchCapacityStatuses(serverId, product) {
if (product) { if (product) {
try { try {
return { return {
custom: await usePyroFetch(`servers/${serverId}/upgrade-stock`, { custom: await useServersFetch(`servers/${serverId}/upgrade-stock`, {
method: "POST", method: "POST",
body: { body: {
cpu: product.metadata.cpu, cpu: product.metadata.cpu,

View File

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

View File

@@ -18,7 +18,7 @@ const initialBatchSize = 256;
* @property {function(string): void} addConsoleOutput - Method to add a new console output line * @property {function(string): void} addConsoleOutput - Method to add a new console output line
* @property {function(): void} clear - Method to clear all console output * @property {function(): void} clear - Method to clear all console output
*/ */
export const usePyroConsole = createGlobalState(() => { export const useModrinthServersConsole = createGlobalState(() => {
/** /**
* Reactive array storing console output lines * Reactive array storing console output lines
* @type {Ref<string[]>} * @type {Ref<string[]>}

View File

@@ -1,284 +0,0 @@
// export interface Mod {
// id: string;
// filename: string;
// modrinth_ids: {
// project_id: string;
// version_id: string;
// };
// }
interface License {
id: string;
name: string;
url: string;
}
interface DonationUrl {
id: string;
platform: string;
url: string;
}
interface GalleryItem {
url: string;
featured: boolean;
title: string;
description: string;
created: string;
ordering: number;
}
export interface Project {
slug: string;
title: string;
description: string;
categories: string[];
client_side: "required" | "optional";
server_side: "required" | "optional";
body: string;
status: "approved" | "pending" | "rejected";
requested_status: "approved" | "pending" | "rejected";
additional_categories: string[];
issues_url: string;
source_url: string;
wiki_url: string;
discord_url: string;
donation_urls: DonationUrl[];
project_type: "mod" | "resourcepack" | "map" | "plugin";
downloads: number;
icon_url: string;
color: number;
thread_id: string;
monetization_status: "monetized" | "non-monetized";
id: string;
team: string;
body_url: string | null;
moderator_message: string | null;
published: string;
updated: string;
approved: string;
queued: string;
followers: number;
license: License;
versions: string[];
game_versions: string[];
loaders: string[];
gallery: GalleryItem[];
}
export interface ServerBackup {
id: string;
name: string;
created_at: string;
}
export interface Allocation {
name: string;
port: number;
}
export interface Server {
server_id: string;
name: string;
status: string;
net: {
ip: string;
port: number;
domain: string;
allocations: Allocation[];
};
game: string;
loader: string | null;
loader_version: string | null;
mc_version: string | null;
backup_quota: number;
used_backup_quota: number;
backups: ServerBackup[];
mods: Mod[];
project: Project | null;
suspension_reason: string | null;
image: string | null;
upstream?: {
kind: "modpack";
project_id: string;
version_id: string;
};
motd: string;
flows: {
intro?: boolean;
};
}
export interface Stats {
current: {
cpu_percent: number;
ram_usage_bytes: number;
ram_total_bytes: number;
storage_usage_bytes: number;
storage_total_bytes: number;
};
past: {
cpu_percent: number;
ram_usage_bytes: number;
ram_total_bytes: number;
storage_usage_bytes: number;
storage_total_bytes: number;
};
graph: {
cpu: number[];
ram: number[];
};
}
export interface WSAuth {
url: string;
token: string;
}
export type ServerState = "running" | "stopped" | "crashed";
// export type WebsocketEventType =
// | "log"
// | "auth"
// | "stats"
// | "power-state"
// | "auth-expiring"
// | "auth-incorrect"
// | "installation-result"
// | (string & {});
// export interface WSEvent {
// event: WebsocketEventType;
// message: string;
// state: ServerState;
// }
export type Loaders =
| "Fabric"
| "Quilt"
| "Forge"
| "NeoForge"
| "Paper"
| "Spigot"
| "Bukkit"
| "Vanilla"
| "Purpur";
export interface WSLogEvent {
event: "log";
message: string;
}
type CurrentStats = Stats["current"];
export interface WSStatsEvent extends CurrentStats {
event: "stats";
}
export interface WSAuthExpiringEvent {
event: "auth-expiring";
}
export interface WSPowerStateEvent {
event: "power-state";
state: ServerState;
// if state "crashed"
oom_killed?: boolean;
exit_code?: number;
}
export interface WSAuthIncorrectEvent {
event: "auth-incorrect";
}
export interface WSInstallationResultOkEvent {
event: "installation-result";
result: "ok";
}
export interface WSInstallationResultErrEvent {
event: "installation-result";
result: "err";
reason: string;
}
export type WSInstallationResultEvent = WSInstallationResultOkEvent | WSInstallationResultErrEvent;
export interface WSAuthOkEvent {
event: "auth-ok";
}
export interface WSUptimeEvent {
event: "uptime";
uptime: number; // seconds
}
export interface WSNewModEvent {
event: "new-mod";
}
export type WSBackupTask = "file" | "create" | "restore";
export type WSBackupState = "ongoing" | "done" | "failed" | "cancelled" | "unchanged";
export interface WSBackupProgressEvent {
event: "backup-progress";
task: WSBackupTask;
id: string;
progress: number; // percentage
state: WSBackupState;
ready: boolean;
}
export type FSQueuedOpUnarchive = {
op: "unarchive";
src: string;
};
export type FSQueuedOp = FSQueuedOpUnarchive;
export type FSOpUnarchive = {
op: "unarchive";
progress: number; // Note: 1 does not mean it's done
id: string; // UUID
mime: string;
src: string;
state:
| "queued"
| "ongoing"
| "cancelled"
| "done"
| "failed-corrupted"
| "failed-invalid-path"
| "failed-cf-no-serverpack"
| "failed-cf-not-available"
| "failed-not-reachable";
current_file: string | null;
failed_path?: string;
bytes_processed: number;
files_processed: number;
started: string;
};
export type FilesystemOp = FSOpUnarchive;
export interface WSFilesystemOpsEvent {
event: "filesystem-ops";
all: FilesystemOp[];
}
export type WSEvent =
| WSLogEvent
| WSStatsEvent
| WSPowerStateEvent
| WSAuthExpiringEvent
| WSAuthIncorrectEvent
| WSInstallationResultEvent
| WSAuthOkEvent
| WSUptimeEvent
| WSNewModEvent
| WSBackupProgressEvent
| WSFilesystemOpsEvent;
export interface Servers {
servers: Server[];
}

View File

@@ -0,0 +1,26 @@
function segmentData<T>(data: T[], segmentSize: number): T[][] {
return data.reduce((acc: T[][], curr, index) => {
const segment = Math.floor(index / segmentSize);
if (!acc[segment]) {
acc[segment] = [];
}
acc[segment].push(curr);
return acc;
}, []);
}
export function fetchSegmented<T>(
data: T[],
createUrl: (ids: T[]) => string,
options = {},
segmentSize = 800,
): Promise<any> {
return Promise.all(
segmentData(data, segmentSize).map((ids) => useBaseFetch(createUrl(ids), options)),
).then((results) => results.flat());
}
export function asEncodedJsonArray<T>(data: T[]): string {
return encodeURIComponent(JSON.stringify(data));
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id FROM users\n WHERE LOWER(email) = LOWER($1)\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "889a4f79b7031436b3ed31d1005dc9b378ca9c97a128366cae97649503d5dfdf"
}

View File

@@ -2,7 +2,7 @@
name = "labrinth" name = "labrinth"
version = "2.7.0" version = "2.7.0"
authors = ["geometrically <jai@modrinth.com>"] authors = ["geometrically <jai@modrinth.com>"]
edition = "2024" edition.workspace = true
license = "AGPL-3.0" license = "AGPL-3.0"
# This seems redundant, but it's necessary for Docker to work # This seems redundant, but it's necessary for Docker to work
@@ -131,3 +131,6 @@ actix-http.workspace = true
dotenv-build.workspace = true dotenv-build.workspace = true
chrono.workspace = true chrono.workspace = true
iana-time-zone.workspace = true iana-time-zone.workspace = true
[lints]
workspace = true

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