diff --git a/e2e/scheduler-runs.spec.ts b/e2e/scheduler-runs.spec.ts new file mode 100644 index 00000000..7bba804c --- /dev/null +++ b/e2e/scheduler-runs.spec.ts @@ -0,0 +1,51 @@ +import { test, expect } from '@playwright/test'; +import { ADMIN_EMAIL, ADMIN_PASSWORD, loginAsAdmin } from './fixtures/admin-login'; + +/** + * df-scheduler#41: the task list shows a "Last run" column and the edit view + * has a "Run history" tab. A task that has never run reads "Never" and the + * tab explains when to expect the first run. + */ +test('Scheduler: Last run column and Run history tab for a never-run task', async ({ + page, +}) => { + const login = await page.request.post('/api/v2/system/admin/session', { + data: { email: ADMIN_EMAIL, password: ADMIN_PASSWORD }, + }); + const headers = { + 'X-DreamFactory-Session-Token': (await login.json()).session_token, + }; + const name = `e2e_runs_${Date.now()}`; + const svc = await page.request.get( + '/api/v2/system/service?filter=name%3Dsystem&fields=id', + { headers } + ); + const serviceId = (await svc.json()).resource[0].id; + const created = await page.request.post('/api/v2/system/scheduler', { + headers, + data: { + resource: [ + { name, service_id: serviceId, component: 'admin', verb_mask: 1, frequency: 5, is_active: false }, + ], + }, + }); + expect(created.ok(), await created.text()).toBe(true); + const id = (await created.json()).resource[0].id; + + try { + await loginAsAdmin(page); + await page.goto('/dreamfactory/dist/#/system-settings/scheduler'); + await expect( + page.getByRole('columnheader', { name: 'Last run' }) + ).toBeVisible({ timeout: 15_000 }); + await expect( + page.getByRole('row').filter({ hasText: name }).getByText('Never') + ).toBeVisible(); + + await page.goto(`/dreamfactory/dist/#/system-settings/scheduler/${id}`); + await page.getByRole('tab', { name: 'Run history' }).click(); + await expect(page.getByText('This task has not run yet')).toBeVisible(); + } finally { + await page.request.delete(`/api/v2/system/scheduler/${id}`, { headers }); + } +}); diff --git a/jest.config.ci.js b/jest.config.ci.js index 302ad813..bb8cea90 100644 --- a/jest.config.ci.js +++ b/jest.config.ci.js @@ -66,5 +66,7 @@ module.exports = { '/src/app/shared/components/df-secret/df-secret.component.spec.ts', // 2026-10-05 searchable pickers (#534): dfSearch filter + componentTypes. '/src/app/shared/components/df-select-search/df-select-search.spec.ts', + // df-scheduler#41: last-run / running-state helpers. + '/src/app/adf-scheduler/utils/run-status.spec.ts', ], }; diff --git a/src/app/adf-scheduler/df-manage-scheduler/df-manage-scheduler-table.component.ts b/src/app/adf-scheduler/df-manage-scheduler/df-manage-scheduler-table.component.ts index 785232d7..c7bb184d 100644 --- a/src/app/adf-scheduler/df-manage-scheduler/df-manage-scheduler-table.component.ts +++ b/src/app/adf-scheduler/df-manage-scheduler/df-manage-scheduler-table.component.ts @@ -13,6 +13,7 @@ import { Service } from 'src/app/shared/types/service'; import { TranslocoService } from '@ngneat/transloco'; import { getFilterQuery } from 'src/app/shared/utilities/filter-queries'; import { UntilDestroy } from '@ngneat/until-destroy'; +import { describeLastRun } from '../utils/run-status'; @UntilDestroy({ checkProperties: true }) @Component({ selector: 'df-manage-scheduler-table', @@ -84,6 +85,14 @@ export class DfManageSchedulerTableComponent extends DfManageTableComponent row.frequency, header: 'scheduler.table.header.frequency', }, + { + columnDef: 'lastRun', + cell: (row: SchedulerTaskData) => { + const d = describeLastRun(row.runStatus); + return this._translateService.translate(d.key, d.params); + }, + header: 'scheduler.table.header.lastRun', + }, { columnDef: 'log', cell: (row: SchedulerTaskData) => !!row.taskLogByTaskId, @@ -106,6 +115,7 @@ export class DfManageSchedulerTableComponent extends DfManageTableComponent{{ 'scheduler.taskOverviewSubtitle' | transloco }} + +
+

+ {{ 'scheduler.run.lastSuccess' | transloco }}: + {{ + rs.lastSuccess + ? (rs.lastSuccess.finishedAt | date: 'medium') + : ('scheduler.run.none' | transloco) + }} + · + {{ 'scheduler.run.lastFailure' | transloco }}: + {{ + rs.lastFailure + ? (rs.lastFailure.finishedAt | date: 'medium') + : ('scheduler.run.none' | transloco) + }} +

+
+

{{ 'scheduler.run.emptyTitle' | transloco }}

+

+ {{ + 'scheduler.run.emptyHint' + | transloco: { frequency: scheduleToEdit.frequency } + }} +

+
+ + + + + + + + + + + + + + + + + + + + + + +
{{ 'scheduler.run.started' | transloco }}{{ 'scheduler.run.duration' | transloco }}{{ 'scheduler.run.status' | transloco }}
{{ run.startedAt | date: 'medium' }} + {{ + durationSeconds(run) === null + ? ('scheduler.run.inProgress' | transloco) + : durationSeconds(run) + 's' + }} + {{ run.statusCode ?? '-' }} + +
{{ run.output }}
+ +
+

diff --git a/src/app/adf-scheduler/df-scheduler-details/df-scheduler-details.component.scss b/src/app/adf-scheduler/df-scheduler-details/df-scheduler-details.component.scss index 8e9afd87..df359a43 100644 --- a/src/app/adf-scheduler/df-scheduler-details/df-scheduler-details.component.scss +++ b/src/app/adf-scheduler/df-scheduler-details/df-scheduler-details.component.scss @@ -7,3 +7,21 @@ h4 { letter-spacing: -0.01em; color: var(--df-text); } + +.run-table { + border-collapse: collapse; + + th, + td { + text-align: left; + padding: 6px 12px; + border-bottom: 1px solid var(--df-border, rgba(128, 128, 128, 0.3)); + } +} + +.run-output { + max-height: 240px; + overflow: auto; + white-space: pre-wrap; + margin: 0; +} diff --git a/src/app/adf-scheduler/df-scheduler-details/df-scheduler-details.component.ts b/src/app/adf-scheduler/df-scheduler-details/df-scheduler-details.component.ts index 91e35005..c6eb454f 100644 --- a/src/app/adf-scheduler/df-scheduler-details/df-scheduler-details.component.ts +++ b/src/app/adf-scheduler/df-scheduler-details/df-scheduler-details.component.ts @@ -13,11 +13,20 @@ import { DfBaseCrudService } from 'src/app/shared/services/df-base-crud.service' import { CreateSchedulePayload, SchedulerTaskData, + TaskRun, UpdateSchedulePayload, } from '../../shared/types/scheduler'; import { TranslocoPipe } from '@ngneat/transloco'; -import { NgIf, NgFor, NgTemplateOutlet, AsyncPipe } from '@angular/common'; +import { + NgIf, + NgFor, + NgTemplateOutlet, + AsyncPipe, + DatePipe, +} from '@angular/common'; import { MatTabsModule } from '@angular/material/tabs'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { durationSeconds, isOk } from '../utils/run-status'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; @@ -50,6 +59,8 @@ import { catchError, throwError } from 'rxjs'; MatButtonModule, MatInputModule, MatTabsModule, + MatPaginatorModule, + DatePipe, MatSelectModule, MatSlideToggleModule, NgIf, @@ -79,6 +90,13 @@ export class DfSchedulerDetailsComponent implements OnInit { runAsApps: Array<{ id: number; name: string }> = []; runAsUsers: Array<{ id: number; name: string; email?: string }> = []; log = ''; + // Run history tab: newest first, paged client-side; output stays collapsed. + runs: TaskRun[] = []; + runPage = 0; + readonly runPageSize = 10; + expandedRuns = new Set(); + durationSeconds = durationSeconds; + isOk = isOk; alertMsg = ''; showAlert = false; alertType: AlertType = 'error'; @@ -135,6 +153,9 @@ export class DfSchedulerDetailsComponent implements OnInit { if (this.scheduleToEdit) { this.log = this.scheduleToEdit.taskLogByTaskId?.content ?? ''; + this.runs = [...(this.scheduleToEdit.taskRunByTaskId ?? [])].sort( + (a, b) => b.id - a.id + ); this.getServiceAccessList(this.scheduleToEdit.serviceId as number); @@ -174,6 +195,19 @@ export class DfSchedulerDetailsComponent implements OnInit { }); } + get pagedRuns(): TaskRun[] { + const start = this.runPage * this.runPageSize; + return this.runs.slice(start, start + this.runPageSize); + } + + onRunPage(e: PageEvent) { + this.runPage = e.pageIndex; + } + + toggleRun(id: number) { + if (!this.expandedRuns.delete(id)) this.expandedRuns.add(id); + } + triggerAlert(type: AlertType, msg: string) { this.alertType = type; this.alertMsg = msg; diff --git a/src/app/adf-scheduler/resolvers/scheduler.resolver.ts b/src/app/adf-scheduler/resolvers/scheduler.resolver.ts index 074a9430..4f820d13 100644 --- a/src/app/adf-scheduler/resolvers/scheduler.resolver.ts +++ b/src/app/adf-scheduler/resolvers/scheduler.resolver.ts @@ -20,7 +20,7 @@ export const schedulerResolver: ResolveFn< const id = route.paramMap.get('id'); if (id) return schedulerService.get(id, { - related: 'task_log_by_task_id', + related: 'task_log_by_task_id,task_run_by_task_id', }); // List branch only: complete navigation on failure so the table diff --git a/src/app/adf-scheduler/utils/run-status.spec.ts b/src/app/adf-scheduler/utils/run-status.spec.ts new file mode 100644 index 00000000..7b0f8503 --- /dev/null +++ b/src/app/adf-scheduler/utils/run-status.spec.ts @@ -0,0 +1,62 @@ +import { RunStatus, RunSummary } from 'src/app/shared/types/scheduler'; +import { describeLastRun, durationSeconds, isOk } from './run-status'; + +const NOW = Date.parse('2026-10-28T12:00:00Z'); +const run = (over: Partial = {}): RunSummary => ({ + id: 1, + startedAt: '2026-10-28T11:55:00Z', + finishedAt: '2026-10-28T11:55:30Z', + statusCode: 200, + ...over, +}); +const status = (over: Partial = {}): RunStatus => ({ + isRunning: false, + lastRun: null, + lastSuccess: null, + lastFailure: null, + ...over, +}); + +describe('describeLastRun', () => { + it('says Running while a run is in progress', () => { + const open = run({ finishedAt: null, statusCode: null }); + expect( + describeLastRun(status({ isRunning: true, lastRun: open }), NOW).key + ).toBe('scheduler.run.running'); + }); + + it('says Never when the task has not run', () => { + expect(describeLastRun(status(), NOW).key).toBe('scheduler.run.never'); + expect(describeLastRun(undefined, NOW).key).toBe('scheduler.run.never'); + }); + + it('reports OK with a relative time', () => { + const d = describeLastRun( + status({ lastRun: run({ finishedAt: '2026-10-28T11:55:00Z' }) }), + NOW + ); + expect(d.key).toBe('scheduler.run.ok'); + expect(d.params['ago']).toContain('5'); + }); + + it('reports Failed with the status code', () => { + const d = describeLastRun( + status({ lastRun: run({ statusCode: 404 }) }), + NOW + ); + expect(d.key).toBe('scheduler.run.failed'); + expect(d.params['code']).toBe(404); + }); +}); + +describe('run helpers', () => { + it('classifies success below 300', () => { + expect(isOk(run({ statusCode: 299 }))).toBe(true); + expect(isOk(run({ statusCode: 300 }))).toBe(false); + }); + + it('computes duration, null while open', () => { + expect(durationSeconds(run())).toBe(30); + expect(durationSeconds(run({ finishedAt: null }))).toBeNull(); + }); +}); diff --git a/src/app/adf-scheduler/utils/run-status.ts b/src/app/adf-scheduler/utils/run-status.ts new file mode 100644 index 00000000..61d0b49e --- /dev/null +++ b/src/app/adf-scheduler/utils/run-status.ts @@ -0,0 +1,46 @@ +import { RunStatus, RunSummary } from 'src/app/shared/types/scheduler'; + +/** "5 minutes ago" style text, locale-aware via the platform Intl API. */ +export function formatAgo(iso: string, now: number = Date.now()): string { + const secs = Math.round((new Date(iso).getTime() - now) / 1000); + const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }); + const units: [Intl.RelativeTimeFormatUnit, number][] = [ + ['day', 86400], + ['hour', 3600], + ['minute', 60], + ]; + for (const [unit, size] of units) { + if (Math.abs(secs) >= size) return rtf.format(Math.round(secs / size), unit); + } + return rtf.format(secs, 'second'); +} + +/** Transloco key + params for the "Last run" cell. */ +export function describeLastRun( + rs: RunStatus | undefined, + now: number = Date.now() +): { key: string; params: Record } { + if (rs?.isRunning) return { key: 'scheduler.run.running', params: {} }; + const run = rs?.lastRun; + if (!run) return { key: 'scheduler.run.never', params: {} }; + const ago = formatAgo(run.finishedAt ?? run.startedAt, now); + return isOk(run) + ? { key: 'scheduler.run.ok', params: { ago } } + : { key: 'scheduler.run.failed', params: { ago, code: run.statusCode } }; +} + +export function isOk(run: RunSummary): boolean { + return run.statusCode !== null && run.statusCode < 300; +} + +/** Whole seconds a finished run took; null while still open. */ +export function durationSeconds(run: RunSummary): number | null { + if (!run.finishedAt) return null; + return Math.max( + 0, + Math.round( + (new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime()) / + 1000 + ) + ); +} diff --git a/src/app/shared/types/scheduler.ts b/src/app/shared/types/scheduler.ts index ddaab00b..7da1a4fb 100644 --- a/src/app/shared/types/scheduler.ts +++ b/src/app/shared/types/scheduler.ts @@ -1,5 +1,21 @@ import { Service } from 'src/app/shared/types/service'; +export type RunSummary = { + id: number; + startedAt: string; + finishedAt: string | null; + statusCode: number | null; +}; + +export type RunStatus = { + isRunning: boolean; + lastRun: RunSummary | null; + lastSuccess: RunSummary | null; + lastFailure: RunSummary | null; +}; + +export type TaskRun = RunSummary & { output: string | null }; + export type UpdateSchedulePayload = CreateSchedulePayload & { createdById: number; createdDate: string; @@ -58,6 +74,8 @@ export interface SchedulerTaskData { verbMask: number; appId?: number | null; userId?: number | null; + runStatus?: RunStatus; + taskRunByTaskId?: TaskRun[]; taskLogByTaskId: { taskId: number; statusCode: number; diff --git a/src/assets/i18n/scheduler/en.json b/src/assets/i18n/scheduler/en.json index f8afd3c8..0027dbe5 100644 --- a/src/assets/i18n/scheduler/en.json +++ b/src/assets/i18n/scheduler/en.json @@ -21,9 +21,28 @@ "component": "Component", "method": "Method", "frequency": "Frequency", + "lastRun": "Last run", "log": "Log" } }, + "run": { + "running": "Running", + "never": "Never", + "ok": "OK, {{ago}}", + "failed": "Failed {{code}}, {{ago}}", + "historyTab": "Run history", + "lastSuccess": "Last success", + "lastFailure": "Last failure", + "none": "None", + "started": "Started", + "duration": "Duration", + "status": "Status", + "inProgress": "In progress", + "showOutput": "Show output", + "hideOutput": "Hide output", + "emptyTitle": "This task has not run yet", + "emptyHint": "It runs every {{frequency}} minute(s) while it is active. Runs appear here after the first one." + }, "logs": { "title": "Scheduler Task Log", "subtitle": "This interface displays the scheduler task error log.",