From c9bcf3346b250a422d9d3d00a0755e7b00b9f345 Mon Sep 17 00:00:00 2001 From: Minh Nguyen Date: Wed, 29 Jul 2026 14:44:01 +0800 Subject: [PATCH] Add a self-contained HTML dashboard report Every analysis now writes dashboard.html next to the markdown and JSON reports. The file embeds the report data and renders entirely client side with no external dependencies, so it works offline and from CI artifact browsers. The dashboard shows the total size tiles, the component breakdown as a stacked bar, per-team download sizes with a drill-down into each team's components, modules, and libraries, and searchable tables for modules, libraries, and large files. Light and dark themes follow the system setting with a manual toggle. --- README.md | 3 +- .../main/kotlin/com/grab/sizer/AppSizer.kt | 11 + .../com/grab/sizer/di/AnalyzerComponent.kt | 6 + .../sizer/report/html/HtmlReportWriter.kt | 105 ++++ .../sizer/report/html/dashboard-template.html | 507 ++++++++++++++++++ .../sizer/report/html/HtmlReportWriterTest.kt | 136 +++++ docs/index.md | 3 +- docs/report.md | 26 +- 8 files changed, 791 insertions(+), 6 deletions(-) create mode 100644 app-sizer/src/main/kotlin/com/grab/sizer/report/html/HtmlReportWriter.kt create mode 100644 app-sizer/src/main/resources/com/grab/sizer/report/html/dashboard-template.html create mode 100644 app-sizer/src/test/kotlin/com/grab/sizer/report/html/HtmlReportWriterTest.kt diff --git a/README.md b/README.md index 1bc1cac..5b811cb 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,9 @@ App Sizer provides two flexible integration methods: ## Report Types -App Sizer currently supports three types of reports: +App Sizer currently supports four types of reports: +* HTML dashboard - a single self-contained file per device for interactive local analysis, no server required. * InfluxDB database (1.x) - It is suitable for CI tracking and enabling the creation of customized dashboards (with visualization tools like Grafana). We provide an InfluxDB and Grafana setup; see our [Docker Setup Guide][grafana_docker_doc]. * Markdown table for convenient local analysis. * JSON data for compatibility with other platforms. diff --git a/app-sizer/src/main/kotlin/com/grab/sizer/AppSizer.kt b/app-sizer/src/main/kotlin/com/grab/sizer/AppSizer.kt index 4b36044..96c99a7 100644 --- a/app-sizer/src/main/kotlin/com/grab/sizer/AppSizer.kt +++ b/app-sizer/src/main/kotlin/com/grab/sizer/AppSizer.kt @@ -31,6 +31,8 @@ import com.grab.sizer.di.AnalyzerComponent import com.grab.sizer.utils.InputProvider import com.grab.sizer.utils.Logger import com.grab.sizer.utils.OutputProvider +import com.grab.sizer.utils.log +import java.io.File class AppSizer( private val inputProvider: InputProvider, @@ -67,5 +69,14 @@ class AppSizer( } } } + logReportLocation() + } + + private fun logReportLocation() { + val reportDirectory = File( + outputProvider.provideOutPutDirectory(), + outputProvider.provideProjectInfo().deviceName + ) + logger.log("Reports generated at ${reportDirectory.toPath().toUri()}") } } \ No newline at end of file diff --git a/app-sizer/src/main/kotlin/com/grab/sizer/di/AnalyzerComponent.kt b/app-sizer/src/main/kotlin/com/grab/sizer/di/AnalyzerComponent.kt index 542d6b7..b931e14 100644 --- a/app-sizer/src/main/kotlin/com/grab/sizer/di/AnalyzerComponent.kt +++ b/app-sizer/src/main/kotlin/com/grab/sizer/di/AnalyzerComponent.kt @@ -57,6 +57,7 @@ import com.grab.sizer.report.DatabaseReportWriter import com.grab.sizer.report.MarkdownReportWriter import com.grab.sizer.report.ReportWriter import com.grab.sizer.report.db.DbReportDaoFactory +import com.grab.sizer.report.html.HtmlReportWriter import com.grab.sizer.report.json.JsonReportWriter import com.grab.sizer.utils.InputProvider import com.grab.sizer.utils.Logger @@ -154,6 +155,11 @@ class AnalyzerComponent( projectInfo = outputProvider.provideProjectInfo(), customProperties = outputProvider.provideCustomProperties(), ), + HtmlReportWriter( + outputDirectory = outputProvider.provideOutPutDirectory(), + projectInfo = outputProvider.provideProjectInfo(), + customProperties = outputProvider.provideCustomProperties(), + ), DatabaseReportWriter( reportDaoSet = lazy { DbReportDaoFactory(outputProvider, logger).create() }, projectInfo = outputProvider.provideProjectInfo(), diff --git a/app-sizer/src/main/kotlin/com/grab/sizer/report/html/HtmlReportWriter.kt b/app-sizer/src/main/kotlin/com/grab/sizer/report/html/HtmlReportWriter.kt new file mode 100644 index 0000000..5e9cb07 --- /dev/null +++ b/app-sizer/src/main/kotlin/com/grab/sizer/report/html/HtmlReportWriter.kt @@ -0,0 +1,105 @@ +/* + * MIT License + * + * Copyright (c) 2024. Grabtaxi Holdings Pte Ltd (GRAB), All rights reserved. + * + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE + */ + +package com.grab.sizer.report.html + +import com.google.gson.Gson +import com.grab.sizer.report.CustomProperties +import com.grab.sizer.report.FIELD_KEY_CONTRIBUTOR +import com.grab.sizer.report.FIELD_KEY_OWNER +import com.grab.sizer.report.FIELD_KEY_SIZE +import com.grab.sizer.report.FIELD_KEY_TAG +import com.grab.sizer.report.ProjectInfo +import com.grab.sizer.report.Report +import com.grab.sizer.report.ReportWriter +import com.grab.sizer.report.Row +import java.io.File + +private const val TEMPLATE_RESOURCE = "/com/grab/sizer/report/html/dashboard-template.html" +private const val DATA_PLACEHOLDER = "__DASHBOARD_DATA__" +internal const val DASHBOARD_FILE_NAME = "index.html" + +/** + * Writes all reports of an analysis run into a single self-contained `index.html` + * dashboard, placed next to the markdown and JSON reports of the device. + * + * The dashboard embeds the report data as JSON inside an HTML template; charts are + * rendered client side with inline SVG. The file has no external dependencies (no CDN + * scripts, fonts, or network calls), so it can be opened offline or from a CI artifact + * browser. + * + * [write] is called once per report; the dashboard is regenerated on every call with + * all reports accumulated so far, keeping the [ReportWriter] contract unchanged. The + * last write therefore produces the complete dashboard. + */ +class HtmlReportWriter( + private val outputDirectory: File, + private val projectInfo: ProjectInfo, + private val customProperties: CustomProperties, + private val gson: Gson = Gson(), +) : ReportWriter { + private val reports = LinkedHashMap() + + private val template: String by lazy { + javaClass.getResourceAsStream(TEMPLATE_RESOURCE) + ?.bufferedReader() + ?.use { it.readText() } + ?: throw IllegalStateException("Dashboard template not found: $TEMPLATE_RESOURCE") + } + + override fun write(report: Report) { + reports[report.id] = report + val dashboard = File(File(outputDirectory, projectInfo.deviceName), DASHBOARD_FILE_NAME) + dashboard.parentFile?.mkdirs() + dashboard.writeText(render()) + } + + private fun render(): String = + template.replace(DATA_PLACEHOLDER, gson.toJson(toDashboardData())) + + private fun toDashboardData(): Map = mapOf( + "project" to mapOf( + "name" to projectInfo.projectName, + "version" to projectInfo.versionName, + "variant" to projectInfo.buildType, + "device" to projectInfo.deviceName, + "generatedAt" to System.currentTimeMillis(), + "customProperties" to customProperties, + ), + "reports" to reports.mapValues { (_, report) -> report.rows.map { it.toEntry() } }, + ) + + private fun Row.toEntry(): Map = buildMap { + put("name", stringField(FIELD_KEY_CONTRIBUTOR) ?: name) + put("size", (fields.find { it.name == FIELD_KEY_SIZE }?.value as? Number)?.toLong() ?: 0L) + stringField(FIELD_KEY_OWNER)?.let { put("owner", it) } + stringField(FIELD_KEY_TAG)?.let { put("tag", it) } + } + + private fun Row.stringField(fieldName: String): String? = + fields.find { it.name == fieldName }?.value?.toString() +} diff --git a/app-sizer/src/main/resources/com/grab/sizer/report/html/dashboard-template.html b/app-sizer/src/main/resources/com/grab/sizer/report/html/dashboard-template.html new file mode 100644 index 0000000..216fe10 --- /dev/null +++ b/app-sizer/src/main/resources/com/grab/sizer/report/html/dashboard-template.html @@ -0,0 +1,507 @@ + + + + + +App Size Dashboard + + + + + +
+

