Compare commits

...
Author SHA1 Message Date
Michael H. 8b753a52ad fix: build labrinth on prod again 2026-07-31 14:45:16 +02:00
Michael H. a53c9c3771 fix: only deploy if internal 2026-07-31 13:21:35 +02:00
Michael H. 919b235621 chore: address action issues 2026-07-31 13:17:26 +02:00
Michael H. 18b0b2848d feat: labrinth argo 2026-07-31 13:10:58 +02:00
Prospector 65a3ac4b34 changelog 2026-07-30 23:10:28 -07:00
ProspectorandGitHub 295db3fb7a only refresh tokens and clear auth cookies on auth fails, not any ran… (#6947)
only refresh tokens and clear auth cookies on auth fails, not any random error
2026-07-30 23:10:08 -07:00
Michael H. 7abe6f8c73 feat: sccache on windows 2026-07-30 20:27:08 +02:00
Michael H. c5ce5bc9b3 fix: create clickhouse db in tests only 2026-07-30 20:08:49 +02:00
Michael H. 572b8027ff fix: clickhouse migrations should not run normally 2026-07-30 19:23:10 +02:00
aecsocketandGitHub 0a53211389 fix: analytics event endpoint (#6938)
* fix: analytics event endpoint

* split admin-only routes into internal

* fix pr comment
2026-07-30 04:40:52 +00:00
Prospector 22b2b7746e changelog 2026-07-29 14:34:41 -07:00
39b3e20d0a fix: feedback file limit (#6931)
* fix: feedback file limit

* prepr

* prepr again

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-29 20:47:05 +00:00
Calum H.andGitHub 3892b62451 fix: reinstall jobs not cleaning up if instance deleted (#6927) 2026-07-29 20:31:26 +00:00
Calum H.andGitHub 5ffffec2e5 fix: gracefully handle shared instance icons 404ing (#6928)
* fix: icon handling

* fix: cleanup impl

* fix: fmt
2026-07-29 20:30:52 +00:00
Truman GaoandGitHub a0724cc5fb feat: dont show analytics incomplete current day revenue (#6933) 2026-07-29 20:26:25 +00:00
ProspectorandGitHub a0320f425e install version matching filters if possible (#6932) 2026-07-29 13:26:39 -07:00
Michael H. 06262e7bb1 feat: clickhouse partitioning and header TTL 2026-07-29 22:10:20 +02:00
ProspectorandGitHub 9ccaab5fad add toggle to hide already-installed modpacks (#6917)
* add toggle to hide already-installed modpacks, on by default

* make false by default
2026-07-29 17:54:21 +00:00
ProspectorandGitHub 39745086fa fix warning when passing extraneous props to floating action bar (#6919) 2026-07-29 13:02:40 +00:00
Truman GaoandGitHub a8dcfa8a5d fix: download modal suggested dep can use wrong mc version (#6925) 2026-07-29 12:44:34 +00:00
Truman GaoandGitHub 0f6a74b87f fix: pats page code invalidating in same session (#6924) 2026-07-29 12:44:30 +00:00
ProspectorandGitHub d5b763c8e3 Fix tauri overlay in modal closing on drag (#6920)
* Fix tauri overlay in modal closing on drag

* prepr
2026-07-29 12:44:28 +00:00
ProspectorandGitHub 7a98f45c3d add installation settings tooltip (#6916) 2026-07-29 12:44:16 +00:00
ProspectorandGitHub 0ea47ad7ef add new instance button (#6918) 2026-07-29 12:43:17 +00:00
ProspectorandGitHub cc804625ec Replace final usage of legacy modal (#6921)
* Replace final usage of legacy modal

* prepr
2026-07-29 12:42:59 +00:00
82 changed files with 1071 additions and 1098 deletions
+21 -11
View File
@@ -34,13 +34,13 @@ runs:
using: 'composite'
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
fetch-depth: 0
- name: Extract PR Number and Commit ID
id: extract-pr-info
uses: actions/github-script@v7
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
script: |
const githubRef = process.env.GITHUB_REF;
@@ -90,20 +90,26 @@ runs:
- name: Print PR Branch
if: env.CAN_SKIP_CHECKS != 'false'
shell: bash
env:
PR_BRANCH: ${{ steps.get-pr-branch.outputs.prBranch }}
run: |
echo "PR Branch: ${{ steps.get-pr-branch.outputs.prBranch }}"
echo "PR Branch: $PR_BRANCH"
- name: Check if PR branch contains the Merge Queue target commit ID
if: env.CAN_SKIP_CHECKS != 'false'
shell: bash
env:
PR_BRANCH: ${{ steps.get-pr-branch.outputs.prBranch }}
COMMIT_ID: ${{ steps.extract-pr-info.outputs.commitId }}
TARGET_BRANCH: ${{ steps.extract-pr-info.outputs.targetBranchName }}
run: |
# Get the branch name from previous steps
branch_name="origin/${{ steps.get-pr-branch.outputs.prBranch }}"
commit_id="${{ steps.extract-pr-info.outputs.commitId }}"
branch_name="origin/$PR_BRANCH"
commit_id="$COMMIT_ID"
# Check if the branch history contains the commit
if git branch -r --contains "$commit_id" | grep -q "$branch_name"; then
echo "Branch '$branch_name' contains commit '$commit_id'. It is up to date with ${{ steps.extract-pr-info.outputs.targetBranchName }}."
if git branch -r --contains "$commit_id" | grep -qF "$branch_name"; then
echo "Branch '$branch_name' contains commit '$commit_id'. It is up to date with $TARGET_BRANCH."
else
echo "Branch '$branch_name' does not contain commit '$commit_id'. It is outdated. Setting CAN_SKIP_CHECKS to false."
echo "CAN_SKIP_CHECKS=false" >> "$GITHUB_ENV"
@@ -112,8 +118,10 @@ runs:
- name: Compare PR Branch with Current Branch
if: env.CAN_SKIP_CHECKS != 'false'
shell: bash
env:
PR_BRANCH: ${{ steps.get-pr-branch.outputs.prBranch }}
run: |
if git diff --quiet "origin/${{ steps.get-pr-branch.outputs.prBranch }}"; then
if git diff --quiet "origin/$PR_BRANCH"; then
echo "No differences found. PR branch is identical with this merge queue branch."
else
echo "Differences detected. PR branch has been updated after PR was added to merge queue. Setting CAN_SKIP_CHECKS to false."
@@ -122,9 +130,11 @@ runs:
- name: Compute/publish skip result
id: passed-checks
uses: actions/github-script@v7
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
env:
SECRET: ${{ inputs.secret }}
PR_BRANCH: ${{ steps.get-pr-branch.outputs.prBranch }}
TARGET_BRANCH: ${{ steps.extract-pr-info.outputs.targetBranchName }}
with:
github-token: ${{ inputs.secret != '' && inputs.secret || github.token }}
script: |
@@ -144,7 +154,7 @@ runs:
const { data: branchProtection } = await github.rest.repos.getBranchProtection({
owner: context.repo.owner,
repo: context.repo.repo,
branch: "${{ steps.extract-pr-info.outputs.targetBranchName }}",
branch: process.env.TARGET_BRANCH,
});
const requiredCheckNames = branchProtection.required_status_checks.contexts;
console.log(`requiredCheckNames = ${requiredCheckNames}`);
@@ -152,7 +162,7 @@ runs:
const { data: checks } = await github.rest.checks.listForRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: "refs/heads/${{ steps.get-pr-branch.outputs.prBranch }}",
ref: `refs/heads/${process.env.PR_BRANCH}`,
});
console.log(`checks.check_runs = ${checks.check_runs.map(check => `${check.status},${check.conclusion},${check.name};`)}`);
+24
View File
@@ -0,0 +1,24 @@
name: Deploy Command
run-name: Deploy PR #${{ github.event.client_payload.github.payload.issue.number }}
on:
repository_dispatch:
types: [deploy-command]
permissions:
contents: read
actions: read
jobs:
deploy:
if: ${{ github.event.client_payload.pull_request.head.repo.full_name == github.repository }}
uses: SparkUniverse/workflows/.github/workflows/deploy-command.yaml@main
secrets:
ARGOCD_DEPLOY_KEY: ${{ secrets.ARGOCD_DEPLOY_KEY }}
CMD_DISPATCH_GH_TOKEN: ${{ secrets.SLASH_CMD_GH_TOKEN }}
with:
application-set: labrinth
build-workflow: labrinth-build.yml
branch: ${{ github.event.client_payload.pull_request.head.ref }}
issue-number: ${{ github.event.client_payload.github.payload.issue.number }}
head-sha: ${{ github.event.client_payload.pull_request.head.sha }}
@@ -1,18 +1,19 @@
name: docker-build
name: Labrinth Build
on:
push:
branches:
- 'main'
- 'prod'
paths:
- .github/workflows/labrinth-docker.yml
- .github/workflows/labrinth-build.yml
- 'apps/labrinth/**'
- Cargo.toml
- Cargo.lock
pull_request:
types: [opened, synchronize]
paths:
- .github/workflows/labrinth-docker.yml
- .github/workflows/labrinth-build.yml
- 'apps/labrinth/**'
- Cargo.toml
- Cargo.lock
@@ -69,7 +70,8 @@ jobs:
echo "skip=false" >> $GITHUB_OUTPUT
fi
docker:
build:
name: Build Labrinth
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'namespace-profile-modrinth-labrinth' || 'ubuntu-latest' }}
needs: [skip-if-clean]
if: ${{ needs.skip-if-clean.outputs.skip != 'true' }}
@@ -120,42 +122,30 @@ jobs:
cp -r apps/labrinth/migrations apps/labrinth/docker-stage/migrations
cp -r apps/labrinth/assets apps/labrinth/docker-stage/assets
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Generate Docker image metadata
id: docker-meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
env:
# GitHub Packages requires annotations metadata in at least the index descriptor to show them
# up properly in its UI it seems, but it's not clear about it, because the docs refer to the
# image manifest only. See:
# https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#adding-a-description-to-multi-arch-images
DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index
- name: Upload Docker context
if: needs.skip-if-clean.outputs.internal == 'true'
uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1.0.3
with:
images: ghcr.io/modrinth/labrinth
labels: |
org.opencontainers.image.title=labrinth
org.opencontainers.image.description=Modrinth API
org.opencontainers.image.licenses=AGPL-3.0-only
annotations: |
org.opencontainers.image.title=labrinth
org.opencontainers.image.description=Modrinth API
org.opencontainers.image.licenses=AGPL-3.0-only
name: labrinth-docker-context
retention-days: 1
path: apps/labrinth/docker-stage
- name: Login to GitHub Packages
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
docker-build:
needs: [skip-if-clean, build]
if: ${{ needs.skip-if-clean.outputs.internal == 'true' }}
uses: SparkUniverse/workflows/.github/workflows/docker-build.yaml@main
with:
image-name: labrinth
dockerfile-path: apps/labrinth/Dockerfile
artifacts-name: labrinth-docker-context
- name: Build and push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: ./apps/labrinth/docker-stage
file: ./apps/labrinth/Dockerfile
push: true
tags: ${{ steps.docker-meta.outputs.tags }}
labels: ${{ steps.docker-meta.outputs.labels }}
annotations: ${{ steps.docker-meta.outputs.annotations }}
deploy:
needs: [skip-if-clean, docker-build]
if: ${{ needs.skip-if-clean.outputs.internal == 'true' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/prod') }}
uses: SparkUniverse/workflows/.github/workflows/argo-update.yaml@main
secrets:
ARGOCD_DEPLOY_KEY: ${{ secrets.ARGOCD_DEPLOY_KEY }}
with:
application-set: labrinth
branch: ${{ github.ref == 'refs/heads/prod' && 'main' || 'develop' }}
environment-name: ${{ github.ref == 'refs/heads/prod' && 'production' || 'staging' }}
+20
View File
@@ -0,0 +1,20 @@
name: Slash Command Dispatch
on:
issue_comment:
types: [created]
permissions: {}
jobs:
dispatch-command:
if: ${{ github.event.sender.type == 'User' && contains(fromJSON('["OWNER", "MEMBER"]'), github.event.comment.author_association) }}
runs-on: namespace-profile-tiny-arm64
steps:
- name: Slash Command Dispatch
uses: peter-evans/slash-command-dispatch@9bdcd7914ec1b75590b790b844aa3b8eee7c683a # v5.0.2
with:
token: ${{ secrets.SLASH_CMD_GH_TOKEN }}
issue-type: pull-request
commands: |
deploy
-2
View File
@@ -92,7 +92,6 @@ jobs:
run: corepack enable
- name: Set up caches
if: contains(matrix.artifact-target-name, 'linux') || contains(matrix.artifact-target-name, 'darwin')
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
with:
cache: |
@@ -100,7 +99,6 @@ jobs:
pnpm
- name: Configure sccache
if: contains(matrix.artifact-target-name, 'linux') || contains(matrix.artifact-target-name, 'darwin')
run: nsc cache sccache setup --cache_name default >> "$GITHUB_ENV"
- name: Generate tauri-dev.conf.json
@@ -26,12 +26,13 @@
</span>
</template>
<div class="flex min-w-0 flex-col gap-3 pt-4">
<div class="max-h-[292px] overflow-y-auto rounded-[20px]">
<div ref="configFileTreeContainer" class="max-h-[292px] overflow-y-auto rounded-[20px]">
<FileTreeSelect
v-model="selectedConfigPaths"
:items="configFileItems"
:show-size="false"
:show-modified="false"
@navigate="scrollConfigFileTreeToTop"
/>
</div>
</div>
@@ -75,6 +76,7 @@ const emit = defineEmits<{
const { formatMessage } = useVIntl()
const { notifySharedInstanceError, notifySharedInstanceUnavailable } = useSharedInstanceErrors()
const publishReviewModal = ref<InstanceType<typeof ContentDiffModal>>()
const configFileTreeContainer = ref<HTMLElement>()
const publishDiffs = ref<ContentDiffItem[]>([])
const configFilePaths = ref<string[]>([])
const selectedConfigPaths = ref<string[]>([])
@@ -132,6 +134,12 @@ async function publishChanges() {
}
}
function scrollConfigFileTreeToTop() {
if (configFileTreeContainer.value) {
configFileTreeContainer.value.scrollTop = 0
}
}
function finishReview() {
if (state.value === 'reviewing') {
setState('idle')
@@ -260,6 +260,9 @@
"app.browse.hide-added-servers": {
"message": "Hide servers already added"
},
"app.browse.hide-installed-modpacks": {
"message": "Hide already installed"
},
"app.browse.project-type.modpacks": {
"message": "Modpacks"
},
+118 -29
View File
@@ -25,6 +25,7 @@ import {
preferencesDiffer,
provideBrowseManager,
requestInstall,
resolveInstallPlan,
stripServerRuntimeInstallFilters,
stripServerRuntimeInstallOverrides,
useBrowseSearch,
@@ -50,6 +51,7 @@ import { instance_listener } from '@/helpers/events.js'
import {
get as getInstance,
get_installed_project_ids as getInstalledProjectIds,
list as listInstances,
} from '@/helpers/instance'
import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
@@ -171,7 +173,7 @@ const instance: Ref<Instance | null> = ref(
queryClient.getQueryData<Instance>(['instances', 'summary', initialInstanceId]) ?? null,
)
const installedProjectIds: Ref<string[] | null> = ref(null)
const instanceHideInstalled = ref(false)
const instanceHideInstalled = ref(route.query.ai === 'true')
const newlyInstalled = ref<string[]>([])
const hiddenInstanceProjectIds = ref<Set<string>>(new Set())
const hiddenInstanceProjectIdsInitialized = ref(false)
@@ -288,7 +290,17 @@ watchServerContextChanges()
await initInstanceContext()
async function refreshInstalledProjectIds() {
if (!route.query.i) return
if (!route.query.i) {
const instances = await listInstances().catch(handleError)
if (!instances) return
const ids = instances
.map((gameInstance) => gameInstance.link?.project_id)
.filter((id): id is string => !!id)
debugLog('installedInstanceProjectIds loaded', { count: ids.length })
installedProjectIds.value = ids
return
}
if (route.query.from === 'worlds') {
const worlds = await get_instance_worlds(route.query.i as string).catch(handleError)
@@ -318,6 +330,7 @@ async function initInstanceContext() {
queryFrom: route.query.from,
})
await initServerContext()
await refreshInstalledProjectIds()
if (route.query.i) {
instance.value = (await getInstance(route.query.i as string).catch(handleError)) ?? null
@@ -327,8 +340,6 @@ async function initInstanceContext() {
gameVersion: instance.value?.game_version,
})
await refreshInstalledProjectIds()
if (instance.value?.link?.project_id) {
debugLog('checking linked project for server status', instance.value.link.project_id)
const projectV3 = await get_project_v3(
@@ -341,13 +352,23 @@ async function initInstanceContext() {
}
}
}
if (route.query.ai && !(route.params.projectType === 'modpack')) {
debugLog('setting instanceHideInstalled from query', route.query.ai)
instanceHideInstalled.value = route.query.ai === 'true'
}
}
function setBrowseHideInstalledFlag(flag: 'hide_installed_modpacks', value: boolean) {
themeStore.featureFlags[flag] = value
getSettings()
.then((settings) => {
settings.feature_flags[flag] = value
return setSettings(settings)
})
.catch(handleError)
}
const hideInstalledModpacks = computed({
get: () => themeStore.getFeatureFlag('hide_installed_modpacks'),
set: (value: boolean) => setBrowseHideInstalledFlag('hide_installed_modpacks', value),
})
const instanceFilters = computed(() => {
const filters = []
@@ -367,11 +388,15 @@ const instanceFilters = computed(() => {
if (isServerInstance.value) {
filters.push({ type: 'environment', option: 'client' })
}
}
if (instanceHideInstalled.value && hiddenInstanceProjectIds.value.size > 0) {
for (const id of hiddenInstanceProjectIds.value) {
filters.push({ type: 'project_id', option: `project_id:${id}`, negative: true })
}
if (
(instance.value || projectType.value === 'modpack') &&
(projectType.value === 'modpack' ? hideInstalledModpacks.value : instanceHideInstalled.value) &&
hiddenInstanceProjectIds.value.size > 0
) {
for (const id of hiddenInstanceProjectIds.value) {
filters.push({ type: 'project_id', option: `project_id:${id}`, negative: true })
}
}
@@ -430,6 +455,12 @@ const serverContextFilters = computed(() => {
{ type: 'environment', option: 'client' },
{ type: 'environment', option: 'server' },
)
if (hideInstalledModpacks.value && hiddenInstanceProjectIds.value.size > 0) {
for (const id of hiddenInstanceProjectIds.value) {
filters.push({ type: 'project_id', option: `project_id:${id}`, negative: true })
}
}
}
if (serverHideInstalled.value && hiddenServerContentProjectIds.value.size > 0) {
@@ -500,6 +531,10 @@ const messages = defineMessages({
id: 'app.browse.hide-added-servers',
defaultMessage: 'Hide servers already added',
},
hideInstalledModpacks: {
id: 'app.browse.hide-installed-modpacks',
defaultMessage: 'Hide already installed',
},
installingToServer: {
id: 'app.browse.server.installing',
defaultMessage: 'Installing',
@@ -553,6 +588,7 @@ function resetInstanceContext() {
hiddenInstanceProjectIdsInitialized.value = false
isServerInstance.value = false
browseBreadcrumb.reset()
void refreshInstalledProjectIds()
}
watch(
@@ -763,6 +799,27 @@ async function chooseInstanceInstallVersion(
return { versionId: selectedVersion.id }
}
async function chooseFilterMatchingInstallVersion(
project: Labrinth.Search.v3.ResultSearchProject,
projectTypeValue: string,
) {
const plan = await resolveInstallPlan({
project: {
project_id: project.project_id,
title: project.title,
icon_url: project.icon_url,
},
contentType: projectTypeValue as BrowseInstallContentType,
selectedFilters: searchState.currentFilters.value,
providedFilters: combinedProvidedFilters.value,
overriddenProvidedFilterTypes: searchState.overriddenProvidedFilterTypes.value,
targetPreferences: {},
getProjectVersions: getInstallProjectVersions,
})
return { versionId: plan.versionId }
}
function getCardActions(
result: Labrinth.Search.v3.ResultSearchProject,
currentProjectType: string,
@@ -781,6 +838,7 @@ function getCardActions(
serverContentProjectIds.value.has(projectResult.project_id || '') ||
serverContextServerData.value?.upstream?.project_id === projectResult.project_id
const isInstalling = installingProjectIds.value.has(projectResult.project_id)
const showAsInstalled = isInstalled && currentProjectType !== 'modpack'
if (
isServerContext.value &&
@@ -790,7 +848,7 @@ function getCardActions(
const isInstallingSelection = isInstallingQueuedServerInstalls.value
const validatingInstall =
isInstalling && currentProjectType !== 'modpack' && !isInstallingSelection
const installLabel = isInstalled
const installLabel = showAsInstalled
? commonMessages.installedLabel
: isQueued
? isInstalling || isInstallingSelection
@@ -810,11 +868,11 @@ function getCardActions(
icon:
isInstalling || isInstallingSelection
? SpinnerIcon
: isQueued || isInstalled
: isQueued || showAsInstalled
? CheckIcon
: PlusIcon,
iconClass: isInstalling || isInstallingSelection ? 'animate-spin' : undefined,
disabled: isInstalled || isInstalling || isInstallingSelection,
disabled: showAsInstalled || isInstalling || isInstallingSelection,
color: isQueued && !isInstalling && !isInstallingSelection ? 'green' : 'brand',
type: 'outlined',
onClick: async () => {
@@ -875,15 +933,15 @@ function getCardActions(
label: formatMessage(
isInstalling
? messages.installingToServer
: isInstalled
: showAsInstalled
? commonMessages.installedLabel
: shouldUseInstallIcon
? commonMessages.installButton
: messages.addToAnInstance,
),
icon: isInstalling ? SpinnerIcon : isInstalled ? CheckIcon : PlusIcon,
icon: isInstalling ? SpinnerIcon : showAsInstalled ? CheckIcon : PlusIcon,
iconClass: isInstalling ? 'animate-spin' : undefined,
disabled: isInstalled || isInstalling,
disabled: showAsInstalled || isInstalling,
color: 'brand',
type: 'outlined',
onClick: async () => {
@@ -891,7 +949,9 @@ function getCardActions(
try {
const selectedInstall = instance.value
? await chooseInstanceInstallVersion(projectResult, currentProjectType)
: { versionId: null as string | null }
: isModpack
? await chooseFilterMatchingInstallVersion(projectResult, currentProjectType)
: { versionId: null as string | null }
if (selectedInstall === null) {
setProjectInstalling(projectResult.project_id, false)
return
@@ -994,10 +1054,11 @@ async function search(requestParams: string) {
...hit,
}
if (instance.value || isServerContext.value) {
const installedIds = instance.value
? new Set([...newlyInstalled.value, ...(installedProjectIds.value ?? [])])
: serverContentProjectIds.value
if (instance.value || isServerContext.value || projectType.value === 'modpack') {
const installedIds =
isServerContext.value && projectType.value !== 'modpack'
? serverContentProjectIds.value
: new Set([...newlyInstalled.value, ...(installedProjectIds.value ?? [])])
mapped.installed = installedIds.has(hit.project_id)
}
@@ -1057,9 +1118,9 @@ watch(
() => projectType.value,
],
() => {
if (isServerContext.value) {
if (isServerContext.value && projectType.value !== 'modpack') {
syncHiddenServerContentProjectIds()
} else if (instance.value) {
} else if (instance.value || projectType.value === 'modpack') {
syncHiddenInstanceProjectIds()
}
},
@@ -1091,6 +1152,18 @@ let unlistenInstances: UnlistenFn | null = null
onMounted(() => {
instance_listener(async (event: { event: string; instance_id: string }) => {
if (event.event === 'added' || event.event === 'created' || event.event === 'removed') {
if (!route.query.i) {
await refreshInstalledProjectIds()
if (projectType.value === 'modpack') {
if (event.event === 'removed') {
syncHiddenInstanceProjectIds()
}
await searchState.refreshSearch()
}
}
}
if (instance.value && event.instance_id === instance.value.id && event.event === 'synced') {
await refreshInstalledProjectIds()
await searchState.refreshSearch()
@@ -1156,8 +1229,17 @@ provideBrowseManager({
installContext,
providedFilters: combinedProvidedFilters,
hideInstalled: computed({
get: () => (isServerContext.value ? serverHideInstalled.value : instanceHideInstalled.value),
get: () => {
if (projectType.value === 'modpack') return hideInstalledModpacks.value
if (isServerContext.value) return serverHideInstalled.value
return instanceHideInstalled.value
},
set: (val: boolean) => {
if (projectType.value === 'modpack') {
hideInstalledModpacks.value = val
if (val) syncHiddenInstanceProjectIds()
return
}
if (isServerContext.value) {
serverHideInstalled.value = val
if (val) syncHiddenServerContentProjectIds()
@@ -1168,11 +1250,18 @@ provideBrowseManager({
},
}),
showHideInstalled: computed(
() => (isServerContext.value && projectType.value !== 'modpack') || !!instance.value,
() =>
projectType.value === 'modpack' ||
(isServerContext.value && projectType.value !== 'modpack') ||
!!instance.value,
),
hideInstalledLabel: computed(() =>
formatMessage(
isFromWorlds.value ? messages.hideAddedServers : commonMessages.hideInstalledContentLabel,
isFromWorlds.value
? messages.hideAddedServers
: projectType.value === 'modpack'
? messages.hideInstalledModpacks
: commonMessages.hideInstalledContentLabel,
),
),
hideSelected: hideSelectedServerInstalls,
+18 -10
View File
@@ -42,16 +42,24 @@ onUnmounted(() => {
<template>
<div class="p-6 flex flex-col gap-3">
<h1 class="m-0 text-2xl hidden">Library</h1>
<NavTabs
:links="[
{ label: 'All instances', href: `/library` },
{ label: 'Modpacks', href: `/library/modpacks` },
{ label: 'Servers', href: `/library/servers` },
{ label: 'Custom', href: `/library/custom` },
{ label: 'Shared with me', href: `/library/shared`, shown: false },
{ label: 'Saved', href: `/library/saved`, shown: false },
]"
/>
<div class="flex items-center justify-between gap-3">
<NavTabs
:links="[
{ label: 'All instances', href: `/library` },
{ label: 'Modpacks', href: `/library/modpacks` },
{ label: 'Servers', href: `/library/servers` },
{ label: 'Custom', href: `/library/custom` },
{ label: 'Shared with me', href: `/library/shared`, shown: false },
{ label: 'Saved', href: `/library/saved`, shown: false },
]"
/>
<ButtonStyled color="brand">
<button :disabled="offline" @click="showCreationModal?.()">
<PlusIcon />
New instance
</button>
</ButtonStyled>
</div>
<template v-if="instances && instances.length > 0">
<RouterView v-if="route.path.startsWith('/library')" :instances="instances" />
</template>
@@ -1,6 +1,11 @@
import type { Labrinth } from '@modrinth/api-client'
import type { ContentInstallInstance, ContentInstallProjectInfo, ContentItem } from '@modrinth/ui'
import { createContext, defineMessage, useVIntl } from '@modrinth/ui'
import {
createContext,
defineMessage,
getLatestMatchingInstallVersion,
useVIntl,
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { openUrl } from '@tauri-apps/plugin-opener'
import dayjs from 'dayjs'
@@ -813,7 +818,22 @@ export function createContentInstall(opts: {
}
if (project.project_type === 'modpack') {
const version = versionId ?? project.versions[project.versions.length - 1]
let version = versionId ?? null
if (!version) {
const hasHints = !!(hints?.preferredGameVersion || hints?.preferredLoader)
if (hasHints) {
const versions = (await get_version_many(
project.versions,
'must_revalidate',
)) as Labrinth.Versions.v2.Version[]
const matching = getLatestMatchingInstallVersion(versions, {
gameVersions: hints?.preferredGameVersion ? [hints.preferredGameVersion] : undefined,
loaders: hints?.preferredLoader ? [hints.preferredLoader] : undefined,
})
version = matching?.id ?? null
}
version ??= project.versions[project.versions.length - 1]
}
const packs = await list()
const existingPack = packs.find((pack) => pack.link?.project_id === project.id)
+1
View File
@@ -17,6 +17,7 @@ export const DEFAULT_FEATURE_FLAGS = {
show_instance_play_time: true,
advanced_filters_collapsed: true,
always_show_copy_details: false,
hide_installed_modpacks: false,
}
export const THEME_OPTIONS = ['dark', 'light', 'oled', 'retro', 'system'] as const
@@ -27,6 +27,7 @@ import {
LineController,
LineElement,
PointElement,
type ScriptableLineSegmentContext,
Tooltip,
} from 'chart.js'
@@ -446,6 +447,12 @@ function buildDatasets() {
pointHoverBackgroundColor: colors.borderColor,
pointHoverBorderWidth: 0,
pointHitRadius: 16,
segment: dataset.lastDataPointUnavailable
? {
borderColor: (context: ScriptableLineSegmentContext) =>
context.p1DataIndex === dataset.data.length - 1 ? 'transparent' : undefined,
}
: undefined,
stack: props.stacked ? 'analytics' : undefined,
}
})
@@ -59,7 +59,9 @@
<span class="font-medium text-primary">
{{ formatMessage(analyticsChartMessages.total) }}
</span>
<span class="font-semibold text-contrast">{{ formattedTotal }}</span>
<span class="font-semibold" :class="totalHasData ? 'text-contrast' : 'text-primary'">
{{ formattedTotal }}
</span>
</div>
<div
v-for="entry in entries"
@@ -111,7 +113,11 @@
:class="[
'shrink-0',
entry.isPreviousPeriod ? 'font-medium text-secondary' : 'font-semibold',
entry.hidden ? 'text-primary line-through opacity-70' : 'text-contrast',
entry.hidden
? 'text-primary line-through opacity-70'
: entry.noData
? 'text-primary'
: 'text-contrast',
]"
>
{{ entry.formattedValue }}
@@ -148,6 +154,7 @@ export type AnalyticsChartTooltipEntry = {
tooltip?: string
color: string
formattedValue: string
noData: boolean
hidden: boolean
toggleDisabled: boolean
isPreviousPeriod?: boolean
@@ -164,6 +171,7 @@ const props = defineProps<{
chartStart: Date | null
chartEnd: Date | null
formattedTotal: string
totalHasData: boolean
entries: AnalyticsChartTooltipEntry[]
containerWidth: number
containerHeight: number
@@ -64,6 +64,7 @@
:chart-start="chartRangeBounds?.start ?? null"
:chart-end="chartRangeBounds?.end ?? null"
:formatted-total="hoverFormattedTotal"
:total-has-data="hoverHasData"
:entries="hoverEntries"
:container-width="containerSize.width"
:container-height="containerSize.height"
@@ -89,6 +90,7 @@ import type {
AnalyticsGroupByPreset,
} from '~/providers/analytics/analytics'
import { analyticsChartMessages } from '../../analytics-messages.ts'
import type {
AnalyticsChartLegendEntry,
AnalyticsChartRangeBounds,
@@ -188,7 +190,26 @@ const hoverTotalValue = computed(() => {
}, 0)
})
function hasDatasetDataAtSlice(dataset: ChartDataset | undefined, sliceIndex: number) {
if (!dataset) return false
return !dataset.lastDataPointUnavailable || sliceIndex !== dataset.data.length - 1
}
const hoverHasData = computed(() => {
if (hoverState.sliceIndex === null) return false
const sliceIndex = hoverState.sliceIndex
return props.currentLegendEntries.some((legendEntry) => {
if (legendEntry.hidden) return false
const dataset = props.chartDatasetById.get(legendEntry.id)
return hasDatasetDataAtSlice(dataset, sliceIndex)
})
})
const hoverFormattedTotal = computed(() => {
if (!hoverHasData.value) {
return formatMessage(analyticsChartMessages.noData)
}
if (props.isRatioMode) {
return hoverTotalValue.value > 0 ? '100%' : '0%'
}
@@ -203,6 +224,7 @@ const hoverEntries = computed(() => {
return props.legendEntries.map((legendEntry) => {
const dataset = props.chartDatasetById.get(legendEntry.id)
const value = dataset?.data[sliceIndex] ?? 0
const hasData = hasDatasetDataAtSlice(dataset, sliceIndex)
const ratioValue = legendEntry.hidden || totalValue === 0 ? 0 : (value / totalValue) * 100
return {
projectId: legendEntry.id,
@@ -210,9 +232,12 @@ const hoverEntries = computed(() => {
projectName: legendEntry.projectName,
tooltip: legendEntry.tooltip,
color: legendEntry.color,
formattedValue: props.isRatioMode
? `${ratioValue.toFixed(1)}%`
: formatMetricValue(value, props.activeStat, formatNumber, formatMessage),
formattedValue: hasData
? props.isRatioMode
? `${ratioValue.toFixed(1)}%`
: formatMetricValue(value, props.activeStat, formatNumber, formatMessage)
: formatMessage(analyticsChartMessages.noData),
noData: !hasData,
hidden: legendEntry.hidden,
toggleDisabled: !legendEntry.hidden && isLegendEntryToggleDisabled(legendEntry),
isPreviousPeriod: legendEntry.isPreviousPeriod,
@@ -38,6 +38,7 @@ export type ChartDataset = {
projectName?: string
tooltip?: string
data: number[]
lastDataPointUnavailable?: boolean
borderColor: string
backgroundColor: string
borderDash?: number[]
@@ -32,6 +32,7 @@ import {
type ChartDataset,
getChartDatasetTotal,
getShortHourlyAxisTickLimit,
getSliceBucketRange,
getSliceCount,
shouldCapitalizeBreakdownLabel,
} from './analytics-chart-utils'
@@ -161,8 +162,8 @@ export function useAnalyticsChartDatasets(
)
: undefined
})
const chartDatasetsByStat = computed<Record<AnalyticsDashboardStat, ChartDataset[]>>(() =>
buildDatasetsByStat(
const chartDatasetsByStat = computed<Record<AnalyticsDashboardStat, ChartDataset[]>>(() => {
const datasets = buildDatasetsByStat(
context.displayedTimeSlices.value,
selectedProjects.value,
legendPalette.value,
@@ -175,8 +176,20 @@ export function useAnalyticsChartDatasets(
showProjectVersionNames.value ? context.getVersionProjectName : undefined,
formatMessage,
sliceCount.value,
),
)
)
const nextFetchRequest = context.displayedFetchRequest.value
if (
nextFetchRequest &&
context.displayedSelectedGroupBy.value === 'day' &&
isLastBucketCurrentDay(nextFetchRequest.time_range, sliceCount.value)
) {
datasets.revenue = datasets.revenue.map((dataset) => ({
...dataset,
lastDataPointUnavailable: true,
}))
}
return datasets
})
const previousChartDatasetsByStat = computed<Record<AnalyticsDashboardStat, ChartDataset[]>>(() =>
buildDatasetsByStat(
context.displayedPreviousTimeSlices.value,
@@ -406,6 +419,16 @@ function buildDatasetsByStat(
return datasetsByStat
}
function isLastBucketCurrentDay(timeRange: Labrinth.Analytics.v3.TimeRange, sliceCount: number) {
const lastBucket = getSliceBucketRange(timeRange, sliceCount, sliceCount - 1)
const todayStart = new Date()
todayStart.setHours(0, 0, 0, 0)
const tomorrowStart = new Date(todayStart)
tomorrowStart.setDate(tomorrowStart.getDate() + 1)
return lastBucket.start < tomorrowStart && lastBucket.end > todayStart
}
function sortDatasetsByTotal(datasets: ChartDataset[]) {
return [...datasets]
.sort((a, b) => {
@@ -688,6 +688,10 @@ export const analyticsChartMessages = defineMessages({
id: 'analytics.chart.tooltip.hide-entry',
defaultMessage: 'Hide {name} in graph',
},
noData: {
id: 'analytics.chart.tooltip.no-data',
defaultMessage: 'No data',
},
durationDays: {
id: 'analytics.chart.tooltip.duration.days',
defaultMessage: '{count, plural, one {# day} other {# days}}',
-156
View File
@@ -1,156 +0,0 @@
<template>
<div v-if="shown">
<div
:class="{
shown: actuallyShown,
noblur: !$orElse(cosmetics.advancedRendering, true),
}"
class="modal-overlay"
@click="hide"
/>
<div class="modal-container" :class="{ shown: actuallyShown }">
<div class="modal-body">
<div v-if="header" class="header">
<strong>{{ header }}</strong>
<ButtonStyled circular type="transparent">
<button @click="hide">
<XIcon />
</button>
</ButtonStyled>
</div>
<div class="content">
<slot />
</div>
</div>
</div>
</div>
<div v-else />
</template>
<script>
import { XIcon } from '@modrinth/assets'
import { ButtonStyled } from '@modrinth/ui'
export default {
components: {
ButtonStyled,
XIcon,
},
props: {
header: {
type: String,
default: null,
},
},
setup() {
const cosmetics = useCosmetics()
return { cosmetics }
},
data() {
return {
shown: false,
actuallyShown: false,
}
},
methods: {
show() {
this.shown = true
setTimeout(() => {
this.actuallyShown = true
}, 50)
},
hide() {
this.actuallyShown = false
setTimeout(() => {
this.shown = false
}, 300)
},
},
}
</script>
<style lang="scss" scoped>
.modal-overlay {
visibility: hidden;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 20;
transition: all 0.3s ease-in-out;
&.shown {
opacity: 1;
visibility: visible;
background: hsla(0, 0%, 0%, 0.5);
backdrop-filter: blur(3px);
}
&.noblur {
backdrop-filter: none;
}
}
.modal-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
z-index: 21;
visibility: hidden;
pointer-events: none;
&.shown {
visibility: visible;
.modal-body {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
}
.modal-body {
position: fixed;
box-shadow: var(--shadow-raised), var(--shadow-inset);
border-radius: var(--size-rounded-lg);
max-height: calc(100% - 2 * var(--spacing-card-bg));
overflow-y: auto;
width: 600px;
pointer-events: auto;
outline: 3px solid transparent;
.header {
display: flex;
justify-content: space-between;
align-items: center;
background-color: var(--color-bg);
padding: var(--spacing-card-md) var(--spacing-card-lg);
strong {
font-size: 1.25rem;
margin: 0.67em 0;
}
}
.content {
background-color: var(--color-raised-bg);
}
transform: translateY(50vh);
visibility: hidden;
opacity: 0;
transition: all 0.25s ease-in-out;
@media screen and (max-width: 650px) {
width: calc(100% - 2 * var(--spacing-card-bg));
}
}
}
</style>
@@ -86,10 +86,17 @@ export function provideDownloadModalProvider(
const tags = useGeneratedState()
const shouldResolveDependencies = computed(
() => !!options.project.value && !!options.selectedVersion.value,
() =>
!!options.project.value &&
!!options.selectedVersion.value &&
!!options.currentGameVersion.value,
)
const dependencyResolutionPreferences = computed(() =>
createResolutionPreferences(options.selectedVersion.value, options.currentPlatform.value),
createResolutionPreferences(
options.selectedVersion.value,
options.currentGameVersion.value,
options.currentPlatform.value,
),
)
const { data: dependencyResolution, isFetching: dependencyResolutionFetching } = useQuery({
@@ -309,10 +316,12 @@ export function provideDownloadModalProvider(
})
async function preloadDependenciesForSelection(selection: ProjectDownloadSelection) {
if (!options.project.value || !selection.selectedVersion) return
if (!options.project.value || !selection.selectedVersion || !selection.currentGameVersion)
return
const preferences = createResolutionPreferences(
selection.selectedVersion,
selection.currentGameVersion,
selection.currentPlatform,
)
@@ -548,10 +557,11 @@ function dependencyProjectsQueryOptions(
function createResolutionPreferences(
version: Labrinth.Versions.v3.Version | null,
currentGameVersion: string | null,
currentPlatform: string | null,
): Labrinth.Content.v3.ResolutionPreferences {
return {
game_versions: version?.game_versions || [],
game_versions: currentGameVersion ? [currentGameVersion] : [],
loaders: currentPlatform ? [currentPlatform] : version?.loaders || [],
}
}
+48 -16
View File
@@ -30,6 +30,29 @@ const normalizeAuthToken = (value: unknown) => {
return ''
}
const getErrorStatus = (error: unknown): number | undefined => {
if (!error || typeof error !== 'object') {
return undefined
}
const typedError = error as { statusCode?: unknown; status?: unknown }
const status = typedError.statusCode ?? typedError.status
return typeof status === 'number' ? status : undefined
}
// only when labrinth actually gives us an auth error
const isAuthFailure = (error: unknown): boolean => {
const status = getErrorStatus(error)
return status === 401 || status === 403
}
const clearAuthCookie = (auth: AuthState, authCookie: { value: string | null }) => {
authCookie.value = null
auth.token = ''
auth.user = null
}
const getQueryString = (value: QueryValue) => {
if (Array.isArray(value)) {
return value[0] ?? null
@@ -90,6 +113,7 @@ export const initAuth = async (oldToken: string | null | undefined = null) => {
}
const tokenStr = normalizeAuthToken(authCookie.value)
let shouldRefresh = false
if (authCookie.value != null && tokenStr === '') {
authCookie.value = null
@@ -111,12 +135,13 @@ export const initAuth = async (oldToken: string | null | undefined = null) => {
},
true,
)) as Labrinth.Users.v2.User
} catch {
/* empty */
} catch (error) {
// only refresh when the token was rejected. not on timeouts or other errors (think this was the cause of random logouts)
shouldRefresh = isAuthFailure(error)
}
}
if (!auth.user && auth.token) {
if (!auth.user && auth.token && shouldRefresh) {
try {
const session = (await useBaseFetch(
'session/refresh',
@@ -132,22 +157,29 @@ export const initAuth = async (oldToken: string | null | undefined = null) => {
auth.token = normalizeAuthToken(session.session)
if (auth.token) {
authCookie.value = auth.token
auth.user = (await useBaseFetch(
'user',
{
apiVersion: 3,
headers: {
Authorization: auth.token,
try {
auth.user = (await useBaseFetch(
'user',
{
apiVersion: 3,
headers: {
Authorization: auth.token,
},
},
},
true,
)) as Labrinth.Users.v2.User
true,
)) as Labrinth.Users.v2.User
} catch (error) {
if (isAuthFailure(error)) {
clearAuthCookie(auth, authCookie)
}
}
} else {
authCookie.value = null
auth.token = ''
clearAuthCookie(auth, authCookie)
}
} catch (error) {
if (isAuthFailure(error)) {
clearAuthCookie(auth, authCookie)
}
} catch {
authCookie.value = null
}
}
@@ -2744,24 +2744,15 @@
"settings.applications.about": {
"message": "O"
},
"settings.applications.button.cancel": {
"message": "Zrušit"
},
"settings.applications.button.create": {
"message": "Vytvořit aplikaci"
},
"settings.applications.button.delete": {
"message": "Smazat"
},
"settings.applications.button.edit": {
"message": "Upravit"
},
"settings.applications.button.new": {
"message": "Nová aplikace"
},
"settings.applications.button.save-changes": {
"message": "Uložit změny"
},
"settings.applications.button.upload-icon": {
"message": "Nahrát ikonu"
},
@@ -2945,24 +2945,15 @@
"settings.applications.button.add-more": {
"message": "Tilføj mere"
},
"settings.applications.button.cancel": {
"message": "Annuller"
},
"settings.applications.button.create": {
"message": "Opret app"
},
"settings.applications.button.delete": {
"message": "Slet"
},
"settings.applications.button.edit": {
"message": "Rediger"
},
"settings.applications.button.new": {
"message": "Ny applikation"
},
"settings.applications.button.save-changes": {
"message": "Gem ændringer"
},
"settings.applications.button.upload-icon": {
"message": "Upload ikon"
},
@@ -4679,24 +4679,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Weiterleitungs-URI hinzufügen"
},
"settings.applications.button.cancel": {
"message": "Abbrechen"
},
"settings.applications.button.create": {
"message": "App erstellen"
},
"settings.applications.button.delete": {
"message": "Löschen"
},
"settings.applications.button.edit": {
"message": "Bearbeiten"
},
"settings.applications.button.new": {
"message": "Neue Anwendung"
},
"settings.applications.button.save-changes": {
"message": "Änderungen speichern"
},
"settings.applications.button.upload-icon": {
"message": "Icon hochladen"
},
@@ -4679,24 +4679,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Weiterleitungs-URI hinzufügen"
},
"settings.applications.button.cancel": {
"message": "Abbrechen"
},
"settings.applications.button.create": {
"message": "Anwendung erstellen"
},
"settings.applications.button.delete": {
"message": "Löschen"
},
"settings.applications.button.edit": {
"message": "Bearbeiten"
},
"settings.applications.button.new": {
"message": "Neue Anwendung"
},
"settings.applications.button.save-changes": {
"message": "Änderungen speichern"
},
"settings.applications.button.upload-icon": {
"message": "Icon hochladen"
},
+3 -9
View File
@@ -191,6 +191,9 @@
"analytics.chart.tooltip.hide-entry": {
"message": "Hide {name} in graph"
},
"analytics.chart.tooltip.no-data": {
"message": "No data"
},
"analytics.chart.tooltip.pinned": {
"message": "Chart tooltip pinned"
},
@@ -4709,24 +4712,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Add a redirect URI"
},
"settings.applications.button.cancel": {
"message": "Cancel"
},
"settings.applications.button.create": {
"message": "Create app"
},
"settings.applications.button.delete": {
"message": "Delete"
},
"settings.applications.button.edit": {
"message": "Edit"
},
"settings.applications.button.new": {
"message": "New application"
},
"settings.applications.button.save-changes": {
"message": "Save changes"
},
"settings.applications.button.upload-icon": {
"message": "Upload icon"
},
@@ -4187,24 +4187,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Agregar un URI de redirección"
},
"settings.applications.button.cancel": {
"message": "Cancelar"
},
"settings.applications.button.create": {
"message": "Crear aplicación"
},
"settings.applications.button.delete": {
"message": "Borrar"
},
"settings.applications.button.edit": {
"message": "Editar"
},
"settings.applications.button.new": {
"message": "Nueva aplicación"
},
"settings.applications.button.save-changes": {
"message": "Guardar cambios"
},
"settings.applications.button.upload-icon": {
"message": "Cargar icono"
},
@@ -3860,24 +3860,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Añadir una URI de redirección"
},
"settings.applications.button.cancel": {
"message": "Cancelar"
},
"settings.applications.button.create": {
"message": "Crear aplicación"
},
"settings.applications.button.delete": {
"message": "Eliminar"
},
"settings.applications.button.edit": {
"message": "Editar"
},
"settings.applications.button.new": {
"message": "Nueva aplicación"
},
"settings.applications.button.save-changes": {
"message": "Guardar cambios"
},
"settings.applications.button.upload-icon": {
"message": "Subir icono"
},
@@ -2840,24 +2840,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Magdagdag ng redirect URI"
},
"settings.applications.button.cancel": {
"message": "Kanselahin"
},
"settings.applications.button.create": {
"message": "Lumikha ng app"
},
"settings.applications.button.delete": {
"message": "Tanggalin"
},
"settings.applications.button.edit": {
"message": "Baguhin"
},
"settings.applications.button.new": {
"message": "Bagong aplikasyon"
},
"settings.applications.button.save-changes": {
"message": "I-save ang mga pagbabago"
},
"settings.applications.button.upload-icon": {
"message": "I-upload ang ikono"
},
@@ -4667,24 +4667,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Ajouter une URI de redirection"
},
"settings.applications.button.cancel": {
"message": "Annuler"
},
"settings.applications.button.create": {
"message": "Créer une appli"
},
"settings.applications.button.delete": {
"message": "Supprimer"
},
"settings.applications.button.edit": {
"message": "Modifier"
},
"settings.applications.button.new": {
"message": "Nouvelle application"
},
"settings.applications.button.save-changes": {
"message": "Sauvegarder les changements"
},
"settings.applications.button.upload-icon": {
"message": "Charger une icône"
},
@@ -2282,9 +2282,6 @@
"settings.account.data-export.description": {
"message": "תבקש עותק של כל הדאטה האישי שהעלית ל-Modrinth. זה יכול לקחת כמה דקות."
},
"settings.applications.button.edit": {
"message": "ערוך"
},
"settings.applications.field.name": {
"message": "שם"
},
@@ -4130,24 +4130,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Átírányítás URI hozzáadása"
},
"settings.applications.button.cancel": {
"message": "Mégse"
},
"settings.applications.button.create": {
"message": "Alkalmazás létrehozása"
},
"settings.applications.button.delete": {
"message": "Törlés"
},
"settings.applications.button.edit": {
"message": "Szerkesztés"
},
"settings.applications.button.new": {
"message": "Új alkalmazás"
},
"settings.applications.button.save-changes": {
"message": "Módosítások mentése"
},
"settings.applications.button.upload-icon": {
"message": "Ikon feltöltése"
},
@@ -2918,24 +2918,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Tambah URI pengarahan ulang"
},
"settings.applications.button.cancel": {
"message": "Batal"
},
"settings.applications.button.create": {
"message": "Buat aplikasi"
},
"settings.applications.button.delete": {
"message": "Hapus"
},
"settings.applications.button.edit": {
"message": "Sunting"
},
"settings.applications.button.new": {
"message": "Aplikasi baru"
},
"settings.applications.button.save-changes": {
"message": "Simpan perubahan"
},
"settings.applications.button.upload-icon": {
"message": "Unggah ikon"
},
@@ -4667,24 +4667,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Aggiungi reindirizzamento"
},
"settings.applications.button.cancel": {
"message": "Annulla"
},
"settings.applications.button.create": {
"message": "Crea app"
},
"settings.applications.button.delete": {
"message": "Elimina"
},
"settings.applications.button.edit": {
"message": "Modifica"
},
"settings.applications.button.new": {
"message": "Nuova applicazione"
},
"settings.applications.button.save-changes": {
"message": "Salva modifiche"
},
"settings.applications.button.upload-icon": {
"message": "Carica icona"
},
@@ -3620,24 +3620,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "リダイレクトURIを追加"
},
"settings.applications.button.cancel": {
"message": "キャンセル"
},
"settings.applications.button.create": {
"message": "アプリを作成する"
},
"settings.applications.button.delete": {
"message": "削除"
},
"settings.applications.button.edit": {
"message": "編集"
},
"settings.applications.button.new": {
"message": "新規アプリケーション"
},
"settings.applications.button.save-changes": {
"message": "変更を保存する"
},
"settings.applications.button.upload-icon": {
"message": "アイコンをアップロード"
},
@@ -4631,24 +4631,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "리디렉션 URI 를 추가하세요"
},
"settings.applications.button.cancel": {
"message": "취소"
},
"settings.applications.button.create": {
"message": "앱 만들기"
},
"settings.applications.button.delete": {
"message": "지우기"
},
"settings.applications.button.edit": {
"message": "편집"
},
"settings.applications.button.new": {
"message": "새 애플리케이션"
},
"settings.applications.button.save-changes": {
"message": "변경사항 저장"
},
"settings.applications.button.upload-icon": {
"message": "아이콘 업로드"
},
@@ -3653,24 +3653,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Tambah URI pengubah hala"
},
"settings.applications.button.cancel": {
"message": "Batal"
},
"settings.applications.button.create": {
"message": "Cipta aplikasi"
},
"settings.applications.button.delete": {
"message": "Padam"
},
"settings.applications.button.edit": {
"message": "Sunting"
},
"settings.applications.button.new": {
"message": "Aplikasi baharu"
},
"settings.applications.button.save-changes": {
"message": "Simpan perubahan"
},
"settings.applications.button.upload-icon": {
"message": "Muat naik ikon"
},
@@ -4706,24 +4706,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Voeg een URI toe"
},
"settings.applications.button.cancel": {
"message": "Annuleren"
},
"settings.applications.button.create": {
"message": "Maak nieuwe"
},
"settings.applications.button.delete": {
"message": "Verwijder"
},
"settings.applications.button.edit": {
"message": "Bewerk"
},
"settings.applications.button.new": {
"message": "Nieuw project"
},
"settings.applications.button.save-changes": {
"message": "Wijzigingen opslaan"
},
"settings.applications.button.upload-icon": {
"message": "Pictogram uploaden"
},
@@ -3539,24 +3539,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Legg til en omdirings-URI"
},
"settings.applications.button.cancel": {
"message": "Avbryt"
},
"settings.applications.button.create": {
"message": "Lag app"
},
"settings.applications.button.delete": {
"message": "Slett"
},
"settings.applications.button.edit": {
"message": "Regiser"
},
"settings.applications.button.new": {
"message": "Ny søknad"
},
"settings.applications.button.save-changes": {
"message": "Lagre endringer"
},
"settings.applications.button.upload-icon": {
"message": "Last opp ikon"
},
@@ -4622,24 +4622,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Dodaj URI przekierowywania"
},
"settings.applications.button.cancel": {
"message": "Anuluj"
},
"settings.applications.button.create": {
"message": "Stwórz aplikację"
},
"settings.applications.button.delete": {
"message": "Usuń"
},
"settings.applications.button.edit": {
"message": "Edytuj"
},
"settings.applications.button.new": {
"message": "Nowa aplikacja"
},
"settings.applications.button.save-changes": {
"message": "Zapisz zmiany"
},
"settings.applications.button.upload-icon": {
"message": "Prześlij ikonę"
},
@@ -4709,24 +4709,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Adicionar URI de redirecionamento"
},
"settings.applications.button.cancel": {
"message": "Cancelar"
},
"settings.applications.button.create": {
"message": "Criar app"
},
"settings.applications.button.delete": {
"message": "Excluir"
},
"settings.applications.button.edit": {
"message": "Editar"
},
"settings.applications.button.new": {
"message": "Novo aplicativo"
},
"settings.applications.button.save-changes": {
"message": "Salvar alterações"
},
"settings.applications.button.upload-icon": {
"message": "Enviar ícone"
},
@@ -3350,24 +3350,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Adicionar um endereço de redirecionamento"
},
"settings.applications.button.cancel": {
"message": "Cancelar"
},
"settings.applications.button.create": {
"message": "Criar app"
},
"settings.applications.button.delete": {
"message": "Apagar"
},
"settings.applications.button.edit": {
"message": "Editar"
},
"settings.applications.button.new": {
"message": "Nova aplicação"
},
"settings.applications.button.save-changes": {
"message": "Guardar alterações"
},
"settings.applications.button.upload-icon": {
"message": "Carregar ícone"
},
@@ -1814,21 +1814,12 @@
"settings.applications.about": {
"message": "Despre"
},
"settings.applications.button.cancel": {
"message": "Anuleaza"
},
"settings.applications.button.create": {
"message": "Creaza aplicația"
},
"settings.applications.button.delete": {
"message": "Sterge"
},
"settings.applications.button.edit": {
"message": "Editeaza"
},
"settings.applications.button.save-changes": {
"message": "Salvează schimbările"
},
"settings.applications.client-id": {
"message": "ID Client"
},
@@ -4655,24 +4655,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Добавить адрес перенаправления"
},
"settings.applications.button.cancel": {
"message": "Отмена"
},
"settings.applications.button.create": {
"message": "Создать приложение"
},
"settings.applications.button.delete": {
"message": "Удалить"
},
"settings.applications.button.edit": {
"message": "Изменить"
},
"settings.applications.button.new": {
"message": "Новое приложение"
},
"settings.applications.button.save-changes": {
"message": "Сохранить изменения"
},
"settings.applications.button.upload-icon": {
"message": "Загрузить иконку"
},
@@ -3713,24 +3713,15 @@
"settings.applications.button.add-more": {
"message": "Lägg till mer"
},
"settings.applications.button.cancel": {
"message": "Avbryt"
},
"settings.applications.button.create": {
"message": "Skapa app"
},
"settings.applications.button.delete": {
"message": "Ta bort"
},
"settings.applications.button.edit": {
"message": "Redigera"
},
"settings.applications.button.new": {
"message": "Ny applikation"
},
"settings.applications.button.save-changes": {
"message": "Spara ändringar"
},
"settings.applications.button.upload-icon": {
"message": "Ladda upp ikon"
},
@@ -4118,24 +4118,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Yönlendirme URI'ları ekle"
},
"settings.applications.button.cancel": {
"message": "İptal"
},
"settings.applications.button.create": {
"message": "Uygulama oluştur"
},
"settings.applications.button.delete": {
"message": "Sil"
},
"settings.applications.button.edit": {
"message": "Düzenle"
},
"settings.applications.button.new": {
"message": "Yeni uygulama"
},
"settings.applications.button.save-changes": {
"message": "Değişiklikleri kaydet"
},
"settings.applications.button.upload-icon": {
"message": "Simge yükle"
},
@@ -4631,24 +4631,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Додати переадресацію URI"
},
"settings.applications.button.cancel": {
"message": "Скасувати"
},
"settings.applications.button.create": {
"message": "Створити застосунок"
},
"settings.applications.button.delete": {
"message": "Видалити"
},
"settings.applications.button.edit": {
"message": "Редагувати"
},
"settings.applications.button.new": {
"message": "Новий застосунок"
},
"settings.applications.button.save-changes": {
"message": "Зберегти зміни"
},
"settings.applications.button.upload-icon": {
"message": "Завантажити значок"
},
@@ -3818,24 +3818,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Thêm URI chuyển hướng"
},
"settings.applications.button.cancel": {
"message": "Huỷ"
},
"settings.applications.button.create": {
"message": "Tạo ứng dụng"
},
"settings.applications.button.delete": {
"message": "Xoá"
},
"settings.applications.button.edit": {
"message": "Chỉnh sửa"
},
"settings.applications.button.new": {
"message": "Ứng dụng mới"
},
"settings.applications.button.save-changes": {
"message": "Lưu thay đổi"
},
"settings.applications.button.upload-icon": {
"message": "Tải lên biểu tượng"
},
@@ -4709,24 +4709,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "添加重定向 URI"
},
"settings.applications.button.cancel": {
"message": "取消"
},
"settings.applications.button.create": {
"message": "新建 App"
},
"settings.applications.button.delete": {
"message": "删除"
},
"settings.applications.button.edit": {
"message": "编辑"
},
"settings.applications.button.new": {
"message": "新建应用程序"
},
"settings.applications.button.save-changes": {
"message": "保存更改"
},
"settings.applications.button.upload-icon": {
"message": "上传图标"
},
@@ -4709,24 +4709,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "新增重新導向 URI"
},
"settings.applications.button.cancel": {
"message": "取消"
},
"settings.applications.button.create": {
"message": "建立應用程式"
},
"settings.applications.button.delete": {
"message": "刪除"
},
"settings.applications.button.edit": {
"message": "編輯"
},
"settings.applications.button.new": {
"message": "新的應用程式"
},
"settings.applications.button.save-changes": {
"message": "儲存變更"
},
"settings.applications.button.upload-icon": {
"message": "上傳圖示"
},
@@ -485,9 +485,9 @@ async function saveEvent() {
const payload = buildEventPayload()
if (modalMode.value === 'edit' && editingEventId.value !== null) {
await client.labrinth.analytics_v3.editEvent(editingEventId.value, payload)
await client.labrinth.analytics_internal.editEvent(editingEventId.value, payload)
} else {
await client.labrinth.analytics_v3.createEvent(payload)
await client.labrinth.analytics_internal.createEvent(payload)
}
await queryClient.invalidateQueries({ queryKey: analyticsEventsQueryKey })
@@ -528,7 +528,7 @@ async function deleteEvent(eventId: Labrinth.Analytics.v3.AnalyticsEventId) {
setDeletingEvent(eventId, true)
try {
await client.labrinth.analytics_v3.deleteEvent(eventId)
await client.labrinth.analytics_internal.deleteEvent(eventId)
await queryClient.invalidateQueries({ queryKey: analyticsEventsQueryKey })
addNotification({
title: 'Analytics event deleted',
@@ -581,7 +581,7 @@ function commitAnnouncementUrl() {
committedAnnouncementUrl.value = form.value.announcementUrl
}
function buildEventPayload(): Labrinth.Analytics.v3.AnalyticsEventUpsert {
function buildEventPayload(): Labrinth.Analytics.Internal.AnalyticsEventUpsert {
const selectedRange = getEventFormDateRange()
if (!selectedRange) {
throw new Error('Select a valid start and end date')
@@ -7,10 +7,10 @@
:proceed-label="formatMessage(messages.deleteConfirmButton)"
@proceed="removeApp(editingId)"
/>
<Modal ref="appModal" :header="formatMessage(messages.modalHeader)">
<div class="universal-modal">
<label for="app-name"
><span class="label__title">{{ formatMessage(messages.nameLabel) }}</span>
<NewModal ref="appModal" :header="formatMessage(messages.modalHeader)" width="40rem" scrollable>
<div class="flex flex-col">
<label for="app-name" class="m-0 mb-2 text-lg font-semibold text-contrast">
{{ formatMessage(messages.nameLabel) }}
</label>
<StyledInput
id="app-name"
@@ -19,8 +19,12 @@
autocomplete="off"
:placeholder="formatMessage(messages.namePlaceholder)"
/>
<label v-if="editingId" for="app-icon"
><span class="label__title">{{ formatMessage(messages.iconLabel) }}</span>
<label
v-if="editingId"
for="app-icon"
class="mb-2 mt-4 text-lg font-semibold text-contrast"
>
{{ formatMessage(messages.iconLabel) }}
</label>
<div v-if="editingId" class="icon-submission">
<Avatar size="md" :src="icon" />
@@ -36,8 +40,8 @@
</FileInput>
</ButtonStyled>
</div>
<label v-if="editingId" for="app-url">
<span class="label__title">{{ formatMessage(messages.urlLabel) }}</span>
<label v-if="editingId" for="app-url" class="mb-2 mt-4 text-lg font-semibold text-contrast">
{{ formatMessage(messages.urlLabel) }}
</label>
<StyledInput
v-if="editingId"
@@ -48,8 +52,12 @@
autocomplete="off"
:placeholder="formatMessage(messages.urlPlaceholder)"
/>
<label v-if="editingId" for="app-description">
<span class="label__title">{{ formatMessage(messages.descriptionLabel) }}</span>
<label
v-if="editingId"
for="app-description"
class="mb-2 mt-4 text-lg font-semibold text-contrast"
>
{{ formatMessage(messages.descriptionLabel) }}
</label>
<StyledInput
v-if="editingId"
@@ -61,15 +69,12 @@
:placeholder="formatMessage(messages.descriptionPlaceholder)"
input-class="h-24 resize-y"
/>
<label for="app-scopes"
><span class="label__title">{{ formatMessage(messages.scopesLabel) }}</span>
<label for="app-scopes" class="mb-2 mt-4 text-lg font-semibold text-contrast">
{{ formatMessage(messages.scopesLabel) }}
</label>
<div
id="app-scopes"
class="scope-items mt-2 grid grid-cols-1 gap-x-6 gap-y-4 min-[600px]:grid-cols-2"
>
<div id="app-scopes" class="scope-items grid grid-cols-1 gap-6 min-[600px]:grid-cols-2">
<div v-for="category in scopeCategories" :key="category.name" class="flex flex-col gap-2">
<h4 class="m-0 border-b border-divider pb-1 text-base font-bold text-contrast">
<h4 class="m-0 text-base font-medium text-primary">
{{ category.name }}
</h4>
<div class="flex flex-col gap-2">
@@ -83,8 +88,8 @@
</div>
</div>
</div>
<label for="app-redirect-uris" class="mt-4"
><span class="label__title">{{ formatMessage(messages.redirectUrisLabel) }}</span>
<label for="app-redirect-uris" class="mb-2 mt-4 text-lg font-semibold text-contrast">
{{ formatMessage(messages.redirectUrisLabel) }}
</label>
<div class="uri-input-list">
<div v-for="(_, index) in redirectUris" :key="index">
@@ -93,6 +98,7 @@
v-model="redirectUris[index]"
:maxlength="2048"
type="url"
class="w-80"
autocomplete="off"
:placeholder="formatMessage(messages.redirectUriPlaceholder)"
/>
@@ -116,18 +122,20 @@
</ButtonStyled>
</div>
</div>
</div>
<div class="submit-row input-group push-right">
<template #actions>
<div class="flex justify-end gap-2 p-2">
<ButtonStyled>
<button @click="$refs.appModal.hide()">
<XIcon />
{{ formatMessage(messages.cancel) }}
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="editingId" color="brand">
<button :disabled="!canSubmit" @click="editApp">
<SaveIcon />
{{ formatMessage(messages.saveChanges) }}
{{ formatMessage(commonMessages.saveChangesButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-else color="brand">
@@ -137,8 +145,8 @@
</button>
</ButtonStyled>
</div>
</div>
</Modal>
</template>
</NewModal>
<div class="header__row">
<div class="header__title">
@@ -220,7 +228,7 @@
"
>
<EditIcon />
{{ formatMessage(messages.edit) }}
{{ formatMessage(commonMessages.editButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
@@ -255,6 +263,7 @@ import {
injectModrinthClient,
injectNotificationManager,
IntlFormatted,
NewModal,
normalizeChildren,
StyledInput,
useFormatDateTime,
@@ -262,7 +271,6 @@ import {
} from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import Modal from '~/components/ui/Modal.vue'
import {
getScopeValue,
hasScope,
@@ -355,14 +363,6 @@ const messages = defineMessages({
id: 'settings.applications.button.add-redirect-uri',
defaultMessage: 'Add a redirect URI',
},
cancel: {
id: 'settings.applications.button.cancel',
defaultMessage: 'Cancel',
},
saveChanges: {
id: 'settings.applications.button.save-changes',
defaultMessage: 'Save changes',
},
createApp: {
id: 'settings.applications.button.create',
defaultMessage: 'Create app',
@@ -396,10 +396,6 @@ const messages = defineMessages({
id: 'settings.applications.created-on',
defaultMessage: 'Created on {date}',
},
edit: {
id: 'settings.applications.button.edit',
defaultMessage: 'Edit',
},
delete: {
id: 'settings.applications.button.delete',
defaultMessage: 'Delete',
+19 -5
View File
@@ -62,7 +62,13 @@
<label for="pat-expires">
<span class="font-semibold">{{ formatMessage(createModalMessages.expiresLabel) }}</span>
</label>
<DatePicker id="pat-expires" v-model="expires" show-today wrapper-class="w-full" />
<DatePicker
id="pat-expires"
v-model="expires"
:min-date="minimumPatExpiry"
show-today
wrapper-class="w-full"
/>
</div>
<div class="ml-auto mt-4 flex gap-2">
@@ -73,13 +79,13 @@
</button>
</ButtonStyled>
<ButtonStyled v-if="editPatId !== null" color="brand">
<button :disabled="loading || !name || !expires" @click="editPat">
<button :disabled="loading || !name || !isExpiryInFuture" @click="editPat">
<SaveIcon />
{{ formatMessage(commonMessages.saveChangesButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-else color="brand">
<button :disabled="loading || !name || !expires" @click="createPat">
<button :disabled="loading || !name || !isExpiryInFuture" @click="createPat">
<PlusIcon />
{{ formatMessage(createModalMessages.action) }}
</button>
@@ -123,8 +129,8 @@
<strong>{{ pat.name }}</strong>
</div>
<div>
<template v-if="pat.access_token">
<CopyCode :text="pat.access_token" />
<template v-if="createdPatTokens[pat.id] || pat.access_token">
<CopyCode :text="createdPatTokens[pat.id] || pat.access_token" />
</template>
<template v-else>
<span v-tooltip="pat.last_used ? formatDateTime(pat.last_used) : null">
@@ -331,12 +337,17 @@ useHead({
const data = useNuxtApp()
const { scopesToLabels } = useScopes()
const patModal = ref()
const minimumPatExpiry = data.$dayjs().add(1, 'day').format('YYYY-MM-DD')
const editPatId = ref(null)
const name = ref(null)
const scopesVal = ref(BigInt(0))
const expires = ref(null)
const createdPatTokens = ref({})
const isExpiryInFuture = computed(
() => expires.value && data.$dayjs(expires.value).isAfter(data.$dayjs(), 'day'),
)
const deletePatIndex = ref(null)
@@ -415,6 +426,9 @@ async function createPat() {
scopes: Number(scopesVal.value),
expires: data.$dayjs(expires.value).toISOString(),
})
if (res.access_token) {
createdPatTokens.value[res.id] = res.access_token
}
queryClient.setQueryData(['pat'], (old) => [...(old || []), res])
patModal.value.hide()
} catch (err) {
+1 -1
View File
@@ -11,7 +11,7 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates dumb-init curl \
&& rm -rf /var/lib/apt/lists/*
COPY labrinth /labrinth/labrinth
COPY --chmod=0755 labrinth /labrinth/labrinth
COPY migrations /labrinth/migrations
COPY assets /labrinth/assets
+3
View File
@@ -168,6 +168,9 @@ pub async fn update_bank_balances(pool: PgPool) -> eyre::Result<()> {
pub async fn run_migrations() -> eyre::Result<()> {
database::check_for_migrations().await?;
crate::clickhouse::run_migrations()
.await
.wrap_err("failed to run ClickHouse migrations")?;
Ok(())
}
+43 -22
View File
@@ -19,29 +19,44 @@ pub async fn init_client() -> clickhouse::error::Result<clickhouse::Client> {
pub async fn init_client_with_database(
database: &str,
) -> clickhouse::error::Result<clickhouse::Client> {
const MINECRAFT_JAVA_SERVER_PINGS: &str = server_ping::CLICKHOUSE_TABLE;
Ok(connect()?.with_database(database))
}
let client = {
let https_connector = HttpsConnectorBuilder::new()
.with_native_roots()?
.https_or_http()
.enable_all_versions()
.build();
let hyper_client =
hyper_util::client::legacy::Client::builder(TokioExecutor::new())
.build(https_connector);
fn connect() -> clickhouse::error::Result<clickhouse::Client> {
let https_connector = HttpsConnectorBuilder::new()
.with_native_roots()?
.https_or_http()
.enable_all_versions()
.build();
let hyper_client =
hyper_util::client::legacy::Client::builder(TokioExecutor::new())
.build(https_connector);
clickhouse::Client::with_http_client(hyper_client)
.with_url(&ENV.CLICKHOUSE_URL)
.with_user(&ENV.CLICKHOUSE_USER)
.with_password(&ENV.CLICKHOUSE_PASSWORD)
.with_validation(false)
};
Ok(clickhouse::Client::with_http_client(hyper_client)
.with_url(&ENV.CLICKHOUSE_URL)
.with_user(&ENV.CLICKHOUSE_USER)
.with_password(&ENV.CLICKHOUSE_PASSWORD)
.with_validation(false))
}
client
#[cfg(feature = "test")]
pub async fn create_database(database: &str) -> clickhouse::error::Result<()> {
connect()?
.query(&format!("CREATE DATABASE IF NOT EXISTS {database}"))
.execute()
.await?;
.await
}
pub async fn run_migrations() -> clickhouse::error::Result<()> {
run_migrations_on_database(&ENV.CLICKHOUSE_DATABASE).await
}
pub async fn run_migrations_on_database(
database: &str,
) -> clickhouse::error::Result<()> {
const MINECRAFT_JAVA_SERVER_PINGS: &str = server_ping::CLICKHOUSE_TABLE;
let client = connect()?;
let clickhouse_replicated = ENV.CLICKHOUSE_REPLICATED;
let cluster_line = if clickhouse_replicated {
@@ -79,9 +94,10 @@ pub async fn init_client_with_database(
ip IPv6,
country String,
user_agent String,
headers Array(Tuple(String, String))
headers Array(Tuple(String, String)) DEFAULT [] TTL toDateTime(recorded) + INTERVAL 60 DAY
)
ENGINE = {engine}
PARTITION BY toYYYYMM(recorded)
{ttl}
PRIMARY KEY (project_id, recorded, ip)
SETTINGS index_granularity = 8192
@@ -106,9 +122,10 @@ pub async fn init_client_with_database(
ip IPv6,
country String,
user_agent String,
headers Array(Tuple(String, String))
headers Array(Tuple(String, String)) DEFAULT [] TTL toDateTime(recorded) + INTERVAL 60 DAY
)
ENGINE = {engine}
PARTITION BY toYYYYMM(recorded)
{ttl}
PRIMARY KEY (project_id, recorded, ip)
SETTINGS index_granularity = 8192
@@ -134,6 +151,7 @@ pub async fn init_client_with_database(
parent UInt64
)
ENGINE = {engine}
PARTITION BY toYYYYMM(recorded)
{ttl}
PRIMARY KEY (project_id, recorded, user_id)
SETTINGS index_granularity = 8192
@@ -156,9 +174,10 @@ pub async fn init_client_with_database(
ip IPv6,
country String,
user_agent String,
headers Array(Tuple(String, String))
headers Array(Tuple(String, String)) DEFAULT [] TTL toDateTime(recorded) + INTERVAL 60 DAY
)
ENGINE = {engine}
PARTITION BY toYYYYMM(recorded)
{ttl}
PRIMARY KEY (affiliate_code_id, recorded)
SETTINGS index_granularity = 8192
@@ -184,6 +203,7 @@ pub async fn init_client_with_database(
players_max Nullable(Int32)
)
ENGINE = {engine}
PARTITION BY toYYYYMM(recorded)
{ttl}
PRIMARY KEY (project_id, recorded)
SETTINGS index_granularity = 8192
@@ -203,6 +223,7 @@ pub async fn init_client_with_database(
minecraft_uuid UUID
)
ENGINE = {engine}
PARTITION BY toYYYYMM(recorded)
{ttl}
PRIMARY KEY (project_id, recorded)
SETTINGS index_granularity = 8192
@@ -264,5 +285,5 @@ pub async fn init_client_with_database(
.execute()
.await?;
Ok(client.with_database(database))
Ok(())
}
+1 -1
View File
@@ -98,7 +98,7 @@ async fn app() -> std::io::Result<()> {
info!("Starting labrinth on {}", &ENV.BIND_ADDR);
if !args.no_migrations {
database::check_for_migrations()
labrinth::background_task::run_migrations()
.await
.expect("An error occurred while running migrations.");
}
@@ -0,0 +1,192 @@
use actix_web::{HttpRequest, delete, patch, post, web};
use chrono::{DateTime, Utc};
use eyre::eyre;
use serde::{Deserialize, Serialize};
use xredis::RedisPool;
use crate::{
auth::get_user_from_headers,
database::{
PgPool,
models::{
DBAnalyticsEvent, DBAnalyticsEventId, generate_analytics_event_id,
},
},
models::{
ids::AnalyticsEventId,
pats::Scopes,
v3::analytics_event::{AnalyticsEvent, AnalyticsEventMeta},
},
queue::session::AuthQueue,
routes::ApiError,
util::error::Context,
};
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(analytics_event_create)
.service(analytics_event_edit)
.service(analytics_event_delete);
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct AnalyticsEventUpsert {
#[serde(flatten)]
pub meta: AnalyticsEventMeta,
pub starts: DateTime<Utc>,
pub ends: DateTime<Utc>,
}
/// Create an analytics event.
#[utoipa::path(
context_path = "/analytics-event",
tag = "analytics events", responses((status = OK, body = AnalyticsEvent))
)]
#[post("")]
pub async fn analytics_event_create(
req: HttpRequest,
event: web::Json<AnalyticsEventUpsert>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<web::Json<AnalyticsEvent>, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::empty(),
)
.await?
.1;
if !user.role.is_admin() {
return Err(ApiError::Auth(eyre!(
"you do not have permission to manage analytics events"
)));
}
let mut transaction = pool
.begin()
.await
.wrap_internal_err("failed to begin transaction")?;
let id = generate_analytics_event_id(&mut transaction)
.await
.wrap_internal_err("failed to generate analytics event ID")?;
let event = DBAnalyticsEvent {
id,
meta: event.meta.clone(),
starts: event.starts,
ends: event.ends,
};
event
.insert(&mut transaction)
.await
.wrap_internal_err("failed to insert analytics event")?;
transaction
.commit()
.await
.wrap_internal_err("failed to commit transaction")?;
DBAnalyticsEvent::clear_cache(&redis)
.await
.wrap_internal_err("failed to clear analytics event cache")?;
Ok(web::Json(event.into()))
}
/// Update an analytics event.
#[utoipa::path(
context_path = "/analytics-event",
tag = "analytics events", responses((status = OK, body = AnalyticsEvent))
)]
#[patch("/{id}")]
pub async fn analytics_event_edit(
req: HttpRequest,
id: web::Path<(AnalyticsEventId,)>,
event: web::Json<AnalyticsEventUpsert>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<web::Json<AnalyticsEvent>, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::empty(),
)
.await?
.1;
if !user.role.is_admin() {
return Err(ApiError::Auth(eyre!(
"you do not have permission to manage analytics events"
)));
}
let event = DBAnalyticsEvent {
id: DBAnalyticsEventId::from(id.into_inner().0),
meta: event.meta.clone(),
starts: event.starts,
ends: event.ends,
};
let updated = event
.update(&**pool)
.await
.wrap_internal_err("failed to update analytics event")?;
if !updated {
return Err(ApiError::NotFound);
}
DBAnalyticsEvent::clear_cache(&redis)
.await
.wrap_internal_err("failed to clear analytics event cache")?;
Ok(web::Json(event.into()))
}
/// Delete an analytics event.
#[utoipa::path(
context_path = "/analytics-event",
tag = "analytics events", responses((status = NO_CONTENT))
)]
#[delete("/{id}")]
pub async fn analytics_event_delete(
req: HttpRequest,
id: web::Path<(AnalyticsEventId,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<(), ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::empty(),
)
.await?
.1;
if !user.role.is_admin() {
return Err(ApiError::Auth(eyre!(
"you do not have permission to manage analytics events"
)));
}
let deleted = DBAnalyticsEvent::remove(
DBAnalyticsEventId::from(id.into_inner().0),
&**pool,
)
.await
.wrap_internal_err("failed to delete analytics event")?;
if !deleted {
return Err(ApiError::NotFound);
}
DBAnalyticsEvent::clear_cache(&redis)
.await
.wrap_internal_err("failed to clear analytics event cache")?;
Ok(())
}
+8 -9
View File
@@ -1,5 +1,6 @@
pub mod admin;
pub mod affiliate;
pub mod analytics_event;
pub mod attribution;
pub mod billing;
pub mod blocked_users;
@@ -35,6 +36,10 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.configure(flows::config)
.configure(pats::config)
.configure(oauth_clients::config)
.service(
web::scope("/analytics-event")
.configure(analytics_event::config),
)
.service(web::scope("/moderation").configure(moderation::config))
.service(web::scope("/affiliate").configure(affiliate::config))
.service(web::scope("/campaign").configure(campaign::config))
@@ -50,11 +55,6 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.configure(medal::config)
.configure(mural::config)
.configure(statuses::config),
)
.service(
web::scope("/v3/analytics-event")
.wrap(default_cors())
.configure(super::v3::analytics_event::config),
);
}
@@ -180,10 +180,9 @@ pub fn config(cfg: &mut web::ServiceConfig) {
medal::redeem,
mural::get_bank_details,
statuses::ws_init,
super::v3::analytics_event::analytics_events_get,
super::v3::analytics_event::analytics_event_create,
super::v3::analytics_event::analytics_event_edit,
super::v3::analytics_event::analytics_event_delete,
analytics_event::analytics_event_create,
analytics_event::analytics_event_edit,
analytics_event::analytics_event_delete,
),
modifiers(&InternalPathModifier, &SecurityAddon)
)]
+6 -187
View File
@@ -1,48 +1,22 @@
use actix_web::{HttpRequest, delete, get, patch, post, web};
use chrono::{DateTime, Utc};
use eyre::eyre;
use serde::{Deserialize, Serialize};
use actix_web::{get, web};
use xredis::RedisPool;
use crate::{
auth::get_user_from_headers,
database::{
PgPool,
models::{
DBAnalyticsEvent, DBAnalyticsEventId, generate_analytics_event_id,
},
},
models::{
ids::AnalyticsEventId,
pats::Scopes,
v3::analytics_event::{AnalyticsEvent, AnalyticsEventMeta},
},
queue::session::AuthQueue,
database::{PgPool, models::DBAnalyticsEvent},
models::v3::analytics_event::AnalyticsEvent,
routes::ApiError,
util::error::Context,
};
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(analytics_events_get)
.service(analytics_event_create)
.service(analytics_event_edit)
.service(analytics_event_delete);
cfg.service(analytics_events_get);
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct AnalyticsEventUpsert {
#[serde(flatten)]
pub meta: AnalyticsEventMeta,
pub starts: DateTime<Utc>,
pub ends: DateTime<Utc>,
}
/// List analytics events.
/// List analytics events.
#[utoipa::path(
context_path = "/v3/analytics-event",
tag = "v3 analytics", responses((status = OK, body = Vec<AnalyticsEvent>))
)]
#[get("")]
#[get("/analytics-event")]
pub async fn analytics_events_get(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
@@ -56,158 +30,3 @@ pub async fn analytics_events_get(
Ok(web::Json(events))
}
/// Create an analytics event.
#[utoipa::path(
context_path = "/v3/analytics-event",
tag = "v3 analytics", responses((status = OK, body = AnalyticsEvent))
)]
#[post("")]
pub async fn analytics_event_create(
req: HttpRequest,
event: web::Json<AnalyticsEventUpsert>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<web::Json<AnalyticsEvent>, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::empty(),
)
.await?
.1;
if !user.role.is_admin() {
return Err(ApiError::Auth(eyre!(
"you do not have permission to manage analytics events"
)));
}
let mut transaction = pool
.begin()
.await
.wrap_internal_err("failed to begin transaction")?;
let id = generate_analytics_event_id(&mut transaction)
.await
.wrap_internal_err("failed to generate analytics event ID")?;
let event = DBAnalyticsEvent {
id,
meta: event.meta.clone(),
starts: event.starts,
ends: event.ends,
};
event
.insert(&mut transaction)
.await
.wrap_internal_err("failed to insert analytics event")?;
transaction
.commit()
.await
.wrap_internal_err("failed to commit transaction")?;
DBAnalyticsEvent::clear_cache(&redis)
.await
.wrap_internal_err("failed to clear analytics event cache")?;
Ok(web::Json(event.into()))
}
/// Update an analytics event.
#[utoipa::path(
context_path = "/v3/analytics-event",
tag = "v3 analytics", responses((status = OK, body = AnalyticsEvent))
)]
#[patch("/{id}")]
pub async fn analytics_event_edit(
req: HttpRequest,
id: web::Path<(AnalyticsEventId,)>,
event: web::Json<AnalyticsEventUpsert>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<web::Json<AnalyticsEvent>, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::empty(),
)
.await?
.1;
if !user.role.is_admin() {
return Err(ApiError::Auth(eyre!(
"you do not have permission to manage analytics events"
)));
}
let event = DBAnalyticsEvent {
id: DBAnalyticsEventId::from(id.into_inner().0),
meta: event.meta.clone(),
starts: event.starts,
ends: event.ends,
};
let updated = event
.update(&**pool)
.await
.wrap_internal_err("failed to update analytics event")?;
if !updated {
return Err(ApiError::NotFound);
}
DBAnalyticsEvent::clear_cache(&redis)
.await
.wrap_internal_err("failed to clear analytics event cache")?;
Ok(web::Json(event.into()))
}
/// Delete an analytics event.
#[utoipa::path(
context_path = "/v3/analytics-event",
tag = "v3 analytics", responses((status = NO_CONTENT))
)]
#[delete("/{id}")]
pub async fn analytics_event_delete(
req: HttpRequest,
id: web::Path<(AnalyticsEventId,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<(), ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::empty(),
)
.await?
.1;
if !user.role.is_admin() {
return Err(ApiError::Auth(eyre!(
"you do not have permission to manage analytics events"
)));
}
let deleted = DBAnalyticsEvent::remove(
DBAnalyticsEventId::from(id.into_inner().0),
&**pool,
)
.await
.wrap_internal_err("failed to delete analytics event")?;
if !deleted {
return Err(ApiError::NotFound);
}
DBAnalyticsEvent::clear_cache(&redis)
.await
.wrap_internal_err("failed to clear analytics event cache")?;
Ok(())
}
+2
View File
@@ -49,6 +49,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/v3")
.wrap(default_cors())
.configure(analytics_event::config)
.configure(limits::config)
.configure(collections::config)
.configure(images::config)
@@ -80,6 +81,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
description = include_str!("../../api_v3_description.md"),
),
paths(
analytics_event::analytics_events_get,
analytics_get::fetch_analytics,
analytics_get::facets::fetch_facets,
analytics_get::old::playtimes_get,
+4
View File
@@ -37,6 +37,10 @@ pub async fn setup(db: &database::TemporaryDatabase) -> LabrinthConfig {
let search_backend = db.search_backend.clone();
let file_host: Arc<dyn FileHost> = Arc::new(file_hosting::MockHost::new());
let file_host = web::Data::<dyn FileHost>::from(file_host);
clickhouse::create_database(&ENV.CLICKHOUSE_DATABASE)
.await
.unwrap();
clickhouse::run_migrations().await.unwrap();
let mut clickhouse = clickhouse::init_client().await.unwrap();
let stripe_client = stripe::Client::new(ENV.STRIPE_API_KEY.clone());
+2
View File
@@ -19,6 +19,7 @@ import { KyrosLogsV1Module } from './kyros/logs/v1'
import { KyrosUploadSessionsV1Module } from './kyros/upload-sessions/v1'
import { LabrinthVersionsV2Module, LabrinthVersionsV3Module } from './labrinth'
import { LabrinthAffiliateInternalModule } from './labrinth/affiliate/internal'
import { LabrinthAnalyticsInternalModule } from './labrinth/analytics/internal'
import { LabrinthAnalyticsV3Module } from './labrinth/analytics/v3'
import { LabrinthAttributionInternalModule } from './labrinth/attribution/internal'
import { LabrinthAuthInternalModule } from './labrinth/auth/internal'
@@ -97,6 +98,7 @@ export const MODULE_REGISTRY = {
kyros_logs_v1: KyrosLogsV1Module,
kyros_upload_sessions_v1: KyrosUploadSessionsV1Module,
labrinth_affiliate_internal: LabrinthAffiliateInternalModule,
labrinth_analytics_internal: LabrinthAnalyticsInternalModule,
labrinth_analytics_v3: LabrinthAnalyticsV3Module,
labrinth_auth_internal: LabrinthAuthInternalModule,
labrinth_auth_v2: LabrinthAuthV2Module,
@@ -0,0 +1,51 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthAnalyticsInternalModule extends AbstractModule {
public getModuleID(): string {
return 'labrinth_analytics_internal'
}
/**
* Create an analytics event.
* POST /_internal/analytics-event
*/
public async createEvent(
data: Labrinth.Analytics.Internal.AnalyticsEventUpsert,
): Promise<Labrinth.Analytics.v3.AnalyticsEvent> {
return this.client.request<Labrinth.Analytics.v3.AnalyticsEvent>('/analytics-event', {
api: 'labrinth',
version: 'internal',
method: 'POST',
body: data,
})
}
/**
* Edit an analytics event.
* PATCH /_internal/analytics-event/{id}
*/
public async editEvent(
id: Labrinth.Analytics.v3.AnalyticsEventId,
data: Labrinth.Analytics.Internal.AnalyticsEventUpsert,
): Promise<Labrinth.Analytics.v3.AnalyticsEvent> {
return this.client.request<Labrinth.Analytics.v3.AnalyticsEvent>(`/analytics-event/${id}`, {
api: 'labrinth',
version: 'internal',
method: 'PATCH',
body: data,
})
}
/**
* Delete an analytics event.
* DELETE /_internal/analytics-event/{id}
*/
public async deleteEvent(id: Labrinth.Analytics.v3.AnalyticsEventId): Promise<void> {
return this.client.request<void>(`/analytics-event/${id}`, {
api: 'labrinth',
version: 'internal',
method: 'DELETE',
})
}
}
@@ -70,47 +70,4 @@ export class LabrinthAnalyticsV3Module extends AbstractModule {
method: 'GET',
})
}
/**
* Create an analytics event.
* POST /v3/analytics-event
*/
public async createEvent(
data: Labrinth.Analytics.v3.AnalyticsEventUpsert,
): Promise<Labrinth.Analytics.v3.AnalyticsEvent> {
return this.client.request<Labrinth.Analytics.v3.AnalyticsEvent>('/analytics-event', {
api: 'labrinth',
version: 3,
method: 'POST',
body: data,
})
}
/**
* Edit an analytics event.
* PATCH /v3/analytics-event/{id}
*/
public async editEvent(
id: Labrinth.Analytics.v3.AnalyticsEventId,
data: Labrinth.Analytics.v3.AnalyticsEventUpsert,
): Promise<Labrinth.Analytics.v3.AnalyticsEvent> {
return this.client.request<Labrinth.Analytics.v3.AnalyticsEvent>(`/analytics-event/${id}`, {
api: 'labrinth',
version: 3,
method: 'PATCH',
body: data,
})
}
/**
* Delete an analytics event.
* DELETE /v3/analytics-event/{id}
*/
public async deleteEvent(id: Labrinth.Analytics.v3.AnalyticsEventId): Promise<void> {
return this.client.request<void>(`/analytics-event/${id}`, {
api: 'labrinth',
version: 3,
method: 'DELETE',
})
}
}
@@ -1,3 +1,4 @@
export * from './analytics/internal'
export * from './analytics/v3'
export * from './attribution/internal'
export * from './auth/internal'
@@ -463,6 +463,16 @@ export namespace Labrinth {
}
export namespace Analytics {
export namespace Internal {
export type AnalyticsEventUpsert = {
announcement_url: string | null
for_metric_kind: v3.AnalyticsEventMetricKind[] | null
title: string
ends: string
starts: string
}
}
export namespace v3 {
export type AnalyticsEventId = number
export type AnalyticsEventMetricKind = 'views' | 'revenue' | 'downloads' | 'playtime'
@@ -476,14 +486,6 @@ export namespace Labrinth {
starts: string
}
export type AnalyticsEventUpsert = {
announcement_url: string | null
for_metric_kind: AnalyticsEventMetricKind[] | null
title: string
ends: string
starts: string
}
export type FetchRequest = {
time_range: TimeRange
return_metrics: ReturnMetrics
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n\t\tUPDATE install_jobs\n\t\tSET instance_id = ?, state = ?, modified = ?\n\t\tWHERE id = ?\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "15b4f72d367d329690f5daddafbdf0e51a285e35404053d62492e8df5fc7132f"
}
@@ -77,6 +77,11 @@ pub async fn remove(instance_id: &str) -> crate::Result<()> {
let instance =
instance_rows::get_instance_display_info(instance_id, &state.pool)
.await?;
crate::install::runner::cancel_jobs_for_instance_deletion(
instance_id,
&state,
)
.await?;
crate::state::remove_instance(instance_id, &state).await?;
if let Some(instance) = instance {
@@ -836,11 +836,29 @@ async fn config_bundle_bytes(
entries: &BTreeMap<String, Vec<u8>>,
) -> crate::Result<Vec<u8>> {
if entries.len() > MAX_CONFIG_BUNDLE_ENTRIES {
return Err(crate::ErrorKind::InputError(
"Shared instance config bundle contains too many entries"
.to_string(),
)
.into());
let mut folder_entry_counts = HashMap::new();
for path in entries.keys() {
if let Some((folder, _)) = path.split_once('/') {
*folder_entry_counts.entry(folder).or_insert(0_usize) += 1;
}
}
if let Some((folder, count)) = folder_entry_counts
.into_iter()
.filter(|(_, count)| *count > MAX_CONFIG_BUNDLE_ENTRIES)
.max_by_key(|(_, count)| *count)
{
return Err(crate::ErrorKind::InputError(format!(
"The \"{folder}\" config folder has too many files to share ({count}; maximum {MAX_CONFIG_BUNDLE_ENTRIES}). Select fewer files from this folder."
))
.into());
}
return Err(crate::ErrorKind::InputError(format!(
"Too many config files were selected to share ({}; maximum {MAX_CONFIG_BUNDLE_ENTRIES}). Select fewer files.",
entries.len()
))
.into());
}
let mut total_size = 0_u64;
for bytes in entries.values() {
+39
View File
@@ -281,6 +281,38 @@ async fn recover_interrupted_job(
if job.state.display.is_none() {
job.state.display = display_from_request(&job.state);
}
if let Some(instance_id) = target_instance_id(&job.state.target)
&& instance_rows::get_instance_by_id(instance_id, &state.pool)
.await?
.is_none()
{
let canceled_phase = job.state.progress.phase;
job.state.error = Some(InstallErrorView::from_message(
"canceled",
canceled_phase,
"Install canceled because the instance was deleted",
));
job.state.record_event(InstallJobEventKind::JobCanceled {
phase: canceled_phase,
});
if let Some(record) = store::finish_active(
job.id,
InstallJobStatus::Canceled,
&job.state,
state,
)
.await?
{
store::dismiss(job.id, state).await?;
clear_staging_dir(&job.state).await;
emit_install_job(&record.snapshot()).await?;
}
return Ok(());
}
let interrupted_phase = job.state.progress.phase;
job.state.record_event(InstallJobEventKind::Interrupted {
reason: InstallInterruptReason::AppClosed,
@@ -341,6 +373,13 @@ async fn recover_interrupted_job(
Ok(())
}
fn target_instance_id(target: &InstallTarget) -> Option<&str> {
match target {
InstallTarget::NewInstance { instance_id } => instance_id.as_deref(),
InstallTarget::ExistingInstance { instance_id } => Some(instance_id),
}
}
fn clear_deleted_new_instance_id(job_state: &mut InstallJobState) {
if matches!(job_state.cleanup, InstallCleanup::DeleteNewInstance { .. }) {
job_state.target = InstallTarget::NewInstance { instance_id: None };
+37
View File
@@ -295,6 +295,39 @@ pub async fn cancel_job(job_id: Uuid) -> crate::Result<InstallJobSnapshot> {
Ok(record.snapshot())
}
pub(crate) async fn cancel_jobs_for_instance_deletion(
instance_id: &str,
state: &State,
) -> crate::Result<()> {
for mut job in store::list_active_for_instance(instance_id, state).await? {
let canceled_phase = job.state.progress.phase;
job.state.error = Some(InstallErrorView::from_message(
"canceled",
canceled_phase,
"Install canceled because the instance was deleted",
));
job.state.record_event(InstallJobEventKind::JobCanceled {
phase: canceled_phase,
});
let Some(record) = store::finish_active(
job.id,
InstallJobStatus::Canceled,
&job.state,
state,
)
.await?
else {
continue;
};
store::dismiss(job.id, state).await?;
emit_install_job(&record.snapshot()).await?;
}
Ok(())
}
pub async fn dismiss_job(job_id: Uuid) -> crate::Result<()> {
let state = State::get().await?;
store::dismiss(job_id, &state).await
@@ -577,7 +610,11 @@ async fn run_job(job_id: Uuid) -> crate::Result<()> {
let result = Box::pin(run_request(job_id, &mut job_state, &state)).await;
if let Ok(record) = store::get_required(job_id, &state).await {
let status = record.status;
job_state = record.state;
if status != InstallJobStatus::Running {
return Ok(());
}
}
let result = match result {
+49 -18
View File
@@ -201,6 +201,17 @@ pub async fn list_interrupted_candidates(
Ok(deserialize_rows(rows))
}
pub async fn list_active_for_instance(
instance_id: &str,
app_state: &State,
) -> crate::Result<Vec<InstallJobRecord>> {
Ok(list_interrupted_candidates(app_state)
.await?
.into_iter()
.filter(|job| job.instance_id.as_deref() == Some(instance_id))
.collect())
}
pub async fn update_state(
id: Uuid,
state: &InstallJobState,
@@ -212,20 +223,30 @@ pub async fn update_state(
let id_value = id.to_string();
let modified = now.timestamp();
sqlx::query!(
let result = sqlx::query(
"
UPDATE install_jobs
SET instance_id = ?, state = ?, modified = ?
WHERE id = ?
SET
instance_id = (SELECT id FROM instances WHERE id = ?),
state = ?,
modified = ?
WHERE id = ? AND status IN ('queued', 'running')
",
instance_id,
json,
modified,
id_value,
)
.bind(instance_id)
.bind(json)
.bind(modified)
.bind(id_value)
.execute(&app_state.pool)
.await?;
if result.rows_affected() == 0 {
return Err(crate::ErrorKind::InputError(format!(
"Install job {id} is no longer active"
))
.into());
}
get_required(id, app_state).await
}
@@ -319,7 +340,12 @@ pub async fn finish_active(
let result = sqlx::query(
"
UPDATE install_jobs
SET instance_id = ?, status = ?, state = ?, modified = ?, finished = ?
SET
instance_id = (SELECT id FROM instances WHERE id = ?),
status = ?,
state = ?,
modified = ?,
finished = ?
WHERE id = ? AND status IN ('queued', 'running')
",
)
@@ -356,19 +382,24 @@ pub async fn complete_success(
let mut transaction = app_state.pool.begin().await?;
let job_result = sqlx::query(
"
"
UPDATE install_jobs
SET instance_id = ?, status = 'succeeded', state = ?, modified = ?, finished = ?
SET
instance_id = (SELECT id FROM instances WHERE id = ?),
status = 'succeeded',
state = ?,
modified = ?,
finished = ?
WHERE id = ? AND status = 'running'
",
)
.bind(&instance_id)
.bind(json)
.bind(now)
.bind(now)
.bind(id_value)
.execute(&mut *transaction)
.await?;
)
.bind(&instance_id)
.bind(json)
.bind(now)
.bind(now)
.bind(id_value)
.execute(&mut *transaction)
.await?;
if job_result.rows_affected() == 0 {
transaction.rollback().await?;
@@ -55,8 +55,12 @@ pub(crate) async fn create_instance(
None
};
let icon_path =
resolve_icon_path(input.icon_path.as_deref(), state).await?;
let icon_path = resolve_icon_path(
input.icon_path.as_deref(),
matches!(&input.link, InstanceLink::SharedInstance { .. }),
state,
)
.await?;
let now = Utc::now();
let instance_id = format!("local:{}", Uuid::new_v4());
let content_set_id = format!("content-set:{}", Uuid::new_v4());
@@ -168,6 +172,7 @@ async fn path_available(
async fn resolve_icon_path(
icon_path: Option<&str>,
ignore_missing_remote_icon: bool,
state: &State,
) -> crate::Result<Option<String>> {
let Some(icon) = icon_path else {
@@ -175,7 +180,7 @@ async fn resolve_icon_path(
};
let file = if icon.starts_with("https://") || icon.starts_with("http://") {
let bytes = fetch::fetch(
let bytes = match fetch::fetch(
icon,
None,
None,
@@ -183,7 +188,16 @@ async fn resolve_icon_path(
&state.fetch_semaphore,
&state.pool,
)
.await?;
.await
{
Ok(bytes) => bytes,
Err(error)
if ignore_missing_remote_icon && is_not_found_error(&error) =>
{
return Ok(None);
}
Err(error) => return Err(error),
};
crate::api::instance::cache_icon(bytes, state).await?
} else {
crate::api::instance::cache_icon_from_path(
@@ -196,6 +210,18 @@ async fn resolve_icon_path(
Ok(Some(file.to_string_lossy().to_string()))
}
fn is_not_found_error(error: &crate::Error) -> bool {
match error.raw.as_ref() {
crate::ErrorKind::FetchError(error) => {
error.status() == Some(reqwest::StatusCode::NOT_FOUND)
}
crate::ErrorKind::LabrinthError(error) => {
error.status == Some(reqwest::StatusCode::NOT_FOUND.as_u16())
}
_ => false,
}
}
fn content_source_kind(link: &InstanceLink) -> ContentSourceKind {
match link {
InstanceLink::Unmanaged => ContentSourceKind::Local,
+1
View File
@@ -65,6 +65,7 @@ pub enum FeatureFlag {
SkipNonEssentialWarnings,
AdvancedFiltersCollapsed,
AlwaysShowCopyDetails,
HideInstalledModpacks,
}
impl Settings {
+48 -13
View File
@@ -10,6 +10,41 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
{
date: `2026-07-31T06:10:23+00:00`,
product: 'web',
body: `## Fixed
- Fixed randomly getting signed out of Modrinth account due to random non-auth related errors.`,
},
{
date: `2026-07-29T21:32:06+00:00`,
product: 'app',
version: '0.17.3',
body: `## Added
- Added button to create a new instance on the Library page.
## Changed
- Added a toggle to hide modpacks that are already installed.
- Added tooltip to installation settings button in installed modpack card.
## Fixed
- Improved error when shared instances reach a config file limit.
- When pushing updates to shared instances, will now scroll to the top when entering a sub-page.
- Fixed instance installations getting stuck when the instance is deleted.
- Fixed error when failing to fetch a shared instance icon.
- Installing content from Discover content will now install the latest that matches the game version and loader filters, not the absolute latest.`,
},
{
date: `2026-07-29T21:32:06+00:00`,
product: 'web',
body: `## Changed
- Incomplete current day revenue hides that day's line segment instead of showing a dip to \$0.
- Updated the modal for creating OAuth applications.
## Fixed
- Fixed dependencies in project download modal could give dependency with wrong Minecraft version.
- Fixed PATS page new generated tokens invalidating in the same session.`,
},
{
date: `2026-07-28T23:36:10+00:00`,
product: 'web',
@@ -71,21 +106,21 @@ const VERSIONS: VersionEntry[] = [
date: `2026-07-28T20:40:11+00:00`,
product: 'web',
body: `## Added
- You can now block users on their profile page. This prevents the user from sending you invites to shared instances and Modrinth Hosting server panels.
- Added new social settings page, where you can manage users you have blocked and in the future set who can send you friend requests and invitations to shared instances and Modrinth Hosting server panels.
- You can now block users on their profile page. This prevents the user from sending you invites to shared instances and Modrinth Hosting server panels.
- Added new social settings page, where you can manage users you have blocked and in the future set who can send you friend requests and invitations to shared instances and Modrinth Hosting server panels.
## Changed
- Cleaned up the layout of and renamed the Public profile settings page to Profile settings.
- Updated "Advanced" toggle filter design to be the same as the other filters, just with only an exclude button as the primary action.
- Updated translations. Want to help translate Modrinth's website? [Click here](https://translate.modrinth.com)
## Changed
- Cleaned up the layout of and renamed the Public profile settings page to Profile settings.
- Updated "Advanced" toggle filter design to be the same as the other filters, just with only an exclude button as the primary action.
- Updated translations. Want to help translate Modrinth's website? [Click here](https://translate.modrinth.com)
## Fixed
- Fixed profile pictures still being deleted after resetting a pending removal in Profile settings.
- Fixed shared instance reports not showing up in the dashboard's Reports page.
- Fixed shared instance report emails saying "Unknown" rather than the shared instance's name.
- Fixed the settings page for Authorized apps being broken.
- Broken Retro theme colors
- Improved vertical alignment of status indicators in notifications.`,
## Fixed
- Fixed profile pictures still being deleted after resetting a pending removal in Profile settings.
- Fixed shared instance reports not showing up in the dashboard's Reports page.
- Fixed shared instance report emails saying "Unknown" rather than the shared instance's name.
- Fixed the settings page for Authorized apps being broken.
- Broken Retro theme colors
- Improved vertical alignment of status indicators in notifications.`,
},
{
date: `2026-07-28T20:40:11+00:00`,
@@ -198,6 +198,9 @@ async function nudge(): Promise<void> {
}
defineExpose({ nudge })
defineOptions({
inheritAttrs: false,
})
</script>
<template>
@@ -205,6 +208,7 @@ defineExpose({ nudge })
<Transition name="floating-action-bar" appear>
<div
v-if="shown"
v-bind="$attrs"
ref="barEl"
class="floating-action-bar drop-shadow-2xl"
:class="inline ? 'floating-action-bar--inline z-10' : 'fixed bottom-0 p-4'"
-184
View File
@@ -1,184 +0,0 @@
<template>
<div v-if="shown">
<div
:class="{ shown: actuallyShown }"
class="tauri-overlay"
data-tauri-drag-region
@click="() => (closable ? hide() : {})"
/>
<div
:class="{
shown: actuallyShown,
noblur: props.noblur,
}"
class="modal-overlay"
@click="() => (closable ? hide() : {})"
/>
<div class="modal-container" :class="{ shown: actuallyShown }">
<div class="modal-body">
<div v-if="props.header" class="header">
<h1>{{ props.header }}</h1>
<button v-if="closable" class="btn icon-only transparent" @click="hide">
<XIcon />
</button>
</div>
<div class="content">
<slot />
</div>
</div>
</div>
</div>
<div v-else></div>
</template>
<script setup>
import { XIcon } from '@modrinth/assets'
import { ref } from 'vue'
const props = defineProps({
header: {
type: String,
default: null,
},
noblur: {
type: Boolean,
default: false,
},
closable: {
type: Boolean,
default: true,
},
onHide: {
type: Function,
default() {
return () => {}
},
},
})
const shown = ref(false)
const actuallyShown = ref(false)
function show() {
shown.value = true
setTimeout(() => {
actuallyShown.value = true
}, 50)
}
function hide() {
props.onHide?.()
actuallyShown.value = false
setTimeout(() => {
shown.value = false
}, 300)
}
defineExpose({
show,
hide,
})
</script>
<style lang="scss" scoped>
.tauri-overlay {
position: fixed;
visibility: hidden;
top: 0;
left: 0;
width: 100%;
height: 100px;
z-index: 20;
&.shown {
opacity: 1;
visibility: visible;
}
}
.modal-overlay {
visibility: hidden;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 19;
transition: all 0.3s ease-in-out;
@media (prefers-reduced-motion) {
transition: none !important;
}
&.shown {
opacity: 1;
visibility: visible;
background: hsla(0, 0%, 0%, 0.5);
backdrop-filter: blur(3px);
}
&.noblur {
backdrop-filter: none;
}
}
.modal-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
z-index: 21;
visibility: hidden;
pointer-events: none;
&.shown {
visibility: visible;
.modal-body {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
}
.modal-body {
position: fixed;
box-shadow: var(--shadow-raised), var(--shadow-inset);
border-radius: var(--radius-lg);
background-color: var(--color-raised-bg);
max-height: calc(100% - 2 * var(--gap-lg));
overflow-y: visible;
width: 600px;
pointer-events: auto;
.header {
display: flex;
justify-content: space-between;
align-items: center;
background-color: var(--color-bg);
padding: var(--gap-md) var(--gap-lg);
h1 {
font-weight: bold;
font-size: 1.25rem;
}
}
transform: translateY(50vh);
visibility: hidden;
opacity: 0;
transition: all 0.25s ease-in-out;
@media (prefers-reduced-motion) {
transition: none !important;
}
@media screen and (max-width: 650px) {
width: calc(100% - 2 * var(--gap-lg));
}
}
}
</style>
+26 -1
View File
@@ -5,7 +5,8 @@
:class="{ shown: visible }"
class="tauri-overlay"
data-tauri-drag-region
@click="() => (closeOnClickOutside && closable ? hide() : {})"
@pointerdown="onTauriOverlayPointerDown"
@click="onTauriOverlayClick"
/>
<div
:class="[
@@ -218,6 +219,30 @@ const props = withDefaults(
const effectiveNoblur = computed(() => props.noblur ?? modalBehavior?.noblur.value ?? false)
const TAURI_DRAG_THRESHOLD_PX = 4
let tauriPointerScreen: { x: number; y: number } | null = null
function onTauriOverlayPointerDown(event: PointerEvent) {
if (event.button !== 0) {
return
}
tauriPointerScreen = { x: event.screenX, y: event.screenY }
}
function onTauriOverlayClick(event: MouseEvent) {
const start = tauriPointerScreen
tauriPointerScreen = null
if (
start &&
Math.hypot(event.screenX - start.x, event.screenY - start.y) >= TAURI_DRAG_THRESHOLD_PX
) {
return
}
if (props.closeOnClickOutside && props.closable && !props.disableClose) {
hide()
}
}
const computedFade = computed(() => {
if (props.fade) return props.fade
if (props.danger) return 'danger'
@@ -1,6 +1,5 @@
export { default as ConfirmLeaveModal } from './ConfirmLeaveModal.vue'
export { default as ConfirmModal } from './ConfirmModal.vue'
export { default as Modal } from './Modal.vue'
export { default as NewModal } from './NewModal.vue'
export type { ServerProject as OpenInAppModalServerProject } from './OpenInAppModal.vue'
export { default as OpenInAppModal } from './OpenInAppModal.vue'
@@ -21,7 +21,7 @@ import OverflowMenu, {
import TagTagItem from '#ui/components/base/TagTagItem.vue'
import TeleportOverflowMenu from '#ui/components/base/TeleportOverflowMenu.vue'
import { useRelativeTime } from '#ui/composables/how-ago'
import { useVIntl } from '#ui/composables/i18n'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
import type {
@@ -33,6 +33,13 @@ import type {
const { formatMessage } = useVIntl()
const messages = defineMessages({
installationSettingsTooltip: {
id: 'content.modpack-card.installation-settings',
defaultMessage: 'Installation settings',
},
})
interface Props {
project: ContentModpackCardProject
projectLink?: string | RouteLocationRaw
@@ -218,7 +225,10 @@ onUnmounted(() => {
</ButtonStyled>
<ButtonStyled v-if="hasSettingsListener" type="outlined" circular>
<button @click="emit('settings')">
<button
v-tooltip="formatMessage(messages.installationSettingsTooltip)"
@click="emit('settings')"
>
<Settings2Icon />
</button>
</ButtonStyled>
@@ -244,7 +254,7 @@ onUnmounted(() => {
</template>
<template #settings>
<Settings2Icon class="size-5" />
{{ formatMessage(commonMessages.settingsLabel) }}
{{ formatMessage(messages.installationSettingsTooltip) }}
</template>
</TeleportOverflowMenu>
</ButtonStyled>
+3
View File
@@ -566,6 +566,9 @@
"content.inline-backup.world-label": {
"defaultMessage": "world"
},
"content.modpack-card.installation-settings": {
"defaultMessage": "Installation settings"
},
"content.page-layout.additional-content": {
"defaultMessage": "Additional content"
},
+4 -4
View File
@@ -153,9 +153,9 @@ For components that need user interaction to show:
```typescript
export const Default: Story = {
render: () => ({
components: { Modal, ButtonStyled },
components: { NewModal, ButtonStyled },
setup() {
const modalRef = ref<InstanceType<typeof Modal> | null>(null)
const modalRef = ref<InstanceType<typeof NewModal> | null>(null)
return { modalRef }
},
template: /* html */ `
@@ -163,9 +163,9 @@ export const Default: Story = {
<ButtonStyled @click="modalRef?.show()">
<button>Open Modal</button>
</ButtonStyled>
<Modal ref="modalRef" header="Example Modal">
<NewModal ref="modalRef" header="Example Modal">
<p>Modal content</p>
</Modal>
</NewModal>
</div>
`,
}),