| // Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file | |
| // for details. 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 'dart:convert'; | |
| import 'dart:io'; | |
| import 'package:analysis_server_plugin/src/correction/performance.dart'; | |
| import 'package:collection/collection.dart'; | |
| String escape(String? text) => text == null ? '' : htmlEscape.convert(text); | |
| /// Formats [bytes] using binary units, e.g. `1.50 MiB`. | |
| String printBytes(int bytes) { | |
| const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; | |
| var value = bytes.toDouble(); | |
| var unitIndex = 0; | |
| while (value >= 1024 && unitIndex < units.length - 1) { | |
| value /= 1024; | |
| unitIndex++; | |
| } | |
| if (unitIndex == 0) { | |
| return '$bytes ${units[unitIndex]}'; | |
| } | |
| var fractionDigits = value >= 100 | |
| ? 0 | |
| : value >= 10 | |
| ? 1 | |
| : 2; | |
| return '${value.toStringAsFixed(fractionDigits)} ${units[unitIndex]}'; | |
| } | |
| String printMilliseconds(int? value) => value != null ? '$value ms' : ''; | |
| String printPercentage(num value, [int fractionDigits = 1]) => | |
| '${(value * 100).toStringAsFixed(fractionDigits)}%'; | |
| /// An entity that knows how to serve itself over http. | |
| abstract class Page { | |
| final StringBuffer buf = StringBuffer(); | |
| final String id; | |
| final String title; | |
| final String? description; | |
| new(this.id, this.title, {this.description}); | |
| // We could make this absolute which would make it work from multi-path | |
| // routes, but that also breaks it when serving through certain proxy servers | |
| // which add prefix segments to the path. | |
| // | |
| // Really, this should be a function that gives you the path from another | |
| // page. | |
| String get path => id; | |
| Future<void> asyncDiv(void Function() gen, {String? classes}) async { | |
| if (classes != null) { | |
| buf.writeln('<div class="$classes">'); | |
| } else { | |
| buf.writeln('<div>'); | |
| } | |
| // TODO(brianwilkerson): Determine if await is necessary, if so, change the | |
| // return type of [gen] to `Future<void>`. | |
| await (gen() as dynamic); | |
| buf.writeln('</div>'); | |
| } | |
| void blankslate(String str) { | |
| div(() => buf.writeln(str), classes: 'blankslate'); | |
| } | |
| String? contentDispositionString(Map<String, String> params) => null; | |
| ContentType contentType(Map<String, String> params) => ContentType.html; | |
| void div(void Function() gen, {String? classes}) { | |
| if (classes != null) { | |
| buf.writeln('<div class="$classes">'); | |
| } else { | |
| buf.writeln('<div>'); | |
| } | |
| gen(); | |
| buf.writeln('</div>'); | |
| } | |
| Future<String> generate(Map<String, String> params) async { | |
| buf.clear(); | |
| await generatePage(params); | |
| return buf.toString(); | |
| } | |
| Future<void> generatePage(Map<String, String> params); | |
| void h1(String text, {String? classes}) { | |
| if (classes != null) { | |
| buf.writeln('<h1 class="$classes">${escape(text)}</h1>'); | |
| } else { | |
| buf.writeln('<h1>${escape(text)}</h1>'); | |
| } | |
| } | |
| void h2(String text) { | |
| buf.writeln('<h2>${escape(text)}</h2>'); | |
| } | |
| void h3(String text, {bool raw = false}) { | |
| buf.writeln('<h3>${raw ? text : escape(text)}</h3>'); | |
| } | |
| void h4(String text, {bool raw = false}) { | |
| buf.writeln('<h4>${raw ? text : escape(text)}</h4>'); | |
| } | |
| void inputList<T>(Iterable<T> items, void Function(T item) gen) { | |
| buf.writeln('<select size="8" style="width: 100%">'); | |
| for (var item in items) { | |
| buf.write('<option>'); | |
| gen(item); | |
| buf.write('</option>'); | |
| } | |
| buf.writeln('</select>'); | |
| } | |
| bool isCurrentPage(String pathToTest) => path == pathToTest; | |
| void p(String text, {String? style, bool raw = false, String? classes}) { | |
| var c = classes == null ? '' : ' class="$classes"'; | |
| if (style != null) { | |
| buf.writeln('<p$c style="$style">${raw ? text : escape(text)}</p>'); | |
| } else { | |
| buf.writeln('<p$c>${raw ? text : escape(text)}</p>'); | |
| } | |
| } | |
| void pre(void Function() gen, {String? classes}) { | |
| if (classes != null) { | |
| buf.write('<pre class="$classes">'); | |
| } else { | |
| buf.write('<pre>'); | |
| } | |
| gen(); | |
| buf.writeln('</pre>'); | |
| } | |
| void prettyJson(Object? data) { | |
| const jsonEncoder = JsonEncoder.withIndent(' '); | |
| pre(() { | |
| buf.write(jsonEncoder.convert(data)); | |
| }); | |
| } | |
| void ul<T>(Iterable<T> items, void Function(T item) gen, {String? classes}) { | |
| buf.writeln('<ul${classes == null ? '' : ' class=$classes'}>'); | |
| for (var item in items) { | |
| buf.write('<li>'); | |
| gen(item); | |
| buf.write('</li>'); | |
| } | |
| buf.writeln('</ul>'); | |
| } | |
| } | |
| mixin PerformanceChartMixin on Page { | |
| void drawChart(List<RequestPerformance> items) { | |
| buf.writeln( | |
| '<div id="chart-div" style="width: 700px; height: 300px; padding-bottom: 30px;"></div>', | |
| ); | |
| var rowData = StringBuffer(); | |
| for (var i = items.length - 1; i >= 0; i--) { | |
| var item = items[i]; | |
| if (rowData.isNotEmpty) { | |
| rowData.write(','); | |
| } | |
| var latency = item.requestLatency ?? 0; | |
| var totalTime = item.performance.elapsed.inMilliseconds; | |
| var totalAdditional = item.additionalTimings?.totalTime ?? 0; | |
| var totalExcludingAdditional = totalTime - totalAdditional; | |
| // label, latency, time-ex-additional, additional1, additional2 | |
| // [' ', 21.0, 101.5] | |
| rowData.write("[' ', $latency, $totalExcludingAdditional, "); | |
| // Enumerate in enum order so legend matches. | |
| for (var kind in RequestPerformanceAdditionalTimingKind.values) { | |
| var time = | |
| item.additionalTimings?.timingsInMilliseconds[kind.name] ?? 0; | |
| rowData.write('$time ,'); | |
| } | |
| rowData.write(']'); | |
| } | |
| buf.writeln(''' | |
| <script type="text/javascript"> | |
| google.charts.load('current', {'packages':['bar']}); | |
| google.charts.setOnLoadCallback(drawChart); | |
| function drawChart() { | |
| var data = google.visualization.arrayToDataTable([ | |
| [ 'Request', 'Latency', 'Time', ${RequestPerformanceAdditionalTimingKind.values.map((kind) => jsonEncode(kind.name)).join(', ')} ], | |
| $rowData | |
| ]); | |
| var options = { | |
| bars: 'vertical', | |
| vAxis: {format: 'decimal'}, | |
| height: 300, | |
| isStacked: true, | |
| series: { | |
| 0: { color: '#C0C0C0' }, // Grey, latency | |
| 1: { color: '#4285f4' }, // Blue, request time | |
| 2: { color: '#F4B142' }, // Orange, first additional (analysis) | |
| } | |
| }; | |
| var chart = new google.charts.Bar(document.getElementById('chart-div')); | |
| chart.draw(data, google.charts.Bar.convertOptions(options)); | |
| } | |
| </script> | |
| '''); | |
| } | |
| } | |
| abstract interface class PostablePage { | |
| /// Handles a HTTP POST and returns a destination path to redirect to. | |
| Future<String> handlePost(Map<String, String> queryParameters); | |
| } | |
| /// Contains a collection of Pages. | |
| abstract class Site { | |
| final String title; | |
| final List<Page> pages = []; | |
| new(this.title); | |
| String get customCss => ''; | |
| Page createExceptionPage(String message, StackTrace trace); | |
| Page createUnknownPage(String unknownPath); | |
| Future<void> handleGetRequest(HttpRequest request) async { | |
| var path = request.uri.path; | |
| if (path == '/') { | |
| unawaited(respondRedirect(request, pages.first.path)); | |
| return; | |
| } | |
| await _tryHandleRequest(request, (response, queryParameters) async { | |
| var page = _getPage(path); | |
| if (page == null) { | |
| await respond(request, createUnknownPage(path), HttpStatus.notFound); | |
| return; | |
| } | |
| response.headers.contentType = page.contentType(queryParameters); | |
| var contentDispositionString = page.contentDispositionString( | |
| queryParameters, | |
| ); | |
| if (contentDispositionString != null) { | |
| response.headers.add('Content-Disposition', contentDispositionString); | |
| } | |
| response.write(await page.generate(queryParameters)); | |
| }); | |
| } | |
| Future<void> handlePostRequest(HttpRequest request) async { | |
| var path = request.uri.path; | |
| await _tryHandleRequest(request, (response, queryParameters) async { | |
| var page = _getPage(path); | |
| if (page == null) { | |
| await respond(request, createUnknownPage(path), HttpStatus.notFound); | |
| return; | |
| } else if (page is PostablePage) { | |
| // For simplicitly we only support POSTs that redirect back to a GET at | |
| // the end and we use query parameters on the URL and don't process | |
| // encoded request bodies. | |
| var destinationPath = await (page as PostablePage).handlePost( | |
| queryParameters, | |
| ); | |
| await respondRedirect(request, destinationPath); | |
| } else { | |
| throw 'Method not supported'; | |
| } | |
| }); | |
| } | |
| Future<void> handleWebSocketRequest(HttpRequest request) async { | |
| var path = request.uri.path; | |
| await _tryHandleRequest(request, (response, queryParameters) async { | |
| var page = _getPage(path.substring(1)); | |
| if (page == null) { | |
| await respond(request, createUnknownPage(path), HttpStatus.notFound); | |
| return; | |
| } else if (page is WebSocketPage) { | |
| var webSocket = await WebSocketTransformer.upgrade(request); | |
| await (page as WebSocketPage).handleWebSocket(webSocket); | |
| await webSocket.done; | |
| } else { | |
| throw 'Method not supported'; | |
| } | |
| }); | |
| } | |
| Future<void> respond( | |
| HttpRequest request, | |
| Page page, [ | |
| int code = HttpStatus.ok, | |
| ]) async { | |
| var response = request.response; | |
| response.statusCode = code; | |
| response.headers.contentType = ContentType.html; | |
| response.write(await page.generate(request.uri.queryParameters)); | |
| await response.close(); | |
| } | |
| Future<void> respondJson( | |
| HttpRequest request, | |
| Map<String, Object> json, [ | |
| int code = HttpStatus.ok, | |
| ]) async { | |
| var response = request.response; | |
| response.statusCode = code; | |
| response.headers.contentType = ContentType.json; | |
| response.write(jsonEncode(json)); | |
| await response.close(); | |
| } | |
| Future<void> respondOk( | |
| HttpRequest request, { | |
| int code = HttpStatus.ok, | |
| }) async { | |
| if (request.headers.contentType?.subType == 'json') { | |
| return respondJson(request, {'success': true}, code); | |
| } | |
| var response = request.response; | |
| response.statusCode = code; | |
| await response.close(); | |
| } | |
| Future<void> respondRedirect(HttpRequest request, String pathFragment) async { | |
| var response = request.response; | |
| response.statusCode = HttpStatus.movedTemporarily; | |
| await response.redirect(request.uri.resolve(pathFragment)); | |
| } | |
| /// Finds the [Page] that should handle requests to [path]. | |
| Page? _getPage(String path) { | |
| path = path.startsWith('/') ? path.substring(1) : path; | |
| return pages.firstWhereOrNull((page) => page.path == path); | |
| } | |
| /// Calls the request handler [handler] and catches unhandled errors to return | |
| /// an exception page. | |
| Future<void> _tryHandleRequest( | |
| HttpRequest request, | |
| Future<void> Function(HttpResponse, Map<String, String>) handler, | |
| ) async { | |
| var response = request.response; | |
| var queryParameters = request.uri.queryParameters; | |
| try { | |
| await handler(response, queryParameters); | |
| unawaited(response.close()); | |
| return; | |
| } catch (e, st) { | |
| try { | |
| await respond( | |
| request, | |
| createExceptionPage('$e', st), | |
| HttpStatus.internalServerError, | |
| ); | |
| } catch (e, st) { | |
| var response = request.response; | |
| try { | |
| response.statusCode = HttpStatus.internalServerError; | |
| response.headers.contentType = ContentType.text; | |
| response.write('$e\n\n$st'); | |
| } catch (_) { | |
| // We may fail to send the above if the request that errored had | |
| // already caused the HTTP headers to be flushed (this can happen | |
| // after a WebSocket upgrade, for example). | |
| } | |
| unawaited(response.close()); | |
| } | |
| } | |
| } | |
| } | |
| abstract interface class WebSocketPage { | |
| /// Handles a WebSocket connection to the page. | |
| Future<void> handleWebSocket(WebSocket socket); | |
| } |