Compare commits

...
Author SHA1 Message Date
tdgao e13b26dc6f pnpm prepr 2026-03-26 12:32:34 -06:00
tdgao 039639b1d9 fix table overflow, add game version tags "+ {num}" overflow menu 2026-03-26 12:26:26 -06:00
tdgao 278d74875f fix no modpack loader showing as resource pack loader 2026-03-26 11:38:10 -06:00
Prospector 3c3cde1908 changelog 2026-03-26 00:22:40 -07:00
ProspectorandGitHub 274325d97c fix: settings page error (#5668) 2026-03-26 00:21:19 -07:00
aecsocketandGitHub da48a12551 Only mark servers as offline if they fail pings 3+ times (#5664)
* wip: online status fix

* use INCR

* properly clear cache
2026-03-26 06:34:20 +00:00
François-Xavier TalbotandGitHub bf24ed8d12 Add feature flag to force Archon requests to be traced (#5666) 2026-03-26 06:34:04 +00:00
aecsocketandGitHub 0731654a1c maybe fix daedalus (#5665) 2026-03-26 06:33:58 +00:00
22 changed files with 291 additions and 67 deletions
@@ -5,7 +5,7 @@
v-if="!isServerProject"
:project="data"
:tags="{ loaders: allLoaders, gameVersions: allGameVersions }"
:v3-metadata="projectV3"
:project-v3="projectV3"
class="project-sidebar-section"
/>
<ProjectSidebarServerInfo
+4 -4
View File
@@ -114,12 +114,12 @@ pub async fn fetch_neo(
}
let forge_versions = fetch_xml::<Metadata>(
"https://maven.neoforged.net/net/neoforged/forge/maven-metadata.xml",
"https://maven.neoforged.net/releases/net/neoforged/forge/maven-metadata.xml",
&semaphore,
)
.await?;
let neo_versions = fetch_xml::<Metadata>(
"https://maven.neoforged.net/net/neoforged/neoforge/maven-metadata.xml",
"https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml",
&semaphore,
)
.await?;
@@ -133,7 +133,7 @@ pub async fn fetch_neo(
Ok(ForgeVersion {
format_version: 2,
installer_url: format!("https://maven.neoforged.net/net/neoforged/forge/{loader_version}/forge-{loader_version}-installer.jar"),
installer_url: format!("https://maven.neoforged.net/releases/net/neoforged/forge/{loader_version}/forge-{loader_version}-installer.jar"),
raw: loader_version,
loader_version: version_split,
game_version: "1.20.1".to_string(), // All NeoForge Forge versions are for 1.20.1
@@ -159,7 +159,7 @@ pub async fn fetch_neo(
Ok(ForgeVersion {
format_version: 2,
installer_url: format!("https://maven.neoforged.net/net/neoforged/neoforge/{loader_version}/neoforge-{loader_version}-installer.jar"),
installer_url: format!("https://maven.neoforged.net/releases/net/neoforged/neoforge/{loader_version}/neoforge-{loader_version}-installer.jar"),
loader_version: loader_version.clone(),
raw: loader_version,
game_version,
@@ -38,6 +38,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
showProjectPageQuickServerButton: false,
newProjectGeneralSettings: false,
newProjectEnvironmentSettings: true,
archonSentryCapture: false,
hideRussiaCensorshipBanner: false,
disablePrettyProjectUrlRedirects: false,
hidePreviewBanner: false,
+4
View File
@@ -13,6 +13,8 @@ import {
} from '@modrinth/api-client'
import type { Ref } from 'vue'
import { useFeatureFlags } from '~/composables/featureFlags.ts'
async function getRateLimitKeyFromSecretsStore(): Promise<string | undefined> {
try {
const mod = 'cloudflare:workers'
@@ -28,6 +30,7 @@ export function createModrinthClient(
auth: Ref<{ token: string | undefined }>,
config: { apiBaseUrl: string; archonBaseUrl: string; rateLimitKey?: string },
): NuxtModrinthClient {
const flags = useFeatureFlags()
const optionalFeatures = [
import.meta.dev ? (new VerboseLoggingFeature() as AbstractFeature) : undefined,
].filter(Boolean) as AbstractFeature[]
@@ -35,6 +38,7 @@ export function createModrinthClient(
const clientConfig: NuxtClientConfig = {
labrinthBaseUrl: config.apiBaseUrl,
archonBaseUrl: config.archonBaseUrl,
archonSentryCapture: () => flags.value.archonSentryCapture,
rateLimitKey: config.rateLimitKey || getRateLimitKeyFromSecretsStore,
features: [
// for modrinth hosting
+1 -1
View File
@@ -877,7 +877,7 @@
v-if="projectV3Loaded && !isServerProject"
:project="project"
:tags="tags"
:v3-metadata="projectV3"
:project-v3="projectV3"
class="card flex-card experimental-styles-within"
/>
<AdPlaceholder v-if="!auth.user && tags.approvedStatuses.includes(project.status)" />
@@ -341,7 +341,8 @@
<div v-if="project.project_type !== 'resourcepack'">
<h4>Loaders</h4>
<Categories :categories="version.loaders" :type="project.project_type" />
<span v-if="noModpackLoader">No mod loader</span>
<Categories v-else :categories="version.loaders ?? []" :type="project.project_type" />
</div>
<div>
<h4>Game versions</h4>
@@ -698,6 +699,25 @@ const title = computed(
() => `${isCreating.value ? 'Create Version' : version.value.name} - ${project.value.title}`,
)
const modpackLoaders = computed<string[]>(() => {
if (project.value.project_type !== 'modpack') {
return []
}
if (Array.isArray(version.value.mrpack_loaders) && version.value.mrpack_loaders.length > 0) {
return version.value.mrpack_loaders
}
return (version.value.loaders ?? []).filter((loader: string) => loader !== 'mrpack')
})
const noModpackLoader = computed(
() =>
project.value.project_type === 'modpack' &&
modpackLoaders.value.length === 1 &&
modpackLoaders.value[0] === 'minecraft',
)
const description = computed(
() =>
`Download ${project.value.title} ${
+4 -4
View File
@@ -196,10 +196,6 @@ import { isDarkTheme, type Theme } from '~/plugins/theme/index.ts'
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
useHead({
title: () => `${formatMessage(messages.headTitle)} - Modrinth`,
})
const messages = defineMessages({
headTitle: {
id: 'settings.head-title',
@@ -219,6 +215,10 @@ const developerModeBanner = defineMessages({
},
})
useHead({
title: () => `${formatMessage(messages.headTitle)} - Modrinth`,
})
const layoutMode = defineMessages({
rows: {
id: 'settings.display.project-list-layouts.mode.rows',
@@ -29,7 +29,8 @@
"low",
"medium",
"high",
"severe"
"severe",
"malware"
]
}
}
@@ -45,7 +46,7 @@
false,
true,
false,
false
true
]
},
"hash": "10e2a3b31ba94b93ed2d6c9753a5aabf13190a0b336089e6521022069813cf17"
@@ -44,7 +44,8 @@
"low",
"medium",
"high",
"severe"
"severe",
"malware"
]
}
}
@@ -79,7 +80,7 @@
true,
false,
false,
false,
true,
null
]
},
@@ -25,7 +25,8 @@
"low",
"medium",
"high",
"severe"
"severe",
"malware"
]
}
}
@@ -22,7 +22,8 @@
"low",
"medium",
"high",
"severe"
"severe",
"malware"
]
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ When the user refers to "perform[ing] pre-PR checks", do the following:
- Run `cargo clippy -p labrinth --all-targets` — there must be ZERO warnings, otherwise CI will fail
- DO NOT run tests unless explicitly requested (they take a long time)
- Prepare the sqlx cache: cd into `apps/labrinth` and run `cargo sqlx prepare`
- Prepare the sqlx cache: cd into `apps/labrinth` and run `cargo sqlx prepare -- --tests`
- NEVER run `cargo sqlx prepare --workspace`
## Testing
+14
View File
@@ -788,6 +788,20 @@ impl RedisConnection {
.await?;
Ok(values)
}
#[tracing::instrument(skip(self))]
pub async fn incr(
&mut self,
namespace: &str,
id: &str,
) -> Result<Option<u64>, DatabaseError> {
let key = format!("{}_{namespace}:{id}", self.meta_namespace);
let value = cmd("INCR")
.arg(key)
.query_async(&mut self.connection)
.await?;
Ok(value)
}
}
#[derive(Serialize, Deserialize)]
+1
View File
@@ -293,4 +293,5 @@ vars! {
SERVER_PING_RETRIES: usize = 3usize;
SERVER_PING_MIN_INTERVAL_SEC: u64 = 30u64 * 60;
SERVER_PING_TIMEOUT_MS: u64 = 3u64 * 1000;
SERVER_PING_MAX_FAIL_COUNT: u64 = 3u64;
}
+57 -18
View File
@@ -24,6 +24,7 @@ pub struct ServerPingQueue {
}
pub const REDIS_NAMESPACE: &str = "minecraft_java_server_ping";
pub const REDIS_FAILURE_NAMESPACE: &str = "minecraft_java_server_ping_failures";
pub const CLICKHOUSE_TABLE: &str = "minecraft_java_server_pings";
impl ServerPingQueue {
@@ -118,27 +119,65 @@ impl ServerPingQueue {
.await
.wrap_err("failed to write ping record")?;
redis
.set_serialized_to_json(
REDIS_NAMESPACE,
project_id,
ping,
let mut updated_project = false;
if data.is_some() {
// ping succeeded; immediately update its online status in redis
redis
.set_serialized_to_json(
REDIS_NAMESPACE,
project_id,
ping,
None,
)
.await
.wrap_err("failed to set redis key")?;
updated_project = true;
redis
.delete(REDIS_FAILURE_NAMESPACE, project_id)
.await
.wrap_err("failed to delete failure count")?;
} else {
// ping failed; if it's failed too many times, mark it as offline in redis
// otherwise, just add to the fail counter
let failure_count = redis
.incr(REDIS_FAILURE_NAMESPACE, &project_id.to_string())
.await
.wrap_err("failed to increment failure count")?;
if let Some(count) = failure_count
&& count >= ENV.SERVER_PING_MAX_FAIL_COUNT
{
redis
.set_serialized_to_json(
REDIS_NAMESPACE,
project_id,
ping,
None,
)
.await
.wrap_err(
"failed to set failed ping record in redis",
)?;
updated_project = true;
}
}
if updated_project {
DBProject::clear_cache(
(*project_id).into(),
None,
None,
&self.redis,
)
.await
.wrap_err("failed to set redis key")?;
DBProject::clear_cache(
(*project_id).into(),
None,
None,
&self.redis,
)
.await
.inspect_err(|err| {
warn!("failed to clear project cache: {err:#}")
})
.ok();
.inspect_err(|err| {
warn!("failed to clear project cache: {err:#}")
})
.ok();
}
}
ch.end()
@@ -126,6 +126,7 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient {
...options.headers,
},
}
this.attachArchonSentryCaptureHeader(mergedOptions)
const headers = mergedOptions.headers
if (headers && 'Content-Type' in headers && headers['Content-Type'] === '') {
@@ -309,6 +310,21 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient {
return headers
}
protected attachArchonSentryCaptureHeader(options: RequestOptions): void {
if (options.api !== 'archon' || !options.headers || !this.shouldCaptureArchonRequests()) {
return
}
options.headers['modrinth-sentry-capture'] = '1'
}
private shouldCaptureArchonRequests(): boolean {
const archonSentryCapture = this.config.archonSentryCapture
return typeof archonSentryCapture === 'function'
? archonSentryCapture()
: archonSentryCapture === true
}
/**
* Execute the actual HTTP request
*
@@ -593,6 +593,7 @@ export namespace Labrinth {
categories: string[]
additional_categories: string[]
loaders: string[]
mrpack_loaders: string[]
versions: string[]
icon_url?: string
link_urls: Record<string, Link>
@@ -46,6 +46,7 @@ export abstract class XHRUploadClient extends AbstractModrinthClient {
...options.headers,
},
}
this.attachArchonSentryCaptureHeader(mergedOptions)
const context = this.buildUploadContext(url, path, mergedOptions)
+8
View File
@@ -55,6 +55,14 @@ export interface ClientConfig {
*/
headers?: Record<string, string>
/**
* Whether to attach `modrinth-sentry-capture: 1` to Archon requests.
* Can be a callback so apps can drive this from runtime feature flags.
*
* @default false
*/
archonSentryCapture?: boolean | (() => boolean)
/**
* Features to enable for this client
* Features are applied in the order they appear in this array
+16
View File
@@ -10,6 +10,22 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
{
date: `2026-03-26T07:22:22+00:00`,
product: 'web',
body: `## Fixed
- Fixed error on settings page.
- Fixed the "500 Server Error" error on the "Authorized apps" settings page.`,
},
{
date: `2026-03-26T07:22:22+00:00`,
product: 'hosting',
body: `## Fixed
- Fixed wrong mod being disabled on hosted servers - Disabling a non-Modrinth mod could incorrectly disable a different mod instead
- Fixed content list not refreshing after installing mods - Navigating back to the content tab after installing something from Browse would show stale data for up to 10 seconds
- Fixed copying mod filenames inserting a newline - Copying a filename or version number from the content tab no longer includes a line break in the middle
- Fixed NeoForge installs sending an empty loader version - Changing to NeoForge on a hosted server could send a blank loader version, resulting in a broken installation`,
},
{
date: `2026-03-24T21:14:30-08:00`,
product: 'hosting',
@@ -3,7 +3,7 @@
<div class="flex flex-wrap justify-between gap-2">
<VersionFilterControl
ref="versionFilters"
:versions="versions"
:versions="normalizedVersions"
:game-versions="gameVersions"
:base-id="`${baseId}-filter`"
@update:query="updateQuery"
@@ -110,13 +110,18 @@
</div>
</div>
<div
class="pointer-events-none relative z-[1] flex flex-col justify-center"
class="pointer-events-none relative z-[1] flex flex-col justify-center overflow-hidden min-w-32"
:class="{
'group-hover:underline': !!versionLink,
}"
title="`${version.version_number} - ${version.name}`"
>
<div class="font-bold text-contrast">{{ version.version_number }}</div>
<div class="text-xs font-medium">{{ version.name }}</div>
<div class="font-bold text-contrast text-ellipsis overflow-hidden">
{{ version.version_number }}
</div>
<div class="text-xs font-medium text-ellipsis overflow-hidden">
{{ version.name }}
</div>
</div>
</div>
<div class="flex flex-col justify-center gap-2 sm:contents">
@@ -127,7 +132,7 @@
v-for="gameVersion in formatVersionsForDisplay(
version.game_versions,
gameVersions,
)"
).slice(0, maxGameVersionTags)"
:key="`version-tag-${gameVersion}`"
v-tooltip="`Toggle filter for ${gameVersion}`"
class="z-[1]"
@@ -137,21 +142,61 @@
>
{{ gameVersion }}
</TagItem>
<Menu
v-if="
formatVersionsForDisplay(version.game_versions, gameVersions).length >
maxGameVersionTags
"
:delay="{ hide: 50, show: 0 }"
no-auto-focus
class="z-[1] cursor-default"
>
<TagItem tabindex="0">
+{{
formatVersionsForDisplay(version.game_versions, gameVersions).length -
maxGameVersionTags
}}
</TagItem>
<template #popper>
<div class="flex gap-1 flex-wrap max-w-[20rem]">
<TagItem
v-for="gameVersion in formatVersionsForDisplay(
version.game_versions,
gameVersions,
).slice(maxGameVersionTags)"
:key="`overflow-version-tag-${gameVersion}`"
:action="
() =>
versionFilters?.toggleFilters('gameVersion', version.game_versions)
"
>
{{ gameVersion }}
</TagItem>
</div>
</template>
</Menu>
</div>
</div>
<div class="flex items-center">
<div class="flex flex-wrap gap-1">
<TagItem
v-for="platform in version.loaders"
:key="`platform-tag-${platform}`"
v-tooltip="`Toggle filter for ${platform}`"
class="z-[1]"
:style="`--_color: var(--color-platform-${platform})`"
:action="() => versionFilters?.toggleFilter('platform', platform)"
>
<component :is="getLoaderIcon(platform)" v-if="getLoaderIcon(platform)" />
<FormattedTag :tag="platform" enforce-type="loader" />
</TagItem>
<template v-if="version.noModLoader">
<TagItem class="z-[1] border !border-solid border-surface-5">
No mod loader
</TagItem>
</template>
<template v-else>
<TagItem
v-for="platform in version.loaders"
:key="`platform-tag-${platform}`"
v-tooltip="`Toggle filter for ${platform}`"
class="z-[1]"
:style="`--_color: var(--color-platform-${platform})`"
:action="() => versionFilters?.toggleFilter('platform', platform)"
>
<component :is="getLoaderIcon(platform)" v-if="getLoaderIcon(platform)" />
<FormattedTag :tag="platform" enforce-type="loader" />
</TagItem>
</template>
</div>
</div>
<div v-if="hasMultipleEnvironments" class="flex items-center">
@@ -162,7 +207,7 @@
class="z-[1] text-center"
>
<component :is="tag.icon" />
{{ formatMessage(tag.label) }}
{{ formatMessage(tag.label).replace('and', '&') }}
</TagItem>
</div>
</div>
@@ -233,6 +278,7 @@ import {
type GameVersionTag,
type Version,
} from '@modrinth/utils'
import { Menu } from 'floating-vue'
import { computed, type Ref, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -251,6 +297,11 @@ const formatDateTime = useFormatDateTime({
type VersionWithDisplayUrlEnding = Version & {
displayUrlEnding: string
environment?: Labrinth.Projects.v3.Environment
mrpack_loaders?: string[]
}
type DisplayVersion = VersionWithDisplayUrlEnding & {
noModLoader: boolean
}
const props = withDefaults(
@@ -278,6 +329,39 @@ const props = withDefaults(
},
)
function getModpackLoaders(version: VersionWithDisplayUrlEnding): string[] {
if (props.project.project_type !== 'modpack') {
return version.loaders
}
if (version.mrpack_loaders?.length) {
return version.mrpack_loaders
}
return version.loaders.filter((loader) => loader !== 'mrpack')
}
function hasNoModLoader(loaders: string[]): boolean {
return (
props.project.project_type === 'modpack' && loaders.length === 1 && loaders[0] === 'minecraft'
)
}
const normalizedVersions = computed<DisplayVersion[]>(() =>
props.versions.map((version) => {
const loaders = getModpackLoaders(version)
const noModLoader = hasNoModLoader(loaders)
return {
...version,
loaders: noModLoader ? [] : loaders,
noModLoader,
}
}),
)
const maxGameVersionTags = 6
const currentPage: Ref<number> = ref(1)
const pageSize: Ref<number> = ref(20)
const versionFilters: Ref<InstanceType<typeof VersionFilterControl> | null> = ref(null)
@@ -296,7 +380,7 @@ const hasMultipleEnvironments = computed(() => {
})
const filteredVersions = computed(() => {
return props.versions.filter(
return normalizedVersions.value.filter(
(version) =>
hasAnySelected(version.game_versions, selectedGameVersions.value) &&
hasAnySelected(version.loaders, selectedPlatforms.value) &&
@@ -15,15 +15,22 @@
<section v-if="project.project_type !== 'resourcepack'" class="flex flex-col gap-2">
<h3 class="text-primary text-base m-0">{{ formatMessage(messages.platforms) }}</h3>
<div class="flex flex-wrap gap-1">
<TagItem
v-for="platform in project.loaders"
:key="`platform-tag-${platform}`"
:action="() => router.push(`/${project.project_type}s?g=categories:${platform}`)"
:style="`--_color: var(--color-platform-${platform})`"
>
<component :is="getLoaderIcon(platform)" v-if="getLoaderIcon(platform)" />
<FormattedTag :tag="platform" enforce-type="loader" />
</TagItem>
<template v-if="noModpackLoader">
<TagItem class="border !border-solid border-surface-5 hover:no-underline">
No mod loader
</TagItem>
</template>
<template v-else>
<TagItem
v-for="platform in project.loaders"
:key="`platform-tag-${platform}`"
:action="() => router.push(`/${project.project_type}s?g=categories:${platform}`)"
:style="`--_color: var(--color-platform-${platform})`"
>
<component :is="getLoaderIcon(platform)" v-if="getLoaderIcon(platform)" />
<FormattedTag :tag="platform" enforce-type="loader" />
</TagItem>
</template>
</div>
</section>
<section v-if="showEnvironments" class="flex flex-col gap-2">
@@ -85,6 +92,7 @@
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
ClientIcon,
getLoaderIcon,
@@ -93,7 +101,7 @@ import {
UserIcon,
} from '@modrinth/assets'
import { FormattedTag, TagItem } from '@modrinth/ui'
import type { EnvironmentV3, GameVersionTag, PlatformTag, ProjectV3Partial } from '@modrinth/utils'
import type { EnvironmentV3, GameVersionTag, PlatformTag } from '@modrinth/utils'
import { getVersionsToDisplay } from '@modrinth/utils'
import { type Component, computed } from 'vue'
import { useRouter } from 'vue-router'
@@ -128,17 +136,24 @@ const props = defineProps<{
gameVersions: GameVersionTag[]
loaders: PlatformTag[]
}
v3Metadata?: ProjectV3Partial
projectV3?: Labrinth.Projects.v3.Project
}>()
const noModpackLoader = computed(
() =>
props.projectV3?.project_types.includes('modpack') &&
props.projectV3?.mrpack_loaders.length === 1 &&
props.projectV3?.mrpack_loaders[0] === 'minecraft',
)
const showEnvironments = computed(
() =>
TYPES_WITH_ENVS.some((x) => props.v3Metadata?.project_types.includes(x)) &&
TYPES_WITH_ENVS.some((x) => props.projectV3?.project_types.includes(x)) &&
primaryEnvironment.value,
)
const primaryEnvironment = computed<EnvironmentV3 | undefined>(() =>
props.v3Metadata?.environment?.find((x) => x !== 'unknown'),
props.projectV3?.environment?.find((x) => x !== 'unknown'),
)
type EnvironmentTag = {