← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~rvb/maas/nodegroup-ui into lp:maas

 

Raphaël Badin has proposed merging lp:~rvb/maas/nodegroup-ui into lp:maas with lp:~rvb/maas/remove-ip-range-uniqueness as a prerequisite.

Commit message:
Add UI pages to edit and delete the clusters and the cluster interfaces.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~rvb/maas/nodegroup-ui/+merge/127603

This branch adds the UI pages to edit and delete the clusters and the cluster interfaces.

I followed Huw's mockups but I had to put the edition of the interfaces on a separate page: on Huw's mockups, only one interface can be edited but as soon as we have many interfaces, then having them all one one page is a mess.

= UI =

Cluster listing on the settings page:
http://people.canonical.com/~rvb/clusters_listing.png

Edition of a cluster:
http://people.canonical.com/~rvb/cluster_edit.png

Deletion of a cluster:
http://people.canonical.com/~rvb/cluster_delete.png

Edition of an interface:
http://people.canonical.com/~rvb/cluster_interface_edit.png

Deletion of an interface:
http://people.canonical.com/~rvb/cluster_interface_delete.png

= Notes =

I'm sure the UI can be improved but maybe it good to get that reviewed and landed and then have a UI specialist (hi Huw) have a look at it and fix things directly.

This branch revealed on problem but it can probably be fixed in a follow-up branch: when one has an approved cluster with a managed interface and sets a network config (like broadcast address) to None, then the dhcp writing task blows up when the form is saved.  The validation of the form has to be more strict.

Also, I did not add the UI to add new clusters or new cluster interfaces.  Not sure this is needed because that registration step should be done via the API, when a cluster registers itself.  But anyway, this can be added later.
-- 
https://code.launchpad.net/~rvb/maas/nodegroup-ui/+merge/127603
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~rvb/maas/nodegroup-ui into lp:maas.
=== modified file 'src/maasserver/enum.py'
--- src/maasserver/enum.py	2012-09-28 17:20:04 +0000
+++ src/maasserver/enum.py	2012-10-03 07:59:24 +0000
@@ -18,6 +18,7 @@
     'NODEGROUP_STATUS_CHOICES',
     'NODEGROUPINTERFACE_MANAGEMENT',
     'NODEGROUPINTERFACE_MANAGEMENT_CHOICES',
+    'NODEGROUPINTERFACE_MANAGEMENT_CHOICES_DICT',
     'NODE_PERMISSION',
     'NODE_STATUS',
     'NODE_STATUS_CHOICES',
@@ -197,3 +198,7 @@
     (NODEGROUPINTERFACE_MANAGEMENT.DHCP, "Manage DHCP"),
     (NODEGROUPINTERFACE_MANAGEMENT.DHCP_AND_DNS, "Manage DHCP and DNS"),
     )
+
+
+NODEGROUPINTERFACE_MANAGEMENT_CHOICES_DICT = (
+    OrderedDict(NODEGROUPINTERFACE_MANAGEMENT_CHOICES))

=== modified file 'src/maasserver/forms.py'
--- src/maasserver/forms.py	2012-10-02 13:55:22 +0000
+++ src/maasserver/forms.py	2012-10-03 07:59:24 +0000
@@ -21,6 +21,7 @@
     "MAASAndNetworkForm",
     "NodeGroupInterfaceForm",
     "NodeGroupWithInterfacesForm",
+    "NodeGroupEdit",
     "NodeWithMACAddressesForm",
     "SSHKeyForm",
     "UbuntuForm",
@@ -652,14 +653,14 @@
     class Meta:
         model = NodeGroupInterface
         fields = (
+            'interface',
+            'management',
             'ip',
-            'interface',
             'subnet_mask',
             'broadcast_ip',
             'router_ip',
             'ip_range_low',
             'ip_range_high',
-            'management',
             )
 
     def clean_management(self):
@@ -682,11 +683,11 @@
                         "nodegroup."]})
         return management
 
-    def save(self, nodegroup=None, *args, **kwargs):
+    def save(self, nodegroup=None, commit=True, *args, **kwargs):
         interface = super(NodeGroupInterfaceForm, self).save(commit=False)
         if nodegroup is not None:
             interface.nodegroup = nodegroup
-        if kwargs.get('commit', True):
+        if commit:
             interface.save(*args, **kwargs)
         return interface
 
@@ -709,8 +710,8 @@
     class Meta:
         model = NodeGroup
         fields = (
+            'cluster_name',
             'name',
-            'cluster_name',
             'uuid',
             )
 
