[Author Prev][Author Next][Thread Prev][Thread Next][Author Index][Thread Index]

[or-cvs] [metrics-web/master 3/3] Make all graph types dynamic.



Author: Karsten Loesing <karsten.loesing@xxxxxxx>
Date: Wed, 6 Oct 2010 11:05:20 +0200
Subject: Make all graph types dynamic.
Commit: 5b12b9c499700f267a6a67c3f2383f4711928412

Adapt R code that was run in a cronjob to accept parameters and work with
Rserve.

Replace various graph image servlets with a single servlet that
understands all parameters and handles all graph types.
---
 rserve/graphs.R                                    |  353 ++++++++++++++++++++
 rserve/linegraphs.R                                |  101 ------
 rserve/rserve-init.R                               |    2 +-
 .../torproject/ernie/web/GraphImageServlet.java    |  306 +++++++++++++++++
 .../ernie/web/NetworkSizeImageServlet.java         |   99 ------
 .../ernie/web/RelayBandwidthImageServlet.java      |   99 ------
 .../ernie/web/RelayPlatformsImageServlet.java      |   99 ------
 .../ernie/web/RelayVersionsImageServlet.java       |   99 ------
 war/WEB-INF/web.xml                                |   50 ++-
 9 files changed, 691 insertions(+), 517 deletions(-)
 create mode 100644 rserve/graphs.R
 delete mode 100644 rserve/linegraphs.R
 create mode 100644 src/org/torproject/ernie/web/GraphImageServlet.java
 delete mode 100644 src/org/torproject/ernie/web/NetworkSizeImageServlet.java
 delete mode 100644 src/org/torproject/ernie/web/RelayBandwidthImageServlet.java
 delete mode 100644 src/org/torproject/ernie/web/RelayPlatformsImageServlet.java
 delete mode 100644 src/org/torproject/ernie/web/RelayVersionsImageServlet.java

