Dashboard Confessional
When Panic, a
Mac software development firm with a strong flair for visual design, showed
their scratch built digital dashboard I felt an overpowering urge to make one for myself.
To avoid having to pay $3,000 for the wall-mounted monitor, I built it with a RSS-reading
wireless digital photo frame. I got it done for $150 and 2 days of work. The code
is open source so that you can make yourself one, too.
Here's just the dashboard itself:
Show/hide Table of Contents
Digital Dashboard Project
I'm a longstanding fan of having a dashboard built into one's website for
monitoring, analytics, and customer service purposes, but the reasons to have one publicly visible are different.
For Panic, it probably achieves an espirit de corps and lets them quickly communicate project status.
For a single-man "team" like my company, it gives me:
- Motivation to continue working on projects.
- Non-urgent status notifications without turning on the computer.
- Obviously visible signs of progress.
Back to the Table of Contents
I'm using a wireless digital photo frame which is capable of displaying RSS picture feeds.
An implementation detail with the particular model of photo frame I have is that those feeds
have to go through Windows Live FrameIt, which was a
source of plenty of frustration to me for reasons which will be made clear shortly.
There are a million ways to generate pictures. Since I happen to run a business which
does PDF creation and that hammer makes me see nails everywhere, I used
to lay out text and graphics, and then
to convert the PDFs to images.
The actual generation of the stats is done on my server, with Rails. In addition to pulling
some values from the database, I also did a bit of work with modern web technologies like RSS and web
services, partially for practical utility and partially to show how you can do it. Since some of the
statistics are expensive to calculate or fetch, I elected to do it asychronously via Delayed::Job. This
could just as easily be done with a cron task on a repeating timer.
Back to the Table of Contents
Here's exactly what I'm using:
- Buffalo PF-50WG Wireless Digital Photo Frame (I got mine from Amazon Japan)
- Windows Live FrameIt (free), required by the frame to wrap my RSS feed.
- Prawn for PDF generation (the easiest way to lay out images I know)
- GraphicsMagick for converting PDFs to images
- SimpleRSS to parse RSS feeds for data for the frame
- Delayed Job
- Ruby on Rails (tying everything together)
In addition, I had a beautiful set of education icons from IconShock
lying around, so I used them for some visual flair. (They'd run about $100 if purchased solely for this project.)
Back to the Table of Contents
I didn't want the photo frame to just repeat what I can tell from a glance at my web-based
dashboard. Also, while I could use it for failure monitoring, the Ride of the Valkyries ringtone
that gets triggered on my cellphone is much more attention-grabbing. I wanted something that fits in
with my lifestyle: an understated reminder of work remaining to be done, for when I'm not on the computer.
The dashboard currently shows:
- A count of unread emails (total and support-related) from Google Apps.
- Sales statistics (for the current month, year, and the percentage growth and forecast)
- A guilt-trip feature: time passed since last release, A/B test released, and blog article written.
- A quick one-glance business health check.
- Titles from recent emails.
- Tweets, because I know the first question folks would ask is "Can you put Twitter on it!?". Heaven
knows how we wasted time before Twitter.
Back to the Table of Contents
This project took about three times as long as it should have:
Windows FrameIt is an abomination.The photo frame presumably has limited onboard processing,
so to avoid having to be compatible with any random RSS feed, it passes them through Windows FrameIt,
a Microsoft SAAS offering which is essentially "input an RSS feed, get an RSS feed in a specified format".
However, Windows FrameIt is a terrible piece of software from the perspective of a developer -- the
error messages are totally opaque (try {... } catch(Exception) { System.out.println("This feed had no images in it.");}
and compatibility with published RSS specs is spotty. I eventually got it working by copying my Flickr feed and
migrating each XML element to the form needed by the dashboard. You wouldn't think a dashboard would need
a photographer attribution, but apparently Windows FrameIt it won't run without it.
PNG incompatibility. I didn't realize until late in the project that the photo frame
cannot use PNGs, despite Windows FrameIt helpfully coercing them into (unreadable) JPGs for it.
This ate a few hours discovering and then tweaking so that the JPGification process didn't result
in ugliness.
Generic hackiness. Laying out anything as an image will always be harder and probably uglier than
doing the same operating in HTML/CSS. However, it means that you don't need a browser (and machine capable of running
one) for the display, so I pressed on.
Back to the Table of Contents
This code is not productized -- you'll have to do significant modifications
to make it useful for your needs (for example, coding the actual logic to calculate
your own statistics.)
This code is available under the MIT license, just like Rails. Please feel free
to modify it to suit your purposes, use it as a springboard, or extend and
commercialize it. If you'd rather read it in your IDE, click the little icon
in the top-right corner to copy/paste.
We'll begin with the controller, which defines one action for the RSS, one for
serving images, and one for serving image thumbnails.
Show/hide code for dashboard_controller.rb
class DashboardController < ApplicationController
IMAGE_PATH = "#{RAILS_ROOT}/storage/dashboard/dashboard"
THUMBNAIL_PATH = "#{RAILS_ROOT}/storage/dashboard/dashboard-thumbnail"
def image
@format = params[:format] || Dashboard.format
#Tell Delayed::Job to update the dashboard for the *next* request.
db = Dashboard.new
db.send_later :update_dashboard_image, @format
#Immediately serve the pre-existing dashboard image.
if (RAILS_ENV == "development")
#Send_file is adequate for development, but locks the Mongrel.
#Not a great idea in production.
send_file(IMAGE_PATH + "." + @format, :type => "image/#{@format}", :disposition => "inline")
else
#This has nginx serve the file from disk.
response.headers["Content-Type"] = "image/#{@format}"
response.headers["X-Accel-Redirect"] = "/dashboard-internal/dashboard.#{@format}"
render :nothing => true
end
end
def thumbnail_image
@format = params[:format] || Dashboard.format
db = Dashboard.new
db.send_later :update_dashboard_image_thumbnail, @format
if (RAILS_ENV == "development")
send_file(THUMBNAIL_PATH + "." + @format, :type => "image/#{@format}", :disposition => "inline")
else
response.headers["Content-Type"] = "image/#{@format}"
response.headers["X-Accel-Redirect"] = "/dashboard-internal/dashboard-thumbnail.#{@format}"
render :nothing => true
end
end
def rss
@format = params[:format] || Dashboard.format
#We keep posts on the RSS feed for 1 ~ 2 hours,
#and update every five minutes.
time = Time.now
time_last_hour = time - 1.hour - time.min.minutes - time.sec
@times = []
time = time_last_hour
while (time < Time.now) do
@times << time
time = time + 5.minutes
end
@times.reverse!
render :template => 'dashboard/dashboard.rss', :layout => false
end
end
Almost all the heavy lifting -- including data access, calculations, and PDF generation --
is performed by the model. You'll likely rewrite most of this. All display-oriented code
is in the pdf method and below.
Show/hide code in dashboard.rb
require 'simple-rss'
require 'open-uri'
class Dashboard
#Including this to use time_ago_in_words helper.
include ActionView::Helpers::DateHelper
gem 'prawn', '= 0.5.1'
gem 'prawn-layout', '= 0.2.1'
require 'prawn'
require 'prawn/layout'
require 'cgi'
#Paths on disk to write the PDF and image to.
#The image omits the extension, so that we can create it in any format.
PDF_PATH = "#{RAILS_ROOT}/storage/dashboard/dashboard.pdf"
IMAGE_PATH = "#{RAILS_ROOT}/storage/dashboard/dashboard"
THUMBNAIL_PATH = "#{RAILS_ROOT}/storage/dashboard/dashboard-thumbnail"
@@logo = "#{RAILS_ROOT}/public/images/dashboard/logo_black_bg.png"
#Feeds that we'll fetch data from.
@@gmail_feeds = "https://mail.google.com/a/bingocardcreator.com/feed/atom"
@@blog_feed = "http://www.kalzumeus.com/feed/"
#Default image format to use. Photo frame doesn't like pngs. *cries*
@@format = "gif"
cattr_reader :format
#Username, password. Set in environment.rb, so that I wouldn't accidentally publish them.
@@http_options ||= [nil, nil]
cattr_accessor :http_options
COLOR_GREEN = '04C608'
COLOR_BLUE = '518DA9'
COLOR_RED = 'FF0000'
COLOR_BLACK = '000000'
COLOR_WHITE = 'FFFFFF'
#Convenience method for locating images.
def image_path(name)
"#{RAILS_ROOT}/public/images/badges/128/#{name}_128.png"
end
#Delayed::Job doesn't serialize things well if they're totally null.
def initialize
@live = true
end
def last_blog_post_time
Rails.cache.fetch("Dashboard.last_blog_post_time", :expires_in => 5.minutes) do
url = @@blog_feed
rss = SimpleRSS.new(open(url, "r"))
a = (rss.items || []).first
a.nil? ? "N/A" : time_ago_in_words(a.pubDate)
end
end
def last_commit
svn = open("|svn info -r HEAD #{RAILS_ROOT}/app").readlines
year = Date.today.year
time_line = svn.select {|line| line =~ /#{Date.today.year}.*\+/}
time = (Time.parse(time_line.first[/#{year}[^\(]*/]) || nil) rescue nil
time.nil? ? "N/A" : time_ago_in_words(time)
end
def last_test
time_ago_in_words(Abingo::Experiment.first(:order => "created_at desc").created_at)
end
def count_of_emails(label = nil)
url = @@gmail_feeds
unless label.nil?
url = "#{url}/#{label}"
end
email_stats = stats_from_gmail(url)
email_stats[:fullcount]
end
def recent_emails(size = 5, label = nil)
url = @@gmail_feeds
unless label.nil?
url = "#{url}/#{label}"
end
email_stats = stats_from_gmail(url)
emails = email_stats[:items][0..(size - 1)]
emails
end
#I personally don't really care about Twitter but I know *somebody* is going
#to ask, so here you go.
def recent_tweets(size = 10)
Rails.cache.fetch("Dashboard.recent_tweets(#{size})", :expires_in => 1.minute) do
url = "http://search.twitter.com/search.atom?q=%40patio11"
rss = SimpleRSS.new(open(url, "r"))
tweets = (rss.items || [])[0..(size - 1)]
tweets.map do |tweet|
tweeter = "@" + tweet.author.sub(/^.*\//m, "") #Only want their @username
tweet = tweet.title
"#{tweeter}: #{CGI.unescapeHTML(tweet)}"
end
end
end
#Grabs count of unread email and as many items as possible from
#the specified Gmail feed URL.
def stats_from_gmail(url)
Rails.cache.fetch("Dashboard.gmail_feed(#{url})", :expires_in => 5.minutes) do
SimpleRSS.feed_tags << :fullcount unless SimpleRSS.feed_tags.include? :fullcount
rss = SimpleRSS.new(open(url, "r", {:http_basic_authentication => @@http_options}))
result = {}
result[:fullcount] = rss.feed.fullcount
result[:items] = (rss.items || []).map do |item|
author = item.author.split("\n").last
title = CGI::unescape(item.title)
"#{author}: #{title}"
end
result
end
end
#Checks status of various internal data sources.
def status_checks
s = {}
cards = Card.count rescue 0
s[:db] = (cards != 0)
cache_status = Rails.cache.stats rescue nil
s[:memcached] = (cache_status != nil)
data_store_status = Abingo.cache.stats rescue nil
s[:data_store] = (data_store_status != nil)
dj_status = Delayed::Job.count rescue nil
s[:dj] = (dj_status != nil)
s[:dj_high] = Delayed::Job.count(:conditions => "priority >= 0") rescue "???"
s[:status] = s[:db] && s[:memcached] && s[:data_store] && s[:dj] && s[:dj_high] < 10
s
end
#Sales statistics. You can ignore these.
def sales
Rails.cache.fetch("Dashboard.sales(#{Sale.count})", :expires_in => 1.hour) do
s = {}
sales_this_month = Sale.find(:all, :conditions => ['time > ?', Date.today.beginning_of_month.to_time])
dollars_this_month = sales_this_month ? sales_this_month.map {|sale| sale.gross.to_d}.sum : 0
sales_this_year = Sale.find(:all, :conditions => ['year(time) = ?', Date.today.year])
sales_this_month_last_year = Sale.find(:all, :conditions => ['year(time) = ? AND month(time) = ?', Date.today.year - 1, Date.today.month])
dollars_this_month_last_year = sales_this_month_last_year ? sales_this_month_last_year.map {|sale| sale.gross.to_d}.sum : 0
sales_last_year = Sale.find(:all, :conditions => ['year(time) = ?', Date.today.year - 1])
dollars_this_year = sales_this_year ? sales_this_year.map {|sale| sale.gross.to_d}.sum : 0
dollars_last_year = sales_last_year ? sales_last_year.map {|sale| sale.gross.to_d}.sum : 0
predicted_sales = dollars_this_month * (1.0 * Date.today.end_of_month.day / Date.today.day)
predicted_sales_yearly = dollars_this_year * (1.0 * (Date.today.end_of_year - Date.today.beginning_of_year + 1) / (Date.today - Date.today.beginning_of_year + 1))
yoy_growth_monthly = ((predicted_sales / dollars_this_month_last_year) - 1) * 100
yoy_growth_yearly = ((predicted_sales_yearly / dollars_last_year) - 1) * 100
s[:sales_this_month] = "$#{dollars_this_month.to_i}"
s[:sales_this_month_predicted] = "$#{predicted_sales.to_i}"
s[:sales_this_year] = "$#{dollars_this_year.to_i}"
if yoy_growth_monthly > 0
s[:yoy_growth_monthly] = sprintf("up %4.2f%", yoy_growth_monthly)
else
s[:yoy_growth_monthly] = sprintf("down %4.2f%", 0 - yoy_growth_monthly)
end
if yoy_growth_monthly > 0
s[:yoy_growth_yearly] = sprintf("up %4.2f%", yoy_growth_yearly)
else
s[:yoy_growth_yearly] = sprintf("down %4.2f%", 0 - yoy_growth_yearly)
end
s
end
end
def emails_and_tweets
s = {}
s[:email_count] = count_of_emails rescue "???"
s[:email_count_support] = count_of_emails(:support) rescue "???"
mails_and_tweets = recent_emails rescue []
if (mails_and_tweets.size < 5)
tweets = recent_tweets(5 - mails_and_tweets.size)
mails_and_tweets += tweets
end
s[:mails_and_tweets] = mails_and_tweets
s
end
#User login counts. Not currently shown anywhere.
def logins
s = {}
s[:logins_today] = Stat.login_count(Date.today) rescue "???"
s[:logins_this_week] = Stat.login_count(Date.today - 7.days) rescue "???"
s
end
def activity_status
s = {}
s[:blog] = last_blog_post_time rescue "???"
s[:commit] = last_commit rescue "???"
s[:last_test] = last_test rescue "???"
s
end
def collect_data
s = {}
s.merge!(logins)
s.merge!(emails_and_tweets)
s.merge!(status_checks)
s.merge!(sales)
s.merge!(activity_status)
s
end
#Creates PDF of dashboard contents.
def pdf
s = collect_data
@pdf = Prawn::Document.new(:page_size => [850, 650], :margin => 0) #800x600 with a bit of room. ImageMagic will take care of it.
@pdf.fill_color COLOR_BLACK
@pdf.rectangle([0, 600], 800, 600)
@pdf.fill
@pdf.fill_color COLOR_GREEN
@pdf.stroke_color COLOR_GREEN
@pdf.font_size 36
@pdf.font "Helvetica"
@pdf.image(@@logo, :at => [615, 575])
text_options = {:at => [36 + 128, 150 - 36], :height => 150 - 36, :width => 400 - 36 - 128, :overflow => :ellipses}
add_block([0, 600], "homework") do |box|
@pdf.font_size 36
box.text_box "Emails: #{s[:email_count]}\nSupport: #{s[:email_count_support]}", text_options
end
add_block([0, 450], "achievement") do |box|
@pdf.font_size 16
text = "Sales this month: #{s[:sales_this_month]} (#{s[:yoy_growth_monthly]})\n"
text += "Sales prediction: #{s[:sales_this_month_predicted]}\n"
text += "Sales (#{Date.today.year}): #{s[:sales_this_year]} (#{s[:yoy_growth_yearly]})"
box.text_box text, text_options
end
add_block([400, 450], "graduation") do |box|
@pdf.font_size 32
text = "#{Date.parse("2010-04-01") - Date.today} days until you quit your job!"
box.text_box text, text_options.merge({:overflow => :wrap})
end
add_block([0, 300], "schedule") do |box|
@pdf.font_size 16
text = "Last post: #{s[:blog]}\n"
text += "Last commit: #{s[:commit]}\n"
text += "Last test: #{s[:last_test]}"
box.text_box text, text_options
end
#If all status are good, make this a pleasant image. If not, make it a warning sign.
status_icon = s[:status] ? "teachers_day" : "electricity"
add_block([400, 300], status_icon) do |box|
@pdf.font_size 16
text = "DB: #{s[:db] ? "Up!" : "Down!"}\n"
text += "Memcache: #{s[:memcached] ? "Up!" : "Down!"}\n"
text += "MemcacheDB: #{s[:data_store] ? "Up!" : "Down!"}\n"
text += "DJ: #{s[:dj] ? "Up!" : "Down!"}\n"
text += "DJ Queue: #{s[:dj_high]}"
box.text_box text, text_options
end
add_long_block([0, 150], "history") do |box|
@pdf.font_size 12
box.text_box s[:mails_and_tweets].join("\n"), text_options.merge({:width => 800 - 36 - 128})
end
@pdf
end
#Convenience method for creating a 400x150 block
#(one eighth of the dashboard) with an image header in it.
def add_block(point, image_name, &block)
@pdf.bounding_box(point, :width => 400, :height => 150) do
@pdf.image(image_path(image_name), :at => [18, @pdf.bounds.height - 11])
yield(@pdf)
end
end
#Convenience method for creating a 400x150 block
#(one fourth of the dashboard) with an image header in it.
def add_long_block(point, image_name, &block)
@pdf.bounding_box(point, :width => 800, :height => 150) do
@pdf.image(image_path(image_name), :at => [18, @pdf.bounds.height - 11])
yield(@pdf)
end
end
#Updates the dashboard image on disk.
#Note this is fired asychronously with actual requests to
#read the image.
def update_dashboard_image(image_format = Dashboard.format)
image_path = IMAGE_PATH + "." + image_format
#This will prevent dashboard from updating more than once every 4 minutes in production.
refresh_period = (RAILS_ENV == "development")? 1.second : 4.minutes
Rails.cache.fetch("Dashboard.update_dashboard_image", :expires_in => refresh_period) do
@pdf = pdf
File.delete(PDF_PATH) if File.exists?(PDF_PATH)
@pdf.render_file(PDF_PATH)
if (image_format == "jpg")
#JPGs need a bit of sharpening or the text starts to look terrible.
system("gm convert #{PDF_PATH}[0] -trim -sharpen 1 -quality 100 #{image_path}")
else
system("gm convert #{PDF_PATH}[0] -trim #{image_path}")
end
true
end
end
def update_dashboard_image_thumbnail(image_format = Dashboard.format)
refresh_period = (RAILS_ENV == "development")? 1.second : 4.minutes
image_path = THUMBNAIL_PATH + "." + image_format
Rails.cache.fetch("Dashboard.update_dashboard_image_thumbnail", :expires_in => refresh_period) do
system("gm convert #{IMAGE_PATH} -resize 75x75 #{image_path}")
true
end
end
end
The view for the RSS feed is absurdly fragile, at least if you're using Microsoft FrameIt.
You can mostly copy/paste it. Note this also supports Google Reader, Firefox, and other
RSS readers for testing.
Show/hide code in dashboard.rb
Finally, there are the entries in routes.rb. Since this system has no security whatsoever
(not that it needs it -- aside from the email headers all the information is public) I put
a big random string in the URLs to sort of deter prying eyes.
You'll note the :id in the RSS feed URL. That just lets me change the physical URL on
a whim, which was important in development because Microsoft perma-cached the content of
early versions frequently and would not update at the speed I was developing at.
map.dashboardRSS '/security-through-obscurity/:id/:format.xml', :controller => 'dashboard', :action => 'rss'
map.dashboardImage '/security-through-obscurity/:timestamp.:format', :controller => 'dashboard', :action => 'image'
map.dashboardImageThumbnail '/security-through-obscurity/dashboard-thumbnail.:format', :controller => 'dashboard', :action => 'thumbnail_image'
There is also some magic done in my nginx config. This is used for the
nginx file serving performed by the controller. Strictly speaking this URL
doesn't have to be internal, but I wanted to force all access through Rails.
location /dashboard-internal/ {
internal;
root /var/www/apps/DailyBingoCards/shared/storage;
expires 5M;
rewrite ([^\/]*)$ /dashboard/$1 break;
}
Back to the Table of Contents
There are a few obvious ways you could improve this system. I might implement
some of them in the future.
- Swap the image template used each interval, allowing the dashboard to display
more stuff, in bigger font, in a more engaging manner.
-
Include sales graphs, server monitoring graphs, etc created in Gruff.
-
Make the design pretty. As you can probably tell, I am not a much-beloved
Mac software design house.
Back to the Table of Contents
I hope you enjoyed this article. If you have a dashboard, I'm always interested
in seeing what other people are doing, for inspiration.
Shoot me an email at patrick@this-domain or feel free to tweet at @patio11.
Your comment will, of course, show up on my dashboard.
Please pass this article to anyone you think will benefit from it.