diff --git a/circle/common/operations.py b/circle/common/operations.py
index 0423043..ed01532 100644
--- a/circle/common/operations.py
+++ b/circle/common/operations.py
@@ -13,6 +13,7 @@ class Operation(object):
     """
     async_queue = 'localhost.man'
     required_perms = ()
+    do_not_call_in_templates = True
 
     def __call__(self, **kwargs):
         return self.call(**kwargs)
@@ -127,6 +128,19 @@ class OperatedMixin(object):
             raise AttributeError("%r object has no attribute %r" %
                                  (self.__class__.__name__, name))
 
+    def get_available_operations(self, user):
+        """Yield Operations that match permissions of user and preconditions.
+        """
+        for name in getattr(self, operation_registry_name, {}):
+            try:
+                op = getattr(self, name)
+                op.check_auth(user)
+                op.check_precond()
+            except:
+                pass  # unavailable
+            else:
+                yield op
+
 
 def register_operation(op_cls, op_id=None, target_cls=None):
     """Register the specified operation with the target class.
diff --git a/circle/dashboard/forms.py b/circle/dashboard/forms.py
index 154af2e..19ddee8 100644
--- a/circle/dashboard/forms.py
+++ b/circle/dashboard/forms.py
@@ -1,3 +1,5 @@
+from __future__ import absolute_import
+
 from datetime import timedelta
 
 from django.contrib.auth.models import User
diff --git a/circle/dashboard/models.py b/circle/dashboard/models.py
index 7592ad3..07a0ae2 100644
--- a/circle/dashboard/models.py
+++ b/circle/dashboard/models.py
@@ -1,3 +1,5 @@
+from __future__ import absolute_import
+
 from itertools import chain
 from logging import getLogger
 
