Display job execution start and end time (#5123)
added start and end times in the job execution details
<img width="350" height="241" alt="image" src="https://github.com/user-attachments/assets/39bd7a41-0d2b-48af-a61d-160f0ea1b5fe" />
fix: https://github.com/flutter/flutter/issues/190633
diff --git a/dashboard/lib/service/data_seeder.dart b/dashboard/lib/service/data_seeder.dart
index f5460a4..f2708aa 100644
--- a/dashboard/lib/service/data_seeder.dart
+++ b/dashboard/lib/service/data_seeder.dart
@@ -668,8 +668,10 @@
.neutral =>
'[INFO] Starting task $jobName...\n[INFO] Test neutral: Dummy Tests',
},
- startTime: creationTime + 30000,
- endTime: creationTime + 60000,
+ startTime: status != TaskStatus.waitingForBackfill
+ ? creationTime + 30000
+ : null,
+ endTime: status.isComplete ? creationTime + 60000 : null,
logAnalysis: switch (status) {
.failed =>
'''
diff --git a/dashboard/lib/views/presubmit_view.dart b/dashboard/lib/views/presubmit_view.dart
index 61a75b9..de3db57 100644
--- a/dashboard/lib/views/presubmit_view.dart
+++ b/dashboard/lib/views/presubmit_view.dart
@@ -12,6 +12,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:gpt_markdown/gpt_markdown.dart';
+import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -620,6 +621,7 @@
],
),
),
+ _buildExecutionDates(selectedJob, isDark),
Expanded(
child: Container(
margin: const EdgeInsets.all(8.0),
@@ -796,6 +798,51 @@
'${job.jobName} is not yet scheduled for execution.\n"View more details on LUCI UI" button will become enabled once the job is scheduled.',
};
}
+
+ Widget _buildExecutionDates(PresubmitJobResponse job, bool isDark) {
+ final start = job.startTime;
+ final end = job.endTime;
+ final created = job.creationTime;
+
+ final parts = <String>[];
+ if (start != null && start != 0 && end != null && end != 0) {
+ final startTime = DateTime.fromMillisecondsSinceEpoch(start);
+ final endTime = DateTime.fromMillisecondsSinceEpoch(end);
+ parts.add(formatDuration(endTime.difference(startTime)));
+ }
+ if (start != null && start != 0) {
+ parts.add('Start: ${_formatDateTime(start)}');
+ }
+ if (end != null && end != 0) {
+ parts.add('End: ${_formatDateTime(end)}');
+ }
+ if (parts.isEmpty && created != 0) {
+ parts.add('Created: ${_formatDateTime(created)}');
+ }
+
+ if (parts.isEmpty) return const SizedBox.shrink();
+
+ return Container(
+ padding: const EdgeInsets.fromLTRB(8.0, 8.0, 8.0, 0),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ parts.join(' '),
+ style: TextStyle(
+ fontSize: 12,
+ color: isDark ? const Color(0xFF8B949E) : const Color(0xFF6B7280),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ String _formatDateTime(int millis) {
+ final dt = DateTime.fromMillisecondsSinceEpoch(millis);
+ return DateFormat('dd/MM/yy HH:mm:ss').format(dt);
+ }
}
class _JobsSidebar extends StatefulWidget {
@@ -1072,3 +1119,43 @@
}
}
}
+
+/// Formats a [Duration] into a human-readable string.
+///
+/// If hours, minutes, or seconds are non-zero, they are included (e.g., "1h 12m 34s").
+/// Zero hours or minutes are omitted.
+/// If all are zero, falls back to milliseconds (e.g., "12ms").
+/// If milliseconds are zero, falls back to microseconds (e.g., "34µs").
+/// If even microseconds are zero, returns "Planck time".
+@visibleForTesting
+String formatDuration(Duration d) {
+ final absD = d.abs();
+ final parts = <String>[];
+ if (absD.inHours > 0) {
+ parts.add('${absD.inHours}h');
+ }
+ final minutes = absD.inMinutes % 60;
+ if (minutes > 0) {
+ parts.add('${minutes}m');
+ }
+ final seconds = absD.inSeconds % 60;
+ if (seconds > 0) {
+ parts.add('${seconds}s');
+ }
+
+ if (parts.isNotEmpty) {
+ return 'Duration: ${parts.join(' ')}';
+ }
+
+ final millis = absD.inMilliseconds;
+ if (millis > 0) {
+ return 'Duration: ${millis}ms';
+ }
+
+ final micros = absD.inMicroseconds;
+ if (micros > 0) {
+ return 'Duration: $microsµs';
+ }
+
+ return 'Duration: Planck time';
+}
diff --git a/dashboard/test/views/presubmit_view_test.dart b/dashboard/test/views/presubmit_view_test.dart
index 7b28f1b..7dbd413 100644
--- a/dashboard/test/views/presubmit_view_test.dart
+++ b/dashboard/test/views/presubmit_view_test.dart
@@ -1459,5 +1459,257 @@
expect(find.text('Filter jobs'), findsOneWidget);
expect(find.text('Re-run failed'), findsOneWidget);
});
+
+ testWidgets(
+ 'PreSubmitView displays job dates at top of job details content area for different attempts',
+ (WidgetTester tester) async {
+ tester.view.physicalSize = const Size(2000, 1080);
+ tester.view.devicePixelRatio = 1.0;
+ addTearDown(tester.view.resetPhysicalSize);
+ addTearDown(tester.view.resetDevicePixelRatio);
+
+ const mockSha = 'decaf_3_real_sha';
+ const guardResponse = PresubmitGuardResponse(
+ prNum: 123,
+ author: 'dash',
+ guardStatus: GuardStatus.succeeded,
+ checkRunId: 456,
+ stages: [
+ PresubmitGuardStage(
+ name: 'Engine',
+ createdAt: 0,
+ jobs: {'Mac mac_host_engine 1': TaskStatus.succeeded},
+ ),
+ ],
+ );
+
+ when(
+ mockCocoonService.fetchPresubmitGuard(
+ repo: anyNamed('repo'),
+ sha: mockSha,
+ ),
+ ).thenAnswer((_) async => const CocoonResponse.data(guardResponse));
+
+ when(
+ mockCocoonService.fetchPresubmitJobDetails(
+ checkRunId: anyNamed('checkRunId'),
+ jobName: argThat(contains('mac_host_engine'), named: 'jobName'),
+ ),
+ ).thenAnswer(
+ (_) async => CocoonResponse.data([
+ PresubmitJobResponse(
+ attemptNumber: 1,
+ jobName: 'Mac mac_host_engine 1',
+ creationTime: DateTime(2026, 8, 5, 10, 0).millisecondsSinceEpoch,
+ startTime: DateTime(2026, 8, 5, 10, 5, 0).millisecondsSinceEpoch,
+ endTime: DateTime(2026, 8, 5, 11, 20, 34).millisecondsSinceEpoch,
+ status: TaskStatus.succeeded,
+ summary: 'Attempt 1 passed',
+ ),
+ PresubmitJobResponse(
+ attemptNumber: 2,
+ jobName: 'Mac mac_host_engine 1',
+ creationTime: DateTime(2026, 8, 5, 10, 0).millisecondsSinceEpoch,
+ startTime: DateTime(2026, 8, 5, 10, 20).millisecondsSinceEpoch,
+ status: TaskStatus.inProgress,
+ summary: 'Attempt 2 in progress',
+ ),
+ PresubmitJobResponse(
+ attemptNumber: 3,
+ jobName: 'Mac mac_host_engine 1',
+ creationTime: DateTime(2026, 8, 5, 10, 30).millisecondsSinceEpoch,
+ status: TaskStatus.waitingForBackfill,
+ summary: 'Attempt 3 queued',
+ ),
+ PresubmitJobResponse(
+ attemptNumber: 4,
+ jobName: 'Mac mac_host_engine 1',
+ creationTime: DateTime(2026, 8, 5, 10, 0).millisecondsSinceEpoch,
+ startTime: DateTime(2026, 8, 5, 10, 5, 0).millisecondsSinceEpoch,
+ endTime: DateTime(2026, 8, 5, 10, 5, 34).millisecondsSinceEpoch,
+ status: TaskStatus.succeeded,
+ summary: 'Attempt 4 passed',
+ ),
+ PresubmitJobResponse(
+ attemptNumber: 5,
+ jobName: 'Mac mac_host_engine 1',
+ creationTime: DateTime(2026, 8, 5, 10, 0).millisecondsSinceEpoch,
+ startTime: DateTime(2026, 8, 5, 10, 5, 0).millisecondsSinceEpoch,
+ endTime: DateTime(2026, 8, 5, 11, 5, 0).millisecondsSinceEpoch,
+ status: TaskStatus.succeeded,
+ summary: 'Attempt 5 passed',
+ ),
+ PresubmitJobResponse(
+ attemptNumber: 6,
+ jobName: 'Mac mac_host_engine 1',
+ creationTime: DateTime(2026, 8, 5, 10, 0).millisecondsSinceEpoch,
+ startTime: DateTime(
+ 2026,
+ 8,
+ 5,
+ 10,
+ 5,
+ 0,
+ 0,
+ ).millisecondsSinceEpoch,
+ endTime: DateTime(
+ 2026,
+ 8,
+ 5,
+ 10,
+ 5,
+ 0,
+ 123,
+ ).millisecondsSinceEpoch,
+ status: TaskStatus.succeeded,
+ summary: 'Attempt 6 passed',
+ ),
+ PresubmitJobResponse(
+ attemptNumber: 7,
+ jobName: 'Mac mac_host_engine 1',
+ creationTime: DateTime(2026, 8, 5, 10, 0).millisecondsSinceEpoch,
+ startTime: DateTime(
+ 2026,
+ 8,
+ 5,
+ 10,
+ 5,
+ 0,
+ 0,
+ ).millisecondsSinceEpoch,
+ endTime: DateTime(2026, 8, 5, 10, 5, 0, 0).millisecondsSinceEpoch,
+ status: TaskStatus.succeeded,
+ summary: 'Attempt 7 passed',
+ ),
+ ]),
+ );
+
+ await tester.runAsync(() async {
+ await tester.pumpWidget(
+ createPreSubmitView({'repo': 'flutter', 'sha': mockSha}),
+ );
+ for (var i = 0; i < 50; i++) {
+ await tester.pump();
+ await Future<void>.delayed(const Duration(milliseconds: 50));
+ if (find
+ .textContaining('Execution Details')
+ .evaluate()
+ .isNotEmpty) {
+ break;
+ }
+ }
+ });
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.textContaining('mac_host_engine').first);
+ await tester.pumpAndSettle();
+
+ expect(find.text('Execution Details'), findsOneWidget);
+
+ // Attempt 1: Both Start and End
+ expect(
+ find.textContaining(
+ 'Duration: 1h 15m 34s Start: 05/08/26 10:05:00 End: 05/08/26 11:20:34',
+ ),
+ findsOneWidget,
+ );
+
+ // Switch to Attempt 2: Only Start
+ await tester.tap(find.text('#2'));
+ await tester.pumpAndSettle();
+ expect(find.textContaining('Start: 05/08/26 10:20:00'), findsOneWidget);
+ expect(find.textContaining('End:'), findsNothing);
+
+ // Switch to Attempt 3: Only Creation
+ await tester.tap(find.text('#3'));
+ await tester.pumpAndSettle();
+ expect(
+ find.textContaining('Created: 05/08/26 10:30:00'),
+ findsOneWidget,
+ );
+ expect(find.textContaining('Start:'), findsNothing);
+
+ // Switch to Attempt 4: Only seconds duration
+ await tester.tap(find.text('#4'));
+ await tester.pumpAndSettle();
+ expect(
+ find.textContaining(
+ 'Duration: 34s Start: 05/08/26 10:05:00 End: 05/08/26 10:05:34',
+ ),
+ findsOneWidget,
+ );
+
+ // Switch to Attempt 5: Only hours duration
+ await tester.tap(find.text('#5'));
+ await tester.pumpAndSettle();
+ expect(
+ find.textContaining(
+ 'Duration: 1h Start: 05/08/26 10:05:00 End: 05/08/26 11:05:00',
+ ),
+ findsOneWidget,
+ );
+
+ // Switch to Attempt 6: Milliseconds duration
+ await tester.tap(find.text('#6'));
+ await tester.pumpAndSettle();
+ expect(
+ find.textContaining(
+ 'Duration: 123ms Start: 05/08/26 10:05:00 End: 05/08/26 10:05:00',
+ ),
+ findsOneWidget,
+ );
+
+ // Switch to Attempt 7: Planck time duration
+ await tester.tap(find.text('#7'));
+ await tester.pumpAndSettle();
+ expect(
+ find.textContaining(
+ 'Duration: Planck time Start: 05/08/26 10:05:00 End: 05/08/26 10:05:00',
+ ),
+ findsOneWidget,
+ );
+ },
+ );
+
+ group('formatDuration', () {
+ test('formats hours, minutes, and seconds correctly', () {
+ expect(
+ formatDuration(const Duration(hours: 1, minutes: 12, seconds: 34)),
+ 'Duration: 1h 12m 34s',
+ );
+ expect(
+ formatDuration(const Duration(hours: 2, seconds: 5)),
+ 'Duration: 2h 5s',
+ );
+ expect(
+ formatDuration(const Duration(minutes: 45, seconds: 12)),
+ 'Duration: 45m 12s',
+ );
+ expect(formatDuration(const Duration(minutes: 30)), 'Duration: 30m');
+ expect(formatDuration(const Duration(seconds: 15)), 'Duration: 15s');
+ });
+
+ test('falls back to milliseconds when h/m/s are zero', () {
+ expect(
+ formatDuration(const Duration(milliseconds: 123)),
+ 'Duration: 123ms',
+ );
+ expect(
+ formatDuration(const Duration(hours: 0, milliseconds: 456)),
+ 'Duration: 456ms',
+ );
+ });
+
+ test('falls back to microseconds when ms is zero', () {
+ expect(
+ formatDuration(const Duration(microseconds: 789)),
+ 'Duration: 789µs',
+ );
+ });
+
+ test('returns Planck time when all are zero', () {
+ expect(formatDuration(Duration.zero), 'Duration: Planck time');
+ });
+ });
});
}