@@ -782,6 +783,25 @@
         return nodegroup
 
 
+class NodeGroupEdit(ModelForm):
+
+    name = forms.CharField(
+        label="DNS zone name",
+        help_text=(
+            "Name of the related DNS zone.  Note that this will only "
+            "be used if MAAS is managing a DNS zone for one of the interfaces "
+            "of this cluster.  See the 'status' of the interfaces below."),
+        required=False)
+
+    class Meta:
+        model = NodeGroup
+        fields = (
+            'cluster_name',
+            'status',
+            'name',
+            )
+
+
 class TagForm(ModelForm):
 
     class Meta:

=== modified file 'src/maasserver/models/nodegroup.py'
--- src/maasserver/models/nodegroup.py	2012-10-02 11:18:26 +0000
+++ src/maasserver/models/nodegroup.py	2012-10-03 07:59:24 +0000
@@ -130,7 +130,7 @@
         max_length=80, unique=False, editable=True, blank=True, null=False)
 
     status = IntegerField(
-        choices=NODEGROUP_STATUS_CHOICES, editable=False,
+        choices=NODEGROUP_STATUS_CHOICES, editable=True,
         default=NODEGROUP_STATUS.DEFAULT_STATUS)
 
     # Credentials for the worker to access the API with.

=== modified file 'src/maasserver/models/nodegroupinterface.py'
--- src/maasserver/models/nodegroupinterface.py	2012-10-02 15:19:23 +0000
+++ src/maasserver/models/nodegroupinterface.py	2012-10-03 07:59:24 +0000
@@ -28,6 +28,7 @@
 from maasserver.enum import (
     NODEGROUPINTERFACE_MANAGEMENT,
     NODEGROUPINTERFACE_MANAGEMENT_CHOICES,
+    NODEGROUPINTERFACE_MANAGEMENT_CHOICES_DICT,
     )
 from maasserver.models.cleansave import CleanSave
 from maasserver.models.timestampedmodel import TimestampedModel
@@ -43,7 +44,9 @@
         unique_together = ('nodegroup', 'interface')
 
     # Static IP of the interface.
