refactor: 移除遥测并修复启动器流程
This commit is contained in:
51
Cargo.lock
generated
51
Cargo.lock
generated
@ -106,7 +106,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"thiserror 2.0.19",
|
||||
"utoipa",
|
||||
"uuid 1.24.0",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -707,7 +707,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"fnv",
|
||||
"uuid 1.24.0",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1431,21 +1431,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "dispatch2"
|
||||
version = "0.3.1"
|
||||
@ -3570,7 +3555,7 @@ dependencies = [
|
||||
"portable-atomic",
|
||||
"smallvec",
|
||||
"tagptr",
|
||||
"uuid 1.24.0",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -5300,7 +5285,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"url",
|
||||
"uuid 1.24.0",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -5904,7 +5889,7 @@ dependencies = [
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid 1.24.0",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -5984,7 +5969,7 @@ dependencies = [
|
||||
"stringprep",
|
||||
"thiserror 2.0.19",
|
||||
"tracing",
|
||||
"uuid 1.24.0",
|
||||
"uuid",
|
||||
"whoami",
|
||||
]
|
||||
|
||||
@ -6022,7 +6007,7 @@ dependencies = [
|
||||
"stringprep",
|
||||
"thiserror 2.0.19",
|
||||
"tracing",
|
||||
"uuid 1.24.0",
|
||||
"uuid",
|
||||
"whoami",
|
||||
]
|
||||
|
||||
@ -6048,7 +6033,7 @@ dependencies = [
|
||||
"thiserror 2.0.19",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid 1.24.0",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -6454,7 +6439,7 @@ dependencies = [
|
||||
"thiserror 2.0.19",
|
||||
"time",
|
||||
"url",
|
||||
"uuid 1.24.0",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@ -6762,7 +6747,7 @@ dependencies = [
|
||||
"toml 1.1.3+spec-1.1.0",
|
||||
"url",
|
||||
"urlpattern",
|
||||
"uuid 1.24.0",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@ -6833,7 +6818,6 @@ dependencies = [
|
||||
"data-url",
|
||||
"derive_more",
|
||||
"dirs",
|
||||
"discord-rich-presence",
|
||||
"dotenvy",
|
||||
"dunce",
|
||||
"either",
|
||||
@ -6901,7 +6885,7 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"uuid 1.24.0",
|
||||
"uuid",
|
||||
"webpki-roots",
|
||||
"whoami",
|
||||
"windows 0.61.3",
|
||||
@ -6967,7 +6951,7 @@ dependencies = [
|
||||
"trash",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"uuid 1.24.0",
|
||||
"uuid",
|
||||
"windows 0.61.3",
|
||||
"zip 6.0.0",
|
||||
]
|
||||
@ -7691,15 +7675,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "uuid"
|
||||
version = "1.24.0"
|
||||
@ -8868,7 +8843,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"uuid 1.24.0",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"winnow 1.0.4",
|
||||
"zbus_macros",
|
||||
|
||||
@ -41,7 +41,6 @@ dashmap = "6.1.0"
|
||||
data-url = "0.3.2"
|
||||
derive_more = "2.1.1"
|
||||
dirs = "6.0.0"
|
||||
discord-rich-presence = "1.0.0"
|
||||
dotenvy = "0.15.7"
|
||||
dunce = "1.0.5"
|
||||
either = "1.15.0"
|
||||
|
||||
@ -85,7 +85,6 @@ import InstanceIconPickerModal from '@/components/ui/modal/InstanceIconPickerMod
|
||||
import JavaDownloadConfirmationModal from '@/components/ui/modal/JavaDownloadConfirmationModal.vue'
|
||||
import ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.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 NavButton from '@/components/ui/NavButton.vue'
|
||||
import NavRail from '@/components/ui/NavRail.vue'
|
||||
@ -98,7 +97,6 @@ import { useDropImport } from '@/composables/useDropImport'
|
||||
import { minecraftLaunchErrorKey } from '@/composables/useMinecraftLaunchError'
|
||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||
import { AxolotlBrandConfig, config, getOfficialLabrinthBaseUrl } from '@/config'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { check_reachable } from '@/helpers/auth.js'
|
||||
import { get_user, get_version } from '@/helpers/cache.js'
|
||||
import { configureCurseForgeManualDownloadWatcher } from '@/helpers/curseforge'
|
||||
@ -119,16 +117,13 @@ import { mergeUrlQuery, parseModrinthLink } from '@/helpers/project-links.ts'
|
||||
import {
|
||||
get as getSettings,
|
||||
getLastBrowseContentProjectType,
|
||||
getPrivacySettings,
|
||||
getUpdateChannel,
|
||||
getUpdatePreferences,
|
||||
isBrowseContentProjectType,
|
||||
type PrivacySettings,
|
||||
savePrivacySettings,
|
||||
set as setSettings,
|
||||
} from '@/helpers/settings.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 {
|
||||
areUpdatesEnabled,
|
||||
backupAppDbForUpdate,
|
||||
@ -369,8 +364,6 @@ watch(
|
||||
)
|
||||
|
||||
const stateInitialized = ref(false)
|
||||
const privacyConsentModal = ref<InstanceType<typeof PrivacyConsentModal>>()
|
||||
const privacyConsentPending = ref(false)
|
||||
const closeChoiceModal = ref<InstanceType<typeof NewModal>>()
|
||||
const closeChoiceOpen = ref(false)
|
||||
const closeChoiceRemember = ref(false)
|
||||
@ -1055,9 +1048,6 @@ async function setupApp() {
|
||||
theme,
|
||||
accent_color,
|
||||
locale,
|
||||
telemetry,
|
||||
telemetry_consent_version,
|
||||
discord_rpc,
|
||||
collapsed_navigation,
|
||||
hide_nametag_skins_page,
|
||||
advanced_rendering,
|
||||
@ -1101,7 +1091,6 @@ async function setupApp() {
|
||||
const dev = await isDev()
|
||||
isDevEnvironment.value = dev
|
||||
if (!onboarded && route.path !== '/') await router.replace('/')
|
||||
privacyConsentPending.value = telemetry_consent_version < 1
|
||||
showOnboarding.value = false
|
||||
onboardingSettings.value = initialSettings
|
||||
|
||||
@ -1132,16 +1121,7 @@ async function setupApp() {
|
||||
themeStore.devMode = developer_mode
|
||||
themeStore.featureFlags = feature_flags
|
||||
stateInitialized.value = true
|
||||
if (privacyConsentPending.value) {
|
||||
await nextTick()
|
||||
privacyConsentModal.value?.show({
|
||||
telemetry,
|
||||
discord_rpc,
|
||||
consent_version: telemetry_consent_version,
|
||||
})
|
||||
} else {
|
||||
showOnboarding.value = !onboarded
|
||||
}
|
||||
void reconcileMojangAuthSourceAtStartup().catch(handleError)
|
||||
|
||||
isMaximized.value = await getCurrentWindow().isMaximized()
|
||||
@ -1274,42 +1254,7 @@ async function closeOnboardingSettings() {
|
||||
}
|
||||
|
||||
async function scheduleStartupDialogs() {
|
||||
if (!stateInitialized.value || privacyConsentPending.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)
|
||||
}
|
||||
if (!stateInitialized.value || showOnboarding.value) return
|
||||
}
|
||||
|
||||
provide('replayOnboarding', replayOnboarding)
|
||||
@ -1320,7 +1265,6 @@ provide(
|
||||
)
|
||||
provide('previewMinecraftCrashModal', () => minecraftCrashModal.value?.showPreview())
|
||||
provide('showLauncherPopup', (_request: unknown) => {})
|
||||
provide('previewPrivacyConsentModal', previewPrivacyConsentModal)
|
||||
|
||||
const stateFailed = ref(false)
|
||||
stateInitialization
|
||||
@ -1429,9 +1373,6 @@ loading.setEnabled(false)
|
||||
let initialLoadToken = loading.begin()
|
||||
let routerToken = null
|
||||
let suspenseToken = null
|
||||
let lastDiscordActivity = null
|
||||
let discordActivityUpdate = Promise.resolve()
|
||||
|
||||
let suspensePending = false
|
||||
|
||||
const sidebarOverlayScrollbarsOptions = Object.freeze({
|
||||
@ -1447,31 +1388,11 @@ router.beforeEach(() => {
|
||||
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) => {
|
||||
hideAllPoppers()
|
||||
if (!failure) void invoke('lightweight_mode_set_route', { route: to.fullPath })
|
||||
trackEvent('PageView', {
|
||||
path: to.path,
|
||||
fromPath: from.path,
|
||||
failed: failure,
|
||||
})
|
||||
if (!failure) {
|
||||
void directLinkSync?.()
|
||||
if (stateInitialized.value) syncDiscordActivity(to)
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (!suspensePending && stateInitialized.value) {
|
||||
@ -1508,7 +1429,6 @@ watch(
|
||||
stateInitialized,
|
||||
(ready) => {
|
||||
if (ready) {
|
||||
syncDiscordActivity(router.currentRoute.value)
|
||||
if (initialLoadToken) {
|
||||
loading.end(initialLoadToken)
|
||||
initialLoadToken = null
|
||||
@ -1624,7 +1544,6 @@ const dropImport = useDropImport({
|
||||
onSkinsPage,
|
||||
onSchematicWorkshopPage,
|
||||
isSchematicFile,
|
||||
trackEvent,
|
||||
router,
|
||||
})
|
||||
|
||||
@ -1836,9 +1755,6 @@ async function handleCommand(e) {
|
||||
} else {
|
||||
await install_create_modpack_instance(location).catch(handleError)
|
||||
}
|
||||
trackEvent('InstanceCreate', {
|
||||
source: 'CreationModalFileDrop',
|
||||
})
|
||||
}
|
||||
} else if (e.event === 'LaunchInstance') {
|
||||
const instance = await getInstance(e.id).catch(() => null)
|
||||
@ -2577,7 +2493,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
/>
|
||||
<MinecraftCrashModal ref="minecraftCrashModal" @error="handleError" />
|
||||
<JavaDownloadConfirmationModal ref="javaDownloadConfirmationModal" />
|
||||
<PrivacyConsentModal ref="privacyConsentModal" @saved="handlePrivacyConsentSaved" />
|
||||
<NewModal
|
||||
ref="closeChoiceModal"
|
||||
:header="formatMessage(messages.closeLauncherTitle)"
|
||||
|
||||
@ -28,7 +28,6 @@ import LegacyProjectCard from '@/components/ui/LegacyProjectCard.vue'
|
||||
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
|
||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { install_duplicate_instance } from '@/helpers/install'
|
||||
import { kill, remove, run } from '@/helpers/instance'
|
||||
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 })
|
||||
})
|
||||
trackEvent('InstanceStart', {
|
||||
loader: args.item.loader,
|
||||
game_version: args.item.game_version,
|
||||
})
|
||||
break
|
||||
case 'stop':
|
||||
await kill(args.item.id).catch(handleError)
|
||||
trackEvent('InstanceStop', {
|
||||
loader: args.item.loader,
|
||||
game_version: args.item.game_version,
|
||||
})
|
||||
break
|
||||
case 'add_content':
|
||||
await router.push({
|
||||
|
||||
@ -18,7 +18,6 @@ import { computed, ref, watch } from 'vue'
|
||||
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import {
|
||||
type DailyPlaytime,
|
||||
type DailyPlaytimeEntry,
|
||||
@ -200,11 +199,6 @@ function selectDay(dateKey: string) {
|
||||
async function playInstance(instance: GameInstance) {
|
||||
try {
|
||||
await run(instance.id)
|
||||
trackEvent('InstanceStart', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'HomeCalendar',
|
||||
})
|
||||
} catch (error) {
|
||||
const handled = await handleMinecraftLaunchError(error, {
|
||||
instance_id: instance.id,
|
||||
@ -216,11 +210,6 @@ async function playInstance(instance: GameInstance) {
|
||||
|
||||
async function stopInstance(instance: GameInstance) {
|
||||
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 })
|
||||
|
||||
@ -25,7 +25,6 @@ import HomeGreeting from '@/components/home/HomeGreeting.vue'
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { process_listener } from '@/helpers/events'
|
||||
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
|
||||
import { kill, run } from '@/helpers/instance'
|
||||
@ -126,11 +125,6 @@ async function playInstance() {
|
||||
loading.value = true
|
||||
try {
|
||||
await run(instance.id)
|
||||
trackEvent('InstanceStart', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'HomeMinimal',
|
||||
})
|
||||
} catch (error) {
|
||||
const handled = await handleMinecraftLaunchError(error, {
|
||||
instance_id: instance.id,
|
||||
@ -149,11 +143,6 @@ async function stopInstance() {
|
||||
|
||||
await kill(instance.id).catch(handleError)
|
||||
running.value = false
|
||||
trackEvent('InstanceStop', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'HomeMinimal',
|
||||
})
|
||||
}
|
||||
|
||||
async function installInstance() {
|
||||
|
||||
@ -22,7 +22,6 @@ import { computed, ref } from 'vue'
|
||||
import type { HomeWidgetSize } from '@/components/home/home-dashboard'
|
||||
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
|
||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { kill } from '@/helpers/instance'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import {
|
||||
@ -107,11 +106,6 @@ async function joinServer(world: ServerWorld & WorldWithInstance, instance: Game
|
||||
|
||||
try {
|
||||
await start_join_server(world.instance_id, world.address)
|
||||
trackEvent('InstanceStart', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'HomePinnedServer',
|
||||
})
|
||||
} catch (error) {
|
||||
const handled = await handleMinecraftLaunchError(error, {
|
||||
instance_id: instance.id,
|
||||
@ -125,11 +119,6 @@ async function joinServer(world: ServerWorld & WorldWithInstance, instance: Game
|
||||
|
||||
async function stopInstance(instance: GameInstance) {
|
||||
await kill(instance.id).catch(handleError)
|
||||
trackEvent('InstanceStop', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'HomePinnedServer',
|
||||
})
|
||||
}
|
||||
|
||||
async function unpinServer(world: ServerWorld & WorldWithInstance) {
|
||||
|
||||
@ -7,7 +7,6 @@ import { getHomeWidgetCardDensity, type HomeWidgetSize } from '@/components/home
|
||||
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
|
||||
import WorldItem from '@/components/ui/world/WorldItem.vue'
|
||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { kill, run } from '@/helpers/instance'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import {
|
||||
@ -73,11 +72,6 @@ async function joinWorld(world: WorldWithInstance, instance: GameInstance) {
|
||||
try {
|
||||
await start_join_singleplayer_world(world.instance_id, world.path)
|
||||
playingWorldKey.value = key
|
||||
trackEvent('InstanceStart', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'HomePinnedWorld',
|
||||
})
|
||||
} catch (error) {
|
||||
const handled = await handleMinecraftLaunchError(error, {
|
||||
instance_id: instance.id,
|
||||
@ -92,11 +86,6 @@ async function joinWorld(world: WorldWithInstance, instance: GameInstance) {
|
||||
async function playInstance(instance: GameInstance) {
|
||||
try {
|
||||
await run(instance.id)
|
||||
trackEvent('InstanceStart', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'HomePinnedWorld',
|
||||
})
|
||||
} catch (error) {
|
||||
const handled = await handleMinecraftLaunchError(error, {
|
||||
instance_id: instance.id,
|
||||
@ -109,11 +98,6 @@ async function playInstance(instance: GameInstance) {
|
||||
async function stopInstance(instance: GameInstance) {
|
||||
await kill(instance.id).catch(handleError)
|
||||
playingWorldKey.value = null
|
||||
trackEvent('InstanceStop', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'HomePinnedWorld',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@ -15,7 +15,6 @@ import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtim
|
||||
import InstanceItem from '@/components/ui/world/InstanceItem.vue'
|
||||
import WorldItem from '@/components/ui/world/WorldItem.vue'
|
||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { kill, run } from '@/helpers/instance'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import {
|
||||
@ -117,11 +116,6 @@ async function joinWorld(world: WorldWithInstance, instance: GameInstance) {
|
||||
await start_join_singleplayer_world(world.instance_id, world.path)
|
||||
}
|
||||
playingWorldKey.value = key
|
||||
trackEvent('InstanceStart', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'HomeRecentWorld',
|
||||
})
|
||||
} catch (error) {
|
||||
const handled = await handleMinecraftLaunchError(error, {
|
||||
instance_id: instance.id,
|
||||
@ -136,11 +130,6 @@ async function joinWorld(world: WorldWithInstance, instance: GameInstance) {
|
||||
async function playInstance(instance: GameInstance) {
|
||||
try {
|
||||
await run(instance.id)
|
||||
trackEvent('InstanceStart', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'HomeRecentWorld',
|
||||
})
|
||||
} catch (error) {
|
||||
const handled = await handleMinecraftLaunchError(error, {
|
||||
instance_id: instance.id,
|
||||
@ -153,11 +142,6 @@ async function playInstance(instance: GameInstance) {
|
||||
async function stopInstance(instance: GameInstance) {
|
||||
await kill(instance.id).catch(handleError)
|
||||
playingWorldKey.value = null
|
||||
trackEvent('InstanceStop', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'HomeRecentWorld',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@ -28,7 +28,6 @@ import type { HomeWidgetPlacement, HomeWidgetSize } from '@/components/home/home
|
||||
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { kill, run } from '@/helpers/instance'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import {
|
||||
@ -205,11 +204,6 @@ async function playInstance(targetInstance: GameInstance) {
|
||||
starting.value = true
|
||||
try {
|
||||
await run(targetInstance.id)
|
||||
trackEvent('InstanceStart', {
|
||||
loader: targetInstance.loader,
|
||||
game_version: targetInstance.game_version,
|
||||
source: 'HomeInstanceWidget',
|
||||
})
|
||||
} catch (error) {
|
||||
const handled = await handleMinecraftLaunchError(error, {
|
||||
instance_id: targetInstance.id,
|
||||
@ -230,11 +224,6 @@ async function playWorld() {
|
||||
} else {
|
||||
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) {
|
||||
const handled = await handleMinecraftLaunchError(error, {
|
||||
instance_id: instance.value.id,
|
||||
|
||||
@ -280,7 +280,6 @@ import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||
import { compareMinecraftAccounts } from '@/helpers/accounts'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import {
|
||||
begin_yggdrasil_login,
|
||||
delete_yggdrasil_password,
|
||||
@ -678,7 +677,6 @@ async function onMicrosoftLogin(account: MinecraftCredential) {
|
||||
loginDisabled.value = true
|
||||
try {
|
||||
await setAccount(account)
|
||||
trackEvent('AccountLogIn')
|
||||
} catch (error) {
|
||||
handleSevereError(error)
|
||||
} finally {
|
||||
@ -833,7 +831,6 @@ async function addYggdrasilAccount() {
|
||||
await persistYggdrasilPasswordPreference()
|
||||
yggdrasilAccountModal.value?.hide()
|
||||
await setAccount(result.credentials)
|
||||
trackEvent('YggdrasilAccountAdd')
|
||||
} else {
|
||||
pendingYggdrasilFlowId.value = result.flow_id
|
||||
pendingYggdrasilProfiles.value = result.profiles
|
||||
@ -859,7 +856,6 @@ async function selectYggdrasilProfile(profileId: string) {
|
||||
await persistYggdrasilPasswordPreference()
|
||||
yggdrasilProfileModal.value?.hide()
|
||||
await setAccount(account)
|
||||
trackEvent('YggdrasilAccountAdd')
|
||||
} catch (error) {
|
||||
handleError(error as Error)
|
||||
} finally {
|
||||
@ -875,7 +871,6 @@ async function logout(account: MinecraftCredential) {
|
||||
} else {
|
||||
notifyAccountChange()
|
||||
}
|
||||
trackEvent('AccountLogOut')
|
||||
}
|
||||
|
||||
async function copyAccountUuid(account: MinecraftCredential) {
|
||||
|
||||
@ -212,7 +212,6 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import AppUpdateButton from '@/components/ui/app-update-button/index.vue'
|
||||
import { useInstallJobNotifications } from '@/composables/browse/install-job-notifications'
|
||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { loading_listener, process_listener } from '@/helpers/events'
|
||||
import { get_many as getInstances } from '@/helpers/instance'
|
||||
import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process'
|
||||
@ -437,11 +436,6 @@ const stop = async (process: RunningProcess) => {
|
||||
try {
|
||||
await killProcess(process.uuid).catch(handleError)
|
||||
|
||||
trackEvent('InstanceStop', {
|
||||
loader: process.instance.loader,
|
||||
game_version: process.instance.game_version,
|
||||
source: 'AppBar',
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
|
||||
@ -23,7 +23,6 @@ import { computed, ref } from 'vue'
|
||||
import { ChatIcon } from '@/assets/icons'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { AxolotlBrandConfig } from '@/config'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
|
||||
import { install_existing_instance } from '@/helpers/install'
|
||||
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 trackEvent('AccountLogIn', { source: 'ErrorModal' })
|
||||
loadingMinecraft.value = false
|
||||
errorModal.value.hide()
|
||||
} catch (err) {
|
||||
|
||||
@ -23,7 +23,6 @@ import { useRouter } from 'vue-router'
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { process_listener } from '@/helpers/events'
|
||||
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
|
||||
import { kill, run } from '@/helpers/instance'
|
||||
@ -121,11 +120,6 @@ const play = async (e, context) => {
|
||||
if (!handled) handleSevereError(err, { instanceId: props.instance.id })
|
||||
})
|
||||
.finally(() => {
|
||||
trackEvent('InstanceStart', {
|
||||
loader: props.instance.loader,
|
||||
game_version: props.instance.game_version,
|
||||
source: context,
|
||||
})
|
||||
})
|
||||
loading.value = false
|
||||
}
|
||||
@ -136,11 +130,6 @@ const stop = async (e, context) => {
|
||||
|
||||
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) => {
|
||||
|
||||
@ -59,7 +59,6 @@ import {
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { java_discovery_listener } from '@/helpers/events'
|
||||
import { find_filtered_jres } from '@/helpers/jre.js'
|
||||
|
||||
@ -131,9 +130,5 @@ const emit = defineEmits(['submit'])
|
||||
function setJavaInstall(javaInstall) {
|
||||
emit('submit', javaInstall)
|
||||
detectJavaModal.value.hide()
|
||||
trackEvent('JavaAutoDetect', {
|
||||
path: javaInstall.path,
|
||||
version: javaInstall.version,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -112,7 +112,6 @@ import { computed, ref, watch } from 'vue'
|
||||
|
||||
import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue'
|
||||
import useJavaTest from '@/composables/useJavaTest'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { auto_install_java, find_filtered_jres, get_jre } from '@/helpers/jre.js'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
@ -196,9 +195,9 @@ const recommendedInstalled = computed(() => {
|
||||
let hasInitialized = false
|
||||
|
||||
async function runTest(path) {
|
||||
await testJavaInstallation(path, testVersion.value, true)
|
||||
await testJavaInstallation(path, testVersion.value)
|
||||
if (props.version != null) {
|
||||
await recommendedJavaTest.testJavaInstallation(path, props.version, false)
|
||||
await recommendedJavaTest.testJavaInstallation(path, props.version)
|
||||
}
|
||||
}
|
||||
|
||||
@ -212,9 +211,9 @@ watch(
|
||||
(newPath) => {
|
||||
if (newPath) {
|
||||
if (!hasInitialized) {
|
||||
testJavaInstallation(newPath, testVersion.value, false)
|
||||
testJavaInstallation(newPath, testVersion.value)
|
||||
if (props.version != null) {
|
||||
recommendedJavaTest.testJavaInstallation(newPath, props.version, false)
|
||||
recommendedJavaTest.testJavaInstallation(newPath, props.version)
|
||||
}
|
||||
hasInitialized = true
|
||||
} else {
|
||||
@ -242,9 +241,6 @@ async function handleJavaFileInput() {
|
||||
}
|
||||
}
|
||||
|
||||
trackEvent('JavaManualSelect', {
|
||||
version: props.version,
|
||||
})
|
||||
|
||||
commitSelection(result)
|
||||
}
|
||||
@ -279,7 +275,6 @@ async function reinstallJava() {
|
||||
}
|
||||
}
|
||||
|
||||
trackEvent('JavaReInstall', { path: path, version: props.version })
|
||||
commitSelection(result)
|
||||
runTest(result.path)
|
||||
} finally {
|
||||
|
||||
@ -14,7 +14,6 @@ import { computed, ref } from 'vue'
|
||||
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { list } from '@/helpers/instance'
|
||||
import { add_server_to_instance, get_instance_worlds } from '@/helpers/worlds.ts'
|
||||
|
||||
@ -91,7 +90,6 @@ defineExpose({
|
||||
instances.value = instanceValues
|
||||
modal.value.show()
|
||||
|
||||
trackEvent('AddServerToInstanceStart', { source: 'AddServerToInstanceModal' })
|
||||
},
|
||||
})
|
||||
|
||||
@ -102,11 +100,6 @@ async function addServer(instance) {
|
||||
instance.added = true
|
||||
await queryClient.invalidateQueries({ queryKey: ['worlds', instance.id] })
|
||||
|
||||
trackEvent('AddServerToInstance', {
|
||||
server_name: serverName.value,
|
||||
instance_name: instance.name,
|
||||
source: 'AddServerToInstanceModal',
|
||||
})
|
||||
} catch (err) {
|
||||
handleError(err)
|
||||
}
|
||||
|
||||
@ -18,7 +18,6 @@ import { useRouter } from 'vue-router'
|
||||
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { install_duplicate_instance } from '@/helpers/install'
|
||||
import { edit, edit_icon, get_full_path, remove } from '@/helpers/instance'
|
||||
import { injectInstanceSettings } from '@/providers/instance-settings'
|
||||
@ -49,10 +48,6 @@ const installing = computed(() => instance.value.install_stage !== 'installed')
|
||||
|
||||
async function duplicateInstance() {
|
||||
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) {
|
||||
@ -104,7 +99,6 @@ watch(selectedReleaseChannel, async (channel, previousChannel) => {
|
||||
async function resetIcon() {
|
||||
icon.value = undefined
|
||||
await edit_icon(instance.value.id, null).catch(handleError)
|
||||
trackEvent('InstanceRemoveIcon')
|
||||
}
|
||||
|
||||
async function setIcon() {
|
||||
@ -116,7 +110,6 @@ async function setIcon() {
|
||||
icon.value = picked.path
|
||||
try {
|
||||
await edit_icon(instance.value.id, picked.path)
|
||||
trackEvent('InstanceSetIcon')
|
||||
} catch (error) {
|
||||
icon.value = previousIcon
|
||||
handleError(error)
|
||||
@ -253,10 +246,6 @@ async function removeInstance() {
|
||||
removing.value = true
|
||||
const path = instance.value.id
|
||||
|
||||
trackEvent('InstanceRemove', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
|
||||
await router.push({ path: '/' })
|
||||
await remove(path).catch(handleError)
|
||||
|
||||
@ -22,7 +22,6 @@ import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import SymlinkInstanceWarning from '@/components/ui/SymlinkInstanceWarning.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { get_project_versions, get_version } from '@/helpers/cache'
|
||||
import { type CurseForgeFile, updateManagedCurseForgeModpack } from '@/helpers/curseforge'
|
||||
import {
|
||||
@ -333,10 +332,6 @@ provideInstallationSettings({
|
||||
afterSave: async () => {
|
||||
debug('afterSave: installing', { instanceId: instance.value.id })
|
||||
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')
|
||||
},
|
||||
|
||||
@ -345,40 +340,27 @@ provideInstallationSettings({
|
||||
repairing.value = true
|
||||
await install_existing_instance(instance.value.id, true).catch(handleError)
|
||||
repairing.value = false
|
||||
trackEvent('InstanceRepair', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
debug('repair: done')
|
||||
},
|
||||
|
||||
async reinstallModpack() {
|
||||
debug('reinstallModpack: called', { instanceId: instance.value.id })
|
||||
reinstalling.value = true
|
||||
let shouldTrack = false
|
||||
try {
|
||||
if (isImportedModpack.value) {
|
||||
shouldTrack = await installLocalModpackFromPicker()
|
||||
await installLocalModpackFromPicker()
|
||||
} else if (isCurseForgeLinkedModpack.value) {
|
||||
const fileId = Number(instance.value.link?.version_id)
|
||||
if (!Number.isFinite(fileId)) {
|
||||
throw new Error('Invalid CurseForge file ID')
|
||||
}
|
||||
await updateManagedCurseForgeModpack(instance.value.id, fileId).catch(handleError)
|
||||
shouldTrack = true
|
||||
} else {
|
||||
await update_repair_modrinth(instance.value.id).catch(handleError)
|
||||
shouldTrack = true
|
||||
}
|
||||
} finally {
|
||||
reinstalling.value = false
|
||||
}
|
||||
if (shouldTrack) {
|
||||
trackEvent('InstanceRepair', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
}
|
||||
debug('reinstallModpack: done')
|
||||
},
|
||||
|
||||
@ -386,13 +368,7 @@ provideInstallationSettings({
|
||||
debug('swapModpack: called', { instanceId: instance.value.id })
|
||||
reinstalling.value = true
|
||||
try {
|
||||
const installed = await installLocalModpackFromPicker()
|
||||
if (installed) {
|
||||
trackEvent('InstanceRepair', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
}
|
||||
await installLocalModpackFromPicker()
|
||||
} finally {
|
||||
reinstalling.value = false
|
||||
}
|
||||
|
||||
@ -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>
|
||||
@ -204,15 +204,6 @@ export const onboardingMessages = defineMessages({
|
||||
defaultMessage:
|
||||
'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' },
|
||||
updatesDescription: {
|
||||
id: 'app.onboarding.updates.description',
|
||||
|
||||
@ -21,7 +21,6 @@ import JetBrainsLogo from '@/assets/java-vendors/jetbrains.png'
|
||||
import MicrosoftLogo from '@/assets/java-vendors/microsoft.png'
|
||||
import OracleLogo from '@/assets/java-vendors/oracle.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'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
@ -117,7 +116,6 @@ function backToVendors() {
|
||||
|
||||
async function downloadVersion(info) {
|
||||
downloading.value = info.major_version
|
||||
trackEvent('JavaDownload', { vendor: info.vendor, version: info.major_version })
|
||||
modal.value?.hide()
|
||||
|
||||
const job = await download_java(info.vendor, info.major_version).catch(handleError)
|
||||
|
||||
@ -23,7 +23,6 @@ const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const isDevEnvironment = await isDev()
|
||||
const previewMinecraftCrashModal = inject<() => void>('previewMinecraftCrashModal')
|
||||
const previewPrivacyConsentModal = inject<() => Promise<void>>('previewPrivacyConsentModal')
|
||||
const messages = defineMessages({
|
||||
resetToDefault: {
|
||||
id: 'app.settings.feature-flags.reset-to-default',
|
||||
@ -53,10 +52,6 @@ const messages = defineMessages({
|
||||
id: 'app.settings.about.preview-minecraft-crash-modal',
|
||||
defaultMessage: 'Preview Minecraft crash window',
|
||||
},
|
||||
previewPrivacyConsentModal: {
|
||||
id: 'app.settings.about.preview-privacy-consent-modal',
|
||||
defaultMessage: 'Preview privacy & security modal',
|
||||
},
|
||||
})
|
||||
|
||||
const settings = ref(await getSettings())
|
||||
@ -127,9 +122,6 @@ watch(
|
||||
<Button type="base" @click="previewMinecraftCrashModal?.()">
|
||||
<WrenchIcon /> {{ formatMessage(messages.previewMinecraftCrashModal) }}
|
||||
</Button>
|
||||
<Button type="base" @click="previewPrivacyConsentModal?.()">
|
||||
<WrenchIcon /> {{ formatMessage(messages.previewPrivacyConsentModal) }}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</template>
|
||||
|
||||
@ -19,7 +19,6 @@ import MemoryAllocationDisplay from '@/components/ui/MemoryAllocationDisplay.vue
|
||||
import DownloadJavaModal from '@/components/ui/settings/DownloadJavaModal.vue'
|
||||
import InstalledJavaModal from '@/components/ui/settings/InstalledJavaModal.vue'
|
||||
import useMemorySlider from '@/composables/useMemorySlider'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { collectGcContext } from '@/helpers/gc/context'
|
||||
import { wait_for_install_job } from '@/helpers/install'
|
||||
import { getJavaArgumentPresets } from '@/helpers/java-argument-presets'
|
||||
@ -249,7 +248,6 @@ async function runScan(exhaustive) {
|
||||
|
||||
scanning.value = true
|
||||
scanMode.value = 'quick'
|
||||
trackEvent('JavaQuickScan', { source: 'settings' })
|
||||
try {
|
||||
await find_filtered_jres(null, false, true, false).catch(handleError)
|
||||
} finally {
|
||||
@ -262,7 +260,6 @@ async function confirmDeepScan() {
|
||||
showDeepScanConfirm.value = false
|
||||
scanning.value = true
|
||||
scanMode.value = 'deep'
|
||||
trackEvent('JavaDeepScan', { source: 'settings' })
|
||||
try {
|
||||
await find_filtered_jres(null, true, true, true).catch(handleError)
|
||||
} finally {
|
||||
@ -280,7 +277,6 @@ async function handleManualAdd() {
|
||||
if (!javaInfo) return
|
||||
|
||||
await set_java_version(javaInfo).catch(handleError)
|
||||
trackEvent('JavaManualSelect', { path: filePath })
|
||||
}
|
||||
|
||||
async function onJavaDownloaded(job) {
|
||||
|
||||
@ -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>
|
||||
@ -10,7 +10,6 @@ export type SettingsCategoryId =
|
||||
| 'content-downloads'
|
||||
| 'network-multiplayer'
|
||||
| 'storage-backups'
|
||||
| 'privacy-data'
|
||||
| 'updates'
|
||||
| 'about'
|
||||
| 'feature-flags'
|
||||
@ -106,15 +105,6 @@ export const settingsCategoryDefinitions: SettingsCategoryDefinition[] = [
|
||||
group: 'data-privacy',
|
||||
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',
|
||||
name: defineMessage({ id: 'app.settings.tabs.updates', defaultMessage: 'Updates' }),
|
||||
|
||||
@ -72,10 +72,6 @@ const categoryContent: Record<SettingsCategoryId, Pick<SettingsCategory, 'icon'
|
||||
icon: ArchiveIcon,
|
||||
content: defineAsyncComponent(() => import('./StorageBackupSettings.vue')),
|
||||
},
|
||||
'privacy-data': {
|
||||
icon: ShieldIcon,
|
||||
content: defineAsyncComponent(() => import('./PrivacySettings.vue')),
|
||||
},
|
||||
updates: {
|
||||
icon: RefreshCwIcon,
|
||||
content: defineAsyncComponent(() => import('./UpdateSettings.vue')),
|
||||
|
||||
@ -221,18 +221,6 @@ export const settingsSearchEntries: SettingsSearchEntry[] = [
|
||||
label: message('app.crash-analysis.ai.settings.title', 'Crash AI explanation'),
|
||||
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',
|
||||
categoryId: 'java-performance',
|
||||
|
||||
@ -28,7 +28,6 @@ const settingsComponentFiles = {
|
||||
'content-downloads': ['./AppearanceSettings.vue', './ResourceManagementSettings.vue'],
|
||||
'network-multiplayer': ['./ResourceManagementSettings.vue', './MultiplayerSettings.vue'],
|
||||
'storage-backups': ['./ResourceManagementSettings.vue', './StorageSettings.vue'],
|
||||
'privacy-data': ['./PrivacySettings.vue'],
|
||||
updates: ['./UpdateSettings.vue'],
|
||||
about: ['./AboutSettings.vue'],
|
||||
'feature-flags': ['./FeatureFlagSettings.vue'],
|
||||
@ -169,7 +168,7 @@ test('developer-only settings stay out of the normal search categories', () => {
|
||||
'content-downloads',
|
||||
'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('developer'), [])
|
||||
assert.deepEqual(categoriesForGroup('developer', true), ['feature-flags'])
|
||||
|
||||
@ -26,7 +26,6 @@ import { useRouter } from 'vue-router'
|
||||
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { get_project } from '@/helpers/cache'
|
||||
import { process_listener } from '@/helpers/events'
|
||||
import { kill, run } from '@/helpers/instance'
|
||||
@ -108,11 +107,6 @@ const play = async (event: MouseEvent) => {
|
||||
if (!handled) handleSevereError(err, { instanceId: props.instance.id })
|
||||
})
|
||||
.finally(() => {
|
||||
trackEvent('InstanceStart', {
|
||||
loader: props.instance.loader,
|
||||
game_version: props.instance.game_version,
|
||||
source: 'InstanceItem',
|
||||
})
|
||||
})
|
||||
emit('play')
|
||||
loading.value = false
|
||||
@ -122,11 +116,6 @@ const stop = async (event: MouseEvent) => {
|
||||
event?.stopPropagation()
|
||||
loading.value = true
|
||||
await kill(props.instance.id).catch(handleError)
|
||||
trackEvent('InstanceStop', {
|
||||
loader: props.instance.loader,
|
||||
game_version: props.instance.game_version,
|
||||
source: 'InstanceItem',
|
||||
})
|
||||
emit('stop')
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@ -114,8 +114,6 @@ export interface DropImportOptions {
|
||||
onSchematicWorkshopPage: ComputedRef<boolean>
|
||||
/** Check if path is a schematic file */
|
||||
isSchematicFile: (path: string) => boolean
|
||||
/** Track analytics event */
|
||||
trackEvent: (name: string, properties?: Record<string, unknown>) => void
|
||||
/** Route to push */
|
||||
router: Router
|
||||
}
|
||||
@ -135,7 +133,6 @@ export interface DropImportOptions {
|
||||
* onSkinsPage,
|
||||
* onSchematicWorkshopPage,
|
||||
* isSchematicFile,
|
||||
* trackEvent,
|
||||
* router,
|
||||
* route,
|
||||
* })
|
||||
@ -151,7 +148,6 @@ export function useDropImport(options: DropImportOptions) {
|
||||
onSkinsPage,
|
||||
onSchematicWorkshopPage,
|
||||
isSchematicFile,
|
||||
trackEvent,
|
||||
router,
|
||||
} = options
|
||||
|
||||
@ -906,7 +902,6 @@ export function useDropImport(options: DropImportOptions) {
|
||||
|
||||
clearDropProcessingNotification()
|
||||
await installModpackFromPath(filePath, fileName, { persistUntilDone: true })
|
||||
trackEvent('InstanceCreate', { source: 'DropConfirmModpack' })
|
||||
await router.push('/library')
|
||||
return
|
||||
}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { get_jre, test_jre } from '@/helpers/jre.js'
|
||||
|
||||
export default function useJavaTest() {
|
||||
@ -8,7 +7,7 @@ export default function useJavaTest() {
|
||||
const javaTestResult = ref<boolean | 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) {
|
||||
clearTimeout(testDebounceTimer)
|
||||
testDebounceTimer = null
|
||||
@ -28,10 +27,6 @@ export default function useJavaTest() {
|
||||
javaTestResult.value = false
|
||||
}
|
||||
testingJava.value = false
|
||||
|
||||
if (track) {
|
||||
trackEvent('JavaTest', { path, success: javaTestResult.value })
|
||||
}
|
||||
}
|
||||
|
||||
function testJavaInstallationDebounced(path: string, version: number | null, delay = 600) {
|
||||
@ -43,8 +38,8 @@ export default function useJavaTest() {
|
||||
testDebounceTimer = setTimeout(() => runJavaTest(path, version, false), delay)
|
||||
}
|
||||
|
||||
async function testJavaInstallation(path: string, version: number | null, track = false) {
|
||||
await runJavaTest(path, version, track)
|
||||
async function testJavaInstallation(path: string, version: number | null) {
|
||||
await runJavaTest(path, version)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@ -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])
|
||||
}
|
||||
}
|
||||
@ -652,10 +652,6 @@ export async function update_project(instanceId: string, projectPath: string): P
|
||||
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 {
|
||||
game_versions?: string[]
|
||||
loaders?: string[]
|
||||
@ -691,14 +687,10 @@ export interface ResolveContentPlan {
|
||||
export async function add_project_from_version(
|
||||
instanceId: string,
|
||||
versionId: string,
|
||||
reason: DownloadReason,
|
||||
dependentOnVersionId?: string,
|
||||
): Promise<string> {
|
||||
return await invoke('plugin:instance|instance_add_project_from_version', {
|
||||
instanceId,
|
||||
versionId,
|
||||
reason,
|
||||
dependentOnVersionId,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -175,9 +175,6 @@ export type AppSettings = {
|
||||
home_widgets: HomeDashboardConfig | null
|
||||
terracotta_public_nodes: string[]
|
||||
|
||||
telemetry: boolean
|
||||
telemetry_consent_version: number
|
||||
discord_rpc: boolean
|
||||
onboarded: boolean
|
||||
onboarding_version: number
|
||||
onboarding_instance_tour_completed: boolean
|
||||
@ -207,12 +204,6 @@ export type AppSettings = {
|
||||
version: number
|
||||
}
|
||||
|
||||
export type PrivacySettings = {
|
||||
telemetry: boolean
|
||||
discord_rpc: boolean
|
||||
consent_version: number
|
||||
}
|
||||
|
||||
type LegacyMirrorSettings = {
|
||||
use_minecraft_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')
|
||||
}
|
||||
|
||||
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> {
|
||||
return await invoke('plugin:settings|proxy_get')
|
||||
}
|
||||
|
||||
@ -44,10 +44,6 @@ export async function 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
|
||||
export async function progress_bars_list() {
|
||||
return await invoke<Record<string, LoadingBar>>('plugin:utils|progress_bars_list')
|
||||
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
2
apps/app-frontend/src/helpers/types.d.ts
vendored
2
apps/app-frontend/src/helpers/types.d.ts
vendored
@ -216,8 +216,6 @@ type AppSettings = {
|
||||
close_behavior: 'ask' | 'close' | 'lightweight'
|
||||
home_widgets: import('@/components/home/home-dashboard').HomeDashboardConfig | null
|
||||
|
||||
telemetry: boolean
|
||||
discord_rpc: boolean
|
||||
developer_mode: boolean
|
||||
|
||||
onboarded: boolean
|
||||
|
||||
@ -5912,12 +5912,6 @@
|
||||
"app.onboarding.mascot-alt": {
|
||||
"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": {
|
||||
"message": "Choose how content downloads and installs, from download sources to safety checks."
|
||||
},
|
||||
@ -5963,30 +5957,6 @@
|
||||
"app.onboarding.welcome.title": {
|
||||
"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": {
|
||||
"message": "Loading CurseForge project…"
|
||||
},
|
||||
@ -6806,9 +6776,6 @@
|
||||
"app.settings.about.preview-minecraft-crash-modal": {
|
||||
"message": "Preview Minecraft crash window"
|
||||
},
|
||||
"app.settings.about.preview-privacy-consent-modal": {
|
||||
"message": "Preview privacy & security modal"
|
||||
},
|
||||
"app.settings.about.product-title": {
|
||||
"message": "About {productName}"
|
||||
},
|
||||
@ -7070,21 +7037,6 @@
|
||||
"app.settings.java.view-installed": {
|
||||
"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": {
|
||||
"message": "Add .minecraft directory"
|
||||
},
|
||||
@ -7466,9 +7418,6 @@
|
||||
"app.settings.tabs.network-multiplayer": {
|
||||
"message": "Network & multiplayer"
|
||||
},
|
||||
"app.settings.tabs.privacy-data": {
|
||||
"message": "Privacy & data sharing"
|
||||
},
|
||||
"app.settings.tabs.storage": {
|
||||
"message": "Storage"
|
||||
},
|
||||
|
||||
@ -104,24 +104,42 @@
|
||||
"app.easteregg.color-mine.revealed": {
|
||||
"message": "第 {row} 行,第 {column} 列,颜色 {color},周围同色 {number} 格"
|
||||
},
|
||||
"app.settings.developer.announcement-preview": { "message": "公告样式预览" },
|
||||
"app.settings.developer.announcement-preview": {
|
||||
"message": "公告样式预览"
|
||||
},
|
||||
"app.settings.developer.announcement-preview-description": {
|
||||
"message": "使用本地示例预览真实公告组件,不请求远端公告,也不改变真实公告的已读状态。"
|
||||
},
|
||||
"app.settings.developer.preview-with-action": { "message": "显示可选的外部链接按钮" },
|
||||
"app.settings.developer.preview-announcement-modal": { "message": "预览启动弹窗" },
|
||||
"app.settings.developer.preview-announcement-popup": { "message": "预览 Popup 通知" },
|
||||
"app.remote-announcements.preview-title": { "message": "公告样式预览" },
|
||||
"app.settings.developer.preview-with-action": {
|
||||
"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": {
|
||||
"message": "这是一条本地预览通知,点击“查看公告”可查看完整 Markdown 正文。"
|
||||
},
|
||||
"app.remote-announcements.preview-action": { "message": "访问官网" },
|
||||
"app.remote-announcements.preview-action": {
|
||||
"message": "访问官网"
|
||||
},
|
||||
"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)"
|
||||
},
|
||||
"app.remote-announcements.view": { "message": "查看公告" },
|
||||
"app.remote-announcements.unread": { "message": "未读" },
|
||||
"app.remote-announcements.read-all": { "message": "将所有公告标为已读" },
|
||||
"app.remote-announcements.view": {
|
||||
"message": "查看公告"
|
||||
},
|
||||
"app.remote-announcements.unread": {
|
||||
"message": "未读"
|
||||
},
|
||||
"app.remote-announcements.read-all": {
|
||||
"message": "将所有公告标为已读"
|
||||
},
|
||||
"app.account.signed-in-as": {
|
||||
"message": "登录身份:"
|
||||
},
|
||||
@ -5984,12 +6002,6 @@
|
||||
"app.onboarding.mascot-alt": {
|
||||
"message": "引导吉祥物"
|
||||
},
|
||||
"app.onboarding.privacy.description": {
|
||||
"message": "匿名遥测、Discord 活动状态与 Minecraft 日志分析服务都可以在这里调整。"
|
||||
},
|
||||
"app.onboarding.privacy.title": {
|
||||
"message": "你的数据,由你决定"
|
||||
},
|
||||
"app.onboarding.resources.description": {
|
||||
"message": "选择内容的下载与安装方式,包括下载源和安全确认。"
|
||||
},
|
||||
@ -6035,30 +6047,6 @@
|
||||
"app.onboarding.welcome.title": {
|
||||
"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": {
|
||||
"message": "正在加载 CurseForge 项目…"
|
||||
},
|
||||
@ -6851,9 +6839,6 @@
|
||||
"app.settings.about.preview-minecraft-crash-modal": {
|
||||
"message": "预览 Minecraft 崩溃窗口"
|
||||
},
|
||||
"app.settings.about.preview-privacy-consent-modal": {
|
||||
"message": "预览隐私与安全弹窗"
|
||||
},
|
||||
"app.settings.about.product-title": {
|
||||
"message": "关于 {productName}"
|
||||
},
|
||||
@ -7100,21 +7085,6 @@
|
||||
"app.settings.java.view-installed": {
|
||||
"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": {
|
||||
"message": "应用缓存"
|
||||
},
|
||||
@ -7505,9 +7475,6 @@
|
||||
"app.settings.tabs.network-multiplayer": {
|
||||
"message": "网络与多人游戏"
|
||||
},
|
||||
"app.settings.tabs.privacy-data": {
|
||||
"message": "隐私与数据共享"
|
||||
},
|
||||
"app.settings.tabs.resource-management": {
|
||||
"message": "资源管理"
|
||||
},
|
||||
@ -10414,6 +10381,5 @@
|
||||
},
|
||||
"app.settings.about.easteregg.game-title": {
|
||||
"message": "彩蛋小游戏"
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -5723,12 +5723,6 @@
|
||||
"app.onboarding.mascot-alt": {
|
||||
"message": "引導吉祥物"
|
||||
},
|
||||
"app.onboarding.privacy.description": {
|
||||
"message": "匿名遙測、Discord 活動狀態與 Minecraft 日誌分析服務都可以在這裡調整。"
|
||||
},
|
||||
"app.onboarding.privacy.title": {
|
||||
"message": "你的資料,由你決定"
|
||||
},
|
||||
"app.onboarding.resources.description": {
|
||||
"message": "調節下載、儲存和啟動器資源。給風扇一點尊嚴。"
|
||||
},
|
||||
@ -5774,30 +5768,6 @@
|
||||
"app.onboarding.welcome.title": {
|
||||
"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": {
|
||||
"message": "正在載入 CurseForge 專案..."
|
||||
},
|
||||
@ -6566,9 +6536,6 @@
|
||||
"app.settings.about.preview-minecraft-crash-modal": {
|
||||
"message": "預覽 Minecraft 崩潰窗口"
|
||||
},
|
||||
"app.settings.about.preview-privacy-consent-modal": {
|
||||
"message": "預覽隱私與安全彈窗"
|
||||
},
|
||||
"app.settings.about.product-title": {
|
||||
"message": "關於 {productName}"
|
||||
},
|
||||
@ -6815,21 +6782,6 @@
|
||||
"app.settings.java.view-installed": {
|
||||
"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": {
|
||||
"message": "應用程式快取"
|
||||
},
|
||||
@ -7220,9 +7172,6 @@
|
||||
"app.settings.tabs.network-multiplayer": {
|
||||
"message": "網路與多人遊戲"
|
||||
},
|
||||
"app.settings.tabs.privacy-data": {
|
||||
"message": "隱私與資料共享"
|
||||
},
|
||||
"app.settings.tabs.resource-management": {
|
||||
"message": "資源管理"
|
||||
},
|
||||
|
||||
@ -9,7 +9,6 @@ import { createApp } from 'vue'
|
||||
|
||||
import App from '@/App.vue'
|
||||
import { overlayScrollbarsDirective } from '@/directives/overlayScrollbars'
|
||||
import { installTelemetryHandlers } from '@/helpers/telemetry'
|
||||
import i18nPlugin from '@/plugins/i18n'
|
||||
import i18nDebugPlugin from '@/plugins/i18n-debug'
|
||||
import router from '@/routes'
|
||||
@ -18,8 +17,6 @@ const pinia = createPinia()
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
installTelemetryHandlers()
|
||||
|
||||
app.use(VueQueryPlugin)
|
||||
app.use(router)
|
||||
app.use(pinia)
|
||||
|
||||
@ -137,11 +137,11 @@
|
||||
</template>
|
||||
</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"
|
||||
>
|
||||
<template
|
||||
v-for="(metric, index) in downloadTelemetry(job)"
|
||||
v-for="(metric, index) in downloadDetails(job)"
|
||||
:key="`${index}-${metric}`"
|
||||
>
|
||||
<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)
|
||||
}
|
||||
|
||||
function downloadTelemetry(job: InstallJobSnapshot) {
|
||||
function downloadDetails(job: InstallJobSnapshot) {
|
||||
const summary = job.summary
|
||||
const metrics: string[] = []
|
||||
if (summary.source && !isRecoveryValidation(job)) {
|
||||
|
||||
@ -419,7 +419,6 @@ import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||
import { postUpgradeNoticeQueryKey, usePostUpgradeNotice } from '@/composables/usePostUpgradeNotice'
|
||||
import { useSymlinkWarningDismiss } from '@/composables/useSymlinkWarningDismiss'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { get_project_v3 } from '@/helpers/cache.js'
|
||||
import { instance_listener, process_listener } from '@/helpers/events'
|
||||
import {
|
||||
@ -776,11 +775,6 @@ const startInstance = async (context: string) => {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
trackEvent('InstanceStart', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
source: context,
|
||||
})
|
||||
}
|
||||
|
||||
const stopInstance = async (context: string) => {
|
||||
@ -790,11 +784,6 @@ const stopInstance = async (context: string) => {
|
||||
playing.value = false
|
||||
|
||||
if (!instance.value) return
|
||||
trackEvent('InstanceStop', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
source: context,
|
||||
})
|
||||
}
|
||||
|
||||
const handlePlayServer = async () => {
|
||||
|
||||
@ -264,7 +264,6 @@ import ContentToggleDependenciesModal from '@/components/ui/modal/ContentToggleD
|
||||
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
|
||||
import { postUpgradeNoticeQueryKey, usePostUpgradeNotice } from '@/composables/usePostUpgradeNotice'
|
||||
import { useWorldDatapacks } from '@/composables/useWorldDatapacks'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { get_project_versions, get_version, get_version_many } from '@/helpers/cache.js'
|
||||
import { applyContentItemUpdates, matchesContentItem } from '@/helpers/content-item-state'
|
||||
import { lookupContentWikiIds, translateContentItemTitles } from '@/helpers/content-search'
|
||||
@ -1580,14 +1579,6 @@ async function toggleDisableMod(
|
||||
const enabled = desiredEnabled ?? !mod.enabled
|
||||
try {
|
||||
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) {
|
||||
handleError(err as Error)
|
||||
}
|
||||
@ -1646,14 +1637,6 @@ async function applyToggleDisableMod(mod: ContentItem, enabled: boolean) {
|
||||
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) {
|
||||
applyContentItemToggleState(mod, operation.originalFileName, originalFilePath, {
|
||||
file_path: originalFilePath,
|
||||
@ -1733,14 +1716,6 @@ async function applyToggleDisableBatch(items: ContentItem[], enabled: boolean) {
|
||||
)
|
||||
|
||||
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) {
|
||||
for (const operation of operations) {
|
||||
@ -1831,13 +1806,6 @@ async function removeMod(mod: ContentItem) {
|
||||
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) {
|
||||
handleError(err as Error)
|
||||
} finally {
|
||||
@ -1999,13 +1967,6 @@ async function updateProject(mod: ContentItem) {
|
||||
try {
|
||||
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) {
|
||||
handleError(err as Error)
|
||||
throw err
|
||||
@ -2024,13 +1985,6 @@ async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions
|
||||
try {
|
||||
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) {
|
||||
handleError(err as Error)
|
||||
} finally {
|
||||
@ -2046,13 +2000,6 @@ async function handleRollbackContent(mod: ContentItem) {
|
||||
|
||||
try {
|
||||
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) {
|
||||
handleError(err as Error)
|
||||
} finally {
|
||||
|
||||
@ -204,7 +204,6 @@ import ConfirmRemoveWorldModal from '@/components/ui/world/modal/ConfirmRemoveWo
|
||||
import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue'
|
||||
import WorldItem from '@/components/ui/world/WorldItem.vue'
|
||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { get_project, get_project_v3 } from '@/helpers/cache.js'
|
||||
import { instance_listener } from '@/helpers/events'
|
||||
import { get_game_versions } from '@/helpers/tags'
|
||||
@ -592,20 +591,10 @@ async function joinWorld(world: World) {
|
||||
const managedProjectId = instance.value.link?.project_id
|
||||
if (managedProjectId && isManagedServerWorld(world)) {
|
||||
await playServerProject(managedProjectId).catch(handleJoinError)
|
||||
trackEvent('InstanceStart', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
source: 'WorldsPage',
|
||||
})
|
||||
startingInstance.value = false
|
||||
return
|
||||
}
|
||||
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') {
|
||||
await start_join_singleplayer_world(instance.value.id, world.path).catch(handleJoinError)
|
||||
}
|
||||
|
||||
@ -18,7 +18,6 @@ import {
|
||||
} from '@modrinth/ui'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import {
|
||||
type ProjectGalleryCaptionField,
|
||||
projectGalleryTranslationSegmentId,
|
||||
@ -130,10 +129,6 @@ function viewImage(entry: GalleryEntry) {
|
||||
zoomedIn.value = false
|
||||
viewerModal.value?.show()
|
||||
|
||||
trackEvent('GalleryImageExpand', {
|
||||
project_id: props.project.id,
|
||||
url: entry.image.url,
|
||||
})
|
||||
}
|
||||
|
||||
function changeImage(offset: number) {
|
||||
@ -146,10 +141,6 @@ function changeImage(offset: number) {
|
||||
selectedGalleryItem.value = filteredGallery.value[nextIndex]
|
||||
zoomedIn.value = false
|
||||
|
||||
trackEvent(offset > 0 ? 'GalleryImageNext' : 'GalleryImagePrevious', {
|
||||
project_id: props.project.id,
|
||||
url: selectedGalleryItem.value.image.url,
|
||||
})
|
||||
}
|
||||
|
||||
function handleViewerHide() {
|
||||
|
||||
@ -210,25 +210,6 @@
|
||||
{{ installButtonLabel }}
|
||||
</button>
|
||||
</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">
|
||||
<OverflowMenu
|
||||
:tooltip="`More options`"
|
||||
@ -339,7 +320,6 @@
|
||||
:translations="translations"
|
||||
:translation-mode="translationMode"
|
||||
:translation-style="translationStyle"
|
||||
:start-server="(version) => openModpackServerFlow(version)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else> Project data couldn't not be loaded. </template>
|
||||
@ -408,7 +388,6 @@ import {
|
||||
PlusIcon,
|
||||
ReportIcon,
|
||||
SearchIcon,
|
||||
ServerIcon,
|
||||
SpinnerIcon,
|
||||
StopCircleIcon,
|
||||
} from '@modrinth/assets'
|
||||
@ -424,7 +403,6 @@ import {
|
||||
getLatestMatchingInstallVersion,
|
||||
getTargetInstallPreferences,
|
||||
injectNotificationManager,
|
||||
injectPopupNotificationManager,
|
||||
NavTabs,
|
||||
OverflowMenu,
|
||||
ProjectBackgroundGradient,
|
||||
@ -501,7 +479,6 @@ import UpgradeProjectReturnBar from './UpgradeProjectReturnBar.vue'
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
const { addNotification, handleError } = injectNotificationManager()
|
||||
const popupNotificationManager = injectPopupNotificationManager()
|
||||
const { install: installVersion } = injectContentInstall()
|
||||
const contentSelection = injectContentSelection()
|
||||
const route = useRoute()
|
||||
@ -603,22 +580,6 @@ const messages = defineMessages({
|
||||
id: 'project.actions.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 } =
|
||||
@ -642,15 +603,6 @@ const favoriteSaved = computed(() =>
|
||||
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() {
|
||||
if (!data.value || !favoriteSupported.value || favoritePending.value) return
|
||||
await contentFavorites
|
||||
|
||||
@ -30,16 +30,6 @@
|
||||
<CheckIcon v-else />
|
||||
</button>
|
||||
</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">
|
||||
<OverflowMenu
|
||||
v-if="false"
|
||||
@ -87,7 +77,6 @@ import {
|
||||
DownloadIcon,
|
||||
ExternalIcon,
|
||||
MoreVerticalIcon,
|
||||
ServerIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
@ -98,7 +87,7 @@ import {
|
||||
ProjectPageVersions,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { SwapIcon } from '@/assets/icons/index.js'
|
||||
@ -117,10 +106,6 @@ const messages = defineMessages({
|
||||
id: 'app.project.versions.add-to-another-instance',
|
||||
defaultMessage: 'Add to another instance',
|
||||
},
|
||||
startServer: {
|
||||
id: 'app.project.versions.start-server',
|
||||
defaultMessage: 'Create server',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
@ -152,16 +137,8 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
startServer: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const serverCapable = computed(
|
||||
() => props.project?.project_type === 'modpack' && props.project?.server_side !== 'unsupported',
|
||||
)
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const route = useRoute()
|
||||
|
||||
|
||||
@ -15,7 +15,6 @@ import type { Router } from 'vue-router'
|
||||
import type ContentInstallPreviewModal from '@/components/ui/ContentInstallPreviewModal.vue'
|
||||
import type { ContentInstallPreviewData } from '@/components/ui/ContentInstallPreviewModal.vue'
|
||||
import type { ModpackInstallModalData } from '@/components/ui/modal/ModpackInstallModal.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import {
|
||||
get_organization,
|
||||
get_project,
|
||||
@ -868,12 +867,6 @@ export function createContentInstall(opts: {
|
||||
})
|
||||
removeInstallingItems(createdInstanceId, [project.id])
|
||||
markInstanceContentChanged(createdInstanceId)
|
||||
trackEvent('PackInstall', {
|
||||
id: project.id,
|
||||
version_id: version.id,
|
||||
title: project.title,
|
||||
source,
|
||||
})
|
||||
callback(version.id, [project.id])
|
||||
} catch (err) {
|
||||
debugState('createAndInstallCurseForgeModpack ERR', { err: String(err), createdInstanceId })
|
||||
@ -997,7 +990,6 @@ export function createContentInstall(opts: {
|
||||
if (sessionId !== currentSessionId) return
|
||||
contentInstallModalOpen = true
|
||||
modalRef?.show()
|
||||
trackEvent('ProjectInstallStart', { source: 'ProjectInstallModal' })
|
||||
}
|
||||
|
||||
get_game_versions()
|
||||
@ -1068,7 +1060,6 @@ export function createContentInstall(opts: {
|
||||
await nextTick()
|
||||
contentInstallModalOpen = true
|
||||
modalRef?.show()
|
||||
trackEvent('ProjectInstallStart', { source: 'ProjectInstallModal' })
|
||||
return sessionId
|
||||
}
|
||||
|
||||
@ -1688,15 +1679,6 @@ export function createContentInstall(opts: {
|
||||
if (storeInstance) storeInstance.installing = true
|
||||
try {
|
||||
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])
|
||||
hideContentInstallModal()
|
||||
} catch (err) {
|
||||
@ -1758,15 +1740,6 @@ export function createContentInstall(opts: {
|
||||
{ ...request, excluded_project_ids: excludedProjectIds },
|
||||
{ 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])
|
||||
hideContentInstallModal()
|
||||
} catch (err) {
|
||||
@ -1786,15 +1759,6 @@ export function createContentInstall(opts: {
|
||||
settleCurrentCallback()
|
||||
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])
|
||||
hideContentInstallModal()
|
||||
} catch (err) {
|
||||
@ -1830,15 +1794,6 @@ export function createContentInstall(opts: {
|
||||
storeInstance.installed = primaryInstalled
|
||||
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)
|
||||
} catch (err) {
|
||||
if (storeInstance) storeInstance.installing = false
|
||||
@ -1878,7 +1833,6 @@ export function createContentInstall(opts: {
|
||||
|
||||
await nextTick()
|
||||
incompatibilityWarningModalRef?.show(version.id)
|
||||
trackEvent('ProjectInstallStart', { source: 'ProjectIncompatibilityWarningModal' })
|
||||
}
|
||||
|
||||
async function handleIncompatibilityWarningInstall(version: Labrinth.Versions.v2.Version) {
|
||||
@ -1931,15 +1885,6 @@ export function createContentInstall(opts: {
|
||||
incompatibilityWarningModalRef?.hide()
|
||||
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() {
|
||||
@ -2056,16 +2001,6 @@ export function createContentInstall(opts: {
|
||||
},
|
||||
{ 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])
|
||||
hideContentInstallModal()
|
||||
return
|
||||
@ -2085,16 +2020,6 @@ export function createContentInstall(opts: {
|
||||
excludedCurseForgeProjectIds,
|
||||
)
|
||||
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])
|
||||
hideContentInstallModal()
|
||||
return
|
||||
@ -2131,18 +2056,6 @@ export function createContentInstall(opts: {
|
||||
: `/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)
|
||||
modalRef?.hide()
|
||||
@ -2282,15 +2195,6 @@ export function createContentInstall(opts: {
|
||||
{ ...installRequest, excluded_project_ids: excludedProjectIds },
|
||||
{ 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])
|
||||
} else {
|
||||
await showIncompatibilityWarning(instance, project, projectVersions, version, callback)
|
||||
@ -2402,15 +2306,6 @@ export function createContentInstall(opts: {
|
||||
if (isVersionCompatible(version, project, instance)) {
|
||||
const job = await queueCurrentCurseForgeVersion(instance, project, version)
|
||||
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])
|
||||
} else {
|
||||
await showIncompatibilityWarning(instance, project, versions, version, callback)
|
||||
@ -2457,7 +2352,6 @@ export function createContentInstall(opts: {
|
||||
if (sessionId !== currentSessionId) return
|
||||
contentInstallModalOpen = true
|
||||
modalRef?.show()
|
||||
trackEvent('ProjectInstallStart', { source: 'ProjectInstallModal' })
|
||||
|
||||
try {
|
||||
const candidates = (await list()).filter(
|
||||
@ -2537,15 +2431,6 @@ export function createContentInstall(opts: {
|
||||
const instance = await get(instanceId)
|
||||
if (!instance) throw new Error(formatMessage(curseForgeWorldUnknownInstanceMessage))
|
||||
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])
|
||||
}
|
||||
|
||||
@ -2700,12 +2585,6 @@ export function createContentInstall(opts: {
|
||||
if (instanceId) {
|
||||
createInstanceCallback(instanceId)
|
||||
}
|
||||
trackEvent('PackInstall', {
|
||||
id: project.id,
|
||||
version_id: versionId,
|
||||
title: project.title,
|
||||
source,
|
||||
})
|
||||
callback(versionId)
|
||||
},
|
||||
handleModpackInstallCancel() {
|
||||
|
||||
@ -4,7 +4,6 @@ import { createContext } from '@modrinth/ui'
|
||||
import { type Ref, ref } from 'vue'
|
||||
import type { Router } from 'vue-router'
|
||||
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { get_project, get_project_v3, get_version } from '@/helpers/cache.js'
|
||||
import {
|
||||
install_create_instance,
|
||||
@ -163,11 +162,6 @@ export function createServerInstall(opts: {
|
||||
action: async () => {
|
||||
try {
|
||||
await joinServer(project.id, serverAddress)
|
||||
trackEvent('InstanceStart', {
|
||||
loader: project.loader,
|
||||
game_version: project.game_version,
|
||||
source: 'ServerProject',
|
||||
})
|
||||
} catch (err) {
|
||||
handleSevereError(err, { instanceId: project.id })
|
||||
}
|
||||
@ -198,11 +192,6 @@ export function createServerInstall(opts: {
|
||||
action: async () => {
|
||||
try {
|
||||
if (serverAddress) await start_join_server(instance.id, serverAddress)
|
||||
trackEvent('InstanceStart', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'ServerProject',
|
||||
})
|
||||
} catch (err) {
|
||||
handleSevereError(err, { instanceId: instance.id })
|
||||
}
|
||||
@ -348,11 +337,6 @@ export function createServerInstall(opts: {
|
||||
// Join server
|
||||
try {
|
||||
await joinServer(instance.id, serverAddress)
|
||||
trackEvent('InstanceStart', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'ServerProject',
|
||||
})
|
||||
} catch (err) {
|
||||
handleSevereError(err, { instanceId: instance.id })
|
||||
}
|
||||
|
||||
@ -13,7 +13,6 @@ import { useRouter } from 'vue-router'
|
||||
|
||||
import type UnknownPackWarningModal from '@/components/ui/install_flow/UnknownPackWarningModal.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 { getCurseForgeFiles, hasCompatibleCurseForgeFile } from '@/helpers/curseforge'
|
||||
import { install_job_listener } from '@/helpers/events.js'
|
||||
@ -143,7 +142,6 @@ export function setupCreationModal(
|
||||
},
|
||||
{ name },
|
||||
).catch(handleError)
|
||||
trackEvent('InstanceCreate', { source: 'CreationModalModpack' })
|
||||
}
|
||||
|
||||
async function handleCreate(config: CreationFlowContextValue) {
|
||||
@ -222,7 +220,6 @@ export function setupCreationModal(
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
trackEvent('InstanceCreate', { source: 'CreationModalImport' })
|
||||
return
|
||||
}
|
||||
|
||||
@ -238,7 +235,6 @@ export function setupCreationModal(
|
||||
const splitPath = config.modpackFilePath.value.split(/[\\/]/)
|
||||
const fileName = splitPath ? splitPath[splitPath.length - 1] : config.modpackFilePath.value
|
||||
await installModpackFromPath(config.modpackFilePath.value, fileName)
|
||||
trackEvent('InstanceCreate', { source: 'CreationModalModpackFile' })
|
||||
return
|
||||
}
|
||||
|
||||
@ -281,9 +277,6 @@ export function setupCreationModal(
|
||||
gameDirOverride,
|
||||
}).catch(handleError)
|
||||
|
||||
trackEvent('InstanceCreate', {
|
||||
source: 'CreationModal',
|
||||
})
|
||||
} catch (err) {
|
||||
handleError(err as Error)
|
||||
}
|
||||
|
||||
@ -509,10 +509,6 @@ fn main() {
|
||||
.commands(&[
|
||||
"settings_get",
|
||||
"settings_set",
|
||||
"privacy_get",
|
||||
"privacy_set",
|
||||
"telemetry_set",
|
||||
"discord_rpc_set",
|
||||
"download_engine_set",
|
||||
"cancel_directory_change",
|
||||
"proxy_get",
|
||||
|
||||
@ -6,7 +6,6 @@ use path_util::SafeRelativeUtf8UnixPathBuf;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use theseus::DownloadReason;
|
||||
use theseus::data::{
|
||||
AppliedContentSetPatch, ContentItem, Dependency,
|
||||
EditInstance as CoreEditInstance, InstanceInstallCandidate,
|
||||
@ -1116,16 +1115,11 @@ pub async fn instance_update_project(
|
||||
pub async fn instance_add_project_from_version(
|
||||
instance_id: &str,
|
||||
version_id: &str,
|
||||
reason: DownloadReason,
|
||||
dependent_on_version_id: Option<String>,
|
||||
) -> Result<String> {
|
||||
Ok(theseus::instance::add_project_from_version(
|
||||
instance_id,
|
||||
version_id,
|
||||
reason,
|
||||
dependent_on_version_id,
|
||||
Ok(
|
||||
theseus::instance::add_project_from_version(instance_id, version_id)
|
||||
.await?,
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@ -25,7 +25,6 @@ pub mod shortcuts;
|
||||
pub mod storage;
|
||||
pub mod system_accent;
|
||||
pub mod tags;
|
||||
pub mod telemetry;
|
||||
pub mod terracotta;
|
||||
pub mod translation;
|
||||
pub mod utils;
|
||||
|
||||
@ -8,10 +8,6 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
settings_get,
|
||||
settings_set,
|
||||
privacy_get,
|
||||
privacy_set,
|
||||
telemetry_set,
|
||||
discord_rpc_set,
|
||||
download_engine_set,
|
||||
cancel_directory_change,
|
||||
proxy_get,
|
||||
@ -41,26 +37,6 @@ pub async fn settings_set(
|
||||
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]
|
||||
pub async fn download_engine_set(
|
||||
engine: settings::DownloadEngine,
|
||||
|
||||
@ -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();
|
||||
}
|
||||
@ -389,16 +389,6 @@ async fn backup_app_db_for_update(
|
||||
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
|
||||
#[tracing::instrument(skip_all)]
|
||||
#[tauri::command]
|
||||
@ -823,7 +813,6 @@ fn main() {
|
||||
.plugin(api::schematic_preview::init())
|
||||
.plugin(api::shortcuts::init())
|
||||
.plugin(api::tags::init())
|
||||
.plugin(api::telemetry::init())
|
||||
.plugin(api::translation::init())
|
||||
.plugin(api::utils::init())
|
||||
.plugin(api::cache::init())
|
||||
@ -851,7 +840,6 @@ fn main() {
|
||||
copy_database_between_channels,
|
||||
beta_database_exists,
|
||||
backup_app_db_for_update,
|
||||
set_discord_activity,
|
||||
is_dev,
|
||||
portable::is_portable_mode,
|
||||
are_updates_enabled,
|
||||
|
||||
@ -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=
|
||||
2
apps/telemetry-dashboard/.gitignore
vendored
2
apps/telemetry-dashboard/.gitignore
vendored
@ -1,2 +0,0 @@
|
||||
.vercel
|
||||
.env*
|
||||
@ -1,5 +0,0 @@
|
||||
.nuxt/
|
||||
.output/
|
||||
.wrangler/
|
||||
node_modules/
|
||||
coverage/
|
||||
@ -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.
|
||||
@ -1,6 +0,0 @@
|
||||
<template>
|
||||
<NuxtRouteAnnouncer />
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
@ -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));
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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)
|
||||
}
|
||||
@ -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 }
|
||||
}
|
||||
@ -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',
|
||||
},
|
||||
},
|
||||
])
|
||||
@ -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>
|
||||
@ -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))
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
})
|
||||
@ -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"
|
||||
}
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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)),
|
||||
)
|
||||
@ -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)),
|
||||
)
|
||||
@ -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)),
|
||||
)
|
||||
@ -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' }
|
||||
})
|
||||
@ -1,4 +0,0 @@
|
||||
import { defineAdminHandler } from '../../utils/handler'
|
||||
import { getAdminApi } from '../../utils/service'
|
||||
|
||||
export default defineAdminHandler(async (event) => getAdminApi(event).system())
|
||||
@ -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
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
@ -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>
|
||||
}
|
||||
@ -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
Reference in New Issue
Block a user