diff --git a/rserve/graphs.R b/rserve/graphs.R
new file mode 100644
index 0000000..13c44c3
--- /dev/null
+++ b/rserve/graphs.R
@@ -0,0 +1,353 @@
+plot_networksize <- function(start, end, path) {
+  drv <- dbDriver("PostgreSQL")
+  con <- dbConnect(drv, user = dbuser, password = dbpassword, dbname = db)
+  q <- paste("SELECT date, avg_running AS relays FROM network_size ",
+      "WHERE date >= '", start, "' AND date <= '", end, "'", sep = "")
+  rs <- dbSendQuery(con, q)
+  relays <- fetch(rs, n = -1)
+  q <- paste("SELECT date, avg_running AS bridges ",
+      "FROM bridge_network_size WHERE date >= '", start,
+      "' AND date <= '", end, "'", sep = "")
+  rs <- dbSendQuery(con, q)
+  bridges <- fetch(rs, n = -1)
+  dbDisconnect(con)
+  dbUnloadDriver(drv)
+  dates <- seq(from = as.Date(start, "%Y-%m-%d"),
+      to = as.Date(end, "%Y-%m-%d"), by="1 day")
+  missing <- setdiff(dates, as.Date(relays$date, origin = "1970-01-01"))
+  if (length(missing) > 0)
+    relays <- rbind(relays,
+        data.frame(date = as.Date(missing, origin = "1970-01-01"),
+        relays = NA))
+  missing <- setdiff(dates, bridges$date)
+  if (length(missing) > 0)
+    bridges <- rbind(bridges,
+        data.frame(date = as.Date(missing, origin = "1970-01-01"),
+        bridges = NA))
+  relays <- melt(relays, id = "date")
+  bridges <- melt(bridges, id = "date")
+  networksize <- rbind(relays, bridges)
+  ggplot(networksize, aes(x = as.Date(date, "%Y-%m-%d"), y = value,
+    colour = variable)) + geom_line(size = 1) +
+    scale_x_date(name = paste("\nThe Tor Project - ",
+        "https://metrics.torproject.org/";, sep = "")) +
+    scale_y_continuous(name = "", limits = c(0, max(networksize$value,
+        na.rm = TRUE))) +
+    scale_colour_hue("", breaks = c("relays", "bridges"),
+        labels = c("Relays", "Bridges")) +
+    opts(title = "Number of relays\n")
+  ggsave(filename = path, width = 8, height = 5, dpi = 72)
+}
+
+plot_versions <- function(start, end, path) {
+  drv <- dbDriver("PostgreSQL")
+  con <- dbConnect(drv, user = dbuser, password = dbpassword, dbname = db)
+  q <- paste("SELECT date, version, relays FROM relay_versions ",
+      "WHERE date >= '", start, "' AND date <= '", end, "'", sep = "")
+  rs <- dbSendQuery(con, q)
+  versions <- fetch(rs, n = -1)
+  dbDisconnect(con)
+  dbUnloadDriver(drv)
+# TODO improve colors?
+  colours <- data.frame(version = c("0.1.0", "0.1.1", "0.1.2", "0.2.0",
+    "0.2.1", "0.2.2", "0.2.3"), colour = c("#B4674D", "#C0448F",
+    "#1F75FE", "#FF7F49", "#1CAC78", "#5D76CB", "#FF496C"),
+    stringsAsFactors = FALSE)
+  colours <- colours[colours$version %in% unique(versions$version),
+      "colour"]
+  ggplot(versions, aes(x = as.Date(date, "%Y-%m-%d"), y = relays,
+      colour = version)) +
+    geom_line(size = 1) +
+    scale_x_date(name = paste("\nThe Tor Project - ",
+        "https://metrics.torproject.org/";, sep = "")) +
+    scale_y_continuous(name = "",
+      limits = c(0, max(versions$relays, na.rm = TRUE))) +
+    scale_colour_manual(name = "Tor version", values = colours) +
+    opts(title = "Relay versions\n")
+  ggsave(filename = path, width = 8,height = 5,dpi = 72)
+}
+
+plot_platforms <- function(start, end, path) {
+  drv <- dbDriver("PostgreSQL")
+  con <- dbConnect(drv, user=dbuser, password=dbpassword, dbname=db)
+  q <- paste("SELECT date, avg_linux, avg_darwin, avg_bsd, avg_windows, ",
+      "avg_other FROM relay_platforms WHERE date >= '", start,
+      "' AND date <= '", end, "'", sep = "")
+  rs <- dbSendQuery(con, q)
+  platforms <- fetch(rs, n = -1)
+  dbDisconnect(con)
+  dbUnloadDriver(drv)
+  platforms <- melt(platforms, id = "date")
+  ggplot(platforms, aes(x = as.Date(date, "%Y-%m-%d"), y = value,
+      colour = variable)) +
+    geom_line(size = 1) +
+    scale_x_date(name = paste("\nThe Tor Project - ",
+        "https://metrics.torproject.org/";, sep = "")) +
+    scale_y_continuous(name = "",
+      limits = c(0, max(platforms$value, na.rm = TRUE))) +
+    scale_colour_brewer(name = "Platform", breaks = c("avg_linux",
+        "avg_darwin", "avg_bsd", "avg_windows", "avg_other"),
+      labels = c("Linux", "Darwin", "FreeBSD", "Windows", "Other")) +
+    opts(title = "Relay platforms\n")
+  ggsave(filename = path,width = 8,height = 5,dpi = 72)
+}
+
+plot_bandwidth <- function(start, end, path) {
+  drv <- dbDriver("PostgreSQL")
+  con <- dbConnect(drv, user = dbuser, password = dbpassword, dbname = db)
+  q <- paste("SELECT date, bwadvertised FROM total_bandwidth ",
+      "WHERE date >= '", start, "' AND date <= '", end, "'", sep = "")
+  rs <- dbSendQuery(con, q)
+  bw_desc <- fetch(rs, n = -1)
+  q <- paste("SELECT date, read, written FROM total_bwhist ",
+      "WHERE date >= '", start, "' AND date <= '", end, "' ",
+      "AND date < current_date - 1", sep = "")
+  rs <- dbSendQuery(con, q)
+  bw_hist <- fetch(rs, n = -1)
+  dbDisconnect(con)
+  dbUnloadDriver(drv)
+  bandwidth <- rbind(data.frame(date = bw_desc$date,
+      value = bw_desc$bwadvertised, variable = "bwadv"),
+    data.frame(date = bw_hist$date, value = (bw_hist$read +
+      bw_hist$written) / (2 * 86400), variable = "bwhist"))
+  ggplot(bandwidth, aes(x = as.Date(date, "%Y-%m-%d"), y = value / 2^20,
+      colour = variable)) +
+    geom_line(size = 1) +
+    scale_x_date(name = paste("\nThe Tor Project - ",
+        "https://metrics.torproject.org/";, sep = "")) +
+    scale_y_continuous(name="Bandwidth (MiB/s)",
+        limits = c(0, max(bandwidth$value, na.rm = TRUE) / 2^20)) +
+    scale_colour_hue(name = "", breaks = c("bwadv", "bwhist"),
+        labels = c("Advertised bandwidth", "Bandwidth history")) +
+    opts(title = "Total relay bandwidth", legend.position = "top")
+  ggsave(filename = path, width = 8, height = 5, dpi = 72)
+}
+
+plot_relayflags <- function(start, end, flags, path) {
+  drv <- dbDriver("PostgreSQL")
+  con <- dbConnect(drv, user = dbuser, password = dbpassword, dbname = db)
+  columns <- paste("avg_", tolower(flags), sep = "", collapse = ", ")
+  q <- paste("SELECT date, ", columns, " FROM network_size ",
+      "WHERE date >= '", start, "' AND date <= '", end, "'", sep = "")
+  rs <- dbSendQuery(con, q)
+  networksize <- fetch(rs, n = -1)
+  dbDisconnect(con)
+  dbUnloadDriver(drv)
+  networksize <- melt(networksize, id = "date")
+# TODO improve colors?
+  colours <- data.frame(flag = c("Running", "Exit", "Guard", "Fast",
+    "Stable"), colour = c("black", "green", "orange", "red", "blue"),
+    stringsAsFactors = FALSE)
+  colours <- colours[colours$flag %in% flags, "colour"]
+  ggplot(networksize, aes(x = as.Date(date, "%Y-%m-%d"), y = value,
+    colour = variable)) + geom_line(size = 1) +
+    scale_x_date(name = paste("\nThe Tor Project - ",
+        "https://metrics.torproject.org/";, sep = "")) +
+    scale_y_continuous(name = "", limits = c(0, max(networksize$value,
+        na.rm = TRUE))) +
+    scale_colour_manual(name = "Relay flags",
+        breaks = paste("avg_", tolower(flags), sep = ""), labels = flags,
+        values = colours) +
+    opts(title = "Number of relays with relay flags assigned\n")
+  ggsave(filename = path, width = 8, height = 5, dpi = 72)
+}
+
+plot_new_users <- function(start, end, country, path) {
+  drv <- dbDriver("PostgreSQL")
+  con <- dbConnect(drv, user = dbuser, password = dbpassword, dbname = db)
+  q <- paste("SELECT date, 6 * requests AS users FROM dirreq_stats ",
+      "WHERE (source = '68333D0761BCF397A587A0C0B963E4A9E99EC4D3' ",
+      "OR source = 'F2044413DAC2E02E3D6BCF4735A19BCA1DE97281') ",
+      "AND date >= '", start, "' AND date <= '", end, "' AND country = '",
+      country, "'", sep = "")
+  rs <- dbSendQuery(con, q)
+  newusers <- fetch(rs, n = -1)
+  dbDisconnect(con)
+  dbUnloadDriver(drv)
+  dates <- seq(from = as.Date(start, "%Y-%m-%d"),
+      to = as.Date(end, "%Y-%m-%d"), by="1 day")
+  missing <- setdiff(dates, newusers$date)
+  if (length(missing) > 0)
+    newusers <- rbind(newusers,
+        data.frame(date = as.Date(missing, origin = "1970-01-01"),
+        users = NA))
+  peoples <- data.frame(country = c("au", "bh", "br", "ca", "cn", "cu",
+    "de", "et", "fr", "gb", "ir", "it", "jp", "kr", "mm", "pl", "ru",
+    "sa", "se", "sy", "tn", "tm", "us", "uz", "vn", "ye"),
+    people = c("Australian", "Bahraini", "Brazilian", "Canadian",
+    "Chinese", "Cuban", "German", "Ethiopian", "French", "U.K.",
+    "Iranian", "Italian", "Japanese", "South Korean", "Burmese", "Polish",
+    "Russian", "Saudi", "Swedish", "Syrian", "Tunisian", "Turkmen",
+    "U.S.", "Uzbek", "Vietnamese", "Yemeni"), stringsAsFactors = FALSE)
+  title <- ifelse(country == "zy",
+    "Total new or returning, directly connecting Tor users (all data)\n",
+    paste("New or returning, directly connecting",
+    peoples[peoples$country == country, "people"], "Tor users\n"))
+  ggplot(newusers, aes(x = as.Date(date, "%Y-%m-%d"), y = users)) +
+    geom_line(size = 1) +
+    scale_x_date(name = paste("\nThe Tor Project - ",
+        "https://metrics.torproject.org/";, sep = "")) +
+    scale_y_continuous(name = "", limits = c(0, max(newusers$users,
+        na.rm = TRUE))) +
+    opts(title = title)
+  ggsave(filename = path, width = 8, height = 5, dpi = 72)
+}
+
+plot_direct_users <- function(start, end, country, path) {
+  drv <- dbDriver("PostgreSQL")
+  con <- dbConnect(drv, user = dbuser, password = dbpassword, dbname = db)
+  q <- paste("SELECT date, 10 * requests / share AS users ",
+      "FROM dirreq_stats WHERE share >= 1 ",
+      "AND source = '8522EB98C91496E80EC238E732594D1509158E77' ",
+      "AND date >= '", start, "' AND date <= '", end, "' AND country = '",
+      country, "'", sep = "")
+  rs <- dbSendQuery(con, q)
+  directusers <- fetch(rs, n = -1)
+  dbDisconnect(con)
+  dbUnloadDriver(drv)
+  dates <- seq(from = as.Date(start, "%Y-%m-%d"),
+      to = as.Date(end, "%Y-%m-%d"), by="1 day")
+  missing <- setdiff(dates, directusers$date)
+  if (length(missing) > 0)
+    directusers <- rbind(directusers,
+        data.frame(date = as.Date(missing, origin = "1970-01-01"),
+        users = NA))
+  peoples <- data.frame(country = c("au", "bh", "br", "ca", "cn", "cu",
+    "de", "et", "fr", "gb", "ir", "it", "jp", "kr", "mm", "pl", "ru",
+    "sa", "se", "sy", "tn", "tm", "us", "uz", "vn", "ye"),
+    people = c("Australian", "Bahraini", "Brazilian", "Canadian",
+    "Chinese", "Cuban", "German", "Ethiopian", "French", "U.K.",
+    "Iranian", "Italian", "Japanese", "South Korean", "Burmese", "Polish",
+    "Russian", "Saudi", "Swedish", "Syrian", "Tunisian", "Turkmen",
+    "U.S.", "Uzbek", "Vietnamese", "Yemeni"), stringsAsFactors = FALSE)
+  title <- ifelse(country == "zy",
+    "Total recurring, directly connecting Tor users (all data)\n",
+    paste("Recurring, directly connecting",
+    peoples[peoples$country == country, "people"], "Tor users\n"))
+  ggplot(directusers, aes(x = as.Date(date, "%Y-%m-%d"), y = users)) +
+    geom_line(size = 1) +
+    scale_x_date(name = paste("\nThe Tor Project - ",
+        "https://metrics.torproject.org/";, sep = "")) +
+    scale_y_continuous(name = "", limits = c(0, max(directusers$users,
+        na.rm = TRUE))) +
+    opts(title = title)
+  ggsave(filename = path, width = 8, height = 5, dpi = 72)
+}
+
+plot_bridge_users <- function(start, end, country, path) {
+  drv <- dbDriver("PostgreSQL")
+  con <- dbConnect(drv, user = dbuser, password = dbpassword, dbname = db)
+  q <- paste("SELECT date, users FROM bridge_stats ",
+      "WHERE date >= '", start, "' AND date <= '", end, "' ",
+      "AND date < (SELECT MAX(date) FROM bridge_stats) ",
+      "AND country = '", country, "'", sep = "")
+  rs <- dbSendQuery(con, q)
+  bridgeusers <- fetch(rs, n = -1)
+  dbDisconnect(con)
+  dbUnloadDriver(drv)
+  dates <- seq(from = as.Date(start, "%Y-%m-%d"),
+      to = as.Date(end, "%Y-%m-%d"), by="1 day")
+  missing <- setdiff(dates, bridgeusers$date)
+  if (length(missing) > 0)
+    bridgeusers <- rbind(bridgeusers,
+        data.frame(date = as.Date(missing, origin = "1970-01-01"),
+        users = NA))
+  peoples <- data.frame(country = c("au", "bh", "br", "ca", "cn", "cu",
+    "de", "et", "fr", "gb", "ir", "it", "jp", "kr", "mm", "pl", "ru",
+    "sa", "se", "sy", "tn", "tm", "us", "uz", "vn", "ye"),
+    people = c("Australian", "Bahraini", "Brazilian", "Canadian",
+    "Chinese", "Cuban", "German", "Ethiopian", "French", "U.K.",
+    "Iranian", "Italian", "Japanese", "South Korean", "Burmese", "Polish",
+    "Russian", "Saudi", "Swedish", "Syrian", "Tunisian", "Turkmen",
+    "U.S.", "Uzbek", "Vietnamese", "Yemeni"), stringsAsFactors = FALSE)
+  title <- ifelse(country == "zy",
+    "Total users via bridges (all data)\n",
+    paste(peoples[peoples$country == country, "people"],
+    "users via bridges\n"))
+  ggplot(bridgeusers, aes(x = as.Date(date, "%Y-%m-%d"), y = users)) +
+    geom_line(size = 1) +
+    scale_x_date(name = paste("\nThe Tor Project - ",
+        "https://metrics.torproject.org/";, sep = "")) +
+    scale_y_continuous(name = "", limits = c(0, max(bridgeusers$users,
+        na.rm = TRUE))) +
+    opts(title = title)
+  ggsave(filename = path, width = 8, height = 5, dpi = 72)
+}
+
+plot_gettor <- function(start, end, bundle, path) {
+  drv <- dbDriver("PostgreSQL")
+  con <- dbConnect(drv, user = dbuser, password = dbpassword, dbname = db)
+  condition <- ifelse(bundle == "all", "<> 'none'",
+      paste("LIKE 'tor-%browser-bundle_", tolower(bundle), "'", sep = ""))
+  q <- paste("SELECT date, SUM(downloads) AS downloads ",
+      "FROM gettor_stats WHERE bundle ", condition, " AND date >= '",
+      start, "' AND date <= '", end, "' GROUP BY date", sep = "")
+  rs <- dbSendQuery(con, q)
+  downloads <- fetch(rs, n = -1)
+  dbDisconnect(con)
+  dbUnloadDriver(drv)
+  dates <- seq(from = as.Date(start, "%Y-%m-%d"),
+      to = as.Date(end, "%Y-%m-%d"), by="1 day")
+  missing <- setdiff(dates, downloads$date)
+  if (length(missing) > 0)
+    downloads <- rbind(downloads,
+        data.frame(date = as.Date(missing, origin = "1970-01-01"),
+        users = NA))
+  title <- ifelse(bundle == "all",
+    "Total packages requested from GetTor per day\n",
+    paste("Tor Browser Bundles (", bundle,
+    ") requested from GetTor per day\n", sep = ""))
+  ggplot(downloads, aes(x = as.Date(date, "%Y-%m-%d"), y = downloads)) +
+    geom_line(size = 1) +
+    scale_x_date(name = paste("\nThe Tor Project - ",
+        "https://metrics.torproject.org/";, sep = "")) +
+    scale_y_continuous(name = "", limits = c(0, max(downloads$downloads,
+        na.rm = TRUE))) +
+    opts(title = title)
+  ggsave(filename = path, width = 8, height = 5, dpi = 72)
+}
+
+plot_torperf <- function(start, end, source, filesize, path) {
+  drv <- dbDriver("PostgreSQL")
+  con <- dbConnect(drv, user = dbuser, password = dbpassword, dbname = db)
+  q <- paste("SELECT date, q1, md, q3 FROM torperf_stats ",
+      "WHERE source = '", paste(source, filesize, sep = "-"),
+      "' AND date >= '", start, "' AND date <= '", end, "'", sep = "")
+  rs <- dbSendQuery(con, q)
+  torperf <- fetch(rs, n = -1)
+  dbDisconnect(con)
+  dbUnloadDriver(drv)
+  dates <- seq(from = as.Date(start, "%Y-%m-%d"),
+      to = as.Date(end, "%Y-%m-%d"), by="1 day")
+  missing <- setdiff(dates, torperf$date)
+  if (length(missing) > 0)
+    torperf <- rbind(torperf,
+        data.frame(date = as.Date(missing, origin = "1970-01-01"),
+        q1 = NA, md = NA, q3 = NA))
+  colours <- data.frame(source = c("siv", "moria", "torperf"),
+      colour = c("#0000EE", "#EE0000", "#00CD00"),
+      stringsAsFactors = FALSE)
+  colour <- colours[colours$source == source, "colour"]
+  filesizes <- data.frame(filesizes = c("5mb", "1mb", "50kb"),
+      label = c("5 MiB", "1 MiB", "50 KiB"), stringsAsFactors = FALSE)
+  filesizeStr <- filesizes[filesizes$filesize == filesize, "label"]
+  maxY <- max(torperf$q3, na.rm = TRUE)
+  ggplot(torperf, aes(x = as.Date(date, "%Y-%m-%d"), y = md/1e3,
+      fill = "line")) +
+    geom_line(colour = colour, size = 0.75) +
+    geom_ribbon(data = torperf, aes(x = date, ymin = q1/1e3,
+      ymax = q3/1e3, fill = "ribbon")) +
+    scale_x_date(name = paste("\nThe Tor Project - ",
+        "https://metrics.torproject.org/";, sep = "")) +
+    scale_y_continuous(name = "", limits = c(0, maxY) / 1e3) +
+    coord_cartesian(ylim = c(0, 0.8 * maxY / 1e3)) +
+    scale_fill_manual(name = paste("Measured times on",
+        source, "per day"),
+      breaks = c("line", "ribbon"),
+      labels = c("Median", "1st to 3rd quartile"),
+      values = paste(colour, c("", "66"), sep = "")) +
+    opts(title = paste("Time in seconds to complete", filesizeStr,
+        "request"), legend.position = "top")
+  ggsave(filename = path, width = 8, height = 5, dpi = 72)
+}
+
diff --git a/rserve/linegraphs.R b/rserve/linegraphs.R
deleted file mode 100644
index 3e40298..0000000
--- a/rserve/linegraphs.R
+++ /dev/null
@@ -1,101 +0,0 @@
-plot_networksize_line <- function(start, end, path) {
-  drv <- dbDriver("PostgreSQL")
-  con <- dbConnect(drv, user=dbuser, password=dbpassword, dbname=db)
-  q <- paste("SELECT date, avg_running, avg_exit, avg_guard ",
-      "FROM network_size WHERE date >= '", start, "' AND date <= '", end,
-      "'", sep = "")
-  rs <- dbSendQuery(con, q)
-  networksize <- fetch(rs,n=-1)
-  networksize <- melt(networksize, id="date")
-  ggplot(networksize, aes(x = as.Date(date, "%Y-%m-%d"), y = value,
-    colour = variable)) + geom_line(size=1) +
-    scale_x_date(name = paste("\nThe Tor Project - ",
-        "https://metrics.torproject.org/";, sep = "")) +
-    scale_y_continuous(name="", limits = c(0, max(networksize$value,
-        na.rm = TRUE))) +
-    scale_colour_hue("",breaks=c("avg_running","avg_exit","avg_guard"),
-        labels=c("Total","Exit","Guard")) +
-    opts(title = "Number of relays\n")
-  ggsave(filename=path, width=8, height=5, dpi=72)
-  dbDisconnect(con)
-  dbUnloadDriver(drv)
-}
-plot_versions_line <- function(start, end, path) {
-  drv <- dbDriver("PostgreSQL")
-  con <- dbConnect(drv, user=dbuser, password=dbpassword, dbname=db)
-  q <- paste("SELECT date, version, relays FROM relay_versions ",
-      "WHERE date >= '", start, "' AND date <= '", end, "'", sep = "")
-  rs <- dbSendQuery(con, q)
-  v <- fetch(rs,n=-1)
-  colours <- data.frame(version = c("0.1.0", "0.1.1", "0.1.2", "0.2.0",
-    "0.2.1", "0.2.2", "0.2.3"), colour = c("#B4674D", "#C0448F",
-    "#1F75FE", "#FF7F49", "#1CAC78", "#5D76CB", "#FF496C"),
-    stringsAsFactors = FALSE)
-  colours <- colours[colours$version %in% unique(v$version), "colour"]
-  ggplot(v, aes(x = as.Date(date, "%Y-%m-%d"), y = relays,
-      colour = version)) +
-    geom_line(size=1) +
-    scale_x_date(name = paste("\nThe Tor Project - ",
-        "https://metrics.torproject.org/";, sep = "")) +
-    scale_y_continuous(name= "",
-      limits = c(0, max(v$relays, na.rm = TRUE))) +
-    scale_colour_manual(name = "Tor version", values = colours) +
-    opts(title = "Relay versions\n")
-  ggsave(filename=path, width=8,height=5,dpi=72)
-  dbDisconnect(con)
-  dbUnloadDriver(drv)
-}
-plot_platforms_line <- function(start, end, path) {
-  drv <- dbDriver("PostgreSQL")
-  con <- dbConnect(drv, user=dbuser, password=dbpassword, dbname=db)
-  q <- paste("SELECT date, avg_linux, avg_darwin, avg_bsd, avg_windows, ",
-      "avg_other FROM relay_platforms WHERE date >= '", start,
-      "' AND date <= '", end, "'", sep = "")
-  rs <- dbSendQuery(con, q)
-  p <- fetch(rs,n=-1)
-  p <- melt(p, id="date")
-  ggplot(p, aes(x=as.Date(date, "%Y-%m-%d"), y=value, colour=variable)) +
-    geom_line(size=1) +
-    scale_x_date(name = paste("\nThe Tor Project - ",
-        "https://metrics.torproject.org/";, sep = "")) +
-    scale_y_continuous(name="",
-      limits=c(0,max(p$value, na.rm=TRUE))) +
-    scale_colour_brewer(name="Platform", breaks=c("avg_linux",
-        "avg_darwin", "avg_bsd", "avg_windows", "avg_other"),
-      labels=c("Linux", "Darwin", "FreeBSD", "Windows", "Other")) +
-    opts(title = "Relay platforms\n")
-  ggsave(filename=path,width=8,height=5,dpi=72)
-  dbDisconnect(con)
-  dbUnloadDriver(drv)
-}
-plot_bandwidth_line <- function(start, end, path) {
-  drv <- dbDriver("PostgreSQL")
-  con <- dbConnect(drv, user=dbuser, password=dbpassword, dbname=db)
-  q1 <- paste("SELECT date, bwadvertised FROM total_bandwidth ",
-      "WHERE date >= '", start, "' AND date <= '", end, "'", sep = "")
-  rs1 <- dbSendQuery(con, q1)
-  bw_desc <- fetch(rs1, n = -1)
-  q2 <- paste("SELECT date, read, written FROM total_bwhist ",
-      "WHERE date >= '", start, "' AND date <= '", end, "' ",
-      "AND date < current_date - 1", sep = "")
-  rs2 <- dbSendQuery(con, q2)
-  bw_hist <- fetch(rs2, n = -1)
-  bandwidth <- rbind(data.frame(date = bw_desc$date,
-      value = bw_desc$bwadvertised, variable = "bwadv"),
-    data.frame(date = bw_hist$date, value = (bw_hist$read +
-      bw_hist$written) / (2 * 86400), variable = "bwhist"))
-  ggplot(bandwidth, aes(x = as.Date(date, "%Y-%m-%d"), y = value / 2^20,
-      colour = variable)) +
-    geom_line(size=1) +
-    scale_x_date(name = paste("\nThe Tor Project - ",
-        "https://metrics.torproject.org/";, sep = "")) +
-    scale_y_continuous(name="Bandwidth (MiB/s)",
-        limits = c(0, max(bandwidth$value, na.rm = TRUE) / 2^20)) +
-    scale_colour_hue(name = "", breaks = c("bwadv", "bwhist"),
-        labels = c("Advertised bandwidth", "Bandwidth history")) +
-    opts(title = "Total relay bandwidth", legend.position = "top")
-  ggsave(filename = path, width = 8, height = 5, dpi = 72)
-  dbDisconnect(con)
-  dbUnloadDriver(drv)
-}
-
diff --git a/rserve/rserve-init.R b/rserve/rserve-init.R
index d0d2eaf..79e9a75 100644
--- a/rserve/rserve-init.R
+++ b/rserve/rserve-init.R
@@ -13,4 +13,4 @@ db = "tordir"
 dbuser = "ernie"
 dbpassword= ""
 
