Commit bf5b78a0 by Csók Tamás

Merge branch 'master' into issue-218

parents 41d90c7f 345a3659
......@@ -23,6 +23,7 @@ celerybeat-schedule
.coverage
*,cover
coverage.xml
.noseids
# Gettext object file:
*.mo
......
......@@ -229,7 +229,7 @@ class AclBase(Model):
levelfilter,
content_type=ct, level__weight__gte=level.weight).distinct()
clsfilter = Q(object_level_set__in=ols.all())
return cls.objects.filter(clsfilter)
return cls.objects.filter(clsfilter).distinct()
def save(self, *args, **kwargs):
super(AclBase, self).save(*args, **kwargs)
......
......@@ -431,9 +431,18 @@ LOGIN_REDIRECT_URL = "/"
AGENT_DIR = get_env_variable(
'DJANGO_AGENT_DIR', join(unicode(expanduser("~")), 'agent'))
# AGENT_DIR is the root directory for the agent.
# The directory structure SHOULD be:
# /home/username/agent
# |-- agent-linux
# | |-- .git
# | +-- ...
# |-- agent-win
# | +-- agent-win-%(version).exe
#
try:
git_env = {'GIT_DIR': join(AGENT_DIR, '.git')}
git_env = {'GIT_DIR': join(join(AGENT_DIR, "agent-linux"), '.git')}
AGENT_VERSION = check_output(
('git', 'log', '-1', r'--pretty=format:%h', 'HEAD'), env=git_env)
except:
......
......@@ -26,6 +26,17 @@ from .models import activity_context, has_suffix, humanize_exception
logger = getLogger(__name__)
class SubOperationMixin(object):
required_perms = ()
def create_activity(self, parent, user, kwargs):
if not parent:
raise TypeError("SubOperation can only be called with "
"parent_activity specified.")
return super(SubOperationMixin, self).create_activity(
parent, user, kwargs)
class Operation(object):
"""Base class for VM operations.
"""
......@@ -36,6 +47,10 @@ class Operation(object):
abortable = False
has_percentage = False
@classmethod
def get_activity_code_suffix(cls):
return cls.id
def __call__(self, **kwargs):
return self.call(**kwargs)
......@@ -62,6 +77,8 @@ class Operation(object):
parent_activity = auxargs.pop('parent_activity')
if parent_activity and user is None and not skip_auth_check:
user = parent_activity.user
if user is None: # parent was a system call
skip_auth_check = True
# check for unexpected keyword arguments
argspec = getargspec(self._operation)
......@@ -232,7 +249,7 @@ class OperatedMixin(object):
operation could be found.
"""
for op in getattr(self, operation_registry_name, {}).itervalues():
if has_suffix(activity_code, op.activity_code_suffix):
if has_suffix(activity_code, op.get_activity_code_suffix()):
return op(self)
else:
return None
......
......@@ -27,9 +27,7 @@ class OperationTestCase(TestCase):
class AbortEx(Exception):
pass
op = Operation(MagicMock())
op.activity_code_suffix = 'test'
op.id = 'test'
op = TestOp(MagicMock())
op.async_operation = MagicMock(
apply_async=MagicMock(side_effect=AbortEx))
......@@ -44,9 +42,7 @@ class OperationTestCase(TestCase):
class AbortEx(Exception):
pass
op = Operation(MagicMock())
op.activity_code_suffix = 'test'
op.id = 'test'
op = TestOp(MagicMock())
with patch.object(Operation, 'create_activity', side_effect=AbortEx):
with patch.object(Operation, 'check_precond') as chk_pre:
try:
......@@ -55,9 +51,7 @@ class OperationTestCase(TestCase):
self.assertTrue(chk_pre.called)
def test_auth_check_on_non_system_call(self):
op = Operation(MagicMock())
op.activity_code_suffix = 'test'
op.id = 'test'
op = TestOp(MagicMock())
user = MagicMock()
with patch.object(Operation, 'check_auth') as check_auth:
with patch.object(Operation, 'check_precond'), \
......@@ -67,9 +61,7 @@ class OperationTestCase(TestCase):
check_auth.assert_called_with(user)
def test_no_auth_check_on_system_call(self):
op = Operation(MagicMock())
op.activity_code_suffix = 'test'
op.id = 'test'
op = TestOp(MagicMock())
with patch.object(Operation, 'check_auth', side_effect=AssertionError):
with patch.object(Operation, 'check_precond'), \
patch.object(Operation, 'create_activity'), \
......@@ -77,39 +69,25 @@ class OperationTestCase(TestCase):
op.call(system=True)
def test_no_exception_for_more_arguments_when_operation_takes_kwargs(self):
class KwargOp(Operation):
activity_code_suffix = 'test'
id = 'test'
def _operation(self, **kwargs):
pass
op = KwargOp(MagicMock())
with patch.object(KwargOp, 'create_activity'), \
patch.object(KwargOp, '_exec_op'):
op = TestOp(MagicMock())
with patch.object(TestOp, 'create_activity'), \
patch.object(TestOp, '_exec_op'):
op.call(system=True, foo=42)
def test_exception_for_unexpected_arguments(self):
class TestOp(Operation):
activity_code_suffix = 'test'
id = 'test'
def _operation(self):
pass
op = TestOp(MagicMock())
with patch.object(TestOp, 'create_activity'), \
patch.object(TestOp, '_exec_op'):
self.assertRaises(TypeError, op.call, system=True, foo=42)
self.assertRaises(TypeError, op.call, system=True, bar=42)
def test_exception_for_missing_arguments(self):
class TestOp(Operation):
activity_code_suffix = 'test'
id = 'test'
def _operation(self, foo):
pass
op = TestOp(MagicMock())
with patch.object(TestOp, 'create_activity'):
self.assertRaises(TypeError, op.call, system=True)
class TestOp(Operation):
id = 'test'
def _operation(self, foo):
pass
......@@ -18,6 +18,7 @@
from __future__ import absolute_import
from datetime import timedelta
from urlparse import urlparse
from django.contrib.auth.forms import (
AuthenticationForm, PasswordResetForm, SetPasswordForm,
......@@ -39,6 +40,7 @@ from django.contrib.auth.forms import UserCreationForm as OrgUserCreationForm
from django.forms.widgets import TextInput, HiddenInput
from django.template import Context
from django.template.loader import render_to_string
from django.utils.html import escape
from django.utils.translation import ugettext_lazy as _
from sizefield.widgets import FileSizeWidget
from django.core.urlresolvers import reverse_lazy
......@@ -79,6 +81,12 @@ class VmSaveForm(forms.Form):
helper.form_tag = False
return helper
def __init__(self, *args, **kwargs):
default = kwargs.pop('default', None)
super(VmSaveForm, self).__init__(*args, **kwargs)
if default:
self.fields['name'].initial = default
class VmCustomizeForm(forms.Form):
name = forms.CharField(widget=forms.TextInput(attrs={
......@@ -744,6 +752,20 @@ class VmRenewForm(forms.Form):
return helper
class VmMigrateForm(forms.Form):
live_migration = forms.BooleanField(
required=False, initial=True, label=_("live migration"))
def __init__(self, *args, **kwargs):
choices = kwargs.pop('choices')
default = kwargs.pop('default')
super(VmMigrateForm, self).__init__(*args, **kwargs)
self.fields.insert(0, 'to_node', forms.ModelChoiceField(
queryset=choices, initial=default, required=False,
widget=forms.RadioSelect(), label=_("Node")))
class VmStateChangeForm(forms.Form):
interrupt = forms.BooleanField(required=False, label=_(
......@@ -752,6 +774,7 @@ class VmStateChangeForm(forms.Form):
"but don't interrupt any tasks."))
new_state = forms.ChoiceField(Instance.STATUS, label=_(
"New status"))
reset_node = forms.BooleanField(required=False, label=_("Reset node"))
def __init__(self, *args, **kwargs):
show_interrupt = kwargs.pop('show_interrupt')
......@@ -769,6 +792,17 @@ class VmStateChangeForm(forms.Form):
return helper
class RedeployForm(forms.Form):
with_emergency_change_state = forms.BooleanField(
required=False, initial=True, label=_("use emergency state change"))
@property
def helper(self):
helper = FormHelper(self)
helper.form_tag = False
return helper
class VmCreateDiskForm(forms.Form):
name = forms.CharField(max_length=100, label=_("Name"))
size = forms.CharField(
......@@ -776,6 +810,12 @@ class VmCreateDiskForm(forms.Form):
help_text=_('Size of disk to create in bytes or with units '
'like MB or GB.'))
def __init__(self, *args, **kwargs):
default = kwargs.pop('default', None)
super(VmCreateDiskForm, self).__init__(*args, **kwargs)
if default:
self.fields['name'].initial = default
def clean_size(self):
size_in_bytes = self.cleaned_data.get("size")
if not size_in_bytes.isdigit() and len(size_in_bytes) > 0:
......@@ -827,13 +867,42 @@ class VmDiskResizeForm(forms.Form):
helper.form_tag = False
if self.disk:
helper.layout = Layout(
HTML(_("<label>Disk:</label> %s") % self.disk),
HTML(_("<label>Disk:</label> %s") % escape(self.disk)),
Field('disk'), Field('size'))
return helper
class VmDiskRemoveForm(forms.Form):
def __init__(self, *args, **kwargs):
choices = kwargs.pop('choices')
self.disk = kwargs.pop('default')
super(VmDiskRemoveForm, self).__init__(*args, **kwargs)
self.fields.insert(0, 'disk', forms.ModelChoiceField(
queryset=choices, initial=self.disk, required=True,
empty_label=None, label=_('Disk')))
if self.disk:
self.fields['disk'].widget = HiddenInput()
@property
def helper(self):
helper = FormHelper(self)
helper.form_tag = False
if self.disk:
helper.layout = Layout(
AnyTag(
"div",
HTML(_("<label>Disk:</label> %s") % escape(self.disk)),
css_class="form-group",
),
Field("disk"),
)
return helper
class VmDownloadDiskForm(forms.Form):
name = forms.CharField(max_length=100, label=_("Name"))
name = forms.CharField(max_length=100, label=_("Name"), required=False)
url = forms.CharField(label=_('URL'), validators=[URLValidator(), ])
@property
......@@ -842,6 +911,18 @@ class VmDownloadDiskForm(forms.Form):
helper.form_tag = False
return helper
def clean(self):
cleaned_data = super(VmDownloadDiskForm, self).clean()
if not cleaned_data['name']:
if cleaned_data['url']:
cleaned_data['name'] = urlparse(
cleaned_data['url']).path.split('/')[-1]
if not cleaned_data['name']:
raise forms.ValidationError(
_("Could not find filename in URL, "
"please specify a name explicitly."))
return cleaned_data
class VmAddInterfaceForm(forms.Form):
def __init__(self, *args, **kwargs):
......
......@@ -236,6 +236,9 @@ class GroupProfile(AclBase):
help_text=_('Unique identifier of the group at the organization.'))
description = TextField(blank=True)
def __unicode__(self):
return self.group.name
def save(self, *args, **kwargs):
if not self.org_id:
self.org_id = None
......
......@@ -528,7 +528,7 @@ footer a, footer a:hover, footer a:visited {
}
#dashboard-template-list a small {
max-width: 50%;
max-width: 45%;
float: left;
padding-top: 2px;
text-overflow: ellipsis;
......@@ -974,6 +974,10 @@ textarea[name="new_members"] {
color: orange;
}
#vm-info-pane {
margin-bottom: 20px;
}
.node-list-table tbody>tr>td, .node-list-table thead>tr>th {
vertical-align: middle;
}
......@@ -996,10 +1000,19 @@ textarea[name="new_members"] {
max-width: 100%;
}
#vm-list-table tbody td:nth-child(3) {
#vm-list-table td.state,
#vm-list-table td.memory {
white-space: nowrap;
}
#vm-list-table td {
vertical-align: middle;
}
.disk-resize-btn {
margin-right: 5px;
}
#vm-migrate-node-list li {
cursor: pointer;
}
......@@ -411,6 +411,17 @@ $(function () {
$(this).removeClass("btn-default").addClass("btn-primary");
return false;
});
// vm migrate select for node
$(document).on("click", "#vm-migrate-node-list li", function(e) {
var li = $(this).closest('li');
if (li.find('input').attr('disabled'))
return true;
$('#vm-migrate-node-list li').removeClass('panel-primary');
li.addClass('panel-primary').find('input').prop("checked", true);
return true;
});
});
function generateVmHTML(pk, name, host, icon, _status, fav, is_last) {
......@@ -445,7 +456,7 @@ function generateNodeHTML(name, icon, _status, url, is_last) {
function generateNodeTagHTML(name, icon, _status, label , url) {
return '<a href="' + url + '" class="label ' + label + '" >' +
'<i class="' + icon + '" title="' + _status + '"></i> ' + name +
'<i class="fa ' + icon + '" title="' + _status + '"></i> ' + name +
'</a> ';
}
......@@ -618,7 +629,7 @@ function addModalConfirmation(func, data) {
}
function clientInstalledAction(location) {
setCookie('downloaded_client', true, 365 * 24 * 60 * 60, "/");
setCookie('downloaded_client', true, 365 * 24 * 60 * 60 * 1000, "/");
window.location.href = location;
$('#confirmation-modal').modal("hide");
}
......
......@@ -16,15 +16,6 @@ $(function() {
$('#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'))
return true;
$('#vm-migrate-node-list li').removeClass('panel-primary');
li.addClass('panel-primary').find('input').attr('checked', true);
return false;
});
$('#vm-migrate-node-list li input:checked').closest('li').addClass('panel-primary');
}
});
......@@ -51,7 +42,8 @@ $(function() {
if(data.success) {
$('a[href="#activity"]').trigger("click");
if(data.with_reload) {
location.reload();
// when the activity check stops the page will reload
reload_vm_detail = true;
}
/* if there are messages display them */
......
var show_all = false;
var in_progress = false;
var activity_hash = 5;
var reload_vm_detail = false;
$(function() {
/* do we need to check for new activities */
......@@ -404,6 +405,7 @@ function checkNewActivity(runs) {
);
} else {
in_progress = false;
if(reload_vm_detail) location.reload();
}
$('a[href="#activity"] i').removeClass('fa-spin');
},
......
<img src="{{ STATIC_URL}}dashboard/img/logo.png" style="height: 25px;"/>
<img src="{{ STATIC_URL}}local-logo.png" style="padding-left: 2px; height: 25px;"/>
{% load i18n %}
{% load sizefieldtags %}
<i class="fa {% if d.is_downloading %}fa-refresh fa-spin{% else %}fa-file{% if d.failed %}" style="color: #d9534f;{% endif %}{% endif %}"></i>
{{ d.name }} (#{{ d.id }}) -
{% if not d.is_downloading %}
{% if not d.failed %}
{% if d.size %}{{ d.size|filesize }}{% endif %}
{% else %}
<div class="label label-danger"{% if user.is_superuser %} title="{{ d.get_latest_activity_result }}"{% endif %}>{% trans "failed" %}</div>
{% endif %}
{% else %}<span class="disk-list-disk-percentage" data-disk-pk="{{ d.pk }}">{{ d.get_download_percentage }}</span>%{% endif %}
{% if is_owner != False %}
<a href="{% url "dashboard.views.disk-remove" pk=d.pk %}?next={{ request.path }}"
data-disk-pk="{{ d.pk }}" class="btn btn-xs btn-danger pull-right disk-remove"
{% if not long_remove %}title="{% trans "Remove" %}"{% endif %}>
<i class="fa fa-times"></i>{% if long_remove %} {% trans "Remove" %}{% endif %}
</a>
{% if op.resize_disk %}
<span class="operation-wrapper">
<a href="{{ op.resize_disk.get_url }}?disk={{d.pk}}" class="btn btn-xs btn-warning pull-right operation">
<i class="fa fa-arrows-alt"></i> {% trans "Resize" %}
</a>
</span>
{% endif %}
<i class="fa fa-file"></i>
{{ d.name }} (#{{ d.id }}) - {{ d.size|filesize }}
{% if op.remove_disk %}
<span class="operation-wrapper">
<a href="{{ op.remove_disk.get_url }}?disk={{d.pk}}"
class="btn btn-xs btn-{{ op.remove_disk.effect}} pull-right operation disk-remove-btn
{% if op.resize_disk.disabled %}disabled{% endif %}">
<i class="fa fa-{{ op.remove_disk.icon }}"></i> {% trans "Remove" %}
</a>
</span>
{% endif %}
{% if op.resize_disk %}
<span class="operation-wrapper">
<a href="{{ op.resize_disk.get_url }}?disk={{d.pk}}"
class="btn btn-xs btn-{{ op.resize_disk.effect }} pull-right operation disk-resize-btn
{% if op.resize_disk.disabled %}disabled{% endif %}">
<i class="fa fa-{{ op.resize_disk.icon }}"></i> {% trans "Resize" %}
</a>
</span>
{% endif %}
<div style="clear: both;"></div>
{% if request.user.is_superuser %}
<small>{% trans "File name" %}: {{ d.filename }}</small>
{% endif %}
{% extends "dashboard/mass-operate.html" %}
{% load i18n %}
{% load sizefieldtags %}
{% load crispy_forms_tags %}
{% block formfields %}
......@@ -11,20 +12,20 @@
<label for="migrate-to-none">
<strong>{% trans "Reschedule" %}</strong>
</label>
<input id="migrate-to-none" type="radio" name="node" value="" style="float: right;" checked="checked">
<input id="migrate-to-none" type="radio" name="to_node" value="" style="float: right;" checked="checked">
<span class="vm-migrate-node-property">
{% trans "This option will reschedule each virtual machine to the optimal node." %}
</span>
<div style="clear: both;"></div>
</div>
</li>
{% for n in nodes %}
{% for n in form.fields.to_node.queryset.all %}
<li class="panel panel-default mass-migrate-node">
<div class="panel-body">
<label for="migrate-to-{{n.pk}}">
<strong>{{ n }}</strong>
</label>
<input id="migrate-to-{{n.pk}}" type="radio" name="node" value="{{ n.pk }}" style="float: right;"/>
<input id="migrate-to-{{n.pk}}" type="radio" name="to_node" value="{{ n.pk }}" style="float: right;"/>
<span class="vm-migrate-node-property">{% trans "CPU load" %}: {{ n.cpu_usage }}</span>
<span class="vm-migrate-node-property">{% trans "RAM usage" %}: {{ n.byte_ram_usage|filesize }}/{{ n.ram_size|filesize }}</span>
<div style="clear: both;"></div>
......@@ -32,5 +33,6 @@
</li>
{% endfor %}
</ul>
{{ form.live_migration|as_crispy_field }}
<hr />
{% endblock %}
{% extends "dashboard/operate.html" %}
{% load i18n %}
{% load sizefieldtags %}
{% load crispy_forms_tags %}
{% block question %}
<p>
......@@ -13,24 +14,27 @@ Choose a compute node to migrate {{obj}} to.
{% 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 %}
{% with current=object.node.pk recommended=form.fields.to_node.initial.pk %}
{% for n in form.fields.to_node.queryset.all %}
<li class="panel panel-default"><div class="panel-body">
<label for="migrate-to-{{n.pk}}">
<strong>{{ n }}</strong>
<div class="label label-primary"><i class="fa {{n.get_status_icon}}"></i>
{{n.get_status_display}}</div>
<div class="label label-primary">
<i class="fa {{n.get_status_icon}}"></i> {{n.get_status_display}}</div>
{% if current == n.pk %}<div class="label label-info">{% trans "current" %}</div>{% endif %}
{% if selected == n.pk %}<div class="label label-success">{% trans "recommended" %}</div>{% endif %}
{% if recommended == n.pk %}<div class="label label-success">{% trans "recommended" %}</div>{% endif %}
</label>
<input id="migrate-to-{{n.pk}}" type="radio" name="node" value="{{ n.pk }}" style="float: right;"
{% if current == n.pk %}disabled="disabled"{% endif %}
{% if selected == n.pk %}checked="checked"{% endif %} />
<input id="migrate-to-{{n.pk}}" type="radio" name="to_node" value="{{ n.pk }}" style="float: right;"
{% if current == n.pk %}disabled="disabled"{% endif %}
{% if recommended == n.pk %}checked="checked"{% endif %}
/>
<span class="vm-migrate-node-property">{% trans "CPU load" %}: {{ n.cpu_usage }}</span>
<span class="vm-migrate-node-property">{% trans "RAM usage" %}: {{ n.byte_ram_usage|filesize }}/{{ n.ram_size|filesize }}</span>
<span class="vm-migrate-node-property">
{% trans "RAM usage" %}: {{ n.byte_ram_usage|filesize }}/{{ n.ram_size|filesize }}</span>
<div style="clear: both;"></div>
</div></li>
</li>
{% endfor %}
{% endwith %}
</ul>
{{ form.live_migration|as_crispy_field }}
{% endblock %}
......@@ -13,7 +13,7 @@
{% block navbar-brand %}
<a class="navbar-brand" href="{% url "dashboard.index" %}" style="padding: 10px 15px;">
<img src="{{ STATIC_URL}}dashboard/img/logo.png" style="height: 25px;"/>
{% include "branding.html" %}
</a>
{% endblock %}
......
......@@ -52,6 +52,36 @@
<hr />
<h3>{% trans "Available objects for this group" %}</h3>
<ul class="dashboard-profile-vm-list fa-ul">
{% for i in vm_objects %}
<li>
<a href="{{ i.get_absolute_url }}">
<i class="fa fa-li {{ i.get_status_icon }}"></i>
{{ i }}
</a>
</li>
{% endfor %}
{% for t in template_objects %}
<li>
<a href="{{ t.get_absolute_url }}">
<i class="fa fa-li fa-puzzle-piece"></i>
{{ t }}
</a>
</li>
{% endfor %}
{% for g in group_objects %}
<li>
<a href="{{ g.get_absolute_url }}">
<i class="fa fa-li fa-users"></i>
{{ g }}
</a>
</li>
{% endfor %}
</ul>
<hr />
<h3>{% trans "User list"|capfirst %}
{% if perms.auth.add_user %}
<a href="{% url "dashboard.views.create-user" group.pk %}" class="btn btn-success pull-right">{% trans "Create user" %}</a>
......
{% load i18n %}
<div class="panel panel-default">
<div class="panel panel-default">
<div class="panel-heading">
<div class="pull-right toolbar">
<div class="btn-group">
......@@ -7,9 +7,10 @@
data-container="body"><i class="fa fa-dashboard"></i></a>
<a href="#index-list-view" data-index-box="node" class="btn btn-default btn-xs disabled"
data-container="body"><i class="fa fa-list"></i></a>
</div>
<span class="btn btn-default btn-xs infobtn" title="{% trans "List of compute nodes, also called worker nodes or hypervisors, which run the virtual machines." %}"><i class="fa fa-info-circle"></i></span>
<span class="btn btn-default btn-xs infobtn" title="{% trans "List of compute nodes, also called worker nodes or hypervisors, which run the virtual machines." %}">
<i class="fa fa-info-circle"></i>
</span>
</div>
<h3 class="no-margin">
<i class="fa fa-sitemap"></i> {% trans "Nodes" %}
......@@ -28,50 +29,55 @@
</a>
{% endfor %}
</div>
<div href="#" class="list-group-item list-group-footer">
<div class="row">
<div class="col-sm-6 col-xs-6 input-group input-group-sm">
<input id="dashboard-node-search-input" type="text" class="form-control" placeholder="{% trans "Search..." %}" />
<div class="input-group-btn">
<button type="submit" class="form-control btn btn-primary" title="search"><i class="fa fa-search"></i></button>
</div>
</div>
<div class="col-sm-6 text-right">
<a class="btn btn-primary btn-xs" href="{% url "dashboard.views.node-list" %}">
<i class="fa fa-chevron-circle-right"></i>
{% if more_nodes > 0 %}
{% blocktrans with count=more_nodes %}<strong>{{count}}</strong> more{% endblocktrans %}
{% else %}
{% trans "list" %}
{% endif %}
</a>
<a class="btn btn-success btn-xs node-create" href="{% url "dashboard.views.node-create" %}"><i class="fa fa-plus-circle"></i> {% trans "new" %}</a>
</div>
</div>
</div>
</div>
</div><!-- #node-list-view -->
<div class="panel-body" id="node-graph-view" style="display: none">
<p class="pull-right"> <input class="knob" data-fgColor="chartreuse" data-thickness=".4" data-width="60" data-height="60" data-readOnly="true" value="{% widthratio node_num.running sum_node_num 100 %}"></p>
<p><span class="big"><big>{{ node_num.running }}</big> running </span>
+ <big>{{ node_num.missing }}</big> missing + <br><big>{{ node_num.disabled }}</big> disabled + <big>{{ node_num.offline }}</big> offline</p>
<ul class="list-inline" id="dashboard-node-taglist">
{% for i in nodes %}
<a href="{{ i.get_absolute_url }}" class="label {{i.get_status_label}}" >
<i class="fa {{ i.get_status_icon }}" title="{{ i.get_status_display }}"></i> {{ i.name }}</a>
{% endfor %}
</ul>
<div class="panel-body" id="node-graph-view" style="display: none; min-height: 204px;">
<p class="pull-right">
<input class="knob" data-fgColor="chartreuse"
data-thickness=".4" data-width="60" data-height="60" data-readOnly="true"
value="{% widthratio node_num.running sum_node_num 100 %}">
</p>
<p>
<span class="big">
<big>{{ node_num.running }}</big> running
</span>
+ <big>{{ node_num.missing }}</big>
missing + <br><big>{{ node_num.disabled }}</big> disabled + <big>{{ node_num.offline }}</big> offline
</p>
<ul class="list-inline" id="dashboard-node-taglist">
{% for i in nodes %}
<a href="{{ i.get_absolute_url }}" class="label {{i.get_status_label}}" >
<i class="fa {{ i.get_status_icon }}" title="{{ i.get_status_display }}"></i> {{ i.name }}</a>
{% endfor %}
</ul>
<div class="clearfix"></div>
<div class="row">
<div class="col-sm-6 text-right pull-right">