diff --git a/PVE/API2/Formatter/Bootstrap.pm b/PVE/API2/Formatter/Bootstrap.pm
new file mode 100644
index 00000000..75baf98a
--- /dev/null
+++ b/PVE/API2/Formatter/Bootstrap.pm
@@ -0,0 +1,232 @@
+package PVE::API2::Formatter::Bootstrap;
+
+use strict;
+use warnings;
+use URI::Escape;
+use HTML::Entities;
+use JSON;
+
+use PVE::AccessControl; # to generate CSRF token
+
+# Helpers to generate simple html pages using Bootstrap markup.
+
+my $jssrc = <<_EOJS;
+PVE = {
+ delete_auth_cookie: function() {
+ document.cookie = "PVEAuthCookie=; expires=Thu, 01 Jan 1970 00:00:01 GMT; path=/; secure;";
+ },
+ open_vm_console: function(node, vmid) {
+ console.log("open vm " + vmid + " on node " + node);
+
+ var downloadWithName = function(uri, name) {
+ var link = jQuery('#pve_console_anchor');
+ link.attr("href", uri);
+
+ // Note: we need to tell android the correct file name extension
+ // but we do not set 'download' tag for other environments, because
+ // It can have strange side effects (additional user prompt on firefox)
+ var andriod = navigator.userAgent.match(/Android/i) ? true : false;
+ if (andriod) {
+ link.attr("download", name);
+ }
+
+ if (document.createEvent) {
+ var evt = document.createEvent("MouseEvents");
+ evt.initMouseEvent('click', true, true, window, 1, 0, 0, 0, 0, false, false, false, false, 0, null);
+ link.get(0).dispatchEvent(evt);
+ } else {
+ link.get(0).fireEvent('onclick');
+ }
+ };
+
+ jQuery.ajax("/api2/json/console", {
+ data: { vmid: vmid, node: node },
+ headers: { CSRFPreventionToken: PVE.CSRFPreventionToken },
+ dataType: 'json',
+ type: 'POST',
+ error: function(jqXHR, textStatus, errorThrown) {
+ // fixme: howto view JS errors ?
+ console.log("ERROR " + textStatus + ": " + errorThrown);
+ },
+ success: function(data) {
+ var raw = "[virt-viewer]\\n";
+ jQuery.each(data.data, function(k, v) {
+ raw += k + "=" + v + "\\n";
+ });
+ var url = 'data:application/x-virt-viewer;charset=UTF-8,' +
+ encodeURIComponent(raw);
+
+ downloadWithName(url, "pve-spice.vv");
+ }
+ });
+ }
+};
+_EOJS
+
+sub new {
+ my ($class, $res, $url) = @_;
+
+ my $self = bless {
+ url => $url,
+ js => '',
+ };
+
+ if (my $username = $res->{auth}->{userid}) {
+ $self->{csrftoken} = PVE::AccessControl::assemble_csrf_prevention_token($username);
+ }
+
+ return $self;
+}
+
+sub body {
+ my ($self, $html) = @_;
+
+ my $jssetup = '';
+
+ if ($self->{csrftoken}) {
+ $jssetup .= "PVE.CSRFPreventionToken = '$self->{csrftoken}';\n";
+ }
+
+ return <<_EOD;
+
+
+
+
+
+
+ Proxmox VE Portal at '$hostname'
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $html
+
+
+
+_EOD
+}
+
+my $comp_id_counter = 0;
+
+sub el {
+ my ($self, %param) = @_;
+
+ $param{tag} = 'div' if !$param{tag};
+
+ my $id;
+
+ my $html = "<$param{tag}";
+
+ if (wantarray) {
+ $comp_id_counter++;
+ $id = "pveid$comp_id_counter";
+ $html .= " id=$id";
+ }
+
+ my $skip = {
+ tag => 1,
+ cn => 1,
+ html => 1,
+ text => 1,
+ };
+
+ my $boolattr = {
+ required => 1,
+ autofocus => 1,
+ };
+
+ my $noescape = {
+ placeholder => 1,
+ };
+
+ foreach my $attr (keys %param) {
+ next if $skip->{$attr};
+ my $v = $noescape->{$attr} ? $param{$attr} : uri_escape_utf8($param{$attr},"[^\/\ A-Za-z0-9\-\._~]");
+ next if !defined($v);
+ if ($boolattr->{$attr}) {
+ $html .= " $attr" if $v;
+ } else {
+ $html .= " $attr=\"$v\"";
+ }
+ }
+
+ $html .= ">";
+
+
+ if (my $cn = $param{cn}) {
+ if(ref($cn) eq 'ARRAY'){
+ foreach my $rec (@$cn) {
+ $html .= $self->el(%$rec);
+ }
+ } else {
+ $html .= $self->el(%$cn);
+ }
+ } elsif ($param{html}) {
+ $html .= $param{html};
+ } elsif ($param{text}) {
+ $html .= encode_entities($param{text});
+ }
+
+ $html .= "$param{tag}>";
+
+ return wantarray ? ($html, $id) : $html;
+}
+
+sub alert {
+ my ($self, %param) = @_;
+
+ return $self->el(class => "alert alert-danger", %param);
+}
+
+sub add_js {
+ my ($self, $js) = @_;
+
+ $self->{js} .= $js . "\n";
+}
+
+my $format_event_callback = sub {
+ my ($info) = @_;
+
+ my $pstr = encode_json($info->{param});
+ return "function(e){$info->{fn}.apply(e, $pstr);}";
+};
+
+sub button {
+ my ($self, %param) = @_;
+
+ $param{tag} = 'button';
+ $param{class} = "btn btn-default btn-xs";
+
+ if (my $click = delete $param{click}) {
+ my ($html, $id) = $self->el(%param);
+ my $cb = &$format_event_callback($click);
+ $self->add_js("jQuery('#$id').on('click', $cb);");
+ return $html;
+ } else {
+ return $self->el(%param);
+ }
+}
+
+1;
diff --git a/bin/pveproxy b/bin/pveproxy
index 970de993..15225315 100755
--- a/bin/pveproxy
+++ b/bin/pveproxy
@@ -79,6 +79,7 @@ eval {
add_dirs($dirs, '/pve2/ext4/', '/usr/share/pve-manager/ext4/');
add_dirs($dirs, '/pve2/images/' => '/usr/share/pve-manager/images/');
add_dirs($dirs, '/pve2/css/' => '/usr/share/pve-manager/css/');
+ add_dirs($dirs, '/pve2/js/' => '/usr/share/pve-manager/js/');
add_dirs($dirs, '/vncterm/' => '/usr/share/vncterm/');
$daemon = PVE::APIDaemon->new(
diff --git a/defines.mk b/defines.mk
index d832684f..410c7dfa 100644
--- a/defines.mk
+++ b/defines.mk
@@ -17,3 +17,4 @@ WWWROOTDIR=${WWWBASEDIR}/root
WWWIMAGEDIR=${WWWBASEDIR}/images
WWWEXT4DIR=${WWWBASEDIR}/ext4
WWWCSSDIR=${WWWBASEDIR}/css
+WWWJSDIR=${WWWBASEDIR}/js
diff --git a/www/bootstrap/Makefile b/www/bootstrap/Makefile
new file mode 100644
index 00000000..be493fd5
--- /dev/null
+++ b/www/bootstrap/Makefile
@@ -0,0 +1,30 @@
+include ../../defines.mk
+
+BTDIR=bootstrap-3.1.1-dist
+BTSRC=${BTDIR}.zip
+
+BTDATA = \
+ ${BTDIR}/css/bootstrap.min.css \
+ ${BTDIR}/js/bootstrap.min.js
+
+${BTDATA}: ${BTSRC}
+ rm -rf ${BTDIR}
+ unzip -x ${BTSRC}
+ touch $@
+
+all: ${BTDATA}
+
+.PHONY: install
+install: ${BTDATA}
+ install -d ${WWWCSSDIR}
+ install -m 0644 -o www-data -g www-data ${BTDIR}/css/bootstrap.min.css ${WWWCSSDIR}
+ install -d ${WWWJSDIR}
+ install -m 0644 -o www-data -g www-data ${BTDIR}/js/bootstrap.min.js ${WWWJSDIR}
+
+.PHONY: distclean
+distclean: clean
+
+.PHONY: clean
+clean:
+ rm -rf *~ ${BTDIR}
+