HtmlReportGenerator.java

/*-
 * ========================LICENSE_START=================================
 * flyway-reports
 * ========================================================================
 * Copyright (C) 2010 - 2026 Red Gate Software Ltd
 * ========================================================================
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 * 
 *      http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 * =========================LICENSE_END==================================
 */
package org.flywaydb.reports.html;

import static org.flywaydb.core.internal.util.ClassUtils.getInstallDir;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.chrono.ChronoLocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Stream;
import lombok.CustomLog;
import lombok.experimental.ExtensionMethod;
import org.flywaydb.core.api.configuration.Configuration;
import org.flywaydb.core.api.output.HtmlResult;
import org.flywaydb.core.extensibility.LicenseGuard;
import org.flywaydb.core.extensibility.Tier;
import org.flywaydb.core.internal.util.CollectionsUtils;
import org.flywaydb.core.internal.util.FileUtils;
import org.flywaydb.reports.api.extensibility.HtmlRenderer;
import org.flywaydb.reports.api.extensibility.HtmlReportSummary;
import org.flywaydb.reports.output.DashboardResult;
import org.flywaydb.reports.output.HoldingResult;

@CustomLog
@ExtensionMethod(Tier.class)
public class HtmlReportGenerator {
    private static final List<HoldingTabMetadata> HOLDING_TAB_METADATA = Arrays.asList(new HoldingTabMetadata("changes",
            "ENTERPRISE"),
        new HoldingTabMetadata("drift", "ENTERPRISE"),
        new HoldingTabMetadata("migrate", "OSS"),
        new HoldingTabMetadata("dryrun", "TEAMS", "ENTERPRISE"),
        new HoldingTabMetadata("code", "OSS"));
    private static final String INSTALL_DIR = getInstallDir(HtmlReportGenerator.class);

    public static String generateHtml(final Collection<? extends HtmlResult> results, final Configuration config) {
        final DashboardResult dashboardResult = new DashboardResult(results);

        final Collection<HtmlResult> allResultsToDisplay = Stream.of(Stream.<HtmlResult>of(dashboardResult),
                results.stream().map(HtmlResult.class::cast),
                getHoldingResults(results, config).stream())
            .flatMap(Function.identity())
            .filter(x -> !x.isLicenseFailed())
            .toList();

        final LocalDateTime lastUpdatedTimestamp = allResultsToDisplay.stream()
            .map(HtmlResult::getTimestamp)
            .max(Comparator.comparing(Function.identity()))
            .orElse(LocalDateTime.now());
        final StringBuilder content = new StringBuilder(getBeginning(lastUpdatedTimestamp));

        content.append("<div>\n");
        content.append(getTabs(allResultsToDisplay, config));

        int tabCount = 0;
        for (final HtmlResult htmlResult : allResultsToDisplay) {
            content.append(renderTab(htmlResult, config, tabCount));
            tabCount++;
        }

        return content.append("</div>\n").append(getEnd()).toString();
    }

    private static Collection<HtmlResult> getHoldingResults(final Collection<? extends HtmlResult> groupedResult,
        final Configuration config) {
        final String currentTier = LicenseGuard.getTierAsString(config);

        final Collection<HtmlResult> holdingResults = new ArrayList<>();
        for (final HoldingTabMetadata holdingTabMetadata : HOLDING_TAB_METADATA) {
            final String holdingTab = holdingTabMetadata.name();
            if (groupedResult.stream().noneMatch(t -> holdingTab.equals(t.getOperation()) && !t.isLicenseFailed())) {
                String htmlFile = FileUtils.readAsStringFallbackToResource(INSTALL_DIR,
                    "assets/report/holdingTabs/" + holdingTab + ".html");
                final String tabTitle = FileUtils.readAsStringFallbackToResource(INSTALL_DIR,
                    "assets/report/holdingTabs/" + holdingTab + ".txt").trim();

                Optional<Exception> maybeException = Optional.empty();
                if (!Objects.equals(holdingTabMetadata.supportedEditions().get(0), "OSS")
                    && !holdingTabMetadata.supportedEditions().contains(currentTier)) {
                    htmlFile = FileUtils.readAsStringFallbackToResource(INSTALL_DIR,
                        "assets/report/upgradeTabs/" + holdingTab + ".html");
                    maybeException = groupedResult.stream()
                        .filter(t -> t.getOperation().equals(holdingTab))
                        .findFirst()
                        .map(x -> x.exceptionObject);
                }

                holdingResults.add(new HoldingResult(holdingTab, tabTitle, htmlFile, maybeException.orElse(null)));
            }
        }

        return holdingResults;
    }

