diff --git a/packages/devtools_app/lib/src/screens/network/network_model.dart b/packages/devtools_app/lib/src/screens/network/network_model.dart index 8a6697fbe65..9ccc3c5217a 100644 --- a/packages/devtools_app/lib/src/screens/network/network_model.dart +++ b/packages/devtools_app/lib/src/screens/network/network_model.dart @@ -29,6 +29,9 @@ abstract class NetworkRequest int? get port; + int? get requestBytes => null; + int? get responseBytes => null; + bool get didFail; /// True if the request hasn't completed yet. @@ -160,6 +163,12 @@ class Socket extends NetworkRequest { @override int get port => _socket.port; + @override + int get requestBytes => writeBytes; + + @override + int get responseBytes => readBytes; + // TODO(kenz): what determines a web socket request failure? @override bool get didFail => false; diff --git a/packages/devtools_app/lib/src/screens/network/network_request_inspector_views.dart b/packages/devtools_app/lib/src/screens/network/network_request_inspector_views.dart index 831b3e52052..aad90c72bf3 100644 --- a/packages/devtools_app/lib/src/screens/network/network_request_inspector_views.dart +++ b/packages/devtools_app/lib/src/screens/network/network_request_inspector_views.dart @@ -17,6 +17,7 @@ import '../../shared/ui/colors.dart'; import '../../shared/ui/common_widgets.dart'; import 'network_controller.dart'; import 'network_model.dart'; +import 'utils/http_utils.dart'; // Approximately double the indent of the expandable tile's title. const _rowIndentPadding = 30.0; @@ -625,6 +626,7 @@ class NetworkRequestOverviewView extends StatelessWidget { } List _buildGeneralRows(BuildContext context) { + final bytes = data.responseBytes; return [ // TODO(kenz): show preview for requests (png, response body, proto) _buildRow( @@ -658,6 +660,14 @@ class NetworkRequestOverviewView extends StatelessWidget { ), const SizedBox(height: defaultSpacing), ], + + _buildRow( + context: context, + title: 'Response Size', + child: _valueText(bytes != null ? formatBytes(bytes) : '-'), + ), + const SizedBox(height: defaultSpacing), + if (data.contentType != null) ...[ _buildRow( context: context, diff --git a/packages/devtools_app/lib/src/screens/network/network_screen.dart b/packages/devtools_app/lib/src/screens/network/network_screen.dart index 5171c5e6736..7e8315520b5 100644 --- a/packages/devtools_app/lib/src/screens/network/network_screen.dart +++ b/packages/devtools_app/lib/src/screens/network/network_screen.dart @@ -30,6 +30,7 @@ import '../../shared/ui/utils.dart'; import 'network_controller.dart'; import 'network_model.dart'; import 'network_request_inspector.dart'; +import 'utils/http_utils.dart'; class NetworkScreen extends Screen { NetworkScreen() : super.fromMetaData(ScreenMetaData.network); @@ -363,6 +364,7 @@ class NetworkRequestsTable extends StatelessWidget { static const statusColumn = StatusColumn(); static const typeColumn = TypeColumn(); static const durationColumn = DurationColumn(); + static const responseSizeColumn = ResponseSizeColumn(); static final timestampColumn = TimestampColumn(); static const actionsColumn = ActionsColumn(); static final columns = >[ @@ -371,6 +373,7 @@ class NetworkRequestsTable extends StatelessWidget { statusColumn, typeColumn, durationColumn, + responseSizeColumn, timestampColumn, actionsColumn, ]; @@ -405,6 +408,20 @@ class NetworkRequestsTable extends StatelessWidget { } } +class ResponseSizeColumn extends ColumnData { + const ResponseSizeColumn() + : super('Size', alignment: ColumnAlignment.right, fixedWidthPx: 90); + + @override + int? getValue(NetworkRequest dataObject) => dataObject.responseBytes; + + @override + String getDisplayValue(NetworkRequest dataObject) { + final bytes = dataObject.responseBytes; + return bytes != null ? formatBytes(bytes) : '-'; + } +} + class AddressColumn extends ColumnData implements ColumnRenderer { AddressColumn() diff --git a/packages/devtools_app/lib/src/screens/network/utils/http_utils.dart b/packages/devtools_app/lib/src/screens/network/utils/http_utils.dart index 9efd5482a73..b4c30797df0 100644 --- a/packages/devtools_app/lib/src/screens/network/utils/http_utils.dart +++ b/packages/devtools_app/lib/src/screens/network/utils/http_utils.dart @@ -29,3 +29,18 @@ int calculateHeadersSize(Map? headers) { // Calculate the byte length of the headers string return utf8.encode(headersString).length; } + +// Output Formats: +// - 512 → "512 B" +// - 2000 → "2.0 kB" +// - 1000000 → "1.0 MB" +// Values are rounded to one decimal place for kB and MB. +// Uses decimal (base-10) units to match Chrome DevTools. +String formatBytes(int? bytes) { + if (bytes == null || bytes < 0) return '-'; + if (bytes < 1000) return '$bytes B'; + if (bytes < 1000 * 1000) { + return '${(bytes / 1000).toStringAsFixed(1)} kB'; + } + return '${(bytes / (1000 * 1000)).toStringAsFixed(1)} MB'; +} diff --git a/packages/devtools_app/lib/src/shared/http/http_request_data.dart b/packages/devtools_app/lib/src/shared/http/http_request_data.dart index 6347ffdbf8d..77a4a6cd965 100644 --- a/packages/devtools_app/lib/src/shared/http/http_request_data.dart +++ b/packages/devtools_app/lib/src/shared/http/http_request_data.dart @@ -227,6 +227,26 @@ class DartIOHttpRequestData extends NetworkRequest { return connectionInfo != null ? connectionInfo[_localPortKey] : null; } + @override + int? get responseBytes { + final contentLength = responseHeaders?['content-length']; + + if (contentLength is String) { + return int.tryParse(contentLength); + } + if (contentLength is List && contentLength.isNotEmpty) { + final first = contentLength.first; + + if (first is int) { + return first; + } + if (first is String) { + return int.tryParse(first); + } + } + return null; + } + /// True if the HTTP request hasn't completed yet, determined by the lack of /// an end time in the response data. @override diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md index 6893cc7d4f4..7f732713318 100644 --- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md +++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md @@ -39,6 +39,9 @@ TODO: Remove this section if there are not any updates. ## Network profiler updates +- Added response size column to the Network tab and displayed response size in the request inspector overview. - + [#9744](https://github.com/flutter/devtools/pull/9744) + - Added a filter setting to hide HTTP-profiler socket data. [#9698](https://github.com/flutter/devtools/pull/9698) ## Logging updates diff --git a/packages/devtools_app/test/shared/http/http_request_data_test.dart b/packages/devtools_app/test/shared/http/http_request_data_test.dart new file mode 100644 index 00000000000..0cb15143ede --- /dev/null +++ b/packages/devtools_app/test/shared/http/http_request_data_test.dart @@ -0,0 +1,70 @@ +import 'package:devtools_app/src/shared/http/http_request_data.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('responseBytes', () { + Map baseJson(Map headers) { + return { + 'method': 'GET', + 'uri': 'https://example.com', + 'status': 200, + 'responseHeaders': headers, + }; + } + + // Verifies parsing when content-length is a string value. + test('parses content-length from string', () { + final request = DartIOHttpRequestData.fromJson( + baseJson({'content-length': '1234'}), + null, // requestPostData not used for this test + null, // responseContent not used for this test + ); + + expect(request.responseBytes, 1234); + }); + + // Verifies parsing when content-length is a list of strings. + test('parses content-length from list of strings', () { + final request = DartIOHttpRequestData.fromJson( + baseJson({'content-length': '5678'}), + null, // requestPostData not used for this test + null, // responseContent not used for this test + ); + + expect(request.responseBytes, 5678); + }); + + // Ensures integer values inside a list are handled correctly. + test('handles integer in list', () { + final request = DartIOHttpRequestData.fromJson( + baseJson({'content-length': '91011'}), + null, // requestPostData not used for this test + null, // responseContent not used for this test + ); + + expect(request.responseBytes, 91011); + }); + + // Returns null when header is missing. + test('returns null for missing header', () { + final request = DartIOHttpRequestData.fromJson( + baseJson({}), // No content-length header + null, // requestPostData not used for this test + null, // responseContent not used for this test + ); + + expect(request.responseBytes, null); + }); + + // Returns null when parsing fails. + test('returns null for invalid value', () { + final request = DartIOHttpRequestData.fromJson( + baseJson({'content-length': 'invalid'}), + null, // requestPostData not used for this test + null, // responseContent not used for this test + ); + + expect(request.responseBytes, null); + }); + }); +} diff --git a/packages/devtools_app/test/shared/http/http_utils_test.dart b/packages/devtools_app/test/shared/http/http_utils_test.dart new file mode 100644 index 00000000000..94853734b4d --- /dev/null +++ b/packages/devtools_app/test/shared/http/http_utils_test.dart @@ -0,0 +1,19 @@ +import 'package:devtools_app/src/screens/network/utils/http_utils.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('formatBytes', () { + // Verifies correct formatting across different unit ranges. + test('formats bytes correctly', () { + expect(formatBytes(512), '512 B'); // bytes + expect(formatBytes(2000), '2.0 kB'); // kilobytes (base-10) + expect(formatBytes(1000000), '1.0 MB'); // megabytes (base-10) + }); + + // Ensures handling of invalid or missing values. + test('handles null and negative values', () { + expect(formatBytes(null), '-'); + expect(formatBytes(-1), '-'); + }); + }); +}