Files
RenewCMS/web/src/views/SettingsView.vue
T

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>