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