Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 28 additions & 28 deletions tests/features/worker-utils/exit-code.unit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,11 @@ test('code 143 alone is sigterm', () => {
test('SIGKILL -> oom-host with English adminMessage and French userMessage', () => {
const d = diagnoseExit(null, 'SIGKILL', '', sample, null, ctx)
expect(d.category).toBe('oom-host')
// English ops message
// English ops message (cause only, metrics live in adminMetrics)
expect(d.adminMessage).toContain('Task killed by the OS')
expect(d.adminMessage).toContain('SIGKILL')
expect(d.adminMessage).toContain('heap used: 723.0MB')
expect(d.adminMessage).toContain('RSS: 812.0MB')
expect(d.adminMetrics).toContain('heap used: 723.0MB')
expect(d.adminMetrics).toContain('RSS: 812.0MB')
// French user-facing message in run log
expect(d.userMessage).toMatch(/tué|terminé par le système/i)
expect(d.userMessage).toContain('SIGKILL')
Expand All @@ -73,7 +73,7 @@ test('code 137 with selfKilled=true -> sigterm category', () => {
test('code 134 -> oom-heap with mention of max heap config (English admin + French user)', () => {
const d = diagnoseExit(134, null, 'FATAL ERROR: JavaScript heap out of memory', sample, null, ctx)
expect(d.category).toBe('oom-heap')
// English admin message
// English admin message (cause + mitigation)
expect(d.adminMessage).toContain('exit code 134')
expect(d.adminMessage).toContain('WORKER_TASK_MAX_HEAP_MB=768')
expect(d.adminMessage).toContain('Concurrent tasks at exit: 3 / concurrency 4')
Expand All @@ -100,7 +100,7 @@ test('SIGABRT -> oom-heap', () => {

test('null lastMem produces "no memory sample was reported before exit"', () => {
const d = diagnoseExit(134, null, '', null, null, ctx)
expect(d.adminMessage).toContain('no memory sample was reported before exit')
expect(d.adminMetrics).toContain('no memory sample was reported before exit')
})

test('code 1 -> plugin-error using stderr (same message both fields, plugin owns language)', () => {
Expand All @@ -123,59 +123,59 @@ test('null code, null signal -> unknown', () => {
expect(d.category).toBe('unknown')
})

test('oom-host with lastExt includes external RSS and CPU ratio in admin', () => {
test('oom-host with lastExt includes external RSS and CPU ratio in adminMetrics', () => {
const d = diagnoseExit(null, 'SIGKILL', '', sample, ext, ctx)
expect(d.category).toBe('oom-host')
expect(d.adminMessage).toMatch(/Last seen RSS \(external\):\s*815\.0MB/)
expect(d.adminMessage).toMatch(/CPU usage \(external\):\s*0\.92/)
expect(d.adminMetrics).toMatch(/Last seen RSS \(external\):\s*815\.0MB/)
expect(d.adminMetrics).toMatch(/CPU usage \(external\):\s*0\.92/)
})

test('oom-host without lastExt falls back to legacy message', () => {
test('oom-host without lastExt keeps cause clean and reports child RSS in metrics', () => {
const d = diagnoseExit(null, 'SIGKILL', '', sample, null, ctx)
expect(d.category).toBe('oom-host')
expect(d.adminMessage).not.toContain('external')
expect(d.adminMessage).toContain('Task killed by the OS')
expect(d.adminMessage).toContain('RSS: 812.0MB')
expect(d.adminMetrics).toContain('RSS: 812.0MB')
})

test('oom-heap with both lastMem and lastExt renders heap primary, external secondary', () => {
test('oom-heap with both lastMem and lastExt renders heap primary, external secondary in metrics', () => {
const d = diagnoseExit(134, 'SIGABRT', '', sample, ext, ctx)
expect(d.category).toBe('oom-heap')
// Primary: child-reported heap (V8-internal)
expect(d.adminMessage).toContain('heap used: 723.0MB')
expect(d.adminMetrics).toContain('heap used: 723.0MB')
// Secondary: external RSS line
expect(d.adminMessage).toMatch(/Last seen RSS \(external\):\s*815\.0MB/)
expect(d.adminMetrics).toMatch(/Last seen RSS \(external\):\s*815\.0MB/)
})

test('plugin-error with lastExt appends external line', () => {
test('plugin-error reports stderr as message and external line in metrics', () => {
const d = diagnoseExit(1, null, 'EACCES: permission denied', null, ext, ctx)
expect(d.category).toBe('plugin-error')
expect(d.adminMessage).toMatch(/Last seen RSS \(external\):\s*815\.0MB/)
expect(d.adminMessage).toContain('EACCES: permission denied')
expect(d.adminMetrics).toMatch(/Last seen RSS \(external\):\s*815\.0MB/)
})

test('lastExt with null cpuRatio omits CPU usage line', () => {
const d = diagnoseExit(null, 'SIGKILL', '', sample, { ...ext, cpuRatio: null }, ctx)
expect(d.adminMessage).toMatch(/Last seen RSS \(external\):\s*815\.0MB/)
expect(d.adminMessage).not.toMatch(/CPU usage/)
expect(d.adminMetrics).toMatch(/Last seen RSS \(external\):\s*815\.0MB/)
expect(d.adminMetrics).not.toMatch(/CPU usage/)
})

test('oom-host with lastExt includes French external RSS and CPU usage in userMessage', () => {
test('oom-host with lastExt includes French external RSS and CPU usage in userMetrics', () => {
const d = diagnoseExit(null, 'SIGKILL', '', sample, ext, ctx)
expect(d.userMessage).toMatch(/Dernier RSS observé \(parent\)\s*:\s*815\.0MB/)
expect(d.userMessage).toMatch(/Utilisation CPU \(parent\)\s*:\s*0\.92/)
expect(d.userMetrics).toMatch(/Dernier RSS observé \(parent\)\s*:\s*815\.0MB/)
expect(d.userMetrics).toMatch(/Utilisation CPU \(parent\)\s*:\s*0\.92/)
})

test('lastExt with null cpuRatio omits CPU usage line from French userMessage', () => {
test('lastExt with null cpuRatio omits CPU usage line from French userMetrics', () => {
const d = diagnoseExit(null, 'SIGKILL', '', sample, { ...ext, cpuRatio: null }, ctx)
expect(d.userMessage).toMatch(/Dernier RSS observé \(parent\)/)
expect(d.userMessage).not.toMatch(/Utilisation CPU/)
expect(d.userMetrics).toMatch(/Dernier RSS observé \(parent\)/)
expect(d.userMetrics).not.toMatch(/Utilisation CPU/)
})

test('oom-host renders external RSS BEFORE in-process memLine (external is primary)', () => {
test('oom-host renders external RSS BEFORE in-process memLine in metrics (external is primary)', () => {
const d = diagnoseExit(null, 'SIGKILL', '', sample, ext, ctx)
const extIdx = d.adminMessage.indexOf('Last seen RSS (external)')
const memIdx = d.adminMessage.indexOf('Last memory sample')
expect(extIdx).toBeGreaterThan(0)
const extIdx = d.adminMetrics.indexOf('Last seen RSS (external)')
const memIdx = d.adminMetrics.indexOf('Last memory sample')
expect(extIdx).toBeGreaterThanOrEqual(0)
expect(memIdx).toBeGreaterThan(0)
expect(extIdx).toBeLessThan(memIdx)
})
144 changes: 106 additions & 38 deletions ui/src/components/run/run-logs-list.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,50 +2,87 @@
<div
v-for="log in logs as LogEntry[]"
:key="log.date"
class="d-flex text-break"
>
<span
v-if="log.type === 'error'"
class="text-error"
>
<template v-if="log.msg.status">
<template v-if="typeof log.msg.data === 'string'">{{ log.msg.data }}</template>
<template v-else>{{ log.msg.statusText || 'Erreur HTTP' }} - {{ log.msg.status }}</template>
</template>
<template v-else>
<div class="d-flex text-break">
<span
v-if="log.type === 'error'"
class="text-error"
>
<template v-if="log.msg.status">
<template v-if="typeof log.msg.data === 'string'">{{ log.msg.data }}</template>
<template v-else>{{ log.msg.statusText || 'Erreur HTTP' }} - {{ log.msg.status }}</template>
</template>
<template v-else>
{{ log.msg }}
</template>
</span>
<span
v-else-if="['warning', 'info', 'debug'].includes(log.type)"
:class="logTextClass(log)"
>
{{ log.msg }}
</template>
</span>
<span
v-else-if="['warning', 'info', 'debug'].includes(log.type)"
:class="logTextClass(log)"
>
{{ log.msg }}
</span>
<span
v-else-if="log.type === 'task'"
:class="`${taskColor(log)}--text`"
>
{{ log.msg }}
<span v-if="log.progress && !log.total">({{ log.progress.toLocaleString() }})</span>
<span v-if="log.total">({{ (log.progress || 0).toLocaleString() }} / {{ log.total.toLocaleString() }})</span>
<v-progress-linear
rounded
:color="taskColor(log)"
:indeterminate="!log.progress || !log.total"
:model-value="log.total ? ((log.progress || 0) / log.total) * 100 : 0"
/>
</span>
<v-spacer />
<span class="pl-2 text-no-wrap text-body-small">
{{ formatDate(log.date) }}
<span v-if="log.progressDate">- {{ formatDate(log.progressDate) }}</span>
</span>
</span>
<span
v-else-if="log.type === 'task'"
:class="`${taskColor(log)}--text`"
>
{{ log.msg }}
<span v-if="log.progress && !log.total">({{ log.progress.toLocaleString() }})</span>
<span v-if="log.total">({{ (log.progress || 0).toLocaleString() }} / {{ log.total.toLocaleString() }})</span>
<v-progress-linear
rounded
:color="taskColor(log)"
:indeterminate="!log.progress || !log.total"
:model-value="log.total ? ((log.progress || 0) / log.total) * 100 : 0"
/>
</span>
<v-spacer />
<v-btn
v-if="isSuperadmin && hasExtra(log)"
class="align-self-center"
variant="text"
size="small"
color="admin"
:append-icon="expanded[log.date] ? mdiChevronUp : mdiChevronDown"
@click="expanded[log.date] = !expanded[log.date]"
>
{{ t('viewExtra') }}
</v-btn>
<span class="pl-2 text-no-wrap text-body-small align-self-center">
{{ formatDate(log.date) }}
<span v-if="log.progressDate">- {{ formatDate(log.progressDate) }}</span>
</span>
</div>
<v-expand-transition>
<v-card
v-if="isSuperadmin && hasExtra(log) && expanded[log.date]"
color="surface-variant"
class="overflow-auto"
max-height="300"
>
<v-btn
:prepend-icon="mdiContentCopy"
variant="text"
size="small"
@click="copyExtra(log.extra)"
>
{{ t('copy') }}
</v-btn>
<v-card-text class="pt-0">
<pre
class="text-body-small"
><code>{{ formatExtra(log.extra) }}</code></pre>
</v-card-text>
</v-card>
</v-expand-transition>
</div>
</template>

<script setup lang="ts">
const { t } = useI18n()
const { dayjs } = useLocaleDayjs()
const session = useSession()
const { sendUiNotif } = useUiNotif()

type LogEntry = {
date: string
Expand All @@ -54,25 +91,56 @@ type LogEntry = {
progress: number
total: number
progressDate: string
extra?: Record<string, any> | string
}

defineProps({
logs: { type: Array, required: true }
})

const expanded = ref<Record<string, boolean>>({})

const isSuperadmin = computed(() => !!session.state.user?.adminMode)

const hasExtra = (log: LogEntry) => {
if (!log.extra) return false
if (typeof log.extra === 'string') return log.extra.length > 0
return Object.keys(log.extra).length > 0
}

const formatExtra = (extra: LogEntry['extra']) =>
typeof extra === 'string' ? extra : JSON.stringify(extra, null, 2)

const copyExtra = async (extra: LogEntry['extra']) => {
await navigator.clipboard.writeText(formatExtra(extra))
sendUiNotif({ type: 'success', msg: t('extraCopied') })
}

const taskColor = (log: LogEntry) => {
if (log.progress && log.progress === log.total) return 'success'
return 'primary'
}

const logTextClass = (log: LogEntry) => {
if (log.type === 'warning') return 'text-warning'
if (log.type === 'debug') return 'text-medium-emphasis'
if (log.type === 'debug') return 'text-admin text-medium-emphasis'
return ''
}

const formatDate = (date: string) => dayjs(date).format('lll')
</script>

<i18n lang="yaml">
en:
viewExtra: View extra
copy: Copy
extraCopied: Extra copied to clipboard

fr:
viewExtra: Voir l'extra
copy: Copier
extraCopied: Extra copié dans le presse-papier
</i18n>

<style scoped>
</style>
3 changes: 3 additions & 0 deletions ui/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,11 @@ export default defineConfig({
'mdiBell',
'mdiBookOpenVariant',
'mdiCheckCircle',
'mdiChevronUp',
'mdiChevronDown',
'mdiClock',
'mdiCloud',
'mdiContentCopy',
'mdiContentDuplicate',
'mdiDatabase',
'mdiDotsVertical',
Expand Down
Loading
Loading