diff --git a/circle/dashboard/static/dashboard/vm-common.js b/circle/dashboard/static/dashboard/vm-common.js
index 55599f3..c26738a 100644
--- a/circle/dashboard/static/dashboard/vm-common.js
+++ b/circle/dashboard/static/dashboard/vm-common.js
@@ -2,22 +2,21 @@
 
 $(function() {
 
-  /* vm migrate */
-  $('.vm-migrate').click(function(e) {
-    var icon = $(this).children("i");
-    var vm = $(this).data("vm-pk");
-    icon.removeClass("icon-truck").addClass("icon-spinner icon-spin");
+  /* vm operations */
+  $('#ops').on('click', '.operation.btn', function(e) {
+    var icon = $(this).children("i").addClass('icon-spinner icon-spin');
 
     $.ajax({
       type: 'GET',
-      url: '/dashboard/vm/' + vm + '/migrate/', 
+      url: $(this).attr('href'),
       success: function(data) {
-        icon.addClass("icon-truck").removeClass("icon-spinner icon-spin");
+        icon.removeClass("icon-spinner icon-spin");
         $('body').append(data);
-        $('#create-modal').modal('show');
-        $('#create-modal').on('hidden.bs.modal', function() {
-          $('#create-modal').remove();
+        $('#confirmation-modal').modal('show');
+        $('#confirmation-modal').on('hidden.bs.modal', function() {
+          $('#confirmation-modal').remove();
         });
+
         $('#vm-migrate-node-list li').click(function(e) {
           var li = $(this).closest('li');
           if (li.find('input').attr('disabled'))
diff --git a/circle/dashboard/static/dashboard/vm-details.js b/circle/dashboard/static/dashboard/vm-details.js
index 916f75b..dce3b55 100644
--- a/circle/dashboard/static/dashboard/vm-details.js
+++ b/circle/dashboard/static/dashboard/vm-details.js
@@ -211,6 +211,7 @@ function checkNewActivity(only_status, runs) {
     success: function(data) {
       if(!only_status) {
         $("#activity-timeline").html(data['activities']);
+        $("#ops").html(data['ops']);
         $("[title]").tooltip();
       }
 
diff --git a/circle/dashboard/tables.py b/circle/dashboard/tables.py
index 301eb3b..14b508c 100644
--- a/circle/dashboard/tables.py
+++ b/circle/dashboard/tables.py
@@ -1,3 +1,5 @@
+from __future__ import absolute_import
+
 from django.contrib.auth.models import Group, User
 from django_tables2 import Table, A
 from django_tables2.columns import (TemplateColumn, Column, BooleanColumn,
diff --git a/circle/dashboard/templates/dashboard/_base.html b/circle/dashboard/templates/dashboard/_base.html
new file mode 100644
index 0000000..572e73f
--- /dev/null
+++ b/circle/dashboard/templates/dashboard/_base.html
@@ -0,0 +1,20 @@
+{% extends "dashboard/base.html" %}
+{% load i18n %}
+
+{% block content %}
+  <div class="body-content">
+    <div class="panel panel-default" style="margin-top: 60px;">
+      <div class="panel-heading">
+        <h3 class="no-margin">
+          {% if title %}
+            {{ title }}
+          {% else %}
+            {% trans "Confirmation" %}
+          {% endif %}  
+        </h3>
+      </div>
+      <div class="panel-body">
+        {{ body|safe|default:"(body missing from context.)" }}
+      </div>
+  </div>
+{% endblock %}
diff --git a/circle/dashboard/templates/dashboard/_modal.html b/circle/dashboard/templates/dashboard/_modal.html
new file mode 100644
index 0000000..cc9ab39
--- /dev/null
+++ b/circle/dashboard/templates/dashboard/_modal.html
@@ -0,0 +1,12 @@
+{% load i18n %}
+<div class="modal fade" id="confirmation-modal" tabindex="-1" role="dialog"> 
+  <div class="modal-dialog"> 
+    <div class="modal-content"> 
+      <div class="modal-body">  
+        {{ body|safe|default:"(body missing from context.)" }}
+        <div class="clearfix"></div>
+      </div>
+      <div class="clearfix"></div>
+    </div><!-- /.modal-content --> 
+  </div><!-- /.modal-dialog --> 
+</div>
diff --git a/circle/dashboard/templates/dashboard/_vm-migrate.html b/circle/dashboard/templates/dashboard/_vm-migrate.html
index e1ec92b..7fb36ce 100644
--- a/circle/dashboard/templates/dashboard/_vm-migrate.html
+++ b/circle/dashboard/templates/dashboard/_vm-migrate.html
@@ -1,10 +1,19 @@
+{% extends "dashboard/operate.html" %}
 {% load i18n %}
 {% load sizefieldtags %}
 
-<form method="POST" action="{% url "dashboard.views.vm-migrate" pk=vm.pk %}">
-  {% csrf_token %}
-  <ul id="vm-migrate-node-list">
-  {% with current=vm.node.pk selected=vm.select_node.pk %}
+{% block question %}
+<p>
+{% blocktrans with obj=object op=op.name %}
+Choose a compute node to migrate {{obj}} to.
+{% endblocktrans %}
+</p>
+<p class="text-info">{{op.name}}: {{op.description}}</p>
+{% endblock %}
+
+{% block formfields %}
+  <ul id="vm-migrate-node-list" class="list-unstyled">
+  {% with current=object.node.pk selected=object.select_node.pk %}
     {% for n in nodes %}
       <li class="panel panel-default"><div class="panel-body">
         <label for="migrate-to-{{n.pk}}">
@@ -22,5 +31,4 @@
     {% endfor %}
   {% endwith %}
   </ul>
-  <button type="submit" class="btn btn-primary btn-sm"><i class="icon-truck"></i> Migrate</button>
-</form>
+{% endblock %}
diff --git a/circle/dashboard/templates/dashboard/operate.html b/circle/dashboard/templates/dashboard/operate.html
new file mode 100644
index 0000000..a56b0a1
--- /dev/null
+++ b/circle/dashboard/templates/dashboard/operate.html
@@ -0,0 +1,19 @@
+{% load i18n %}
+
+{% block question %}
+<p>
+{% blocktrans with obj=object op=op.name %}
+Do you want to do the following operation on {{obj}}:
+<strong>{{op}}</strong>?
+{% endblocktrans %}
+</p>
+<p class="text-info">{{op.name}}: {{op.description}}</p>
+{% endblock %}
+<form method="POST" action="{{url}}">{% csrf_token %}
+  {% block formfields %}{% endblock %}
+  <div class="pull-right">
+    <a class="btn btn-default" href="{{object.get_absolute_url}}"
+        data-dismiss="modal">{% trans "Cancel" %}</a>
+      <button class="btn btn-danger" type="submit">{% if op.icon %}<i class="icon-{{op.icon}}"></i> {% endif %}{{ op|capfirst }}</button>
+  </div>
+</form>
diff --git a/circle/dashboard/templates/dashboard/vm-detail.html b/circle/dashboard/templates/dashboard/vm-detail.html
index 2bcdcbd..799557f 100644
--- a/circle/dashboard/templates/dashboard/vm-detail.html
+++ b/circle/dashboard/templates/dashboard/vm-detail.html
@@ -6,54 +6,8 @@
 {% block content %}
 <div class="body-content">
   <div class="page-header">
-    <div class="pull-right" style="padding-top: 15px;">
-      <form style="display: inline;" method="POST" action="{% url "dashboard.views.detail" pk=instance.pk %}">
-        {% csrf_token %}
-        <input type="hidden" name="sleep" />
-        <button title="{% trans "Sleep" %}" class="btn btn-default btn-xs" type="submit"><i class="icon-moon"></i></button>
-      </form>
-      <form style="display: inline;" method="POST" action="{% url "dashboard.views.detail" pk=instance.pk %}">
-        {% csrf_token %}
-        <input type="hidden" name="deploy" />
-        <button title="{% trans "Deploy" %}" class="btn btn-default btn-xs" type="submit"><i class="icon-play"></i></button>
-      </form>
-      <form style="display: inline;" method="POST" action="{% url "dashboard.views.detail" pk=instance.pk %}">
-        {% csrf_token %}
-        <input type="hidden" name="wake_up" />
-        <button title="{% trans "Wake up" %}" class="btn btn-default btn-xs" type="submit"><i class="icon-sun"></i></button>
-      </form>
-      <form style="display: inline;" method="POST" action="{% url "dashboard.views.detail" pk=instance.pk %}">
-        {% csrf_token %}
-        <input type="hidden" name="shut_down" />
-        <button title="{% trans "Shut down" %}" class="btn btn-default btn-xs" type="submit"><i class="icon-off"></i></button>
-      </form>
-      <form style="display: inline;" method="POST" action="{% url "dashboard.views.detail" pk=instance.pk %}">
-        {% csrf_token %}
-        <input type="hidden" name="reboot" />
-        <button title="{% trans "Reboot (ctrl + alt + del)" %}" class="btn btn-default btn-xs" type="submit"><i class="icon-refresh"></i></button>
-      </form>
-      <form style="display: inline;" method="POST" action="{% url "dashboard.views.detail" pk=instance.pk %}">
-        {% csrf_token %}
-        <input type="hidden" name="reset" />
-        <button title="{% trans "Reset (power cycle)" %}" class="btn btn-default btn-xs" type="submit"><i class="icon-bolt"></i></button>
-      </form>
-      <form style="display: inline;" method="POST" action="{% url "dashboard.views.detail" pk=instance.pk %}">
-        {% csrf_token %}
-        <input type="hidden" name="shut_off"/>
-        <button title="{% trans "Shut off" %}" class="btn btn-default btn-xs" type="submit">
-          <i class="icon-ban-circle"></i>
-        </button>
-      </form>
-      <a title="Migrate" data-vm-pk="{{ instance.pk }}" href="{% url "dashboard.views.vm-migrate" pk=instance.pk %}" class="btn btn-default btn-xs vm-migrate">
-        <i class="icon-truck"></i>
-      </a>
-      <form style="display: inline;" method="POST" action="{% url "dashboard.views.detail" pk=instance.pk %}">
-        {% csrf_token %}
-        <input type="hidden" name="save_as" />
-        <button title="{% trans "Save as template" %}" class="btn btn-default btn-xs" type="submit"><i class="icon-save"></i></button>
-      </form>
-      <a title="{% trans "Destroy" %}" href="{% url "dashboard.views.delete-vm" pk=instance.pk %}" class="btn btn-default btn-xs vm-delete" data-vm-pk="{{ instance.pk }}"><i class="icon-remove"></i></a>
-      <a title="{% trans "Help" %}" href="#" class="btn btn-default btn-xs vm-details-help-button"><i class="icon-question"></i></a>
+    <div class="pull-right" style="padding-top: 15px;" id="ops">
+      {% include "dashboard/vm-detail/_operations.html" %}
     </div>
     <h1>
       <div id="vm-details-rename">
@@ -68,46 +22,6 @@
       </div>
       <small>{{ instance.primary_host.get_fqdn }}</small>
     </h1>
-    <div class="vm-details-help js-hidden">
-      <ul style="list-style: none;">
-        <li>
-          <strong>{% trans "Sleep" %}:</strong>
-          {% trans "Suspend virtual machine with memory dump." %}
-        </li>
-        <li>
-          <strong>{% trans "Wake up" %}:</strong>
-          {% trans "Wake up suspended machine." %}
-        </li>
-        <li>
-          <strong>{% trans "Shutdown" %}:</strong>
-          {% trans "Shutdown virtual machine with ACPI signal." %}
-        </li>
-        <li>
-          <strong>{% trans "Reboot (ctrl + alt + del)" %}:</strong>
-          {% trans "Reboot virtual machine with Ctrl+Alt+Del signal." %}
-        </li>
-        <li>
-          <strong>{% trans "Reset (power cycle)" %}:</strong>
-          {% trans "Reset virtual machine (reset button)" %}
-        </li>
-        <li>
-          <strong>{% trans "Shut off" %}:</strong>
-          {% trans "Shut off VM. (plug-out)" %}
-        </li>
-        <li>
-          <strong>{% trans "Migrate" %}:</strong>
-          {% trans "Live migrate running vm to another node." %}
-        </li>
-        <li>
-          <strong>{% trans "Save as template" %}:</strong>
-          {% trans "Shut down the virtual machine, and save it as a new template." %}
-        </li>
-        <li>
-          <strong>{% trans "Destroy" %}:</strong>
-          {% trans "Remove virtual machine and its networks." %}
-        </li>
-      </ul>
-    </div>
     <div style="clear: both;"></div>
   </div>
   <div class="row">
diff --git a/circle/dashboard/templates/dashboard/vm-detail/_operations.html b/circle/dashboard/templates/dashboard/vm-detail/_operations.html
new file mode 100644
index 0000000..a28ad1a
--- /dev/null
+++ b/circle/dashboard/templates/dashboard/vm-detail/_operations.html
@@ -0,0 +1,9 @@
+{% load i18n %}
+
+{% for op in ops %}
+<a href="{{op.get_url}}" class="operation operation-{{op.op}} btn btn-default btn-xs"
+  title="{{op.name}}: {{op.description}}">
+    <i class="icon-{{op.icon}}"></i>
+    <span class="sr-only">{{op.name}}</span>
+</a>
+{% endfor %}
diff --git a/circle/dashboard/tests/test_mockedviews.py b/circle/dashboard/tests/test_mockedviews.py
index 0dd9468..4761305 100644
--- a/circle/dashboard/tests/test_mockedviews.py
+++ b/circle/dashboard/tests/test_mockedviews.py
@@ -3,10 +3,11 @@ from factory import Factory, Sequence
 from mock import patch, MagicMock
 
 from django.contrib.auth.models import User
-# from django.core.exceptions import PermissionDenied
+from django.core.exceptions import PermissionDenied
 from django.http import HttpRequest, Http404
 
-from dashboard.views import InstanceActivityDetail, InstanceActivity
+from ..views import InstanceActivityDetail, InstanceActivity
+from ..views import vm_ops, Instance
 
 
 class ViewUserTestCase(unittest.TestCase):
@@ -36,6 +37,81 @@ class ViewUserTestCase(unittest.TestCase):
             self.assertEquals(view(request, pk=1234).render().status_code, 200)
 
 
+class VmOperationViewTestCase(unittest.TestCase):
+
+    def test_available(self):
+        request = FakeRequestFactory(superuser=True)
+        view = vm_ops['destroy']
+
+        with patch.object(view, 'get_object') as go:
+            inst = MagicMock(spec=Instance)
+            inst._meta.object_name = "Instance"
+            inst.destroy = Instance._ops['destroy'](inst)
+            go.return_value = inst
+            self.assertEquals(
+                view.as_view()(request, pk=1234).render().status_code, 200)
+
+    def test_unpermitted(self):
+        request = FakeRequestFactory()
+        view = vm_ops['destroy']
+
+        with patch.object(view, 'get_object') as go:
+            inst = MagicMock(spec=Instance)
+            inst._meta.object_name = "Instance"
+            inst.destroy = Instance._ops['destroy'](inst)
+            inst.has_level.return_value = False
+            go.return_value = inst
+            with self.assertRaises(PermissionDenied):
+                view.as_view()(request, pk=1234).render()
+
+    def test_migrate(self):
+        request = FakeRequestFactory(POST={'node': 1})
+        view = vm_ops['migrate']
+
+        with patch.object(view, 'get_object') as go, \
+                patch('dashboard.views.messages') as msg, \
+                patch('dashboard.views.get_object_or_404') as go4:
+            inst = MagicMock(spec=Instance)
+            inst._meta.object_name = "Instance"
+            inst.migrate = Instance._ops['migrate'](inst)
+            inst.migrate.async = MagicMock()
+            inst.has_level.return_value = True
+            go.return_value = inst
+            go4.return_value = MagicMock()
+            assert view.as_view()(request, pk=1234)['location']
+            assert not msg.error.called
+
+    def test_migrate_failed(self):
+        request = FakeRequestFactory(POST={'node': 1})
+        view = vm_ops['migrate']
+
+        with patch.object(view, 'get_object') as go, \
+                patch('dashboard.views.messages') as msg, \
+                patch('dashboard.views.get_object_or_404') as go4:
+            inst = MagicMock(spec=Instance)
+            inst._meta.object_name = "Instance"
+            inst.migrate = Instance._ops['migrate'](inst)
+            inst.migrate.async = MagicMock()
+            inst.migrate.async.side_effect = Exception
+            inst.has_level.return_value = True
+            go.return_value = inst
+            go4.return_value = MagicMock()
+            assert view.as_view()(request, pk=1234)['location']
+            assert msg.error.called
+
+    def test_migrate_template(self):
+        request = FakeRequestFactory()
+        view = vm_ops['migrate']
+
+        with patch.object(view, 'get_object') as go:
+            inst = MagicMock(spec=Instance)
+            inst._meta.object_name = "Instance"
+            inst.migrate = Instance._ops['migrate'](inst)
+            inst.has_level.return_value = True
+            go.return_value = inst
+            self.assertEquals(
+                view.as_view()(request, pk=1234).render().status_code, 200)
+
 def FakeRequestFactory(*args, **kwargs):
     ''' FakeRequestFactory, FakeMessages and FakeRequestContext are good for
     mocking out django views; they are MUCH faster than the Django test client.
@@ -48,12 +124,12 @@ def FakeRequestFactory(*args, **kwargs):
     request = HttpRequest()
     request.user = user
     request.session = kwargs.get('session', {})
-    if kwargs.get('POST'):
+    if kwargs.get('POST') is not None:
         request.method = 'POST'
         request.POST = kwargs.get('POST')
     else:
         request.method = 'GET'
-        request.POST = kwargs.get('GET', {})
+        request.GET = kwargs.get('GET', {})
 
     return request
 
diff --git a/circle/dashboard/tests/test_views.py b/circle/dashboard/tests/test_views.py
index c834269..0daac0d 100644
--- a/circle/dashboard/tests/test_views.py
+++ b/circle/dashboard/tests/test_views.py
@@ -495,9 +495,11 @@ class VmDetailTest(LoginMixin, TestCase):
             mock_method.side_effect = inst.wake_up
             inst.manual_state_change('RUNNING')
             inst.set_level(self.u2, 'owner')
-            self.assertRaises(inst.WrongStateError, c.post,
-                              "/dashboard/vm/1/", {'wake_up': True})
-            self.assertEqual(inst.status, 'RUNNING')
+            with patch('dashboard.views.messages') as msg:
+                c.post("/dashboard/vm/1/op/wake_up/")
+                assert msg.error.called
+            inst = Instance.objects.get(pk=1)
+            self.assertEqual(inst.status, 'RUNNING')  # mocked anyway
             assert mock_method.called
 
     def test_permitted_wake_up(self):
@@ -511,7 +513,9 @@ class VmDetailTest(LoginMixin, TestCase):
                     inst.get_remote_queue_name = Mock(return_value='test')
                     inst.manual_state_change('SUSPENDED')
                     inst.set_level(self.u2, 'owner')
-                    response = c.post("/dashboard/vm/1/", {'wake_up': True})
+                    with patch('dashboard.views.messages') as msg:
+                        response = c.post("/dashboard/vm/1/op/wake_up/")
+                        assert not msg.error.called
                     self.assertEqual(response.status_code, 302)
                     self.assertEqual(inst.status, 'RUNNING')
                     assert new_wake_up.called
@@ -523,8 +527,11 @@ class VmDetailTest(LoginMixin, TestCase):
         inst = Instance.objects.get(pk=1)
         inst.manual_state_change('SUSPENDED')
         inst.set_level(self.u2, 'user')
-        response = c.post("/dashboard/vm/1/", {'wake_up': True})
-        self.assertEqual(response.status_code, 403)
+        with patch('dashboard.views.messages') as msg:
+            response = c.post("/dashboard/vm/1/op/wake_up/")
+            assert msg.error.called
+            self.assertEqual(response.status_code, 302)
+        inst = Instance.objects.get(pk=1)
         self.assertEqual(inst.status, 'SUSPENDED')
 
     def test_non_existing_template_get(self):
diff --git a/circle/dashboard/urls.py b/circle/dashboard/urls.py
index 94e8776..5161b30 100644
--- a/circle/dashboard/urls.py
+++ b/circle/dashboard/urls.py
@@ -1,4 +1,5 @@
-from django.conf.urls import patterns, url
+from __future__ import absolute_import
+from django.conf.urls import patterns, url, include
 
 from vm.models import Instance
 from .views import (
@@ -34,7 +35,7 @@ urlpatterns = patterns(
         name="dashboard.views.template-list"),
     url(r"^template/delete/(?P<pk>\d+)/$", TemplateDelete.as_view(),
         name="dashboard.views.template-delete"),
-
+    url(r'^vm/(?P<pk>\d+)/op/', include('dashboard.vm.urls')),
     url(r'^vm/(?P<pk>\d+)/remove_port/(?P<rule>\d+)/$', PortDelete.as_view(),
         name='dashboard.views.remove-port'),
     url(r'^vm/(?P<pk>\d+)/$', VmDetailView.as_view(),
diff --git a/circle/dashboard/views.py b/circle/dashboard/views.py
index 385a29a..3c4ed3f 100644
--- a/circle/dashboard/views.py
+++ b/circle/dashboard/views.py
@@ -1,10 +1,9 @@
-from __future__ import unicode_literals
+from __future__ import unicode_literals, absolute_import
 
 from os import getenv
 import json
 import logging
 import re
-from datetime import datetime
 import requests
 
 from django.conf import settings
@@ -47,7 +46,7 @@ from vm.models import (
 )
 from storage.models import Disk
 from firewall.models import Vlan, Host, Rule
-from dashboard.models import Favourite, Profile
+from .models import Favourite, Profile
 
 logger = logging.getLogger(__name__)
 
@@ -203,7 +202,8 @@ class VmDetailView(CheckedDetailView):
         context.update({
             'graphite_enabled': VmGraphView.get_graphite_url() is not None,
             'vnc_url': reverse_lazy("dashboard.views.detail-vnc",
-                                    kwargs={'pk': self.object.pk})
+                                    kwargs={'pk': self.object.pk}),
+            'ops': get_operations(instance, self.request.user),
         })
 
         # activity data
@@ -242,14 +242,6 @@ class VmDetailView(CheckedDetailView):
             'to_remove': self.__remove_tag,
             'port': self.__add_port,
             'new_network_vlan': self.__new_network,
-            'save_as': self.__save_as,
-            'shut_down': self.__shut_down,
-            'sleep': self.__sleep,
-            'wake_up': self.__wake_up,
-            'deploy': self.__deploy,
-            'reset': self.__reset,
-            'reboot': self.__reboot,
-            'shut_off': self.__shut_off,
         }
         for k, v in options.iteritems():
             if request.POST.get(k) is not None:
@@ -414,75 +406,132 @@ class VmDetailView(CheckedDetailView):
         return redirect("%s#network" % reverse_lazy(
             "dashboard.views.detail", kwargs={'pk': self.object.pk}))
 
-    def __save_as(self, request):
-        self.object = self.get_object()
-        if not self.object.has_level(request.user, 'owner'):
-            raise PermissionDenied()
 
-        date = datetime.now().strftime("%Y-%m-%d %H:%M")
-        new_name = "Saved from %s (#%d) at %s" % (
-            self.object.name, self.object.pk, date
-        )
-        self.object.save_as_template.async(name=new_name,
-                                           user=request.user)
-        messages.success(request, _("Saving instance as template!"))
-        return redirect("%s#activity" % self.object.get_absolute_url())
+class OperationView(DetailView):
 
-    def __shut_down(self, request):
-        self.object = self.get_object()
-        if not self.object.has_level(request.user, 'owner'):
-            raise PermissionDenied()
+    template_name = 'dashboard/operate.html'
 
-        self.object.shutdown.async(user=request.user)
-        return redirect("%s#activity" % self.object.get_absolute_url())
+    @property
+    def name(self):
+        return self.get_op().name
 
-    def __sleep(self, request):
-        self.object = self.get_object()
-        if not self.object.has_level(request.user, 'owner'):
-            raise PermissionDenied()
+    @property
+    def description(self):
+        return self.get_op().description
 
-        self.object.sleep.async(user=request.user)
-        return redirect("%s#activity" % self.object.get_absolute_url())
+    @classmethod
+    def get_urlname(cls):
+        return 'dashboard.vm.op.%s' % cls.op
 
-    def __wake_up(self, request):
-        self.object = self.get_object()
-        if not self.object.has_level(request.user, 'owner'):
-            raise PermissionDenied()
+    def get_url(self):
+        return reverse(self.get_urlname(), args=(self.get_object().pk, ))
 
-        self.object.wake_up.async(user=request.user)
-        return redirect("%s#activity" % self.object.get_absolute_url())
+    def get_wrapper_template_name(self):
+        if self.request.is_ajax():
+            return 'dashboard/_modal.html'
+        else:
+            return 'dashboard/_base.html'
 
-    def __deploy(self, request):
-        self.object = self.get_object()
-        if not self.object.has_level(request.user, 'owner'):
-            raise PermissionDenied()
+    @classmethod
+    def get_op_by_object(cls, obj):
+        return getattr(obj, cls.op)
 
-        self.object.deploy.async(user=request.user)
-        return redirect("%s#activity" % self.object.get_absolute_url())
+    def get_op(self):
+        if not hasattr(self, '_opobj'):
+            setattr(self, '_opobj', getattr(self.get_object(), self.op))
+        return self._opobj
 
-    def __reset(self, request):
-        self.object = self.get_object()
-        if not self.object.has_level(request.user, 'owner'):
-            raise PermissionDenied()
+    def get_context_data(self, **kwargs):
+        ctx = super(OperationView, self).get_context_data(**kwargs)
+        ctx['op'] = self.get_op()
+        ctx['url'] = self.request.path
+        return ctx
 
-        self.object.reset.async(user=request.user)
-        return redirect("%s#activity" % self.object.get_absolute_url())
+    def get(self, request, *args, **kwargs):
+        self.get_op().check_auth(request.user)
+        response = super(OperationView, self).get(request, *args, **kwargs)
+        response.render()
+        response.content = render_to_string(self.get_wrapper_template_name(),
+                                            {'body': response.content})
+        return response
 
-    def __reboot(self, request):
+    def post(self, request, extra=None, *args, **kwargs):
         self.object = self.get_object()
-        if not self.object.has_level(request.user, 'owner'):
-            raise PermissionDenied()
-
-        self.object.reboot.async(user=request.user)
+        if extra is None:
+            extra = {}
+        try:
+            self.get_op().async(user=request.user, **extra)
+        except Exception as e:
+            messages.error(request, _('Could not start operation.'))
+            logger.error(e)
         return redirect("%s#activity" % self.object.get_absolute_url())
 
-    def __shut_off(self, request):
-        self.object = self.get_object()
-        if not self.object.has_level(request.user, 'owner'):
-            raise PermissionDenied()
+    @classmethod
+    def factory(cls, op, icon='cog'):
+        return type(str(cls.__name__ + op),
+                    (cls, ), {'op': op, 'icon': icon})
 
-        self.object.shut_off.async(user=request.user)
-        return redirect("%s#activity" % self.object.get_absolute_url())
+    @classmethod
+    def bind_to_object(cls, instance):
+        v = cls()
+        v.get_object = lambda: instance
+        return v
+
+
+class VmOperationView(OperationView):
+
+    model = Instance
+
+
+class VmMigrateView(VmOperationView):
+
+    op = 'migrate'
+    icon = 'truck'
+    template_name = 'dashboard/_vm-migrate.html'
+
+    def get_context_data(self, **kwargs):
+        ctx = super(VmOperationView, self).get_context_data(**kwargs)
+        ctx['nodes'] = [n for n in Node.objects.filter(enabled=True)
+                        if n.state == "ONLINE"]
+        return ctx
+
+    def post(self, request, extra=None, *args, **kwargs):
+        if extra is None:
+            extra = {}
+        node = self.request.POST.get("node")
+        if node:
+            node = get_object_or_404(Node, pk=node)
+            extra["to_node"] = node
+        return super(VmMigrateView, self).post(request, extra, *args, **kwargs)
+
+
+vm_ops = {
+    'reset': VmOperationView.factory(op='reset', icon='bolt'),
+    'deploy': VmOperationView.factory(op='deploy', icon='play'),
+    'migrate': VmMigrateView,
+    'reboot': VmOperationView.factory(op='reboot', icon='refresh'),
+    'shut_off': VmOperationView.factory(op='shut_off', icon='ban-circle'),
+    'shutdown': VmOperationView.factory(op='shutdown', icon='off'),
+    'save_as_template': VmOperationView.factory(
+        op='save_as_template', icon='save'),
+    'destroy': VmOperationView.factory(op='destroy', icon='remove'),
+    'sleep': VmOperationView.factory(op='sleep', icon='moon'),
+    'wake_up': VmOperationView.factory(op='wake_up', icon='sun'),
+}
+
+
+def get_operations(instance, user):
+    ops = []
+    for k, v in vm_ops.iteritems():
+        try:
+            op = v.get_op_by_object(instance)
+            op.check_auth(user)
+            op.check_precond()
+        except:
+            pass  # unavailable
+        else:
+            ops.append(v.bind_to_object(instance))
+    return ops
 
 
 class NodeDetailView(LoginRequiredMixin, SuperuserRequiredMixin, DetailView):
@@ -1592,23 +1641,28 @@ def vm_activity(request, pk):
         raise PermissionDenied()
 
     response = {}
-    only_status = request.GET.get("only_status")
+    only_status = request.GET.get("only_status", "false")
 
     response['human_readable_status'] = instance.get_status_display()
     response['status'] = instance.status
     response['icon'] = instance.get_status_icon()
     if only_status == "false":  # instance activity
         context = {
+            'instance': instance,
             'activities': InstanceActivity.objects.filter(
                 instance=instance, parent=None
-            ).order_by('-started').select_related()
+            ).order_by('-started').select_related(),
+            'ops': get_operations(instance, request.user),
         }
 
-        activities = render_to_string(
+        response['activities'] = render_to_string(
             "dashboard/vm-detail/_activity-timeline.html",
             RequestContext(request, context),
         )
-        response['activities'] = activities
+        response['ops'] = render_to_string(
+            "dashboard/vm-detail/_operations.html",
+            RequestContext(request, context),
+        )
 
     return HttpResponse(
         json.dumps(response),
@@ -2018,40 +2072,6 @@ class NotificationView(LoginRequiredMixin, TemplateView):
         return response
 
 
-class VmMigrateView(SuperuserRequiredMixin, TemplateView):
-
-    def get_template_names(self):
-        if self.request.is_ajax():
-            return ['dashboard/modal-wrapper.html']
-        else:
-            return ['dashboard/nojs-wrapper.html']
-
-    def get(self, request, form=None, *args, **kwargs):
-        context = self.get_context_data(**kwargs)
-        vm = Instance.objects.get(pk=kwargs['pk'])
-        context.update({
-            'template': 'dashboard/_vm-migrate.html',
-            'box_title': _('Migrate %(name)s' % {'name': vm.name}),
-            'ajax_title': True,
-            'vm': vm,
-            'nodes': [n for n in Node.objects.filter(enabled=True)
-                      if n.state == "ONLINE"]
-        })
-        return self.render_to_response(context)
-
-    def post(self, *args, **kwargs):
-        node = self.request.POST.get("node")
-        vm = Instance.objects.get(pk=kwargs['pk'])
-
-        if node:
-            node = Node.objects.get(pk=node)
-            vm.migrate.async(to_node=node, user=self.request.user)
-        else:
-            messages.error(self.request, _("You didn't select a node!"))
-
-        return redirect("%s#activity" % vm.get_absolute_url())
-
-
 def circle_login(request):
     authentication_form = CircleAuthenticationForm
     extra_context = {
diff --git a/circle/dashboard/vm/__init__.py b/circle/dashboard/vm/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/circle/dashboard/vm/__init__.py
diff --git a/circle/dashboard/vm/urls.py b/circle/dashboard/vm/urls.py
new file mode 100644
index 0000000..d421d0c
--- /dev/null
+++ b/circle/dashboard/vm/urls.py
@@ -0,0 +1,8 @@
+from django.conf.urls import patterns, url
+
+from ..views import vm_ops
+
+
+urlpatterns = patterns('',
+                       *(url(r'^%s/$' % op, v.as_view(), name=v.get_urlname())
+                         for op, v in vm_ops.iteritems()))
diff --git a/circle/vm/operations.py b/circle/vm/operations.py
index 4fee39d..b25694d 100644
--- a/circle/vm/operations.py
+++ b/circle/vm/operations.py
@@ -60,6 +60,7 @@ class DeployOperation(InstanceOperation):
     id = 'deploy'
     name = _("deploy")
     description = _("Deploy new virtual machine with network.")
+    icon = 'play'
 
     def on_commit(self, activity):
         activity.resultant_state = 'RUNNING'
@@ -96,6 +97,7 @@ class DestroyOperation(InstanceOperation):
     id = 'destroy'
     name = _("destroy")
     description = _("Destroy virtual machine and its networks.")
+    icon = 'remove'
 
     def on_commit(self, activity):
         activity.resultant_state = 'DESTROYED'
@@ -136,6 +138,7 @@ class MigrateOperation(InstanceOperation):
     id = 'migrate'
     name = _("migrate")
     description = _("Live migrate running VM to another node.")
+    icon = 'truck'
 
     def _operation(self, activity, user, system, to_node=None, timeout=120):
         if not to_node:
@@ -166,6 +169,7 @@ class RebootOperation(InstanceOperation):
     id = 'reboot'
     name = _("reboot")
     description = _("Reboot virtual machine with Ctrl+Alt+Del signal.")
+    icon = 'refresh'
 
     def _operation(self, activity, user, system, timeout=5):
         self.instance.reboot_vm(timeout=timeout)
@@ -179,6 +183,7 @@ class ResetOperation(InstanceOperation):
     id = 'reset'
     name = _("reset")
     description = _("Reset virtual machine (reset button).")
+    icon = 'bolt'
 
     def _operation(self, activity, user, system, timeout=5):
         self.instance.reset_vm(timeout=timeout)
@@ -195,6 +200,7 @@ class SaveAsTemplateOperation(InstanceOperation):
         Template can be shared with groups and users.
         Users can instantiate Virtual Machines from Templates.
         """)
+    icon = 'save'
 
     def _operation(self, activity, name, user, system, timeout=300,
                    with_shutdown=True, **kwargs):
@@ -260,6 +266,7 @@ class ShutdownOperation(InstanceOperation):
     id = 'shutdown'
     name = _("shutdown")
     description = _("Shutdown virtual machine with ACPI signal.")
+    icon = 'off'
 
     def check_precond(self):
         super(ShutdownOperation, self).check_precond()
@@ -289,6 +296,7 @@ class ShutOffOperation(InstanceOperation):
     id = 'shut_off'
     name = _("shut off")
     description = _("Shut off VM (plug-out).")
+    icon = 'ban-circle'
 
     def on_commit(self, activity):
         activity.resultant_state = 'STOPPED'
@@ -315,6 +323,7 @@ class SleepOperation(InstanceOperation):
     id = 'sleep'
     name = _("sleep")
     description = _("Suspend virtual machine with memory dump.")
+    icon = 'moon'
 
     def check_precond(self):
         super(SleepOperation, self).check_precond()
@@ -354,6 +363,7 @@ class WakeUpOperation(InstanceOperation):
 
         Power on Virtual Machine and load its memory from dump.
         """)
+    icon = 'sun'
 
     def check_precond(self):
         super(WakeUpOperation, self).check_precond()