/* * Copyright (c) 2007-2009 Yahoo! Inc. All rights reserved. * The copyrights to the contents of this file are licensed under the MIT License (http://www.opensource.org/licenses/mit-license.php) */ package hudson.plugins.plot; import hudson.model.AbstractBuild; import hudson.model.AbstractProject; import hudson.model.Build; import hudson.model.Project; import hudson.model.Run; import hudson.util.ChartUtil; import hudson.util.ShiftedCategoryAxis; import java.awt.BasicStroke; import java.awt.Color; import java.awt.Polygon; import java.awt.Shape; import java.io.File; import java.io.FileReader; import java.io.FileWriter; import java.io.IOException; import java.io.PrintStream; import java.text.NumberFormat; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; import java.util.Random; import java.util.logging.Logger; import org.jfree.chart.ChartFactory; import org.jfree.chart.ChartRenderingInfo; import org.jfree.chart.ChartUtilities; import org.jfree.chart.JFreeChart; import org.jfree.chart.axis.CategoryAxis; import org.jfree.chart.axis.CategoryLabelPositions; import org.jfree.chart.labels.StandardCategoryToolTipGenerator; import org.jfree.chart.plot.CategoryPlot; import org.jfree.chart.plot.DefaultDrawingSupplier; import org.jfree.chart.plot.DrawingSupplier; import org.jfree.chart.plot.PlotOrientation; import org.jfree.chart.renderer.category.AbstractCategoryItemRenderer; import org.jfree.chart.renderer.category.LineAndShapeRenderer; import org.kohsuke.stapler.DataBoundConstructor; import org.kohsuke.stapler.StaplerRequest; import org.kohsuke.stapler.StaplerResponse; import au.com.bytecode.opencsv.CSVReader; import au.com.bytecode.opencsv.CSVWriter; /** * Represents the configuration for a single plot. A plot can * have one or more data series (lines). Each data series * has one data point per build. The x-axis is always the * build number. * * A plot has the following characteristics: * <ul> * <li> a title (mandatory) * <li> y-axis label (defaults to no label) * <li> one or more data series * <li> plot group (defaults to no group) * <li> number of builds to show on the plot (defaults to all) * </ul> * * A plots group effects the way in which plots are displayed. Group names * are listed as links on the top-level plot page. The user then clicks * on a group and sees the plots that belong to that group. * * @author Nigel Daley */ public class Plot implements Comparable { private static final Logger LOGGER = Logger.getLogger(Plot.class.getName()); private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("MMM d"); /** * Effectively a 2-dimensional array, where each row is the * data for one data series of an individual build; the columns * are: series y-value, series label, build number, optional URL */ private transient ArrayList<String[]> rawPlotData; /** * The generated plot, which is only regenerated when new data * is added (it is re-rendered, however, every time it is requested). */ private transient JFreeChart plot; /** * The project (or job) that this plot belongs to. A reference * to the project is needed to retrieve and save the CSV file * that is stored in the project's root directory. */ private transient AbstractProject project; /** All plots share the same JFreeChart drawing supplier object. */ private static final DrawingSupplier supplier = new DefaultDrawingSupplier( DefaultDrawingSupplier.DEFAULT_PAINT_SEQUENCE, DefaultDrawingSupplier.DEFAULT_OUTLINE_PAINT_SEQUENCE, DefaultDrawingSupplier.DEFAULT_STROKE_SEQUENCE, DefaultDrawingSupplier.DEFAULT_OUTLINE_STROKE_SEQUENCE, // the plot data points are a small diamond shape new Shape[] { new Polygon(new int[] {3, 0, -3, 0}, new int[] {0, 4, 0, -4}, 4) } ); /** The default plot width. */ private static final int DEFAULT_WIDTH = 750; /** The default plot height. */ private static final int DEFAULT_HEIGHT = 450; /** The default number of builds on plot (all). */ private static final String DEFAULT_NUMBUILDS = ""; // Transient values /** The width of the plot. */ private transient int width; /** The height of the plot. */ private transient int height; /** The right-most build number on the plot. */ private transient int rightBuildNum; /** Whether or not the plot has a legend. */ private transient boolean hasLegend = true; /** Number of builds back to show on this plot from url. */ public transient String urlNumBuilds; /** Title of plot from url. */ public transient String urlTitle; /** Style of plot from url. */ public transient String urlStyle; /** Use description flag from url. */ public transient Boolean urlUseDescr; // Configuration values /** Title of plot. Mandatory. */ public String title; /** Y-axis label. Optional. */ public String yaxis; /** Array of data series. */ public Series[] series; /** Group name that this plot belongs to. */ public String group; /** * Number of builds back to show on this plot. * Empty string means all builds. Must not be "0". */ public String numBuilds; /** * The name of the CSV file that persists the plots data. * The CSV file is stored in the projects root directory. * This is different from the source csv file that can be used as a source for the plot. */ public String csvFileName; /** The date of the last change to the CSV file. */ private long csvLastModification; /** Optional style of plot: line, line3d, stackedArea, stackedBar, etc. */ public String style; /** Whether or not to use build descriptions as X-axis labels. Optional. */ public boolean useDescr; /** * Creates a new plot with the given paramenters. If numBuilds * is the empty string, then all builds will be included. Must * not be zero. */ @DataBoundConstructor public Plot(String title, String yaxis, String group, String numBuilds, String csvFileName, String style, boolean useDescr) { this.title = title; this.yaxis = yaxis; this.group = group; this.numBuilds = numBuilds; if (csvFileName == null || csvFileName.trim().length()==0) { //TODO: check project dir to ensure uniqueness instead of just random csvFileName = Math.abs(new Random().nextInt()) + ".csv"; } this.csvFileName = csvFileName; this.style = style; this.useDescr = useDescr; } // needed for serialization public Plot() {} public int compareTo(Object o) { return title.compareTo(((Plot)o).getTitle()); } @Override public String toString() { return "TITLE("+getTitle()+ "),YAXIS("+yaxis+ "),NUMSERIES("+series.length+ "),GROUP("+group+ "),NUMBUILDS("+numBuilds+ "),RIGHTBUILDNUM("+getRightBuildNum()+ "),HASLEGEND("+hasLegend()+ "),FILENAME("+csvFileName+")"; } public String getYaxis() { return yaxis; } public Series[] getSeries() { return series; } public String getGroup() { return group; } public String getCsvFileName() { return csvFileName; } /** * Sets the title for the plot from the "title" parameter * in the given StaplerRequest. */ private void setTitle(StaplerRequest req) { urlTitle = req.getParameter("title"); } private String getURLTitle() { return urlTitle != null ? urlTitle : title; } public String getTitle() { return title; } private void setStyle(StaplerRequest req) { urlStyle = req.getParameter("style"); } private String getUrlStyle() { return urlStyle != null ? urlStyle : (style != null ? style : ""); } private void setUseDescr(StaplerRequest req) { String u = req.getParameter("usedescr"); if (u == null) { urlUseDescr = null; } else { urlUseDescr = u.equalsIgnoreCase("on") || u.equalsIgnoreCase("true"); } } private boolean getUrlUseDescr() { return urlUseDescr != null ? urlUseDescr : useDescr; } /** * Sets the number of builds to plot from the "numbuilds" parameter * in the given StaplerRequest. If the parameter doesn't exist * or isn't an integer then a default is used. */ private void setHasLegend(StaplerRequest req) { String legend = req.getParameter("legend"); hasLegend = legend == null || legend.equalsIgnoreCase("on") || legend.equalsIgnoreCase("true"); } public boolean hasLegend() { return hasLegend; } /** * Sets the number of builds to plot from the "numbuilds" parameter * in the given StaplerRequest. If the parameter doesn't exist * or isn't an integer then a default is used. */ private void setNumBuilds(StaplerRequest req) { urlNumBuilds = req.getParameter("numbuilds"); if (urlNumBuilds != null) { try { // simply try and parse the string to see if it's a valid number, throw away the result. } catch (NumberFormatException nfe) { urlNumBuilds = null; } } } public String getURLNumBuilds() { return urlNumBuilds != null ? urlNumBuilds : numBuilds; } public String getNumBuilds() { return numBuilds; } /** * Sets the right-most build number shown on the plot from * the "rightbuildnum" parameter in the given StaplerRequest. * If the parameter doesn't exist or isn't an integer then * a default is used. */ private void setRightBuildNum(StaplerRequest req) { String build = req.getParameter("rightbuildnum"); if (build == null) { rightBuildNum = Integer.MAX_VALUE; } else { try { rightBuildNum = Integer.parseInt(build); } catch (NumberFormatException nfe) { rightBuildNum = Integer.MAX_VALUE; } } } private int getRightBuildNum() { return rightBuildNum; } /** * Sets the plot width from the "width" parameter in the * given StaplerRequest. If the parameter doesn't exist * or isn't an integer then a default is used. */ private void setWidth(StaplerRequest req) { String w = req.getParameter("width"); if (w == null) { width = DEFAULT_WIDTH; } else { try { width = Integer.parseInt(w); } catch (NumberFormatException nfe) { width = DEFAULT_WIDTH; } } } private int getWidth() { return width; } /** * Sets the plot height from the "height" parameter in the * given StaplerRequest. If the parameter doesn't exist * or isn't an integer then a default is used. */ private void setHeight(StaplerRequest req) { String h = req.getParameter("height"); if (h == null) { height = DEFAULT_HEIGHT; } else { try { height = Integer.parseInt(h); } catch (NumberFormatException nfe) { height = DEFAULT_HEIGHT; } } } private int getHeight() { return height; } /** * A reference to the project is needed to retrieve * the project's root directory where the CSV file * is located. Unfortunately, a reference to the project * is not available when this object is created. * * @param project the project */ public void setProject(Project project) { this.project = project; } /** * Generates and writes the plot to the response output stream. * * @param req the incoming request * @param rsp the response stream * @throws IOException */ public void plotGraph(StaplerRequest req, StaplerResponse rsp) throws IOException { if (ChartUtil.awtProblemCause != null) { // Not available. Send out error message. rsp.sendRedirect2(req.getContextPath()+"/images/headless.png"); return; } setWidth(req); setHeight(req); setNumBuilds(req); setRightBuildNum(req); setHasLegend(req); setTitle(req); setStyle(req); setUseDescr(req); // need to force regenerate the plot in case build // descriptions (used for tool tips) have changed generatePlot(true); ChartUtil.generateGraph(req, rsp, plot, getWidth(), getHeight()); } /** * Generates and writes the plot's clickable map to the response * output stream. * * @param req the incoming request * @param rsp the response stream * @throws IOException */ public void plotGraphMap(StaplerRequest req, StaplerResponse rsp) throws IOException { if (ChartUtil.awtProblemCause != null) { // not available. send out error message rsp.sendRedirect2(req.getContextPath()+"/images/headless.png"); return; } setWidth(req); setHeight(req); setNumBuilds(req); setRightBuildNum(req); setHasLegend(req); setTitle(req); setStyle(req); setUseDescr(req); generatePlot(false); ChartRenderingInfo info = new ChartRenderingInfo(); plot.createBufferedImage(getWidth(),getHeight(),info); rsp.setContentType("text/plain;charset=UTF-8"); rsp.getWriter().println(ChartUtilities.getImageMap(getCsvFileName(),info)); } /** * Called when a build completes. Adds the finished build to this plot. * This method extracts the data for each data series from the build and * saves it in the plot's CSV file. * * @param build * @param logger */ public void addBuild(Build build, PrintStream logger) { if (project == null) project = build.getProject(); // load the existing plot data from disk loadPlotData(); // extract the data for each data series for (Series series : getSeries()) { if (series == null) continue; PlotPoint[] seriesData = series.loadSeries(build.getWorkspace(),logger); if (seriesData != null) { for (PlotPoint point : seriesData) { if (point == null) continue; rawPlotData.add(new String[] { point.getYvalue(), point.getLabel(), build.getNumber() + "", // convert to a string build.getTimestamp().getTimeInMillis() + "", point.getUrl() }); } } } // save the updated plot data to disk savePlotData(); // currently only support for csv type if (getSeries() != null) { Series series = getSeries()[0]; if ("csv".equals(series.getFileType())) { saveTableData(build, series.getFile()); } } } /** * Generates the plot and stores it in the plot instance variable. * * @param forceGenerate if true, force the plot to be re-generated * even if the on-disk data hasn't changed */ private void generatePlot(boolean forceGenerate) { class Label implements Comparable<Label> { final private Integer buildNum; final private String buildDate; final private String text; public Label(String buildNum, String buildTime, String text) { this.buildNum = Integer.parseInt(buildNum); this.buildDate = DATE_FORMAT.format( new Date(Long.parseLong(buildTime))); this.text = text; } public Label(String buildNum, String buildTime) { this(buildNum, buildTime, null); } public int compareTo(Label that) { return this.buildNum - that.buildNum; } @Override public boolean equals(Object o) { return o instanceof Label && ((Label) o).buildNum.equals(buildNum); } @Override public int hashCode() { return buildNum.hashCode(); } public String numDateString() { return "#" + buildNum + " (" + buildDate + ")"; } @Override public String toString() { return text != null ? text : numDateString(); } } //LOGGER.info("Determining if we should generate plot " + getCsvFileName()); File csvFile = new File(project.getRootDir(),getCsvFileName()); if (csvFile.lastModified() == csvLastModification && plot != null && !forceGenerate) { // data hasn't changed so don't regenerate the plot return; } if (rawPlotData == null || csvFile.lastModified() > csvLastModification) { // data has changed or has not been loaded so load it now loadPlotData(); } //LOGGER.info("Generating plot " + getCsvFileName()); csvLastModification = csvFile.lastModified(); PlotCategoryDataset dataset = new PlotCategoryDataset(); for (String[] record : rawPlotData) { // record: series y-value, series label, build number, build date, url int buildNum; try { buildNum = Integer.valueOf(record[2]); if (buildNum > getRightBuildNum()) { continue; // skip this record } } catch (NumberFormatException nfe) { continue; // skip this record all together } Number value = null; try { value = Integer.valueOf(record[0]); } catch (NumberFormatException nfe) { try { value = Double.valueOf(record[0]); } catch (NumberFormatException nfe2) { continue; // skip this record all together } } String series = record[1]; Label xlabel = getUrlUseDescr() ? new Label(record[2], record[3], descriptionForBuild(buildNum)) : new Label(record[2], record[3]); String url = null; if (record.length >= 5) url = record[4]; dataset.setValue(value, url, series, xlabel); } int numBuilds; try { numBuilds = Integer.parseInt(getURLNumBuilds()); } catch (NumberFormatException nfe) { numBuilds = Integer.MAX_VALUE; } dataset.clipDataset(numBuilds); plot = createChart(dataset); CategoryPlot categoryPlot = (CategoryPlot) plot.getPlot(); categoryPlot.setDomainGridlinePaint(Color.black); categoryPlot.setRangeGridlinePaint(Color.black); categoryPlot.setDrawingSupplier(Plot.supplier); CategoryAxis domainAxis = new ShiftedCategoryAxis("Build"); categoryPlot.setDomainAxis(domainAxis); domainAxis.setCategoryLabelPositions(CategoryLabelPositions.UP_90); domainAxis.setLowerMargin(0.0); domainAxis.setUpperMargin(0.03); domainAxis.setCategoryMargin(0.0); for (Object category : dataset.getColumnKeys()) { Label label = (Label) category; if (label.text != null) { domainAxis.addCategoryLabelToolTip(label, label.numDateString()); } else { domainAxis.addCategoryLabelToolTip(label, descriptionForBuild(label.buildNum)); } } AbstractCategoryItemRenderer renderer = (AbstractCategoryItemRenderer) categoryPlot.getRenderer(); int numColors = dataset.getRowCount(); for (int i = 0; i < numColors; i++) { renderer.setSeriesPaint(i, new Color(Color.HSBtoRGB( (1f / numColors) * i, 1f, 1f))); } renderer.setStroke(new BasicStroke(2.0f)); renderer.setToolTipGenerator(new StandardCategoryToolTipGenerator( "Build {1}: {2}", NumberFormat.getInstance())); renderer.setItemURLGenerator(new PointURLGenerator()); if (renderer instanceof LineAndShapeRenderer) { LineAndShapeRenderer lasRenderer = (LineAndShapeRenderer) renderer; lasRenderer.setShapesVisible(true); // TODO: deprecated, may be unnecessary } } /** * Creates a Chart of the style indicated by getEffStyle() using the given dataset. * Defaults to using createLineChart. */ private JFreeChart createChart(PlotCategoryDataset dataset) { String s = getUrlStyle(); if (s.equalsIgnoreCase("area")) { return ChartFactory.createAreaChart( getURLTitle(), /*categoryAxisLabel=*/null, getYaxis(), dataset, PlotOrientation.VERTICAL, hasLegend(), /*tooltips=*/true, /*url=*/false); } if (s.equalsIgnoreCase("bar")) { return ChartFactory.createBarChart( getURLTitle(), /*categoryAxisLabel=*/null, getYaxis(), dataset, PlotOrientation.VERTICAL, hasLegend(), /*tooltips=*/true, /*url=*/false); } if (s.equalsIgnoreCase("bar3d")) { return ChartFactory.createBarChart3D( getURLTitle(), /*categoryAxisLabel=*/null, getYaxis(), dataset, PlotOrientation.VERTICAL, hasLegend(), /*tooltips=*/true, /*url=*/false); } if (s.equalsIgnoreCase("line3d")) { return ChartFactory.createLineChart3D( getURLTitle(), /*categoryAxisLabel=*/null, getYaxis(), dataset, PlotOrientation.VERTICAL, hasLegend(), /*tooltips=*/true, /*url=*/false); } if (s.equalsIgnoreCase("stackedarea")) { return ChartFactory.createStackedAreaChart( getURLTitle(), /*categoryAxisLabel=*/null, getYaxis(), dataset, PlotOrientation.VERTICAL, hasLegend(), /*tooltips=*/true, /*url=*/false); } if (s.equalsIgnoreCase("stackedbar")) { return ChartFactory.createStackedBarChart( getURLTitle(), /*categoryAxisLabel=*/null, getYaxis(), dataset, PlotOrientation.VERTICAL, hasLegend(), /*tooltips=*/true, /*url=*/false); } if (s.equalsIgnoreCase("stackedbar3d")) { return ChartFactory.createStackedBarChart3D( getURLTitle(), /*categoryAxisLabel=*/null, getYaxis(), dataset, PlotOrientation.VERTICAL, hasLegend(), /*tooltips=*/true, /*url=*/false); } if (s.equalsIgnoreCase("waterfall")) { return ChartFactory.createWaterfallChart( getURLTitle(), /*categoryAxisLabel=*/null, getYaxis(), dataset, PlotOrientation.VERTICAL, hasLegend(), /*tooltips=*/true, /*url=*/false); } return ChartFactory.createLineChart( getURLTitle(), /*categoryAxisLabel=*/null, getYaxis(), dataset, PlotOrientation.VERTICAL, hasLegend(), /*tooltips=*/true, /*url=*/false); } /** * Returns a trimmed description string for the build specified by the given build number. */ private String descriptionForBuild(int buildNum) { Run r = project.getBuildByNumber(buildNum); if (r != null) { String tip = r.getTruncatedDescription(); if (tip != null) { return tip.replaceAll("<p> *|<br> *", ", "); } } return null; } /** * Loads the plot data from the CSV file on disk. The * CSV file is stored in the projects root directory. * The data is stored in the rawPlotData instance variable. */ private void loadPlotData() { rawPlotData = new ArrayList<String[]>(); // load existing plot file File plotFile = new File(project.getRootDir(),getCsvFileName()); if (!plotFile.exists()) { return; } CSVReader reader = null; rawPlotData = new ArrayList<String[]>(); try { reader = new CSVReader(new FileReader(plotFile)); // throw away 2 header lines reader.readNext(); reader.readNext(); // read each line of the CSV file and add to rawPlotData String [] nextLine; while ((nextLine = reader.readNext()) != null) { rawPlotData.add(nextLine); } } catch (IOException ioe) { //ignore } finally { if (reader != null) { try { reader.close(); } catch (IOException ignore) { //ignore } } } } /** * Saves the plot data to the CSV file on disk. The * CSV file is stored in the projects root directory. * The data is read from the rawPlotData instance variable. */ private void savePlotData() { File plotFile = new File(project.getRootDir(),getCsvFileName()); CSVWriter writer = null; try { writer = new CSVWriter(new FileWriter(plotFile)); // write 2 header lines String[] header1 = new String[] {"Title",this.getTitle()}; String[] header2 = new String[] {"Value","Series Label","Build Number","Build Date","URL"}; writer.writeNext(header1); writer.writeNext(header2); // write each entry of rawPlotData to a new line in the CSV file for (String[] entry : rawPlotData) { writer.writeNext(entry); } } catch (IOException ioe) { //ignore } finally { if (writer != null) { try { writer.close(); } catch (IOException ignore) { //ignore } } } } private void saveTableData(AbstractBuild<?,?> build, String fileName) { ArrayList rawTableData = new ArrayList(); File tableFile = new File(project.getRootDir(), "table_"+getCsvFileName()); File rawCSVFile = new File(build.getWorkspace() + File.separator + fileName); CSVReader existingTableReader = null; CSVReader newTupleReader = null; CSVWriter newTableWriter = null; try { newTupleReader = new CSVReader(new FileReader(rawCSVFile)); // new header including build # String [] header = newTupleReader.readNext(); String [] headerIncBuild = new String [header.length+1]; headerIncBuild[0] = "build #"; System.arraycopy(header, 0, headerIncBuild, 1, header.length); // add a new tuple String [] tuple = newTupleReader.readNext(); String [] tupleIncBuild = new String [tuple.length+1]; tupleIncBuild[0] = ""+ build.getNumber(); System.arraycopy(tuple, 0, tupleIncBuild, 1, tuple.length); rawTableData.add (tupleIncBuild); // load existing data if (tableFile.exists()) { existingTableReader = new CSVReader(new FileReader(tableFile)); // skip header existingTableReader.readNext(); int numBuilds; try { numBuilds = Integer.parseInt(getNumBuilds()); } catch (NumberFormatException nfe) { numBuilds = Integer.MAX_VALUE; } String [] nextLine; int count = 0; while ((nextLine = existingTableReader.readNext()) != null && count++ < numBuilds-1) { rawTableData.add(nextLine); } } // write to CSV file newTableWriter = new CSVWriter(new FileWriter(tableFile)); newTableWriter.writeNext(headerIncBuild); newTableWriter.writeAll(rawTableData); } catch (IOException ioe) { //ignore } finally { if (existingTableReader != null) { try { existingTableReader.close(); } catch (IOException ignore) { //ignore } } if (newTupleReader != null) { try { newTupleReader.close(); } catch (IOException ignore) { //ignore } } if (newTableWriter != null) { try { newTableWriter.close(); } catch (IOException ignore) { //ignore } } } } }