From 890420fdb5eb176c2e79a3dcd37ad6167209fc9f Mon Sep 17 00:00:00 2001 From: zedshaw Date: Mon, 4 Sep 2006 23:18:26 +0000 Subject: [PATCH] Upload progress documentation with examples for Rails (but works with other frameworks). git-svn-id: svn+ssh://rubyforge.org/var/svn/mongrel/trunk@345 19e92222-5c0b-0410-8929-a290d50e31e9 --- doc/site/config.yaml | 2 - doc/site/config.yml | 12 ++ doc/site/src/docs/contrib.page | 2 +- doc/site/src/docs/upload_progress.page | 129 +++++++++++++++++- doc/site/src/docs/upload_progress_form.html | 53 +++++++ .../src/docs/upload_progress_javascript.js | 113 +++++++++++++++ doc/site/src/docs/upload_progress_rails.rb | 14 ++ 7 files changed, 319 insertions(+), 6 deletions(-) delete mode 100644 doc/site/config.yaml create mode 100644 doc/site/config.yml create mode 100644 doc/site/src/docs/upload_progress_form.html create mode 100644 doc/site/src/docs/upload_progress_javascript.js create mode 100644 doc/site/src/docs/upload_progress_rails.rb diff --git a/doc/site/config.yaml b/doc/site/config.yaml deleted file mode 100644 index 1bbd69c1..00000000 --- a/doc/site/config.yaml +++ /dev/null @@ -1,2 +0,0 @@ -# Configuration file for webgen -# Used to set the parameters of the plugins diff --git a/doc/site/config.yml b/doc/site/config.yml new file mode 100644 index 00000000..54093e32 --- /dev/null +++ b/doc/site/config.yml @@ -0,0 +1,12 @@ +# Configuration file for webgen +# Used to set the parameters of the plugins +FileCopyHandler: + paths: + - "**/*.css" + - "**/*.jpg" + - "**/*.png" + - "**/*.gif" + - "**/*.js" + - "**/*.html" + - "**/*.rb" + diff --git a/doc/site/src/docs/contrib.page b/doc/site/src/docs/contrib.page index 25a32555..6f3c6c10 100644 --- a/doc/site/src/docs/contrib.page +++ b/doc/site/src/docs/contrib.page @@ -6,7 +6,7 @@ directoryName: Contribute h1. Contributing To The Mongrel Canon -The documentation for Mongrel is typically written by the me (Zed), but other +The documentation for Mongrel is typically written by me (Zed), but other people have contributed lots of blog articles about Mongrel, actual site documentation, or reviews and enhancements. It's pretty easy to contribute documentation to the Mongrel canon, although what gets in is strictly diff --git a/doc/site/src/docs/upload_progress.page b/doc/site/src/docs/upload_progress.page index 9de539d4..f3e6da9e 100644 --- a/doc/site/src/docs/upload_progress.page +++ b/doc/site/src/docs/upload_progress.page @@ -6,8 +6,131 @@ directoryName: Upload Progress h1. Mongrel Upload Progress Plugin -There's a plugin for Rails that gives folks a fancy "upload progress" but which really -only works with FastCGI. There's plans in the works to create a similar system -for Mongrel, but until that's made official you're pretty much on your own. +One of the really nice things about Mongrel is its simplicity. It's very easy +for someone to take and extend for their own needs. The Mongrel Upload +Progress plugin is an example of how I'm able to extend the Mongrel HTTP +Request object and provide near-realtime progress updates. + +The reason why this is a challenge, is because web servers usually gather the +HTTP request, send it on to the web framework, and wait on a response. This is +fine for most requests, because they're too small to cause an issue. For large +file uploads this is a usability nightmare. The user is left wondering what +whether their upload is going through or not. + +To do this, I've written a Mongrel handler that hooks into some basic Request +callbacks. To use it, you need to install the gem, and create a small config +file for it: + +
gem install mongrel_upload_progress
+
+# config/mongrel_upload_progress.conf
+uri "/", 
+  :handler => plugin("/handlers/upload", :path_info => '/files/upload'), 
+  :in_front => true
+
+# start mongrel
+mongrel_rails -d -p 3000 -S config/mongrel_upload_progress.conf
+
+ +That config file tells mongrel to load the Upload handler in front of all other +handlers. :path_info is passed to it, telling upload_progress to +only watch the /files/upload action. There are two more parameters that I'll +get into later: :frequency and :drb. I'm using Rails +as an example, but this should work with any Ruby framework, such as Camping or +Nitro. + +Now that Mongrel is set up, let's create a "basic upload +form":/docs/upload_progress_form.html. If you +look closely you'll notice a few things: + +* A unique :upload_id parameter must be sent to the upload_progress handler. This is so requests don't get mixed up, and the client page has an ID to query with. +* The <form> tag is targetted to an iFrame to do the uploading. Certain browsers (like Safari) won't execute javascript while a request is taken place, so this step is necessary. +* There is a little "javascript library":/docs/upload_progress_javascript.js being used. This handles the polling and status bar updates. +* Notice the form's action is file/upload, just like the upload_progress handler. + +The "Rails controller +actions":/docs/upload_progress_rails.rb for this are very +simple. The upload form itself needs no custom code. The upload action only +renders javascript to be executed in the iFrame, to modify the contents of the +parent page. The progress action is a basic RJS action that updates the +current status. Most of the guts of this are implemented in the javascript +library. + +Here's what happens when you submit the form: + +* The UploadProgress class creates a PeriodicalExecuter and gets ready to poll. +* The browser initiates the upload. +* Every 3 seconds, the PeriodicalExecuter calls the RJS #progress action and gets back the current status of the file. +* Once finished, the iFrame calls window.parent.UploadProgress.finish(), which removes the status bar and performs any other finishing actions. + +How's this work with a single Mongrel process if "Mongrel synchronizes Rails +requests":http://david.planetargon.us/articles/2006/08/08/why-you-need-multiple-mongrel-instances-with-rails? +It's actually very careful about locking, synchronizing only the bare minimum. +The whole time that Mongrel is receiving the request and updating the progress +is spent _in_ Mongrel, so it can happily serve other requests. This is how the +RJS action is able poll while it's uploading. + +This is fine and dandy, but not too many sites run on a single Mongrel. You'll +quickly run into problems with multiple mongrels since only one Mongrel process +knows about the upload. You'll either have to specify a specific mongrel port +to communicate with, or set up a dedicated mongrel upload process. The third +option, is use DRb. + +
# config/mongrel_upload_progress.conf
+uri "/", 
+  :handler => plugin("/handlers/upload", 
+    :path_info => '/files/upload', 
+    :drb => 'druby://0.0.0.0:2999'), 
+  :in_front => true
+
+# lib/upload.rb, the upload drb server
+require 'rubygems'
+require 'drb'
+require 'gem_plugin'
+GemPlugin::Manager.instance.load 'mongrel' => GemPlugin::INCLUDE
+DRb.start_service 'druby://0.0.0.0:2999', Mongrel::UploadProgress.new
+DRb.thread.join
+ +Now in addition to starting mongrel, you'll need to start the DRb service too: + +
ruby lib/upload.rb
+ +The Rails app should work the same as before, but now it is using a shared DRb +instance to store the updates. This gives us one other advantage: a console +interface to the current uploads. + +
# lib/upload_client.rb, a simple upload drb client
+require 'drb'
+DRb.start_service
+
+def get_status
+  DRbObject.new nil, 'druby://0.0.0.0:2999'
+end
+
+# typical console session
+$ irb -r lib/upload_client.rb
+>> uploads = get_status
+>> uploads.list
+=> []
+# start uploading in the browser
+>> uploads.list
+=> ["1157399821"]
+>> uploads.check "1157399821"
+=> {:size=>863467686, :received=>0}
+ +Using DRb gives you a simple way to monitor the status of current uploads in +progress. You could also write a simple web frontend for this too, accessing +the DRb client with Mongrel::Uploads. + +One final note is the use of the :frequency option. By default, + the upload progress is marked every three seconds. This can be modified + through the mongrel config file: + +
uri "/", 
+  :handler => plugin("/handlers/upload", 
+    :path_info => '/files/upload', 
+    :frequency => 1,
+    :drb => 'druby://0.0.0.0:2999'), 
+  :in_front => true
diff --git a/doc/site/src/docs/upload_progress_form.html b/doc/site/src/docs/upload_progress_form.html new file mode 100644 index 00000000..8a9b6753 --- /dev/null +++ b/doc/site/src/docs/upload_progress_form.html @@ -0,0 +1,53 @@ + + + + mongrel test + <%= javascript_include_tag :defaults %> + + + + +