-    ip = GenericIPAddressField(null=False, editable=True)
+    ip = GenericIPAddressField(
+        null=False, editable=True,
+        help_text="Static IP Address of the interface")
 
     # The `NodeGroup` this interface belongs to.
     nodegroup = ForeignKey(
@@ -55,7 +58,8 @@
 
     # DHCP server settings.
     interface = CharField(
-        blank=True, editable=True, max_length=255, default='')
+        blank=True, editable=True, max_length=255, default='',
+        help_text="Name of this interface (e.g. 'em1').")
     subnet_mask = GenericIPAddressField(
         editable=True, unique=False, blank=True, null=True, default=None)
     broadcast_ip = GenericIPAddressField(
@@ -80,6 +84,10 @@
             return IPNetwork("%s/%s" % (self.broadcast_ip, self.subnet_mask))
         return None
 
+    def display_management(self):
+        """Return management status text as displayed to the user."""
+        return NODEGROUPINTERFACE_MANAGEMENT_CHOICES_DICT[self.management]
+
     def __repr__(self):
         return "<NodeGroupInterface %r,%s>" % (self.nodegroup, self.interface)
 

=== added file 'src/maasserver/templates/maasserver/nodegroup_confirm_delete.html'
--- src/maasserver/templates/maasserver/nodegroup_confirm_delete.html	1970-01-01 00:00:00 +0000
+++ src/maasserver/templates/maasserver/nodegroup_confirm_delete.html	2012-10-03 07:59:24 +0000
@@ -0,0 +1,24 @@
+{% extends "maasserver/base.html" %}
+
+{% block nav-active-settings %}active{% endblock %}
+{% block title %}Delete Cluster Controller{% endblock %}
+{% block page-title %}Delete Cluster Controller{% endblock %}
+
+{% block content %}
+    <div class="block auto-width">
+        <h2>
+          Are you sure you want to delete the cluster controller
+          "{{ cluster_to_delete.cluster_name }}"?
+        </h2>
+        <p>This action is permanent and can not be undone.</p>
+        <p>
+          <form action="." method="post">{% csrf_token %}
+            <input type="hidden" name="post" value="yes" />
+            <input type="submit" value="Delete cluster controller"
+                   class="right" />
+            <a href="{% url 'settings' %}">Cancel</a>
+          </form>
+        </p>
+    </div>
+{% endblock %}
+

=== added file 'src/maasserver/templates/maasserver/nodegroup_edit.html'
--- src/maasserver/templates/maasserver/nodegroup_edit.html	1970-01-01 00:00:00 +0000
+++ src/maasserver/templates/maasserver/nodegroup_edit.html	2012-10-03 07:59:24 +0000
@@ -0,0 +1,66 @@
+{% extends "maasserver/base.html" %}
+
+{% block nav-active-settings %}active{% endblock %}
+{% block title %}Edit Cluster Controller{% endblock %}
+{% block page-title %}Edit Cluster Controller{% endblock %}
+
+{% block content %}
+<div class="block size10 first">
+    <form action="." method="post" class="block">
+      {% csrf_token %}
+      <ul>
+      {% for field in form %}
+        {% include "maasserver/form_field.html" %}
+      {% endfor %}
+      </ul>
+      <input type="submit" value="Save cluster controller"
+             class="button right" />
+      <a class="link-button" href="{% url 'settings' %}">Cancel</a>
+      <h2>Interfaces</h2>
+      {% with nb_interfaces=cluster.nodegroupinterface_set.count %}
+      This cluster controller has {{ nb_interfaces }}
+      interface{{ nb_interfaces|pluralize }}.
+      {% endwith %}
+      <table class="list">
+        <thead>
+          <tr>
+            <th>Interface</th>
+            <th>Network</th>
+            <th>Status</th>
+            <th></th>
+          </tr>
+        </thead>
+        <tbody>
+      {% for interface in interfaces %}
+        <tr class="interface {% cycle 'even' 'odd' %}"
+            id="{{ interface.interface }}">
+          <td>{{ interface.interface }}</td>
+          <td>{{ interface.network|default_if_none:"Not configured" }}</td>
+          <td>{{ interface.display_management }}</td>
+          <td>
+            <a href="{% url 'cluster-interface-edit' uuid=cluster.uuid interface=interface.interface %}"
+               title="Edit interface {{ interface.interface }}">
+              <img src="{{ STATIC_URL }}img/edit.png"
+                   alt="edit"
+                   class="space-right-small" />
+            </a>
+            <a title="Delete interface {{ interface.interface }}"
+               href="{% url 'cluster-interface-delete' cluster.uuid interface.interface %}">
+               <img src="{{ STATIC_URL }}img/delete.png" alt="delete" />
+            </a>
+            <form method="POST"
+                  action="{% url 'settings' %}">
+              {% csrf_token %}
+              <input type="hidden" name="uuid"
+                     value="{{ interface.interface }}" />
+            </form>
+          </td>
+        </tr>
+        {% endfor %}
+        </tbody>
+      </table>
+    </form>
+  </div>
+  <div class="clear"></div>
+</div>
+{% endblock %}

=== added file 'src/maasserver/templates/maasserver/nodegroupinterface_confirm_delete.html'
--- src/maasserver/templates/maasserver/nodegroupinterface_confirm_delete.html	1970-01-01 00:00:00 +0000
+++ src/maasserver/templates/maasserver/nodegroupinterface_confirm_delete.html	2012-10-03 07:59:24 +0000
@@ -0,0 +1,23 @@
+{% extends "maasserver/base.html" %}
+
+{% block nav-active-settings %}active{% endblock %}
+{% block title %}Delete Interface{% endblock %}
+{% block page-title %}Delete Interface{% endblock %}
+
+{% block content %}
+    <div class="block auto-width">
+        <h2>
+          Are you sure you want to delete the interface
+          "{{ interface_to_delete.interface }}"?
+        </h2>
+        <p>This action is permanent and can not be undone.</p>
+        <p>
+          <form action="." method="post">{% csrf_token %}
+            <input type="hidden" name="post" value="yes" />
+            <input type="submit" value="Delete interface" class="right" />
+            <a href="{% url 'cluster-edit' interface_to_delete.nodegroup.uuid %}">Cancel</a>
+          </form>
+        </p>
+    </div>
+{% endblock %}
+

=== added file 'src/maasserver/templates/maasserver/nodegroupinterface_edit.html'
--- src/maasserver/templates/maasserver/nodegroupinterface_edit.html	1970-01-01 00:00:00 +0000
+++ src/maasserver/templates/maasserver/nodegroupinterface_edit.html	2012-10-03 07:59:24 +0000
@@ -0,0 +1,23 @@
+{% extends "maasserver/base.html" %}
+
+{% block nav-active-settings %}active{% endblock %}
+{% block title %}Edit Cluster Interface{% endblock %}
+{% block page-title %}Edit Cluster Interface{% endblock %}
+
+{% block content %}
+<div class="block size10 first">
+    <form action="." method="post" class="block">
+      {% csrf_token %}
+      <ul>
+      {% for field in form %}
+        {% include "maasserver/form_field.html" %}
+      {% endfor %}
+      </ul>
+      <input type="submit" value="Save interface" class="button right" />
+      <a class="link-button"
+         href="{% url 'cluster-edit' interface.nodegroup.uuid %}">Cancel</a>
+    </form>
+  </div>
+  <div class="clear"></div>
+</div>
+{% endblock %}

=== modified file 'src/maasserver/templates/maasserver/settings.html'
--- src/maasserver/templates/maasserver/settings.html	2012-09-27 13:50:34 +0000
+++ src/maasserver/templates/maasserver/settings.html	2012-10-03 07:59:24 +0000
@@ -69,6 +69,9 @@
       </a>
       <div class="clear"></div>
     </div>
+    <div id="clusters" class="block size7 first">
+      {% include "maasserver/settings_cluster_listing.html" %}
+    </div>
     <div id="commissioning" class="block size7 first">
       <h2>Commissioning</h2>
       <form action="{% url "settings" %}" method="post">

=== added file 'src/maasserver/templates/maasserver/settings_cluster_listing.html'
--- src/maasserver/templates/maasserver/settings_cluster_listing.html	1970-01-01 00:00:00 +0000
+++ src/maasserver/templates/maasserver/settings_cluster_listing.html	2012-10-03 07:59:24 +0000
@@ -0,0 +1,28 @@
+<h2 id="accepted-clusters">Cluster controllers</h2>
+<table class="list" id="accepted-clusters-list">
+  <tbody>
+    {% for cluster in accepted_clusters %}
+    {% include "maasserver/settings_cluster_listing_row.html" %}
+    {% endfor %}
+  </tbody>
+</table>
+{% if pending_clusters %}
+<h3 id="pending-clusters">Pending clusters</h3>
+<table class="list" id="pending-clusters-list">
+  <tbody>
+    {% for cluster in pending_clusters %}
+    {% include "maasserver/settings_cluster_listing_row.html" %}
+    {% endfor %}
+  </tbody>
+</table>
+{% endif %}
+{% if rejected_clusters %}
+<h3 id="rejected-clusters">Rejected clusters</h3>
+<table class="list" id="rejected-clusters-list">
+  <tbody>
+    {% for cluster in rejected_clusters %}
+    {% include "maasserver/settings_cluster_listing_row.html" %}
+    {% endfor %}
+  </tbody>
+</table>
+{% endif %}

=== added file 'src/maasserver/templates/maasserver/settings_cluster_listing_row.html'
--- src/maasserver/templates/maasserver/settings_cluster_listing_row.html	1970-01-01 00:00:00 +0000
+++ src/maasserver/templates/maasserver/settings_cluster_listing_row.html	2012-10-03 07:59:24 +0000
@@ -0,0 +1,22 @@
+<tr class="cluster {% cycle 'even' 'odd' %}"
+    id="{{ cluster.cluster_name }}">
+  <td>{{ cluster.cluster_name }}</td>
+  <td>
+    <a href="{% url 'cluster-edit' cluster.uuid %}"
+       title="Edit cluster {{ cluster.cluster_name }}">
+      <img src="{{ STATIC_URL }}img/edit.png"
+           alt="edit"
+           class="space-right-small" />
+    </a>
+    <a title="Delete cluster {{ cluster.cluster_name }}"
+       href="{% url 'cluster-delete' cluster.uuid %}">
+       <img src="{{ STATIC_URL }}img/delete.png" alt="delete" />
+    </a>
+    <form method="POST"
+          action="{% url 'settings' %}">
+      {% csrf_token %}
+      <input type="hidden" name="uuid"
+             value="{{ cluster.uuid }}" />
+    </form>
+  </td>
+</tr>

=== modified file 'src/maasserver/testing/factory.py'
--- src/maasserver/testing/factory.py	2012-09-30 19:48:23 +0000
+++ src/maasserver/testing/factory.py	2012-10-03 07:59:24 +0000
@@ -32,6 +32,7 @@
     MACAddress,
     Node,
     NodeGroup,
+    NodeGroupInterface,
     SSHKey,
     Tag,
     )
