From 3232f9d90114778cb8d38cc1bc8477435dc65259 Mon Sep 17 00:00:00 2001 From: Florian Pritz Date: Sat, 28 Feb 2009 14:36:14 +0100 Subject: initial commit --- philesight/README | 100 ++++++++++ philesight/philesight | 62 +++++++ philesight/philesight.cgi | 127 +++++++++++++ philesight/philesight.rb | 458 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 747 insertions(+) create mode 100644 philesight/README create mode 100755 philesight/philesight create mode 100755 philesight/philesight.cgi create mode 100644 philesight/philesight.rb (limited to 'philesight') diff --git a/philesight/README b/philesight/README new file mode 100644 index 0000000..1a9905e --- /dev/null +++ b/philesight/README @@ -0,0 +1,100 @@ + +Summary +======= + +Philesight is a tool to browse your filesystem and see where the diskspace is +being used at a glance. Philesight is implemented as a simple command line +program that generates PNG files; a wrapper CGI script is supplied to allow +navigating through the filesystem. + +Philesight is actually a clone of the filelight program. Wheres filelight is +ment as an interactive, user friendly application for the X-windows desktop, +philesight is designed to run on a remote server without graphical user +interface. + + +Dependencies +============ + +Philesight is written in ruby1.8, and requires the berkely-db4 and cairo +ruby-libraries. + +Changelog +========= + +2008-05-29 Added option (use_gradients) to enable/disable gradients + in circular graph. Added option to set graph size. + +2008-04-16 Added optional table with file list to cgi, some bugfixes, + increased default graph size to 800. (Thanks to Guillaume + Sachot) + +2008-03-17 Fixed bug where no image was shown with CGI's running + on apache-mpm-worker. + +2007-03-12 Fixed crash when indexing file named 'rest' + +2006-12-09 Workaround for segmentaion fault with ruby 1.8.5 + +Usage +===== + +Philesight can be run in different modes: first, the filesystem is indexed +and the results are stored in a database. When the database is generated, the +tool can used to generate PNG files with the graphs. The database should be +updated every once in a while of course. + + * Index bulding: + + ./philesight --db {db} --index {path} + + {db} is the name of the database file that will be generated. It is a good + idea to throw away existing database files before indexing to avoid removed + files showing in your graph. + + {path} is the top-level directory to start indexing. Usually, '/' is a + good choice. + + This process might take some time, since it traverses the whole tree + from path-to-index downward and stores the data into the db. Make + sure to remove previous database files before indexing. + + + * PNG generating: In this mode, philesight generates a graph of the + filesystem from path and 4 levels downward. i + + ./philesight --db {db} --path {path} --draw {png} + + {db} is the filename of the index file that was generated earlier, + {path} is the directory which should be drawn, and {png} is the filename + of the to-be-generated PNG image file + + + * CGI: Philesight comes with a CGI module that can be run from within a + web server. Edit the configurable parameters in the top of this file + to point to the database. Make sure the datbase file is readable by + the webserver! + + Available options: + + * db: Path to database file. + + * default_path: default path to show when CGI first loads. + + * size: graph size. 800 pixels is often a good choice. + + * show_list: render list of directories and their sizes blow graph. + + * use_gradients: use gradient colors in graph (set to 'false' to generate + smaller PNG files) + + +Bugs +==== + + * Philesight is a ruby program, and thus is not particulary fast. + * Indexing takes longer than necassery. + * Proper error handling is mostly missing. + * Not very well tested. + * It might eat your disks. + diff --git a/philesight/philesight b/philesight/philesight new file mode 100755 index 0000000..5ab0e09 --- /dev/null +++ b/philesight/philesight @@ -0,0 +1,62 @@ +#!/usr/bin/ruby +# vi: ts=2 sw=2 + +require 'getoptlong' +require 'philesight' + +opts = GetoptLong.new( + [ "--index", "-i", GetoptLong::REQUIRED_ARGUMENT ], + [ "--draw", "-d", GetoptLong::REQUIRED_ARGUMENT ], + [ "--path", "-p", GetoptLong::REQUIRED_ARGUMENT ], + [ "--db", "-D", GetoptLong::REQUIRED_ARGUMENT ], + [ "--dump", "-u", GetoptLong::NO_ARGUMENT ], + [ "--help", "-h", GetoptLong::NO_ARGUMENT ] +) + +def usage + puts + puts "usage: philesight " + puts + puts "Options:" + puts " --db Set path to database file" + puts " --path Path to show in generated image" + puts " --index Top-level directory to start indexing" + puts " --dump Dump database to readable format" + puts + puts "Examples:" + puts " Index to database: philesight --db --index " + puts " Generate PNG: philesight --db --path --draw " + puts +end + + +t = Philesight.new +path = "" + +opts.each do |opt, arg| + + case opt + when "--draw" + t.draw(path, arg) + + when "--index" + t.readdir(arg) + + when "--path" + path = arg + + when "--db" + t.db_open(arg) + + when "--dump" + t.dump + + else + usage + + end +end + +# +# End +# diff --git a/philesight/philesight.cgi b/philesight/philesight.cgi new file mode 100755 index 0000000..969269a --- /dev/null +++ b/philesight/philesight.cgi @@ -0,0 +1,127 @@ +#!/usr/bin/ruby +# vi: ts=4 sw=4 + +require 'philesight' +require 'cgi' + +# Config variables + +db = "./ps.db" +default_path = "/" +size = 800 +show_list = true +use_gradients = true + +# Get parameters from environment and CGI. ISMAP image maps do not return a +# proper CGI parameter, but only the coordinates appended after a question +# mark. If this is found in the QUERY_STRING, assume the 'find' command + +cgi = CGI.new; +qs = ENV["QUERY_STRING"] +cmd = cgi.params['cmd'][0] +path = cgi.params['path'][0] || default_path + +if(qs && qs =~ /\?(\d+,\d+)/ ) then + find_pos = $1 + cmd = 'find' +end + +ps = Philesight.new(4, size, use_gradients) +ps.db_open(db) + +# Perform action depending on 'cmd' parameter + +case cmd + + when "img" + puts "Content-type: image/png" + puts + $stdout.flush + ps.draw(path, "-") + + when "find" + if(find_pos =~ /(\d+),(\d+)/) then + x, y = $1.to_i, $2.to_i + url = "?path=%s" % ps.find(path, x, y) + puts "Content-type: text/html" + puts + puts '' + puts '' + puts '' + puts ' ' + puts ' ' + puts '' + puts '' + puts '' + end + + else + random = "" + 1.upto(32) { random += (rand(26) + ?a).chr } + puts "Content-type: text/html" + puts + puts '' + puts '' + puts '' + puts ' ' + puts " Disk usage : #{path}" + puts ' ' + puts '' + puts '' + puts '