<%= link_to (@upid = Time.now.to_i.to_s), :action => 'status', :upload_id => @upid %>

+<%= start_form_tag({:action => 'upload', :upload_id => @upid}, {:multipart => true, :target => 'upload', + :onsubmit => "UploadProgress.monitor('#{escape_javascript @upid}')"}) %> +
+

<%= file_field_tag :data %>

+
+

<%= link_to_function 'Add File Field', 'UploadProgress.FileField.add()' %> +

+

<%= submit_tag :Upload %>

+ + +
+
+ + + + + diff --git a/doc/site/src/docs/upload_progress_javascript.js b/doc/site/src/docs/upload_progress_javascript.js new file mode 100644 index 00000000..6fbc51f1 --- /dev/null +++ b/doc/site/src/docs/upload_progress_javascript.js @@ -0,0 +1,113 @@ +var UploadProgress = { + uploading: null, + monitor: function(upid) { + if(!this.periodicExecuter) { + this.periodicExecuter = new PeriodicalExecuter(function() { + if(!UploadProgress.uploading) return; + new Ajax.Request('/files/progress?upload_id=' + upid); + }, 3); + } + + this.uploading = true; + this.StatusBar.create(); + }, + + update: function(total, current) { + if(!this.uploading) return; + var status = current / total; + var statusHTML = status.toPercentage(); + $('results').innerHTML = statusHTML + "
" + current.toHumanSize() + ' of ' + total.toHumanSize() + " uploaded."; + this.StatusBar.update(status, statusHTML); + }, + + finish: function() { + this.uploading = false; + this.StatusBar.finish(); + $('results').innerHTML = 'finished!'; + }, + + cancel: function(msg) { + if(!this.uploading) return; + this.uploading = false; + if(this.StatusBar.statusText) this.StatusBar.statusText.innerHTML = msg || 'canceled'; + }, + + StatusBar: { + statusBar: null, + statusText: null, + statusBarWidth: 500, + + create: function() { + this.statusBar = this._createStatus('status-bar'); + this.statusText = this._createStatus('status-text'); + this.statusText.innerHTML = '0%'; + this.statusBar.style.width = '0'; + }, + + update: function(status, statusHTML) { + this.statusText.innerHTML = statusHTML; + this.statusBar.style.width = Math.floor(this.statusBarWidth * status); + }, + + finish: function() { + this.statusText.innerHTML = '100%'; + this.statusBar.style.width = '100%'; + }, + + _createStatus: function(id) { + el = $(id); + if(!el) { + el = document.createElement('span'); + el.setAttribute('id', id); + $('progress-bar').appendChild(el); + } + return el; + } + }, + + FileField: { + add: function() { + new Insertion.Bottom('file-fields', '

x

') + $$('#file-fields p').last().visualEffect('blind_down', {duration:0.3}); + }, + + remove: function(anchor) { + anchor.parentNode.visualEffect('drop_out', {duration:0.25}); + } + } +} + +Number.prototype.bytes = function() { return this; }; +Number.prototype.kilobytes = function() { return this * 1024; }; +Number.prototype.megabytes = function() { return this * (1024).kilobytes(); }; +Number.prototype.gigabytes = function() { return this * (1024).megabytes(); }; +Number.prototype.terabytes = function() { return this * (1024).gigabytes(); }; +Number.prototype.petabytes = function() { return this * (1024).terabytes(); }; +Number.prototype.exabytes = function() { return this * (1024).petabytes(); }; +['byte', 'kilobyte', 'megabyte', 'gigabyte', 'terabyte', 'petabyte', 'exabyte'].each(function(meth) { + Number.prototype[meth] = Number.prototype[meth+'s']; +}); + +Number.prototype.toPrecision = function() { + var precision = arguments[0] || 2; + var s = Math.round(this * Math.pow(10, precision)).toString(); + var pos = s.length - precision; + var last = s.substr(pos, precision); + return s.substr(0, pos) + (last.match("^0{" + precision + "}$") ? '' : '.' + last); +} + +// (1/10).toPercentage() +// # => '10%' +Number.prototype.toPercentage = function() { + return (this * 100).toPrecision() + '%'; +} + +Number.prototype.toHumanSize = function() { + if(this < (1).kilobyte()) return this + " Bytes"; + if(this < (1).megabyte()) return (this / (1).kilobyte()).toPrecision() + ' KB'; + if(this < (1).gigabytes()) return (this / (1).megabyte()).toPrecision() + ' MB'; + if(this < (1).terabytes()) return (this / (1).gigabytes()).toPrecision() + ' GB'; + if(this < (1).petabytes()) return (this / (1).terabytes()).toPrecision() + ' TB'; + if(this < (1).exabytes()) return (this / (1).petabytes()).toPrecision() + ' PB'; + return (this / (1).exabytes()).toPrecision() + ' EB'; +} diff --git a/doc/site/src/docs/upload_progress_rails.rb b/doc/site/src/docs/upload_progress_rails.rb new file mode 100644 index 00000000..d04d6581 --- /dev/null +++ b/doc/site/src/docs/upload_progress_rails.rb @@ -0,0 +1,14 @@ +class FilesController < ApplicationController + session :off, :only => :progress + + def progress + render :update do |page| + @status = Mongrel::Uploads.check(params[:upload_id]) + page.upload_progress.update(@status[:size], @status[:received]) if @status + end + end + + def upload + render :text => %(UPLOADED: #{params.inspect}.) + end +end