@@ -120,6 +121,38 @@
             Node.objects.filter(id=node.id).update(created=created)
         return reload_object(node)
 
+    def get_interface_fields(self, ip=None, router_ip=None, network=None,
+                             subnet_mask=None, broadcast_ip=None,
+                             ip_range_low=None, ip_range_high=None,
+                             interface=None, management=None, **kwargs):
+        if network is None:
+            network = factory.getRandomNetwork()
+        if subnet_mask is None:
+            subnet_mask = str(network.netmask)
+        if broadcast_ip is None:
+            broadcast_ip = str(network.broadcast)
+        if ip_range_low is None:
+            ip_range_low = str(IPAddress(network.first))
+        if ip_range_high is None:
+            ip_range_high = str(IPAddress(network.last))
+        if router_ip is None:
+            router_ip = factory.getRandomIPInNetwork(network)
+        if ip is None:
+            ip = factory.getRandomIPInNetwork(network)
+        if management is None:
+            management = factory.getRandomEnum(NODEGROUPINTERFACE_MANAGEMENT)
+        if interface is None:
+            interface = self.make_name('interface')
+        return dict(
+            subnet_mask=subnet_mask,
+            broadcast_ip=broadcast_ip,
+            ip_range_low=ip_range_low,
+            ip_range_high=ip_range_high,
+            router_ip=router_ip,
+            ip=ip,
+            management=management,
+            interface=interface)
+
     def make_node_group(self, name=None, uuid=None, ip=None,
                         router_ip=None, network=None, subnet_mask=None,
                         broadcast_ip=None, ip_range_low=None,
@@ -140,32 +173,34 @@
             name = self.make_name('nodegroup')
         if uuid is None:
             uuid = factory.getRandomUUID()
-        if network is None:
-            network = factory.getRandomNetwork()
-        if subnet_mask is None:
-            subnet_mask = str(network.netmask)
-        if broadcast_ip is None:
-            broadcast_ip = str(network.broadcast)
-        if ip_range_low is None:
-            ip_range_low = str(IPAddress(network.first))
-        if ip_range_high is None:
-            ip_range_high = str(IPAddress(network.last))
-        if router_ip is None:
-            router_ip = factory.getRandomIPInNetwork(network)
-        if ip is None:
-            ip = factory.getRandomIPInNetwork(network)
-        if interface is None:
-            interface = self.make_name('interface')
+        interface_settings = self.get_interface_fields(
+            ip=ip, router_ip=router_ip, network=network,
+            subnet_mask=subnet_mask, broadcast_ip=broadcast_ip,
+            ip_range_low=ip_range_low, ip_range_high=ip_range_high,
+            interface=interface, management=management)
+        interface_settings.update(kwargs)
         ng = NodeGroup.objects.new(
-            name=name, uuid=uuid, ip=ip,
-            subnet_mask=subnet_mask, broadcast_ip=broadcast_ip,
-            router_ip=router_ip, ip_range_low=ip_range_low,
-            ip_range_high=ip_range_high, interface=interface,
-            management=management, **kwargs)
+            name=name, uuid=uuid, **interface_settings)
         ng.status = status
         ng.save()
         return ng
 