-source('linegraphs.R')
+source('graphs.R')
diff --git a/src/org/torproject/ernie/web/GraphImageServlet.java b/src/org/torproject/ernie/web/GraphImageServlet.java
new file mode 100644
index 0000000..304b103
--- /dev/null
+++ b/src/org/torproject/ernie/web/GraphImageServlet.java
@@ -0,0 +1,306 @@
+package org.torproject.ernie.web;
+
+import java.io.*;
+import java.text.*;
+import java.util.*;
+import javax.servlet.*;
+import javax.servlet.http.*;
+
+/**
+ * Servlet that reads an HTTP request for a graph image, asks the
+ * GraphController to generate this graph if it's not in the cache, and
+ * returns the image bytes to the client.
+ */
+public class GraphImageServlet extends HttpServlet {
+
+  private GraphController graphController;
+
+  private SimpleDateFormat dateFormat;
+
+  /* Available graphs with corresponding parameter lists. */
+  private Map<String, String> availableGraphs;
+
+  /* Known parameters and parameter values. */
+  private Map<String, String> knownParameterValues;
+
+  public GraphImageServlet()  {
+    this.graphController = GraphController.getInstance();
+    this.dateFormat = new SimpleDateFormat("yyyy-MM-dd");
+    this.dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
+
+    this.availableGraphs = new HashMap<String, String>();
+    this.availableGraphs.put("networksize", "start,end,filename");
+    this.availableGraphs.put("relayflags", "start,end,flag,filename");
+    this.availableGraphs.put("versions", "start,end,filename");
+    this.availableGraphs.put("platforms", "start,end,filename");
+    this.availableGraphs.put("bandwidth", "start,end,filename");
+    this.availableGraphs.put("new-users", "start,end,country,filename");
+    this.availableGraphs.put("direct-users",
+        "start,end,country,filename");
+    this.availableGraphs.put("bridge-users",
+         "start,end,country,filename");
+    this.availableGraphs.put("gettor", "start,end,bundle,filename");
+    this.availableGraphs.put("torperf",
+         "start,end,source,filesize,filename");
+
+    this.knownParameterValues = new HashMap<String, String>();
+    this.knownParameterValues.put("flag",
+        "Running,Exit,Guard,Fast,Stable");
+    this.knownParameterValues.put("country", "au,bh,br,ca,cn,cu,de,et,"
+         + "fr,gb,ir,it,jp,kr,mm,pl,ru,sa,se,sy,tn,tm,us,uz,vn,ye");
+    this.knownParameterValues.put("bundle", "en,zh_cn,fa");
+    this.knownParameterValues.put("source", "siv,moria,torperf");
+    this.knownParameterValues.put("filesize", "50kb,1mb,5mb");
+  }
+
+  public void doGet(HttpServletRequest request,
+      HttpServletResponse response) throws IOException,
+      ServletException {
+
+    /* Find out which graph type was requested and make sure we know this
+     * graph type. */
+    String requestedGraph = request.getRequestURI();
+    if (requestedGraph == null || requestedGraph.length() < 6) {
+      response.sendError(HttpServletResponse.SC_BAD_REQUEST);
+      return;
+    }
+    requestedGraph = requestedGraph.substring(1,
+        requestedGraph.length() - 4);
+    if (!this.availableGraphs.containsKey(requestedGraph)) {
+      response.sendError(HttpServletResponse.SC_BAD_REQUEST);
+      return;
+    }
+
+    /* Find out which other parameters are supported by this graph type
+     * and parse them if they are given. */
+    Set<String> supportedGraphParameters = new HashSet<String>(Arrays.
+        asList(this.availableGraphs.get(requestedGraph).split(",")));
+    Map<String, String[]> recognizedGraphParameters =
+        new HashMap<String, String[]>();
+
+    /* Parse start and end dates if supported by the graph type. If
+     * neither start nor end date are given, set the default date range to
+     * the past 90 days. */
+    if (supportedGraphParameters.contains("start") ||
+        supportedGraphParameters.contains("end")) {
+      String startParameter = request.getParameter("start");
+      String endParameter = request.getParameter("end");
+      if (startParameter == null && endParameter == null) {
+        /* If no start and end parameters are given, set default date
+         * range to the past 90 days. */
+        long now = System.currentTimeMillis();
+        startParameter = dateFormat.format(now
+            - 90L * 24L * 60L * 60L * 1000L);
+        endParameter = dateFormat.format(now);
+      } else if (startParameter != null && endParameter != null) {
+        long startTimestamp = -1L, endTimestamp = -1L;
+        try {
+          startTimestamp = dateFormat.parse(startParameter).getTime();
+          endTimestamp = dateFormat.parse(endParameter).getTime();
+        } catch (ParseException e)  {
+          response.sendError(HttpServletResponse.SC_BAD_REQUEST);
+          return;
+        }
+        /* The parameters are dates. Good. Does end not precede start? */
+        if (startTimestamp > endTimestamp) {
+          response.sendError(HttpServletResponse.SC_BAD_REQUEST);
+          return;
+        }
+        /* And while we're at it, make sure both parameters lie in this
+         * century. */
+        if (!startParameter.startsWith("20") ||
+            !endParameter.startsWith("20")) {
+          response.sendError(HttpServletResponse.SC_BAD_REQUEST);
+          return;
+        }
+        /* Looks like sane parameters. Re-format them to get a canonical
+         * version, not something like 2010-1-1, 2010-01-1, etc. */
+        startParameter = dateFormat.format(startTimestamp);
+        endParameter = dateFormat.format(endTimestamp);
+      } else {
+        /* Either none or both of start and end need to be set. */
+        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
+        return;
+      }
+      recognizedGraphParameters.put("start",
+          new String[] { startParameter });
+      recognizedGraphParameters.put("end", new String[] { endParameter });
+    }
+
+    /* Parse relay flags if supported by the graph type. If no relay flags
+     * are passed or none of them have been recognized, use the set of all
+     * known flags as default. */
+    if (supportedGraphParameters.contains("flag")) {
+      String[] flagParameters = request.getParameterValues("flag");
+      List<String> knownFlags = Arrays.asList(
+          this.knownParameterValues.get("flag").split(","));
+      if (flagParameters != null) {
+        for (String flag : flagParameters) {
+          if (flag == null || flag.length() == 0 ||
+              !knownFlags.contains(flag)) {
+            response.sendError(HttpServletResponse.SC_BAD_REQUEST);
+            return;
+          }
+        }
+      } else {
+        flagParameters = this.knownParameterValues.get("flag").split(",");
+      }
+      recognizedGraphParameters.put("flag", flagParameters);
+    }
+
+    /* Parse country codes if supported by the graph type. If no countries
+     * are passed, use country code "zy" (all countries) as default. */
+    if (supportedGraphParameters.contains("country")) {
+      String[] countryParameters = request.getParameterValues("country");
+      List<String> knownCountries = Arrays.asList(
+          this.knownParameterValues.get("country").split(","));
+      if (countryParameters != null) {
+        for (String country : countryParameters) {
+          if (country == null || country.length() == 0 ||
+              !knownCountries.contains(country)) {
+            response.sendError(HttpServletResponse.SC_BAD_REQUEST);
+            return;
+          }
+        }
+      } else {
+        countryParameters = new String[] { "zy" };
+      }
+      recognizedGraphParameters.put("country", countryParameters);
+    }
+
+    /* Parse GetTor bundle if supported by the graph type. Only a single
+     * bundle can be passed. If no bundle is passed, use "all" as
+     * default. */
+    if (supportedGraphParameters.contains("bundle")) {
+      String[] bundleParameter = request.getParameterValues("bundle");
+      List<String> knownBundles = Arrays.asList(
+          this.knownParameterValues.get("bundle").split(","));
+      if (bundleParameter != null) {
+        if (bundleParameter.length != 1) {
+          response.sendError(HttpServletResponse.SC_BAD_REQUEST);
+          return;
+        }
+        if (bundleParameter[0].length() == 0 ||
+            !knownBundles.contains(bundleParameter[0])) {
+          response.sendError(HttpServletResponse.SC_BAD_REQUEST);
+          return;
+        }
+      } else {
+        bundleParameter = new String[] { "all" };
+      }
+      recognizedGraphParameters.put("bundle", bundleParameter);
+    }
+
+    /* Parse torperf data source if supported by the graph type. Only a
+     * single source can be passed. If no source is passed, use "torperf"
+     * as default. */
+    if (supportedGraphParameters.contains("source")) {
+      String[] sourceParameter = request.getParameterValues("source");
+      List<String> knownSources = Arrays.asList(
+          this.knownParameterValues.get("source").split(","));
+      if (sourceParameter != null) {
+        if (sourceParameter.length != 1) {
+          response.sendError(HttpServletResponse.SC_BAD_REQUEST);
+          return;
+        }
+        if (sourceParameter[0].length() == 0 ||
+            !knownSources.contains(sourceParameter[0])) {
+          response.sendError(HttpServletResponse.SC_BAD_REQUEST);
+          return;
+        }
+      } else {
+        sourceParameter = new String[] { "torperf" };
+      }
+      recognizedGraphParameters.put("source", sourceParameter);
+    }
+
+    /* Parse torperf file size if supported by the graph type. Only a
+     * single file size can be passed. If no file size is passed, use
+     * "50kb" as default. */
+    if (supportedGraphParameters.contains("filesize")) {
+      String[] filesizeParameter = request.getParameterValues("filesize");
+      List<String> knownFilesizes = Arrays.asList(
+          this.knownParameterValues.get("filesize").split(","));
+      if (filesizeParameter != null) {
+        if (filesizeParameter.length != 1) {
+          response.sendError(HttpServletResponse.SC_BAD_REQUEST);
+          return;
+        }
+        if (filesizeParameter[0].length() == 0 ||
+            !knownFilesizes.contains(filesizeParameter[0])) {
+          response.sendError(HttpServletResponse.SC_BAD_REQUEST);
+          return;
+        }
+      } else {
+        filesizeParameter = new String[] { "50kb" };
+      }
+      recognizedGraphParameters.put("filesize", filesizeParameter);
+    }
+
+    /* Prepare filename and R query string. */
+    StringBuilder rQueryBuilder = new StringBuilder("plot_"
+        + requestedGraph.replaceAll("-", "_") + "("),
+        imageFilenameBuilder = new StringBuilder(requestedGraph);
+    List<String> requiredGraphParameters = Arrays.asList(
+        this.availableGraphs.get(requestedGraph).split(","));
+    for (String graphParameter : requiredGraphParameters) {
+      if (graphParameter.equals("filename")) {
+        break;
+      }
+      if (!recognizedGraphParameters.containsKey(graphParameter)) {
+        /* We should have parsed this parameter above! */
+        response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
+            "Missing parameter: " + graphParameter);
+        return;
+      }
+      String[] parameterValues = recognizedGraphParameters.get(
+          graphParameter);
+      if (parameterValues.length == 0) {
+        /* We should not have added a zero-length array here! */
+        response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
+            "Missing parameter: " + graphParameter);
+        return;
+      }
+      for (String param : parameterValues) {
+        imageFilenameBuilder.append("-" + param);
+      }
+      if (parameterValues.length < 2) {
+        rQueryBuilder.append("'" + parameterValues[0] + "', ");
+      } else {
+        rQueryBuilder.append("c(");
+        for (int i = 0; i < parameterValues.length - 1; i++) {
+          rQueryBuilder.append("'" + parameterValues[i] + "', ");
+        }
+        rQueryBuilder.append("'" + parameterValues[
+            parameterValues.length - 1] + "'), ");
+      }
+    }
+    imageFilenameBuilder.append(".png");
+    String imageFilename = imageFilenameBuilder.toString();
+    rQueryBuilder.append("'%s')");
+    String rQuery = rQueryBuilder.toString();
+
+    /* Request graph from graph controller, which either returns it from
+     * its cache or asks Rserve to generate it. */
+    byte[] graphBytes = graphController.generateGraph(rQuery,
+        imageFilename);
+
+    /* Make sure that we have a graph to return. */
+    if (graphBytes == null) {
+      response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+      return;
+    }
+
+    /* Write graph bytes to response. */
+    BufferedOutputStream output = null;
+    response.setContentType("image/png");
+    response.setHeader("Content-Length",
+        String.valueOf(graphBytes.length));
+    response.setHeader("Content-Disposition",
+        "inline; filename=\"" + imageFilename + "\"");
+    output = new BufferedOutputStream(response.getOutputStream(), 1024);
+    output.write(graphBytes, 0, graphBytes.length);
+    output.close();
+  }
+}
+
diff --git a/src/org/torproject/ernie/web/NetworkSizeImageServlet.java b/src/org/torproject/ernie/web/NetworkSizeImageServlet.java
deleted file mode 100644
index c31da86..0000000
--- a/src/org/torproject/ernie/web/NetworkSizeImageServlet.java
+++ /dev/null
@@ -1,99 +0,0 @@
-package org.torproject.ernie.web;
-
-import java.io.*;
-import java.text.*;
-import java.util.*;
-import javax.servlet.*;
-import javax.servlet.http.*;
-
-/* TODO This class shares a lot of code with the other *ImageServlet
- * classes. We should at some point try harder to reuse code. But let's
- * wait until we know what parameters besides start and end time will be
- * shared between these classes. We'll likely want to add more parameters
- * that reduce the code that can be re-used between servlets. */
-
-public class NetworkSizeImageServlet extends HttpServlet {
-
-  private GraphController graphController;
-
-  private SimpleDateFormat dateFormat;
-
-  public NetworkSizeImageServlet()  {
-    this.graphController = GraphController.getInstance();
-    this.dateFormat = new SimpleDateFormat("yyyy-MM-dd");
-    this.dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
-  }
-
-  public void doGet(HttpServletRequest request,
-      HttpServletResponse response) throws IOException,
-      ServletException {
-
-    /* Check parameters. */
-    String startParameter = request.getParameter("start");
-    String endParameter = request.getParameter("end");
-    if (startParameter == null && endParameter == null) {
-      /* If no parameters are given, set default date range to the past 30
-       * days. */
-      long now = System.currentTimeMillis();
-      startParameter = dateFormat.format(now
-          - 30L * 24L * 60L * 60L * 1000L);
-      endParameter = dateFormat.format(now);
-    } else if (startParameter != null && endParameter != null) {
-      long startTimestamp = -1L, endTimestamp = -1L;
-      try {
-        startTimestamp = dateFormat.parse(startParameter).getTime();
-        endTimestamp = dateFormat.parse(endParameter).getTime();
-      } catch (ParseException e)  {
-        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
-        return;
-      }
-      /* The parameters are dates. Good. Does end not precede start? */
-      if (startTimestamp > endTimestamp) {
-        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
-        return;
-      }
-      /* And while we're at it, make sure both parameters lie in this
-       * century. */
-      if (!startParameter.startsWith("20") ||
-          !endParameter.startsWith("20")) {
-        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
-        return;
-      }
-      /* Looks like sane parameters. Re-format them to get a canonical
-       * version, not something like 2010-1-1, 2010-01-1, etc. */
-      startParameter = dateFormat.format(startTimestamp);
-      endParameter = dateFormat.format(endTimestamp);
-    } else {
-      /* Either none or both of start and end need to be set. */
-      response.sendError(HttpServletResponse.SC_BAD_REQUEST);
-      return;
-    }
-
-    /* Request graph from graph controller, which either returns it from
-     * its cache or asks Rserve to generate it. */
-    String imageFilename = "networksize-" + startParameter + "-"
-        + endParameter + ".png";
-    String rQuery = "plot_networksize_line('" + startParameter + "', '"
-        + endParameter + "', '%s')";
-    byte[] graphBytes = graphController.generateGraph(rQuery,
-        imageFilename);
-
-    /* Make sure that we have a graph to return. */
-    if (graphBytes == null) {
-      response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
-      return;
-    }
-
-    /* Write graph bytes to response. */
-    BufferedOutputStream output = null;
-    response.setContentType("image/png");
-    response.setHeader("Content-Length",
-        String.valueOf(graphBytes.length));
-    response.setHeader("Content-Disposition",
-        "inline; filename=\"" + imageFilename + "\"");
-    output = new BufferedOutputStream(response.getOutputStream(), 1024);
-    output.write(graphBytes, 0, graphBytes.length);
-    output.close();
-  }
-}
-
diff --git a/src/org/torproject/ernie/web/RelayBandwidthImageServlet.java b/src/org/torproject/ernie/web/RelayBandwidthImageServlet.java
deleted file mode 100644
index 83475a4..0000000
--- a/src/org/torproject/ernie/web/RelayBandwidthImageServlet.java
+++ /dev/null
@@ -1,99 +0,0 @@
-package org.torproject.ernie.web;
-
-import java.io.*;
-import java.text.*;
-import java.util.*;
-import javax.servlet.*;
-import javax.servlet.http.*;
-
-/* TODO This class shares a lot of code with the other *ImageServlet
- * classes. We should at some point try harder to reuse code. But let's
- * wait until we know what parameters besides start and end time will be
- * shared between these classes. We'll likely want to add more parameters
- * that reduce the code that can be re-used between servlets. */
-
-public class RelayBandwidthImageServlet extends HttpServlet {
-
-  private GraphController graphController;
-
-  private SimpleDateFormat dateFormat;
-
-  public RelayBandwidthImageServlet()  {
-    this.graphController = GraphController.getInstance();
-    this.dateFormat = new SimpleDateFormat("yyyy-MM-dd");
-    this.dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
-  }
-
-  public void doGet(HttpServletRequest request,
-      HttpServletResponse response) throws IOException,
-      ServletException {
-
-    /* Check parameters. */
-    String startParameter = request.getParameter("start");
-    String endParameter = request.getParameter("end");
-    if (startParameter == null && endParameter == null) {
-      /* If no parameters are given, set default date range to the past 30
-       * days. */
-      long now = System.currentTimeMillis();
-      startParameter = dateFormat.format(now
-          - 30L * 24L * 60L * 60L * 1000L);
-      endParameter = dateFormat.format(now);
-    } else if (startParameter != null && endParameter != null) {
-      long startTimestamp = -1L, endTimestamp = -1L;
-      try {
-        startTimestamp = dateFormat.parse(startParameter).getTime();
-        endTimestamp = dateFormat.parse(endParameter).getTime();
-      } catch (ParseException e)  {
-        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
-        return;
-      }
-      /* The parameters are dates. Good. Does end not precede start? */
-      if (startTimestamp > endTimestamp) {
-        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
-        return;
-      }
-      /* And while we're at it, make sure both parameters lie in this
-       * century. */
-      if (!startParameter.startsWith("20") ||
-          !endParameter.startsWith("20")) {
-        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
-        return;
-      }
-      /* Looks like sane parameters. Re-format them to get a canonical
-       * version, not something like 2010-1-1, 2010-01-1, etc. */
-      startParameter = dateFormat.format(startTimestamp);
-      endParameter = dateFormat.format(endTimestamp);
-    } else {
-      /* Either none or both of start and end need to be set. */
-      response.sendError(HttpServletResponse.SC_BAD_REQUEST);
-      return;
-    }
-
-    /* Request graph from graph controller, which either returns it from
-     * its cache or asks Rserve to generate it. */
-    String imageFilename = "bandwidth-" + startParameter + "-"
-        + endParameter + ".png";
-    String rQuery = "plot_bandwidth_line('" + startParameter + "', '"
-        + endParameter + "', '%s')";
-    byte[] graphBytes = graphController.generateGraph(rQuery,
-        imageFilename);
-
-    /* Make sure that we have a graph to return. */
-    if (graphBytes == null) {
-      response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
-      return;
-    }
-
-    /* Write graph bytes to response. */
-    BufferedOutputStream output = null;
-    response.setContentType("image/png");
-    response.setHeader("Content-Length",
-        String.valueOf(graphBytes.length));
-    response.setHeader("Content-Disposition",
-        "inline; filename=\"" + imageFilename + "\"");
-    output = new BufferedOutputStream(response.getOutputStream(), 1024);
-    output.write(graphBytes, 0, graphBytes.length);
-    output.close();
-  }
-}
-
diff --git a/src/org/torproject/ernie/web/RelayPlatformsImageServlet.java b/src/org/torproject/ernie/web/RelayPlatformsImageServlet.java
deleted file mode 100644
index ee3efec..0000000
--- a/src/org/torproject/ernie/web/RelayPlatformsImageServlet.java
+++ /dev/null
@@ -1,99 +0,0 @@
-package org.torproject.ernie.web;
-
-import java.io.*;
-import java.text.*;
-import java.util.*;
-import javax.servlet.*;
-import javax.servlet.http.*;
-
-/* TODO This class shares a lot of code with the other *ImageServlet
- * classes. We should at some point try harder to reuse code. But let's
- * wait until we know what parameters besides start and end time will be
- * shared between these classes. We'll likely want to add more parameters
- * that reduce the code that can be re-used between servlets. */
-
-public class RelayPlatformsImageServlet extends HttpServlet {
-
-  private GraphController graphController;
-
-  private SimpleDateFormat dateFormat;
-
-  public RelayPlatformsImageServlet()  {
-    this.graphController = GraphController.getInstance();
-    this.dateFormat = new SimpleDateFormat("yyyy-MM-dd");
-    this.dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
-  }
-
-  public void doGet(HttpServletRequest request,
-      HttpServletResponse response) throws IOException,
-      ServletException {
-
-    /* Check parameters. */
-    String startParameter = request.getParameter("start");
-    String endParameter = request.getParameter("end");
-    if (startParameter == null && endParameter == null) {
-      /* If no parameters are given, set default date range to the past 30
-       * days. */
-      long now = System.currentTimeMillis();
-      startParameter = dateFormat.format(now
-          - 30L * 24L * 60L * 60L * 1000L);
-      endParameter = dateFormat.format(now);
-    } else if (startParameter != null && endParameter != null) {
-      long startTimestamp = -1L, endTimestamp = -1L;
-      try {
-        startTimestamp = dateFormat.parse(startParameter).getTime();
-        endTimestamp = dateFormat.parse(endParameter).getTime();
-      } catch (ParseException e)  {
-        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
-        return;
-      }
-      /* The parameters are dates. Good. Does end not precede start? */
-      if (startTimestamp > endTimestamp) {
-        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
-        return;
-      }
-      /* And while we're at it, make sure both parameters lie in this
-       * century. */
-      if (!startParameter.startsWith("20") ||
-          !endParameter.startsWith("20")) {
-        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
-        return;
-      }
-      /* Looks like sane parameters. Re-format them to get a canonical
-       * version, not something like 2010-1-1, 2010-01-1, etc. */
-      startParameter = dateFormat.format(startTimestamp);
-      endParameter = dateFormat.format(endTimestamp);
-    } else {
-      /* Either none or both of start and end need to be set. */
-      response.sendError(HttpServletResponse.SC_BAD_REQUEST);
-      return;
-    }
-
-    /* Request graph from graph controller, which either returns it from
-     * its cache or asks Rserve to generate it. */
-    String imageFilename = "platforms-" + startParameter + "-"
-        + endParameter + ".png";
-    String rQuery = "plot_platforms_line('" + startParameter + "', '"
-        + endParameter + "', '%s')";
-    byte[] graphBytes = graphController.generateGraph(rQuery,
-        imageFilename);
-
-    /* Make sure that we have a graph to return. */
-    if (graphBytes == null) {
-      response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
-      return;
-    }
-
-    /* Write graph bytes to response. */
-    BufferedOutputStream output = null;
-    response.setContentType("image/png");
-    response.setHeader("Content-Length",
-        String.valueOf(graphBytes.length));
-    response.setHeader("Content-Disposition",
-        "inline; filename=\"" + imageFilename + "\"");
-    output = new BufferedOutputStream(response.getOutputStream(), 1024);
-    output.write(graphBytes, 0, graphBytes.length);
-    output.close();
-  }
-}
-
diff --git a/src/org/torproject/ernie/web/RelayVersionsImageServlet.java b/src/org/torproject/ernie/web/RelayVersionsImageServlet.java
deleted file mode 100644
index 36c2684..0000000
--- a/src/org/torproject/ernie/web/RelayVersionsImageServlet.java
+++ /dev/null
@@ -1,99 +0,0 @@
-package org.torproject.ernie.web;
-
-import java.io.*;
-import java.text.*;
-import java.util.*;
-import javax.servlet.*;
-import javax.servlet.http.*;
-
-/* TODO This class shares a lot of code with the other *ImageServlet
- * classes. We should at some point try harder to reuse code. But let's
- * wait until we know what parameters besides start and end time will be
- * shared between these classes. We'll likely want to add more parameters
- * that reduce the code that can be re-used between servlets. */
-
-public class RelayVersionsImageServlet extends HttpServlet {
-
-  private GraphController graphController;
-
-  private SimpleDateFormat dateFormat;
-
-  public RelayVersionsImageServlet()  {
-    this.graphController = GraphController.getInstance();
-    this.dateFormat = new SimpleDateFormat("yyyy-MM-dd");
-    this.dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
-  }
-
-  public void doGet(HttpServletRequest request,
-      HttpServletResponse response) throws IOException,
-      ServletException {
-
-    /* Check parameters. */
-    String startParameter = request.getParameter("start");
-    String endParameter = request.getParameter("end");
-    if (startParameter == null && endParameter == null) {
-      /* If no parameters are given, set default date range to the past 30
-       * days. */
-      long now = System.currentTimeMillis();
-      startParameter = dateFormat.format(now
-          - 30L * 24L * 60L * 60L * 1000L);
-      endParameter = dateFormat.format(now);
-    } else if (startParameter != null && endParameter != null) {
-      long startTimestamp = -1L, endTimestamp = -1L;
-      try {
-        startTimestamp = dateFormat.parse(startParameter).getTime();
-        endTimestamp = dateFormat.parse(endParameter).getTime();
-      } catch (ParseException e)  {
-        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
-        return;
-      }
-      /* The parameters are dates. Good. Does end not precede start? */
-      if (startTimestamp > endTimestamp) {
-        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
-        return;
-      }
-      /* And while we're at it, make sure both parameters lie in this
-       * century. */
-      if (!startParameter.startsWith("20") ||
-          !endParameter.startsWith("20")) {
-        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
-        return;
-      }
-      /* Looks like sane parameters. Re-format them to get a canonical
-       * version, not something like 2010-1-1, 2010-01-1, etc. */
-      startParameter = dateFormat.format(startTimestamp);
-      endParameter = dateFormat.format(endTimestamp);
-    } else {
-      /* Either none or both of start and end need to be set. */
-      response.sendError(HttpServletResponse.SC_BAD_REQUEST);
-      return;
-    }
-
-    /* Request graph from graph controller, which either returns it from
-     * its cache or asks Rserve to generate it. */
-    String imageFilename = "versions-" + startParameter + "-"
-        + endParameter + ".png";
-    String rQuery = "plot_versions_line('" + startParameter + "', '"
-        + endParameter + "', '%s')";
-    byte[] graphBytes = graphController.generateGraph(rQuery,
-        imageFilename);
-
-    /* Make sure that we have a graph to return. */
-    if (graphBytes == null) {
-      response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
-      return;
-    }
-
-    /* Write graph bytes to response. */
-    BufferedOutputStream output = null;
-    response.setContentType("image/png");
-    response.setHeader("Content-Length",
-        String.valueOf(graphBytes.length));
-    response.setHeader("Content-Disposition",
-        "inline; filename=\"" + imageFilename + "\"");
-    output = new BufferedOutputStream(response.getOutputStream(), 1024);
-    output.write(graphBytes, 0, graphBytes.length);
-    output.close();
-  }
-}
-
diff --git a/war/WEB-INF/web.xml b/war/WEB-INF/web.xml
index 29d3579..ed0ce53 100644
--- a/war/WEB-INF/web.xml
+++ b/war/WEB-INF/web.xml
@@ -38,37 +38,49 @@
     <url-pattern>/relay-search.html</url-pattern>
   </servlet-mapping>
   <servlet>
