Files
Starlight_Lancher/apps/app-frontend/src/pages/instance/upgrade/Confirm.vue

820 lines
29 KiB
Vue

<template>
<section class="flex flex-col gap-6 py-2">
<header>
<h2 class="m-0 text-xl font-semibold text-contrast">{{ formatMessage(messages.title) }}</h2>
<p class="mb-0 mt-1 text-secondary">{{ formatMessage(messages.description) }}</p>
</header>
<Admonition v-if="executionError" type="critical" :header="formatMessage(messages.startError)">
{{ executionError }}
</Admonition>
<Admonition type="warning" :header="formatMessage(messages.worldTitle)">
<div class="flex flex-col gap-2">
<span>{{ formatMessage(messages.worldBody) }}</span>
<span>{{ formatMessage(messages.datapackNote, { path: datapackPath }) }}</span>
</div>
</Admonition>
<div class="grid gap-3 md:grid-cols-2">
<Card class="!m-0 p-4">
<h3 class="m-0 text-base font-semibold text-contrast">
{{ formatMessage(messages.environment) }}
</h3>
<div class="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
<span class="text-secondary">Minecraft</span>
<strong
>{{ plan.sourceEnvironment.gameVersion }} <span aria-hidden="true"></span>
{{ plan.targetEnvironment.gameVersion }}</strong
>
<span class="text-secondary">{{ formatMessage(messages.loader) }}</span>
<strong>{{ sourceLoader }} <span aria-hidden="true"></span> {{ targetLoader }}</strong>
</div>
</Card>
<Card class="!m-0 p-4">
<h3 class="m-0 text-base font-semibold text-contrast">
{{ formatMessage(messages.strategy) }}
</h3>
<p class="mb-0 mt-3 text-lg font-semibold text-brand">{{ strategyLabel }}</p>
</Card>
</div>
<div
class="grid grid-cols-2 gap-px overflow-hidden rounded-md bg-divider sm:grid-cols-3 lg:grid-cols-6"
>
<div v-for="metric in metrics" :key="metric.label" class="bg-surface-2 p-3">
<div class="text-xl font-semibold text-contrast">{{ metric.value }}</div>
<div class="text-sm text-secondary">{{ metric.label }}</div>
</div>
</div>
<section v-if="sharedInstance" class="order-first flex flex-col gap-3">
<div>
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.sharedTitle) }}
</h3>
<p class="mb-0 mt-1 text-sm text-secondary">
{{ formatMessage(messages.sharedDescription) }}
</p>
</div>
<div class="grid gap-3 md:grid-cols-2">
<button
type="button"
class="rounded-md border border-solid p-4 text-left"
:class="modeClass('direct')"
@click="selectMode('direct')"
>
<strong class="text-contrast">{{ formatMessage(messages.direct) }}</strong>
<p class="mb-0 mt-2 text-sm text-secondary">
{{ formatMessage(messages.directDescription) }}
</p>
</button>
<button
type="button"
class="rounded-md border border-solid p-4 text-left"
:class="modeClass('copy_and_upgrade')"
@click="selectMode('copy_and_upgrade')"
>
<strong class="text-contrast">{{ formatMessage(messages.copy) }}</strong>
<p class="mb-0 mt-2 text-sm text-secondary">
{{ formatMessage(messages.copyDescription) }}
</p>
</button>
</div>
</section>
<section class="rounded-md border border-solid border-surface-4 bg-surface-2 p-4">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.backupTitle) }}
</h3>
<template v-if="effectiveMode === 'copy_and_upgrade'">
<p class="mb-0 mt-2 text-sm text-secondary">{{ formatMessage(messages.copyNoBackup) }}</p>
</template>
<template v-else>
<Checkbox
v-model="flow.createFullBackup.value"
class="mt-3"
:label="formatMessage(messages.backupToggle)"
@update:model-value="rememberBackupPreference"
/>
<p class="mb-0 mt-2 text-sm text-secondary">
{{ formatMessage(messages.backupDescription) }}
</p>
<Admonition
v-if="!flow.createFullBackup.value"
class="mt-3"
type="warning"
:header="formatMessage(messages.backupOffTitle)"
>
{{ formatMessage(messages.backupOffBody) }}
</Admonition>
</template>
<div class="mt-4 border-0 border-t border-solid border-divider pt-3 text-sm text-secondary">
<strong class="text-contrast">{{ formatMessage(messages.rollbackTitle) }}</strong>
<p class="mb-0 mt-1">{{ formatMessage(messages.rollbackDescription) }}</p>
</div>
</section>
<section>
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.preservedTitle) }}
</h3>
<p class="mb-0 mt-2 text-sm text-secondary">{{ formatMessage(messages.preservedBody) }}</p>
</section>
<section v-if="detailGroups.some((group) => group.items.length)" class="flex flex-col gap-2">
<h3 class="m-0 text-lg font-semibold text-contrast">{{ formatMessage(messages.details) }}</h3>
<Accordion
v-for="group in detailGroups.filter((entry) => entry.items.length)"
:key="group.label"
button-class="flex w-full items-center gap-2 border-0 bg-transparent p-0 text-left text-contrast"
content-class="pt-2"
>
<template #title>
<strong>{{ group.label }}</strong>
<span class="text-sm text-secondary">{{ group.items.length }}</span>
</template>
<div class="grid gap-2 sm:grid-cols-2">
<div
v-for="item in group.items"
:key="item.key"
class="flex min-w-0 items-center gap-3 rounded-md bg-surface-2 p-3"
>
<Avatar :src="item.icon" :tint-by="item.title" size="2.5rem" no-shadow />
<div class="min-w-0 flex-1">
<RouterLink
v-if="item.projectPath"
:to="item.projectPath"
class="inline-flex max-w-full cursor-pointer items-center gap-1 font-semibold text-contrast hover:text-brand hover:underline focus-visible:underline"
@click="parkProjectReturn"
><span class="truncate">{{ item.title }}</span
><ExternalIcon class="size-3 shrink-0" aria-hidden="true"
/></RouterLink>
<div v-else class="truncate font-semibold text-contrast">{{ item.title }}</div>
<div class="flex min-w-0 flex-wrap items-center gap-x-2 text-sm text-secondary">
<span>{{ item.providerLabel }}</span>
<UpgradeVersionChangelogPopout
v-if="item.projectPath && item.currentReleaseId && item.currentLabel"
:label="item.currentLabel"
:provider="item.provider"
:project-id="item.projectId"
:release-id="item.currentReleaseId"
/>
<span v-else-if="item.currentLabel">{{ item.currentLabel }}</span>
<UpgradeVersionChangelogPopout
v-if="item.projectPath && item.targetReleaseId && item.targetLabel"
:label="item.targetLabel"
:provider="item.provider"
:project-id="item.projectId"
:release-id="item.targetReleaseId"
/>
<span v-else-if="item.targetLabel">{{ item.targetLabel }}</span>
<span v-if="item.stateLabel">{{ item.stateLabel }}</span>
</div>
</div>
</div>
</div>
</Accordion>
</section>
</section>
</template>
<script setup lang="ts">
import { ExternalIcon } from '@modrinth/assets'
import {
Accordion,
Admonition,
Avatar,
buildUpgradeDisplayNames,
Card,
Checkbox,
defineMessages,
formatLoaderLabel,
useVIntl,
} from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { InstanceContentSnapshotItem } from '@/helpers/instance'
import {
type InstanceContentData,
loadInstanceContentData,
localContentIconUrl,
} from '@/helpers/instance-content'
import type {
ContentProvider,
InstanceUpgradeDependencyChange,
InstanceUpgradePlanItem,
InstanceUpgradeSelection,
SharedUpgradeMode,
} from '@/helpers/instance-upgrade'
import { parkUpgradeFlow, upgradeProjectPath } from '@/helpers/upgrade-return-state'
import {
loadUpgradeProjectDisplayMetadata,
loadUpgradeVersionDisplayMetadata,
upgradeProjectDisplayCacheKey,
type UpgradeProjectIdentity,
type UpgradeReleaseIdentity,
upgradeVersionDisplayLabel,
} from '@/helpers/upgrade-version-metadata'
import {
confirmSelectionReleaseSlots,
confirmSolutionGroups,
confirmTargetLoaderLabel,
confirmUpgradeOptions,
contentIdentityKeys,
isSharedUpgradeInstance,
normalizeUpgradePath,
resolveConfirmDependencyReleases,
solutionSummary,
upgradeContentDisplayMetadata,
} from './analysis'
import { attachUpgradeJobToFlow, useInstanceUpgradeFlow } from './flow'
import { submitInstanceUpgrade } from './install-job'
import UpgradeVersionChangelogPopout from './UpgradeVersionChangelogPopout.vue'
const messages = defineMessages({
title: { id: 'instance.upgrade.confirm.title', defaultMessage: 'Confirm upgrade' },
description: {
id: 'instance.upgrade.confirm.description',
defaultMessage: 'Review the changes and backup options before starting the upgrade.',
},
environment: { id: 'instance.upgrade.confirm.environment', defaultMessage: 'Environment' },
loader: { id: 'instance.upgrade.confirm.loader', defaultMessage: 'Loader' },
automatic: { id: 'instance.upgrade.confirm.loader.automatic', defaultMessage: 'automatic' },
strategy: { id: 'instance.upgrade.confirm.strategy', defaultMessage: 'Upgrade strategy' },
newest: {
id: 'instance.upgrade.confirm.strategy.newest',
defaultMessage: 'Update as much as possible',
},
minimal: {
id: 'instance.upgrade.confirm.strategy.minimal',
defaultMessage: 'Change as little as possible',
},
custom: { id: 'instance.upgrade.confirm.strategy.custom', defaultMessage: 'Custom' },
updated: { id: 'instance.upgrade.confirm.updated', defaultMessage: 'Will update' },
kept: { id: 'instance.upgrade.confirm.kept', defaultMessage: 'Will keep' },
disabled: { id: 'instance.upgrade.confirm.disabled', defaultMessage: 'Will disable' },
added: {
id: 'instance.upgrade.confirm.dependencies-added',
defaultMessage: 'Dependencies added',
},
dependencyUpdated: {
id: 'instance.upgrade.confirm.dependencies-updated',
defaultMessage: 'Dependencies updated',
},
removed: {
id: 'instance.upgrade.confirm.dependencies-removed',
defaultMessage: 'Dependencies removed',
},
sharedTitle: {
id: 'instance.upgrade.confirm.shared.title',
defaultMessage: 'Shared instance handling',
},
sharedDescription: {
id: 'instance.upgrade.confirm.shared.description',
defaultMessage:
'Choose whether to modify the external target or upgrade an independent local copy.',
},
direct: { id: 'instance.upgrade.confirm.shared.direct', defaultMessage: 'Direct upgrade' },
directDescription: {
id: 'instance.upgrade.confirm.shared.direct-description',
defaultMessage: 'Modify the real external target folder. The existing link remains in place.',
},
copy: { id: 'instance.upgrade.confirm.shared.copy', defaultMessage: 'Copy and upgrade' },
copyDescription: {
id: 'instance.upgrade.confirm.shared.copy-description',
defaultMessage:
'Create and upgrade a new local instance. The original link and external target remain untouched.',
},
backupTitle: { id: 'instance.upgrade.confirm.backup.title', defaultMessage: 'Full backup' },
backupToggle: {
id: 'instance.upgrade.confirm.backup.toggle',
defaultMessage: 'Create a full backup before upgrading',
},
backupDescription: {
id: 'instance.upgrade.confirm.backup.description',
defaultMessage:
'A separate instance copy preserves worlds and configuration for a later return.',
},
copyNoBackup: {
id: 'instance.upgrade.confirm.backup.copy-mode',
defaultMessage:
'The original shared instance will not be modified, so no additional full backup will be created.',
},
backupOffTitle: {
id: 'instance.upgrade.confirm.backup.off-title',
defaultMessage: 'No full backup will be created',
},
backupOffBody: {
id: 'instance.upgrade.confirm.backup.off-body',
defaultMessage: 'World changes after launching the upgraded game may not be reversible.',
},
rollbackTitle: {
id: 'instance.upgrade.confirm.rollback.title',
defaultMessage: 'Automatic technical rollback',
},
rollbackDescription: {
id: 'instance.upgrade.confirm.rollback.description',
defaultMessage:
'If file changes fail during the upgrade, the launcher automatically rolls back the operation. This is separate from a full user backup.',
},
worldTitle: {
id: 'instance.upgrade.confirm.world.title',
defaultMessage: 'World saves may be migrated irreversibly',
},
worldBody: {
id: 'instance.upgrade.confirm.world.body',
defaultMessage:
'Launching the upgraded instance may migrate worlds to a newer save format. Opening migrated worlds with an older Minecraft version may be unsafe or unsupported. A full backup is strongly recommended.',
},
datapackNote: {
id: 'instance.upgrade.confirm.world.datapacks',
defaultMessage: 'Datapacks inside {path} are preserved but are not automatically upgraded.',
},
preservedTitle: {
id: 'instance.upgrade.confirm.preserved.title',
defaultMessage: 'Preserved data',
},
preservedBody: {
id: 'instance.upgrade.confirm.preserved.body',
defaultMessage:
'Worlds, options, servers, and configuration files are preserved. Mods, resource packs, and shaders follow the selected upgrade solution.',
},
details: { id: 'instance.upgrade.confirm.details', defaultMessage: 'Change details' },
updatedContent: {
id: 'instance.upgrade.confirm.details.updated',
defaultMessage: 'Updated content',
},
keptContent: {
id: 'instance.upgrade.confirm.details.kept',
defaultMessage: 'Kept content',
},
disabledContent: {
id: 'instance.upgrade.confirm.details.disabled',
defaultMessage: 'Disabled content',
},
dependencyChanges: {
id: 'instance.upgrade.confirm.details.dependencies',
defaultMessage: 'Dependency changes',
},
providerModrinth: { id: 'instance.upgrade.provider.modrinth', defaultMessage: 'Modrinth' },
providerCurseForge: {
id: 'instance.upgrade.provider.curseforge',
defaultMessage: 'CurseForge',
},
providerLocal: { id: 'instance.upgrade.provider.local', defaultMessage: 'Local' },
providerUnknown: { id: 'instance.upgrade.provider.unknown', defaultMessage: 'Unknown provider' },
dependencyFallback: {
id: 'instance.upgrade.confirm.details.new-dependency',
defaultMessage: 'New {provider} dependency',
},
contentFallback: {
id: 'instance.upgrade.confirm.details.content-item',
defaultMessage: 'Content item',
},
versionTarget: {
id: 'instance.upgrade.confirm.details.version-target',
defaultMessage: 'Target {version}',
},
versionCurrent: {
id: 'instance.upgrade.confirm.details.version-current',
defaultMessage: 'Current {version}',
},
disabledState: {
id: 'instance.upgrade.confirm.details.disabled-state',
defaultMessage: 'Disabled',
},
back: { id: 'instance.upgrade.confirm.back', defaultMessage: 'Back' },
start: { id: 'instance.upgrade.confirm.start', defaultMessage: 'Start upgrade' },
starting: { id: 'instance.upgrade.confirm.starting', defaultMessage: 'Starting upgrade…' },
startError: {
id: 'instance.upgrade.confirm.start-error',
defaultMessage: 'Unable to start upgrade',
},
activeJob: {
id: 'instance.upgrade.confirm.active-job',
defaultMessage: 'An upgrade is already in progress',
},
backupInstanceName: {
id: 'instance.upgrade.confirm.backup.instance-name',
defaultMessage: '{name} (Pre-upgrade backup)',
},
copyInstanceName: {
id: 'instance.upgrade.confirm.copy.instance-name',
defaultMessage: '{name} (Upgraded copy)',
},
})
const flow = useInstanceUpgradeFlow()
const route = useRoute()
const router = useRouter()
const { formatMessage } = useVIntl()
const datapackPath = 'saves/<world>/datapacks'
const submissionLock = ref(false)
const executionError = ref<string | null>(null)
const plan = computed(() => flow.plan.value!)
const solution = computed(() => plan.value.selectedSolution!)
const sharedInstance = computed(() => isSharedUpgradeInstance(flow.instance.value))
const confirmOptions = computed(() =>
confirmUpgradeOptions(
sharedInstance.value,
flow.sharedUpgradeMode.value,
flow.directFullBackupPreference.value,
),
)
const effectiveMode = computed(() => confirmOptions.value.effectiveMode)
const routeInstanceId = computed(() =>
Array.isArray(route.params.id) ? route.params.id[0] : route.params.id,
)
const submissionBusy = computed(() => flow.busy.value || submissionLock.value)
const canStartUpgrade = computed(
() =>
flow.plan.value !== null &&
flow.plan.value.blockingIssues.length === 0 &&
flow.plan.value.selectedSolution !== null &&
!flow.busy.value &&
!submissionLock.value &&
flow.activeJobId.value === null &&
routeInstanceId.value === flow.instanceId.value &&
confirmOptions.value.canStart,
)
const summary = computed(() => solutionSummary(solution.value))
const metrics = computed(() => [
{ label: formatMessage(messages.updated), value: summary.value.upgraded },
{ label: formatMessage(messages.kept), value: summary.value.kept },
{ label: formatMessage(messages.disabled), value: summary.value.disabled },
{ label: formatMessage(messages.added), value: summary.value.dependencyAdditions },
{ label: formatMessage(messages.dependencyUpdated), value: summary.value.dependencyUpdates },
{ label: formatMessage(messages.removed), value: summary.value.dependencyRemovals },
])
const sourceLoader = computed(() =>
loaderLabel(
plan.value.sourceEnvironment.modLoader,
plan.value.sourceEnvironment.modLoaderVersion,
),
)
const targetLoader = computed(() =>
confirmTargetLoaderLabel(
formatLoaderLabel(plan.value.targetEnvironment.modLoader),
plan.value.targetEnvironment.modLoader,
plan.value.targetEnvironment.modLoaderVersion,
formatMessage(messages.automatic),
),
)
const displayNames = computed(() => {
const instance = flow.instance.value
if (!instance) return { backup: null, copy: null, upgradedTarget: null, shouldAutoRename: false }
return buildUpgradeDisplayNames({
sourceName: instance.name,
sourceLoader: instance.loader,
sourceGameVersion: instance.game_version,
sourceLoaderVersion: instance.loader_version ?? null,
targetLoader: plan.value.targetEnvironment.modLoader,
targetGameVersion: plan.value.targetEnvironment.gameVersion,
targetLoaderVersion: plan.value.targetEnvironment.modLoaderVersion,
backupName: formatMessage(messages.backupInstanceName, { name: instance.name }),
customCopyName: formatMessage(messages.copyInstanceName, { name: instance.name }),
})
})
const strategyLabel = computed(
() =>
({
newest: formatMessage(messages.newest),
minimal_change: formatMessage(messages.minimal),
custom: formatMessage(messages.custom),
})[solution.value.kind],
)
const contentDataQuery = useQuery({
queryKey: computed(() => ['instance-upgrade', 'content-data', flow.instanceId.value]),
queryFn: () => loadInstanceContentData(flow.instanceId.value),
staleTime: Number.POSITIVE_INFINITY,
})
const snapshotByContentId = computed(() => {
const entries = (contentDataQuery.data.value?.snapshot.items ?? []).flatMap((item) =>
contentIdentityKeys({
instanceEntryId: item.entryId,
instanceMemberId: item.memberId,
instanceFileId: item.fileId,
relativePath: item.expectedRelativePath,
}).map((key) => [key, item] as const),
)
return new Map<string, InstanceContentSnapshotItem>(entries)
})
const contentByContentId = computed(() => {
const data = contentDataQuery.data.value as InstanceContentData | null | undefined
return new Map(
[...(data?.contentItems ?? []), ...(data?.linkedContentItems ?? [])].flatMap((item) =>
contentIdentityKeys(item).map((key) => [key, item] as const),
),
)
})
const itemByContentId = computed(
() => new Map(plan.value.items.map((item) => [item.contentId, item])),
)
const itemByProviderProject = computed(
() =>
new Map(
plan.value.items.flatMap((item) =>
item.provider && item.projectId
? [[`${item.provider}:${item.projectId}`, item] as const]
: [],
),
),
)
const releaseIdentities = computed(() => {
const identities: UpgradeReleaseIdentity[] = []
const add = (
provider: ContentProvider | null,
projectId: string | null,
releaseId: string | null,
) => {
if ((provider === 'modrinth' || provider === 'curseforge') && projectId && releaseId) {
identities.push({ provider, projectId, releaseId })
}
}
for (const selection of solution.value.selections) {
add(selection.provider, selection.projectId, selection.currentReleaseId)
add(selection.provider, selection.projectId, selection.targetReleaseId)
}
for (const change of solution.value.dependencyChanges) {
add(change.provider, change.projectId, change.currentReleaseId)
add(change.provider, change.projectId, change.targetReleaseId)
}
return identities
})
const versionMetadataQuery = useQuery({
queryKey: computed(() => [
'instance-upgrade',
'version-display',
...releaseIdentities.value.map(
(identity) => `${identity.provider}:${identity.projectId}:${identity.releaseId}`,
),
]),
queryFn: () => loadUpgradeVersionDisplayMetadata(releaseIdentities.value),
staleTime: Number.POSITIVE_INFINITY,
})
const projectIdentities = computed(() => {
const identities: UpgradeProjectIdentity[] = []
const add = (provider: ContentProvider | null, projectId: string | null) => {
if ((provider === 'modrinth' || provider === 'curseforge') && projectId) {
identities.push({ provider, projectId })
}
}
for (const selection of solution.value.selections) add(selection.provider, selection.projectId)
for (const change of solution.value.dependencyChanges) add(change.provider, change.projectId)
return identities
})
const projectMetadataQuery = useQuery({
queryKey: computed(() => [
'instance-upgrade',
'project-display',
...projectIdentities.value.map((identity) => `${identity.provider}:${identity.projectId}`),
]),
queryFn: () => loadUpgradeProjectDisplayMetadata(projectIdentities.value),
staleTime: Number.POSITIVE_INFINITY,
})
interface ConfirmDetailRow {
key: string
title: string
icon: string
provider: ContentProvider | null
providerLabel: string
projectId: string | null
projectPath: string | null
currentReleaseId: string | null
currentLabel: string | null
targetReleaseId: string | null
targetLabel: string | null
stateLabel: string | null
}
const groupedChanges = computed(() => confirmSolutionGroups(solution.value))
const detailGroups = computed(() => [
{
label: formatMessage(messages.updatedContent),
items: groupedChanges.value.updated.map(selectionDetail),
},
{
label: formatMessage(messages.keptContent),
items: groupedChanges.value.kept.map(selectionDetail),
},
{
label: formatMessage(messages.disabledContent),
items: groupedChanges.value.disabled.map(selectionDetail),
},
{
label: formatMessage(messages.dependencyChanges),
items: groupedChanges.value.dependencyChanges.map(dependencyDetail),
},
])
function loaderLabel(
loader: typeof plan.value.sourceEnvironment.modLoader,
version: string | null,
) {
const label = formatLoaderLabel(loader)
return version ? `${label} ${version}` : label
}
function contentMetadata(item: InstanceUpgradePlanItem) {
return upgradeContentDisplayMetadata(
item,
contentByContentId.value.get(item.contentId) ??
contentByContentId.value.get(normalizeUpgradePath(item.relativePath)),
snapshotByContentId.value.get(item.contentId) ??
snapshotByContentId.value.get(normalizeUpgradePath(item.relativePath)),
)
}
function providerLabel(provider: ContentProvider | null): string {
if (provider === 'modrinth') return formatMessage(messages.providerModrinth)
if (provider === 'curseforge') return formatMessage(messages.providerCurseForge)
if (provider === 'local') return formatMessage(messages.providerLocal)
return formatMessage(messages.providerUnknown)
}
function releaseLabel(
provider: ContentProvider | null,
projectId: string | null,
releaseId: string | null,
fallback?: string | null,
): string | null {
if (!releaseId) return fallback ?? null
if (!provider || !projectId) return fallback ?? releaseId
const resolved = upgradeVersionDisplayLabel(versionMetadataQuery.data.value, {
provider,
projectId,
releaseId,
})
return resolved === releaseId && fallback ? fallback : resolved
}
function projectMetadata(provider: ContentProvider | null, projectId: string | null) {
if (!provider || !projectId) return null
return (
projectMetadataQuery.data.value?.get(upgradeProjectDisplayCacheKey(provider, projectId)) ?? null
)
}
function selectionDetail(selection: InstanceUpgradeSelection): ConfirmDetailRow {
const item = itemByContentId.value.get(selection.contentId)
const metadata = item ? contentMetadata(item) : null
const providerMetadata = projectMetadata(selection.provider, selection.projectId)
const releases = confirmSelectionReleaseSlots(selection)
const current = releaseLabel(
selection.provider,
selection.projectId,
releases.currentReleaseId,
metadata?.currentVersion,
)
const target = releaseLabel(selection.provider, selection.projectId, releases.targetReleaseId)
return {
key: selection.contentId,
title: metadata?.title ?? providerMetadata?.title ?? formatMessage(messages.contentFallback),
icon: localContentIconUrl(metadata?.iconUrl ?? providerMetadata?.iconUrl),
provider: selection.provider,
providerLabel: providerLabel(selection.provider),
projectId: selection.projectId,
projectPath: upgradeProjectPath(selection.provider, selection.projectId),
currentReleaseId: releases.currentReleaseId,
currentLabel: current ? formatMessage(messages.versionCurrent, { version: current }) : null,
targetReleaseId: releases.targetReleaseId,
targetLabel: target ? formatMessage(messages.versionTarget, { version: target }) : null,
stateLabel: selection.action === 'disable' ? formatMessage(messages.disabledState) : null,
}
}
function dependencyDetail(change: InstanceUpgradeDependencyChange): ConfirmDetailRow {
const item =
(change.existingContentId ? itemByContentId.value.get(change.existingContentId) : null) ??
itemByProviderProject.value.get(`${change.provider}:${change.projectId}`)
const metadata = item ? contentMetadata(item) : null
const providerMetadata = projectMetadata(change.provider, change.projectId)
const releases = resolveConfirmDependencyReleases(change, (releaseId, slot) =>
releaseLabel(
change.provider,
change.projectId,
releaseId,
slot === 'current' ? metadata?.currentVersion : undefined,
),
)
return {
key: `${change.provider}:${change.projectId}:${change.existingContentId ?? 'new'}:${change.kind}`,
title:
metadata?.title ??
providerMetadata?.title ??
formatMessage(messages.dependencyFallback, { provider: providerLabel(change.provider) }),
icon: localContentIconUrl(metadata?.iconUrl ?? providerMetadata?.iconUrl),
provider: change.provider,
providerLabel: providerLabel(change.provider),
projectId: change.projectId,
projectPath: upgradeProjectPath(change.provider, change.projectId),
currentReleaseId: releases.currentReleaseId,
currentLabel: releases.current
? formatMessage(messages.versionCurrent, { version: releases.current })
: null,
targetReleaseId: releases.targetReleaseId,
targetLabel: releases.target
? formatMessage(messages.versionTarget, { version: releases.target })
: null,
stateLabel: null,
}
}
function parkProjectReturn() {
parkUpgradeFlow({
instanceId: flow.instanceId.value,
returnFullPath: router.currentRoute.value.fullPath,
targetEnvironment: flow.targetEnvironment.value,
plan: flow.plan.value,
createFullBackup: flow.createFullBackup.value,
directFullBackupPreference: flow.directFullBackupPreference.value,
sharedUpgradeMode: flow.sharedUpgradeMode.value,
activeJobId: flow.activeJobId.value,
result: flow.result.value,
initialBlockingPlanId: flow.initialBlockingPlanId.value,
initialBlockingIssues: flow.initialBlockingIssues.value,
customizeActiveStrategy: flow.customizeActiveStrategy.value,
})
}
function selectMode(mode: SharedUpgradeMode) {
const previousMode = flow.sharedUpgradeMode.value
flow.sharedUpgradeMode.value = mode
if (mode === 'copy_and_upgrade') {
if (previousMode !== 'copy_and_upgrade') {
flow.directFullBackupPreference.value = flow.createFullBackup.value
}
flow.createFullBackup.value = false
} else {
flow.createFullBackup.value = flow.directFullBackupPreference.value
}
}
function rememberBackupPreference(value: boolean) {
flow.directFullBackupPreference.value = value
}
function modeClass(mode: SharedUpgradeMode) {
return flow.sharedUpgradeMode.value === mode
? 'border-brand bg-surface-2 ring-1 ring-brand'
: 'border-surface-4 bg-surface-2 hover:bg-surface-3'
}
function errorMessage(error: unknown): string {
if (error instanceof Error) return error.message
if (typeof error === 'string') return error
if (typeof error === 'object' && error && 'message' in error) return String(error.message)
return String(error)
}
async function startUpgrade() {
if (!canStartUpgrade.value || !flow.plan.value || !effectiveMode.value) return
flow.busy.value = true
executionError.value = null
try {
const submitted = await submitInstanceUpgrade(
{
instanceId: flow.instanceId.value,
planId: flow.plan.value.id,
createFullBackup: confirmOptions.value.createFullBackup,
sharedUpgradeMode: effectiveMode.value,
displayNames: displayNames.value,
},
submissionLock,
)
if (!submitted) return
await router.replace(attachUpgradeJobToFlow(flow, submitted.job))
} catch (error) {
executionError.value = errorMessage(error)
} finally {
flow.busy.value = false
}
}
function registerControls() {
flow.registerStepControls({
canNext: canStartUpgrade,
busy: submissionBusy,
nextLabel: formatMessage(submissionBusy.value ? messages.starting : messages.start),
onNext: startUpgrade,
onBack: () =>
router.push(`/instance/${encodeURIComponent(flow.instanceId.value)}/upgrade/customize`),
})
}
onMounted(() => {
if (!sharedInstance.value) {
flow.sharedUpgradeMode.value = 'direct'
flow.createFullBackup.value = confirmOptions.value.createFullBackup
}
registerControls()
})
watch([canStartUpgrade, submissionBusy], registerControls)
onBeforeUnmount(() => flow.registerStepControls(null))
</script>