feat: complete dockerization (optional)
This commit is contained in:
1 parent
3e01c29657
commit
d275ba3c14
38 files changed
+1244
-20
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface UpdateStatus {
|
||||
error: UpdateErrorKind | null
|
||||
started_at: string | null
|
||||
finished_at: string | null
|
||||
container: boolean
|
||||
}
|
||||
|
||||
export function useUpdate() {
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -37,6 +37,7 @@ export function upToDate(overrides: Partial<UpdateStatus> = {}): UpdateStatus {
|
||||
error: null,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
container: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user