-    <servlet-name>NetworkSizeImageServlet</servlet-name>
-    <servlet-class>org.torproject.ernie.web.NetworkSizeImageServlet</servlet-class>
+    <servlet-name>GraphImageServlet</servlet-name>
+    <servlet-class>org.torproject.ernie.web.GraphImageServlet</servlet-class>
   </servlet>
   <servlet-mapping>
-    <servlet-name>NetworkSizeImageServlet</servlet-name>
+    <servlet-name>GraphImageServlet</servlet-name>
     <url-pattern>/networksize.png</url-pattern>
   </servlet-mapping>
-  <servlet>
-    <servlet-name>RelayPlatformsImageServlet</servlet-name>
-    <servlet-class>org.torproject.ernie.web.RelayPlatformsImageServlet</servlet-class>
-  </servlet>
   <servlet-mapping>
-    <servlet-name>RelayPlatformsImageServlet</servlet-name>
-    <url-pattern>/platforms.png</url-pattern>
+    <servlet-name>GraphImageServlet</servlet-name>
+    <url-pattern>/relayflags.png</url-pattern>
   </servlet-mapping>
-  <servlet>
-    <servlet-name>RelayVersionsImageServlet</servlet-name>
-    <servlet-class>org.torproject.ernie.web.RelayVersionsImageServlet</servlet-class>
-  </servlet>
   <servlet-mapping>
