| // Copyright 2019 The Flutter 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 'dart:async'; |
| |
| import 'package:cocoon_common/rpc_model.dart'; |
| import 'package:cocoon_common/task_status.dart'; |
| import 'package:flutter/material.dart'; |
| import 'package:flutter/services.dart'; |
| import 'package:flutter_app_icons/flutter_app_icons.dart'; |
| import 'package:provider/provider.dart'; |
| import 'package:truncate/truncate.dart'; |
| import 'package:url_launcher/url_launcher.dart'; |
| |
| import '../dashboard_navigation_drawer.dart'; |
| import '../logic/task_grid_filter.dart'; |
| import '../service/cocoon.dart'; |
| import '../state/build.dart'; |
| import '../widgets/app_bar.dart'; |
| import '../widgets/error_brook_watcher.dart'; |
| import '../widgets/filter_property_sheet.dart'; |
| import '../widgets/task_box.dart'; |
| import '../widgets/task_grid.dart'; |
| |
| /// Shows information about the current build status of flutter/flutter. |
| /// |
| /// The tree's current build status is reflected in [AppBar]. |
| /// The results from tasks run on individual commits is shown in [TaskGrid]. |
| class BuildDashboardPage extends StatefulWidget { |
| const BuildDashboardPage({super.key, this.queryParameters}); |
| |
| static const String routeSegment = 'build'; |
| static const String routeName = '/$routeSegment'; |
| |
| final Map<String, String>? queryParameters; |
| |
| @override |
| State createState() => BuildDashboardPageState(); |
| } |
| |
| class BuildDashboardPageState extends State<BuildDashboardPage> { |
| TaskGridFilter? _filter; |
| TaskGridFilter? _settingsBasis; |
| bool _smallScreen = false; |
| double screenWidthThreshold = 600; |
| final _flutterAppIconsPlugin = FlutterAppIcons(); |
| |
| /// Current Flutter repository to view. |
| String? repo; |
| |
| /// Git branch in [repo] to view. |
| /// |
| /// The frontend will update default branches based on [defaultBranches]. This enables users to easily switch from |
| /// master on one repo, to main for a different repo. |
| String? branch; |
| |
| /// Example branch for [truncate]. |
| /// |
| /// Include the ellipsis to get the correct length that should be truncated at. |
| static const _exampleBranch = 'flutter-3.12-candidate.23...'; |
| |
| @override |
| void initState() { |
| super.initState(); |
| if (widget.queryParameters != null) { |
| repo = widget.queryParameters!['repo'] ?? 'flutter'; |
| branch = widget.queryParameters!['branch'] ?? 'master'; |
| } |
| repo ??= 'flutter'; |
| branch ??= 'master'; |
| if (branch == 'master' || branch == 'main') { |
| branch = defaultBranches[repo!]; |
| } |
| _filter = TaskGridFilter.fromMap(widget.queryParameters); |
| _filter!.addListener(() { |
| setState(() {}); |
| }); |
| } |
| |
| @override |
| void dispose() { |
| unawaited(_flutterAppIconsPlugin.setIcon(icon: 'favicon.png')); |
| super.dispose(); |
| } |
| |
| /// Convert the fields from this class into a URL. |
| /// |
| /// This enables bookmarking state specific values, like [repo]. |
| void _updateNavigation(BuildContext context) { |
| var queryParameters = <String, String>{}; |
| if (widget.queryParameters != null) { |
| queryParameters.addAll(widget.queryParameters!); |
| } |
| if (_filter != null) { |
| queryParameters = _filter!.toMap(initialMap: queryParameters); |
| } |
| |
| queryParameters['repo'] = repo!; |
| |
| queryParameters['branch'] = branch!; |
| |
| final uri = Uri( |
| path: BuildDashboardPage.routeName, |
| queryParameters: queryParameters, |
| ); |
| |
| final buildState = Provider.of<BuildState>(context, listen: false); |
| buildState.updateCurrentRepoBranch(repo!, branch!); |
| unawaited(Navigator.pushNamed(context, uri.toString())); |
| } |
| |
| void _removeSettingsDialog() { |
| setState(() { |
| _settingsBasis = null; |
| }); |
| } |
| |
| void _showSettingsDialog() { |
| setState(() { |
| _settingsBasis = TaskGridFilter.fromMap(_filter!.toMap()); |
| }); |
| } |
| |
| Widget _settingsDialog(BuildContext context, BuildState buildState) { |
| final theme = Theme.of(context); |
| final backgroundColor = theme.brightness == Brightness.dark |
| ? Colors.grey[800]! |
| : Colors.white; |
| return Center( |
| child: Container( |
| decoration: BoxDecoration( |
| color: backgroundColor.withAlpha(0xe0), |
| borderRadius: BorderRadius.circular(20.0), |
| ), |
| child: Material( |
| color: Colors.transparent, |
| child: FocusTraversalGroup( |
| child: Column( |
| mainAxisSize: MainAxisSize.min, |
| children: <Widget>[ |
| if (_smallScreen) |
| ..._buildRepositorySelectionWidgets(context, buildState), |
| AnimatedBuilder( |
| animation: buildState, |
| builder: (context, child) { |
| final isAuthenticated = |
| buildState.authService.isAuthenticated; |
| return TextButton( |
| onPressed: isAuthenticated |
| ? buildState.refreshGitHubCommits |
| : null, |
| child: child!, |
| ); |
| }, |
| child: const Text('Refresh GitHub Commits'), |
| ), |
| Row( |
| mainAxisSize: MainAxisSize.min, |
| children: [Center(child: FilterPropertySheet(_filter))], |
| ), |
| Row( |
| mainAxisSize: MainAxisSize.min, |
| mainAxisAlignment: MainAxisAlignment.center, |
| children: <Widget>[ |
| TextButton( |
| onPressed: _filter!.isDefault |
| ? null |
| : () => _filter!.reset(), |
| child: const Text('Defaults'), |
| ), |
| TextButton( |
| onPressed: _filter == _settingsBasis |
| ? null |
| : () => _updateNavigation(context), |
| child: const Text('Apply'), |
| ), |
| TextButton( |
| child: const Text('Cancel'), |
| onPressed: () { |
| if (_filter != _settingsBasis) { |
| _filter!.reset(); |
| _filter!.applyMap(_settingsBasis!.toMap()); |
| } |
| _removeSettingsDialog(); |
| }, |
| ), |
| ], |
| ), |
| ], |
| ), |
| ), |
| ), |
| ), |
| ); |
| } |
| |
| /// List of widgets for selecting slug and branch for configuring the build view. |
| List<Widget> _buildRepositorySelectionWidgets( |
| BuildContext context, |
| BuildState buildState, |
| ) { |
| final theme = Theme.of(context); |
| return <Widget>[ |
| const Padding( |
| padding: EdgeInsets.only(top: 22, left: 5, right: 5), |
| child: Text('repo: ', textAlign: TextAlign.center), |
| ), |
| DropdownButton<String>( |
| key: const Key('repo dropdown'), |
| isExpanded: _smallScreen, |
| value: buildState.currentRepo, |
| icon: const Padding( |
| padding: EdgeInsets.only(top: 8), |
| child: Icon(Icons.arrow_downward), |
| ), |
| iconSize: 24, |
| elevation: 16, |
| underline: Container(height: 2), |
| onChanged: (String? selectedRepo) { |
| repo = selectedRepo; |
| _updateNavigation(context); |
| }, |
| items: buildState.repos.map<DropdownMenuItem<String>>((String value) { |
| return DropdownMenuItem<String>( |
| value: value, |
| child: Padding( |
| padding: const EdgeInsets.only(top: 11), |
| child: Center( |
| child: Text( |
| value, |
| style: theme.primaryTextTheme.bodyLarge, |
| textAlign: TextAlign.center, |
| ), |
| ), |
| ), |
| ); |
| }).toList(), |
| ), |
| const Padding( |
| padding: EdgeInsets.only(top: 22, left: 5, right: 5), |
| child: Text('branch: ', textAlign: TextAlign.center), |
| ), |
| DropdownButton<String>( |
| key: const Key('branch dropdown'), |
| isExpanded: _smallScreen, |
| value: buildState.currentBranch, |
| icon: const Padding( |
| padding: EdgeInsets.only(top: 8.0), |
| child: Icon(Icons.arrow_downward), |
| ), |
| iconSize: 24, |
| elevation: 16, |
| underline: Container(height: 2), |
| onChanged: (String? selectedBranch) { |
| branch = selectedBranch; |
| _updateNavigation(context); |
| }, |
| items: [ |
| DropdownMenuItem<String>( |
| value: buildState.currentBranch, |
| child: Padding( |
| padding: const EdgeInsets.only(top: 9.0), |
| child: Center( |
| child: Text( |
| truncate(buildState.currentBranch, _exampleBranch.length), |
| style: theme.primaryTextTheme.bodyLarge, |
| ), |
| ), |
| ), |
| ), |
| ...buildState.branches |
| .where((b) => b.reference != buildState.currentBranch) |
| .map((b) { |
| return DropdownMenuItem( |
| value: b.reference, |
| child: Padding( |
| padding: const EdgeInsets.only(top: 9.0), |
| child: Center( |
| child: Text( |
| '${(b.channel != 'master') ? '${b.channel}: ' : ''}${truncate(b.reference, _exampleBranch.length)}', |
| style: theme.primaryTextTheme.bodyLarge, |
| ), |
| ), |
| ), |
| ); |
| }), |
| ], |
| ), |
| const Padding(padding: EdgeInsets.symmetric(horizontal: 4)), |
| ]; |
| } |
| |
| PopupMenuItem<String> _getTaskKeyEntry({ |
| required Widget box, |
| required String description, |
| }) { |
| return PopupMenuItem<String>( |
| value: description, |
| child: Wrap( |
| crossAxisAlignment: WrapCrossAlignment.center, |
| children: <Widget>[ |
| const SizedBox(width: 10.0), |
| SizedBox.fromSize(size: Size.square(TaskBox.of(context)), child: box), |
| const SizedBox(width: 10.0), |
| Text(description), |
| ], |
| ), |
| ); |
| } |
| |
| List<PopupMenuEntry<String>> _getTaskKey(bool isDark) { |
| final key = <PopupMenuEntry<String>>[]; |
| |
| for (final status in TaskBox.statusColor.keys) { |
| key.add( |
| _getTaskKeyEntry( |
| box: Container(color: TaskBox.statusColor[status]), |
| description: status.value, |
| ), |
| ); |
| key.add(const PopupMenuDivider()); |
| } |
| |
| key.add( |
| _getTaskKeyEntry( |
| box: Container(color: TaskBox.statusColorFailedAndRerunning), |
| description: 'Failed but rerunning', |
| ), |
| ); |
| key.add(const PopupMenuDivider()); |
| |
| key.add( |
| _getTaskKeyEntry( |
| box: Center( |
| child: Container( |
| width: TaskBox.of(context) * 0.8, |
| height: TaskBox.of(context) * 0.8, |
| decoration: BoxDecoration( |
| border: Border.all( |
| width: 2.0, |
| color: isDark ? Colors.white : Colors.black, |
| ), |
| ), |
| ), |
| ), |
| description: 'Flaky', |
| ), |
| ); |
| |
| key.add(const PopupMenuDivider()); |
| |
| key.add( |
| _getTaskKeyEntry( |
| box: const Center( |
| child: Text( |
| '!', |
| style: TextStyle(fontSize: 24.0, fontWeight: FontWeight.bold), |
| ), |
| ), |
| description: 'Ran more than once', |
| ), |
| ); |
| |
| key.add(const PopupMenuDivider()); |
| |
| return key; |
| } |
| |
| String _getStatusTitle(BuildState? buildState) { |
| if (buildState == null || buildState.isTreeBuilding == null) { |
| return 'Loading...'; |
| } |
| if (buildState.isTreeBuilding!) { |
| return 'Tree is Open'; |
| } else { |
| if (buildState.failingTasks.isNotEmpty) { |
| return 'Tree is Closed (failing: ${buildState.failingTasks.join(', ')})'; |
| } else { |
| return 'Tree is Closed'; |
| } |
| } |
| } |
| |
| void _updatePage(BuildContext context, String newRepo, String newBranch) { |
| repo = newRepo; |
| branch = newBranch; |
| _updateNavigation(context); |
| } |
| |
| @override |
| Widget build(BuildContext context) { |
| final isDark = Theme.of(context).brightness == Brightness.dark; |
| final queryData = MediaQuery.of(context); |
| final devicePixelRatio = queryData.devicePixelRatio; |
| _smallScreen = |
| queryData.size.width * devicePixelRatio < screenWidthThreshold; |
| |
| /// Color of [AppBar] based on [buildState.isTreeBuilding]. |
| final colorTable = <bool?, Color?>{ |
| null: Colors.grey[850], |
| false: isDark ? Colors.red[800] : Colors.red, |
| true: isDark ? Colors.green[800] : Colors.green, |
| }; |
| |
| final flutterIssueUrl = Uri.parse( |
| 'https://github.com/flutter/flutter/issues/new?labels=team-infra', |
| ); |
| final flutterInfraTicketQueue = Uri.parse( |
| 'https://github.com/orgs/flutter/projects/81', |
| ); |
| final buildState = Provider.of<BuildState>(context); |
| buildState.updateCurrentRepoBranch(repo!, branch!); |
| return CallbackShortcuts( |
| bindings: <ShortcutActivator, VoidCallback>{ |
| const SingleActivator(LogicalKeyboardKey.arrowUp): () => |
| _updatePage(context, 'flutter', 'master'), |
| const SingleActivator(LogicalKeyboardKey.arrowLeft): () => |
| _updatePage(context, 'cocoon', 'main'), |
| const SingleActivator(LogicalKeyboardKey.arrowRight): () => |
| _updatePage(context, 'packages', 'main'), |
| }, |
| child: Focus( |
| autofocus: true, |
| child: AnimatedBuilder( |
| animation: buildState, |
| builder: (BuildContext context, Widget? _) => Scaffold( |
| appBar: CocoonAppBar( |
| title: Tooltip( |
| message: _getStatusTitle(buildState), |
| child: SelectionArea(child: Text(_getStatusTitle(buildState))), |
| ), |
| backgroundColor: colorTable[buildState.isTreeBuilding], |
| actions: <Widget>[ |
| if (!_smallScreen) |
| ..._buildRepositorySelectionWidgets(context, buildState), |
| IconButton( |
| tooltip: 'Infra Ticket Queue', |
| icon: const Icon(Icons.queue), |
| onPressed: () async { |
| if (await canLaunchUrl(flutterInfraTicketQueue)) { |
| await launchUrl(flutterInfraTicketQueue); |
| } else { |
| throw 'Could not launch $flutterInfraTicketQueue'; |
| } |
| }, |
| ), |
| IconButton( |
| tooltip: 'Report Issue', |
| icon: const Icon(Icons.bug_report), |
| onPressed: () async { |
| if (await canLaunchUrl(flutterIssueUrl)) { |
| await launchUrl(flutterIssueUrl); |
| } else { |
| throw 'Could not launch $flutterIssueUrl'; |
| } |
| }, |
| ), |
| PopupMenuButton<String>( |
| tooltip: 'Task Status Key', |
| child: const Icon(Icons.info_outline), |
| itemBuilder: (BuildContext context) => _getTaskKey(isDark), |
| ), |
| IconButton( |
| tooltip: 'Settings', |
| icon: const Icon(Icons.settings), |
| onPressed: _settingsBasis == null |
| ? _showSettingsDialog |
| : null, |
| ), |
| ], |
| ), |
| body: ErrorBrookWatcher( |
| errors: buildState.errors, |
| child: Stack( |
| children: <Widget>[ |
| SizedBox.expand( |
| child: TaskGridContainer( |
| filter: _filter, |
| useAnimatedLoading: true, |
| schedulePostsubmitBuildForReleaseBranch: () { |
| if (!buildState.authService.isAuthenticated) { |
| return null; |
| } |
| if (buildState.currentRepo != 'flutter') { |
| return null; |
| } |
| if (buildState.currentBranch == 'master') { |
| return null; |
| } |
| return (Commit commit) async { |
| return _schedulePostsubmitBuildForReleaseBranch( |
| context: context, |
| commit: commit, |
| ); |
| }; |
| }(), |
| ), |
| ), |
| if (_settingsBasis != null) |
| _settingsDialog(context, buildState), |
| ], |
| ), |
| ), |
| drawer: const DashboardNavigationDrawer(), |
| ), |
| ), |
| ), |
| ); |
| } |
| |
| Future<void> _schedulePostsubmitBuildForReleaseBranch({ |
| required BuildContext context, |
| required Commit commit, |
| }) async { |
| final buildState = Provider.of<BuildState>(context, listen: false); |
| final response = await buildState.cocoonService.rerunCommit( |
| commitSha: commit.sha, |
| idToken: await buildState.authService.idToken, |
| branch: buildState.currentBranch, |
| repo: buildState.currentRepo, |
| include: {TaskStatus.skipped}, |
| ); |
| if (!context.mounted) { |
| return; |
| } |
| final SnackBar snackBar; |
| if (response.error case final error?) { |
| snackBar = SnackBar(content: Text('Failed to schedule: $error.')); |
| } else { |
| snackBar = SnackBar( |
| content: Text('Scheduled post-submits for commit ${commit.sha}.'), |
| ); |
| } |
| ScaffoldMessenger.of(context).showSnackBar(snackBar); |
| } |
| } |