+    def make_node_group_interface(self, nodegroup, ip=None,
+                                  router_ip=None, network=None,
+                                  subnet_mask=None, broadcast_ip=None,
+                                  ip_range_low=None, ip_range_high=None,
+                                  interface=None, management=None, **kwargs):
+        interface_settings = self.get_interface_fields(
+            ip=ip, router_ip=router_ip, network=network,
+            subnet_mask=subnet_mask, broadcast_ip=broadcast_ip,
+            ip_range_low=ip_range_low, ip_range_high=ip_range_high,
+            interface=interface, management=management)
+        interface_settings.update(**kwargs)
+        interface = NodeGroupInterface(
+            nodegroup=nodegroup, **interface_settings)
+        interface.save()
+        return interface
+
     def make_node_commission_result(self, node=None, name=None, data=None):
         if node is None:
             node = self.make_node()

=== modified file 'src/maasserver/tests/test_nodegroupinterface.py'
--- src/maasserver/tests/test_nodegroupinterface.py	2012-10-01 19:55:34 +0000
+++ src/maasserver/tests/test_nodegroupinterface.py	2012-10-03 07:59:24 +0000
@@ -15,6 +15,7 @@
 from maasserver.enum import (
     NODEGROUP_STATUS,
     NODEGROUPINTERFACE_MANAGEMENT,
+    NODEGROUPINTERFACE_MANAGEMENT_CHOICES_DICT,
     )
 from maasserver.testing.factory import factory
 from maasserver.testing.testcase import TestCase
@@ -53,3 +54,9 @@
         interface = make_interface()
         interface.subnet_mask = ""
         self.assertIsNone(interface.network)
