feat: complete dockerization (optional)

This commit is contained in:
fsylvain committed 2026-10-05 04:52:49 +02:00
1 parent 3e01c29657
commit d275ba3c14
38 files changed
+1244 -20

No files matched your search

+8 -4
View File
@@ -4,9 +4,13 @@ import { t } from '@/i18n'
import { computed } from 'vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
const props = defineProps<{
line: string
}>()
const props = withDefaults(
defineProps<{
line: string
showNote?: boolean
}>(),
{ showNote: true },
)
const emit = defineEmits<{
copy: []
@@ -50,7 +54,7 @@ async function copyLine() {
{{ buttonLabel }}
</UButton>
</div>
<span class="text-xs/5 text-muted">{{
<span v-if="showNote" class="text-xs/5 text-muted">{{
t('They run this once on the server. After that, Update and Restart appear here.')
}}</span>
</div>
+44 -1
View File
@@ -10,9 +10,11 @@ import type { RestartKind, UpdateStatus } from '@/composables/useUpdate'
import type { UserRole } from '@/composables/useApi'
import type { UpdateApplyPhase, UpdateCheckPhase } from '@/stores/update'
import {
CONTAINER_UPDATE_COMMAND,
ENABLE_BROWSER_UPDATES_COMMAND,
VERIFYING_ANNOUNCEMENT,
browserUpdatesDocsUrl,
containerUpdatesDocsUrl,
displayVersion,
downloadAnnouncement,
formatDownloadProgress,
@@ -383,6 +385,21 @@ const block = computed<Block>(() => {
meta: props.status.finished_at ? formatUpdatedAt(props.status.finished_at) : undefined,
}
}
if (showContainerGuidance.value) {
return {
tone: 'info',
icon: 'i-lucide-info',
get title() {
return t('Version {0} is available', [newVersion.value])
},
get text() {
return t(
'You have {0}. This site runs in Docker, so it updates by pulling a new image. Run the commands below on the server.',
[props.status.current],
)
},
}
}
if (props.status.available && isOwner.value) {
return {
tone: 'info',
@@ -435,9 +452,15 @@ const restartAvailable = computed(
() => canApplyView.value && (props.status.state === 'idle' || isDone.value),
)
const showContainerGuidance = computed(
() =>
isOwner.value && props.status.container && props.status.available && props.phase === 'idle',
)
const showServerGuidance = computed(
() =>
isOwner.value &&
!props.status.container &&
!props.status.can_apply &&
props.status.available &&
props.phase === 'idle',
@@ -550,7 +573,10 @@ function onAction(button: ActionButton) {
const showNotes = computed(
() =>
showServerGuidance.value || applyView.value === 'available' || applyView.value === 'ready',
showServerGuidance.value ||
showContainerGuidance.value ||
applyView.value === 'available' ||
applyView.value === 'ready',
)
const notes = computed(() => {
@@ -625,6 +651,12 @@ const notes = computed(() => {
<ServerCommand v-if="showServerGuidance" :line="ENABLE_BROWSER_UPDATES_COMMAND" />
<ServerCommand
v-if="showContainerGuidance"
:line="CONTAINER_UPDATE_COMMAND"
:show-note="false"
/>
<div v-if="rowButtons.length" class="flex flex-col gap-2 md:flex-row md:items-center">
<UButton
v-for="button in rowButtons"
@@ -660,6 +692,17 @@ const notes = computed(() => {
<UIcon name="i-lucide-arrow-up-right" class="size-3.5" />
</a>
<a
v-if="showContainerGuidance && status.release_url"
:href="containerUpdatesDocsUrl(status.release_url)"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 self-start text-sm font-medium text-highlighted underline underline-offset-3"
>
{{ t('How to update') }}
<UIcon name="i-lucide-arrow-up-right" class="size-3.5" />
</a>
<a
v-if="isDone && status.release_url"
:href="status.release_url"
+1
View File
@@ -38,6 +38,7 @@ export interface UpdateStatus {
error: UpdateErrorKind | null
started_at: string | null
finished_at: string | null
container: boolean
}
export function useUpdate() {
+2
View File
@@ -629,6 +629,8 @@ export const messages = {
"This site can't update itself from the browser yet. Whoever manages your server can set it up once, or update RenewCMS for you.":
'Ce site ne peut pas encore se mettre à jour depuis le navigateur. La personne qui gère votre serveur peut l’activer ou mettre à jour RenewCMS pour vous.',
'How to update': 'Comment mettre à jour',
'You have {0}. This site runs in Docker, so it updates by pulling a new image. Run the commands below on the server.':
'Vous avez {0}. Ce site fonctionne dans Docker : il se met à jour en récupérant une nouvelle image. Exécutez les commandes ci-dessous sur le serveur.',
'For whoever manages the server': 'Pour la personne qui gère le serveur',
'They run this once on the server. After that, Update and Restart appear here.':
'Cette commande s’exécute une fois sur le serveur. Les actions de mise à jour et de redémarrage apparaissent ensuite ici.',
+11 -1
View File
@@ -72,13 +72,23 @@ export function downloadAnnouncement(version: string, percent: number) {
export const VERIFYING_ANNOUNCEMENT = 'Checking the download'
const BROWSER_UPDATES_DOCS_PATH = '/src/branch/main/docs/deployment.md#browser-updates'
const CONTAINER_UPDATES_DOCS_PATH = '/src/branch/main/docs/docker.md'
export const CONTAINER_UPDATE_COMMAND = 'docker compose pull && docker compose up -d'
const RELEASES_SEGMENT = /^(.*)\/releases\//
export function browserUpdatesDocsUrl(releaseUrl: string) {
return docsUrl(releaseUrl, BROWSER_UPDATES_DOCS_PATH)
}
export function containerUpdatesDocsUrl(releaseUrl: string) {
return docsUrl(releaseUrl, CONTAINER_UPDATES_DOCS_PATH)
}
function docsUrl(releaseUrl: string, docsPath: string) {
try {
const url = new URL(releaseUrl)
const repository = RELEASES_SEGMENT.exec(url.pathname)
return repository ? `${url.origin}${repository[1]}${BROWSER_UPDATES_DOCS_PATH}` : releaseUrl
return repository ? `${url.origin}${repository[1]}${docsPath}` : releaseUrl
} catch {
return releaseUrl
}
+1
View File
@@ -100,6 +100,7 @@ function neverCheckedStatus(): UpdateStatus {
error: null,
started_at: null,
finished_at: null,
container: false,
}
}
@@ -0,0 +1,150 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { useApi, type UserRole } from '@/composables/useApi'
import type { UpdateStatus } from '@/composables/useUpdate'
import { useSessionStore } from '@/stores/session'
import SettingsView from '../SettingsView.vue'
import {
RUNNING_VERSION,
STATUS_PATH,
findButton,
newerAvailable,
stubClipboard,
upToDate,
visibleButtonLabels,
} from './updateTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return {
...actual,
useApi: vi.fn(),
}
})
const CONTAINER_COMMAND = 'docker compose pull && docker compose up -d'
let wrapper: VueWrapper | null = null
async function mountSettings(role: UserRole, status: UpdateStatus) {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta', id: 1, role }
session.status = 'ready'
const request = vi.fn((path: string) => {
if (path === '/v1/settings/') {
return Promise.resolve({ version: RUNNING_VERSION })
}
if (path === STATUS_PATH) {
return Promise.resolve(status)
}
throw new Error(`Unexpected request: ${path}`)
})
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({
verified: true,
username: 'marta',
id: 1,
displayName: '',
role,
}),
request,
})
await router.push('/settings')
wrapper = mount(SettingsView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
}
function updatesCard() {
return document.body.querySelector('[data-testid="updates-card"]')!
}
function cardText() {
return updatesCard().textContent ?? ''
}
afterEach(async () => {
wrapper?.unmount()
wrapper = null
vi.useRealTimers()
await router.push('/login')
document.body.innerHTML = ''
})
describe('Settings, owner, container, update available', () => {
it('shows the pull-image guidance, the command, Copy and the docs link, with no other button', async () => {
await mountSettings('owner', newerAvailable({ container: true }))
expect(cardText()).toContain('Version 2.3.0-beta is available')
expect(cardText()).toContain(
'You have v2.2.0-beta. This site runs in Docker, so it updates by pulling a new image. Run the commands below on the server.',
)
expect(updatesCard().querySelector('code')?.textContent).toBe(CONTAINER_COMMAND)
const link = Array.from(updatesCard().querySelectorAll('a')).find(
(anchor) => anchor.textContent?.trim() === 'How to update',
)!
expect(link.getAttribute('href')).toBe(
'https://git.example/fsylvain/RenewCMS/src/branch/main/docs/docker.md',
)
expect(visibleButtonLabels(updatesCard())).toEqual(["What's new in 2.3.0-beta", 'Copy'])
expect(cardText()).not.toContain('renewcms enable-browser-updates')
expect(cardText()).not.toContain('They run this once on the server')
expect(cardText()).not.toContain("can't update itself from the browser")
})
it('copies the container command and reads Copied for 2 s', async () => {
const writeText = stubClipboard()
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
await mountSettings('owner', newerAvailable({ container: true }))
findButton('Copy')!.click()
await flushPromises()
expect(writeText).toHaveBeenCalledWith(CONTAINER_COMMAND)
expect(findButton('Copied')!.getAttribute('aria-live')).toBe('polite')
await vi.advanceTimersByTimeAsync(2000)
expect(findButton('Copy')?.textContent?.trim()).toBe('Copy')
expect(findButton('Copied')).toBeUndefined()
})
})
describe('Settings, owner, container, up to date', () => {
it("shows You're up to date with only Check for updates and no container hint", async () => {
await mountSettings('owner', upToDate({ container: true }))
expect(cardText()).toContain("You're up to date")
expect(visibleButtonLabels(updatesCard())).toEqual(['Check for updates'])
expect(cardText()).not.toContain('Docker')
expect(updatesCard().querySelector('code')).toBeNull()
})
})
describe('Settings, admin, container', () => {
it('keeps the admin view with no command block', async () => {
await mountSettings('admin', newerAvailable({ container: true }))
expect(cardText()).toContain('Only the owner can update RenewCMS.')
expect(cardText()).not.toContain('Docker')
expect(updatesCard().querySelector('code')).toBeNull()
expect(visibleButtonLabels(updatesCard())).toEqual([])
})
})
describe('Settings, owner, not container, not prepared', () => {
it('still shows the enable-browser-updates command', async () => {
await mountSettings('owner', newerAvailable({ container: false }))
expect(updatesCard().querySelector('code')?.textContent).toBe(
'renewcms enable-browser-updates',
)
expect(cardText()).toContain("This site can't update itself from the browser yet.")
expect(cardText()).not.toContain('Docker')
})
})
+1
View File
@@ -37,6 +37,7 @@ export function upToDate(overrides: Partial<UpdateStatus> = {}): UpdateStatus {
error: null,
started_at: null,
finished_at: null,
container: false,
...overrides,
}
}