App Size Dashboard

+ + +
+ +
+ +
+

Download size by component

+
Share of the total app download size
+
+
+
+ +
+

Download size by team

+
Select a team to see its components, modules, and libraries
+
+
+ + + +
+

Modules

+
Download size contribution per project module
+
+ + +
+
+
+ +
+

Libraries

+
Download size contribution per external dependency
+
+ + +
+
+
+ +
+

Large files

+
Resources and assets above the configured threshold
+
+ + +
+
+
+ + + + + + + diff --git a/app-sizer/src/test/kotlin/com/grab/sizer/report/html/HtmlReportWriterTest.kt b/app-sizer/src/test/kotlin/com/grab/sizer/report/html/HtmlReportWriterTest.kt new file mode 100644 index 0000000..eab579a --- /dev/null +++ b/app-sizer/src/test/kotlin/com/grab/sizer/report/html/HtmlReportWriterTest.kt @@ -0,0 +1,136 @@ +/* + * MIT License + * + * Copyright (c) 2024. Grabtaxi Holdings Pte Ltd (GRAB), All rights reserved. + * + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE + */ + +package com.grab.sizer.report.html + +import com.grab.sizer.report.DefaultField +import com.grab.sizer.report.ProjectInfo +import com.grab.sizer.report.Report +import com.grab.sizer.report.Row +import com.grab.sizer.report.TagField +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class HtmlReportWriterTest { + + @get:Rule + val outputDir = TemporaryFolder() + + private val projectInfo = ProjectInfo( + versionName = "1.2.3", + projectName = "sample", + deviceName = "device-1", + buildType = "proRelease", + ) + + private lateinit var writer: HtmlReportWriter + + @Before + fun setup() { + writer = HtmlReportWriter( + outputDirectory = outputDir.root, + projectInfo = projectInfo, + customProperties = mapOf("pipeline_id" to "42"), + ) + } + + private fun row(name: String, size: Long, owner: String? = null): Row = Row( + name = name, + fields = listOfNotNull( + TagField("contributor", name), + DefaultField("size", size), + owner?.let { TagField("owner", it) }, + ), + ) + + private fun dashboardFile(): File = File(File(outputDir.root, "device-1"), DASHBOARD_FILE_NAME) + + @Test + fun `writes a self-contained dashboard next to the other reports`() { + writer.write(Report(id = "apk_basic", name = "basic", rows = listOf(row("apk", 1048576)))) + + val content = dashboardFile().readText() + assertTrue(content.startsWith("")) + assertTrue(content.contains("\"apk_basic\"")) + assertFalse("template placeholder must be replaced", content.contains("__DASHBOARD_DATA__")) + assertFalse("must not reference external resources", content.contains("https://cdn")) + } + + @Test + fun `accumulates reports across write calls`() { + writer.write(Report(id = "apk_basic", name = "basic", rows = listOf(row("apk", 100)))) + writer.write(Report(id = "team", name = "team", rows = listOf(row("total", 60, owner = "team-a")))) + + val content = dashboardFile().readText() + assertTrue(content.contains("\"apk_basic\"")) + assertTrue(content.contains("\"team\"")) + assertTrue(content.contains("team-a")) + } + + @Test + fun `embeds project info and custom properties`() { + writer.write(Report(id = "apk_basic", name = "basic", rows = listOf(row("apk", 100)))) + + val content = dashboardFile().readText() + assertTrue(content.contains("\"sample\"")) + assertTrue(content.contains("\"proRelease\"")) + assertTrue(content.contains("\"pipeline_id\"")) + } + + @Test + fun `report data cannot break out of the embedding script tag`() { + val hostile = "" + writer.write(Report(id = "module", name = "m", rows = listOf(row(hostile, 10, owner = "team")))) + + val content = dashboardFile().readText() + assertFalse("closing tags in data must be escaped", content.contains(hostile)) + assertTrue(content.contains("\\u003c/script")) + } + + @Test + fun `rows keep owner and fall back to zero size`() { + writer.write( + Report( + id = "module", + name = "m", + rows = listOf( + Row(name = "no-size", fields = listOf(TagField("contributor", "no-size"))), + row("owned", 5, owner = "team-b"), + ), + ) + ) + + val content = dashboardFile().readText() + assertTrue(content.contains("\"no-size\"")) + assertTrue(content.contains("team-b")) + } +} diff --git a/docs/index.md b/docs/index.md index 69bf069..4186726 100644 --- a/docs/index.md +++ b/docs/index.md @@ -33,8 +33,9 @@ App Sizer provides two flexible integration methods: ## Report types -App Sizer currently supports three types of reports: +App Sizer currently supports four types of reports: +* HTML dashboard - a single self-contained file per device for interactive local analysis, no server required. * InfluxDB database (1.x) - It is suitable for CI tracking and enabling the creation of customized dashboards (with visualization tools like Grafana). We provide an InfluxDB and Grafana setup; see our [Docker Setup Guide][grafana_docker_doc]. * Markdown table for convenient local analysis. * JSON data for compatibility with other platforms. diff --git a/docs/report.md b/docs/report.md index 09e92b6..b84c54a 100644 --- a/docs/report.md +++ b/docs/report.md @@ -1,10 +1,28 @@ # Reports -App Sizer supports three types of reports to cater to different use cases and environments: +App Sizer supports four types of reports to cater to different use cases and environments: -1. InfluxDB database (1.x) -2. Markdown tables -3. JSON data +1. HTML dashboard +2. InfluxDB database (1.x) +3. Markdown tables +4. JSON data + +## HTML Dashboard + +Every analysis writes a self-contained `index.html` dashboard into each device's report folder, next to the markdown and JSON reports: + +* Gradle plugin: `[outputDirectory]/[device]/index.html` (default `app/build/sizer/reports/[variant]/[device]/index.html`) +* CLI: `[output-directory]/[device]/index.html` + +It has no external dependencies, so it can be opened offline, shared as a single file, or viewed straight from a CI artifact browser; any static file server serves it automatically when the folder is opened. + +The dashboard includes: + +* Header tiles with the total download size and the code/resources/native/assets split +* Download size by component (stacked bar with legend) +* Download size by team, with a per-team drill-down into its components, modules, and libraries +* Searchable tables for all modules, libraries, and large files +* Light and dark themes (follows the system setting, with a manual toggle) ## InfluxDB Database