refactor: 移除遥测并修复启动器流程

This commit is contained in:
2026-09-13 20:31:55 +08:00
parent 1fa56add19
commit 1593ac7a7c
175 changed files with 547 additions and 12210 deletions

51
Cargo.lock generated
View File

@ -106,7 +106,7 @@ dependencies = [
"serde_json", "serde_json",
"thiserror 2.0.19", "thiserror 2.0.19",
"utoipa", "utoipa",
"uuid 1.24.0", "uuid",
] ]
[[package]] [[package]]
@ -707,7 +707,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
dependencies = [ dependencies = [
"byteorder", "byteorder",
"fnv", "fnv",
"uuid 1.24.0", "uuid",
] ]
[[package]] [[package]]
@ -1431,21 +1431,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "discord-rich-presence"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90c55d69cab17c19677ce3a5f8face993a9e6eaf847fecac3547f3a3ff4a2494"
dependencies = [
"log",
"serde",
"serde_derive",
"serde_json",
"serde_repr",
"thiserror 2.0.19",
"uuid 0.8.2",
]
[[package]] [[package]]
name = "dispatch2" name = "dispatch2"
version = "0.3.1" version = "0.3.1"
@ -3570,7 +3555,7 @@ dependencies = [
"portable-atomic", "portable-atomic",
"smallvec", "smallvec",
"tagptr", "tagptr",
"uuid 1.24.0", "uuid",
] ]
[[package]] [[package]]
@ -5300,7 +5285,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"url", "url",
"uuid 1.24.0", "uuid",
] ]
[[package]] [[package]]
@ -5904,7 +5889,7 @@ dependencies = [
"tokio-stream", "tokio-stream",
"tracing", "tracing",
"url", "url",
"uuid 1.24.0", "uuid",
] ]
[[package]] [[package]]
@ -5984,7 +5969,7 @@ dependencies = [
"stringprep", "stringprep",
"thiserror 2.0.19", "thiserror 2.0.19",
"tracing", "tracing",
"uuid 1.24.0", "uuid",
"whoami", "whoami",
] ]
@ -6022,7 +6007,7 @@ dependencies = [
"stringprep", "stringprep",
"thiserror 2.0.19", "thiserror 2.0.19",
"tracing", "tracing",
"uuid 1.24.0", "uuid",
"whoami", "whoami",
] ]
@ -6048,7 +6033,7 @@ dependencies = [
"thiserror 2.0.19", "thiserror 2.0.19",
"tracing", "tracing",
"url", "url",
"uuid 1.24.0", "uuid",
] ]
[[package]] [[package]]
@ -6454,7 +6439,7 @@ dependencies = [
"thiserror 2.0.19", "thiserror 2.0.19",
"time", "time",
"url", "url",
"uuid 1.24.0", "uuid",
"walkdir", "walkdir",
] ]
@ -6762,7 +6747,7 @@ dependencies = [
"toml 1.1.3+spec-1.1.0", "toml 1.1.3+spec-1.1.0",
"url", "url",
"urlpattern", "urlpattern",
"uuid 1.24.0", "uuid",
"walkdir", "walkdir",
] ]
@ -6833,7 +6818,6 @@ dependencies = [
"data-url", "data-url",
"derive_more", "derive_more",
"dirs", "dirs",
"discord-rich-presence",
"dotenvy", "dotenvy",
"dunce", "dunce",
"either", "either",
@ -6901,7 +6885,7 @@ dependencies = [
"tracing-subscriber", "tracing-subscriber",
"url", "url",
"urlencoding", "urlencoding",
"uuid 1.24.0", "uuid",
"webpki-roots", "webpki-roots",
"whoami", "whoami",
"windows 0.61.3", "windows 0.61.3",
@ -6967,7 +6951,7 @@ dependencies = [
"trash", "trash",
"url", "url",
"urlencoding", "urlencoding",
"uuid 1.24.0", "uuid",
"windows 0.61.3", "windows 0.61.3",
"zip 6.0.0", "zip 6.0.0",
] ]
@ -7691,15 +7675,6 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "uuid"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7"
dependencies = [
"getrandom 0.2.17",
]
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.24.0" version = "1.24.0"
@ -8868,7 +8843,7 @@ dependencies = [
"tokio", "tokio",
"tracing", "tracing",
"uds_windows", "uds_windows",
"uuid 1.24.0", "uuid",
"windows-sys 0.61.2", "windows-sys 0.61.2",
"winnow 1.0.4", "winnow 1.0.4",
"zbus_macros", "zbus_macros",

View File

@ -41,7 +41,6 @@ dashmap = "6.1.0"
data-url = "0.3.2" data-url = "0.3.2"
derive_more = "2.1.1" derive_more = "2.1.1"
dirs = "6.0.0" dirs = "6.0.0"
discord-rich-presence = "1.0.0"
dotenvy = "0.15.7" dotenvy = "0.15.7"
dunce = "1.0.5" dunce = "1.0.5"
either = "1.15.0" either = "1.15.0"

View File

@ -85,7 +85,6 @@ import InstanceIconPickerModal from '@/components/ui/modal/InstanceIconPickerMod
import JavaDownloadConfirmationModal from '@/components/ui/modal/JavaDownloadConfirmationModal.vue' import JavaDownloadConfirmationModal from '@/components/ui/modal/JavaDownloadConfirmationModal.vue'
import ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue' import ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue'
import ModpackInstallModal from '@/components/ui/modal/ModpackInstallModal.vue' import ModpackInstallModal from '@/components/ui/modal/ModpackInstallModal.vue'
import PrivacyConsentModal from '@/components/ui/modal/PrivacyConsentModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue' import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import NavButton from '@/components/ui/NavButton.vue' import NavButton from '@/components/ui/NavButton.vue'
import NavRail from '@/components/ui/NavRail.vue' import NavRail from '@/components/ui/NavRail.vue'
@ -98,7 +97,6 @@ import { useDropImport } from '@/composables/useDropImport'
import { minecraftLaunchErrorKey } from '@/composables/useMinecraftLaunchError' import { minecraftLaunchErrorKey } from '@/composables/useMinecraftLaunchError'
import { useNetworkStatus } from '@/composables/useNetworkStatus' import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { AxolotlBrandConfig, config, getOfficialLabrinthBaseUrl } from '@/config' import { AxolotlBrandConfig, config, getOfficialLabrinthBaseUrl } from '@/config'
import { trackEvent } from '@/helpers/analytics'
import { check_reachable } from '@/helpers/auth.js' import { check_reachable } from '@/helpers/auth.js'
import { get_user, get_version } from '@/helpers/cache.js' import { get_user, get_version } from '@/helpers/cache.js'
import { configureCurseForgeManualDownloadWatcher } from '@/helpers/curseforge' import { configureCurseForgeManualDownloadWatcher } from '@/helpers/curseforge'
@ -119,16 +117,13 @@ import { mergeUrlQuery, parseModrinthLink } from '@/helpers/project-links.ts'
import { import {
get as getSettings, get as getSettings,
getLastBrowseContentProjectType, getLastBrowseContentProjectType,
getPrivacySettings,
getUpdateChannel, getUpdateChannel,
getUpdatePreferences, getUpdatePreferences,
isBrowseContentProjectType, isBrowseContentProjectType,
type PrivacySettings,
savePrivacySettings,
set as setSettings, set as setSettings,
} from '@/helpers/settings.ts' } from '@/helpers/settings.ts'
import { getSidebarExpanded, setSidebarExpanded } from '@/helpers/sidebar-state.ts' import { getSidebarExpanded, setSidebarExpanded } from '@/helpers/sidebar-state.ts'
import { get_opening_command, initialize_state, set_discord_activity } from '@/helpers/state' import { get_opening_command, initialize_state } from '@/helpers/state'
import { import {
areUpdatesEnabled, areUpdatesEnabled,
backupAppDbForUpdate, backupAppDbForUpdate,
@ -369,8 +364,6 @@ watch(
) )
const stateInitialized = ref(false) const stateInitialized = ref(false)
const privacyConsentModal = ref<InstanceType<typeof PrivacyConsentModal>>()
const privacyConsentPending = ref(false)
const closeChoiceModal = ref<InstanceType<typeof NewModal>>() const closeChoiceModal = ref<InstanceType<typeof NewModal>>()
const closeChoiceOpen = ref(false) const closeChoiceOpen = ref(false)
const closeChoiceRemember = ref(false) const closeChoiceRemember = ref(false)
@ -1055,9 +1048,6 @@ async function setupApp() {
theme, theme,
accent_color, accent_color,
locale, locale,
telemetry,
telemetry_consent_version,
discord_rpc,
collapsed_navigation, collapsed_navigation,
hide_nametag_skins_page, hide_nametag_skins_page,
advanced_rendering, advanced_rendering,
@ -1101,7 +1091,6 @@ async function setupApp() {
const dev = await isDev() const dev = await isDev()
isDevEnvironment.value = dev isDevEnvironment.value = dev
if (!onboarded && route.path !== '/') await router.replace('/') if (!onboarded && route.path !== '/') await router.replace('/')
privacyConsentPending.value = telemetry_consent_version < 1
showOnboarding.value = false showOnboarding.value = false
onboardingSettings.value = initialSettings onboardingSettings.value = initialSettings
@ -1132,16 +1121,7 @@ async function setupApp() {
themeStore.devMode = developer_mode themeStore.devMode = developer_mode
themeStore.featureFlags = feature_flags themeStore.featureFlags = feature_flags
stateInitialized.value = true stateInitialized.value = true
if (privacyConsentPending.value) {
await nextTick()
privacyConsentModal.value?.show({
telemetry,
discord_rpc,
consent_version: telemetry_consent_version,
})
} else {
showOnboarding.value = !onboarded showOnboarding.value = !onboarded
}
void reconcileMojangAuthSourceAtStartup().catch(handleError) void reconcileMojangAuthSourceAtStartup().catch(handleError)
isMaximized.value = await getCurrentWindow().isMaximized() isMaximized.value = await getCurrentWindow().isMaximized()
@ -1274,42 +1254,7 @@ async function closeOnboardingSettings() {
} }
async function scheduleStartupDialogs() { async function scheduleStartupDialogs() {
if (!stateInitialized.value || privacyConsentPending.value || showOnboarding.value) return if (!stateInitialized.value || showOnboarding.value) return
}
async function handlePrivacyConsentSaved(privacy: PrivacySettings) {
privacyConsentPending.value = false
if (onboardingSettings.value) {
onboardingSettings.value.telemetry = privacy.telemetry
onboardingSettings.value.discord_rpc = privacy.discord_rpc
onboardingSettings.value.telemetry_consent_version = privacy.consent_version
}
if (!onboardingSettings.value?.onboarded) {
startOnboarding('main')
} else {
await scheduleStartupDialogs()
}
}
async function previewPrivacyConsentModal() {
try {
const current = await getPrivacySettings()
const privacy = await savePrivacySettings({
telemetry: false,
discord_rpc: current.discord_rpc,
consent_version: 0,
})
privacyConsentPending.value = true
if (onboardingSettings.value) {
onboardingSettings.value.telemetry = privacy.telemetry
onboardingSettings.value.discord_rpc = privacy.discord_rpc
onboardingSettings.value.telemetry_consent_version = privacy.consent_version
}
await nextTick()
privacyConsentModal.value?.show(privacy)
} catch (error) {
handleError(error)
}
} }
provide('replayOnboarding', replayOnboarding) provide('replayOnboarding', replayOnboarding)
@ -1320,7 +1265,6 @@ provide(
) )
provide('previewMinecraftCrashModal', () => minecraftCrashModal.value?.showPreview()) provide('previewMinecraftCrashModal', () => minecraftCrashModal.value?.showPreview())
provide('showLauncherPopup', (_request: unknown) => {}) provide('showLauncherPopup', (_request: unknown) => {})
provide('previewPrivacyConsentModal', previewPrivacyConsentModal)
const stateFailed = ref(false) const stateFailed = ref(false)
stateInitialization stateInitialization
@ -1429,9 +1373,6 @@ loading.setEnabled(false)
let initialLoadToken = loading.begin() let initialLoadToken = loading.begin()
let routerToken = null let routerToken = null
let suspenseToken = null let suspenseToken = null
let lastDiscordActivity = null
let discordActivityUpdate = Promise.resolve()
let suspensePending = false let suspensePending = false
const sidebarOverlayScrollbarsOptions = Object.freeze({ const sidebarOverlayScrollbarsOptions = Object.freeze({
@ -1447,31 +1388,11 @@ router.beforeEach(() => {
routerToken = loading.begin() routerToken = loading.begin()
}) })
function syncDiscordActivity(to: RouteLocationNormalizedLoaded) {
const activity =
typeof to.meta.discordActivity === 'string' ? to.meta.discordActivity : 'Idling...'
if (activity === lastDiscordActivity) return
lastDiscordActivity = activity
discordActivityUpdate = discordActivityUpdate
.then(() => set_discord_activity(activity))
.catch((error) => {
if (lastDiscordActivity === activity) lastDiscordActivity = null
console.error('Failed to update Discord activity', error)
})
}
router.afterEach((to, from, failure) => { router.afterEach((to, from, failure) => {
hideAllPoppers() hideAllPoppers()
if (!failure) void invoke('lightweight_mode_set_route', { route: to.fullPath }) if (!failure) void invoke('lightweight_mode_set_route', { route: to.fullPath })
trackEvent('PageView', {
path: to.path,
fromPath: from.path,
failed: failure,
})
if (!failure) { if (!failure) {
void directLinkSync?.() void directLinkSync?.()
if (stateInitialized.value) syncDiscordActivity(to)
} }
setTimeout(() => { setTimeout(() => {
if (!suspensePending && stateInitialized.value) { if (!suspensePending && stateInitialized.value) {
@ -1508,7 +1429,6 @@ watch(
stateInitialized, stateInitialized,
(ready) => { (ready) => {
if (ready) { if (ready) {
syncDiscordActivity(router.currentRoute.value)
if (initialLoadToken) { if (initialLoadToken) {
loading.end(initialLoadToken) loading.end(initialLoadToken)
initialLoadToken = null initialLoadToken = null
@ -1624,7 +1544,6 @@ const dropImport = useDropImport({
onSkinsPage, onSkinsPage,
onSchematicWorkshopPage, onSchematicWorkshopPage,
isSchematicFile, isSchematicFile,
trackEvent,
router, router,
}) })
@ -1836,9 +1755,6 @@ async function handleCommand(e) {
} else { } else {
await install_create_modpack_instance(location).catch(handleError) await install_create_modpack_instance(location).catch(handleError)
} }
trackEvent('InstanceCreate', {
source: 'CreationModalFileDrop',
})
} }
} else if (e.event === 'LaunchInstance') { } else if (e.event === 'LaunchInstance') {
const instance = await getInstance(e.id).catch(() => null) const instance = await getInstance(e.id).catch(() => null)
@ -2577,7 +2493,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
/> />
<MinecraftCrashModal ref="minecraftCrashModal" @error="handleError" /> <MinecraftCrashModal ref="minecraftCrashModal" @error="handleError" />
<JavaDownloadConfirmationModal ref="javaDownloadConfirmationModal" /> <JavaDownloadConfirmationModal ref="javaDownloadConfirmationModal" />
<PrivacyConsentModal ref="privacyConsentModal" @saved="handlePrivacyConsentSaved" />
<NewModal <NewModal
ref="closeChoiceModal" ref="closeChoiceModal"
:header="formatMessage(messages.closeLauncherTitle)" :header="formatMessage(messages.closeLauncherTitle)"

View File

@ -28,7 +28,6 @@ import LegacyProjectCard from '@/components/ui/LegacyProjectCard.vue'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue' import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError' import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { useNetworkStatus } from '@/composables/useNetworkStatus' import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { trackEvent } from '@/helpers/analytics'
import { install_duplicate_instance } from '@/helpers/install' import { install_duplicate_instance } from '@/helpers/install'
import { kill, remove, run } from '@/helpers/instance' import { kill, remove, run } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process.js' import { get_by_instance_id } from '@/helpers/process.js'
@ -158,17 +157,9 @@ const handleOptionsClick = async (args) => {
}) })
if (!handled) handleSevereError(err, { instanceId: args.item.id }) if (!handled) handleSevereError(err, { instanceId: args.item.id })
}) })
trackEvent('InstanceStart', {
loader: args.item.loader,
game_version: args.item.game_version,
})
break break
case 'stop': case 'stop':
await kill(args.item.id).catch(handleError) await kill(args.item.id).catch(handleError)
trackEvent('InstanceStop', {
loader: args.item.loader,
game_version: args.item.game_version,
})
break break
case 'add_content': case 'add_content':
await router.push({ await router.push({

View File

@ -18,7 +18,6 @@ import { computed, ref, watch } from 'vue'
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime' import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
import InstanceIcon from '@/components/ui/InstanceIcon.vue' import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError' import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { trackEvent } from '@/helpers/analytics'
import { import {
type DailyPlaytime, type DailyPlaytime,
type DailyPlaytimeEntry, type DailyPlaytimeEntry,
@ -200,11 +199,6 @@ function selectDay(dateKey: string) {
async function playInstance(instance: GameInstance) { async function playInstance(instance: GameInstance) {
try { try {
await run(instance.id) await run(instance.id)
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomeCalendar',
})
} catch (error) { } catch (error) {
const handled = await handleMinecraftLaunchError(error, { const handled = await handleMinecraftLaunchError(error, {
instance_id: instance.id, instance_id: instance.id,
@ -216,11 +210,6 @@ async function playInstance(instance: GameInstance) {
async function stopInstance(instance: GameInstance) { async function stopInstance(instance: GameInstance) {
await kill(instance.id).catch(handleError) await kill(instance.id).catch(handleError)
trackEvent('InstanceStop', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomeCalendar',
})
} }
watch(() => anchor.value.getTime(), refreshPlaytime, { immediate: true }) watch(() => anchor.value.getTime(), refreshPlaytime, { immediate: true })

View File

@ -25,7 +25,6 @@ import HomeGreeting from '@/components/home/HomeGreeting.vue'
import InstanceIcon from '@/components/ui/InstanceIcon.vue' import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError' import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { useNetworkStatus } from '@/composables/useNetworkStatus' import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { trackEvent } from '@/helpers/analytics'
import { process_listener } from '@/helpers/events' import { process_listener } from '@/helpers/events'
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install' import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
import { kill, run } from '@/helpers/instance' import { kill, run } from '@/helpers/instance'
@ -126,11 +125,6 @@ async function playInstance() {
loading.value = true loading.value = true
try { try {
await run(instance.id) await run(instance.id)
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomeMinimal',
})
} catch (error) { } catch (error) {
const handled = await handleMinecraftLaunchError(error, { const handled = await handleMinecraftLaunchError(error, {
instance_id: instance.id, instance_id: instance.id,
@ -149,11 +143,6 @@ async function stopInstance() {
await kill(instance.id).catch(handleError) await kill(instance.id).catch(handleError)
running.value = false running.value = false
trackEvent('InstanceStop', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomeMinimal',
})
} }
async function installInstance() { async function installInstance() {

View File

@ -22,7 +22,6 @@ import { computed, ref } from 'vue'
import type { HomeWidgetSize } from '@/components/home/home-dashboard' import type { HomeWidgetSize } from '@/components/home/home-dashboard'
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime' import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError' import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { trackEvent } from '@/helpers/analytics'
import { kill } from '@/helpers/instance' import { kill } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
import { import {
@ -107,11 +106,6 @@ async function joinServer(world: ServerWorld & WorldWithInstance, instance: Game
try { try {
await start_join_server(world.instance_id, world.address) await start_join_server(world.instance_id, world.address)
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomePinnedServer',
})
} catch (error) { } catch (error) {
const handled = await handleMinecraftLaunchError(error, { const handled = await handleMinecraftLaunchError(error, {
instance_id: instance.id, instance_id: instance.id,
@ -125,11 +119,6 @@ async function joinServer(world: ServerWorld & WorldWithInstance, instance: Game
async function stopInstance(instance: GameInstance) { async function stopInstance(instance: GameInstance) {
await kill(instance.id).catch(handleError) await kill(instance.id).catch(handleError)
trackEvent('InstanceStop', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomePinnedServer',
})
} }
async function unpinServer(world: ServerWorld & WorldWithInstance) { async function unpinServer(world: ServerWorld & WorldWithInstance) {

View File

@ -7,7 +7,6 @@ import { getHomeWidgetCardDensity, type HomeWidgetSize } from '@/components/home
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime' import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
import WorldItem from '@/components/ui/world/WorldItem.vue' import WorldItem from '@/components/ui/world/WorldItem.vue'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError' import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { trackEvent } from '@/helpers/analytics'
import { kill, run } from '@/helpers/instance' import { kill, run } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
import { import {
@ -73,11 +72,6 @@ async function joinWorld(world: WorldWithInstance, instance: GameInstance) {
try { try {
await start_join_singleplayer_world(world.instance_id, world.path) await start_join_singleplayer_world(world.instance_id, world.path)
playingWorldKey.value = key playingWorldKey.value = key
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomePinnedWorld',
})
} catch (error) { } catch (error) {
const handled = await handleMinecraftLaunchError(error, { const handled = await handleMinecraftLaunchError(error, {
instance_id: instance.id, instance_id: instance.id,
@ -92,11 +86,6 @@ async function joinWorld(world: WorldWithInstance, instance: GameInstance) {
async function playInstance(instance: GameInstance) { async function playInstance(instance: GameInstance) {
try { try {
await run(instance.id) await run(instance.id)
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomePinnedWorld',
})
} catch (error) { } catch (error) {
const handled = await handleMinecraftLaunchError(error, { const handled = await handleMinecraftLaunchError(error, {
instance_id: instance.id, instance_id: instance.id,
@ -109,11 +98,6 @@ async function playInstance(instance: GameInstance) {
async function stopInstance(instance: GameInstance) { async function stopInstance(instance: GameInstance) {
await kill(instance.id).catch(handleError) await kill(instance.id).catch(handleError)
playingWorldKey.value = null playingWorldKey.value = null
trackEvent('InstanceStop', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomePinnedWorld',
})
} }
</script> </script>

View File

@ -15,7 +15,6 @@ import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtim
import InstanceItem from '@/components/ui/world/InstanceItem.vue' import InstanceItem from '@/components/ui/world/InstanceItem.vue'
import WorldItem from '@/components/ui/world/WorldItem.vue' import WorldItem from '@/components/ui/world/WorldItem.vue'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError' import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { trackEvent } from '@/helpers/analytics'
import { kill, run } from '@/helpers/instance' import { kill, run } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
import { import {
@ -117,11 +116,6 @@ async function joinWorld(world: WorldWithInstance, instance: GameInstance) {
await start_join_singleplayer_world(world.instance_id, world.path) await start_join_singleplayer_world(world.instance_id, world.path)
} }
playingWorldKey.value = key playingWorldKey.value = key
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomeRecentWorld',
})
} catch (error) { } catch (error) {
const handled = await handleMinecraftLaunchError(error, { const handled = await handleMinecraftLaunchError(error, {
instance_id: instance.id, instance_id: instance.id,
@ -136,11 +130,6 @@ async function joinWorld(world: WorldWithInstance, instance: GameInstance) {
async function playInstance(instance: GameInstance) { async function playInstance(instance: GameInstance) {
try { try {
await run(instance.id) await run(instance.id)
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomeRecentWorld',
})
} catch (error) { } catch (error) {
const handled = await handleMinecraftLaunchError(error, { const handled = await handleMinecraftLaunchError(error, {
instance_id: instance.id, instance_id: instance.id,
@ -153,11 +142,6 @@ async function playInstance(instance: GameInstance) {
async function stopInstance(instance: GameInstance) { async function stopInstance(instance: GameInstance) {
await kill(instance.id).catch(handleError) await kill(instance.id).catch(handleError)
playingWorldKey.value = null playingWorldKey.value = null
trackEvent('InstanceStop', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomeRecentWorld',
})
} }
</script> </script>

View File

@ -28,7 +28,6 @@ import type { HomeWidgetPlacement, HomeWidgetSize } from '@/components/home/home
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime' import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
import InstanceIcon from '@/components/ui/InstanceIcon.vue' import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError' import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { trackEvent } from '@/helpers/analytics'
import { kill, run } from '@/helpers/instance' import { kill, run } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
import { import {
@ -205,11 +204,6 @@ async function playInstance(targetInstance: GameInstance) {
starting.value = true starting.value = true
try { try {
await run(targetInstance.id) await run(targetInstance.id)
trackEvent('InstanceStart', {
loader: targetInstance.loader,
game_version: targetInstance.game_version,
source: 'HomeInstanceWidget',
})
} catch (error) { } catch (error) {
const handled = await handleMinecraftLaunchError(error, { const handled = await handleMinecraftLaunchError(error, {
instance_id: targetInstance.id, instance_id: targetInstance.id,
@ -230,11 +224,6 @@ async function playWorld() {
} else { } else {
await start_join_singleplayer_world(instance.value.id, world.value.path) await start_join_singleplayer_world(instance.value.id, world.value.path)
} }
trackEvent('InstanceStart', {
loader: instance.value.loader,
game_version: instance.value.game_version,
source: 'HomeShortcutWidget',
})
} catch (error) { } catch (error) {
const handled = await handleMinecraftLaunchError(error, { const handled = await handleMinecraftLaunchError(error, {
instance_id: instance.value.id, instance_id: instance.value.id,

View File

@ -280,7 +280,6 @@ import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { useNetworkStatus } from '@/composables/useNetworkStatus' import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { compareMinecraftAccounts } from '@/helpers/accounts' import { compareMinecraftAccounts } from '@/helpers/accounts'
import { trackEvent } from '@/helpers/analytics'
import { import {
begin_yggdrasil_login, begin_yggdrasil_login,
delete_yggdrasil_password, delete_yggdrasil_password,
@ -678,7 +677,6 @@ async function onMicrosoftLogin(account: MinecraftCredential) {
loginDisabled.value = true loginDisabled.value = true
try { try {
await setAccount(account) await setAccount(account)
trackEvent('AccountLogIn')
} catch (error) { } catch (error) {
handleSevereError(error) handleSevereError(error)
} finally { } finally {
@ -833,7 +831,6 @@ async function addYggdrasilAccount() {
await persistYggdrasilPasswordPreference() await persistYggdrasilPasswordPreference()
yggdrasilAccountModal.value?.hide() yggdrasilAccountModal.value?.hide()
await setAccount(result.credentials) await setAccount(result.credentials)
trackEvent('YggdrasilAccountAdd')
} else { } else {
pendingYggdrasilFlowId.value = result.flow_id pendingYggdrasilFlowId.value = result.flow_id
pendingYggdrasilProfiles.value = result.profiles pendingYggdrasilProfiles.value = result.profiles
@ -859,7 +856,6 @@ async function selectYggdrasilProfile(profileId: string) {
await persistYggdrasilPasswordPreference() await persistYggdrasilPasswordPreference()
yggdrasilProfileModal.value?.hide() yggdrasilProfileModal.value?.hide()
await setAccount(account) await setAccount(account)
trackEvent('YggdrasilAccountAdd')
} catch (error) { } catch (error) {
handleError(error as Error) handleError(error as Error)
} finally { } finally {
@ -875,7 +871,6 @@ async function logout(account: MinecraftCredential) {
} else { } else {
notifyAccountChange() notifyAccountChange()
} }
trackEvent('AccountLogOut')
} }
async function copyAccountUuid(account: MinecraftCredential) { async function copyAccountUuid(account: MinecraftCredential) {

View File

@ -212,7 +212,6 @@ import { useRoute, useRouter } from 'vue-router'
import AppUpdateButton from '@/components/ui/app-update-button/index.vue' import AppUpdateButton from '@/components/ui/app-update-button/index.vue'
import { useInstallJobNotifications } from '@/composables/browse/install-job-notifications' import { useInstallJobNotifications } from '@/composables/browse/install-job-notifications'
import { useNetworkStatus } from '@/composables/useNetworkStatus' import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { trackEvent } from '@/helpers/analytics'
import { loading_listener, process_listener } from '@/helpers/events' import { loading_listener, process_listener } from '@/helpers/events'
import { get_many as getInstances } from '@/helpers/instance' import { get_many as getInstances } from '@/helpers/instance'
import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process' import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process'
@ -437,11 +436,6 @@ const stop = async (process: RunningProcess) => {
try { try {
await killProcess(process.uuid).catch(handleError) await killProcess(process.uuid).catch(handleError)
trackEvent('InstanceStop', {
loader: process.instance.loader,
game_version: process.instance.game_version,
source: 'AppBar',
})
} catch (e) { } catch (e) {
console.error(e) console.error(e)
} }

View File

@ -23,7 +23,6 @@ import { computed, ref } from 'vue'
import { ChatIcon } from '@/assets/icons' import { ChatIcon } from '@/assets/icons'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { AxolotlBrandConfig } from '@/config' import { AxolotlBrandConfig } from '@/config'
import { trackEvent } from '@/helpers/analytics'
import { login as login_flow, set_default_user } from '@/helpers/auth.js' import { login as login_flow, set_default_user } from '@/helpers/auth.js'
import { install_existing_instance } from '@/helpers/install' import { install_existing_instance } from '@/helpers/install'
import { cancel_directory_change } from '@/helpers/settings.ts' import { cancel_directory_change } from '@/helpers/settings.ts'
@ -241,7 +240,6 @@ async function loginMinecraft() {
await set_default_user(loggedIn.profile.id).catch(handleError) await set_default_user(loggedIn.profile.id).catch(handleError)
} }
await trackEvent('AccountLogIn', { source: 'ErrorModal' })
loadingMinecraft.value = false loadingMinecraft.value = false
errorModal.value.hide() errorModal.value.hide()
} catch (err) { } catch (err) {

View File

@ -23,7 +23,6 @@ import { useRouter } from 'vue-router'
import InstanceIcon from '@/components/ui/InstanceIcon.vue' import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError' import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { useNetworkStatus } from '@/composables/useNetworkStatus' import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { trackEvent } from '@/helpers/analytics'
import { process_listener } from '@/helpers/events' import { process_listener } from '@/helpers/events'
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install' import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
import { kill, run } from '@/helpers/instance' import { kill, run } from '@/helpers/instance'
@ -121,11 +120,6 @@ const play = async (e, context) => {
if (!handled) handleSevereError(err, { instanceId: props.instance.id }) if (!handled) handleSevereError(err, { instanceId: props.instance.id })
}) })
.finally(() => { .finally(() => {
trackEvent('InstanceStart', {
loader: props.instance.loader,
game_version: props.instance.game_version,
source: context,
})
}) })
loading.value = false loading.value = false
} }
@ -136,11 +130,6 @@ const stop = async (e, context) => {
await kill(props.instance.id).catch(handleError) await kill(props.instance.id).catch(handleError)
trackEvent('InstanceStop', {
loader: props.instance.loader,
game_version: props.instance.game_version,
source: context,
})
} }
const repair = async (e) => { const repair = async (e) => {

View File

@ -59,7 +59,6 @@ import {
import { onUnmounted, ref } from 'vue' import { onUnmounted, ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { java_discovery_listener } from '@/helpers/events' import { java_discovery_listener } from '@/helpers/events'
import { find_filtered_jres } from '@/helpers/jre.js' import { find_filtered_jres } from '@/helpers/jre.js'
@ -131,9 +130,5 @@ const emit = defineEmits(['submit'])
function setJavaInstall(javaInstall) { function setJavaInstall(javaInstall) {
emit('submit', javaInstall) emit('submit', javaInstall)
detectJavaModal.value.hide() detectJavaModal.value.hide()
trackEvent('JavaAutoDetect', {
path: javaInstall.path,
version: javaInstall.version,
})
} }
</script> </script>

View File

@ -112,7 +112,6 @@ import { computed, ref, watch } from 'vue'
import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue' import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue'
import useJavaTest from '@/composables/useJavaTest' import useJavaTest from '@/composables/useJavaTest'
import { trackEvent } from '@/helpers/analytics'
import { auto_install_java, find_filtered_jres, get_jre } from '@/helpers/jre.js' import { auto_install_java, find_filtered_jres, get_jre } from '@/helpers/jre.js'
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
@ -196,9 +195,9 @@ const recommendedInstalled = computed(() => {
let hasInitialized = false let hasInitialized = false
async function runTest(path) { async function runTest(path) {
await testJavaInstallation(path, testVersion.value, true) await testJavaInstallation(path, testVersion.value)
if (props.version != null) { if (props.version != null) {
await recommendedJavaTest.testJavaInstallation(path, props.version, false) await recommendedJavaTest.testJavaInstallation(path, props.version)
} }
} }
@ -212,9 +211,9 @@ watch(
(newPath) => { (newPath) => {
if (newPath) { if (newPath) {
if (!hasInitialized) { if (!hasInitialized) {
testJavaInstallation(newPath, testVersion.value, false) testJavaInstallation(newPath, testVersion.value)
if (props.version != null) { if (props.version != null) {
recommendedJavaTest.testJavaInstallation(newPath, props.version, false) recommendedJavaTest.testJavaInstallation(newPath, props.version)
} }
hasInitialized = true hasInitialized = true
} else { } else {
@ -242,9 +241,6 @@ async function handleJavaFileInput() {
} }
} }
trackEvent('JavaManualSelect', {
version: props.version,
})
commitSelection(result) commitSelection(result)
} }
@ -279,7 +275,6 @@ async function reinstallJava() {
} }
} }
trackEvent('JavaReInstall', { path: path, version: props.version })
commitSelection(result) commitSelection(result)
runTest(result.path) runTest(result.path)
} finally { } finally {

View File

@ -14,7 +14,6 @@ import { computed, ref } from 'vue'
import InstanceIcon from '@/components/ui/InstanceIcon.vue' import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { list } from '@/helpers/instance' import { list } from '@/helpers/instance'
import { add_server_to_instance, get_instance_worlds } from '@/helpers/worlds.ts' import { add_server_to_instance, get_instance_worlds } from '@/helpers/worlds.ts'
@ -91,7 +90,6 @@ defineExpose({
instances.value = instanceValues instances.value = instanceValues
modal.value.show() modal.value.show()
trackEvent('AddServerToInstanceStart', { source: 'AddServerToInstanceModal' })
}, },
}) })
@ -102,11 +100,6 @@ async function addServer(instance) {
instance.added = true instance.added = true
await queryClient.invalidateQueries({ queryKey: ['worlds', instance.id] }) await queryClient.invalidateQueries({ queryKey: ['worlds', instance.id] })
trackEvent('AddServerToInstance', {
server_name: serverName.value,
instance_name: instance.name,
source: 'AddServerToInstanceModal',
})
} catch (err) { } catch (err) {
handleError(err) handleError(err)
} }

View File

@ -18,7 +18,6 @@ import { useRouter } from 'vue-router'
import InstanceIcon from '@/components/ui/InstanceIcon.vue' import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue' import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import { trackEvent } from '@/helpers/analytics'
import { install_duplicate_instance } from '@/helpers/install' import { install_duplicate_instance } from '@/helpers/install'
import { edit, edit_icon, get_full_path, remove } from '@/helpers/instance' import { edit, edit_icon, get_full_path, remove } from '@/helpers/instance'
import { injectInstanceSettings } from '@/providers/instance-settings' import { injectInstanceSettings } from '@/providers/instance-settings'
@ -49,10 +48,6 @@ const installing = computed(() => instance.value.install_stage !== 'installed')
async function duplicateInstance() { async function duplicateInstance() {
await install_duplicate_instance(instance.value.id).catch(handleError) await install_duplicate_instance(instance.value.id).catch(handleError)
trackEvent('InstanceDuplicate', {
loader: instance.value.loader,
game_version: instance.value.game_version,
})
} }
function formatReleaseChannelLabel(channel: ReleaseChannel) { function formatReleaseChannelLabel(channel: ReleaseChannel) {
@ -104,7 +99,6 @@ watch(selectedReleaseChannel, async (channel, previousChannel) => {
async function resetIcon() { async function resetIcon() {
icon.value = undefined icon.value = undefined
await edit_icon(instance.value.id, null).catch(handleError) await edit_icon(instance.value.id, null).catch(handleError)
trackEvent('InstanceRemoveIcon')
} }
async function setIcon() { async function setIcon() {
@ -116,7 +110,6 @@ async function setIcon() {
icon.value = picked.path icon.value = picked.path
try { try {
await edit_icon(instance.value.id, picked.path) await edit_icon(instance.value.id, picked.path)
trackEvent('InstanceSetIcon')
} catch (error) { } catch (error) {
icon.value = previousIcon icon.value = previousIcon
handleError(error) handleError(error)
@ -253,10 +246,6 @@ async function removeInstance() {
removing.value = true removing.value = true
const path = instance.value.id const path = instance.value.id
trackEvent('InstanceRemove', {
loader: instance.value.loader,
game_version: instance.value.game_version,
})
await router.push({ path: '/' }) await router.push({ path: '/' })
await remove(path).catch(handleError) await remove(path).catch(handleError)

View File

@ -22,7 +22,6 @@ import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import SymlinkInstanceWarning from '@/components/ui/SymlinkInstanceWarning.vue' import SymlinkInstanceWarning from '@/components/ui/SymlinkInstanceWarning.vue'
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_version } from '@/helpers/cache' import { get_project_versions, get_version } from '@/helpers/cache'
import { type CurseForgeFile, updateManagedCurseForgeModpack } from '@/helpers/curseforge' import { type CurseForgeFile, updateManagedCurseForgeModpack } from '@/helpers/curseforge'
import { import {
@ -333,10 +332,6 @@ provideInstallationSettings({
afterSave: async () => { afterSave: async () => {
debug('afterSave: installing', { instanceId: instance.value.id }) debug('afterSave: installing', { instanceId: instance.value.id })
await install_existing_instance(instance.value.id, false).catch(handleError) await install_existing_instance(instance.value.id, false).catch(handleError)
trackEvent('InstanceRepair', {
loader: instance.value.loader,
game_version: instance.value.game_version,
})
debug('afterSave: done') debug('afterSave: done')
}, },
@ -345,40 +340,27 @@ provideInstallationSettings({
repairing.value = true repairing.value = true
await install_existing_instance(instance.value.id, true).catch(handleError) await install_existing_instance(instance.value.id, true).catch(handleError)
repairing.value = false repairing.value = false
trackEvent('InstanceRepair', {
loader: instance.value.loader,
game_version: instance.value.game_version,
})
debug('repair: done') debug('repair: done')
}, },
async reinstallModpack() { async reinstallModpack() {
debug('reinstallModpack: called', { instanceId: instance.value.id }) debug('reinstallModpack: called', { instanceId: instance.value.id })
reinstalling.value = true reinstalling.value = true
let shouldTrack = false
try { try {
if (isImportedModpack.value) { if (isImportedModpack.value) {
shouldTrack = await installLocalModpackFromPicker() await installLocalModpackFromPicker()
} else if (isCurseForgeLinkedModpack.value) { } else if (isCurseForgeLinkedModpack.value) {
const fileId = Number(instance.value.link?.version_id) const fileId = Number(instance.value.link?.version_id)
if (!Number.isFinite(fileId)) { if (!Number.isFinite(fileId)) {
throw new Error('Invalid CurseForge file ID') throw new Error('Invalid CurseForge file ID')
} }
await updateManagedCurseForgeModpack(instance.value.id, fileId).catch(handleError) await updateManagedCurseForgeModpack(instance.value.id, fileId).catch(handleError)
shouldTrack = true
} else { } else {
await update_repair_modrinth(instance.value.id).catch(handleError) await update_repair_modrinth(instance.value.id).catch(handleError)
shouldTrack = true
} }
} finally { } finally {
reinstalling.value = false reinstalling.value = false
} }
if (shouldTrack) {
trackEvent('InstanceRepair', {
loader: instance.value.loader,
game_version: instance.value.game_version,
})
}
debug('reinstallModpack: done') debug('reinstallModpack: done')
}, },
@ -386,13 +368,7 @@ provideInstallationSettings({
debug('swapModpack: called', { instanceId: instance.value.id }) debug('swapModpack: called', { instanceId: instance.value.id })
reinstalling.value = true reinstalling.value = true
try { try {
const installed = await installLocalModpackFromPicker() await installLocalModpackFromPicker()
if (installed) {
trackEvent('InstanceRepair', {
loader: instance.value.loader,
game_version: instance.value.game_version,
})
}
} finally { } finally {
reinstalling.value = false reinstalling.value = false
} }

View File

@ -1,145 +0,0 @@
<script setup lang="ts">
import { ExternalIcon, ShieldIcon, SpinnerIcon } from '@modrinth/assets'
import {
ButtonStyled,
defineMessages,
injectNotificationManager,
NewModal,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { ref } from 'vue'
import { type PrivacySettings, savePrivacySettings } from '@/helpers/settings'
const emit = defineEmits<{
saved: [privacy: PrivacySettings]
}>()
const CONSENT_VERSION = 1
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const modal = ref<InstanceType<typeof NewModal>>()
const telemetry = ref(true)
const discordRpc = ref(true)
const saving = ref(false)
const messages = defineMessages({
title: {
id: 'app.privacy-consent.title',
defaultMessage: 'Privacy & security',
},
intro: {
id: 'app.privacy-consent.intro',
defaultMessage:
'Choose what Starlight may send or display. Nothing is sent until you confirm these choices.',
},
telemetry: {
id: 'app.privacy-consent.telemetry',
defaultMessage: 'Allow anonymous telemetry',
},
telemetryDescription: {
id: 'app.privacy-consent.telemetry-description',
defaultMessage:
'Helps count opted-in installations and daily active users. Full Minecraft logs and account credentials are never uploaded.',
},
discordRpc: {
id: 'app.privacy-consent.discord-rpc',
defaultMessage: 'Discord Rich Presence',
},
discordRpcDescription: {
id: 'app.privacy-consent.discord-rpc-description',
defaultMessage:
'Shows your current launcher or game activity in Discord when Discord is running.',
},
privacyPolicy: {
id: 'app.privacy-consent.privacy-policy',
defaultMessage: 'Read the privacy policy',
},
continue: {
id: 'app.privacy-consent.continue',
defaultMessage: 'Save and continue',
},
})
function show(current: PrivacySettings) {
telemetry.value = true
discordRpc.value = current.discord_rpc
modal.value?.show()
}
async function save() {
if (saving.value) return
saving.value = true
try {
const privacy = await savePrivacySettings({
telemetry: telemetry.value,
discord_rpc: discordRpc.value,
consent_version: CONSENT_VERSION,
})
modal.value?.hide()
emit('saved', privacy)
} catch (error) {
handleError(error)
} finally {
saving.value = false
}
}
defineExpose({ show })
</script>
<template>
<NewModal ref="modal" :header="formatMessage(messages.title)" :closable="false" max-width="600px">
<div class="flex flex-col gap-6">
<div class="flex items-start gap-3">
<ShieldIcon class="mt-0.5 size-6 shrink-0 text-brand" />
<p class="m-0 leading-relaxed text-primary">
{{ formatMessage(messages.intro) }}
</p>
</div>
<div class="flex items-center justify-between gap-5">
<div class="min-w-0">
<label for="consent-telemetry" class="font-semibold text-contrast">
{{ formatMessage(messages.telemetry) }}
</label>
<p class="mb-0 mt-1 text-sm leading-relaxed text-secondary">
{{ formatMessage(messages.telemetryDescription) }}
</p>
</div>
<Toggle id="consent-telemetry" v-model="telemetry" :disabled="saving" />
</div>
<div class="flex items-center justify-between gap-5">
<div class="min-w-0">
<label for="consent-discord-rpc" class="font-semibold text-contrast">
{{ formatMessage(messages.discordRpc) }}
</label>
<p class="mb-0 mt-1 text-sm leading-relaxed text-secondary">
{{ formatMessage(messages.discordRpcDescription) }}
</p>
</div>
<Toggle id="consent-discord-rpc" v-model="discordRpc" :disabled="saving" />
</div>
</div>
<template #actions>
<div class="flex items-center justify-between gap-4">
<ButtonStyled type="transparent">
<button type="button" :disabled="saving" @click="openUrl('https://axlmc.org/privacy')">
<ExternalIcon />
{{ formatMessage(messages.privacyPolicy) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button type="button" :disabled="saving" @click="save">
<SpinnerIcon v-if="saving" class="animate-spin" />
{{ formatMessage(messages.continue) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>

View File

@ -204,15 +204,6 @@ export const onboardingMessages = defineMessages({
defaultMessage: defaultMessage:
'Choose how content downloads and installs, from download sources to safety checks.', 'Choose how content downloads and installs, from download sources to safety checks.',
}, },
privacyTitle: {
id: 'app.onboarding.privacy.title',
defaultMessage: 'Your data, your call',
},
privacyDescription: {
id: 'app.onboarding.privacy.description',
defaultMessage:
'Manage anonymous telemetry, Discord Rich Presence, and the Minecraft log analysis service whenever you need to.',
},
updatesTitle: { id: 'app.onboarding.updates.title', defaultMessage: 'Stay in the loop' }, updatesTitle: { id: 'app.onboarding.updates.title', defaultMessage: 'Stay in the loop' },
updatesDescription: { updatesDescription: {
id: 'app.onboarding.updates.description', id: 'app.onboarding.updates.description',

View File

@ -21,7 +21,6 @@ import JetBrainsLogo from '@/assets/java-vendors/jetbrains.png'
import MicrosoftLogo from '@/assets/java-vendors/microsoft.png' import MicrosoftLogo from '@/assets/java-vendors/microsoft.png'
import OracleLogo from '@/assets/java-vendors/oracle.png' import OracleLogo from '@/assets/java-vendors/oracle.png'
import SapLogo from '@/assets/java-vendors/sap.png' import SapLogo from '@/assets/java-vendors/sap.png'
import { trackEvent } from '@/helpers/analytics'
import { download_java, list_java_feed_vendors, list_java_feed_versions } from '@/helpers/jre' import { download_java, list_java_feed_vendors, list_java_feed_versions } from '@/helpers/jre'
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
@ -117,7 +116,6 @@ function backToVendors() {
async function downloadVersion(info) { async function downloadVersion(info) {
downloading.value = info.major_version downloading.value = info.major_version
trackEvent('JavaDownload', { vendor: info.vendor, version: info.major_version })
modal.value?.hide() modal.value?.hide()
const job = await download_java(info.vendor, info.major_version).catch(handleError) const job = await download_java(info.vendor, info.major_version).catch(handleError)

View File

@ -23,7 +23,6 @@ const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager() const { addNotification } = injectNotificationManager()
const isDevEnvironment = await isDev() const isDevEnvironment = await isDev()
const previewMinecraftCrashModal = inject<() => void>('previewMinecraftCrashModal') const previewMinecraftCrashModal = inject<() => void>('previewMinecraftCrashModal')
const previewPrivacyConsentModal = inject<() => Promise<void>>('previewPrivacyConsentModal')
const messages = defineMessages({ const messages = defineMessages({
resetToDefault: { resetToDefault: {
id: 'app.settings.feature-flags.reset-to-default', id: 'app.settings.feature-flags.reset-to-default',
@ -53,10 +52,6 @@ const messages = defineMessages({
id: 'app.settings.about.preview-minecraft-crash-modal', id: 'app.settings.about.preview-minecraft-crash-modal',
defaultMessage: 'Preview Minecraft crash window', defaultMessage: 'Preview Minecraft crash window',
}, },
previewPrivacyConsentModal: {
id: 'app.settings.about.preview-privacy-consent-modal',
defaultMessage: 'Preview privacy & security modal',
},
}) })
const settings = ref(await getSettings()) const settings = ref(await getSettings())
@ -127,9 +122,6 @@ watch(
<Button type="base" @click="previewMinecraftCrashModal?.()"> <Button type="base" @click="previewMinecraftCrashModal?.()">
<WrenchIcon /> {{ formatMessage(messages.previewMinecraftCrashModal) }} <WrenchIcon /> {{ formatMessage(messages.previewMinecraftCrashModal) }}
</Button> </Button>
<Button type="base" @click="previewPrivacyConsentModal?.()">
<WrenchIcon /> {{ formatMessage(messages.previewPrivacyConsentModal) }}
</Button>
</div> </div>
</SettingsSection> </SettingsSection>
</template> </template>

View File

@ -19,7 +19,6 @@ import MemoryAllocationDisplay from '@/components/ui/MemoryAllocationDisplay.vue
import DownloadJavaModal from '@/components/ui/settings/DownloadJavaModal.vue' import DownloadJavaModal from '@/components/ui/settings/DownloadJavaModal.vue'
import InstalledJavaModal from '@/components/ui/settings/InstalledJavaModal.vue' import InstalledJavaModal from '@/components/ui/settings/InstalledJavaModal.vue'
import useMemorySlider from '@/composables/useMemorySlider' import useMemorySlider from '@/composables/useMemorySlider'
import { trackEvent } from '@/helpers/analytics'
import { collectGcContext } from '@/helpers/gc/context' import { collectGcContext } from '@/helpers/gc/context'
import { wait_for_install_job } from '@/helpers/install' import { wait_for_install_job } from '@/helpers/install'
import { getJavaArgumentPresets } from '@/helpers/java-argument-presets' import { getJavaArgumentPresets } from '@/helpers/java-argument-presets'
@ -249,7 +248,6 @@ async function runScan(exhaustive) {
scanning.value = true scanning.value = true
scanMode.value = 'quick' scanMode.value = 'quick'
trackEvent('JavaQuickScan', { source: 'settings' })
try { try {
await find_filtered_jres(null, false, true, false).catch(handleError) await find_filtered_jres(null, false, true, false).catch(handleError)
} finally { } finally {
@ -262,7 +260,6 @@ async function confirmDeepScan() {
showDeepScanConfirm.value = false showDeepScanConfirm.value = false
scanning.value = true scanning.value = true
scanMode.value = 'deep' scanMode.value = 'deep'
trackEvent('JavaDeepScan', { source: 'settings' })
try { try {
await find_filtered_jres(null, true, true, true).catch(handleError) await find_filtered_jres(null, true, true, true).catch(handleError)
} finally { } finally {
@ -280,7 +277,6 @@ async function handleManualAdd() {
if (!javaInfo) return if (!javaInfo) return
await set_java_version(javaInfo).catch(handleError) await set_java_version(javaInfo).catch(handleError)
trackEvent('JavaManualSelect', { path: filePath })
} }
async function onJavaDownloaded(job) { async function onJavaDownloaded(job) {

View File

@ -1,156 +0,0 @@
<script setup lang="ts">
import { defineMessages, injectNotificationManager, Toggle, useVIntl } from '@modrinth/ui'
import { computed, ref } from 'vue'
import { getPrivacySettings, setDiscordRpcEnabled, setTelemetryEnabled } from '@/helpers/settings'
import SettingsRow from './SettingsRow.vue'
import SettingsSaveStatus from './SettingsSaveStatus.vue'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const privacy = ref(await getPrivacySettings())
const telemetrySaving = ref(false)
const discordSaving = ref(false)
const lastSaveState = ref<'idle' | 'saved' | 'error'>('idle')
const retrySave = ref<(() => void) | undefined>()
const messages = defineMessages({
telemetry: {
id: 'app.settings.privacy.telemetry',
defaultMessage: 'Allow telemetry',
},
telemetryDescription: {
id: 'app.settings.privacy.telemetry-description',
defaultMessage:
'Send one anonymous daily activity signal to improve usage statistics. Minecraft logs and account credentials are never uploaded.',
},
discordRpc: {
id: 'app.settings.privacy.discord-rpc',
defaultMessage: 'Discord Rich Presence',
},
discordRpcDescription: {
id: 'app.settings.privacy.discord-rpc-description',
defaultMessage: 'Show your current launcher or game activity in Discord.',
},
dataHandling: {
id: 'app.settings.privacy.data-handling',
defaultMessage:
'Telemetry uses a random installation identifier and sends only a daily activity signal. Turning telemetry off clears pending data immediately.',
},
})
const saveStatus = computed(() => {
if (telemetrySaving.value || discordSaving.value) return 'saving'
return lastSaveState.value
})
async function updateTelemetry(value: boolean) {
if (telemetrySaving.value) return
const previous = privacy.value.telemetry
privacy.value.telemetry = value
telemetrySaving.value = true
lastSaveState.value = 'idle'
retrySave.value = undefined
try {
const saved = await setTelemetryEnabled(value)
privacy.value.telemetry = saved.telemetry
privacy.value.consent_version = saved.consent_version
lastSaveState.value = 'saved'
} catch (error) {
privacy.value.telemetry = previous
retrySave.value = () => void updateTelemetry(value)
lastSaveState.value = 'error'
handleError(error)
} finally {
telemetrySaving.value = false
}
}
async function updateDiscordRpc(value: boolean) {
if (discordSaving.value) return
const previous = privacy.value.discord_rpc
privacy.value.discord_rpc = value
discordSaving.value = true
lastSaveState.value = 'idle'
retrySave.value = undefined
try {
const saved = await setDiscordRpcEnabled(value)
privacy.value.discord_rpc = saved.discord_rpc
lastSaveState.value = 'saved'
} catch (error) {
privacy.value.discord_rpc = previous
retrySave.value = () => void updateDiscordRpc(value)
lastSaveState.value = 'error'
handleError(error)
} finally {
discordSaving.value = false
}
}
</script>
<template>
<div class="flex w-full flex-col gap-4">
<header class="settings-page-header">
<SettingsSaveStatus :status="saveStatus" :retry="retrySave" />
</header>
<div class="settings-page-card">
<SettingsRow>
<template #label>
<span id="settings-target-privacy-telemetry" tabindex="-1">
{{ formatMessage(messages.telemetry) }}
</span>
</template>
<template #description>{{ formatMessage(messages.telemetryDescription) }}</template>
<template #control>
<Toggle
id="privacy-telemetry"
:model-value="privacy.telemetry"
:disabled="telemetrySaving"
@update:model-value="(value) => updateTelemetry(!!value)"
/>
</template>
</SettingsRow>
<SettingsRow>
<template #label>
<span id="settings-target-privacy-discord-rpc" tabindex="-1">
{{ formatMessage(messages.discordRpc) }}
</span>
</template>
<template #description>{{ formatMessage(messages.discordRpcDescription) }}</template>
<template #control>
<Toggle
id="privacy-discord-rpc"
:model-value="privacy.discord_rpc"
:disabled="discordSaving"
@update:model-value="(value) => updateDiscordRpc(!!value)"
/>
</template>
</SettingsRow>
</div>
<p class="settings-page-note">{{ formatMessage(messages.dataHandling) }}</p>
</div>
</template>
<style scoped>
.settings-page-card {
overflow: hidden;
border: 1px solid
var(--settings-card-border, color-mix(in srgb, var(--surface-4) 72%, transparent));
border-radius: var(--radius-md);
background: var(--surface-2);
}
.settings-page-header {
display: flex;
min-height: 0;
justify-content: flex-end;
}
.settings-page-note {
margin: 0;
color: var(--color-secondary);
font-size: 0.8125rem;
line-height: 1.5;
}
</style>

View File

@ -10,7 +10,6 @@ export type SettingsCategoryId =
| 'content-downloads' | 'content-downloads'
| 'network-multiplayer' | 'network-multiplayer'
| 'storage-backups' | 'storage-backups'
| 'privacy-data'
| 'updates' | 'updates'
| 'about' | 'about'
| 'feature-flags' | 'feature-flags'
@ -106,15 +105,6 @@ export const settingsCategoryDefinitions: SettingsCategoryDefinition[] = [
group: 'data-privacy', group: 'data-privacy',
onboardingId: 'settings-tab-storage-backups', onboardingId: 'settings-tab-storage-backups',
}, },
{
id: 'privacy-data',
name: defineMessage({
id: 'app.settings.tabs.privacy-data',
defaultMessage: 'Privacy & data sharing',
}),
group: 'data-privacy',
onboardingId: 'settings-tab-privacy-data',
},
{ {
id: 'updates', id: 'updates',
name: defineMessage({ id: 'app.settings.tabs.updates', defaultMessage: 'Updates' }), name: defineMessage({ id: 'app.settings.tabs.updates', defaultMessage: 'Updates' }),

View File

@ -72,10 +72,6 @@ const categoryContent: Record<SettingsCategoryId, Pick<SettingsCategory, 'icon'
icon: ArchiveIcon, icon: ArchiveIcon,
content: defineAsyncComponent(() => import('./StorageBackupSettings.vue')), content: defineAsyncComponent(() => import('./StorageBackupSettings.vue')),
}, },
'privacy-data': {
icon: ShieldIcon,
content: defineAsyncComponent(() => import('./PrivacySettings.vue')),
},
updates: { updates: {
icon: RefreshCwIcon, icon: RefreshCwIcon,
content: defineAsyncComponent(() => import('./UpdateSettings.vue')), content: defineAsyncComponent(() => import('./UpdateSettings.vue')),

View File

@ -221,18 +221,6 @@ export const settingsSearchEntries: SettingsSearchEntry[] = [
label: message('app.crash-analysis.ai.settings.title', 'Crash AI explanation'), label: message('app.crash-analysis.ai.settings.title', 'Crash AI explanation'),
keywords: [message('app.settings.tabs.launch-defaults', 'Launch & instance defaults')], keywords: [message('app.settings.tabs.launch-defaults', 'Launch & instance defaults')],
}, },
{
id: 'privacy-telemetry',
categoryId: 'privacy-data',
targetId: 'settings-target-privacy-telemetry',
label: message('app.settings.privacy.telemetry', 'Telemetry'),
},
{
id: 'privacy-discord-rpc',
categoryId: 'privacy-data',
targetId: 'settings-target-privacy-discord-rpc',
label: message('app.settings.privacy.discord-rpc', 'Discord rich presence'),
},
{ {
id: 'java-installations', id: 'java-installations',
categoryId: 'java-performance', categoryId: 'java-performance',

View File

@ -28,7 +28,6 @@ const settingsComponentFiles = {
'content-downloads': ['./AppearanceSettings.vue', './ResourceManagementSettings.vue'], 'content-downloads': ['./AppearanceSettings.vue', './ResourceManagementSettings.vue'],
'network-multiplayer': ['./ResourceManagementSettings.vue', './MultiplayerSettings.vue'], 'network-multiplayer': ['./ResourceManagementSettings.vue', './MultiplayerSettings.vue'],
'storage-backups': ['./ResourceManagementSettings.vue', './StorageSettings.vue'], 'storage-backups': ['./ResourceManagementSettings.vue', './StorageSettings.vue'],
'privacy-data': ['./PrivacySettings.vue'],
updates: ['./UpdateSettings.vue'], updates: ['./UpdateSettings.vue'],
about: ['./AboutSettings.vue'], about: ['./AboutSettings.vue'],
'feature-flags': ['./FeatureFlagSettings.vue'], 'feature-flags': ['./FeatureFlagSettings.vue'],
@ -169,7 +168,7 @@ test('developer-only settings stay out of the normal search categories', () => {
'content-downloads', 'content-downloads',
'network-multiplayer', 'network-multiplayer',
]) ])
assert.deepEqual(categoriesForGroup('data-privacy'), ['storage-backups', 'privacy-data']) assert.deepEqual(categoriesForGroup('data-privacy'), ['storage-backups'])
assert.deepEqual(categoriesForGroup('support'), ['updates', 'about']) assert.deepEqual(categoriesForGroup('support'), ['updates', 'about'])
assert.deepEqual(categoriesForGroup('developer'), []) assert.deepEqual(categoriesForGroup('developer'), [])
assert.deepEqual(categoriesForGroup('developer', true), ['feature-flags']) assert.deepEqual(categoriesForGroup('developer', true), ['feature-flags'])

View File

@ -26,7 +26,6 @@ import { useRouter } from 'vue-router'
import InstanceIcon from '@/components/ui/InstanceIcon.vue' import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError' import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { trackEvent } from '@/helpers/analytics'
import { get_project } from '@/helpers/cache' import { get_project } from '@/helpers/cache'
import { process_listener } from '@/helpers/events' import { process_listener } from '@/helpers/events'
import { kill, run } from '@/helpers/instance' import { kill, run } from '@/helpers/instance'
@ -108,11 +107,6 @@ const play = async (event: MouseEvent) => {
if (!handled) handleSevereError(err, { instanceId: props.instance.id }) if (!handled) handleSevereError(err, { instanceId: props.instance.id })
}) })
.finally(() => { .finally(() => {
trackEvent('InstanceStart', {
loader: props.instance.loader,
game_version: props.instance.game_version,
source: 'InstanceItem',
})
}) })
emit('play') emit('play')
loading.value = false loading.value = false
@ -122,11 +116,6 @@ const stop = async (event: MouseEvent) => {
event?.stopPropagation() event?.stopPropagation()
loading.value = true loading.value = true
await kill(props.instance.id).catch(handleError) await kill(props.instance.id).catch(handleError)
trackEvent('InstanceStop', {
loader: props.instance.loader,
game_version: props.instance.game_version,
source: 'InstanceItem',
})
emit('stop') emit('stop')
loading.value = false loading.value = false
} }

View File

@ -114,8 +114,6 @@ export interface DropImportOptions {
onSchematicWorkshopPage: ComputedRef<boolean> onSchematicWorkshopPage: ComputedRef<boolean>
/** Check if path is a schematic file */ /** Check if path is a schematic file */
isSchematicFile: (path: string) => boolean isSchematicFile: (path: string) => boolean
/** Track analytics event */
trackEvent: (name: string, properties?: Record<string, unknown>) => void
/** Route to push */ /** Route to push */
router: Router router: Router
} }
@ -135,7 +133,6 @@ export interface DropImportOptions {
* onSkinsPage, * onSkinsPage,
* onSchematicWorkshopPage, * onSchematicWorkshopPage,
* isSchematicFile, * isSchematicFile,
* trackEvent,
* router, * router,
* route, * route,
* }) * })
@ -151,7 +148,6 @@ export function useDropImport(options: DropImportOptions) {
onSkinsPage, onSkinsPage,
onSchematicWorkshopPage, onSchematicWorkshopPage,
isSchematicFile, isSchematicFile,
trackEvent,
router, router,
} = options } = options
@ -906,7 +902,6 @@ export function useDropImport(options: DropImportOptions) {
clearDropProcessingNotification() clearDropProcessingNotification()
await installModpackFromPath(filePath, fileName, { persistUntilDone: true }) await installModpackFromPath(filePath, fileName, { persistUntilDone: true })
trackEvent('InstanceCreate', { source: 'DropConfirmModpack' })
await router.push('/library') await router.push('/library')
return return
} }

View File

@ -1,6 +1,5 @@
import { ref } from 'vue' import { ref } from 'vue'
import { trackEvent } from '@/helpers/analytics'
import { get_jre, test_jre } from '@/helpers/jre.js' import { get_jre, test_jre } from '@/helpers/jre.js'
export default function useJavaTest() { export default function useJavaTest() {
@ -8,7 +7,7 @@ export default function useJavaTest() {
const javaTestResult = ref<boolean | null>(null) const javaTestResult = ref<boolean | null>(null)
let testDebounceTimer: ReturnType<typeof setTimeout> | null = null let testDebounceTimer: ReturnType<typeof setTimeout> | null = null
async function runJavaTest(path: string, version: number | null, track = true) { async function runJavaTest(path: string, version: number | null) {
if (testDebounceTimer) { if (testDebounceTimer) {
clearTimeout(testDebounceTimer) clearTimeout(testDebounceTimer)
testDebounceTimer = null testDebounceTimer = null
@ -28,10 +27,6 @@ export default function useJavaTest() {
javaTestResult.value = false javaTestResult.value = false
} }
testingJava.value = false testingJava.value = false
if (track) {
trackEvent('JavaTest', { path, success: javaTestResult.value })
}
} }
function testJavaInstallationDebounced(path: string, version: number | null, delay = 600) { function testJavaInstallationDebounced(path: string, version: number | null, delay = 600) {
@ -43,8 +38,8 @@ export default function useJavaTest() {
testDebounceTimer = setTimeout(() => runJavaTest(path, version, false), delay) testDebounceTimer = setTimeout(() => runJavaTest(path, version, false), delay)
} }
async function testJavaInstallation(path: string, version: number | null, track = false) { async function testJavaInstallation(path: string, version: number | null) {
await runJavaTest(path, version, track) await runJavaTest(path, version)
} }
return { return {

View File

@ -1,75 +0,0 @@
interface InstanceProperties {
loader: string
game_version: string
}
interface ProjectProperties extends InstanceProperties {
id: string
project_type: string
}
type AnalyticsEventMap = {
Launched: { version: string; dev: boolean; onboarded: boolean }
PageView: { path: string; fromPath: string; failed: unknown }
InstanceCreate: { source: string }
InstanceCreateStart: { source: string }
InstanceStart: InstanceProperties & { source: string }
InstanceStop: Partial<InstanceProperties> & { source?: string }
InstanceDuplicate: InstanceProperties
InstanceRepair: InstanceProperties
InstanceSetIcon: Record<string, never>
InstanceRemoveIcon: Record<string, never>
InstanceUpdateAll: InstanceProperties & { count: number; selected: boolean }
InstanceProjectUpdate: InstanceProperties & { id: string; name: string; project_type: string }
InstanceProjectDisable: InstanceProperties & {
id: string
name: string
project_type: string
disabled: boolean
}
InstanceProjectRemove: InstanceProperties & { id: string; name: string; project_type: string }
ProjectInstall: ProjectProperties & { version_id: string; title: string; source: string }
ProjectInstallStart: { source: string }
PackInstall: { id: string; version_id: string; title: string; source: string }
PackInstallStart: Record<string, never>
AccountLogIn: { source?: string }
AccountLogOut: Record<string, never>
JavaTest: { path: string; success: boolean }
JavaManualSelect: { version: string }
JavaAutoDetect: { path: string; version: string }
GalleryImageNext: { project_id: string; url: string }
GalleryImagePrevious: { project_id: string; url: unknown }
GalleryImageExpand: { project_id: string; url: string }
}
export type AnalyticsEvent = keyof AnalyticsEventMap
let optedIn = false
let debugEnabled = false
export const initAnalytics = () => {
optedIn = true
}
export const debugAnalytics = () => {
debugEnabled = true
}
export const optOutAnalytics = () => {
optedIn = false
}
export const optInAnalytics = () => {
optedIn = true
}
type OptionalArgs<T> = Record<string, never> extends T ? [properties?: T] : [properties: T]
export const trackEvent = <E extends AnalyticsEvent>(
eventName: E,
...args: OptionalArgs<AnalyticsEventMap[E]>
) => {
if (optedIn && debugEnabled) {
console.debug('[Axolotl telemetry disabled]', eventName, args[0])
}
}

View File

@ -652,10 +652,6 @@ export async function update_project(instanceId: string, projectPath: string): P
return await invoke('plugin:instance|instance_update_project', { instanceId, projectPath }) return await invoke('plugin:instance|instance_update_project', { instanceId, projectPath })
} }
// Add a project to an instance from a version
// Returns a path to the new project file
export type DownloadReason = 'standalone' | 'dependency' | 'modpack' | 'update'
export interface ResolutionPreferences { export interface ResolutionPreferences {
game_versions?: string[] game_versions?: string[]
loaders?: string[] loaders?: string[]
@ -691,14 +687,10 @@ export interface ResolveContentPlan {
export async function add_project_from_version( export async function add_project_from_version(
instanceId: string, instanceId: string,
versionId: string, versionId: string,
reason: DownloadReason,
dependentOnVersionId?: string,
): Promise<string> { ): Promise<string> {
return await invoke('plugin:instance|instance_add_project_from_version', { return await invoke('plugin:instance|instance_add_project_from_version', {
instanceId, instanceId,
versionId, versionId,
reason,
dependentOnVersionId,
}) })
} }

View File

@ -175,9 +175,6 @@ export type AppSettings = {
home_widgets: HomeDashboardConfig | null home_widgets: HomeDashboardConfig | null
terracotta_public_nodes: string[] terracotta_public_nodes: string[]
telemetry: boolean
telemetry_consent_version: number
discord_rpc: boolean
onboarded: boolean onboarded: boolean
onboarding_version: number onboarding_version: number
onboarding_instance_tour_completed: boolean onboarding_instance_tour_completed: boolean
@ -207,12 +204,6 @@ export type AppSettings = {
version: number version: number
} }
export type PrivacySettings = {
telemetry: boolean
discord_rpc: boolean
consent_version: number
}
type LegacyMirrorSettings = { type LegacyMirrorSettings = {
use_minecraft_mirror?: boolean use_minecraft_mirror?: boolean
use_modrinth_mirror?: boolean use_modrinth_mirror?: boolean
@ -301,22 +292,6 @@ export async function cancel_directory_change(): Promise<void> {
return await invoke('plugin:settings|cancel_directory_change') return await invoke('plugin:settings|cancel_directory_change')
} }
export async function getPrivacySettings(): Promise<PrivacySettings> {
return await invoke('plugin:settings|privacy_get')
}
export async function savePrivacySettings(privacy: PrivacySettings): Promise<PrivacySettings> {
return await invoke('plugin:settings|privacy_set', { privacy })
}
export async function setTelemetryEnabled(enabled: boolean): Promise<PrivacySettings> {
return await invoke('plugin:settings|telemetry_set', { enabled })
}
export async function setDiscordRpcEnabled(enabled: boolean): Promise<PrivacySettings> {
return await invoke('plugin:settings|discord_rpc_set', { enabled })
}
export async function getProxyConfig(): Promise<ProxyConfig> { export async function getProxyConfig(): Promise<ProxyConfig> {
return await invoke('plugin:settings|proxy_get') return await invoke('plugin:settings|proxy_get')
} }

View File

@ -44,10 +44,6 @@ export async function initialize_state() {
return await invoke<void>('initialize_state') return await invoke<void>('initialize_state')
} }
export async function set_discord_activity(activity: string) {
return await invoke<void>('set_discord_activity', { activity })
}
// Gets active progress bars // Gets active progress bars
export async function progress_bars_list() { export async function progress_bars_list() {
return await invoke<Record<string, LoadingBar>>('plugin:utils|progress_bars_list') return await invoke<Record<string, LoadingBar>>('plugin:utils|progress_bars_list')

View File

@ -1,7 +0,0 @@
import { invoke } from '@tauri-apps/api/core'
export function installTelemetryHandlers(): void {
window.addEventListener('online', () => {
void invoke('plugin:telemetry|notify_online').catch(() => undefined)
})
}

View File

@ -216,8 +216,6 @@ type AppSettings = {
close_behavior: 'ask' | 'close' | 'lightweight' close_behavior: 'ask' | 'close' | 'lightweight'
home_widgets: import('@/components/home/home-dashboard').HomeDashboardConfig | null home_widgets: import('@/components/home/home-dashboard').HomeDashboardConfig | null
telemetry: boolean
discord_rpc: boolean
developer_mode: boolean developer_mode: boolean
onboarded: boolean onboarded: boolean

View File

@ -5912,12 +5912,6 @@
"app.onboarding.mascot-alt": { "app.onboarding.mascot-alt": {
"message": "Starlight guide" "message": "Starlight guide"
}, },
"app.onboarding.privacy.description": {
"message": "Manage anonymous telemetry, Discord Rich Presence, and the Minecraft log analysis service whenever you need to."
},
"app.onboarding.privacy.title": {
"message": "Your data, your call"
},
"app.onboarding.resources.description": { "app.onboarding.resources.description": {
"message": "Choose how content downloads and installs, from download sources to safety checks." "message": "Choose how content downloads and installs, from download sources to safety checks."
}, },
@ -5963,30 +5957,6 @@
"app.onboarding.welcome.title": { "app.onboarding.welcome.title": {
"message": "Everything is ready" "message": "Everything is ready"
}, },
"app.privacy-consent.continue": {
"message": "Save and continue"
},
"app.privacy-consent.discord-rpc": {
"message": "Discord Rich Presence"
},
"app.privacy-consent.discord-rpc-description": {
"message": "Shows your current launcher or game activity in Discord when Discord is running."
},
"app.privacy-consent.intro": {
"message": "Choose what Starlight may send or display. Nothing is sent until you confirm these choices."
},
"app.privacy-consent.privacy-policy": {
"message": "Read the privacy policy"
},
"app.privacy-consent.telemetry": {
"message": "Allow anonymous telemetry"
},
"app.privacy-consent.telemetry-description": {
"message": "Helps count opted-in installations and daily active users. Full Minecraft logs and account credentials are never uploaded."
},
"app.privacy-consent.title": {
"message": "Privacy & security"
},
"app.project.curseforge.loading": { "app.project.curseforge.loading": {
"message": "Loading CurseForge project…" "message": "Loading CurseForge project…"
}, },
@ -6806,9 +6776,6 @@
"app.settings.about.preview-minecraft-crash-modal": { "app.settings.about.preview-minecraft-crash-modal": {
"message": "Preview Minecraft crash window" "message": "Preview Minecraft crash window"
}, },
"app.settings.about.preview-privacy-consent-modal": {
"message": "Preview privacy & security modal"
},
"app.settings.about.product-title": { "app.settings.about.product-title": {
"message": "About {productName}" "message": "About {productName}"
}, },
@ -7070,21 +7037,6 @@
"app.settings.java.view-installed": { "app.settings.java.view-installed": {
"message": "View installed Java" "message": "View installed Java"
}, },
"app.settings.privacy.data-handling": {
"message": "Telemetry uses a random installation identifier and sends only a daily activity signal. Turning telemetry off clears pending data immediately."
},
"app.settings.privacy.discord-rpc": {
"message": "Discord Rich Presence"
},
"app.settings.privacy.discord-rpc-description": {
"message": "Show your current launcher or game activity in Discord."
},
"app.settings.privacy.telemetry": {
"message": "Allow telemetry"
},
"app.settings.privacy.telemetry-description": {
"message": "Send one anonymous daily activity signal to improve usage statistics. Minecraft logs and account credentials are never uploaded."
},
"app.settings.resources.add-minecraft-directory": { "app.settings.resources.add-minecraft-directory": {
"message": "Add .minecraft directory" "message": "Add .minecraft directory"
}, },
@ -7466,9 +7418,6 @@
"app.settings.tabs.network-multiplayer": { "app.settings.tabs.network-multiplayer": {
"message": "Network & multiplayer" "message": "Network & multiplayer"
}, },
"app.settings.tabs.privacy-data": {
"message": "Privacy & data sharing"
},
"app.settings.tabs.storage": { "app.settings.tabs.storage": {
"message": "Storage" "message": "Storage"
}, },

View File

@ -104,24 +104,42 @@
"app.easteregg.color-mine.revealed": { "app.easteregg.color-mine.revealed": {
"message": "第 {row} 行,第 {column} 列,颜色 {color},周围同色 {number} 格" "message": "第 {row} 行,第 {column} 列,颜色 {color},周围同色 {number} 格"
}, },
"app.settings.developer.announcement-preview": { "message": "公告样式预览" }, "app.settings.developer.announcement-preview": {
"message": "公告样式预览"
},
"app.settings.developer.announcement-preview-description": { "app.settings.developer.announcement-preview-description": {
"message": "使用本地示例预览真实公告组件,不请求远端公告,也不改变真实公告的已读状态。" "message": "使用本地示例预览真实公告组件,不请求远端公告,也不改变真实公告的已读状态。"
}, },
"app.settings.developer.preview-with-action": { "message": "显示可选的外部链接按钮" }, "app.settings.developer.preview-with-action": {
"app.settings.developer.preview-announcement-modal": { "message": "预览启动弹窗" }, "message": "显示可选的外部链接按钮"
"app.settings.developer.preview-announcement-popup": { "message": "预览 Popup 通知" }, },
"app.remote-announcements.preview-title": { "message": "公告样式预览" }, "app.settings.developer.preview-announcement-modal": {
"message": "预览启动弹窗"
},
"app.settings.developer.preview-announcement-popup": {
"message": "预览 Popup 通知"
},
"app.remote-announcements.preview-title": {
"message": "公告样式预览"
},
"app.remote-announcements.preview-summary": { "app.remote-announcements.preview-summary": {
"message": "这是一条本地预览通知,点击“查看公告”可查看完整 Markdown 正文。" "message": "这是一条本地预览通知,点击“查看公告”可查看完整 Markdown 正文。"
}, },
"app.remote-announcements.preview-action": { "message": "访问官网" }, "app.remote-announcements.preview-action": {
"message": "访问官网"
},
"app.remote-announcements.preview-content": { "app.remote-announcements.preview-content": {
"message": "## 公告预览\n\n这是**示例正文**,不是已发布公告。\n\n- 支持标题、列表和链接\n- 默认自带关闭按钮\n\n> 预览不会影响真实公告的已读状态。\n\n| 类型 | 展示方式 |\n| --- | --- |\n| 启动弹窗 | 完整 Markdown 正文 |\n| Popup 通知 | 先显示摘要,再查看全文 |\n\n[访问官网](https://axlmc.org)" "message": "## 公告预览\n\n这是**示例正文**,不是已发布公告。\n\n- 支持标题、列表和链接\n- 默认自带关闭按钮\n\n> 预览不会影响真实公告的已读状态。\n\n| 类型 | 展示方式 |\n| --- | --- |\n| 启动弹窗 | 完整 Markdown 正文 |\n| Popup 通知 | 先显示摘要,再查看全文 |\n\n[访问官网](https://axlmc.org)"
}, },
"app.remote-announcements.view": { "message": "查看公告" }, "app.remote-announcements.view": {
"app.remote-announcements.unread": { "message": "未读" }, "message": "查看公告"
"app.remote-announcements.read-all": { "message": "将所有公告标为已读" }, },
"app.remote-announcements.unread": {
"message": "未读"
},
"app.remote-announcements.read-all": {
"message": "将所有公告标为已读"
},
"app.account.signed-in-as": { "app.account.signed-in-as": {
"message": "登录身份:" "message": "登录身份:"
}, },
@ -5984,12 +6002,6 @@
"app.onboarding.mascot-alt": { "app.onboarding.mascot-alt": {
"message": "引导吉祥物" "message": "引导吉祥物"
}, },
"app.onboarding.privacy.description": {
"message": "匿名遥测、Discord 活动状态与 Minecraft 日志分析服务都可以在这里调整。"
},
"app.onboarding.privacy.title": {
"message": "你的数据,由你决定"
},
"app.onboarding.resources.description": { "app.onboarding.resources.description": {
"message": "选择内容的下载与安装方式,包括下载源和安全确认。" "message": "选择内容的下载与安装方式,包括下载源和安全确认。"
}, },
@ -6035,30 +6047,6 @@
"app.onboarding.welcome.title": { "app.onboarding.welcome.title": {
"message": "一切就绪" "message": "一切就绪"
}, },
"app.privacy-consent.continue": {
"message": "保存并继续"
},
"app.privacy-consent.discord-rpc": {
"message": "Discord 活动状态"
},
"app.privacy-consent.discord-rpc-description": {
"message": "Discord 运行时显示你当前使用启动器或游玩的状态。"
},
"app.privacy-consent.intro": {
"message": "选择 Starlight 可以发送或展示的内容。确认前不会发送任何遥测。"
},
"app.privacy-consent.privacy-policy": {
"message": "阅读隐私政策"
},
"app.privacy-consent.telemetry": {
"message": "允许匿名遥测"
},
"app.privacy-consent.telemetry-description": {
"message": "用于统计选择加入的安装量和每日活跃用户。绝不上传完整 Minecraft 日志或账户凭据。"
},
"app.privacy-consent.title": {
"message": "隐私与安全"
},
"app.project.curseforge.loading": { "app.project.curseforge.loading": {
"message": "正在加载 CurseForge 项目…" "message": "正在加载 CurseForge 项目…"
}, },
@ -6851,9 +6839,6 @@
"app.settings.about.preview-minecraft-crash-modal": { "app.settings.about.preview-minecraft-crash-modal": {
"message": "预览 Minecraft 崩溃窗口" "message": "预览 Minecraft 崩溃窗口"
}, },
"app.settings.about.preview-privacy-consent-modal": {
"message": "预览隐私与安全弹窗"
},
"app.settings.about.product-title": { "app.settings.about.product-title": {
"message": "关于 {productName}" "message": "关于 {productName}"
}, },
@ -7100,21 +7085,6 @@
"app.settings.java.view-installed": { "app.settings.java.view-installed": {
"message": "查看已安装 Java" "message": "查看已安装 Java"
}, },
"app.settings.privacy.data-handling": {
"message": "遥测使用随机安装标识,仅发送每日活跃信号。关闭遥测会立即清空待发送数据。"
},
"app.settings.privacy.discord-rpc": {
"message": "Discord 活动状态"
},
"app.settings.privacy.discord-rpc-description": {
"message": "在 Discord 中显示你当前使用启动器或游玩的状态。"
},
"app.settings.privacy.telemetry": {
"message": "允许遥测"
},
"app.settings.privacy.telemetry-description": {
"message": "发送匿名每日活跃信号以优化使用统计。绝不上传 Minecraft 日志或账户凭据。"
},
"app.settings.resources.app-cache": { "app.settings.resources.app-cache": {
"message": "应用缓存" "message": "应用缓存"
}, },
@ -7505,9 +7475,6 @@
"app.settings.tabs.network-multiplayer": { "app.settings.tabs.network-multiplayer": {
"message": "网络与多人游戏" "message": "网络与多人游戏"
}, },
"app.settings.tabs.privacy-data": {
"message": "隐私与数据共享"
},
"app.settings.tabs.resource-management": { "app.settings.tabs.resource-management": {
"message": "资源管理" "message": "资源管理"
}, },
@ -10414,6 +10381,5 @@
}, },
"app.settings.about.easteregg.game-title": { "app.settings.about.easteregg.game-title": {
"message": "彩蛋小游戏" "message": "彩蛋小游戏"
} }
} }

View File

@ -5723,12 +5723,6 @@
"app.onboarding.mascot-alt": { "app.onboarding.mascot-alt": {
"message": "引導吉祥物" "message": "引導吉祥物"
}, },
"app.onboarding.privacy.description": {
"message": "匿名遙測、Discord 活動狀態與 Minecraft 日誌分析服務都可以在這裡調整。"
},
"app.onboarding.privacy.title": {
"message": "你的資料,由你決定"
},
"app.onboarding.resources.description": { "app.onboarding.resources.description": {
"message": "調節下載、儲存和啟動器資源。給風扇一點尊嚴。" "message": "調節下載、儲存和啟動器資源。給風扇一點尊嚴。"
}, },
@ -5774,30 +5768,6 @@
"app.onboarding.welcome.title": { "app.onboarding.welcome.title": {
"message": "一切就緒" "message": "一切就緒"
}, },
"app.privacy-consent.continue": {
"message": "儲存並繼續"
},
"app.privacy-consent.discord-rpc": {
"message": "Discord 活動狀態"
},
"app.privacy-consent.discord-rpc-description": {
"message": "Discord 執行時顯示你當前使用啟動器或遊玩的狀態。"
},
"app.privacy-consent.intro": {
"message": "選擇 Starlight 可以傳送或展示的內容。確認前不會傳送任何遙測。"
},
"app.privacy-consent.privacy-policy": {
"message": "閱讀隱私政策"
},
"app.privacy-consent.telemetry": {
"message": "允許匿名遙測"
},
"app.privacy-consent.telemetry-description": {
"message": "透過脫敏且限長的報告統計選擇加入的安裝量並排查啟動器錯誤。不會上傳完整 Minecraft 日誌或賬戶憑據。"
},
"app.privacy-consent.title": {
"message": "隱私與安全"
},
"app.project.curseforge.loading": { "app.project.curseforge.loading": {
"message": "正在載入 CurseForge 專案..." "message": "正在載入 CurseForge 專案..."
}, },
@ -6566,9 +6536,6 @@
"app.settings.about.preview-minecraft-crash-modal": { "app.settings.about.preview-minecraft-crash-modal": {
"message": "預覽 Minecraft 崩潰窗口" "message": "預覽 Minecraft 崩潰窗口"
}, },
"app.settings.about.preview-privacy-consent-modal": {
"message": "預覽隱私與安全彈窗"
},
"app.settings.about.product-title": { "app.settings.about.product-title": {
"message": "關於 {productName}" "message": "關於 {productName}"
}, },
@ -6815,21 +6782,6 @@
"app.settings.java.view-installed": { "app.settings.java.view-installed": {
"message": "查看已安裝 Java" "message": "查看已安裝 Java"
}, },
"app.settings.privacy.data-handling": {
"message": "遙測使用隨機安裝標識。錯誤上下文離開裝置前會經過脫敏和長度限制。關閉遙測會立即清空待傳送報告。"
},
"app.settings.privacy.discord-rpc": {
"message": "Discord 活動狀態"
},
"app.settings.privacy.discord-rpc-description": {
"message": "在 Discord 中顯示你當前使用啟動器或遊玩的狀態。"
},
"app.settings.privacy.telemetry": {
"message": "允許遙測"
},
"app.settings.privacy.telemetry-description": {
"message": "傳送匿名每日活躍訊號和脫敏的啟動器錯誤報告。絕不上傳 Minecraft 日誌或賬戶憑據。"
},
"app.settings.resources.app-cache": { "app.settings.resources.app-cache": {
"message": "應用程式快取" "message": "應用程式快取"
}, },
@ -7220,9 +7172,6 @@
"app.settings.tabs.network-multiplayer": { "app.settings.tabs.network-multiplayer": {
"message": "網路與多人遊戲" "message": "網路與多人遊戲"
}, },
"app.settings.tabs.privacy-data": {
"message": "隱私與資料共享"
},
"app.settings.tabs.resource-management": { "app.settings.tabs.resource-management": {
"message": "資源管理" "message": "資源管理"
}, },

View File

@ -9,7 +9,6 @@ import { createApp } from 'vue'
import App from '@/App.vue' import App from '@/App.vue'
import { overlayScrollbarsDirective } from '@/directives/overlayScrollbars' import { overlayScrollbarsDirective } from '@/directives/overlayScrollbars'
import { installTelemetryHandlers } from '@/helpers/telemetry'
import i18nPlugin from '@/plugins/i18n' import i18nPlugin from '@/plugins/i18n'
import i18nDebugPlugin from '@/plugins/i18n-debug' import i18nDebugPlugin from '@/plugins/i18n-debug'
import router from '@/routes' import router from '@/routes'
@ -18,8 +17,6 @@ const pinia = createPinia()
const app = createApp(App) const app = createApp(App)
installTelemetryHandlers()
app.use(VueQueryPlugin) app.use(VueQueryPlugin)
app.use(router) app.use(router)
app.use(pinia) app.use(pinia)

View File

@ -137,11 +137,11 @@
</template> </template>
</div> </div>
<div <div
v-if="downloadTelemetry(job).length" v-if="downloadDetails(job).length"
class="mt-1 flex flex-wrap items-center gap-2 text-sm text-secondary" class="mt-1 flex flex-wrap items-center gap-2 text-sm text-secondary"
> >
<template <template
v-for="(metric, index) in downloadTelemetry(job)" v-for="(metric, index) in downloadDetails(job)"
:key="`${index}-${metric}`" :key="`${index}-${metric}`"
> >
<BulletDivider v-if="index > 0" /> <BulletDivider v-if="index > 0" />
@ -1058,7 +1058,7 @@ function totalRequiredFiles(job: InstallJobSnapshot) {
return Math.max(job.summary.files_total ?? 0, job.items.length, completed + missing) return Math.max(job.summary.files_total ?? 0, job.items.length, completed + missing)
} }
function downloadTelemetry(job: InstallJobSnapshot) { function downloadDetails(job: InstallJobSnapshot) {
const summary = job.summary const summary = job.summary
const metrics: string[] = [] const metrics: string[] = []
if (summary.source && !isRecoveryValidation(job)) { if (summary.source && !isRecoveryValidation(job)) {

View File

@ -419,7 +419,6 @@ import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { useNetworkStatus } from '@/composables/useNetworkStatus' import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { postUpgradeNoticeQueryKey, usePostUpgradeNotice } from '@/composables/usePostUpgradeNotice' import { postUpgradeNoticeQueryKey, usePostUpgradeNotice } from '@/composables/usePostUpgradeNotice'
import { useSymlinkWarningDismiss } from '@/composables/useSymlinkWarningDismiss' import { useSymlinkWarningDismiss } from '@/composables/useSymlinkWarningDismiss'
import { trackEvent } from '@/helpers/analytics'
import { get_project_v3 } from '@/helpers/cache.js' import { get_project_v3 } from '@/helpers/cache.js'
import { instance_listener, process_listener } from '@/helpers/events' import { instance_listener, process_listener } from '@/helpers/events'
import { import {
@ -776,11 +775,6 @@ const startInstance = async (context: string) => {
loading.value = false loading.value = false
} }
trackEvent('InstanceStart', {
loader: instance.value.loader,
game_version: instance.value.game_version,
source: context,
})
} }
const stopInstance = async (context: string) => { const stopInstance = async (context: string) => {
@ -790,11 +784,6 @@ const stopInstance = async (context: string) => {
playing.value = false playing.value = false
if (!instance.value) return if (!instance.value) return
trackEvent('InstanceStop', {
loader: instance.value.loader,
game_version: instance.value.game_version,
source: context,
})
} }
const handlePlayServer = async () => { const handlePlayServer = async () => {

View File

@ -264,7 +264,6 @@ import ContentToggleDependenciesModal from '@/components/ui/modal/ContentToggleD
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue' import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
import { postUpgradeNoticeQueryKey, usePostUpgradeNotice } from '@/composables/usePostUpgradeNotice' import { postUpgradeNoticeQueryKey, usePostUpgradeNotice } from '@/composables/usePostUpgradeNotice'
import { useWorldDatapacks } from '@/composables/useWorldDatapacks' import { useWorldDatapacks } from '@/composables/useWorldDatapacks'
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_version, get_version_many } from '@/helpers/cache.js' import { get_project_versions, get_version, get_version_many } from '@/helpers/cache.js'
import { applyContentItemUpdates, matchesContentItem } from '@/helpers/content-item-state' import { applyContentItemUpdates, matchesContentItem } from '@/helpers/content-item-state'
import { lookupContentWikiIds, translateContentItemTitles } from '@/helpers/content-search' import { lookupContentWikiIds, translateContentItemTitles } from '@/helpers/content-search'
@ -1580,14 +1579,6 @@ async function toggleDisableMod(
const enabled = desiredEnabled ?? !mod.enabled const enabled = desiredEnabled ?? !mod.enabled
try { try {
await toggleWorldDatapackItem(mod, enabled) await toggleWorldDatapackItem(mod, enabled)
trackEvent('InstanceProjectDisable', {
loader: props.instance.loader,
game_version: props.instance.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
disabled: !enabled,
})
} catch (err) { } catch (err) {
handleError(err as Error) handleError(err as Error)
} }
@ -1646,14 +1637,6 @@ async function applyToggleDisableMod(mod: ContentItem, enabled: boolean) {
enabled: actualEnabled, enabled: actualEnabled,
}) })
trackEvent('InstanceProjectDisable', {
loader: props.instance.loader,
game_version: props.instance.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
disabled: !actualEnabled,
})
} catch (err) { } catch (err) {
applyContentItemToggleState(mod, operation.originalFileName, originalFilePath, { applyContentItemToggleState(mod, operation.originalFileName, originalFilePath, {
file_path: originalFilePath, file_path: originalFilePath,
@ -1733,14 +1716,6 @@ async function applyToggleDisableBatch(items: ContentItem[], enabled: boolean) {
) )
for (const operation of operations) { for (const operation of operations) {
trackEvent('InstanceProjectDisable', {
loader: props.instance.loader,
game_version: props.instance.game_version,
id: operation.item.project?.id,
name: operation.item.project?.title ?? operation.originalFileName,
project_type: operation.item.project_type,
disabled: !enabled,
})
} }
} catch (error) { } catch (error) {
for (const operation of operations) { for (const operation of operations) {
@ -1831,13 +1806,6 @@ async function removeMod(mod: ContentItem) {
projects.value = projects.value.filter((x) => removedPath !== x.file_path) projects.value = projects.value.filter((x) => removedPath !== x.file_path)
} }
trackEvent('InstanceProjectRemove', {
loader: props.instance.loader,
game_version: props.instance.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
})
} catch (err) { } catch (err) {
handleError(err as Error) handleError(err as Error)
} finally { } finally {
@ -1999,13 +1967,6 @@ async function updateProject(mod: ContentItem) {
try { try {
await update_content_entry(props.instance.id, contentId) await update_content_entry(props.instance.id, contentId)
trackEvent('InstanceProjectUpdate', {
loader: props.instance.loader,
game_version: props.instance.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
})
} catch (err) { } catch (err) {
handleError(err as Error) handleError(err as Error)
throw err throw err
@ -2024,13 +1985,6 @@ async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions
try { try {
await switch_content_entry_version(props.instance.id, contentId, version.id) await switch_content_entry_version(props.instance.id, contentId, version.id)
trackEvent('InstanceProjectUpdate', {
loader: props.instance.loader,
game_version: props.instance.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
})
} catch (err) { } catch (err) {
handleError(err as Error) handleError(err as Error)
} finally { } finally {
@ -2046,13 +2000,6 @@ async function handleRollbackContent(mod: ContentItem) {
try { try {
await rollback_project(props.instance.id, mod.file_path) await rollback_project(props.instance.id, mod.file_path)
trackEvent('InstanceProjectRollback', {
loader: props.instance.loader,
game_version: props.instance.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
})
} catch (err) { } catch (err) {
handleError(err as Error) handleError(err as Error)
} finally { } finally {

View File

@ -204,7 +204,6 @@ import ConfirmRemoveWorldModal from '@/components/ui/world/modal/ConfirmRemoveWo
import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue' import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue'
import WorldItem from '@/components/ui/world/WorldItem.vue' import WorldItem from '@/components/ui/world/WorldItem.vue'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError' import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { trackEvent } from '@/helpers/analytics'
import { get_project, get_project_v3 } from '@/helpers/cache.js' import { get_project, get_project_v3 } from '@/helpers/cache.js'
import { instance_listener } from '@/helpers/events' import { instance_listener } from '@/helpers/events'
import { get_game_versions } from '@/helpers/tags' import { get_game_versions } from '@/helpers/tags'
@ -592,20 +591,10 @@ async function joinWorld(world: World) {
const managedProjectId = instance.value.link?.project_id const managedProjectId = instance.value.link?.project_id
if (managedProjectId && isManagedServerWorld(world)) { if (managedProjectId && isManagedServerWorld(world)) {
await playServerProject(managedProjectId).catch(handleJoinError) await playServerProject(managedProjectId).catch(handleJoinError)
trackEvent('InstanceStart', {
loader: instance.value.loader,
game_version: instance.value.game_version,
source: 'WorldsPage',
})
startingInstance.value = false startingInstance.value = false
return return
} }
await start_join_server(instance.value.id, world.address).catch(handleJoinError) await start_join_server(instance.value.id, world.address).catch(handleJoinError)
trackEvent('InstanceStart', {
loader: instance.value.loader,
game_version: instance.value.game_version,
source: 'WorldsPage',
})
} else if (world.type === 'singleplayer') { } else if (world.type === 'singleplayer') {
await start_join_singleplayer_world(instance.value.id, world.path).catch(handleJoinError) await start_join_singleplayer_world(instance.value.id, world.path).catch(handleJoinError)
} }

View File

@ -18,7 +18,6 @@ import {
} from '@modrinth/ui' } from '@modrinth/ui'
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { trackEvent } from '@/helpers/analytics'
import { import {
type ProjectGalleryCaptionField, type ProjectGalleryCaptionField,
projectGalleryTranslationSegmentId, projectGalleryTranslationSegmentId,
@ -130,10 +129,6 @@ function viewImage(entry: GalleryEntry) {
zoomedIn.value = false zoomedIn.value = false
viewerModal.value?.show() viewerModal.value?.show()
trackEvent('GalleryImageExpand', {
project_id: props.project.id,
url: entry.image.url,
})
} }
function changeImage(offset: number) { function changeImage(offset: number) {
@ -146,10 +141,6 @@ function changeImage(offset: number) {
selectedGalleryItem.value = filteredGallery.value[nextIndex] selectedGalleryItem.value = filteredGallery.value[nextIndex]
zoomedIn.value = false zoomedIn.value = false
trackEvent(offset > 0 ? 'GalleryImageNext' : 'GalleryImagePrevious', {
project_id: props.project.id,
url: selectedGalleryItem.value.image.url,
})
} }
function handleViewerHide() { function handleViewerHide() {

View File

@ -210,25 +210,6 @@
{{ installButtonLabel }} {{ installButtonLabel }}
</button> </button>
</ButtonStyled> </ButtonStyled>
<!-- 开服功能暂有问题,隐藏该按钮
<Transition name="start-server">
<ButtonStyled
v-if="serverCapableModpack"
key="modpack-start-server"
size="large"
type="outlined"
>
<button
v-tooltip="formatMessage(messages.startServer)"
type="button"
@click="openModpackServerFlow"
>
<ServerIcon />
{{ formatMessage(messages.startServer) }}
</button>
</ButtonStyled>
</Transition>
-->
<ButtonStyled size="large" circular type="transparent"> <ButtonStyled size="large" circular type="transparent">
<OverflowMenu <OverflowMenu
:tooltip="`More options`" :tooltip="`More options`"
@ -339,7 +320,6 @@
:translations="translations" :translations="translations"
:translation-mode="translationMode" :translation-mode="translationMode"
:translation-style="translationStyle" :translation-style="translationStyle"
:start-server="(version) => openModpackServerFlow(version)"
/> />
</template> </template>
<template v-else> Project data couldn't not be loaded. </template> <template v-else> Project data couldn't not be loaded. </template>
@ -408,7 +388,6 @@ import {
PlusIcon, PlusIcon,
ReportIcon, ReportIcon,
SearchIcon, SearchIcon,
ServerIcon,
SpinnerIcon, SpinnerIcon,
StopCircleIcon, StopCircleIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
@ -424,7 +403,6 @@ import {
getLatestMatchingInstallVersion, getLatestMatchingInstallVersion,
getTargetInstallPreferences, getTargetInstallPreferences,
injectNotificationManager, injectNotificationManager,
injectPopupNotificationManager,
NavTabs, NavTabs,
OverflowMenu, OverflowMenu,
ProjectBackgroundGradient, ProjectBackgroundGradient,
@ -501,7 +479,6 @@ import UpgradeProjectReturnBar from './UpgradeProjectReturnBar.vue'
dayjs.extend(relativeTime) dayjs.extend(relativeTime)
const { addNotification, handleError } = injectNotificationManager() const { addNotification, handleError } = injectNotificationManager()
const popupNotificationManager = injectPopupNotificationManager()
const { install: installVersion } = injectContentInstall() const { install: installVersion } = injectContentInstall()
const contentSelection = injectContentSelection() const contentSelection = injectContentSelection()
const route = useRoute() const route = useRoute()
@ -603,22 +580,6 @@ const messages = defineMessages({
id: 'project.actions.view-modpacks', id: 'project.actions.view-modpacks',
defaultMessage: 'View modpacks', defaultMessage: 'View modpacks',
}, },
startServer: {
id: 'app.project.modpack-server.start',
defaultMessage: 'Start server',
},
serverCreated: {
id: 'app.project.modpack-server.created-title',
defaultMessage: 'Server created',
},
serverCreatedDescription: {
id: 'app.project.modpack-server.created-description',
defaultMessage: '{name} is ready. Configure and start it in Multiplayer.',
},
openServer: {
id: 'app.project.modpack-server.open',
defaultMessage: 'Open server',
},
}) })
const { installingServerProjects, playServerProject, showAddServerToInstanceModal } = const { installingServerProjects, playServerProject, showAddServerToInstanceModal } =
@ -642,15 +603,6 @@ const favoriteSaved = computed(() =>
data.value ? contentFavorites.isFavorite('modrinth', data.value.id) : false, data.value ? contentFavorites.isFavorite('modrinth', data.value.id) : false,
) )
const serverCapableModpack = computed(
() => data.value?.project_type === 'modpack' && data.value.server_side !== 'unsupported',
)
async function openModpackServerFlow() {
// 服务器创建弹窗CreateModpackServerModal已在上游移除此流程暂不可用。
// 如需恢复:重新实现该组件,并在此处调用其 show() 方法。
}
async function toggleFavorite() { async function toggleFavorite() {
if (!data.value || !favoriteSupported.value || favoritePending.value) return if (!data.value || !favoriteSupported.value || favoritePending.value) return
await contentFavorites await contentFavorites

View File

@ -30,16 +30,6 @@
<CheckIcon v-else /> <CheckIcon v-else />
</button> </button>
</ButtonStyled> </ButtonStyled>
<!-- 开服功能暂有问题,隐藏该按钮
<ButtonStyled v-if="serverCapable && startServer" circular type="transparent">
<button
v-tooltip="formatMessage(messages.startServer)"
@click.stop="() => startServer(version)"
>
<ServerIcon />
</button>
</ButtonStyled>
-->
<ButtonStyled circular type="transparent"> <ButtonStyled circular type="transparent">
<OverflowMenu <OverflowMenu
v-if="false" v-if="false"
@ -87,7 +77,6 @@ import {
DownloadIcon, DownloadIcon,
ExternalIcon, ExternalIcon,
MoreVerticalIcon, MoreVerticalIcon,
ServerIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { import {
ButtonStyled, ButtonStyled,
@ -98,7 +87,7 @@ import {
ProjectPageVersions, ProjectPageVersions,
useVIntl, useVIntl,
} from '@modrinth/ui' } from '@modrinth/ui'
import { computed, ref } from 'vue' import { ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { SwapIcon } from '@/assets/icons/index.js' import { SwapIcon } from '@/assets/icons/index.js'
@ -117,10 +106,6 @@ const messages = defineMessages({
id: 'app.project.versions.add-to-another-instance', id: 'app.project.versions.add-to-another-instance',
defaultMessage: 'Add to another instance', defaultMessage: 'Add to another instance',
}, },
startServer: {
id: 'app.project.versions.start-server',
defaultMessage: 'Create server',
},
}) })
const props = defineProps({ const props = defineProps({
@ -152,16 +137,8 @@ const props = defineProps({
type: String, type: String,
default: null, default: null,
}, },
startServer: {
type: Function,
default: null,
},
}) })
const serverCapable = computed(
() => props.project?.project_type === 'modpack' && props.project?.server_side !== 'unsupported',
)
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
const route = useRoute() const route = useRoute()

View File

@ -15,7 +15,6 @@ import type { Router } from 'vue-router'
import type ContentInstallPreviewModal from '@/components/ui/ContentInstallPreviewModal.vue' import type ContentInstallPreviewModal from '@/components/ui/ContentInstallPreviewModal.vue'
import type { ContentInstallPreviewData } from '@/components/ui/ContentInstallPreviewModal.vue' import type { ContentInstallPreviewData } from '@/components/ui/ContentInstallPreviewModal.vue'
import type { ModpackInstallModalData } from '@/components/ui/modal/ModpackInstallModal.vue' import type { ModpackInstallModalData } from '@/components/ui/modal/ModpackInstallModal.vue'
import { trackEvent } from '@/helpers/analytics'
import { import {
get_organization, get_organization,
get_project, get_project,
@ -868,12 +867,6 @@ export function createContentInstall(opts: {
}) })
removeInstallingItems(createdInstanceId, [project.id]) removeInstallingItems(createdInstanceId, [project.id])
markInstanceContentChanged(createdInstanceId) markInstanceContentChanged(createdInstanceId)
trackEvent('PackInstall', {
id: project.id,
version_id: version.id,
title: project.title,
source,
})
callback(version.id, [project.id]) callback(version.id, [project.id])
} catch (err) { } catch (err) {
debugState('createAndInstallCurseForgeModpack ERR', { err: String(err), createdInstanceId }) debugState('createAndInstallCurseForgeModpack ERR', { err: String(err), createdInstanceId })
@ -997,7 +990,6 @@ export function createContentInstall(opts: {
if (sessionId !== currentSessionId) return if (sessionId !== currentSessionId) return
contentInstallModalOpen = true contentInstallModalOpen = true
modalRef?.show() modalRef?.show()
trackEvent('ProjectInstallStart', { source: 'ProjectInstallModal' })
} }
get_game_versions() get_game_versions()
@ -1068,7 +1060,6 @@ export function createContentInstall(opts: {
await nextTick() await nextTick()
contentInstallModalOpen = true contentInstallModalOpen = true
modalRef?.show() modalRef?.show()
trackEvent('ProjectInstallStart', { source: 'ProjectInstallModal' })
return sessionId return sessionId
} }
@ -1688,15 +1679,6 @@ export function createContentInstall(opts: {
if (storeInstance) storeInstance.installing = true if (storeInstance) storeInstance.installing = true
try { try {
await queueCurrentCurseForgeWorld(selectedInstance) await queueCurrentCurseForgeWorld(selectedInstance)
trackEvent('ProjectInstall', {
loader: selectedInstance.loader,
game_version: selectedInstance.game_version,
id: currentProject.id,
version_id: currentWorldFileId ?? '',
project_type: 'world',
title: currentProject.title,
source: 'ProjectInstallModal',
})
settleCurrentCallback(currentWorldFileId ?? undefined, [currentProject.id]) settleCurrentCallback(currentWorldFileId ?? undefined, [currentProject.id])
hideContentInstallModal() hideContentInstallModal()
} catch (err) { } catch (err) {
@ -1758,15 +1740,6 @@ export function createContentInstall(opts: {
{ ...request, excluded_project_ids: excludedProjectIds }, { ...request, excluded_project_ids: excludedProjectIds },
{ title: currentProject.title, iconUrl: currentProject.icon_url }, { title: currentProject.title, iconUrl: currentProject.icon_url },
) )
trackEvent('ProjectInstall', {
loader: selectedInstance.loader,
game_version: selectedInstance.game_version,
id: currentProject.id,
version_id: version.id,
project_type: currentProject.project_type,
title: currentProject.title,
source: 'ProjectInstallModal',
})
settleCurrentCallback(version.id, [currentProject.id]) settleCurrentCallback(version.id, [currentProject.id])
hideContentInstallModal() hideContentInstallModal()
} catch (err) { } catch (err) {
@ -1786,15 +1759,6 @@ export function createContentInstall(opts: {
settleCurrentCallback() settleCurrentCallback()
return return
} }
trackEvent('ProjectInstall', {
loader: selectedInstance.loader,
game_version: selectedInstance.game_version,
id: currentProject.id,
version_id: version.id,
project_type: currentProject.project_type,
title: currentProject.title,
source: 'ProjectInstallModal',
})
settleCurrentCallback(version.id, [currentProject.id]) settleCurrentCallback(version.id, [currentProject.id])
hideContentInstallModal() hideContentInstallModal()
} catch (err) { } catch (err) {
@ -1830,15 +1794,6 @@ export function createContentInstall(opts: {
storeInstance.installed = primaryInstalled storeInstance.installed = primaryInstalled
storeInstance.installing = false storeInstance.installing = false
} }
trackEvent('ProjectInstall', {
loader: selectedInstance.loader,
game_version: selectedInstance.game_version,
id: currentProject!.id,
version_id: version.id,
project_type: currentProject!.project_type,
title: currentProject!.title,
source: 'ProjectInstallModal',
})
settleCurrentCallback(primaryInstalled ? version.id : undefined, installedProjectIds) settleCurrentCallback(primaryInstalled ? version.id : undefined, installedProjectIds)
} catch (err) { } catch (err) {
if (storeInstance) storeInstance.installing = false if (storeInstance) storeInstance.installing = false
@ -1878,7 +1833,6 @@ export function createContentInstall(opts: {
await nextTick() await nextTick()
incompatibilityWarningModalRef?.show(version.id) incompatibilityWarningModalRef?.show(version.id)
trackEvent('ProjectInstallStart', { source: 'ProjectIncompatibilityWarningModal' })
} }
async function handleIncompatibilityWarningInstall(version: Labrinth.Versions.v2.Version) { async function handleIncompatibilityWarningInstall(version: Labrinth.Versions.v2.Version) {
@ -1931,15 +1885,6 @@ export function createContentInstall(opts: {
incompatibilityWarningModalRef?.hide() incompatibilityWarningModalRef?.hide()
removeInstallingItems(instance.id, [project.id]) removeInstallingItems(instance.id, [project.id])
trackEvent('ProjectInstall', {
loader: instance.loader ?? '',
game_version: instance.game_version ?? '',
id: project.id,
version_id: version.id,
project_type: project.project_type,
title: project.title,
source: 'ProjectIncompatibilityWarningModal',
})
} }
function handleIncompatibilityWarningCancel() { function handleIncompatibilityWarningCancel() {
@ -2056,16 +2001,6 @@ export function createContentInstall(opts: {
}, },
{ title: currentProject!.title, iconUrl: currentProject!.icon_url }, { title: currentProject!.title, iconUrl: currentProject!.icon_url },
) )
trackEvent('InstanceCreate', { source: 'ProjectInstallModal' })
trackEvent('ProjectInstall', {
loader: data.loader,
game_version: data.gameVersion,
id: currentProject!.id,
version_id: version.id,
project_type: currentProject!.project_type,
title: currentProject!.title,
source: 'ProjectInstallModal',
})
settleCurrentCallback(version.id, [currentProject!.id]) settleCurrentCallback(version.id, [currentProject!.id])
hideContentInstallModal() hideContentInstallModal()
return return
@ -2085,16 +2020,6 @@ export function createContentInstall(opts: {
excludedCurseForgeProjectIds, excludedCurseForgeProjectIds,
) )
if (!job) return if (!job) return
trackEvent('InstanceCreate', { source: 'ProjectInstallModal' })
trackEvent('ProjectInstall', {
loader: data.loader,
game_version: data.gameVersion,
id: currentProject!.id,
version_id: version.id,
project_type: currentProject!.project_type,
title: currentProject!.title,
source: 'ProjectInstallModal',
})
settleCurrentCallback(version.id, [currentProject!.id]) settleCurrentCallback(version.id, [currentProject!.id])
hideContentInstallModal() hideContentInstallModal()
return return
@ -2131,18 +2056,6 @@ export function createContentInstall(opts: {
: `/instance/${encodeURIComponent(id)}`, : `/instance/${encodeURIComponent(id)}`,
) )
trackEvent('InstanceCreate', {
source: 'ProjectInstallModal',
})
trackEvent('ProjectInstall', {
loader: data.loader,
game_version: data.gameVersion,
id: currentProject!.id,
version_id: version.id,
project_type: currentProject!.project_type,
title: currentProject!.title,
source: 'ProjectInstallModal',
})
settleCurrentCallback(version.id, installedProjectIds) settleCurrentCallback(version.id, installedProjectIds)
modalRef?.hide() modalRef?.hide()
@ -2282,15 +2195,6 @@ export function createContentInstall(opts: {
{ ...installRequest, excluded_project_ids: excludedProjectIds }, { ...installRequest, excluded_project_ids: excludedProjectIds },
{ title: project.title, iconUrl: project.icon_url }, { title: project.title, iconUrl: project.icon_url },
) )
trackEvent('ProjectInstall', {
loader: instance.loader,
game_version: instance.game_version,
id: project.id,
project_type: project.project_type,
version_id: version.id,
title: project.title,
source,
})
callback(version.id, [project.id]) callback(version.id, [project.id])
} else { } else {
await showIncompatibilityWarning(instance, project, projectVersions, version, callback) await showIncompatibilityWarning(instance, project, projectVersions, version, callback)
@ -2402,15 +2306,6 @@ export function createContentInstall(opts: {
if (isVersionCompatible(version, project, instance)) { if (isVersionCompatible(version, project, instance)) {
const job = await queueCurrentCurseForgeVersion(instance, project, version) const job = await queueCurrentCurseForgeVersion(instance, project, version)
if (!job) return if (!job) return
trackEvent('ProjectInstall', {
loader: instance.loader,
game_version: instance.game_version,
id: project.id,
project_type: project.project_type,
version_id: version.id,
title: project.title,
source,
})
callback(version.id, [project.id]) callback(version.id, [project.id])
} else { } else {
await showIncompatibilityWarning(instance, project, versions, version, callback) await showIncompatibilityWarning(instance, project, versions, version, callback)
@ -2457,7 +2352,6 @@ export function createContentInstall(opts: {
if (sessionId !== currentSessionId) return if (sessionId !== currentSessionId) return
contentInstallModalOpen = true contentInstallModalOpen = true
modalRef?.show() modalRef?.show()
trackEvent('ProjectInstallStart', { source: 'ProjectInstallModal' })
try { try {
const candidates = (await list()).filter( const candidates = (await list()).filter(
@ -2537,15 +2431,6 @@ export function createContentInstall(opts: {
const instance = await get(instanceId) const instance = await get(instanceId)
if (!instance) throw new Error(formatMessage(curseForgeWorldUnknownInstanceMessage)) if (!instance) throw new Error(formatMessage(curseForgeWorldUnknownInstanceMessage))
await queueCurrentCurseForgeWorld(instance) await queueCurrentCurseForgeWorld(instance)
trackEvent('ProjectInstall', {
loader: instance.loader,
game_version: instance.game_version,
id: currentProject!.id,
version_id: file.id.toString(),
project_type: 'world',
title: currentProject!.title,
source,
})
callback(file.id.toString(), [currentProject!.id]) callback(file.id.toString(), [currentProject!.id])
} }
@ -2700,12 +2585,6 @@ export function createContentInstall(opts: {
if (instanceId) { if (instanceId) {
createInstanceCallback(instanceId) createInstanceCallback(instanceId)
} }
trackEvent('PackInstall', {
id: project.id,
version_id: versionId,
title: project.title,
source,
})
callback(versionId) callback(versionId)
}, },
handleModpackInstallCancel() { handleModpackInstallCancel() {

View File

@ -4,7 +4,6 @@ import { createContext } from '@modrinth/ui'
import { type Ref, ref } from 'vue' import { type Ref, ref } from 'vue'
import type { Router } from 'vue-router' import type { Router } from 'vue-router'
import { trackEvent } from '@/helpers/analytics'
import { get_project, get_project_v3, get_version } from '@/helpers/cache.js' import { get_project, get_project_v3, get_version } from '@/helpers/cache.js'
import { import {
install_create_instance, install_create_instance,
@ -163,11 +162,6 @@ export function createServerInstall(opts: {
action: async () => { action: async () => {
try { try {
await joinServer(project.id, serverAddress) await joinServer(project.id, serverAddress)
trackEvent('InstanceStart', {
loader: project.loader,
game_version: project.game_version,
source: 'ServerProject',
})
} catch (err) { } catch (err) {
handleSevereError(err, { instanceId: project.id }) handleSevereError(err, { instanceId: project.id })
} }
@ -198,11 +192,6 @@ export function createServerInstall(opts: {
action: async () => { action: async () => {
try { try {
if (serverAddress) await start_join_server(instance.id, serverAddress) if (serverAddress) await start_join_server(instance.id, serverAddress)
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'ServerProject',
})
} catch (err) { } catch (err) {
handleSevereError(err, { instanceId: instance.id }) handleSevereError(err, { instanceId: instance.id })
} }
@ -348,11 +337,6 @@ export function createServerInstall(opts: {
// Join server // Join server
try { try {
await joinServer(instance.id, serverAddress) await joinServer(instance.id, serverAddress)
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'ServerProject',
})
} catch (err) { } catch (err) {
handleSevereError(err, { instanceId: instance.id }) handleSevereError(err, { instanceId: instance.id })
} }

View File

@ -13,7 +13,6 @@ import { useRouter } from 'vue-router'
import type UnknownPackWarningModal from '@/components/ui/install_flow/UnknownPackWarningModal.vue' import type UnknownPackWarningModal from '@/components/ui/install_flow/UnknownPackWarningModal.vue'
import type ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue' import type ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue'
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_search_results } from '@/helpers/cache.js' import { get_project_versions, get_search_results } from '@/helpers/cache.js'
import { getCurseForgeFiles, hasCompatibleCurseForgeFile } from '@/helpers/curseforge' import { getCurseForgeFiles, hasCompatibleCurseForgeFile } from '@/helpers/curseforge'
import { install_job_listener } from '@/helpers/events.js' import { install_job_listener } from '@/helpers/events.js'
@ -143,7 +142,6 @@ export function setupCreationModal(
}, },
{ name }, { name },
).catch(handleError) ).catch(handleError)
trackEvent('InstanceCreate', { source: 'CreationModalModpack' })
} }
async function handleCreate(config: CreationFlowContextValue) { async function handleCreate(config: CreationFlowContextValue) {
@ -222,7 +220,6 @@ export function setupCreationModal(
handleError(error) handleError(error)
} }
} }
trackEvent('InstanceCreate', { source: 'CreationModalImport' })
return return
} }
@ -238,7 +235,6 @@ export function setupCreationModal(
const splitPath = config.modpackFilePath.value.split(/[\\/]/) const splitPath = config.modpackFilePath.value.split(/[\\/]/)
const fileName = splitPath ? splitPath[splitPath.length - 1] : config.modpackFilePath.value const fileName = splitPath ? splitPath[splitPath.length - 1] : config.modpackFilePath.value
await installModpackFromPath(config.modpackFilePath.value, fileName) await installModpackFromPath(config.modpackFilePath.value, fileName)
trackEvent('InstanceCreate', { source: 'CreationModalModpackFile' })
return return
} }
@ -281,9 +277,6 @@ export function setupCreationModal(
gameDirOverride, gameDirOverride,
}).catch(handleError) }).catch(handleError)
trackEvent('InstanceCreate', {
source: 'CreationModal',
})
} catch (err) { } catch (err) {
handleError(err as Error) handleError(err as Error)
} }

View File

@ -509,10 +509,6 @@ fn main() {
.commands(&[ .commands(&[
"settings_get", "settings_get",
"settings_set", "settings_set",
"privacy_get",
"privacy_set",
"telemetry_set",
"discord_rpc_set",
"download_engine_set", "download_engine_set",
"cancel_directory_change", "cancel_directory_change",
"proxy_get", "proxy_get",

View File

@ -6,7 +6,6 @@ use path_util::SafeRelativeUtf8UnixPathBuf;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use theseus::DownloadReason;
use theseus::data::{ use theseus::data::{
AppliedContentSetPatch, ContentItem, Dependency, AppliedContentSetPatch, ContentItem, Dependency,
EditInstance as CoreEditInstance, InstanceInstallCandidate, EditInstance as CoreEditInstance, InstanceInstallCandidate,
@ -1116,16 +1115,11 @@ pub async fn instance_update_project(
pub async fn instance_add_project_from_version( pub async fn instance_add_project_from_version(
instance_id: &str, instance_id: &str,
version_id: &str, version_id: &str,
reason: DownloadReason,
dependent_on_version_id: Option<String>,
) -> Result<String> { ) -> Result<String> {
Ok(theseus::instance::add_project_from_version( Ok(
instance_id, theseus::instance::add_project_from_version(instance_id, version_id)
version_id, .await?,
reason,
dependent_on_version_id,
) )
.await?)
} }
#[tauri::command] #[tauri::command]

View File

@ -25,7 +25,6 @@ pub mod shortcuts;
pub mod storage; pub mod storage;
pub mod system_accent; pub mod system_accent;
pub mod tags; pub mod tags;
pub mod telemetry;
pub mod terracotta; pub mod terracotta;
pub mod translation; pub mod translation;
pub mod utils; pub mod utils;

View File

@ -8,10 +8,6 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
settings_get, settings_get,
settings_set, settings_set,
privacy_get,
privacy_set,
telemetry_set,
discord_rpc_set,
download_engine_set, download_engine_set,
cancel_directory_change, cancel_directory_change,
proxy_get, proxy_get,
@ -41,26 +37,6 @@ pub async fn settings_set(
Ok(()) Ok(())
} }
#[tauri::command]
pub async fn privacy_get() -> Result<PrivacySettings> {
Ok(settings::get_privacy().await?)
}
#[tauri::command]
pub async fn privacy_set(privacy: PrivacySettings) -> Result<PrivacySettings> {
Ok(settings::set_privacy(privacy).await?)
}
#[tauri::command]
pub async fn telemetry_set(enabled: bool) -> Result<PrivacySettings> {
Ok(settings::set_telemetry(enabled).await?)
}
#[tauri::command]
pub async fn discord_rpc_set(enabled: bool) -> Result<PrivacySettings> {
Ok(settings::set_discord_rpc(enabled).await?)
}
#[tauri::command] #[tauri::command]
pub async fn download_engine_set( pub async fn download_engine_set(
engine: settings::DownloadEngine, engine: settings::DownloadEngine,

View File

@ -1,10 +0,0 @@
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("telemetry")
.invoke_handler(tauri::generate_handler![notify_online,])
.build()
}
#[tauri::command]
pub fn notify_online() {
theseus::telemetry::notify_online();
}

View File

@ -389,16 +389,6 @@ async fn backup_app_db_for_update(
Ok(()) Ok(())
} }
#[tauri::command]
async fn set_discord_activity(activity: String) -> api::Result<()> {
let state = State::get().await?;
state
.discord_rpc
.set_launcher_activity(&activity, true)
.await?;
Ok(())
}
// Should be call once Vue has mounted the app // Should be call once Vue has mounted the app
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
#[tauri::command] #[tauri::command]
@ -823,7 +813,6 @@ fn main() {
.plugin(api::schematic_preview::init()) .plugin(api::schematic_preview::init())
.plugin(api::shortcuts::init()) .plugin(api::shortcuts::init())
.plugin(api::tags::init()) .plugin(api::tags::init())
.plugin(api::telemetry::init())
.plugin(api::translation::init()) .plugin(api::translation::init())
.plugin(api::utils::init()) .plugin(api::utils::init())
.plugin(api::cache::init()) .plugin(api::cache::init())
@ -851,7 +840,6 @@ fn main() {
copy_database_between_channels, copy_database_between_channels,
beta_database_exists, beta_database_exists,
backup_app_db_for_update, backup_app_db_for_update,
set_discord_activity,
is_dev, is_dev,
portable::is_portable_mode, portable::is_portable_mode,
are_updates_enabled, are_updates_enabled,

View File

@ -1,9 +0,0 @@
# Fictional local values only. Never commit a real Access token or audience.
TELEMETRY_ADMIN_MOCK_AUTH=true
TELEMETRY_ADMIN_MOCK_SCENARIO=normal
NUXT_ACCESS_TEAM_DOMAIN=example
NUXT_ACCESS_AUDIENCE=fixture-audience
# Cloudflare GraphQL Analytics API (system page account usage check, local dev only)
# CLOUDFLARE_ACCOUNT_ID=
# CLOUDFLARE_ANALYTICS_TOKEN=

View File

@ -1,2 +0,0 @@
.vercel
.env*

View File

@ -1,5 +0,0 @@
.nuxt/
.output/
.wrangler/
node_modules/
coverage/

View File

@ -1,106 +0,0 @@
# Starlight Telemetry Dashboard
Read-only administration UI for the existing `axolotl-telemetry` D1 database and
`axolotl-telemetry-errors` R2 bucket. It does not share routes or code with the public telemetry
collector.
## Local development
Mock authentication is accepted only when `NODE_ENV=development` and
`TELEMETRY_ADMIN_MOCK_AUTH=true` are both present.
Use the production D1 and R2 resources through Wrangler remote bindings while keeping authentication
local to the development server:
```powershell
$env:TELEMETRY_ADMIN_MOCK_AUTH='true'
$env:TELEMETRY_ADMIN_REMOTE_BINDINGS='true'
pnpm --filter @axolotl/telemetry-dashboard dev
```
Remote mode is read-only at the application layer: the Admin API issues only `SELECT` statements
and a single registered-object R2 `GET`. It never exposes Cloudflare credentials to the browser.
For deterministic fixture scenarios, leave `TELEMETRY_ADMIN_REMOTE_BINDINGS` unset:
```powershell
$env:TELEMETRY_ADMIN_MOCK_AUTH='true'
$env:TELEMETRY_ADMIN_MOCK_SCENARIO='normal'
pnpm --filter @axolotl/telemetry-dashboard dev
```
Supported deterministic scenarios are `normal`, `empty`, `api-error`, `no-sample`,
`budget-reached`, `unconfigured-auth`, and `forbidden`. All mock values are synthetic fixtures.
## Production deployment (Cloudflare Workers)
The dashboard runs on the `axolotl-telemetry-dashboard` Worker with the custom domain
`admin.axlmc.org`. It uses the native D1 and R2 bindings declared in `wrangler.toml`; no data
credentials are sent to the browser.
Do not deploy the dashboard until every item below is complete:
1. Create a GitHub OAuth App controlled by the `Axolotl-Launcher` organization. Use
`https://admin.axlmc.org` as its homepage and the callback URL shown by Cloudflare Zero Trust,
normally `https://<team-name>.cloudflareaccess.com/cdn-cgi/access/callback`.
2. Add GitHub as a Cloudflare Zero Trust identity provider using that Client ID and Client Secret.
Keep the Client Secret only in GitHub and Cloudflare.
3. Create a self-hosted Access application for `admin.axlmc.org` with an 8-hour session duration.
4. Create one Allow policy with `Include -> GitHub Organization -> Axolotl-Launcher`. Do not add a
bypass policy or a broader include rule.
5. Keep the Worker custom domain `admin.axlmc.org` bound to `axolotl-telemetry-dashboard`. The DNS
records for `axlmc.org` stay proxied through Cloudflare.
Build and deploy from `apps/telemetry-dashboard`:
```powershell
pnpm --filter @axolotl/telemetry-dashboard build
pnpm --filter @axolotl/telemetry-dashboard exec wrangler deploy
```
`nuxi build` uses Nitro's `cloudflare-module` preset by default and emits
`.output/server/index.mjs` plus `.output/public`, exactly what `wrangler.toml` references.
### Automatic deployment
`.github/workflows/deploy-telemetry-dashboard.yml` deploys on every push to `main` that touches
`apps/telemetry-dashboard/**`, and can also be triggered manually. It requires one repository
secret:
- `CLOUDFLARE_API_TOKEN`: an API token with Workers Scripts Edit and Workers Routes Edit
permission for the account that owns `axolotl-telemetry-dashboard`.
Do not add `[skip ci]` to commits that should reach production: GitHub Actions skips all workflows
for such commits, including this deployment.
### Cloudflare account usage check
The system page reports account-wide Workers, D1, and R2 usage through the GraphQL Analytics API.
Enable it once with two Worker secrets; secrets survive CI deploys because they live on the Worker,
not in the repository:
1. Create an API token at `dash.cloudflare.com` → My Profile → API Tokens with the
`Account Analytics: Read` permission for the account that owns the telemetry resources.
2. From `apps/telemetry-dashboard`, run
`pnpm exec wrangler secret put CLOUDFLARE_ANALYTICS_TOKEN` and paste that token.
3. Run `pnpm exec wrangler secret put CLOUDFLARE_ACCOUNT_ID` and paste the 32-character account ID.
The check stays fail-closed: without both values the row reports 尚未配置, and any dataset failure
only degrades the row. The token is used server-side for three read-only GraphQL queries per system
page load (Workers invocations 24h, D1 rows today, R2 operations 30d) and is never sent to the
browser.
### Unused Vercel target
An earlier Vercel project (`axolotl-telemetry-dashboard`) exists, but its `admin.axlmc.org` domain
was never pointed at Vercel and traffic is served by the Cloudflare Worker. The Nitro `vercel`
preset and `server/utils/vercel-data-source.ts` remain as an offline fallback and are not part of
the production path. Do not enable both targets for the same hostname.
Verify an unauthenticated request is intercepted by Cloudflare Access, then verify
`/api/admin/session` reports `dataSource: production` after authentication.
The server fails closed when Access or the remote data source configuration is absent. A production
build ignores the mock provider even if a mock environment variable is present. All Admin API D1
operations are restricted to `SELECT`/`WITH` queries, and R2 reads require an object key previously
registered in D1.

View File

@ -1,6 +0,0 @@
<template>
<NuxtRouteAnnouncer />
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>

View File

@ -1,162 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 210 18% 97%;
--foreground: 220 20% 11%;
--card: 0 0% 100%;
--card-foreground: 220 18% 12%;
--popover: 0 0% 100%;
--popover-foreground: 220 18% 12%;
--primary: 164 64% 27%;
--primary-foreground: 0 0% 100%;
--secondary: 210 16% 93%;
--secondary-foreground: 220 16% 18%;
--muted: 210 16% 94%;
--muted-foreground: 215 12% 43%;
--accent: 38 92% 52%;
--accent-foreground: 30 30% 12%;
--destructive: 0 72% 48%;
--destructive-foreground: 0 0% 100%;
--border: 214 16% 86%;
--input: 214 16% 78%;
--ring: 164 64% 27%;
--surface-1: 210 18% 97%;
--surface-2: 0 0% 100%;
--surface-3: 210 17% 94%;
--surface-4: 214 16% 86%;
--surface-5: 214 15% 72%;
--chart-green: #17845f;
--chart-gold: #d19213;
--chart-coral: #d85d4a;
--chart-cyan: #278ba8;
}
.dark {
--background: 220 16% 8%;
--foreground: 210 16% 92%;
--card: 220 15% 12%;
--card-foreground: 210 16% 92%;
--popover: 220 16% 12%;
--popover-foreground: 210 16% 92%;
--primary: 160 54% 48%;
--primary-foreground: 220 18% 8%;
--secondary: 218 14% 17%;
--secondary-foreground: 210 16% 90%;
--muted: 218 14% 16%;
--muted-foreground: 215 12% 64%;
--accent: 40 88% 56%;
--accent-foreground: 30 32% 10%;
--destructive: 0 62% 51%;
--destructive-foreground: 0 0% 100%;
--border: 218 13% 23%;
--input: 218 13% 27%;
--ring: 160 54% 48%;
--surface-1: 220 16% 8%;
--surface-2: 220 15% 11%;
--surface-3: 218 14% 15%;
--surface-4: 218 13% 23%;
--surface-5: 216 12% 34%;
--chart-green: #40c995;
--chart-gold: #e6ad3e;
--chart-coral: #ef7b68;
--chart-cyan: #52b6d0;
}
* {
@apply border-border;
}
html,
body,
#__nuxt {
@apply min-h-full;
}
body {
@apply m-0 overflow-x-hidden bg-surface-1 font-sans text-foreground antialiased;
letter-spacing: 0;
}
button,
input,
select {
letter-spacing: 0;
}
}
@layer utilities {
.tabular-nums {
font-variant-numeric: tabular-nums;
}
}
.dashboard-page-enter-active,
.dashboard-page-leave-active {
transition:
opacity 160ms ease,
transform 160ms ease;
}
.dashboard-page-enter-from {
opacity: 0;
transform: translateY(4px);
}
.dashboard-page-leave-to {
opacity: 0;
transform: translateY(-2px);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 160ms ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.sheet-overlay[data-state='open'] {
animation: overlay-in 180ms ease-out;
}
.sheet-panel[data-state='open'] {
animation: sheet-in 220ms cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes overlay-in {
from {
opacity: 0;
}
}
@keyframes sheet-in {
from {
transform: translateX(20px);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.dashboard-page-enter-active,
.dashboard-page-leave-active,
.sheet-overlay[data-state='open'],
.sheet-panel[data-state='open'] {
animation: none;
transition: none;
}
}
.unovis-xy-container text {
fill: hsl(var(--muted-foreground));
font-size: 11px;
}
.unovis-xy-container .axis line,
.unovis-xy-container .axis path {
stroke: hsl(var(--surface-4));
}

View File

@ -1,16 +0,0 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "new-york",
"typescript": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "assets/styles/tailwind.css",
"baseColor": "zinc",
"cssVariables": true
},
"aliases": {
"components": "~/components",
"utils": "~/lib/utils",
"ui": "~/components/ui"
}
}

View File

@ -1,57 +0,0 @@
<script setup lang="ts">
import { Ban, LogIn, RefreshCw, ServerOff } from 'lucide-vue-next'
import { computed } from 'vue'
import Button from '~/components/ui/Button.vue'
const props = defineProps<{
kind: 'error' | 'unauthenticated' | 'forbidden'
compact?: boolean
}>()
const emit = defineEmits<{ retry: [] }>()
const content = computed(() => {
if (props.kind === 'unauthenticated') {
return {
icon: LogIn,
title: '需要登录',
detail: '请使用 Axolotl-Launcher 组织成员账号通过 Cloudflare Access 登录。',
action: '前往登录',
}
}
if (props.kind === 'forbidden') {
return {
icon: Ban,
title: '无权访问',
detail: '当前 GitHub 身份不符合 Cloudflare Access 组织策略。',
action: null,
}
}
return {
icon: ServerOff,
title: '遥测数据暂不可用',
detail: '只读管理 API 未返回可用结果,请稍后重试。',
action: '重新加载',
}
})
</script>
<template>
<div
:class="compact ? 'py-10' : 'min-h-[60vh]'"
class="flex flex-col items-center justify-center px-4 text-center"
:data-state="kind"
>
<div class="flex size-10 items-center justify-center rounded-lg bg-muted">
<component :is="content.icon" class="size-5 text-muted-foreground" />
</div>
<h2 class="mt-4 text-base font-semibold">{{ content.title }}</h2>
<p class="mt-1 max-w-md text-sm text-muted-foreground">{{ content.detail }}</p>
<Button v-if="content.action" class="mt-4" variant="outline" @click="emit('retry')">
<RefreshCw v-if="kind === 'error'" class="size-4" />
<LogIn v-else class="size-4" />
{{ content.action }}
</Button>
</div>
</template>

View File

@ -1,50 +0,0 @@
<script setup lang="ts">
import type { Component } from 'vue'
import Card from '~/components/ui/Card.vue'
import { formatNumber } from '~/utils/format'
const props = withDefaults(
defineProps<{
title: string
value: number
detail: string
icon: Component
tone?: 'green' | 'cyan' | 'gold' | 'coral' | 'neutral'
}>(),
{ tone: 'neutral' },
)
const tones = {
green: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300',
cyan: 'bg-cyan-500/12 text-cyan-700 dark:text-cyan-300',
gold: 'bg-amber-500/12 text-amber-700 dark:text-amber-300',
coral: 'bg-rose-500/12 text-rose-700 dark:text-rose-300',
neutral: 'bg-surface-3 text-muted-foreground',
}
</script>
<template>
<Card class="min-w-0 overflow-hidden">
<div class="flex items-start justify-between gap-3 p-4 pb-3">
<div class="min-w-0">
<p class="truncate text-xs font-medium text-muted-foreground">{{ title }}</p>
<p class="mt-2 text-[28px] font-semibold tabular-nums leading-8">
{{ formatNumber(value) }}
</p>
</div>
<div
class="flex size-9 shrink-0 items-center justify-center rounded-md"
:class="tones[props.tone]"
>
<component :is="icon" class="size-[18px]" />
</div>
</div>
<p
class="truncate border-t border-surface-4 bg-surface-3/45 px-4 py-2 text-[11px] text-muted-foreground"
:title="detail"
>
{{ detail }}
</p>
</Card>
</template>

View File

@ -1,13 +0,0 @@
<script setup lang="ts">
defineProps<{ title: string; description: string }>()
</script>
<template>
<header class="mb-6 flex flex-wrap items-end justify-between gap-3">
<div class="min-w-0">
<h1 class="text-[22px] font-semibold leading-8">{{ title }}</h1>
<p class="mt-1 max-w-3xl text-sm leading-6 text-muted-foreground">{{ description }}</p>
</div>
<slot />
</header>
</template>

View File

@ -1,38 +0,0 @@
<script setup lang="ts">
import type { DistributionItemDto } from '~/shared/types/telemetry'
import { formatNumber } from '~/utils/format'
const props = defineProps<{ items: DistributionItemDto[]; color: string }>()
const maximum = computed(() => Math.max(1, ...props.items.map((item) => item.value)))
</script>
<template>
<div v-if="items.length" class="h-56 space-y-3.5 overflow-y-auto pr-1">
<div
v-for="item in items"
:key="item.label"
class="group"
:title="`${item.label}: ${formatNumber(item.value)}`"
>
<div class="mb-1 flex items-center justify-between gap-3 text-xs">
<span class="truncate font-medium">{{ item.label }}</span>
<span class="shrink-0 tabular-nums text-muted-foreground">{{
formatNumber(item.value)
}}</span>
</div>
<div class="h-1.5 overflow-hidden rounded-sm bg-surface-3">
<div
class="h-full rounded-sm transition-[width] duration-300"
:style="{ width: `${(item.value / maximum) * 100}%`, backgroundColor: color }"
></div>
</div>
</div>
</div>
<div
v-else
class="flex h-56 items-center justify-center text-sm text-muted-foreground"
data-state="empty"
>
当前 UTC 时间范围内暂无数据
</div>
</template>

View File

@ -1,87 +0,0 @@
<script setup lang="ts">
import { VisAxis, VisLine, VisXYContainer } from '@unovis/vue'
import { computed, ref } from 'vue'
import type { DailyPointDto } from '~/shared/types/telemetry'
import { formatNumber, formatUtcDay } from '~/utils/format'
const props = defineProps<{
data: DailyPointDto[]
series: Array<{
key: 'activeInstallations' | 'newInstallations'
label: string
color: string
}>
}>()
const hovered = ref<number | null>(null)
const available = computed(() =>
props.data.some((point) => props.series.some((series) => Number(point[series.key]) > 0)),
)
const yAccessors = computed(() =>
props.series.map((series) => (point: DailyPointDto) => point[series.key]),
)
const colors = computed(() => props.series.map((series) => series.color))
function move(event: PointerEvent): void {
if (!props.data.length) return
const box = (event.currentTarget as HTMLElement).getBoundingClientRect()
const ratio = Math.min(1, Math.max(0, (event.clientX - box.left) / box.width))
hovered.value = Math.min(props.data.length - 1, Math.round(ratio * (props.data.length - 1)))
}
</script>
<template>
<div class="relative h-64 min-w-0" @pointermove="move" @pointerleave="hovered = null">
<div v-if="available" class="h-56 w-full">
<VisXYContainer :data="data" :margin="{ left: 16, right: 12, top: 12, bottom: 28 }">
<VisLine
:x="(_point: DailyPointDto, index: number) => index"
:y="yAccessors"
:color="colors"
:line-width="2"
/>
<VisAxis
type="x"
:x="(_point: DailyPointDto, index: number) => index"
:tick-format="(value: number) => formatUtcDay(data[Math.round(value)]?.day || '')"
:num-ticks="4"
/>
<VisAxis type="y" :num-ticks="4" :tick-format="(value: number) => formatNumber(value)" />
</VisXYContainer>
</div>
<div
v-else
class="flex h-56 items-center justify-center text-sm text-muted-foreground"
data-state="empty"
>
当前 UTC 时间范围内暂无数据
</div>
<div
class="flex h-8 flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground"
aria-label="图表图例"
>
<span v-for="item in series" :key="item.key" class="inline-flex items-center gap-1.5">
<span class="size-2 rounded-sm" :style="{ backgroundColor: item.color }"></span>
{{ item.label }}
</span>
</div>
<div
v-if="hovered !== null && available"
class="pointer-events-none absolute right-2 top-2 z-10 min-w-40 rounded-md border bg-popover p-2 text-xs text-popover-foreground shadow-md"
role="tooltip"
>
<p class="mb-1 font-medium">{{ data[hovered].day }}UTC</p>
<p
v-for="item in series"
:key="item.key"
class="flex justify-between gap-4 py-0.5 text-muted-foreground"
>
<span>{{ item.label }}</span>
<span class="font-medium tabular-nums text-foreground">{{
formatNumber(data[hovered][item.key])
}}</span>
</p>
</div>
</div>
</template>

View File

@ -1,34 +0,0 @@
<script setup lang="ts">
import { CircleCheck, CircleMinus, TriangleAlert } from 'lucide-vue-next'
import Badge from '~/components/ui/Badge.vue'
import type { ServiceCheckDto } from '~/shared/types/telemetry'
defineProps<{ name: string; check: ServiceCheckDto }>()
const icons = { available: CircleCheck, degraded: TriangleAlert, unavailable: CircleMinus }
const variants = { available: 'success', degraded: 'warning', unavailable: 'secondary' } as const
</script>
<template>
<div
class="flex flex-wrap items-center gap-3 border-b border-surface-4 px-4 py-3.5 transition-colors last:border-0 hover:bg-surface-3/45"
>
<component
:is="icons[check.status]"
class="size-4 shrink-0"
:class="
check.status === 'available'
? 'text-emerald-600'
: check.status === 'degraded'
? 'text-amber-600'
: 'text-muted-foreground'
"
/>
<div class="min-w-48 flex-1">
<p class="text-sm font-medium">{{ name }}</p>
<p class="mt-0.5 text-xs text-muted-foreground">{{ check.detail }}</p>
</div>
<Badge :variant="variants[check.status]">{{ check.label }}</Badge>
</div>
</template>

View File

@ -1,30 +0,0 @@
<script setup lang="ts">
import { AlertCircle, CircleCheck, Info } from 'lucide-vue-next'
import { cn } from '~/lib/utils'
const props = withDefaults(
defineProps<{ variant?: 'info' | 'success' | 'destructive'; title: string; class?: string }>(),
{ variant: 'info', class: undefined },
)
const icons = { info: Info, success: CircleCheck, destructive: AlertCircle }
const styles = {
info: 'border-border bg-muted/40',
success: 'border-emerald-500/30 bg-emerald-500/10',
destructive: 'border-red-500/30 bg-red-500/10',
}
</script>
<template>
<div
role="alert"
:class="cn('flex gap-3 rounded-lg border p-3.5 text-sm', styles[variant], props.class)"
>
<component :is="icons[variant]" class="mt-0.5 size-4 shrink-0" />
<div class="min-w-0">
<p class="font-medium">{{ title }}</p>
<div class="mt-0.5 text-muted-foreground"><slot /></div>
</div>
</div>
</template>

View File

@ -1,33 +0,0 @@
<script setup lang="ts">
import { cn } from '~/lib/utils'
const props = withDefaults(
defineProps<{
variant?: 'default' | 'secondary' | 'success' | 'warning' | 'destructive'
class?: string
}>(),
{ variant: 'secondary', class: undefined },
)
const variants = {
default: 'bg-primary text-primary-foreground',
secondary: 'bg-secondary text-secondary-foreground',
success: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300',
warning: 'bg-amber-500/15 text-amber-800 dark:text-amber-300',
destructive: 'bg-red-500/15 text-red-700 dark:text-red-300',
}
</script>
<template>
<span
:class="
cn(
'inline-flex items-center rounded px-2 py-0.5 text-xs font-medium',
variants[variant],
props.class,
)
"
>
<slot />
</span>
</template>

View File

@ -1,45 +0,0 @@
<script setup lang="ts">
import type { ButtonHTMLAttributes } from 'vue'
import { cn } from '~/lib/utils'
const props = withDefaults(
defineProps<{
variant?: 'default' | 'secondary' | 'ghost' | 'outline' | 'destructive'
size?: 'default' | 'sm' | 'icon'
class?: ButtonHTMLAttributes['class']
}>(),
{ variant: 'default', size: 'default', class: undefined },
)
const variants = {
default: 'bg-primary text-primary-foreground shadow-sm hover:brightness-95 active:brightness-90',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/75',
ghost: 'text-foreground hover:bg-surface-3 active:bg-surface-4',
outline:
'border-surface-4 bg-surface-2 text-foreground hover:border-surface-5 hover:bg-surface-3 active:bg-surface-4',
destructive: 'bg-destructive text-destructive-foreground hover:brightness-95',
}
const sizes = {
default: 'h-9 px-3.5',
sm: 'h-8 px-2.5 text-xs',
icon: 'size-9 shrink-0 p-0',
}
</script>
<template>
<button
:data-variant="variant"
:class="
cn(
'inline-flex cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium transition-[color,background-color,border-color,box-shadow,filter] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-auto disabled:cursor-not-allowed disabled:opacity-50',
variants[props.variant],
sizes[props.size],
props.class,
)
"
>
<slot />
</button>
</template>

View File

@ -1,20 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '~/lib/utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<section
:class="
cn(
'rounded-lg border border-surface-4 bg-surface-2 text-card-foreground shadow-[0_1px_2px_rgba(15,23,42,0.04)]',
props.class,
)
"
>
<slot />
</section>
</template>

View File

@ -1,66 +0,0 @@
<script setup lang="ts">
import { BarChart3, Search, Server } from 'lucide-vue-next'
import {
DialogContent,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
} from 'reka-ui'
const open = defineModel<boolean>('open', { required: true })
const query = ref('')
const router = useRouter()
const items = [
{ label: '数据总览', path: '/', icon: BarChart3 },
{ label: '系统状态', path: '/system', icon: Server },
]
const filtered = computed(() =>
items.filter((item) => item.label.toLowerCase().includes(query.value.toLowerCase())),
)
async function select(path: string): Promise<void> {
open.value = false
query.value = ''
await router.push(path)
}
</script>
<template>
<DialogRoot v-model:open="open">
<DialogPortal>
<DialogOverlay class="sheet-overlay fixed inset-0 z-40 bg-black/45 backdrop-blur-[1px]" />
<DialogContent
class="fixed left-1/2 top-[18vh] z-50 w-[calc(100vw-2rem)] max-w-lg -translate-x-1/2 overflow-hidden rounded-lg border border-surface-5 bg-popover shadow-2xl outline-none"
>
<DialogTitle class="sr-only">快速跳转</DialogTitle>
<DialogDescription class="sr-only">搜索控制台页面</DialogDescription>
<div class="flex items-center gap-2 border-b px-3">
<Search class="size-4 text-muted-foreground" />
<input
v-model="query"
autofocus
placeholder="输入页面名称..."
class="h-12 min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
<div class="p-1.5">
<p v-if="!filtered.length" class="px-3 py-8 text-center text-sm text-muted-foreground">
没有匹配的页面
</p>
<button
v-for="item in filtered"
:key="item.path"
type="button"
class="flex w-full cursor-pointer items-center gap-3 rounded-md px-3 py-2 text-left text-sm hover:bg-surface-3 focus:bg-surface-3 focus:outline-none"
@click="select(item.path)"
>
<component :is="item.icon" class="size-4 text-muted-foreground" />
{{ item.label }}
</button>
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>

View File

@ -1,79 +0,0 @@
<script setup lang="ts">
import { Check, ChevronDown, LogOut, Monitor, Moon, Sun } from 'lucide-vue-next'
import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from 'reka-ui'
import type { AdminSessionDto } from '~/shared/types/telemetry'
import { type ThemeMode, useTheme } from '../../composables/use-theme'
defineProps<{ session: AdminSessionDto }>()
const { mode } = useTheme()
const themes: Array<{ value: ThemeMode; label: string; icon: typeof Sun }> = [
{ value: 'light', label: '浅色', icon: Sun },
{ value: 'dark', label: '深色', icon: Moon },
{ value: 'system', label: '跟随系统', icon: Monitor },
]
</script>
<template>
<DropdownMenuRoot>
<DropdownMenuTrigger
class="inline-flex h-9 min-w-0 cursor-pointer items-center gap-2 rounded-md border border-surface-4 bg-surface-2 px-2.5 text-sm outline-none transition-colors hover:border-surface-5 hover:bg-surface-3 focus:ring-2 focus:ring-ring"
>
<span
class="flex size-6 shrink-0 items-center justify-center rounded bg-primary/15 text-xs font-semibold text-primary"
>
{{ session.identity.name.slice(0, 1).toUpperCase() }}
</span>
<span class="hidden max-w-32 truncate sm:block">{{ session.identity.name }}</span>
<ChevronDown class="size-3.5 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
align="end"
:side-offset="6"
class="z-50 w-64 rounded-lg border border-surface-5 bg-popover p-1 text-popover-foreground shadow-xl outline-none"
>
<DropdownMenuLabel class="px-2 py-1.5">
<p class="truncate text-sm font-medium">{{ session.identity.name }}</p>
<p class="truncate text-xs font-normal text-muted-foreground">
{{ session.identity.email || 'Cloudflare Access 身份' }}
</p>
</DropdownMenuLabel>
<DropdownMenuSeparator class="my-1 h-px bg-border" />
<DropdownMenuLabel class="px-2 py-1 text-xs text-muted-foreground"
>界面主题</DropdownMenuLabel
>
<DropdownMenuItem
v-for="theme in themes"
:key="theme.value"
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm outline-none hover:bg-surface-3 focus:bg-surface-3"
@click="mode = theme.value"
>
<component :is="theme.icon" class="size-4 text-muted-foreground" />
<span class="flex-1">{{ theme.label }}</span>
<Check v-if="mode === theme.value" class="size-4" />
</DropdownMenuItem>
<DropdownMenuSeparator class="my-1 h-px bg-border" />
<DropdownMenuItem as-child>
<a
:href="session.logoutUrl"
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm outline-none hover:bg-surface-3 focus:bg-surface-3"
>
<LogOut class="size-4 text-muted-foreground" />
退出登录
</a>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</template>

View File

@ -1,27 +0,0 @@
<script setup lang="ts">
import { ChevronDown } from 'lucide-vue-next'
defineProps<{
modelValue: string
label: string
options: Array<{ value: string; label: string }>
}>()
defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<label class="relative inline-flex min-w-0 items-center">
<span class="sr-only">{{ label }}</span>
<select
:value="modelValue"
class="h-9 max-w-full cursor-pointer appearance-none rounded-md border border-surface-4 bg-surface-2 py-0 pl-3 pr-8 text-sm text-foreground outline-none transition-colors hover:border-surface-5 hover:bg-surface-3 focus:ring-2 focus:ring-ring"
@change="$emit('update:modelValue', ($event.target as HTMLSelectElement).value)"
>
<option v-for="option in options" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<ChevronDown class="pointer-events-none absolute right-2 size-4 text-muted-foreground" />
</label>
</template>

View File

@ -1,44 +0,0 @@
<script setup lang="ts">
import { X } from 'lucide-vue-next'
import {
DialogClose,
DialogContent,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
} from 'reka-ui'
defineProps<{ open: boolean; title: string; description?: string }>()
defineEmits<{ 'update:open': [value: boolean] }>()
</script>
<template>
<DialogRoot :open="open" @update:open="$emit('update:open', $event)">
<DialogPortal>
<DialogOverlay class="sheet-overlay fixed inset-0 z-40 bg-black/45 backdrop-blur-[1px]" />
<DialogContent
class="sheet-panel fixed inset-y-0 right-0 z-50 flex w-full max-w-xl flex-col border-l border-surface-5 bg-surface-1 shadow-2xl focus:outline-none"
>
<header class="flex shrink-0 items-start justify-between gap-4 border-b px-5 py-4">
<div class="min-w-0">
<DialogTitle class="text-base font-semibold">{{ title }}</DialogTitle>
<DialogDescription v-if="description" class="mt-1 text-sm text-muted-foreground">
{{ description }}
</DialogDescription>
</div>
<DialogClose
class="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground hover:bg-surface-3 hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
aria-label="关闭详情"
>
<X class="size-4" />
</DialogClose>
</header>
<div class="min-h-0 flex-1 overflow-y-auto px-5 py-5">
<slot />
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>

View File

@ -1,9 +0,0 @@
<script setup lang="ts">
import { cn } from '~/lib/utils'
const props = defineProps<{ class?: string }>()
</script>
<template>
<div :class="cn('animate-pulse rounded-md bg-muted', props.class)" aria-hidden="true"></div>
</template>

View File

@ -1,30 +0,0 @@
<script setup lang="ts">
defineProps<{
modelValue: string
label: string
items: Array<{ value: string; label: string }>
}>()
defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<div
role="tablist"
:aria-label="label"
class="inline-flex h-9 items-center rounded-md border border-surface-4 bg-surface-3 p-1"
>
<button
v-for="item in items"
:key="item.value"
type="button"
role="tab"
:aria-selected="modelValue === item.value"
class="h-7 cursor-pointer rounded px-2.5 text-xs font-medium text-muted-foreground transition-[color,background-color,box-shadow] hover:text-foreground active:bg-surface-4"
:class="modelValue === item.value && 'bg-surface-2 text-foreground shadow-sm'"
@click="$emit('update:modelValue', item.value)"
>
{{ item.label }}
</button>
</div>
</template>

View File

@ -1,15 +0,0 @@
<script setup lang="ts">
defineProps<{ text: string }>()
</script>
<template>
<span class="group relative inline-flex">
<slot />
<span
role="tooltip"
class="pointer-events-none absolute left-1/2 top-full z-50 mt-2 hidden w-max max-w-56 -translate-x-1/2 rounded-md bg-foreground px-2 py-1 text-xs text-background shadow-md group-focus-within:block group-hover:block"
>
{{ text }}
</span>
</span>
</template>

View File

@ -1,9 +0,0 @@
import type { AdminRange } from '~/shared/types/telemetry'
export function useDashboardRange() {
return useState<AdminRange>('dashboard-range', () => '30d')
}
export function useDashboardRefresh() {
return useState<number>('dashboard-refresh', () => 0)
}

View File

@ -1,23 +0,0 @@
export type ThemeMode = 'light' | 'dark' | 'system'
export function useTheme() {
const mode = useState<ThemeMode>('theme-mode', () => 'system')
function apply(value: ThemeMode): void {
if (!import.meta.client) return
const dark =
value === 'dark' ||
(value === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
document.documentElement.classList.toggle('dark', dark)
localStorage.setItem('telemetry-admin-theme', value)
}
onMounted(() => {
const saved = localStorage.getItem('telemetry-admin-theme') as ThemeMode | null
if (saved === 'light' || saved === 'dark' || saved === 'system') mode.value = saved
apply(mode.value)
})
watch(mode, apply)
return { mode }
}

View File

@ -1,10 +0,0 @@
import config from '@modrinth/tooling-config/eslint/nuxt.mjs'
export default config.append([
{
rules: {
'import/no-unresolved': 'off',
'no-undef': 'off',
},
},
])

View File

@ -1,229 +0,0 @@
<script setup lang="ts">
import {
Activity,
BarChart3,
Database,
Menu,
RefreshCw,
Search,
Server,
ShieldCheck,
X,
} from 'lucide-vue-next'
import AppState from '~/components/AppState.vue'
import Badge from '~/components/ui/Badge.vue'
import Button from '~/components/ui/Button.vue'
import Command from '~/components/ui/Command.vue'
import DropdownMenu from '~/components/ui/DropdownMenu.vue'
import Skeleton from '~/components/ui/Skeleton.vue'
import Tabs from '~/components/ui/Tabs.vue'
import Tooltip from '~/components/ui/Tooltip.vue'
import type { AdminRange, AdminSessionDto } from '~/shared/types/telemetry'
import { statusCode } from '~/utils/format'
const route = useRoute()
const requestFetch = useRequestFetch()
const range = useDashboardRange()
const refreshEpoch = useDashboardRefresh()
const mobileOpen = ref(false)
const commandOpen = ref(false)
const {
data: session,
status,
error,
refresh: refreshSession,
} = await useAsyncData('admin-session', () => requestFetch<AdminSessionDto>('/api/admin/session'))
const nav = [
{ label: '数据总览', path: '/', icon: BarChart3 },
{ label: '系统状态', path: '/system', icon: Server },
]
const ranges = [
{ value: '7d', label: '7 天' },
{ value: '30d', label: '30 天' },
{ value: '90d', label: '90 天' },
{ value: '365d', label: '1 年' },
]
function setRange(value: string): void {
range.value = value as AdminRange
}
function refresh(): void {
refreshEpoch.value += 1
}
function keydown(event: KeyboardEvent): void {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
event.preventDefault()
commandOpen.value = !commandOpen.value
}
}
onMounted(() => window.addEventListener('keydown', keydown))
onBeforeUnmount(() => window.removeEventListener('keydown', keydown))
watch(
() => route.path,
() => {
mobileOpen.value = false
},
)
</script>
<template>
<div class="min-h-dvh bg-surface-1">
<template v-if="status === 'pending'">
<div
class="fixed inset-y-0 left-0 hidden w-60 border-r border-surface-4 bg-surface-2 p-4 md:block"
>
<Skeleton class="h-10 w-44" />
<div class="mt-8 space-y-2">
<Skeleton v-for="index in 3" :key="index" class="h-10 w-full" />
</div>
</div>
<div class="md:pl-60">
<div class="h-16 border-b border-surface-4 bg-surface-2"></div>
<div class="space-y-4 p-5">
<Skeleton class="h-9 w-48" />
<Skeleton class="h-36 w-full" />
</div>
</div>
</template>
<div v-else-if="error || !session" class="flex min-h-dvh items-center justify-center">
<AppState
:kind="
statusCode(error) === 403
? 'forbidden'
: statusCode(error) === 401
? 'unauthenticated'
: 'error'
"
@retry="refreshSession"
/>
</div>
<template v-else>
<Transition name="fade">
<div
v-if="mobileOpen"
class="fixed inset-0 z-30 bg-black/45 backdrop-blur-[1px] md:hidden"
aria-hidden="true"
@click="mobileOpen = false"
></div>
</Transition>
<aside
class="fixed inset-y-0 left-0 z-40 flex w-60 -translate-x-full flex-col border-r border-surface-4 bg-surface-2 transition-transform duration-200 md:translate-x-0"
:class="mobileOpen && 'translate-x-0'"
>
<div class="flex h-16 items-center gap-3 border-b border-surface-4 px-4">
<div
class="flex size-9 items-center justify-center rounded-md bg-primary text-primary-foreground shadow-sm"
>
<Activity class="size-[18px]" />
</div>
<div class="min-w-0">
<p class="truncate text-sm font-semibold">Axolotl 遥测中心</p>
<p class="mt-0.5 text-[11px] text-muted-foreground">只读管理控制台</p>
</div>
<Button
variant="ghost"
size="icon"
class="ml-auto md:hidden"
aria-label="关闭导航"
@click="mobileOpen = false"
>
<X class="size-4" />
</Button>
</div>
<div class="px-3 pb-2 pt-4 text-[11px] font-medium text-muted-foreground">工作区</div>
<nav class="space-y-1 px-3" aria-label="主导航">
<NuxtLink
v-for="item in nav"
:key="item.path"
:to="item.path"
class="flex h-10 cursor-pointer items-center gap-3 rounded-md px-3 text-sm font-medium text-muted-foreground transition-colors hover:bg-surface-3 hover:text-foreground active:bg-surface-4"
:class="route.path === item.path && 'bg-primary/10 text-primary'"
>
<component :is="item.icon" class="size-4" />
{{ item.label }}
</NuxtLink>
</nav>
<div class="mt-auto p-3">
<div class="rounded-md border border-surface-4 bg-surface-1 p-3">
<div class="flex items-center justify-between gap-2">
<span class="inline-flex items-center gap-2 text-xs font-medium">
<Database class="size-3.5 text-primary" />数据源
</span>
<Badge :variant="session.dataSource === 'production' ? 'success' : 'warning'">
{{ session.dataSource === 'production' ? '生产数据' : '模拟数据' }}
</Badge>
</div>
<p class="mt-2 text-[11px] leading-5 text-muted-foreground">
统计范围仅包含主动同意遥测的匿名安装
</p>
</div>
<div class="mt-3 flex items-center gap-2 px-1 text-[11px] text-muted-foreground">
<ShieldCheck class="size-3.5 text-emerald-600" />
Cloudflare Access 已保护
</div>
</div>
</aside>
<div class="min-w-0 md:pl-60">
<header
class="sticky top-0 z-20 flex h-16 items-center gap-2 border-b border-surface-4 bg-surface-2/95 px-3 backdrop-blur md:px-5"
>
<Button
variant="ghost"
size="icon"
class="md:hidden"
aria-label="打开导航"
@click="mobileOpen = true"
>
<Menu class="size-4" />
</Button>
<button
type="button"
class="hidden h-9 min-w-52 cursor-pointer items-center gap-2 rounded-md border border-surface-4 bg-surface-1 px-3 text-sm text-muted-foreground transition-colors hover:border-surface-5 hover:bg-surface-3 lg:flex"
@click="commandOpen = true"
>
<Search class="size-4" />
<span class="flex-1 text-left">快速跳转</span>
<kbd class="rounded border border-surface-4 bg-surface-2 px-1.5 py-0.5 text-[10px]"
>Ctrl K</kbd
>
</button>
<div class="ml-auto flex min-w-0 items-center gap-2">
<Tabs
:model-value="range"
label="UTC 时间范围"
:items="ranges"
class="hidden sm:inline-flex"
@update:model-value="setRange"
/>
<Tooltip text="刷新当前数据">
<Button variant="outline" size="icon" aria-label="刷新当前数据" @click="refresh">
<RefreshCw class="size-4" />
</Button>
</Tooltip>
<DropdownMenu :session="session" />
</div>
</header>
<div class="border-b border-surface-4 bg-surface-2 px-3 py-2 sm:hidden">
<Tabs
:model-value="range"
label="UTC 时间范围"
:items="ranges"
@update:model-value="setRange"
/>
</div>
<main class="mx-auto min-w-0 max-w-[1680px] p-4 md:p-5 lg:p-6">
<slot />
</main>
</div>
<Command v-model:open="commandOpen" />
</template>
</div>
</template>

View File

@ -1,6 +0,0 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs))
}

View File

@ -1,70 +0,0 @@
import { fileURLToPath } from 'node:url'
const remoteBindingsEnabled =
process.env.NODE_ENV === 'development' && process.env.TELEMETRY_ADMIN_REMOTE_BINDINGS === 'true'
export default defineNuxtConfig({
compatibilityDate: '2025-08-13',
devtools: { enabled: false },
telemetry: false,
css: ['~/assets/styles/tailwind.css'],
app: {
head: {
htmlAttrs: { lang: 'zh-CN' },
title: 'Axolotl 遥测中心',
meta: [
{ name: 'robots', content: 'noindex, nofollow' },
{ name: 'color-scheme', content: 'light dark' },
],
script: [
{
key: 'theme-init',
innerHTML:
"(function(){try{var t=localStorage.getItem('telemetry-admin-theme')||'system';var d=document.documentElement;var dark=t==='dark'||(t==='system'&&matchMedia('(prefers-color-scheme: dark)').matches);d.classList.toggle('dark',dark)}catch(e){}})()",
},
],
},
pageTransition: { name: 'dashboard-page', mode: 'out-in' },
},
runtimeConfig: {
accessTeamDomain: '',
accessAudience: '',
mockAuth: false,
mockScenario: 'normal',
publicWorkerHealthUrl: 'https://telemetry.axlmc.org/health',
public: {
adminOrigin: 'https://admin.axlmc.org',
},
},
postcss: {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
},
hooks: {
'vite:extendConfig'(config, { isClient }) {
if (process.env.NODE_ENV !== 'development') return
if (!config.server) return
const port = isClient ? 24679 : 24680
config.server.hmr = { port, clientPort: port }
},
},
nitro: {
preset:
process.env.NITRO_PRESET ||
(remoteBindingsEnabled
? 'node-server'
: process.env.VERCEL === '1'
? 'vercel'
: 'cloudflare_module'),
externals: remoteBindingsEnabled ? { external: ['wrangler'] } : undefined,
plugins: remoteBindingsEnabled
? [fileURLToPath(new URL('./server/dev/remote-bindings.ts', import.meta.url))]
: [],
},
typescript: {
strict: true,
typeCheck: false,
},
})

View File

@ -1,51 +0,0 @@
{
"name": "@axolotl/telemetry-dashboard",
"private": true,
"type": "module",
"scripts": {
"build": "nuxi build",
"deploy": "pnpm run build && wrangler deploy",
"dev": "nuxi dev",
"preview": "nuxi preview",
"postinstall": "nuxi prepare",
"format": "prettier --check .",
"lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write .",
"typecheck": "nuxi typecheck && tsc --noEmit -p tsconfig.test.json",
"test": "pnpm run test:server && pnpm run test:ui",
"test:server": "vitest run --config vitest.server.config.ts",
"test:ui": "vitest run --config vitest.ui.config.ts",
"prepr": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1105.0",
"@unovis/vue": "^1.6.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"jose": "^6.1.0",
"lucide-vue-next": "^0.468.0",
"nuxt": "=3.20.2",
"reka-ui": "^2.6.0",
"tailwind-merge": "^2.6.0",
"vue": "^3.5.13",
"vue-router": "*",
"zod": "^3.25.76"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.8.69",
"@cloudflare/workers-types": "^5.20260801.1",
"@modrinth/tooling-config": "workspace:*",
"@types/node": "^24",
"@vitejs/plugin-vue": "^5.2.4",
"@vue/test-utils": "^2.4.6",
"autoprefixer": "^10.4.19",
"happy-dom": "^18.0.1",
"postcss": "^8.4.39",
"prettier-plugin-tailwindcss": "^0.6.5",
"tailwindcss": "^3.4.4",
"typescript": "^5.9.2",
"vitest": "^3.2.4",
"wrangler": "^4.28.1"
},
"prettier": "@modrinth/tooling-config/frontend.prettier.config.cjs"
}

View File

@ -1,159 +0,0 @@
<script setup lang="ts">
import {
Activity,
CalendarDays,
Clock3,
Database,
PackagePlus,
Users,
} from 'lucide-vue-next'
import AppState from '~/components/AppState.vue'
import DistributionChart from '~/components/charts/DistributionChart.vue'
import TrendChart from '~/components/charts/TrendChart.vue'
import MetricCard from '~/components/MetricCard.vue'
import PageHeader from '~/components/PageHeader.vue'
import Alert from '~/components/ui/Alert.vue'
import Badge from '~/components/ui/Badge.vue'
import Card from '~/components/ui/Card.vue'
import Skeleton from '~/components/ui/Skeleton.vue'
import type { ActivityDto, DistributionsDto, OverviewDto } from '~/shared/types/telemetry'
import { formatUtcTimestamp } from '~/utils/format'
const range = useDashboardRange()
const refreshEpoch = useDashboardRefresh()
const requestFetch = useRequestFetch()
const { data, status, error, refresh } = await useAsyncData(
'overview-data',
async () => {
const query = { range: range.value }
const [overview, activity, distributions] = await Promise.all([
requestFetch<OverviewDto>('/api/admin/overview', { query }),
requestFetch<ActivityDto>('/api/admin/activity', { query }),
requestFetch<DistributionsDto>('/api/admin/distributions', { query }),
])
return { overview, activity, distributions }
},
{ watch: [range, refreshEpoch] },
)
const metricDefinitions = [
{ key: 'totalInstallations', title: '累计同意遥测安装', icon: Database, tone: 'green' },
{ key: 'dau', title: '日活跃安装DAU', icon: Activity, tone: 'cyan' },
{ key: 'wau', title: '周活跃安装WAU', icon: CalendarDays, tone: 'cyan' },
{ key: 'mau', title: '月活跃安装MAU', icon: Users, tone: 'cyan' },
{ key: 'newInstallationsToday', title: '今日新增安装', icon: PackagePlus, tone: 'green' },
] as const
const trendCharts = [
{
title: '每日活跃安装趋势',
description: '按 UTC 自然日统计的唯一活跃安装。',
series: [
{
key: 'activeInstallations' as const,
label: '活跃安装',
color: 'var(--chart-green)',
},
],
},
{
title: '每日新增安装趋势',
description: '按 UTC 自然日首次出现的同意遥测安装。',
series: [{ key: 'newInstallations' as const, label: '新增安装', color: 'var(--chart-cyan)' }],
},
]
const distributionCharts = computed(() => [
{
title: '启动器版本',
description: '按上报版本统计的唯一活跃安装。',
items: data.value?.distributions.versions ?? [],
color: 'var(--chart-green)',
},
{
title: '操作系统',
description: '按上报平台统计的唯一活跃安装。',
items: data.value?.distributions.platforms ?? [],
color: 'var(--chart-gold)',
},
{
title: 'CPU 架构',
description: '按上报架构统计的唯一活跃安装。',
items: data.value?.distributions.architectures ?? [],
color: 'var(--chart-cyan)',
},
])
</script>
<template>
<div>
<PageHeader
title="数据总览"
description="查看匿名启动器遥测的活跃度、增长和运行环境分布。"
>
<Badge v-if="data" variant="secondary" class="gap-1.5 px-2.5 py-1">
<Clock3 class="size-3.5" />更新于 {{ formatUtcTimestamp(data.overview.generatedAt) }}
</Badge>
</PageHeader>
<Alert title="统计口径" class="mb-5">
所有统计仅包含主动同意遥测的匿名安装时间均按 UTC 计算
</Alert>
<div v-if="status === 'pending' && !data" data-state="loading">
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
<Skeleton v-for="index in 5" :key="index" class="h-32" />
</div>
<div class="mt-5 grid gap-4 xl:grid-cols-2">
<Skeleton v-for="index in 4" :key="index" class="h-80" />
</div>
</div>
<AppState v-else-if="error || !data" compact kind="error" @retry="refresh" />
<template v-else>
<div
class="grid grid-cols-2 gap-3 lg:grid-cols-4"
:class="status === 'pending' && 'opacity-65'"
:aria-busy="status === 'pending'"
>
<MetricCard
v-for="metric in metricDefinitions"
:key="metric.key"
:title="metric.title"
:value="data.overview.metrics[metric.key].value"
:detail="data.overview.metrics[metric.key].label"
:icon="metric.icon"
:tone="metric.tone"
/>
</div>
<section class="mt-6">
<div class="mb-3">
<h2 class="text-sm font-semibold">使用者趋势</h2>
<p class="mt-0.5 text-xs text-muted-foreground">当前范围 {{ range }}截止今日 UTC</p>
</div>
<div class="grid gap-4 xl:grid-cols-2">
<Card v-for="chart in trendCharts" :key="chart.title" class="min-w-0 p-4">
<h3 class="text-sm font-semibold">{{ chart.title }}</h3>
<p class="mt-1 text-xs text-muted-foreground">{{ chart.description }}</p>
<TrendChart class="mt-3" :data="data.activity.points" :series="chart.series" />
</Card>
</div>
</section>
<section class="mt-6">
<div class="mb-3">
<h2 class="text-sm font-semibold">运行环境分布</h2>
<p class="mt-0.5 text-xs text-muted-foreground">所选时间范围内的唯一活跃安装</p>
</div>
<div class="grid gap-4 lg:grid-cols-3">
<Card v-for="chart in distributionCharts" :key="chart.title" class="min-w-0 p-4">
<h3 class="text-sm font-semibold">{{ chart.title }}</h3>
<p class="mt-1 text-xs text-muted-foreground">{{ chart.description }}</p>
<DistributionChart class="mt-4" :items="chart.items" :color="chart.color" />
</Card>
</div>
</section>
</template>
</div>
</template>

View File

@ -1,52 +0,0 @@
<script setup lang="ts">
import { Database, ShieldCheck } from 'lucide-vue-next'
import AppState from '~/components/AppState.vue'
import PageHeader from '~/components/PageHeader.vue'
import StatusRow from '~/components/system/StatusRow.vue'
import Card from '~/components/ui/Card.vue'
import Skeleton from '~/components/ui/Skeleton.vue'
import type { SystemDto } from '~/shared/types/telemetry'
import { formatUtcTimestamp } from '~/utils/format'
const refreshEpoch = useDashboardRefresh()
const requestFetch = useRequestFetch()
const { data, status, error, refresh } = await useAsyncData(
'system-data',
() => requestFetch<SystemDto>('/api/admin/system'),
{ watch: [refreshEpoch] },
)
</script>
<template>
<div>
<PageHeader title="系统状态" description="检查采集服务、聚合任务和数据状态。" />
<div v-if="status === 'pending' && !data" class="grid gap-4 lg:grid-cols-2" data-state="loading">
<Skeleton v-for="index in 3" :key="index" class="h-48" />
</div>
<AppState v-else-if="error || !data" compact kind="error" @retry="refresh" />
<template v-else>
<div class="grid gap-4 lg:grid-cols-2" :class="status === 'pending' && 'opacity-65'">
<Card class="overflow-hidden">
<div class="border-b px-4 py-3">
<h2 class="text-sm font-semibold">服务检查</h2>
<p class="mt-0.5 text-xs text-muted-foreground">检查时间 {{ formatUtcTimestamp(data.generatedAt) }}</p>
</div>
<StatusRow name="公开遥测 Worker" :check="data.publicWorker" />
<StatusRow name="D1 只读查询" :check="data.d1" />
<StatusRow name="Cron 聚合任务" :check="data.cron" />
<StatusRow name="Cloudflare 账户用量" :check="data.accountUsage" />
</Card>
<Card class="p-4">
<h2 class="text-sm font-semibold">数据状态</h2>
<p class="mt-1 text-xs text-muted-foreground">每日聚合的最近可用数据</p>
<div class="mt-7 flex items-center justify-between gap-4 text-sm">
<span class="text-muted-foreground">最新数据日期</span>
<span class="flex items-center gap-2 tabular-nums"><Database class="size-4" />{{ data.latestDataDay ? `${data.latestDataDay}UTC` : '暂无' }}</span>
</div>
<div class="mt-7 flex items-center gap-2 border-t pt-4 text-xs text-muted-foreground"><ShieldCheck class="size-4 text-emerald-600" />管理 API 只读访问</div>
</Card>
</div>
</template>
</div>
</template>

View File

@ -1,7 +0,0 @@
import { defineAdminHandler } from '../../utils/handler'
import { getAdminApi } from '../../utils/service'
import { getQueryRecord, parseRange } from '../../utils/validation'
export default defineAdminHandler(async (event) =>
getAdminApi(event).activity(parseRange(getQueryRecord(event).range)),
)

View File

@ -1,7 +0,0 @@
import { defineAdminHandler } from '../../utils/handler'
import { getAdminApi } from '../../utils/service'
import { getQueryRecord, parseRange } from '../../utils/validation'
export default defineAdminHandler(async (event) =>
getAdminApi(event).distributions(parseRange(getQueryRecord(event).range)),
)

View File

@ -1,7 +0,0 @@
import { defineAdminHandler } from '../../utils/handler'
import { getAdminApi } from '../../utils/service'
import { getQueryRecord, parseRange } from '../../utils/validation'
export default defineAdminHandler(async (event) =>
getAdminApi(event).overview(parseRange(getQueryRecord(event).range)),
)

View File

@ -1,8 +0,0 @@
import { defineAdminHandler } from '../../utils/handler'
import { getAdminApi, requireSession } from '../../utils/service'
export default defineAdminHandler(async (event) => {
const session = requireSession(event)
getAdminApi(event)
return { ...session, dataSource: session.mock ? 'fixture' : 'production' }
})

View File

@ -1,4 +0,0 @@
import { defineAdminHandler } from '../../utils/handler'
import { getAdminApi } from '../../utils/service'
export default defineAdminHandler(async (event) => getAdminApi(event).system())

View File

@ -1,26 +0,0 @@
import { createRequire } from 'node:module'
import type { PlatformProxy } from 'wrangler'
import type { DashboardBindings } from '../utils/bindings'
let proxy: PlatformProxy<DashboardBindings> | null = null
const require = createRequire(import.meta.url)
export default defineNitroPlugin(async (nitroApp) => {
const { getPlatformProxy } = require('wrangler') as typeof import('wrangler')
proxy = await getPlatformProxy<DashboardBindings>({
configPath: 'wrangler.toml',
persist: false,
remoteBindings: true,
})
nitroApp.hooks.hook('request', (event) => {
event.context.cloudflare = { env: proxy?.env ?? {} }
})
nitroApp.hooks.hook('close', async () => {
await proxy?.dispose()
proxy = null
})
})

View File

@ -1,36 +0,0 @@
import { getHeader } from 'h3'
import { toAdminHttpError } from '../utils/admin-http-error'
import {
accessSettings,
authenticateAccessToken,
mockAuthEnabled,
mockScenario,
mockSession,
} from '../utils/auth'
import { AdminApiError, unauthorized, unavailable } from '../utils/errors'
import type { AdminEventContext } from '../utils/service'
export default defineEventHandler(async (event) => {
if (!event.path.startsWith('/api/admin/')) return
try {
const config = useRuntimeConfig(event)
if (mockAuthEnabled(config)) {
;(event.context as AdminEventContext).adminSession = mockSession(mockScenario(config))
return
}
const settings = accessSettings(config)
if (!settings) {
if (process.env.NODE_ENV === 'production') throw unavailable()
throw unauthorized()
}
const token = getHeader(event, 'cf-access-jwt-assertion')
;(event.context as AdminEventContext).adminSession = await authenticateAccessToken(
token,
settings,
)
} catch (error) {
if (error instanceof AdminApiError) throw toAdminHttpError(error)
throw error
}
})

View File

@ -1,21 +0,0 @@
import type {
ActivityDto,
AdminRange,
DistributionsDto,
ErrorDetailDto,
ErrorSampleDto,
ErrorsPageDto,
OverviewDto,
SystemDto,
} from '../../shared/types/telemetry'
import type { ErrorQuery } from './validation'
export interface TelemetryAdminApi {
overview(range: AdminRange): Promise<OverviewDto>
activity(range: AdminRange): Promise<ActivityDto>
distributions(range: AdminRange): Promise<DistributionsDto>
errors(query: ErrorQuery): Promise<ErrorsPageDto>
errorDetail(fingerprint: string): Promise<ErrorDetailDto | null>
errorSample(fingerprint: string): Promise<ErrorSampleDto | null>
system(): Promise<SystemDto>
}

View File

@ -1,11 +0,0 @@
import { createError } from 'h3'
import type { AdminApiError } from './errors'
export function toAdminHttpError(error: AdminApiError) {
return createError({
statusCode: error.statusCode,
statusMessage: error.message,
data: { error: { code: error.code, message: error.message } },
})
}

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