[web] prevent preview crashes from replayed progress logs #188

Merged
eleith merged 1 commits from fix/preview-progress-replay into main 2026-11-03 23:39:30 +00:00
16 changed files with 293 additions and 248 deletions

No files matched your search

@@ -0,0 +1,5 @@
---
"@luzzle/web": patch
---
Start previews through an explicit POST action instead of page loading or link preloading. Merge replayed preview logs by identity and show the latest attempt of each phase so working previews do not fail with duplicate rendering keys.
@@ -1,6 +1,7 @@
<script lang="ts">
import type { Snippet } from 'svelte'
import { resolve } from '$app/paths'
import { enhance } from '$app/forms'
import Button from '#lib/components/ui/Button.svelte'
import { encodeArchivePath } from '#lib/pieces/navigation.js'
@@ -34,21 +35,23 @@
{@render children()}
</div>
<div class="status" aria-live="polite">
<span>
<div>
{#if isSaving}
Saving…
{:else if isDirty}
Unsaved
{:else}
<a
href={resolve('/(auth)/admin/piece/[...path]/preview', { path })}
aria-label="Preview saved piece"
title="Preview saved piece"
<form
method="post"
action={resolve('/(auth)/admin/piece/[...path]/preview', { path })}
use:enhance
>
Saved
</a>
<button type="submit" aria-label="Preview saved piece" title="Preview saved piece">
Saved
</button>
</form>
{/if}
</span>
</div>
<span class="publication-status">
{#if isUnpublishing}
Unpublishing…
@@ -93,8 +96,17 @@
padding-inline-start: var(--space-3);
}
.status a {
.status a,
.status button {
color: inherit;
text-decoration: underline;
}
.status button {
border: 0;
padding: 0;
background: transparent;
font: inherit;
cursor: pointer;
}
</style>
@@ -14,6 +14,7 @@ vi.mock('$app/paths', () => ({
route.replace('/(auth)', '').replace('[...path]', () => params.path)
)
}))
vi.mock('$app/forms', () => ({ enhance: vi.fn() }))
beforeEach(() => vi.clearAllMocks())
const file = 'books/源 #?%.books.md'
@@ -50,19 +51,22 @@ function buttonAttributes(output: string, label: string): string {
}
test.each([false, true])(
'saved files show Save and More, with Saved linking to preview when published=%s',
'saved files submit a preview action when published=%s',
(hasPublishedVersion) => {
const output = html({ hasPublishedVersion })
expect(buttons(output).map((button) => button.label)).toEqual(['Save'])
expect(buttons(output).map((button) => button.label)).toEqual(['Save', 'Saved'])
expect(buttonAttributes(output, 'Save')).toMatch(disabledAttribute)
expect(output).toContain(`href="${previewHref}"`)
expect(buttonAttributes(output, 'Saved')).toContain('type="submit"')
expect(output).toContain('method="post"')
expect(output).toContain(`action="${previewHref}"`)
expect(output).not.toContain(`href="${previewHref}"`)
expect(output).toContain('aria-label="Preview saved piece"')
expect(output).toContain('More fixture')
expect(output).toContain('aria-live="polite"')
}
)
test('unsaved documents have a short status instead of a Saved preview link', () => {
test('unsaved documents have a short status instead of a Saved preview action', () => {
const output = html({ isDirty: true })
expect(buttonAttributes(output, 'Save')).not.toMatch(disabledAttribute)
expect(output).toContain('Unsaved')
@@ -131,7 +135,7 @@ test('encoded file paths enter the app resolver unchanged', () => {
['/(auth)/admin/piece/[...path]/preview', { path: encodeArchivePath(file) }],
['/(auth)/admin/piece/[...path]/published', { path: encodeArchivePath(file) }]
])
expect(output).toContain(`href="${preview}"`)
expect(output).toContain(`action="${preview}"`)
expect(output).toContain(`href="${published}"`)
})
@@ -0,0 +1,106 @@
import { expect, test } from 'vitest'
import { latestPhases, mergePhaseLogs, type JobProgressLog } from './progress.js'
function phaseLog(overrides: Partial<JobProgressLog> = {}): JobProgressLog {
return {
job_id: 'job-one',
phase: 'cdn.sync',
line_number: 1,
ts: 100,
level: 'info',
message: 'Syncing',
...overrides
}
}
test('SSR seed and SSE replay retain each log identity once and append new lines in order', () => {
const first = phaseLog()
const second = phaseLog({ line_number: 2, message: 'Synced' })
const seeded = mergePhaseLogs({}, [first, { ...first }])
expect(seeded).toEqual({ 'cdn.sync': [first] })
expect(mergePhaseLogs(seeded, [second, { ...first }])).toEqual({
'cdn.sync': [first, second]
})
expect(mergePhaseLogs({}, [])).toEqual({})
})
test('repeated stream frames retain unique log keys', () => {
const frame = [phaseLog({ line_number: 3 }), phaseLog(), phaseLog({ line_number: 2 })]
const first = mergePhaseLogs({}, frame)
const replayed = mergePhaseLogs(
first,
frame.map((row) => ({ ...row }))
)
expect(replayed).toEqual(first)
expect(replayed['cdn.sync'].map((row) => row.line_number)).toEqual([1, 2, 3])
const keys = replayed['cdn.sync'].map((row) => `${row.job_id}:${row.line_number}`)
expect(new Set(keys).size).toBe(keys.length)
})
test('log identity includes both job and phase as well as line number', () => {
const first = phaseLog()
const otherJob = phaseLog({ job_id: 'job-two' })
const otherPhase = phaseLog({ phase: 'cache.purge' })
const later = phaseLog({ job_id: 'job-two', line_number: 2 })
const seeded = mergePhaseLogs({}, [later, first, otherPhase])
const merged = mergePhaseLogs(seeded, [otherJob, { ...first }, { ...otherPhase }])
expect(merged).toEqual({
'cdn.sync': [first, otherJob, later],
'cache.purge': [otherPhase]
})
})
test('identical log payloads remain distinct when line identities differ', () => {
const first = phaseLog()
const second = phaseLog({ line_number: 2 })
expect(mergePhaseLogs({}, [second, first, { ...first }])).toEqual({
'cdn.sync': [first, second]
})
})
test('incoming rows replace existing identities with the freshest supplied row', () => {
const first = phaseLog()
const next = phaseLog({ line_number: 2 })
const update = phaseLog({ ts: 200, level: 'warn', message: 'Retrying' })
const freshest = phaseLog({ ts: 300, level: 'error', message: 'Failed' })
const merged = mergePhaseLogs({ 'cdn.sync': [first, next] }, [update, freshest])
expect(merged).toEqual({ 'cdn.sync': [freshest, next] })
expect(merged['cdn.sync'][0]).toBe(freshest)
})
test('merging logs does not mutate input records, arrays, or rows', () => {
const current = {
'cdn.sync': [phaseLog({ line_number: 3 }), phaseLog()],
'cache.purge': [phaseLog({ phase: 'cache.purge' })]
}
const incoming = [phaseLog({ line_number: 2 }), phaseLog({ message: 'Updated' })]
const beforeCurrent = structuredClone(current)
const beforeIncoming = structuredClone(incoming)
for (const rows of [...Object.values(current), incoming]) {
for (const row of rows) Object.freeze(row)
Object.freeze(rows)
}
Object.freeze(current)
const merged = mergePhaseLogs(current, incoming)
expect(current).toEqual(beforeCurrent)
expect(incoming).toEqual(beforeIncoming)
expect(merged).not.toBe(current)
expect(merged['cdn.sync']).not.toBe(current['cdn.sync'])
expect(merged['cache.purge']).not.toBe(current['cache.purge'])
expect(merged['cdn.sync'].map((row) => row.line_number)).toEqual([1, 2, 3])
expect(mergePhaseLogs(current, [])).toEqual({
'cdn.sync': [current['cdn.sync'][1], current['cdn.sync'][0]],
'cache.purge': current['cache.purge']
})
})
test('retry attempts collapse to the latest state per phase without changing phase order', () => {
const first = { phase: 'assets.generate', status: 'completed' }
const failed = { phase: 'cdn.sync', status: 'failed' }
const retry = { phase: 'cdn.sync', status: 'completed' }
const cache = { phase: 'cache.purge', status: 'completed' }
const rows = [first, failed, retry, cache]
expect(latestPhases(rows)).toEqual([first, retry, cache])
expect(rows).toEqual([first, failed, retry, cache])
expect(latestPhases([])).toEqual([])
})
+37
View File
@@ -13,3 +13,40 @@ export type ProgressLog = {
level: string
message: string
}
export type JobProgressLog = ProgressLog & { job_id: string }
export function mergePhaseLogs(
current: Record<string, JobProgressLog[]>,
incoming: JobProgressLog[]
): Record<string, JobProgressLog[]> {
const phases = new Map<string, Map<string, JobProgressLog>>()
for (const phase of Object.keys(current)) {
phases.set(phase, new Map())
}
for (const rows of [...Object.values(current), incoming]) {
for (const row of rows) {
let phaseRows = phases.get(row.phase)
if (!phaseRows) {
phaseRows = new Map()
phases.set(row.phase, phaseRows)
}
const identity = JSON.stringify([row.job_id, row.line_number])
phaseRows.set(identity, row)
}
}
return Object.fromEntries(
[...phases].map(([phase, rows]) => [
phase,
[...rows.values()].sort((a, b) => a.line_number - b.line_number)
])
)
}
export function latestPhases<T extends { phase: string }>(rows: T[]): T[] {
const latestByPhase = new Map<string, T>()
for (const row of rows) {
latestByPhase.set(row.phase, row)
}
return Array.from(latestByPhase.values())
}
@@ -3,25 +3,27 @@ import { getPieces } from '#lib/server/pieces.js'
import { config } from '#lib/server/config.js'
import { getOpenWorkflow } from '#lib/server/workflow/index.js'
import { previewSpec } from '@luzzle/web.jobs/specs'
import type { PageServerLoad } from './$types'
import type { Actions, PageServerLoad } from './$types'
import { previewHref } from '#lib/pieces/navigation.js'
export const load: PageServerLoad = async ({ params }) => {
const file = params.path
const pieces = getPieces()
const { type } = pieces.parseFilename(file)
if (!type) {
return error(404, 'piece type does not exist')
function validatePreviewFile(file: string) {
const { type } = getPieces().parseFilename(file)
if (!type) error(404, 'piece type does not exist')
if (!config.pieces.some((piece) => piece.type === type)) {
error(404, 'piece type not configured')
}
const pieceConfig = config.pieces.find((p) => p.type === type)
if (!pieceConfig) {
return error(404, 'piece type not configured')
}
const openWorkflow = getOpenWorkflow()
const handle = await openWorkflow.runWorkflow(previewSpec, { filePath: file })
const runId = handle.workflowRun.id
redirect(303, previewHref(file, runId))
}
export const load: PageServerLoad = ({ params }) => {
validatePreviewFile(params.path)
return { file: params.path }
}
export const actions = {
default: async ({ params }) => {
const file = params.path
validatePreviewFile(file)
const handle = await getOpenWorkflow().runWorkflow(previewSpec, { filePath: file })
redirect(303, previewHref(file, handle.workflowRun.id))
}
} satisfies Actions
@@ -1,3 +1,17 @@
<script lang="ts">
// redirect happens in +page.server.ts
import { enhance } from '$app/forms'
import Button from '#lib/components/ui/Button.svelte'
import PieceOverview from '#lib/pieces/viewer/PieceOverview.svelte'
import type { PageProps } from './$types'
let { data }: PageProps = $props()
</script>
<main id="main-content">
<PieceOverview title="Preview" fileUrl={() => undefined}>
<p>Preview the saved contents of <code>{data.file}</code>.</p>
<form method="post" use:enhance>
<Button type="submit">Start preview</Button>
</form>
</PieceOverview>
</main>
@@ -5,6 +5,11 @@
import { resolve } from '$app/paths'
import type { LayoutProps } from './$types'
import { encodeArchivePath } from '#lib/pieces/navigation.js'
import {
mergePhaseLogs,
type ProgressPhase,
type JobProgressLog
} from '#lib/components/progress.js'
import ArrowLeftIcon from 'virtual:icons/ph/arrow-left'
import PieceOverview from '#lib/pieces/viewer/PieceOverview.svelte'
@@ -15,39 +20,14 @@
type PreviewStatus = 'enqueued' | 'running' | 'completed' | 'failed' | 'expired'
type PhaseProgress = {
phase: string
status: string
started_at: number
finished_at: number | null
message: string | null
}
type PhaseLog = {
job_id: string
phase: string
line_number: number
ts: number
level: string
message: string
}
let status = $state<PreviewStatus>(
data.status === 'waiting' ? 'enqueued' : data.status === 'expired' ? 'expired' : data.status
)
let errorMessage = $state(data.errorMessage ?? '')
let streamDisconnected = $state(false)
const initialPhases = data.phases ?? []
const initialLogs: Record<string, PhaseLog[]> = {}
if (data.logs) {
for (const log of data.logs) {
if (!initialLogs[log.phase]) initialLogs[log.phase] = []
initialLogs[log.phase].push(log)
}
}
let phases = $state<PhaseProgress[]>(initialPhases)
let logs = $state<Record<string, PhaseLog[]>>(initialLogs)
let phases = $state<ProgressPhase[]>(data.phases ?? [])
let logs = $state(mergePhaseLogs({}, data.logs ?? []))
let eventSource: EventSource | null = null
@@ -79,15 +59,8 @@
})
eventSource.addEventListener('log', (e) => {
const newLogs: PhaseLog[] = JSON.parse(e.data)
const updated = { ...logs }
for (const log of newLogs) {
if (!updated[log.phase]) {
updated[log.phase] = []
}
updated[log.phase] = [...updated[log.phase], log]
}
logs = updated
const newLogs: JobProgressLog[] = JSON.parse(e.data)
logs = mergePhaseLogs(logs, newLogs)
})
eventSource.addEventListener('done', (e) => {
@@ -1,5 +1,5 @@
<script lang="ts">
import type { ProgressLog } from '#lib/components/progress.js'
import { mergePhaseLogs } from '#lib/components/progress.js'
import PieceOverview from '#lib/pieces/viewer/PieceOverview.svelte'
import { previewOutputUrl } from '#lib/pieces/preview/output-url.js'
import PreviewFacts from './PreviewFacts.svelte'
@@ -11,14 +11,7 @@
return previewOutputUrl(path, data.job)
}
let logsMap = $derived.by(() => {
const map: Record<string, ProgressLog[]> = {}
for (const log of data.logs ?? []) {
if (!map[log.phase]) map[log.phase] = []
map[log.phase].push(log)
}
return map
})
const logsMap = $derived(mergePhaseLogs({}, data.logs ?? []))
</script>
{#if data.status === 'completed'}
@@ -1,6 +1,6 @@
<script lang="ts">
import { tick } from 'svelte'
import type { ProgressPhase, ProgressLog } from '#lib/components/progress.js'
import { latestPhases, type ProgressPhase, type ProgressLog } from '#lib/components/progress.js'
import { formatDuration } from './duration'
import CheckCircleFill from 'virtual:icons/ph/check-circle-fill'
import XCircleFill from 'virtual:icons/ph/x-circle-fill'
@@ -20,6 +20,7 @@
const consoleId = $props.id()
let scrollLocks = $state<Record<string, boolean>>({})
const timelinePhases = $derived(latestPhases(phases))
function handleScroll(phaseName: string, event: Event) {
const el = event.currentTarget as HTMLDivElement
@@ -58,9 +59,9 @@
</div>
{/if}
{#if phases.length > 0}
{#if timelinePhases.length > 0}
<div class="timeline">
{#each phases as phase (phase.phase)}
{#each timelinePhases as phase (phase.phase)}
{@const hasLogs = (logs[phase.phase]?.length ?? 0) > 0}
<div class="phase">
<div class="phase-row">
@@ -113,6 +113,22 @@ describe('preview phases and logs', () => {
expect(withoutLogs.body).not.toContain('class="log-console')
})
test('renders one timeline row for a retried phase using its latest state', () => {
const result = render(WorkflowConsole, {
props: {
status: 'completed',
phases: [
{ ...phases[0], status: 'failed', message: 'First attempt failed' },
{ ...phases[0], message: 'Retry succeeded' }
],
logs
}
})
expect(result.body.match(/class="phase-name\b/g)).toHaveLength(1)
expect(text(result.body)).toContain('Retry succeeded')
expect(text(result.body)).not.toContain('First attempt failed')
})
test('scopes log viewport IDs to the rendered console', () => {
const props = { status: 'completed', phases, logs }
const first = render(WorkflowConsole, { props, idPrefix: 'first' })
@@ -144,6 +144,19 @@ beforeEach(() => {
vi.clearAllMocks()
})
test.each(['running', 'completed'] as const)(
'%s Preview renders duplicated seed logs once and retains new lines',
(status) => {
const props = data(status)
const first = { ...props.logs[0], message: 'Duplicated seed line' }
props.logs = [first, { ...first }, { ...first, line_number: 2, message: 'New seed line' }]
const html = tree(props)
expect(html.match(/Duplicated seed line/g)).toHaveLength(1)
expect(html.match(/New seed line/g)).toHaveLength(1)
expect(html).toContain('2 lines')
}
)
test('completed Preview reads title, consolidated facts, Preview logs, then assets with a single main/title', () => {
const html = tree(data())
expect(html.match(/<main\b/g)).toHaveLength(1)
@@ -1,11 +1,14 @@
import { beforeEach, expect, test, vi } from 'vitest'
import { getOpenWorkflow } from '#lib/server/workflow/index.js'
import { previewSpec } from '@luzzle/web.jobs/specs'
import { load } from './+page.server.js'
import { actions, load } from './+page.server.js'
const { runWorkflow } = vi.hoisted(() => ({ runWorkflow: vi.fn() }))
const { runWorkflow, parseFilename } = vi.hoisted(() => ({
runWorkflow: vi.fn(),
parseFilename: vi.fn()
}))
vi.mock('#lib/server/pieces.js', () => ({
getPieces: () => ({ parseFilename: () => ({ type: 'book' }) })
getPieces: () => ({ parseFilename })
}))
vi.mock('#lib/server/config.js', async () => {
const { makeConfig } = await import('#lib/server/config.fixture.js')
@@ -19,13 +22,21 @@ vi.mock('#lib/server/workflow/index.js', () => ({ getOpenWorkflow: vi.fn() }))
beforeEach(() => {
vi.clearAllMocks()
parseFilename.mockReturnValue({ type: 'book' })
vi.mocked(getOpenWorkflow).mockReturnValue({ runWorkflow } as never)
runWorkflow.mockResolvedValue({ workflowRun: { id: 'preview-id' } })
})
test('the UI queues its preview through the web client and redirects without the standalone POST', async () => {
test('loading or preloading the preview page does not create a job', async () => {
const event = { params: { path: 'example.book.md' } } as Parameters<typeof load>[0]
await expect(load(event)).rejects.toMatchObject({
expect(await load(event)).toEqual({ file: 'example.book.md' })
expect(getOpenWorkflow).not.toHaveBeenCalled()
expect(runWorkflow).not.toHaveBeenCalled()
})
test('submitting the preview action creates a job and redirects to it', async () => {
const event = { params: { path: 'example.book.md' } } as Parameters<typeof actions.default>[0]
await expect(actions.default(event)).rejects.toMatchObject({
status: 303,
location: '/admin/piece/example.book.md/preview/preview-id'
})
@@ -47,8 +58,19 @@ test.each([
'Preview starts once from raw file %s and redirects with encoded segments',
async (file, job, location) => {
runWorkflow.mockResolvedValue({ workflowRun: { id: job } })
const event = { params: { path: file } } as Parameters<typeof load>[0]
await expect(load(event)).rejects.toMatchObject({ status: 303, location })
const event = { params: { path: file } } as Parameters<typeof actions.default>[0]
await expect(actions.default(event)).rejects.toMatchObject({ status: 303, location })
expect(runWorkflow).toHaveBeenCalledExactlyOnceWith(previewSpec, { filePath: file })
}
)
test.each([undefined, 'unconfigured'])(
'unsupported piece type %s cannot create a preview',
async (type) => {
parseFilename.mockReturnValue({ type })
const event = { params: { path: 'example.book.md' } } as Parameters<typeof actions.default>[0]
await expect(actions.default(event)).rejects.toMatchObject({ status: 404 })
expect(getOpenWorkflow).not.toHaveBeenCalled()
expect(runWorkflow).not.toHaveBeenCalled()
}
)
@@ -4,9 +4,6 @@ import {
getRunKind,
editorHref,
publishedHref,
latestPhases,
mergePhaseLogs,
type PhaseLog,
selectablePaths,
initialSelection,
toggleSelection,
@@ -167,17 +164,6 @@ test('legacy stream frames without workflow metadata retain their existing kind'
expect(getRunKind('unknown', 'publish')).toBe('publish')
})
test('retry attempts collapse to the latest state per phase without changing phase order', () => {
const first = { phase: 'assets.generate', status: 'completed' }
const failed = { phase: 'cdn.sync', status: 'failed' }
const retry = { phase: 'cdn.sync', status: 'completed' }
const cache = { phase: 'cache.purge', status: 'completed' }
const rows = [first, failed, retry, cache]
expect(latestPhases(rows)).toEqual([first, retry, cache])
expect(rows).toEqual([first, failed, retry, cache])
expect(latestPhases([])).toEqual([])
})
test.each([
'books/chapter#1.book.md',
'books/name?query%.book.md',
@@ -364,96 +350,3 @@ test('unconfirmed completion leaves pending unchanged and null pending stays nul
expect(pendingAfterPublication(null, appliedChanges({ added: ['ok'] }), [])).toBeNull()
expect(pendingAfterPublication(null, null, [])).toBeNull()
})
function phaseLog(overrides: Partial<PhaseLog> = {}): PhaseLog {
return {
job_id: 'job-one',
phase: 'cdn.sync',
line_number: 1,
ts: 100,
level: 'info',
message: 'Syncing',
...overrides
}
}
test('SSR seed and SSE replay retain each log identity once and append new lines in order', () => {
const first = phaseLog()
const second = phaseLog({ line_number: 2, message: 'Synced' })
const seeded = mergePhaseLogs({}, [first, { ...first }])
expect(seeded).toEqual({ 'cdn.sync': [first] })
expect(mergePhaseLogs(seeded, [second, { ...first }])).toEqual({
'cdn.sync': [first, second]
})
expect(mergePhaseLogs({}, [])).toEqual({})
})
test('repeated SSE frames do not create duplicate log or DOM identities', () => {
const frame = [phaseLog({ line_number: 3 }), phaseLog(), phaseLog({ line_number: 2 })]
const first = mergePhaseLogs({}, frame)
const replayed = mergePhaseLogs(
first,
frame.map((row) => ({ ...row }))
)
expect(replayed).toEqual(first)
expect(replayed['cdn.sync'].map((row) => row.line_number)).toEqual([1, 2, 3])
const keys = replayed['cdn.sync'].map((row) => `${row.job_id}:${row.line_number}`)
expect(new Set(keys).size).toBe(keys.length)
})
test('log identity includes both job and phase as well as line number', () => {
const first = phaseLog()
const otherJob = phaseLog({ job_id: 'job-two' })
const otherPhase = phaseLog({ phase: 'cache.purge' })
const later = phaseLog({ job_id: 'job-two', line_number: 2 })
const seeded = mergePhaseLogs({}, [later, first, otherPhase])
const merged = mergePhaseLogs(seeded, [otherJob, { ...first }, { ...otherPhase }])
expect(merged).toEqual({
'cdn.sync': [first, otherJob, later],
'cache.purge': [otherPhase]
})
})
test('identical log payloads remain distinct when line identities differ', () => {
const first = phaseLog()
const second = phaseLog({ line_number: 2 })
expect(mergePhaseLogs({}, [second, first, { ...first }])).toEqual({
'cdn.sync': [first, second]
})
})
test('incoming rows replace existing identities with the freshest supplied row', () => {
const first = phaseLog()
const next = phaseLog({ line_number: 2 })
const update = phaseLog({ ts: 200, level: 'warn', message: 'Retrying' })
const freshest = phaseLog({ ts: 300, level: 'error', message: 'Failed' })
const merged = mergePhaseLogs({ 'cdn.sync': [first, next] }, [update, freshest])
expect(merged).toEqual({ 'cdn.sync': [freshest, next] })
expect(merged['cdn.sync'][0]).toBe(freshest)
})
test('merging logs does not mutate input records, arrays, or rows', () => {
const current = {
'cdn.sync': [phaseLog({ line_number: 3 }), phaseLog()],
'cache.purge': [phaseLog({ phase: 'cache.purge' })]
}
const incoming = [phaseLog({ line_number: 2 }), phaseLog({ message: 'Updated' })]
const beforeCurrent = structuredClone(current)
const beforeIncoming = structuredClone(incoming)
for (const rows of [...Object.values(current), incoming]) {
for (const row of rows) Object.freeze(row)
Object.freeze(rows)
}
Object.freeze(current)
const merged = mergePhaseLogs(current, incoming)
expect(current).toEqual(beforeCurrent)
expect(incoming).toEqual(beforeIncoming)
expect(merged).not.toBe(current)
expect(merged['cdn.sync']).not.toBe(current['cdn.sync'])
expect(merged['cache.purge']).not.toBe(current['cache.purge'])
expect(merged['cdn.sync'].map((row) => row.line_number)).toEqual([1, 2, 3])
expect(mergePhaseLogs(current, [])).toEqual({
'cdn.sync': [current['cdn.sync'][1], current['cdn.sync'][0]],
'cache.purge': current['cache.purge']
})
})
@@ -98,54 +98,8 @@ export function getRunKind(workflowName: unknown, fallback: ActiveKind | null):
return fallback
}
export type PhaseProgress = {
phase: string
status: string
started_at: number
finished_at: number | null
message: string | null
}
export type PhaseLog = {
job_id: string
phase: string
line_number: number
ts: number
level: string
message: string
}
export function mergePhaseLogs(
current: Record<string, PhaseLog[]>,
incoming: PhaseLog[]
): Record<string, PhaseLog[]> {
const phases = new Map<string, Map<string, PhaseLog>>()
for (const phase of Object.keys(current)) {
phases.set(phase, new Map())
}
for (const rows of [...Object.values(current), incoming]) {
for (const row of rows) {
let phaseRows = phases.get(row.phase)
if (!phaseRows) {
phaseRows = new Map()
phases.set(row.phase, phaseRows)
}
const identity = JSON.stringify([row.job_id, row.line_number])
phaseRows.set(identity, row)
}
}
return Object.fromEntries(
[...phases].map(([phase, rows]) => [
phase,
[...rows.values()].sort((a, b) => a.line_number - b.line_number)
])
)
}
export function latestPhases<T extends { phase: string }>(rows: T[]): T[] {
const latestByPhase = new Map<string, T>()
for (const row of rows) {
latestByPhase.set(row.phase, row)
}
return Array.from(latestByPhase.values())
}
export { mergePhaseLogs, latestPhases } from '#lib/components/progress.js'
export type {
ProgressPhase as PhaseProgress,
JobProgressLog as PhaseLog
} from '#lib/components/progress.js'
+2 -2
View File
@@ -14,8 +14,8 @@ New to the files underneath? Start with [archives](../archive.md) or the
## Using the editor
With [authentication configured](config.md#sign-in), open `/admin` to manage
pieces. Save edits, preview them, then use `/admin/publish` to check changes and
publish. Saving a file is not the same as publishing it.
pieces. Save edits, then click **Saved** to start a preview of that saved file.
Use `/admin/publish` to check changes and publish. Saving is not publishing.
Check lists all pending changes. Choose which added or updated pieces to publish.
Publishing uses saved files, so edits saved after Check may be included.