// Copyright 2016 Twitter. All rights reserved. // // 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. package com.twitter.heron.scheduler.dryrun; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import com.google.common.base.Optional; import com.google.common.base.Strings; import com.twitter.heron.common.basics.ByteAmount; import com.twitter.heron.spi.packing.PackingPlan; import com.twitter.heron.spi.packing.Resource; /** * Formatter utilities */ public final class FormatterUtils { public FormatterUtils(boolean rich) { this.rich = rich; } /** * If render in rich format (with color and text style) */ private final boolean rich; /** * Simple and self-contained support of using ANSI escape codes. * * @see <a href="https://en.wikipedia.org/wiki/ANSI_escape_code">ANSI escape code</a> * */ private static final String ANSI_RESET = "\u001B[0m"; private static final String ANSI_RED = "\u001B[31m"; private static final String ANSI_GREEN = "\u001B[32m"; private static final String ANSI_BOLD = "\u001B[1m"; /* * Unicode of long strike overlay. A character followed by \u0036 will * be rendered on terminal as itself being struck through * * See: http://unicode.org/charts/PDF/U0300.pdf */ private static final String STRIKETHROUGH = "\u0336"; public enum TextColor { DEFAULT, RED, GREEN } public enum TextStyle { DEFAULT, BOLD, STRIKETHROUGH } public enum ContainerChange { UNAFFECTED, MODIFIED, NEW, REMOVED } /** * Poor man's tabulate implementation * * Each tabulates consists of a list of rows. Each row consists of a list of cells. * */ /** * Cell is the smallest unit in a tabulate. More generally, it is a class * that represents a piece of text with style and color added. */ public static class Cell { // Text in the cell private final String text; // Length of the text. It is used to calculate the proper widht of a column private final int length; private String formatter; private TextColor color; private TextStyle style; public Cell(String text) { this.text = text; this.length = text.length(); this.formatter = "%s"; this.color = TextColor.DEFAULT; this.style = TextStyle.DEFAULT; } public Cell(String text, TextColor color) { this(text); this.color = color; } public Cell(String text, TextStyle style) { this(text); this.style = style; } public Cell(String text, TextColor color, TextStyle style) { this(text); this.color = color; this.style = style; } public void setColor(TextColor color) { this.color = color; } public void setStyle(TextStyle style) { this.style = style; } public void setFormatter(String formatter) { this.formatter = formatter; } public final int getLength() { return this.length; } /** * Convert Cell to String * @param rich if render in rich format * @return */ public String toString(boolean rich) { StringBuilder builder = new StringBuilder(); String formattedText = String.format(formatter, text); if (rich) { switch (style) { case BOLD: builder.append(ANSI_BOLD); builder.append(formattedText); break; /* Adding strike-through effect to a string is different. One needs to append unicode of long strikethrough overlay to each single character in a string of characters. */ case STRIKETHROUGH: for (int i = 0; i < formattedText.length(); i++) { builder.append(formattedText.charAt(i)); builder.append(STRIKETHROUGH); } break; case DEFAULT: builder.append(formattedText); break; default: throw new RuntimeException("Unknown text style: " + style); } switch (color) { case RED: builder.insert(0, ANSI_RED); break; case GREEN: builder.insert(0, ANSI_GREEN); break; case DEFAULT: break; default: throw new RuntimeException("Unknown text color: " + color); } // Only append ANSI reset escape code if text style or text color is added if (style != TextStyle.DEFAULT || color != TextColor.DEFAULT) { builder.append(ANSI_RESET); } } else { builder.append(formattedText); } return builder.toString(); } } /** * Row, which consists a list of cells. * * <pre> * ---------------------- * | xxxx | yyyy | zzzz | <- a list of cells * ---------------------- * ^ * |------- separator: "|" * </pre> */ public static class Row { private List<Cell> row; private static final String SEPARATOR = "|"; public Row(List<String> row) { this.row = new ArrayList<>(); for (String text: row) { this.row.add(new Cell(text)); } } /** * Set color for a list of cells in a row * @param color */ public void setColor(TextColor color) { for (Cell cell: row) { cell.setColor(color); } } /** * Set style for a list of cells in a row * @param style */ public void setStyle(TextStyle style) { for (Cell cell: row) { cell.setStyle(style); } } /** * Set formatter for each cell in the row * @param formatters */ public void setFormatters(List<String> formatters) { for (int i = 0; i < formatters.size(); i++) { row.get(i).setFormatter(formatters.get(i)); } } public int size() { return row.size(); } /** * Get length of the cell at index {@code index} in the row * @param index * @return length of the cell */ public Cell getCell(int index) { return row.get(index); } public String toString(boolean rich) { List<String> renderedCells = new ArrayList<>(); for (Cell c: row) { renderedCells.add(c.toString(rich)); } return String.format("%s %s %s", SEPARATOR, String.join(" " + SEPARATOR + " ", renderedCells), SEPARATOR); } } /** * Table, which consists of a title and a list of rows below the title * * <pre> * ============================ * | title1 | title2 | title3 | <----- title * ============================ * | xxxxxx | yyy | zzzzzz | <-| * ---------------------------- |----- rows * | gggg | uuuuu | ooo | <-| * </pre> */ public static class Table { private Row title; private List<Row> rows; public Table(Row title, List<Row> rows) { this.title = title; this.rows = rows; } private StringBuilder addRow(StringBuilder builder, String row) { builder.append(row); builder.append('\n'); return builder; } /** * Calculate proper width for each column. * * Notice that if a cell contains text "foo" in red and bold style, the internal * representation will be "\u001B[31m\u001B[1mfoo\u001B[0m". However during the * calculation, ANSI escape codes/Unicode overlay should not be counted because they only * serve as visual effect and do not take extra space on terminal. * * @return a list of integers specifying proper length of each column */ private List<Integer> calculateColumnsMax() { List<Integer> width = new ArrayList<>(); for (int i = 0; i < title.size(); i++) { width.add(title.getCell(i).getLength()); } for (Row row: rows) { for (int i = 0; i < row.size(); i++) { width.set(i, Math.max(width.get(i), row.getCell(i).getLength())); } } return width; } /** * Generate formatter for each row based on rows. Width of a column is the * max width of all cells on that column. * * Explanation of the {@code metaCellFormatter}: * * If the column max width is 8, then {@code String.format(metaCellFormatter, 8)} * gives us {@code "%8s"} * * @see <a href="https://docs.oracle.com/javase/8/docs/api/java/util/Formatter.html"> * Formatter</a> * * @return formatter for rows */ private List<String> generateRowFormatter() { List<String> formatters = new ArrayList<>(); List<Integer> columnsMax = calculateColumnsMax(); String metaCellFormatter = "%%%ds"; for (Integer width: columnsMax) { formatters.add(String.format(metaCellFormatter, width)); } return formatters; } /** * Generate length of table frame * * Definition of table frame: * * =============================== <- table frame * | xxxxx | yyyyy | zzzzz | sss | * =============================== * | ..... | ..... | ..... | ... | * * The constant 3 comes from two spaces surrounding the text * and one separator after the text. * * space * | * --------- * | xxxxx | <- one separator * --------- * | * space * * The final constant 1 is the leftmost separator of a row * * @return */ private int calculateFrameLength() { int total = 0; for (Integer width: calculateColumnsMax()) { total += width + 3; } return total + 1; } /** * Format rows and title into a table * @param rich if render table in rich format * @return Formatted table */ public String createTable(boolean rich) { // Generate formatter for each cell in a single row List<String> formatters = generateRowFormatter(); // Set formatter for each row title.setFormatters(formatters); for (Row row: rows) { row.setFormatters(formatters); } // Calculate length for frames int frameLength = calculateFrameLength(); // Start building table StringBuilder builder = new StringBuilder(); // Add upper frame addRow(builder, Strings.repeat("=", frameLength)); // Add title addRow(builder, title.toString(rich)); // Add one single line to separate title and content addRow(builder, Strings.repeat("-", frameLength)); // Add each row for (Row row: rows) { addRow(builder, row.toString(rich)); } // Add lower frame addRow(builder, Strings.repeat("=", frameLength)); return builder.toString(); } } private static final List<String> TITLE_NAMES = Arrays.asList( "component", "task ID", "CPU", "RAM (MB)", "disk (MB)"); /******************************** Auxiliary functions ********************************/ /** * Format new amount associated with change in percentage * For example, with {@code oldAmount = 2} and {@code newAmount = 1} * the result Cell is " -50.00%" (in red color) * @param oldAmount old resource usage * @param newAmount new resource usage * @return formatted chagne in percentage if oldAmount and newAmount differ */ public static Optional<Cell> percentageChange(double oldAmount, double newAmount) { double delta = newAmount - oldAmount; double percentage = delta / oldAmount * 100.0; if (percentage == 0.0) { return Optional.absent(); } else { String sign = ""; if (percentage > 0.0) { sign = "+"; } Cell cell = new Cell(String.format("%s%.2f%%", sign, percentage)); // set color to red if percentage drops, to green if percentage increases if ("".equals(sign)) { cell.setColor(TextColor.RED); } else { cell.setColor(TextColor.GREEN); } return Optional.of(cell); } } public String renderContainerName(Integer containerId) { return new Cell(String.format("Container %d", containerId), FormatterUtils.TextStyle.BOLD).toString(rich); } public String renderContainerChange(ContainerChange change) { Cell c = new Cell(change.toString()); switch (change) { case NEW: c.setColor(TextColor.GREEN); break; case REMOVED: c.setColor(TextColor.RED); break; default: break; } return c.toString(rich); } public Row rowOfInstancePlan(PackingPlan.InstancePlan plan, TextColor color, TextStyle style) { String taskId = String.valueOf(plan.getTaskId()); String cpu = String.valueOf(plan.getResource().getCpu()); String ram = String.valueOf(plan.getResource().getRam().asMegabytes()); String disk = String.valueOf(plan.getResource().getDisk().asMegabytes()); List<String> cells = Arrays.asList( plan.getComponentName(), taskId, cpu, ram, disk); Row row = new Row(cells); row.setStyle(style); row.setColor(color); return row; } public String renderOneContainer(List<Row> rows) { Row title = new Row(TITLE_NAMES); title.setStyle(TextStyle.BOLD); return new Table(title, rows).createTable(rich); } public String renderResourceUsage(Resource resource) { double cpu = resource.getCpu(); ByteAmount ram = resource.getRam(); ByteAmount disk = resource.getDisk(); return String.format("CPU: %s, RAM: %s MB, Disk: %s MB", cpu, ram.asMegabytes(), disk.asMegabytes()); } public String renderResourceUsageChange(Resource oldResource, Resource newResource) { double oldCpu = oldResource.getCpu(); double newCpu = newResource.getCpu(); Optional<Cell> cpuUsageChange = FormatterUtils.percentageChange(oldCpu, newCpu); long oldRam = oldResource.getRam().asMegabytes(); long newRam = newResource.getRam().asMegabytes(); Optional<Cell> ramUsageChange = FormatterUtils.percentageChange(oldRam, newRam); long oldDisk = oldResource.getDisk().asMegabytes(); long newDisk = newResource.getDisk().asMegabytes(); Optional<Cell> diskUsageChange = FormatterUtils.percentageChange(oldDisk, newDisk); String cpuUsage = String.format("CPU: %s", newCpu); if (cpuUsageChange.isPresent()) { cpuUsage += String.format(" (%s)", cpuUsageChange.get().toString(rich)); } String ramUsage = String.format("RAM: %s MB", newRam); if (ramUsageChange.isPresent()) { ramUsage += String.format(" (%s)", ramUsageChange.get().toString(rich)); } String diskUsage = String.format("Disk: %s MB", newDisk); if (diskUsageChange.isPresent()) { diskUsage += String.format(" (%s)", diskUsageChange.get().toString(rich)); } return String.join(", ", cpuUsage, ramUsage, diskUsage); } }