486 lines
12 KiB
Vue
486 lines
12 KiB
Vue
<script setup lang="ts">
|
|
import { t } from '@/i18n'
|
|
|
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
|
|
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
|
import DashboardShell from '@/components/DashboardShell.vue'
|
|
import MediaLinksCard from '@/components/MediaLinksCard.vue'
|
|
import PublishingLinksCard from '@/components/PublishingLinksCard.vue'
|
|
import MediaCleanupCard from '@/components/MediaCleanupCard.vue'
|
|
import RestartOverlay from '@/components/RestartOverlay.vue'
|
|
import RetentionCard from '@/components/RetentionCard.vue'
|
|
import UpdatesCard from '@/components/UpdatesCard.vue'
|
|
import { ApiError } from '@/composables/useApi'
|
|
import { useRetryAfter } from '@/composables/useRetryAfter'
|
|
import { useSettings, type WorkspaceSettings } from '@/composables/useSettings'
|
|
import type { RestartKind, UpdateStatus } from '@/composables/useUpdate'
|
|
import { reloadPage } from '@/lib/reloadPage'
|
|
import { displayVersion, isCheckDue } from '@/lib/updateFormat'
|
|
import { useSessionStore } from '@/stores/session'
|
|
import { usePermissionsStore } from '@/stores/permissions'
|
|
import { useUpdateStore, type RestartOutcome, type RestartRequestResult } from '@/stores/update'
|
|
|
|
const SKELETON_DELAY_MS = 150
|
|
const POLL_INTERVAL_MS = 1000
|
|
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
|
|
const OFFLINE_COPY =
|
|
"You're offline. Changing automatic purge and purging duplicates need a connection."
|
|
const RESTART_FAILED_COPY =
|
|
"Couldn't start the restart. Nothing was changed and your site is still running. Try again in a moment."
|
|
const RESTART_WARNING =
|
|
"Your site will be unavailable for about 10 seconds. This page reloads on its own when the site is back. Save anything you're editing first."
|
|
const PLAIN_RESTART_DIALOG = {
|
|
get title() {
|
|
return t('Restart the site?')
|
|
},
|
|
get description() {
|
|
return t(RESTART_WARNING)
|
|
},
|
|
get confirmLabel() {
|
|
return t('Restart site')
|
|
},
|
|
get cancelLabel() {
|
|
return t('Cancel')
|
|
},
|
|
}
|
|
|
|
type LoadState = 'loading' | 'ready' | 'error'
|
|
|
|
const { getSettings } = useSettings()
|
|
const toast = useLocalizedToast()
|
|
const overlay = useLocalizedOverlay()
|
|
const session = useSessionStore()
|
|
const permissions = usePermissionsStore()
|
|
const update = useUpdateStore()
|
|
const retry = useRetryAfter()
|
|
|
|
const updatesCard = ref<InstanceType<typeof UpdatesCard> | null>(null)
|
|
const loadState = ref<LoadState>('loading')
|
|
const settings = ref<WorkspaceSettings | null>(null)
|
|
const version = computed(() => settings.value?.version ?? '')
|
|
const canApply = computed(() => update.canApply)
|
|
const canManageSites = computed(() => permissions.can('settings.manage_sites'))
|
|
const showSkeleton = ref(false)
|
|
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
function clearSkeletonTimer() {
|
|
if (skeletonTimer !== null) {
|
|
clearTimeout(skeletonTimer)
|
|
skeletonTimer = null
|
|
}
|
|
}
|
|
|
|
function isHandledGlobally(error: unknown) {
|
|
return error instanceof ApiError && (error.status === 401 || error.status === 403)
|
|
}
|
|
|
|
async function loadSettings() {
|
|
loadState.value = 'loading'
|
|
showSkeleton.value = false
|
|
clearSkeletonTimer()
|
|
skeletonTimer = setTimeout(() => {
|
|
showSkeleton.value = true
|
|
}, SKELETON_DELAY_MS)
|
|
|
|
try {
|
|
settings.value = await getSettings()
|
|
loadState.value = 'ready'
|
|
} catch (error) {
|
|
if (!isHandledGlobally(error)) {
|
|
loadState.value = 'error'
|
|
}
|
|
} finally {
|
|
clearSkeletonTimer()
|
|
showSkeleton.value = false
|
|
}
|
|
}
|
|
|
|
function neverCheckedStatus(): UpdateStatus {
|
|
return {
|
|
current: version.value,
|
|
latest: '',
|
|
available: false,
|
|
release_url: null,
|
|
notes: null,
|
|
checked_at: null,
|
|
can_apply: false,
|
|
state: 'idle',
|
|
progress: null,
|
|
error: null,
|
|
started_at: null,
|
|
finished_at: null,
|
|
container: false,
|
|
}
|
|
}
|
|
|
|
const updatesStatus = computed(() => {
|
|
if (update.status) {
|
|
return update.status
|
|
}
|
|
return update.loadState === 'error' && update.canApply ? neverCheckedStatus() : null
|
|
})
|
|
|
|
const updatesPhase = computed(() => (update.status ? update.phase : 'failed'))
|
|
|
|
async function reloadAfterRoleChange() {
|
|
await session.refresh()
|
|
await update.load()
|
|
}
|
|
|
|
function notifyOnlyOwner() {
|
|
toast.add(() => ({
|
|
color: 'error',
|
|
icon: 'i-lucide-circle-x',
|
|
get title() {
|
|
return t('Only the owner can do this')
|
|
},
|
|
get description() {
|
|
return t('Ask the owner of this site to update RenewCMS.')
|
|
},
|
|
}))
|
|
}
|
|
|
|
async function onUpdate() {
|
|
const result = await update.startDownload()
|
|
if (result === 'limited') {
|
|
return
|
|
}
|
|
if (result === 'forbidden') {
|
|
notifyOnlyOwner()
|
|
await reloadAfterRoleChange()
|
|
return
|
|
}
|
|
if (result === 'nothing') {
|
|
toast.add(() => ({
|
|
color: 'info',
|
|
icon: 'i-lucide-info',
|
|
get title() {
|
|
return t('Nothing to update')
|
|
},
|
|
get description() {
|
|
return t('You already have the latest version.')
|
|
},
|
|
}))
|
|
await update.load()
|
|
return
|
|
}
|
|
if (result === 'conflict') {
|
|
await update.load()
|
|
if (update.jobRunning) {
|
|
toast.add(() => ({
|
|
color: 'info',
|
|
icon: 'i-lucide-info',
|
|
get title() {
|
|
return t('An update is already running')
|
|
},
|
|
get description() {
|
|
return t('Another owner started it. Progress shows here.')
|
|
},
|
|
}))
|
|
}
|
|
}
|
|
}
|
|
|
|
async function onCheck() {
|
|
const result = await update.check()
|
|
if (result !== 'forbidden') {
|
|
return
|
|
}
|
|
notifyOnlyOwner()
|
|
await reloadAfterRoleChange()
|
|
}
|
|
|
|
function installRestartDialog() {
|
|
const version = displayVersion(update.status?.latest ?? '')
|
|
return {
|
|
get title() {
|
|
return t('Restart to finish the update?')
|
|
},
|
|
get description() {
|
|
return t('RenewCMS will install version {0} and restart. {1}', [
|
|
version,
|
|
t(RESTART_WARNING),
|
|
])
|
|
},
|
|
get confirmLabel() {
|
|
return t('Restart now')
|
|
},
|
|
get cancelLabel() {
|
|
return t('Restart later')
|
|
},
|
|
}
|
|
}
|
|
|
|
async function onRestart(kind: RestartKind) {
|
|
const attempt = { result: 'failed' as RestartRequestResult }
|
|
const dialog = kind === 'update' ? installRestartDialog() : PLAIN_RESTART_DIALOG
|
|
const confirmed = await overlay
|
|
.create(ConfirmDialog, {
|
|
props: {
|
|
...dialog,
|
|
get pendingLabel() {
|
|
return t('Restarting…')
|
|
},
|
|
tone: 'neutral',
|
|
action: async () => {
|
|
attempt.result = await update.requestRestart(kind)
|
|
if (attempt.result === 'failed') {
|
|
throw new Error(t(RESTART_FAILED_COPY))
|
|
}
|
|
},
|
|
},
|
|
})
|
|
.open()
|
|
|
|
if (attempt.result === 'started') {
|
|
return
|
|
}
|
|
if (!confirmed) {
|
|
await nextTick()
|
|
updatesCard.value?.focusRestart()
|
|
return
|
|
}
|
|
await settleRefusedRestart(attempt.result)
|
|
}
|
|
|
|
async function settleRefusedRestart(result: RestartRequestResult) {
|
|
if (result === 'forbidden') {
|
|
notifyOnlyOwner()
|
|
await reloadAfterRoleChange()
|
|
return
|
|
}
|
|
if (result !== 'conflict') {
|
|
return
|
|
}
|
|
await update.load()
|
|
if (update.jobRunning || update.restarting) {
|
|
toast.add(() => ({
|
|
color: 'info',
|
|
icon: 'i-lucide-info',
|
|
get title() {
|
|
return t('An update is already running')
|
|
},
|
|
get description() {
|
|
return t('Another owner started it. Progress shows here.')
|
|
},
|
|
}))
|
|
}
|
|
update.resumeRestart()
|
|
}
|
|
|
|
const OUTCOME_TOASTS = {
|
|
updated: () => ({
|
|
color: 'success' as const,
|
|
icon: 'i-lucide-circle-check',
|
|
get title() {
|
|
return t('Update installed')
|
|
},
|
|
get description() {
|
|
return t('RenewCMS is now on version {0}.', [
|
|
displayVersion(update.status?.current ?? ''),
|
|
])
|
|
},
|
|
}),
|
|
'rolled-back': () => ({
|
|
color: 'error' as const,
|
|
icon: 'i-lucide-circle-x',
|
|
get title() {
|
|
return t("The update didn't install")
|
|
},
|
|
get description() {
|
|
return t('Your site is back on {0}.', [update.status?.current ?? ''])
|
|
},
|
|
duration: 0,
|
|
}),
|
|
restarted: () => ({
|
|
color: 'success' as const,
|
|
icon: 'i-lucide-circle-check',
|
|
get title() {
|
|
return t('Site restarted')
|
|
},
|
|
get description() {
|
|
return t('Your site is running again.')
|
|
},
|
|
}),
|
|
}
|
|
|
|
async function announceRestartOutcome(outcome: RestartOutcome) {
|
|
toast.add(() => OUTCOME_TOASTS[outcome]())
|
|
await nextTick()
|
|
updatesCard.value?.focusHeading()
|
|
}
|
|
|
|
const updateRequested = ref(false)
|
|
let autoCheckHandled = false
|
|
|
|
watch(
|
|
() => [session.status, updateRequested.value, update.loadState] as const,
|
|
([sessionStatus, requested, loadStateOfUpdate]) => {
|
|
if (
|
|
autoCheckHandled ||
|
|
!requested ||
|
|
sessionStatus !== 'ready' ||
|
|
loadStateOfUpdate !== 'ready'
|
|
) {
|
|
return
|
|
}
|
|
autoCheckHandled = true
|
|
const jobActive = update.jobRunning || update.readyToInstall
|
|
if (update.canApply && !jobActive && isCheckDue(update.status?.checked_at ?? null)) {
|
|
onCheck()
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
watch(
|
|
() => [update.phase, update.applyPhase] as const,
|
|
([phase, applyPhase]) => {
|
|
if (phase === 'limited' || applyPhase === 'limited') {
|
|
retry.start(update.retryAfter)
|
|
}
|
|
},
|
|
)
|
|
|
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
|
|
|
function stopPolling() {
|
|
if (pollTimer !== null) {
|
|
clearInterval(pollTimer)
|
|
pollTimer = null
|
|
}
|
|
}
|
|
|
|
function startPolling() {
|
|
if (pollTimer === null) {
|
|
pollTimer = setInterval(update.poll, POLL_INTERVAL_MS)
|
|
}
|
|
}
|
|
|
|
watch(
|
|
() => update.jobRunning,
|
|
(running) => (running ? startPolling() : stopPolling()),
|
|
{ immediate: true },
|
|
)
|
|
|
|
onMounted(async () => {
|
|
loadSettings()
|
|
await update.load()
|
|
update.resumeRestart()
|
|
const outcome = update.settleRememberedRestart()
|
|
if (outcome) {
|
|
await announceRestartOutcome(outcome)
|
|
}
|
|
updateRequested.value = true
|
|
})
|
|
onBeforeUnmount(() => {
|
|
clearSkeletonTimer()
|
|
stopPolling()
|
|
update.stopRestart()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<DashboardShell
|
|
compact-padding
|
|
bleed-offline-banner
|
|
:offline-message="t(OFFLINE_COPY)"
|
|
offline-icon="i-lucide-wifi-off"
|
|
>
|
|
<RestartOverlay
|
|
v-if="update.restartRun"
|
|
:kind="update.restartRun.kind"
|
|
:version="update.restartRun.version"
|
|
:started-at="update.restartRun.startedAt"
|
|
:slow="update.restartSlow"
|
|
:now="update.restartNow"
|
|
@reload="reloadPage"
|
|
/>
|
|
<div class="flex max-w-164 flex-col gap-4" :inert="update.restartRun !== null">
|
|
<div class="flex flex-col gap-0.5">
|
|
<h1 class="text-xl/7 font-semibold text-highlighted">
|
|
{{ t('Settings') }}
|
|
</h1>
|
|
<p class="text-muted">{{ t('Workspace information.') }}</p>
|
|
</div>
|
|
|
|
<div
|
|
data-testid="settings-card"
|
|
:aria-busy="loadState === 'loading' && showSkeleton ? 'true' : undefined"
|
|
class="border border-default bg-default p-4 lg:p-5"
|
|
>
|
|
<UAlert
|
|
v-if="loadState === 'error'"
|
|
role="alert"
|
|
color="error"
|
|
variant="soft"
|
|
orientation="vertical"
|
|
:close="false"
|
|
icon="i-lucide-circle-x"
|
|
:description="t(UNREACHABLE_COPY)"
|
|
:ui="{ actions: 'mt-2' }"
|
|
>
|
|
<template #actions>
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
icon="i-lucide-rotate-cw"
|
|
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-8"
|
|
:ui="{ leadingIcon: 'size-3.5' }"
|
|
@click="loadSettings"
|
|
>
|
|
{{ t('Try again') }}
|
|
</UButton>
|
|
</template>
|
|
</UAlert>
|
|
|
|
<div v-else class="flex items-center justify-between gap-4">
|
|
<span class="text-[0.9375rem]/6 font-semibold text-highlighted">{{
|
|
t('Version')
|
|
}}</span>
|
|
<USkeleton
|
|
v-if="loadState === 'loading' && showSkeleton"
|
|
data-testid="settings-skeleton"
|
|
class="h-5 w-24 rounded-none bg-accented"
|
|
/>
|
|
<span
|
|
v-else-if="loadState === 'ready'"
|
|
data-testid="settings-version"
|
|
class="font-mono text-base text-toned"
|
|
>{{ version }}</span
|
|
>
|
|
</div>
|
|
</div>
|
|
|
|
<UpdatesCard
|
|
v-if="updatesStatus"
|
|
ref="updatesCard"
|
|
:status="updatesStatus"
|
|
:can-apply="canApply"
|
|
:phase="updatesPhase"
|
|
:apply-phase="update.applyPhase"
|
|
:retry-label="retry.formatted.value"
|
|
:retry-active="retry.active.value"
|
|
@check="onCheck"
|
|
@update="onUpdate"
|
|
@restart="onRestart"
|
|
/>
|
|
|
|
<MediaLinksCard />
|
|
|
|
<PublishingLinksCard v-if="canManageSites" />
|
|
|
|
<MediaCleanupCard />
|
|
|
|
<RetentionCard
|
|
:settings="settings"
|
|
:load-state="loadState"
|
|
:show-skeleton="showSkeleton"
|
|
@saved="settings = $event"
|
|
@reload="loadSettings"
|
|
/>
|
|
</div>
|
|
</DashboardShell>
|
|
</template>
|