Skip to content

Commit b458012

Browse files
committed
feat: implement cron task logging functionality with enhanced error handling and UI for viewing logs in the settings page
1 parent 4b8aeac commit b458012

File tree

5 files changed

+235
-5
lines changed

5 files changed

+235
-5
lines changed

apps/api/src/_common/functions/handler-logger.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import path from 'path'
33
import { ConfigService } from '@nestjs/config'
44
import { Logger } from '@nestjs/common'
55

6+
export function toSafeHandlerName(handler: string): string {
7+
return handler.replace(/[^a-zA-Z0-9-_]/g, '_').toLowerCase()
8+
}
9+
610
function ensureDir(dir: string) {
711
try {
812
fs.mkdirSync(dir, { recursive: true, mode: 0o777 })
@@ -18,7 +22,7 @@ function stripAnsiCodes(str: string): string {
1822
}
1923

2024
export function createHandlerLogger(config: ConfigService, handler: string) {
21-
const safeHandler = handler.replace(/[^a-zA-Z0-9-_]/g, '_').toLowerCase()
25+
const safeHandler = toSafeHandlerName(handler)
2226
const logDir = config.get('cron.logDirectory') || path.join(process.cwd(), 'logs', 'handlers')
2327
const logFile = path.join(logDir, `${safeHandler}.log`)
2428

@@ -61,7 +65,7 @@ export function createHandlerLogger(config: ConfigService, handler: string) {
6165
return {
6266
log: (msg: string) => log(msg),
6367
error: (msg: string) => error(msg),
64-
close: () => () => {
68+
close: () => {
6569
jump(2)
6670
stream?.close()
6771
},

apps/api/src/core/cron/cron.controller.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11

2-
import { Controller, Get, Res, HttpStatus, Query } from '@nestjs/common'
2+
import { Controller, Get, Res, HttpStatus, Query, Param, DefaultValuePipe, ParseIntPipe, NotFoundException } from '@nestjs/common'
33
import { ApiTags } from '@nestjs/swagger'
44
import { CronService } from './cron.service'
55
import { Response } from 'express'
@@ -69,10 +69,36 @@ export class CronController {
6969
})
7070
@ApiReadResponseDecorator(CronDto)
7171
public async read(
72+
@Param('name') name: string,
7273
@Res() res: Response,
7374
): Promise<Response> {
75+
const data = await this.cronService.read(name)
76+
if (!data) {
77+
throw new NotFoundException(`Cron task <${name}> not found`)
78+
}
79+
80+
return res.json({
81+
statusCode: HttpStatus.OK,
82+
data,
83+
})
84+
}
85+
86+
@Get(':name/logs')
87+
@UseRoles({
88+
resource: '/core/cron',
89+
action: AC_ACTIONS.READ,
90+
possession: AC_DEFAULT_POSSESSION,
91+
})
92+
public async readLogs(
93+
@Param('name') name: string,
94+
@Query('tail', new DefaultValuePipe(500), ParseIntPipe) tail: number,
95+
@Res() res: Response,
96+
): Promise<Response> {
97+
const data = await this.cronService.readLogs(name, tail)
98+
7499
return res.json({
75100
statusCode: HttpStatus.OK,
101+
data,
76102
})
77103
}
78104
}

apps/api/src/core/cron/cron.service.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import { CronHooksService } from './cron-hooks.service'
55
import { pick } from 'radash'
66
import { CronTaskDTO } from './_dto/config-task.dto'
77
import { CronJob } from 'cron'
8+
import path from 'node:path'
9+
import { existsSync, readFileSync, statSync } from 'node:fs'
10+
import { ConfigService } from '@nestjs/config'
11+
import { toSafeHandlerName } from '~/_common/functions/handler-logger'
812

913
@Injectable()
1014
export class CronService {
@@ -13,6 +17,7 @@ export class CronService {
1317
public constructor(
1418
private schedulerRegistry: SchedulerRegistry,
1519
private readonly cronHooksService: CronHooksService,
20+
private readonly configService: ConfigService,
1621
) {
1722
}
1823

@@ -112,4 +117,55 @@ export class CronService {
112117
_job,
113118
}
114119
}
120+
121+
public async readLogs(name: string, tail = 500): Promise<{
122+
name: string
123+
exists: boolean
124+
file: string
125+
updatedAt: string | null
126+
content: string
127+
}> {
128+
const safeName = toSafeHandlerName(name)
129+
const logDir = this.configService.get('cron.logDirectory') || path.join(process.cwd(), 'logs', 'handlers')
130+
const logFile = path.join(logDir, `${safeName}.log`)
131+
132+
if (!existsSync(logFile)) {
133+
return {
134+
name,
135+
exists: false,
136+
file: logFile,
137+
updatedAt: null,
138+
content: '',
139+
}
140+
}
141+
142+
const stats = statSync(logFile)
143+
const fullContent = readFileSync(logFile, 'utf-8')
144+
const boundedTail = Math.min(Math.max(tail || 200, 1), 2_000)
145+
const maxLineChars = 4_000
146+
const maxContentChars = 200_000
147+
148+
const tailedLines = fullContent
149+
.split('\n')
150+
.slice(-boundedTail)
151+
.map((line) => {
152+
if (line.length <= maxLineChars) {
153+
return line
154+
}
155+
return `${line.slice(0, maxLineChars)} … [line truncated]`
156+
})
157+
158+
let content = tailedLines.join('\n')
159+
if (content.length > maxContentChars) {
160+
content = `... [output truncated to last ${maxContentChars} characters]\n${content.slice(-maxContentChars)}`
161+
}
162+
163+
return {
164+
name,
165+
exists: true,
166+
file: logFile,
167+
updatedAt: stats.mtime.toISOString(),
168+
content,
169+
}
170+
}
115171
}

apps/web/src/composables/useDebug.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,17 @@ export function useDebug() {
5151
}
5252

5353
const monacoOptions = computed<Monaco.editor.IStandaloneEditorConstructionOptions>(() => {
54+
;(window as any).MonacoEnvironment = {
55+
locale: 'fr'
56+
}
57+
5458
return {
5559
theme: $q.dark.isActive ? 'vs-dark' : 'vs-light',
5660
readOnly: true,
5761
minimap: {
5862
enabled: true,
5963
},
64+
largeFileOptimizations: true,
6065
scrollBeyondLastColumn: 0,
6166
scrollBeyondLastLine: false,
6267
}

apps/web/src/pages/settings/cron.vue

Lines changed: 141 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,49 @@
1616
)
1717
template(#top-table)
1818
sesame-core-pan-filters(:columns='columns' mode='simple' placeholder='Rechercher par nom, description, ...')
19-
//template(v-slot:row-actions='{ row }')
20-
//q-btn(:to='toPathWithQueries(`/settings/cron/${row.name}`)' color='primary' icon='mdi-eye' size='sm' flat round dense)
19+
template(v-slot:row-actions='{ row }')
20+
q-btn(
21+
:disable='!hasPermission("/core/cron", "read")'
22+
color='primary'
23+
icon='mdi-file-document-outline'
24+
size='sm'
25+
flat
26+
round
27+
dense
28+
@click='openLogsModal(row)'
29+
)
30+
q-tooltip.text-body2.bg-negative.text-white(
31+
v-if="!hasPermission('/core/cron', 'read')"
32+
anchor="top middle"
33+
self="center middle"
34+
) Vous n'avez pas les permissions nécessaires pour effectuer cette action
2135
template(#body-cell-enabled="props")
2236
q-td
2337
q-checkbox(:model-value="props.row.enabled" :disable="true" size="xs")
38+
q-dialog(v-model='logsDialog' maximized)
39+
q-card.fit.column.no-wrap(style='overflow: hidden;')
40+
q-toolbar.bg-info.text-white(bordered dense style='height: 28px; line-height: 28px;')
41+
q-toolbar-title Logs de la tâche "{{ selectedCronName }}"
42+
q-space
43+
q-btn(flat round dense icon='mdi-refresh' :loading='logsLoading' @click='loadCronLogs')
44+
q-btn(flat round dense icon='mdi-close' v-close-popup)
45+
q-separator
46+
q-card-section.col.q-pa-none(ref='logsContainer' style='min-height: 0; overflow: auto;')
47+
q-inner-loading(:showing='logsLoading')
48+
div.text-center(v-if='!logsLoading && !logsExists').text-grey-7 Aucun fichier de log trouvé pour cette tâche.
49+
client-only(v-if='logsExists')
50+
MonacoEditor.fit(
51+
ref='logsMonacoEditor'
52+
:model-value="logsContent || 'Aucun contenu de log.'"
53+
:options='logsMonacoOptions'
54+
lang='shell'
55+
@load='onLogsEditorLoad'
56+
)
2457
</template>
2558

2659
<script lang="ts">
2760
import type { LocationQueryValue } from 'vue-router'
61+
import { computed, ref } from 'vue'
2862
import { NewTargetId } from '~/constants/variables'
2963
3064
export default defineNuxtComponent({
@@ -80,8 +114,32 @@ export default defineNuxtComponent({
80114
async setup() {
81115
const { useHttpPaginationOptions, useHttpPaginationReactive } = usePagination()
82116
const { toPathWithQueries, navigateToTab } = useRouteQueries()
117+
const { hasPermission } = useAccessControl()
118+
const { monacoOptions } = useDebug()
119+
120+
const editorEl = useTemplateRef<HTMLDivElement>('logsMonacoEditor')
83121
84122
const paginationOptions = useHttpPaginationOptions()
123+
const logsDialog = ref(false)
124+
const selectedCronName = ref('')
125+
const logsLoading = ref(false)
126+
const logsTail = ref(250)
127+
const logsTailStep = 250
128+
const logsTailMax = 5_000
129+
const logsFullyLoaded = ref(false)
130+
const logsContent = ref('')
131+
const logsExists = ref(false)
132+
const logsMonacoEditor = ref<any>(null)
133+
const logsMonacoOptions = computed(() => ({
134+
...monacoOptions.value,
135+
minimap: { enabled: false },
136+
readOnly: true,
137+
wordWrap: 'off',
138+
scrollBeyondLastLine: false,
139+
lineNumbers: 'off',
140+
folding: false,
141+
glyphMargin: false,
142+
}))
85143
86144
const {
87145
data: cronTasks,
@@ -109,6 +167,19 @@ export default defineNuxtComponent({
109167
refresh,
110168
toPathWithQueries,
111169
navigateToTab,
170+
hasPermission,
171+
editorEl,
172+
logsDialog,
173+
selectedCronName,
174+
logsLoading,
175+
logsTail,
176+
logsTailStep,
177+
logsTailMax,
178+
logsFullyLoaded,
179+
logsContent,
180+
logsExists,
181+
logsMonacoEditor,
182+
logsMonacoOptions,
112183
}
113184
},
114185
computed: {
@@ -139,6 +210,74 @@ export default defineNuxtComponent({
139210
}
140211
return 'N/A'
141212
},
213+
async openLogsModal(cronTask: any): Promise<void> {
214+
this.selectedCronName = cronTask?.name || ''
215+
this.logsTail = this.logsTailStep
216+
this.logsFullyLoaded = false
217+
this.logsDialog = true
218+
await this.loadCronLogs()
219+
},
220+
async loadCronLogs(): Promise<void> {
221+
if (!this.selectedCronName) {
222+
return
223+
}
224+
225+
this.logsLoading = true
226+
try {
227+
const response = await this.$http.get(`/core/cron/${encodeURIComponent(this.selectedCronName)}/logs`, {
228+
query: {
229+
tail: this.logsTail,
230+
},
231+
})
232+
this.logsContent = response?._data?.data?.content || ''
233+
this.logsExists = !!response?._data?.data?.exists
234+
const lineCount = this.logsContent ? this.logsContent.split('\n').length : 0
235+
this.logsFullyLoaded = this.logsTail >= this.logsTailMax || lineCount < this.logsTail
236+
} catch (error: any) {
237+
this.logsContent = ''
238+
this.logsExists = false
239+
this.logsFullyLoaded = true
240+
this.$q.notify({
241+
message: error?.response?._data?.message || 'Impossible de charger les logs de la tâche cron (timeout ou erreur réseau).',
242+
color: 'negative',
243+
position: 'top-right',
244+
icon: 'mdi-alert-circle-outline',
245+
})
246+
} finally {
247+
this.logsLoading = false
248+
}
249+
},
250+
onLogsEditorLoad(editor: any): void {
251+
this.logsMonacoEditor = editor
252+
const model = editor.getModel()
253+
const lineCount = model?.getLineCount() || 1
254+
editor.revealLineNearTop(lineCount)
255+
editor.onDidScrollChange(async () => {
256+
const isAtTop = editor.getScrollTop() <= 0
257+
if (!isAtTop || this.logsLoading || this.logsFullyLoaded || !this.logsExists) {
258+
return
259+
}
260+
261+
const nextTail = Math.min(this.logsTail + this.logsTailStep, this.logsTailMax)
262+
if (nextTail <= this.logsTail) {
263+
this.logsFullyLoaded = true
264+
return
265+
}
266+
267+
this.logsTail = nextTail
268+
const previousModel = editor.getModel()
269+
const previousLineCount = previousModel?.getLineCount() || 1
270+
const anchorLine = editor.getVisibleRanges()?.[0]?.startLineNumber || 1
271+
await this.loadCronLogs()
272+
this.$nextTick(() => {
273+
const updatedModel = editor.getModel()
274+
const updatedLineCount = updatedModel?.getLineCount() || previousLineCount
275+
const addedLines = Math.max(updatedLineCount - previousLineCount, 0)
276+
const nextAnchorLine = Math.min(anchorLine + addedLines, updatedLineCount)
277+
editor.revealLineNearTop(nextAnchorLine)
278+
})
279+
})
280+
},
142281
},
143282
})
144283
</script>

0 commit comments

Comments
 (0)