+
+    def test_display_management_display_management(self):
+        interface = make_interface()
+        self.assertEqual(
+            NODEGROUPINTERFACE_MANAGEMENT_CHOICES_DICT[interface.management],
+            interface.display_management())

=== added file 'src/maasserver/tests/test_views_settings_clusters.py'
--- src/maasserver/tests/test_views_settings_clusters.py	1970-01-01 00:00:00 +0000
+++ src/maasserver/tests/test_views_settings_clusters.py	2012-10-03 07:59:24 +0000
@@ -0,0 +1,144 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test maasserver clusters views."""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+__metaclass__ = type
+__all__ = []
+
+import httplib
+
+from django.core.urlresolvers import reverse
+from maasserver.enum import (
+    NODEGROUP_STATUS,
+    NODEGROUPINTERFACE_MANAGEMENT,
+    )
+from maasserver.models import (
+    NodeGroup,
+    NodeGroupInterface,
+    )
+from maasserver.testing import (
+    extract_redirect,
+    get_content_links,
+    reload_object,
+    )
+from maasserver.testing.factory import factory
+from maasserver.testing.testcase import (
+    AdminLoggedInTestCase,
+    )
+from maastesting.matchers import ContainsAll
+from testtools.matchers import MatchesStructure
+
+
+class ClusterListingTest(AdminLoggedInTestCase):
+
+    def test_settings_contains_links_to_edit_and_delete_clusters(self):
+        nodegroups = {
+            factory.make_node_group(status=NODEGROUP_STATUS.ACCEPTED),
+            factory.make_node_group(status=NODEGROUP_STATUS.PENDING),
+            factory.make_node_group(status=NODEGROUP_STATUS.REJECTED),
+            }
+        links = get_content_links(self.client.get(reverse('settings')))
+        nodegroup_edit_links = [
+            reverse('cluster-edit', args=[nodegroup.uuid])
+            for nodegroup in nodegroups]
+        nodegroup_delete_links = [
+            reverse('cluster-delete', args=[nodegroup.uuid])
+            for nodegroup in nodegroups]
+        self.assertThat(
+            links,
+            ContainsAll(nodegroup_edit_links + nodegroup_delete_links))
+
+
+class ClusterDeleteTest(AdminLoggedInTestCase):
+
+    def test_can_delete_cluster(self):
+        nodegroup = factory.make_node_group()
+        delete_link = reverse('cluster-delete', args=[nodegroup.uuid])
+        response = self.client.post(delete_link, {'post': 'yes'})
+        self.assertEqual(
+            (httplib.FOUND, reverse('settings')),
+            (response.status_code, extract_redirect(response)))
+        self.assertFalse(
+            NodeGroup.objects.filter(uuid=nodegroup.uuid).exists())
+
+
+class ClusterEditTest(AdminLoggedInTestCase):
+
+    def test_cluster_page_contains_links_to_edit_and_delete_interfaces(self):
+        nodegroup = factory.make_node_group()
+        interfaces = set()
+        for i in range(3):
+            interface = factory.make_node_group_interface(
+                nodegroup=nodegroup,
+                management=NODEGROUPINTERFACE_MANAGEMENT.UNMANAGED)
+            interfaces.add(interface)
+        links = get_content_links(
+            self.client.get(reverse('cluster-edit', args=[nodegroup.uuid])))
+        interface_edit_links = [
+            reverse('cluster-interface-edit',
+            args=[nodegroup.uuid, interface.interface])
+            for interface in interfaces]
+        interface_delete_links = [
+            reverse('cluster-interface-delete',
+            args=[nodegroup.uuid, interface.interface])
+            for interface in interfaces]
+        self.assertThat(
+            links,
+            ContainsAll(interface_edit_links + interface_delete_links))
+
+    def test_can_edit_cluster(self):
+        nodegroup = factory.make_node_group()
+        edit_link = reverse('cluster-edit', args=[nodegroup.uuid])
+        data = {
+            'cluster_name': factory.make_name('cluster_name'),
+            'name': factory.make_name('name'),
+            'status': factory.getRandomEnum(NODEGROUP_STATUS),
+            }
+        response = self.client.post(edit_link, data)
+        self.assertEqual(httplib.FOUND, response.status_code, response.content)
+        self.assertThat(
+            reload_object(nodegroup),
+            MatchesStructure.byEquality(**data))
+
+
+class ClusterInterfaceDeleteTest(AdminLoggedInTestCase):
+
+    def test_can_delete_cluster_interface(self):
+        nodegroup = factory.make_node_group()
+        interface = factory.make_node_group_interface(
+            nodegroup=nodegroup,
+            management=NODEGROUPINTERFACE_MANAGEMENT.UNMANAGED)
+        delete_link = reverse(
+            'cluster-interface-delete',
+            args=[nodegroup.uuid, interface.interface])
+        response = self.client.post(delete_link, {'post': 'yes'})
+        self.assertEqual(
+            (httplib.FOUND, reverse('cluster-edit', args=[nodegroup.uuid])),
+            (response.status_code, extract_redirect(response)))
+        self.assertFalse(
+            NodeGroupInterface.objects.filter(id=interface.id).exists())
+
+
+class ClusterInterfaceEditTest(AdminLoggedInTestCase):
+
+    def test_can_edit_cluster_interface(self):
+        nodegroup = factory.make_node_group(
+            management=NODEGROUPINTERFACE_MANAGEMENT.UNMANAGED)
+        interface = factory.make_node_group_interface(
+            nodegroup=nodegroup)
+        edit_link = reverse(
+            'cluster-interface-edit',
+            args=[nodegroup.uuid, interface.interface])
+        data = factory.get_interface_fields()
+        response = self.client.post(edit_link, data)
+        self.assertEqual(httplib.FOUND, response.status_code, response.content)
+        self.assertThat(
+            reload_object(interface),
+            MatchesStructure.byEquality(**data))

=== modified file 'src/maasserver/urls.py'
--- src/maasserver/urls.py	2012-10-01 08:24:34 +0000
+++ src/maasserver/urls.py	2012-10-03 07:59:24 +0000
@@ -51,9 +51,13 @@
     settings,
     settings_add_archive,
     )
-from maasserver.views.tags import (
-    TagView,
+from maasserver.views.settings_clusters import (
+    ClusterDelete,
+    ClusterEdit,
+    ClusterInterfaceDelete,
+    ClusterInterfaceEdit,
     )
+from maasserver.views.tags import TagView
 
 
 def adminurl(regexp, view, *args, **kwargs):
@@ -129,6 +133,20 @@
 ## URLs for admin users.
 # Settings views.
 urlpatterns += patterns('maasserver.views',
+    adminurl(
+        r'^clusters/(?P<uuid>[\w\-]+)/edit/$', ClusterEdit.as_view(),
+        name='cluster-edit'),
+    adminurl(
+        r'^clusters/(?P<uuid>[\w\-]+)/delete/$', ClusterDelete.as_view(),
+        name='cluster-delete'),
+    adminurl(
+        r'^clusters/(?P<uuid>[\w\-]+)/interfaces/(?P<interface>[\w\-]*)/'
+        'edit/$',
+        ClusterInterfaceEdit.as_view(), name='cluster-interface-edit'),
+    adminurl(
+        r'^clusters/(?P<uuid>[\w\-]+)/interfaces/(?P<interface>[\w\-]*)/'
+        'delete/$',
+        ClusterInterfaceDelete.as_view(), name='cluster-interface-delete'),
     adminurl(r'^settings/$', settings, name='settings'),
     adminurl(
         r'^settings/archives/add/$', settings_add_archive,

=== modified file 'src/maasserver/views/settings.py'
--- src/maasserver/views/settings.py	2012-04-23 14:02:51 +0000
+++ src/maasserver/views/settings.py	2012-10-03 07:59:24 +0000
@@ -37,6 +37,7 @@
 from django.views.generic.base import TemplateView
 from django.views.generic.detail import SingleObjectTemplateResponseMixin
 from django.views.generic.edit import ModelFormMixin
+from maasserver.enum import NODEGROUP_STATUS
 from maasserver.exceptions import CannotDeleteUserException
 from maasserver.forms import (
     AddArchiveForm,
@@ -46,7 +47,10 @@
     NewUserCreationForm,
     UbuntuForm,
     )
-from maasserver.models import UserProfile
+from maasserver.models import (
+    NodeGroup,
+    UserProfile,
+    )
 from maasserver.views import process_form
 
 
@@ -173,10 +177,21 @@
     if response is not None:
         return response
 
+    # Cluster settings:
+    accepted_clusters = NodeGroup.objects.filter(
+        status=NODEGROUP_STATUS.ACCEPTED).order_by('cluster_name')
+    pending_clusters = NodeGroup.objects.filter(
+        status=NODEGROUP_STATUS.PENDING).order_by('cluster_name')
+    rejected_clusters = NodeGroup.objects.filter(
+        status=NODEGROUP_STATUS.REJECTED).order_by('cluster_name')
+
     return render_to_response(
         'maasserver/settings.html',
         {
             'user_list': user_list,
+            'accepted_clusters': accepted_clusters,
+            'pending_clusters': pending_clusters,
+            'rejected_clusters': rejected_clusters,
             'maas_and_network_form': maas_and_network_form,
             'commissioning_form': commissioning_form,
             'ubuntu_form': ubuntu_form,

=== added file 'src/maasserver/views/settings_clusters.py'
--- src/maasserver/views/settings_clusters.py	1970-01-01 00:00:00 +0000
+++ src/maasserver/views/settings_clusters.py	2012-10-03 07:59:24 +0000
@@ -0,0 +1,119 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Cluster Settings views."""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+__metaclass__ = type
+__all__ = [
+    "ClusterDelete",
+    "ClusterEdit",
+    "ClusterInterfaceDelete",
+    "ClusterInterfaceEdit",
+    ]
+
+from django.contrib import messages
+from django.core.urlresolvers import reverse
+from django.http import HttpResponseRedirect
+from django.shortcuts import get_object_or_404
+from django.views.generic import (
+    DeleteView,
+    UpdateView,
+    )
+from maasserver.forms import (
+    NodeGroupEdit,
+    NodeGroupInterfaceForm,
+    )
+from maasserver.models import (
+    NodeGroup,
+    NodeGroupInterface,
+    )
+
+
+class ClusterEdit(UpdateView):
+    model = NodeGroup
+    template_name = 'maasserver/nodegroup_edit.html'
+    form_class = NodeGroupEdit
+    context_object_name = 'cluster'
+
+    def get_context_data(self, **kwargs):
+        context = super(ClusterEdit, self).get_context_data(**kwargs)
+        context['interfaces'] = (
+            self.object.nodegroupinterface_set.all().order_by('interface'))
+        return context
+
+    def get_success_url(self):
+        return reverse('settings')
+
+    def get_object(self):
+        uuid = self.kwargs.get('uuid', None)
+        return get_object_or_404(NodeGroup, uuid=uuid)
+
+    def form_valid(self, form):
+        messages.info(self.request, "Cluster updated.")
+        return super(ClusterEdit, self).form_valid(form)
+
+
+class ClusterDelete(DeleteView):
+
+    template_name = 'maasserver/nodegroup_confirm_delete.html'
+    context_object_name = 'cluster_to_delete'
+
+    def get_object(self):
+        uuid = self.kwargs.get('uuid', None)
+        return get_object_or_404(NodeGroup, uuid=uuid)
+
+    def get_next_url(self):
+        return reverse('settings')
+
+    def delete(self, request, *args, **kwargs):
+        cluster = self.get_object()
+        cluster.delete()
+        messages.info(request, "Cluster %s deleted." % cluster.cluster_name)
+        return HttpResponseRedirect(self.get_next_url())
+
+
+class ClusterInterfaceDelete(DeleteView):
+    template_name = 'maasserver/nodegroupinterface_confirm_delete.html'
+    context_object_name = 'interface_to_delete'
+
+    def get_object(self):
+        uuid = self.kwargs.get('uuid', None)
+        interface = self.kwargs.get('interface', None)
+        return get_object_or_404(
+            NodeGroupInterface, nodegroup__uuid=uuid, interface=interface)
+
+    def get_next_url(self):
+        uuid = self.kwargs.get('uuid', None)
+        return reverse('cluster-edit', args=[uuid])
+
+    def delete(self, request, *args, **kwargs):
+        interface = self.get_object()
+        interface.delete()
+        messages.info(request, "Interface %s deleted." % interface.interface)
+        return HttpResponseRedirect(self.get_next_url())
+
+
+class ClusterInterfaceEdit(UpdateView):
+    template_name = 'maasserver/nodegroupinterface_edit.html'
+    form_class = NodeGroupInterfaceForm
+    context_object_name = 'interface'
+
+    def get_success_url(self):
+        uuid = self.kwargs.get('uuid', None)
+        return reverse('cluster-edit', args=[uuid])
+
+    def form_valid(self, form):
+        messages.info(self.request, "Interface updated.")
+        return super(ClusterInterfaceEdit, self).form_valid(form)
+
+    def get_object(self):
+        uuid = self.kwargs.get('uuid', None)
+        interface = self.kwargs.get('interface', None)
+        return get_object_or_404(
+            NodeGroupInterface, nodegroup__uuid=uuid, interface=interface)