-    <servlet-name>RelayVersionsImageServlet</servlet-name>
+    <servlet-name>GraphImageServlet</servlet-name>
     <url-pattern>/versions.png</url-pattern>
   </servlet-mapping>
-  <servlet>
-    <servlet-name>RelayBandwidthImageServlet</servlet-name>
-    <servlet-class>org.torproject.ernie.web.RelayBandwidthImageServlet</servlet-class>
-  </servlet>
   <servlet-mapping>
-    <servlet-name>RelayBandwidthImageServlet</servlet-name>
+    <servlet-name>GraphImageServlet</servlet-name>
+    <url-pattern>/platforms.png</url-pattern>
+  </servlet-mapping>
+  <servlet-mapping>
+    <servlet-name>GraphImageServlet</servlet-name>
     <url-pattern>/bandwidth.png</url-pattern>
   </servlet-mapping>
+  <servlet-mapping>
+    <servlet-name>GraphImageServlet</servlet-name>
+    <url-pattern>/new-users.png</url-pattern>
+  </servlet-mapping>
+  <servlet-mapping>
+    <servlet-name>GraphImageServlet</servlet-name>
+    <url-pattern>/direct-users.png</url-pattern>
+  </servlet-mapping>
+  <servlet-mapping>
+    <servlet-name>GraphImageServlet</servlet-name>
+    <url-pattern>/bridge-users.png</url-pattern>
+  </servlet-mapping>
+  <servlet-mapping>
+    <servlet-name>GraphImageServlet</servlet-name>
+    <url-pattern>/gettor.png</url-pattern>
+  </servlet-mapping>
+  <servlet-mapping>
+    <servlet-name>GraphImageServlet</servlet-name>
+    <url-pattern>/torperf.png</url-pattern>
+  </servlet-mapping>
   <servlet>
     <servlet-name>ExoneraTor</servlet-name>
     <servlet-class>org.torproject.ernie.web.ExoneraTorServlet</servlet-class>
-- 
1.7.1