Skip to content
Draft
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
51 changes: 51 additions & 0 deletions e2e/scheduler-runs.spec.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
2 changes: 2 additions & 0 deletions jest.config.ci.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,5 +66,7 @@ module.exports = {
'<rootDir>/src/app/shared/components/df-secret/df-secret.component.spec.ts',
// 2026-10-05 searchable pickers (#534): dfSearch filter + componentTypes.
'<rootDir>/src/app/shared/components/df-select-search/df-select-search.spec.ts',
// df-scheduler#41: last-run / running-state helpers.
'<rootDir>/src/app/adf-scheduler/utils/run-status.spec.ts',
],
};
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -84,6 +85,14 @@ export class DfManageSchedulerTableComponent extends DfManageTableComponent<Sche
cell: (row: SchedulerTaskData) => 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,
Expand All @@ -106,6 +115,7 @@ export class DfManageSchedulerTableComponent extends DfManageTableComponent<Sche
verb: val.verb,
frequency: val.frequency,
taskLogByTaskId: val.taskLogByTaskId,
runStatus: val.runStatus,
serviceByServiceId: val.serviceByServiceId,
};
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,86 @@ <h4>{{ 'scheduler.taskOverviewSubtitle' | transloco }}</h4>
</div>
</form>
</mat-tab>
<mat-tab
*ngIf="scheduleToEdit"
[label]="'scheduler.run.historyTab' | transloco">
<div class="details-section run-history">
<p *ngIf="scheduleToEdit.runStatus as rs" class="run-summary">
{{ 'scheduler.run.lastSuccess' | transloco }}:
{{
rs.lastSuccess
? (rs.lastSuccess.finishedAt | date: 'medium')
: ('scheduler.run.none' | transloco)
}}
&middot;
{{ 'scheduler.run.lastFailure' | transloco }}:
{{
rs.lastFailure
? (rs.lastFailure.finishedAt | date: 'medium')
: ('scheduler.run.none' | transloco)
}}
</p>
<div *ngIf="!runs.length" class="run-empty">
<p>{{ 'scheduler.run.emptyTitle' | transloco }}</p>
<p>
{{
'scheduler.run.emptyHint'
| transloco: { frequency: scheduleToEdit.frequency }
}}
</p>
</div>
<table *ngIf="runs.length" class="run-table full-width">
<thead>
<tr>
<th>{{ 'scheduler.run.started' | transloco }}</th>
<th>{{ 'scheduler.run.duration' | transloco }}</th>
<th>{{ 'scheduler.run.status' | transloco }}</th>
<th></th>
</tr>
</thead>
<tbody>
<ng-container *ngFor="let run of pagedRuns; trackBy: trackById">
<tr data-testid="run-row">
<td>{{ run.startedAt | date: 'medium' }}</td>
<td>
{{
durationSeconds(run) === null
? ('scheduler.run.inProgress' | transloco)
: durationSeconds(run) + 's'
}}
</td>
<td>{{ run.statusCode ?? '-' }}</td>
<td>
<button
type="button"
mat-button
*ngIf="run.output"
[attr.aria-expanded]="expandedRuns.has(run.id)"
(click)="toggleRun(run.id)">
{{
(expandedRuns.has(run.id)
? 'scheduler.run.hideOutput'
: 'scheduler.run.showOutput'
) | transloco
}}
</button>
</td>
</tr>
<tr *ngIf="expandedRuns.has(run.id)">
<td colspan="4"><pre class="run-output">{{ run.output }}</pre></td>
</tr>
</ng-container>
</tbody>
</table>
<mat-paginator
*ngIf="runs.length > runPageSize"
[length]="runs.length"
[pageSize]="runPageSize"
[pageIndex]="runPage"
[hidePageSize]="true"
(page)="onRunPage($event)"></mat-paginator>
</div>
</mat-tab>
<mat-tab label="Log">
<div class="details-section">
<p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -50,6 +59,8 @@ import { catchError, throwError } from 'rxjs';
MatButtonModule,
MatInputModule,
MatTabsModule,
MatPaginatorModule,
DatePipe,
MatSelectModule,
MatSlideToggleModule,
NgIf,
Expand Down Expand Up @@ -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<number>();
durationSeconds = durationSeconds;
isOk = isOk;
alertMsg = '';
showAlert = false;
alertType: AlertType = 'error';
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/app/adf-scheduler/resolvers/scheduler.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const schedulerResolver: ResolveFn<
const id = route.paramMap.get('id');
if (id)
return schedulerService.get<SchedulerTaskData>(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
Expand Down
62 changes: 62 additions & 0 deletions src/app/adf-scheduler/utils/run-status.spec.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): RunSummary => ({
id: 1,
startedAt: '2026-10-28T11:55:00Z',
finishedAt: '2026-10-28T11:55:30Z',
statusCode: 200,
...over,
});
const status = (over: Partial<RunStatus> = {}): 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();
});
});
46 changes: 46 additions & 0 deletions src/app/adf-scheduler/utils/run-status.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> } {
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
)
);
}
Loading
Loading