' + puts ' ' + ' + puts '

' + + if show_list then + # Array of files + content = ps.listcontent("#{path}") + if(content && content[0]) then + puts ' ' + puts ' ' + puts ' ' + puts ' ' + puts ' ' + puts ' ' + + if(content[1].size > 0) then + linenum = 0 + + content[1] = content[1].sort_by { |f| - f[:size] } + content[1].each do |f| + if(linenum%2 == 0) then + print ' ' + else + print ' ' + end + + puts '' + + linenum += 1 + end + end + puts ' ' + puts '
FilenameSize
' + content[0][:path].to_s + '' + content[0][:humansize].to_s + '
' + f[:path].to_s + '' + f[:humansize].to_s + '
' + end + end + + puts '' + puts '' +end + +# +# End +# + diff --git a/philesight/philesight.rb b/philesight/philesight.rb new file mode 100644 index 0000000..b669927 --- /dev/null +++ b/philesight/philesight.rb @@ -0,0 +1,458 @@ +#!/usr/bin/ruby +# vi: ts=2 sw=2 + +require 'getoptlong' +require 'cgi' +require 'cairo' +require 'bdb' + +class PNGWriter + + def initialize(fname) + if fname != "-" then + @fd = File.open(fname, "w") + end + end + + def write(data) + if @fd then + @fd.write(data) + else + print(data) + end + return data.length + end +end + + +class Philesight + + def initialize(ringcount=4, size=800, use_gradient=true) + @max_files_per_dir = 50 + @w = size + @h = size + @cx = @w / 2 + @cy = @h / 2 + @ringcount = ringcount + @ringwidth = ((size-50)/2) / (ringcount+1) + @use_gradient = use_gradient + @find_a = 0 + @find_r = 0 + end + + + # + # Open the database. Try read-write mode first, if this fails, re-open readonly + # + + def db_open(fname) + begin + @db = BDB::Btree.open fname , nil, BDB::CREATE, 0644, "set_pagesize" => 1024, "set_cachesize" => [0, 32*1024,0] + rescue + @db = BDB::Btree.open fname , nil, BDB::RDONLY, 0644, "set_pagesize" => 1024, "set_cachesize" => [0, 32*1024,0] + end + end + + + # + # Dump database in human-readable form + # + + def dump + @db.keys.each do |f| + puts "%s %s" % [ f, Marshal::load(@db[f]).inspect ] + end + end + + + # + # Concatenate a directory and a filename + # + + def addpath(a, b) + return a + b if(a =~ /\/$/) + return a + "/" + b + end + + + # + # Read a directory and add to the database; this function is recursive + # for sub-directories + # + + def readdir(dir) + + size_file = {} + size_dir = {} + size_total = 0 + + # Traverse the directory and collect the size of all files and + # directories + + begin + Dir.foreach(dir) do |f| + + if(f != "." && f != "..") then + + f_full = addpath(dir, f) + stat = File.lstat(f_full) + + if(!stat.symlink?) then + + if(stat.file?) then + size = File.size(f_full) + size_file[f] = size + size_total += size + end + + if(stat.directory?) then + size = readdir(f_full) + if(size > 0) then + size_dir[f] = size + size_total += size + end + end + end + end + + end + rescue SystemCallError => errmsg + puts errmsg + end + + # If there are a lot of small files in this directory, group + # the smallest into one entry to avoid clutter + + if(size_file.keys.length > @max_files_per_dir) then + list = size_file.keys.select { |f| size_file[f] < size_total / @max_files_per_dir } + rest = 0 + list.each do |f| + rest += size_file[f] + size_file.delete(f) + end + size_file['rest'] = rest + end + + # Store the files in the database + + size_file.keys.each do |f| + f_full = addpath(dir, f) + @db[f_full] = Marshal::dump( [ size_file[f], [] ] ) + end + + # Store this directory with the list of children in the database + + size = size_dir.merge(size_file) + children = size.keys.sort + @db[dir] = Marshal::dump( [ size_total, children ] ) + return size_total + end + + + # + # Draw one section + # + + def draw_section(cr, ang_from, ang_to, r_from, r_to, brightness) + + ang_to, ang_from = ang_from, ang_to if (ang_to < ang_from) + + if(brightness > 0) then + r, g, b = hsv2rgb((ang_from+ang_to)*0.5 / (Math::PI*2), 1.0-brightness, brightness/2+0.5) + else + r, g, b = 0.9, 0.9, 0.9 + end + + # Instead of using the r_from and r_to for the radial pattern, the stops + # are calculated. This is to work around a bug in cairo + + r_total = ((@w - 50) / 2).to_f + pat = Cairo::RadialPattern.new(@cx, @cy, 0, @cx, @cy, r_total) + if @use_gradient then + pat.add_color_stop_rgb(r_from / r_total, r*0.8, g*0.8, b*0.8) + pat.add_color_stop_rgb(r_to / r_total, r*1.5, g*1.5, b*1.5) + else + pat.add_color_stop_rgb(0, r, g, b) + end + + cr.new_path + cr.arc(@cx, @cy, r_from, ang_from, ang_to) + cr.arc_negative(@cx, @cy, r_to, ang_to, ang_from) + cr.close_path + + cr.set_source(pat) + cr.fill_preserve + end + + + # + # Draw ring. This function is recursive for the outer rings + # + + def draw_ring(cr, level, ang_min, ang_max, path) + + ang_from = ang_min + ang_to = ang_min + ang_range = (ang_max - ang_min).to_f + r_from = level * @ringwidth + r_to = r_from + @ringwidth + + unless(@db[path]) then + return "/" + end + + total_path, child_path = Marshal::load( @db[path] ) + + # Draw a section proportional to the size of each file or subdir + + child_path.each do |f| + + f_full = addpath(path, f) + total_f, child_f = Marshal::load( @db[f_full] ) + + # Calculate start and end angles and draw section + + ang_from = ang_to + ang_to += ang_range * total_f / total_path if(total_path > 0) + brightness = r_from.to_f / @cx + brightness = 0 if(f == 'rest') + draw_section(cr, ang_from, ang_to, r_from, r_to, brightness) if(cr) + + # If we are looking for the path of an (x,y) pair, check if this section matches + + if( (@find_a >= ang_from) && (@find_a <= ang_to) && (@find_r >= r_from) && (@find_r <= r_to) ) then + @find_path = f_full + end + + # Draw outer rings + + if(level < @ringcount) then + draw_ring(cr, level+1, ang_from, ang_to, f_full) + else + draw_section(cr, ang_from, ang_to, r_to, r_to+5, 0.5) if(cr && child_f.nitems > 0) + end + + # Generate and save labels of filenames/sizes + + if(cr && (ang_to - ang_from > 0.2)) then + size = filesize_readable(total_f) + x, y = pol2xy((ang_from+ang_to)/2, (r_from+r_to)/2) + label = {} + label[:x] = x + label[:y] = y + label[:text] = "%s\n%s" % [ f, size] + @labels << label + end + end + end + + + # + # Draw graph of the given path + # + + def draw(path, fname) + + # Create drawing context, white background + + format = Cairo::FORMAT_ARGB32 + surf = Cairo::ImageSurface.new(format, @w, @h) + cr = Cairo::Context.new(surf) + writer = PNGWriter.new(fname) + + # Draw top level filename and size + + unless(@db[path]) then + draw_text(cr, @cx, @cy, "Path '#{path}' not found in database", 12) + cr.target.write_to_png(writer) + return + end + + total_path, child_path = Marshal::load( @db[path] ) + draw_text(cr, @cx, 10, "%s (%s)" % [ path, filesize_readable(total_path) ], 14, true) + draw_text(cr, @cx, @cy, "cd ..", 14, true) + + # Draw rings, recursively + + @labels = [] + draw_ring(cr, 1, 0, Math::PI*2, path) + + # Draw circles on ring borders + + cr.set_source_rgba(0, 0, 0, 0.7) + 0.upto(@ringcount+1) do | level | + cr.new_path + cr.set_line_width(0.3) + cr.arc(@cx, @cy, level * @ringwidth, 0, 2*Math::PI) + cr.stroke + end + + # Draw labels on top of graph + + @labels.each do |label| + + cr.select_font_face("Sans", Cairo::FONT_SLANT_NORMAL, Cairo::FONT_WEIGHT_NORMAL) + cr.set_font_size(9) + + # Draw text 4 times in a dark color, one time white on top + + [[-1, 0, 0.2], [+1, 0, 0.2], [0, -1, 0.2], [0, +1, 0.2], [0, 0, 0.9]].each do |dx, dy, color| + cr.set_source_rgba(color, color, color, 1.0) + draw_text(cr, label[:x]+dx, label[:y]+dy, label[:text]) + end + + end + + # Generate PNG file + + cr.target.write_to_png(writer) + end + + + # + # List files/dir of the given path + # + + def listcontent(path) + + unless(@db[path]) then + return nil + end + + dircontent = [] + + total_path, child_path = Marshal::load( @db[path] ) + + currentdir = {} + currentdir[:path] = path + currentdir[:humansize] = filesize_readable(total_path) + + child_path.each do |f| + f_full = addpath(path, f) + total_f, child_f = Marshal::load( @db[f_full] ) + + fileinfo = {} + fileinfo[:path] = f_full + fileinfo[:size] = Integer(total_f) + fileinfo[:humansize] = filesize_readable(total_f) + dircontent << fileinfo + end + + return [currentdir, dircontent] + end + + + # + # Find the path belonging to a (x,y) position in the graph + # + + def find(path, x, y) + @find_a, @find_r = xy2pol(x, y) + @find_path = File.dirname(path) + draw_ring(nil, 1, 0, Math::PI*2, path) + @find_path + end + + + private + + + # + # Draw text on pos x,y + # + + def draw_text(cr, x, y, text, size=11, bold=false) + + lines = text.count("\n") + 1 + y -= (lines-1) * (size+2) / 2.0 + + cr.select_font_face("Sans", Cairo::FONT_SLANT_NORMAL, bold ? Cairo::FONT_WEIGHT_BOLD : Cairo::FONT_WEIGHT_NORMAL) + cr.set_font_size(size) + + text.split("\n").each do |line| + extents = cr.text_extents(line) + w = extents.width + h = extents.height + cr.move_to(x - w/2, y + h/2) + cr.show_text(line) + y += size+2 + end + end + + + # + # convert color from (h,s,v) to (r,g,b) colorspace + # + + def hsv2rgb(h, s, v) + + h = h.to_f + s = s.to_f + v = v.to_f + + h *= 6.0 + i = h.floor + f = h - i + f = 1-f if ((i & 1) == 0) + m = v * (1 - s) + n = v * (1 - s * f) + i=0 if(i<0) + i=6 if(i>6) + + case i + when 0, 6: r=v; g=n; b=m + when 1: r=n; g=v; b=m + when 2: r=m; g=v; b=n + when 3: r=m; g=n; b=v + when 4: r=n; g=m; b=v + when 5: r=v; g=m; b=n + end + + [r, g, b] + end + + + # + # Convert polair (ang,radius) coordinate to cartesian (x,y) + # + + def pol2xy(a, r) + x = Math.cos(a) * r + @cx + y = Math.sin(a) * r + @cy + [x, y] + end + + # + # Convert cartesian (x,y) coordinate to polair (ang, radius) + # + + def xy2pol(x, y) + x -= @cx; + y -= @cy; + a = Math.atan2(y, x) + a += 2*Math::PI if(a<0) + r = Math.sqrt(x*x + y*y) + [a, r] + end + + + # + # Convert a filesize in bytes to a human readable form + # + + def filesize_readable(size) + if(size > 1024*1024*1024) then + return "%.1fG" % (size / (1024.0*1024*1024)) + elsif(size > 1024*1024) then + return "%.1fM" % (size / (1024.0*1024)) + elsif(size > 1024) then + return "%.1fK" % (size / (1024.0)) + end + size + end + +end + +# +# End +# -- cgit v1.2.3-24-g4f1b