    private static String getBeginning(final ChronoLocalDateTime<LocalDate> lastUpdatedTimestamp) {
        return "<!doctype html>\n"
            + "<html lang=\"en\">\n"
            + "<head><meta charset=\"utf-8\">\n"
            + "<style>\n"
            + getCodeStyle()
            + "</style>\n</head>\n"
            + "<body>\n"
            + FileUtils.readAsStringFallbackToResource(INSTALL_DIR, "assets/report/icons/AddFilled.svg")
            + FileUtils.readAsStringFallbackToResource(INSTALL_DIR, "assets/report/icons/Calendar.svg")
            + FileUtils.readAsStringFallbackToResource(INSTALL_DIR, "assets/report/icons/CheckFilled.svg")
            + FileUtils.readAsStringFallbackToResource(INSTALL_DIR, "assets/report/icons/ClockOutlined.svg")
            + FileUtils.readAsStringFallbackToResource(INSTALL_DIR, "assets/report/icons/Database.svg")
            + FileUtils.readAsStringFallbackToResource(INSTALL_DIR, "assets/report/icons/DeleteFilled.svg")
            + FileUtils.readAsStringFallbackToResource(INSTALL_DIR, "assets/report/icons/Document.svg")
            + FileUtils.readAsStringFallbackToResource(INSTALL_DIR, "assets/report/icons/EditFilled.svg")
            + FileUtils.readAsStringFallbackToResource(INSTALL_DIR, "assets/report/icons/ErrorFilled.svg")
            + FileUtils.readAsStringFallbackToResource(INSTALL_DIR, "assets/report/icons/FeedbackOutlined.svg")
            + FileUtils.readAsStringFallbackToResource(INSTALL_DIR, "assets/report/icons/flyway-upgrade-icon.svg")
            + FileUtils.readAsStringFallbackToResource(INSTALL_DIR, "assets/report/icons/InfoOutlined.svg")
            + FileUtils.readAsStringFallbackToResource(INSTALL_DIR, "assets/report/icons/PipelineFilled.svg")
            + FileUtils.readAsStringFallbackToResource(INSTALL_DIR, "assets/report/icons/ScriptOutlined.svg")
            + FileUtils.readAsStringFallbackToResource(INSTALL_DIR, "assets/report/icons/upgrade.svg")
            + FileUtils.readAsStringFallbackToResource(INSTALL_DIR, "assets/report/icons/WarningFilled.svg")
            + " <div class=\"container\">"
            + "  <div class=\"header\">"
            + "    <div class=\"flywayLogo headerElement\"></div>\n"
            + "    <div class=\"headerElement leftPaddedElement\">Flyway Reports</div>"
            + "      <div class=\"redgateText\"><a class='unstyledLink' href='https://www.redgate.com'>redgate</a></div>"
            + "  </div>\n"
            + "  <div class=\"content\">\n"
            + renderLastUpdatedTimestamp(lastUpdatedTimestamp);
    }

