| // Copyright (c) 2019 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import 'package:flutter/material.dart'; |
| import 'package:flutter_progress_button/flutter_progress_button.dart'; |
| import 'package:url_launcher/url_launcher.dart'; |
| |
| import 'package:cocoon_service/protos.dart' show Commit, Task; |
| |
| import 'agent_dashboard_page.dart'; |
| import 'state/flutter_build.dart'; |
| import 'status_grid.dart'; |
| import 'task_attempt_summary.dart'; |
| import 'task_helper.dart'; |
| |
| /// Displays information from a [Task]. |
| /// |
| /// If [Task.status] is "In Progress", it will show as a "New" task |
| /// with a [CircularProgressIndicator] in the box. |
| /// Shows a black box for unknown statuses. |
| class TaskBox extends StatefulWidget { |
| const TaskBox({ |
| Key key, |
| @required this.buildState, |
| @required this.task, |
| @required this.commit, |
| @visibleForTesting this.insertColorKeys = false, |
| }) : assert(task != null), |
| assert(buildState != null), |
| assert(commit != null), |
| super(key: key); |
| |
| /// Reference to the build state to perform actions on this [Task], like rerunning or viewing the log. |
| final FlutterBuildState buildState; |
| |
| /// [Task] to show information from. |
| final Task task; |
| |
| /// [Commit] for cirrus tasks to show log. |
| final Commit commit; |
| |
| /// Test variable for storing the color in the key. |
| final bool insertColorKeys; |
| |
| /// Status messages that map to TaskStatus enums. |
| // TODO(chillers): Remove these and use TaskStatus enum when available. https://github.com/flutter/cocoon/issues/441 |
| static const String statusFailed = 'Failed'; |
| static const String statusNew = 'New'; |
| static const String statusSkipped = 'Skipped'; |
| static const String statusSucceeded = 'Succeeded'; |
| static const String statusSucceededButFlaky = 'Succeeded Flaky'; |
| static const String statusUnderperformed = 'Underperformed'; |
| static const String statusUnderperformedInProgress = |
| 'Underperfomed In Progress'; |
| static const String statusInProgress = 'In Progress'; |
| |
| /// A lookup table to define the background color for this TaskBox. |
| /// |
| /// The status messages are based on the messages the backend sends. |
| static const Map<String, Color> statusColor = <String, Color>{ |
| statusFailed: Colors.red, |
| statusNew: Colors.blue, |
| statusInProgress: Colors.blue, |
| statusSkipped: Colors.transparent, |
| statusSucceeded: Colors.green, |
| statusSucceededButFlaky: Colors.yellow, |
| statusUnderperformed: Colors.orange, |
| statusUnderperformedInProgress: Colors.orange, |
| }; |
| |
| @override |
| _TaskBoxState createState() => _TaskBoxState(); |
| } |
| |
| class _TaskBoxState extends State<TaskBox> { |
| OverlayEntry _taskOverlay; |
| |
| /// [Task.status] modified to take into account [Task.attempts] to create |
| /// a more descriptive status. |
| /// |
| /// For example, [Task.status] = "In Progress" and [Task.attempts] > 1 results |
| /// in the status of [statusUnderperformedInProgress]. |
| String status; |
| |
| @override |
| Widget build(BuildContext context) { |
| final bool attempted = widget.task.attempts > 1; |
| |
| status = widget.task.status; |
| if (attempted) { |
| if (status == TaskBox.statusSucceeded) { |
| status = TaskBox.statusSucceededButFlaky; |
| } else if (status == TaskBox.statusNew) { |
| status = TaskBox.statusUnderperformed; |
| } else if (status == TaskBox.statusInProgress) { |
| status = TaskBox.statusUnderperformedInProgress; |
| } |
| } |
| |
| final Color taskColor = TaskBox.statusColor.containsKey(status) |
| ? TaskBox.statusColor[status] |
| : Colors.black; |
| |
| return SizedBox( |
| key: widget.insertColorKeys ? Key(taskColor.toString()) : null, |
| width: StatusGrid.cellSize, |
| height: StatusGrid.cellSize, |
| child: GestureDetector( |
| onTap: _handleTap, |
| child: Container( |
| margin: const EdgeInsets.all(1.0), |
| color: taskColor, |
| child: taskIndicators(widget.task, status), |
| ), |
| ), |
| ); |
| } |
| |
| /// Compiles a stack of indicators to show on a [TaskBox]. |
| /// |
| /// If [Task.isFlaky], show a question mark. |
| /// If [status] is in progress, show an in progress indicator. |
| Stack taskIndicators(Task task, String status) { |
| return Stack( |
| children: <Widget>[ |
| if (task.isFlaky) |
| const Padding( |
| padding: EdgeInsets.fromLTRB(10.0, 12.0, 10.0, 12.0), |
| child: Icon( |
| Icons.help, |
| color: Colors.white60, |
| size: 25, |
| ), |
| ), |
| if (status == TaskBox.statusInProgress || |
| status == TaskBox.statusUnderperformedInProgress) |
| const Padding( |
| padding: EdgeInsets.fromLTRB(10.0, 12.0, 10.0, 12.0), |
| child: Icon( |
| Icons.timelapse, |
| color: Colors.white60, |
| size: 25, |
| ), |
| ), |
| ], |
| ); |
| } |
| |
| void _handleTap() { |
| _taskOverlay = OverlayEntry( |
| builder: (_) => TaskOverlayEntry( |
| buildState: widget.buildState, |
| parentContext: context, |
| task: widget.task, |
| taskStatus: status, |
| closeCallback: _closeOverlay, |
| commit: widget.commit, |
| ), |
| ); |
| |
| Overlay.of(context).insert(_taskOverlay); |
| } |
| |
| void _closeOverlay() => _taskOverlay.remove(); |
| } |
| |
| /// Displays the information from [Task] and allows interacting with a [Task]. |
| /// |
| /// This is intended to be inserted in an [OverlayEntry] as it requires |
| /// [closeCallback] that will remove the widget from the tree. |
| class TaskOverlayEntry extends StatelessWidget { |
| const TaskOverlayEntry({ |
| Key key, |
| @required this.parentContext, |
| @required this.task, |
| @required this.taskStatus, |
| @required this.closeCallback, |
| @required this.buildState, |
| this.commit, |
| }) : assert(parentContext != null), |
| assert(buildState != null), |
| assert(task != null), |
| assert(closeCallback != null), |
| super(key: key); |
| |
| /// The parent context that has the size of the whole screen |
| final BuildContext parentContext; |
| |
| /// A reference to the [FlutterBuildState] for performing operations on this [Task]. |
| final FlutterBuildState buildState; |
| |
| /// The [Task] to display in the overlay |
| final Task task; |
| |
| /// [Commit] for cirrus tasks to show log. |
| final Commit commit; |
| |
| /// [Task.status] modified to take into account [Task.attempts] to create |
| /// a more descriptive status. |
| final String taskStatus; |
| |
| /// This callback removes the parent overlay from the widget tree. |
| /// |
| /// On a click that is outside the area of the overlay (the rest of the screen), |
| /// this callback is called closing the overlay. |
| final void Function() closeCallback; |
| |
| @override |
| Widget build(BuildContext context) { |
| final RenderBox renderBox = parentContext.findRenderObject(); |
| final Offset offsetLeft = renderBox.localToGlobal(Offset.zero); |
| |
| return Stack( |
| children: <Widget>[ |
| /// This is a focus container to emphasize the [TaskBox] that this |
| /// [Overlay] is currently showing information from. |
| Positioned( |
| top: offsetLeft.dy, |
| left: offsetLeft.dx, |
| width: renderBox.size.width, |
| height: renderBox.size.height, |
| child: Container( |
| color: Colors.white70, |
| key: const Key('task-overlay-key'), |
| ), |
| ), |
| // This is the area a user can click (the rest of the screen) to close the overlay. |
| GestureDetector( |
| onTap: closeCallback, |
| child: Container( |
| width: MediaQuery.of(parentContext).size.width, |
| height: MediaQuery.of(parentContext).size.height, |
| // Color must be defined otherwise the container can't be clicked on |
| color: Colors.transparent, |
| ), |
| ), |
| Positioned( |
| width: 350, |
| // Move this overlay to be where the parent is |
| top: offsetLeft.dy + (renderBox.size.height / 2), |
| left: offsetLeft.dx + (renderBox.size.width / 2), |
| child: TaskOverlayContents( |
| showSnackbarCallback: Scaffold.of(parentContext).showSnackBar, |
| buildState: buildState, |
| task: task, |
| taskStatus: taskStatus, |
| commit: commit, |
| closeCallback: closeCallback, |
| ), |
| ), |
| ], |
| ); |
| } |
| } |
| |
| /// Displays the information from [Task] and allows interacting with a [Task]. |
| /// |
| /// This is intended to be inserted in [TaskOverlayEntry]. |
| /// |
| /// Offers the functionality of opening the log for this [Task] and rerunning |
| /// this [Task] through the build system. |
| class TaskOverlayContents extends StatelessWidget { |
| const TaskOverlayContents({ |
| Key key, |
| @required this.showSnackbarCallback, |
| @required this.buildState, |
| @required this.task, |
| @required this.taskStatus, |
| @required this.closeCallback, |
| this.commit, |
| }) : assert(showSnackbarCallback != null), |
| assert(buildState != null), |
| assert(task != null), |
| super(key: key); |
| |
| final ScaffoldFeatureController<SnackBar, SnackBarClosedReason> Function( |
| SnackBar) showSnackbarCallback; |
| |
| /// A reference to the [FlutterBuildState] for performing operations on this [Task]. |
| final FlutterBuildState buildState; |
| |
| /// The [Task] to display in the overlay |
| final Task task; |
| |
| /// [Task.status] modified to take into account [Task.attempts] to create |
| /// a more descriptive status. |
| final String taskStatus; |
| |
| /// [Commit] for cirrus tasks to show log. |
| final Commit commit; |
| |
| /// This callback removes the parent overlay from the widget tree. |
| /// |
| /// This is used in this scope to close this overlay on redirection to view |
| /// the agent for this task in the agent dashboard. |
| final void Function() closeCallback; |
| |
| @visibleForTesting |
| static const String rerunErrorMessage = 'Failed to rerun task.'; |
| @visibleForTesting |
| static const String rerunSuccessMessage = |
| 'Devicelab is rerunning the task. This can take a minute to propagate.'; |
| @visibleForTesting |
| static const Duration rerunSnackbarDuration = Duration(seconds: 15); |
| @visibleForTesting |
| static const String downloadLogErrorMessage = 'Failed to download task log.'; |
| @visibleForTesting |
| static const Duration downloadLogSnackbarDuration = Duration(seconds: 15); |
| |
| /// A lookup table to define the [Icon] for this [taskStatus]. |
| static const Map<String, Icon> statusIcon = <String, Icon>{ |
| TaskBox.statusFailed: Icon(Icons.clear, color: Colors.red, size: 32), |
| TaskBox.statusNew: Icon(Icons.new_releases, color: Colors.blue, size: 32), |
| TaskBox.statusInProgress: |
| Icon(Icons.autorenew, color: Colors.blue, size: 32), |
| TaskBox.statusSucceeded: |
| Icon(Icons.check_circle, color: Colors.green, size: 32), |
| TaskBox.statusSucceededButFlaky: Icon(Icons.check_circle_outline, size: 32), |
| TaskBox.statusUnderperformed: |
| Icon(Icons.new_releases, color: Colors.orange, size: 32), |
| TaskBox.statusUnderperformedInProgress: |
| Icon(Icons.autorenew, color: Colors.orange, size: 32), |
| }; |
| |
| @override |
| Widget build(BuildContext context) { |
| final DateTime createTime = |
| DateTime.fromMillisecondsSinceEpoch(task.createTimestamp.toInt()); |
| final DateTime startTime = |
| DateTime.fromMillisecondsSinceEpoch(task.startTimestamp.toInt()); |
| final DateTime endTime = |
| DateTime.fromMillisecondsSinceEpoch(task.endTimestamp.toInt()); |
| |
| final Duration queueDuration = startTime.difference(createTime); |
| final Duration runDuration = endTime.difference(startTime); |
| |
| return Card( |
| child: Column( |
| mainAxisSize: MainAxisSize.max, |
| children: <Widget>[ |
| ListTile( |
| leading: |
| Tooltip(message: taskStatus, child: statusIcon[taskStatus]), |
| title: SelectableText(task.name), |
| subtitle: isDevicelab(task) |
| ? Text('Attempts: ${task.attempts}\n' |
| 'Run time: ${runDuration.inMinutes} minutes\n' |
| 'Queue time: ${queueDuration.inSeconds} seconds\n' |
| 'Flaky: ${task.isFlaky}') |
| : const Text('Task was run outside of devicelab'), |
| contentPadding: const EdgeInsets.all(16.0), |
| ), |
| if (isDevicelab(task)) TaskAttemptSummary(task: task), |
| ButtonBar( |
| children: <Widget>[ |
| if (isDevicelab(task)) |
| FlatButton( |
| child: Text(task.reservedForAgentId), |
| onPressed: () { |
| // Close the current overlay |
| closeCallback(); |
| |
| // Open the agent dashboard |
| Navigator.pushNamed( |
| context, |
| AgentDashboardPage.routeName, |
| arguments: task.reservedForAgentId, |
| ); |
| }, |
| ), |
| ProgressButton( |
| defaultWidget: const Text('Log'), |
| progressWidget: const CircularProgressIndicator(), |
| width: 60, |
| height: 50, |
| onPressed: _viewLog, |
| animate: false, |
| ), |
| if (isDevicelab(task)) |
| ProgressButton( |
| defaultWidget: const Text('Rerun'), |
| progressWidget: const CircularProgressIndicator(), |
| width: 70, |
| height: 50, |
| onPressed: _rerunTask, |
| animate: false, |
| ), |
| ], |
| ), |
| ], |
| ), |
| ); |
| } |
| |
| Future<void> _rerunTask() async { |
| final bool success = await buildState.rerunTask(task); |
| final Text snackbarText = success |
| ? const Text(rerunSuccessMessage) |
| : const Text(rerunErrorMessage); |
| showSnackbarCallback( |
| SnackBar( |
| content: snackbarText, |
| duration: rerunSnackbarDuration, |
| ), |
| ); |
| } |
| |
| /// If [task] is in the devicelab, download the log. Otherwise, open the |
| /// url closest to where the log will be. |
| /// |
| /// If a devicelab log fails to download, show an error snackbar. |
| Future<void> _viewLog() async { |
| if (isDevicelab(task)) { |
| final bool success = await buildState.downloadLog(task, commit); |
| |
| if (!success) { |
| /// Only show [Snackbar] on failure since the user's device will |
| /// indicate a download has been made. |
| showSnackbarCallback( |
| const SnackBar( |
| content: Text(downloadLogErrorMessage), |
| duration: rerunSnackbarDuration, |
| ), |
| ); |
| } |
| |
| return; |
| } |
| |
| /// Tasks outside of devicelab have public logs that we just redirect to. |
| launch(logUrl(task, commit: commit)); |
| } |
| } |