    private static String renderLastUpdatedTimestamp(final ChronoLocalDateTime<LocalDate> lastUpdatedTimestamp) {
        final String formattedTimestamp = lastUpdatedTimestamp.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss",
            Locale.ROOT));
        return "<div>Last updated: " + formattedTimestamp + "</div>\n";
    }

    public static HtmlRenderer<HtmlResult> getRenderer(final HtmlResult htmlResult, final Configuration config) {
        @SuppressWarnings("unchecked") final HtmlRenderer<HtmlResult> result = config.getPluginRegister()
            .getInstancesOf(HtmlRenderer.class)
            .stream()
            .map(x -> (HtmlRenderer<HtmlResult>) x)
            .filter(t -> t.getType().isAssignableFrom(htmlResult.getClass()))
            .findFirst()
            .orElse(null);

        if (result == null) {
            System.out.println("No renderer found for " + htmlResult.getClass().getName());
        }

        return result;
    }

    private static String getTabs(final Iterable<? extends HtmlResult> result, final Configuration config) {
        final StringBuilder tabs = new StringBuilder();
        int tabCount = 0;
        for (final HtmlResult htmlResult : result) {
            final String id2 = htmlResult.getOperation() + "-" + tabCount;
            final StringBuilder button = new StringBuilder("<button class=\"tab\" onclick=\"onTabClick(event, '").append(
                getTabId(htmlResult, config, tabCount)).append("','").append(id2).append("')\"");
            button.append(" id=\"").append(id2).append("\">");
            String tabTitle = "";
            final HtmlRenderer<HtmlResult> correctRenderer = getRenderer(htmlResult, config);
            if (correctRenderer != null) {
                tabTitle = correctRenderer.tabTitle(htmlResult, config);
            }
            tabs.append(button).append("<h4>").append(tabTitle).append("</h4>").append("</button>\n");
            tabCount++;
        }
        return "<div class=\"tabs\">\n" + tabs + "</div>";
    }

    private static String renderTab(final HtmlResult result, final Configuration config, final int tabCount) {
        final HtmlRenderer<HtmlResult> renderer = getRenderer(result, config);
        return getTabOpening(result, config, tabCount) + renderTabSummary(result, config) + renderer.render(result,
            config) + getTabEnding(result);
    }

    private static String renderTabSummary(final HtmlResult result, final Configuration config) {
        final HtmlRenderer<HtmlResult> renderer = getRenderer(result, config);
        final List<HtmlReportSummary> summaries = renderer.getHtmlSummary(result, config);
        if (!CollectionsUtils.hasItems(summaries)) {
            return "";
        }
        final StringBuilder html = new StringBuilder("<div class='summaryHeader'>");

        for (final HtmlReportSummary s : summaries) {
            html.append("<div class='summaryDiv ")
                .append(s.getCssClass())
                .append("'><div class='summaryDivContent'><span class='summaryIcon'><svg fill=\"none\"><use href=\"#")
                .append(s.getIcon())
                .append("\"/></svg></span><span class='summaryText'>")
                .append(s.getSummaryText())
                .append("</span></div></div>");
        }
        html.append("</div>");

        return html.toString();
    }

    private static String getTabOpening(final HtmlResult result, final Configuration config, final int tabCount) {
        return "<div id=\"" + getTabId(result, config, tabCount) + "\" class=\"tabcontent\">\n";
    }

    private static String getTabEnding(final HtmlResult result) {
        String htmlResult = "";
        if (result.getException() != null) {
            htmlResult += "<div class=\"error\">\n"
                + "<pre class=\"exception\">Flyway Exception: "
                + result.getException()
                + "</pre>\n"
                + "</div>\n";
        }
        return htmlResult + "</div>\n";
    }

    public static String getTabId(final HtmlResult result, final Configuration config, final int tabCount) {
        final HtmlRenderer<HtmlResult> correctRenderer = getRenderer(result, config);
        return (correctRenderer.tabTitle(result, config) + "_" + tabCount);
    }

    private static String getEnd() {
        String html = "</div>\n";
        html += FileUtils.readAsStringFallbackToResource(INSTALL_DIR, "assets/report/footer.html");
        html += "</div></body>\n" + getScript() + "</html>\n";

        return html;
    }

    private static String getScript() {
        return FileUtils.readAsStringFallbackToResource(INSTALL_DIR, "assets/report/reportScript.html");
    }

    private static String getCodeStyle() {
        return FileUtils.readAsStringFallbackToResource(INSTALL_DIR, "assets/report/report.css");
    }
}