Răsfoiți Sursa

[zookeeper] Initial commit

Andrei Savu 12 ani în urmă
părinte
comite
4237673ef9

+ 20 - 0
apps/zookeeper/Makefile

@@ -0,0 +1,20 @@
+# Licensed to Cloudera, Inc. under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  Cloudera, Inc. licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+ifeq ($(ROOT),)
+  $(error "Error: Expect the environment variable $$ROOT to point to the Desktop installation")
+endif
+
+include $(ROOT)/Makefile.sdk

+ 46 - 0
apps/zookeeper/setup.py

@@ -0,0 +1,46 @@
+# Licensed to Cloudera, Inc. under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  Cloudera, Inc. licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+from setuptools import setup, find_packages
+import os
+
+def expand_package_data(src_dirs, strip=""):
+  ret = []
+  for src_dir in src_dirs:
+    for path, dnames, fnames in os.walk(src_dir):
+      for fname in fnames:
+        ret.append(os.path.join(path, fname).replace(strip, ""))
+  return ret
+
+os.chdir(os.path.dirname(os.path.abspath(__file__)))
+setup(
+  name = "zkui",
+  version = "0.1",
+  url = 'http://github.com/andreisavu/hue',
+  description = 'ZooKeeper Browser',
+  packages = find_packages('src'),
+  package_dir = {'': 'src'},
+  install_requires = ['setuptools', 'desktop'],
+  entry_points = { 'desktop.sdk.application': 'zkui=zkui' },
+  zip_safe = False,
+  package_data = {
+    # Include static resources.  Package_data doesn't
+    # deal well with directory globs, so we enumerate
+    # the files manually.
+    'zkui': expand_package_data(
+      ["src/zkui/templates", "src/zkui/static"],
+      "src/zkui/")
+  }
+)

+ 16 - 0
apps/zookeeper/src/zkui/__init__.py

@@ -0,0 +1,16 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+
+#     http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+

+ 29 - 0
apps/zookeeper/src/zkui/forms.py

@@ -0,0 +1,29 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+
+#     http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from django import forms
+from django.forms.widgets import Textarea, HiddenInput
+
+class CreateZNodeForm(forms.Form):
+  name = forms.CharField(max_length=64)
+  data = forms.CharField(required=False, widget=Textarea)
+  sequence = forms.BooleanField(required=False)
+
+class EditZNodeForm(forms.Form):
+  data = forms.CharField(required=False, widget=Textarea)
+  version = forms.IntegerField(required=False, widget=HiddenInput)
+  
+

+ 17 - 0
apps/zookeeper/src/zkui/models.py

@@ -0,0 +1,17 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+
+#     http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+

+ 230 - 0
apps/zookeeper/src/zkui/rest.py

@@ -0,0 +1,230 @@
+
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import urllib2
+import urllib
+import simplejson
+
+from contextlib import contextmanager
+
+class RequestWithMethod(urllib2.Request):
+    """ Request class that know how to set the method name """
+    def __init__(self, *args, **kwargs):
+        urllib2.Request.__init__(self, *args, **kwargs)
+        self._method = None
+
+    def get_method(self):
+        return self._method or \
+            urllib2.Request.get_method(self)
+
+    def set_method(self, method):
+        self._method = method
+
+class ZooKeeper(object):
+
+    class Error(Exception): pass
+
+    class NotFound(Error): pass
+
+    class ZNodeExists(Error): pass
+
+    class InvalidSession(Error): pass
+
+    class WrongVersion(Error): pass
+
+    def __init__(self, uri = 'http://localhost:9998'):
+        self._base = uri
+        self._session = None
+
+    def start_session(self, expire=5, id=None):
+        """ Create a session and return the ID """
+        if id is None:
+            url = "%s/sessions/v1/?op=create&expire=%d" % (self._base, expire)
+            self._session = self._do_post(url)['id']
+        else:
+            self._session = id
+        return self._session
+
+    def close_session(self):
+        """ Close the session on the server """
+        if self._session is not None:
+            url = '%s/sessions/v1/%s' % (self._base, self._session)
+            self._do_delete(url)
+            self._session = None
+
+    def heartbeat(self):
+        """ Send a heartbeat request. This is needed in order to keep a session alive """
+        if self._session is not None:
+            url = '%s/sessions/v1/%s' % (self._base, self._session)
+            self._do_put(url, '')
+
+    @contextmanager
+    def session(self, *args, **kwargs):
+        """ Session handling using a context manager """
+        yield self.start_session(*args, **kwargs)
+        self.close_session()
+
+    def get(self, path):
+        """ Get a node """
+        url = "%s/znodes/v1%s" % (self._base, path)
+        return self._do_get(url)
+
+    def get_children(self, path):
+        """ Get all the children for a given path. This function creates a generator """
+        for child_path in self.get_children_paths(path, uris=True):
+            try:
+                yield self._do_get(child_path)
+            except ZooKeeper.NotFound:
+                continue
+
+    def get_children_paths(self, path, uris=False):
+        """ Get the paths for children nodes """
+        url = "%s/znodes/v1%s?view=children" % (self._base, path)
+        resp = self._do_get(url)
+        for child in resp.get('children', []):
+            yield child if not uris else resp['child_uri_template']\
+              .replace('{child}', urllib2.quote(child))
+       
+    def create(self, path, data=None, sequence=False, ephemeral=False):
+        """ Create a new node. By default this call creates a persistent znode.
+
+        You can also create an ephemeral or a sequential znode.
+        """
+        ri = path.rindex('/')
+        head, name = path[:ri+1], path[ri+1:]
+        if head != '/': head = head[:-1]
+
+        flags = {
+            'null': 'true' if data is None else 'false',
+            'ephemeral': 'true' if ephemeral else 'false',
+            'sequence': 'true' if sequence else 'false'
+        }
+        if ephemeral:
+            if self._session:
+                flags['session'] = self._session
+            else:
+                raise ZooKeeper.Error, 'You need a session '\
+                    'to create an ephemeral node'
+        flags = urllib.urlencode(flags)
+
+        url = "%s/znodes/v1%s?op=create&name=%s&%s" % \
+            (self._base, head, name, flags)
+
+        return self._do_post(url, data)
+
+    def set(self, path, data=None, version=-1, null=False):
+        """ Set the value of node """
+        url = "%s/znodes/v1%s?%s" % (self._base, path, \
+            urllib.urlencode({
+                'version': version,
+                'null': 'true' if null else 'false'
+        }))
+        return self._do_put(url, data)
+
+    def delete(self, path, version=-1):
+        """ Delete a znode """
+        if type(path) is list:
+            map(lambda el: self.delete(el, version), path)
+            return
+
+        url = '%s/znodes/v1%s?%s' % (self._base, path, \
+            urllib.urlencode({
+                'version':version
+        }))
+        try:
+            return self._do_delete(url)
+        except urllib2.HTTPError, e:
+            if e.code == 412:
+                raise ZooKeeper.WrongVersion(path)
+            elif e.code == 404:
+                raise ZooKeeper.NotFound(path)
+            raise
+
+    def recursive_delete(self, path):
+        """ Delete all the nodes from the tree """
+        for child in self.get_children_paths(path):
+            fp = ("%s/%s" % (path, child)).replace('//', '/')
+            self.recursive_delete(fp)
+        self.delete(path)
+
+    def exists(self, path):
+        """ Do a znode exists """
+        try:
+            self.get(path)
+            return True
+        except ZooKeeper.NotFound:
+            return False
+
+    def _do_get(self, uri):
+        """ Send a GET request and convert errors to exceptions """
+        try:
+            req = urllib2.urlopen(uri)
+            resp = simplejson.load(req)
+
+            if 'Error' in resp:
+               raise ZooKeeper.Error(resp['Error'])
+
+            return resp
+        except urllib2.HTTPError, e:
+            if e.code == 404:
+                raise ZooKeeper.NotFound(uri)
+            raise
+
+    def _do_post(self, uri, data=None):
+        """ Send a POST request and convert errors to exceptions """
+        try:
+            req = urllib2.Request(uri, {})
+            req.add_header('Content-Type', 'application/octet-stream')
+            if data is not None:
+                req.add_data(data)
+
+            resp = simplejson.load(urllib2.urlopen(req))
+            if 'Error' in resp:
+                raise ZooKeeper.Error(resp['Error'])
+            return resp
+
+        except urllib2.HTTPError, e:
+            if e.code == 201:
+                return True
+            elif e.code == 409:
+                raise ZooKeeper.ZNodeExists(uri)
+            elif e.code == 401:
+                raise ZooKeeper.InvalidSession(uri)
+            raise
+
+    def _do_delete(self, uri):
+        """ Send a DELETE request """
+        req = RequestWithMethod(uri)
+        req.set_method('DELETE')
+        req.add_header('Content-Type', 'application/octet-stream')
+        return urllib2.urlopen(req).read()
+
+    def _do_put(self, uri, data):
+        """ Send a PUT request """
+        try:
+            req = RequestWithMethod(uri)
+            req.set_method('PUT')
+            req.add_header('Content-Type', 'application/octet-stream')
+            if data is not None:
+                req.add_data(data)
+
+            return urllib2.urlopen(req).read()
+        except urllib2.HTTPError, e:
+            if e.code == 412: # precondition failed
+                raise ZooKeeper.WrongVersion(uri)
+            raise
+

+ 30 - 0
apps/zookeeper/src/zkui/settings.py

@@ -0,0 +1,30 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+
+#     http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+DJANGO_APPS = [ "zkui" ]
+NICE_NAME = "ZooKeeper Browser"
+REQUIRES_HADOOP = False
+
+CLUSTERS = [{
+        'nice_name': 'Default',
+        'hostport': 'localhost:2181,localhost:2182,localhost:2183',
+        'rest_gateway': 'http://localhost:9998'
+    }
+]
+
+DEPENDER_PACKAGE_YMLS = [
+    "src/zkui/static/js/package.yml",
+]

BIN
apps/zookeeper/src/zkui/static/art/line_icons.png


BIN
apps/zookeeper/src/zkui/static/art/zkui.png


+ 32 - 0
apps/zookeeper/src/zkui/static/bootstrap.js

@@ -0,0 +1,32 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+CCS.Desktop.register({
+	Zkui : {
+		name : 'ZooKeeper Browser',
+		css : '/zkui/static/css/zkui.css',
+		require: [ 'Zkui' ],
+		launch: function(path, options){
+			return new Zkui(path || '/zkui/', options);
+		},
+		menu: {
+			id: 'ccs-zkui-menu',
+			img: {
+				src: '/zkui/static/art/zkui.png'
+			}
+		},
+		help: '/help/zkui/'
+	}
+});

+ 62 - 0
apps/zookeeper/src/zkui/static/css/zkui.css

@@ -0,0 +1,62 @@
+/* 
+	zkui styles
+	important: these should be namespaced
+	example:
+		let's say you are creating an app called "calculator"; you should prefix all your styles with your application's name like so
+		
+		.calculator img {
+			border: 1px solid #000;
+		}
+		.calculator p {
+			margin: 10px 0px;
+		}
+		etc...
+
+	other notes:
+	* don't use ids - there may be more than one of your apps open; use classes!
+	* the toolbar element is absolutely positioned and 100% wide (and therefor 0px high);
+	  any elements inside it should also be absolutely positioned
+*/
+
+.zkui img.zkui_icon {
+	width: 55px;
+	height: 55px;
+	position: absolute;
+	top: 27px;
+	left: 3px;
+}
+
+div.zkui .left_col li {
+    margin: 5px 0px;
+    font-size: 16px;
+    background-color: white;
+    color: black;
+    padding: 2px 1px 1px 5px;
+    -moz-border-radius: 3px;
+    -webkit-border-radius: 3px;
+    border: solid black 1px;
+}
+
+div.zkui .left_col li:hover {
+    background-color: lightBlue;
+    color: white;
+}
+
+div.zkui .left_col li a {
+    color: black;
+    display: block;
+}
+
+div.zkui .left_col li a:hover {
+    text-decoration: none;
+}
+
+div.zkui td a.delete {
+//  background: url(/zkui/static/art/line_icons.png) no-repeat 0 -70px;
+}
+
+div.zkui .createZnodeForm td,
+div.zkui .editZnodeForm td {
+  padding: 5px;
+}
+

+ 10 - 0
apps/zookeeper/src/zkui/static/help/index.html

@@ -0,0 +1,10 @@
+
+<h1>ZooKeeper Browser</h1>
+
+
+<p>ZooKeeper is a centralized service for maintaining configuration information, naming, providing distributed synchronization, and providing group services</p>
+
+<h2>About</h2>
+
+<p>The ZooKeeper Browser application allows you to see how the cluster nodes are working and also allows you to do CRUD operations on the znode hierarchy.</p>
+

+ 50 - 0
apps/zookeeper/src/zkui/static/js/Source/Zkui/Zkui.js

@@ -0,0 +1,50 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+/*
+---
+
+script: Zkui.js
+
+description: Defines Zkui; a Hue application that extends CCS.JBrowser.
+
+authors:
+- Unknown
+
+requires:
+- ccs-shared/CCS.JBrowser
+
+provides: [Zkui]
+
+...
+*/
+ART.Sheet.define('window.art.browser.zkui', {
+	'min-width': 620
+});
+
+var Zkui = new Class({
+
+	Extends: CCS.JBrowser,
+
+	options: {
+		className: 'art browser logo_header zkui'
+	},
+
+	initialize: function(path, options){
+		this.parent(path || '/zkui/', options);
+	}
+
+});

+ 5 - 0
apps/zookeeper/src/zkui/static/js/package.yml

@@ -0,0 +1,5 @@
+copyright: Apache License v2.0
+version: 0.1
+description: ZooKeeper Browser
+name: ZooKeeper Browser
+sources: [Source/Zkui/Zkui.js]

+ 170 - 0
apps/zookeeper/src/zkui/stats.py

@@ -0,0 +1,170 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+
+#     http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import socket
+import re
+
+from StringIO import StringIO
+
+class Session(object):
+
+  class BrokenLine(Exception): pass
+
+  def __init__(self, session):
+    m = re.search('/(\d+\.\d+\.\d+\.\d+):(\d+)\[(\d+)\]\((.*)\)', session)
+    if m:
+        self.host = m.group(1)
+        self.port = m.group(2)
+        self.interest_ops = m.group(3)
+        for d in m.group(4).split(","):
+            k,v = d.split("=")
+            self.__dict__[k] = v
+    else:
+        raise Session.BrokenLine() 
+
+class ZooKeeperStats(object):
+
+    def __init__(self, host='localhost', port='2181', timeout=1):
+        self._address = (host, int(port))
+        self._timeout = timeout
+
+    def get_stats(self):
+        """ Get ZooKeeper server stats as a map """
+        data = self._send_cmd('mntr')
+        if data:
+            return self._parse(data)
+        else:
+            data = self._send_cmd('stat')
+            return self._parse_stat(data)
+
+    def get_clients(self):
+      """ Get ZooKeeper server clients """
+      clients = []
+
+      stat = self._send_cmd('stat')
+      if not stat:
+        return clients
+
+      sio = StringIO(stat)
+
+      #skip two lines
+      sio.readline()
+      sio.readline()
+
+      for line in sio:
+        if not line.strip():
+          break
+        try:
+          clients.append(Session(line.strip()))
+        except Session.BrokenLine:
+          continue
+
+      return clients
+
+    def _create_socket(self):
+        return socket.socket()
+
+    def _send_cmd(self, cmd):
+        """ Send a 4letter word command to the server """
+        s = self._create_socket()
+        s.settimeout(self._timeout)
+
+        s.connect(self._address)
+        s.send(cmd)
+
+        data = s.recv(2048)
+        s.close()
+
+        return data
+
+    def _parse(self, data):
+        """ Parse the output from the 'mntr' 4letter word command """
+        h = StringIO(data)
+        
+        result = {}
+        for line in h.readlines():
+            try:
+                key, value = self._parse_line(line)
+                result[key] = value
+            except ValueError:
+                pass # ignore broken lines
+
+        return result
+
+    def _parse_stat(self, data):
+        """ Parse the output from the 'stat' 4letter word command """
+        h = StringIO(data)
+
+        result = {}
+        
+        version = h.readline()
+        if version:
+            result['zk_version'] = version[version.index(':')+1:].strip()
+
+        # skip all lines until we find the empty one
+        while h.readline().strip(): pass
+
+        for line in h.readlines():
+            m = re.match('Latency min/avg/max: (\d+)/(\d+)/(\d+)', line)
+            if m is not None:
+                result['zk_min_latency'] = int(m.group(1))
+                result['zk_avg_latency'] = int(m.group(2))
+                result['zk_max_latency'] = int(m.group(3))
+                continue
+
+            m = re.match('Received: (\d+)', line)
+            if m is not None:
+                result['zk_packets_received'] = int(m.group(1))
+                continue
+
+            m = re.match('Sent: (\d+)', line)
+            if m is not None:
+                result['zk_packets_sent'] = int(m.group(1))
+                continue
+
+            m = re.match('Outstanding: (\d+)', line)
+            if m is not None:
+                result['zk_outstanding_requests'] = int(m.group(1))
+                continue
+
+            m = re.match('Mode: (.*)', line)
+            if m is not None:
+                result['zk_server_state'] = m.group(1)
+                continue
+
+            m = re.match('Node count: (\d+)', line)
+            if m is not None:
+                result['zk_znode_count'] = int(m.group(1))
+                continue
+
+        return result 
+
+    def _parse_line(self, line):
+        try:
+            key, value = map(str.strip, line.split('\t'))
+        except ValueError:
+            raise ValueError('Found invalid line: %s' % line)
+
+        if not key:
+            raise ValueError('The key is mandatory and should not be empty')
+
+        try:
+            value = int(value)
+        except (TypeError, ValueError):
+            pass
+
+        return key, value
+

+ 33 - 0
apps/zookeeper/src/zkui/templates/clients.mako

@@ -0,0 +1,33 @@
+<%namespace name="shared" file="shared_components.mako" />
+
+${shared.header("ZooKeeper Browser > Clients > %s:%s" % (host, port))}
+
+<h1>${host}:${port} :: client connections</h1>
+<br />
+
+% if clients:
+  <table data-filters="HtmlTable"> 
+  <thead>
+    <tr>
+      <th>Host</th>
+      <th>Port</th>
+      <th>Interest Ops</th>
+      <th>Queued</th>
+      <th>Received</th>
+      <th>Sent</th>
+  </thead>
+  % for client in clients:
+    <tr>
+      <td>${client.host}</td>
+      <td>${client.port}</td>
+      <td>${client.interest_ops}</td>
+      <td>${client.queued}</td>
+      <td>${client.recved}</td>
+      <td>${client.sent}</td>
+    </tr>
+  % endfor
+  </table>
+% endif
+
+${shared.footer()}
+

+ 17 - 0
apps/zookeeper/src/zkui/templates/create.mako

@@ -0,0 +1,17 @@
+<%namespace name="shared" file="shared_components.mako" />
+
+${shared.header("ZooKeeper Browser > Create Znode")}
+
+<h2>Create New Znode :: ${path}</h2>
+<hr /><br />
+
+<form class="createZnodeForm" action="" method="POST">
+<table align="center">
+  ${form.as_table()|n}
+<tr><td colspan="2" align="right">
+  <button type="submit">Create</button>
+</td></tr>
+</table>
+</form>
+
+${shared.footer()}

+ 17 - 0
apps/zookeeper/src/zkui/templates/edit.mako

@@ -0,0 +1,17 @@
+<%namespace name="shared" file="shared_components.mako" />
+
+${shared.header("ZooKeeper Browser > Edit Znode > %s" % path)}
+
+<h2>Edit Znode Data :: ${path}</h2>
+<hr /><br />
+
+<form class="editZnodeForm" action="" method="POST">
+<table align="center">
+  ${form.as_table()|n}
+<tr><td colspan="2" align="right">
+  <button type="submit">Save</button>
+</td></tr>
+</table>
+</form>
+
+${shared.footer()}

+ 37 - 0
apps/zookeeper/src/zkui/templates/index.mako

@@ -0,0 +1,37 @@
+<%namespace name="shared" file="shared_components.mako" />
+
+${shared.header("ZooKeeper Browser")}
+
+<h2>Overview</h2>
+
+<br />
+
+% for i, c in enumerate(overview):
+  <h3> ${i+1}. <a href="${url('zkui.views.view', id=i)}">${c['nice_name']} Cluster Overview</a></h3><br />
+
+  <table data-filters="HtmlTable">
+  <thead>
+    <tr>
+      <th>Node</th>
+      <th>Role</th>
+      <th>Avg Latency</th>
+      <th>Watch Count</th>
+      <th>Version</th>
+    </tr>
+  </thead>
+  % for host, stats in c['stats'].items():
+    <tr>
+      <td>${host}</td>
+      <td>${stats.get('zk_server_state', '')}</td>
+      <td>${stats.get('zk_avg_latency', '')}</td>
+      <td>${stats.get('zk_watch_count', '')}</td>
+      <td>${stats.get('zk_version', '')}</td>
+    </tr>
+  % endfor
+  </table>
+
+  <br /><br />
+% endfor 
+
+${shared.footer()}
+

+ 53 - 0
apps/zookeeper/src/zkui/templates/shared_components.mako

@@ -0,0 +1,53 @@
+<%!
+#declare imports here, for example:
+#import datetime
+%>
+
+<%!
+import datetime
+from django.template.defaultfilters import urlencode, escape
+from zkui import settings
+%>
+
+<%def name="header(title='ZooKeeper Browser', toolbar=True)">
+  <!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN">
+  <html>
+    <head>
+      <title>${title}</title>
+    </head>
+    <body>
+      % if toolbar:
+      <div class="toolbar">
+        <a href="${url('zkui.views.index')}"><img src="/zkui/static/art/zkui.png" class="zkui_icon"/></a>
+      </div>
+      % endif
+
+    <div data-filters="SplitView">
+    <div class="left_col jframe_padded" style="width:150px;">
+        <ul>
+          <li><a href="${url("zkui.views.index")}">Overview</a></li>
+        </ul>
+        <br />
+
+        <h2>Clusters</h2>
+        <ul>
+            % for id, c in enumerate(settings.CLUSTERS):
+                <li><a href="${url("zkui.views.view", id=id)}">
+                    ${c['nice_name']}</a></li>
+            % endfor
+        </ul>
+    </div>
+
+    <div class="right_col jframe_padded">
+</%def>
+
+<%def name="info_button(url, text)">
+  <a data-filters="ArtButton" href="${url}" style="background: url(/static/art/info.png) left 50%; padding: 6px 6px 6px 20px; margin: 10px;" data-icon-styles="{'width': 14, 'height': 14}">${text}</a>
+</%def>
+
+<%def name="footer()">
+        </div>
+    </div>
+    </body>
+  </html>
+</%def>

+ 58 - 0
apps/zookeeper/src/zkui/templates/tree.mako

@@ -0,0 +1,58 @@
+<%namespace name="shared" file="shared_components.mako" />
+
+${shared.header("ZooKeeper Browser > Tree > %s > %s" % (cluster['nice_name'], path))}
+
+<h1>${cluster['nice_name'].lower()} :: ${path}</h1>
+<br />
+
+<table data-filters="HtmlTable">
+  <thead>
+  <th colspan="2">Children</th>
+  </thead>
+  % for child in children:
+    <tr><td width="100%">
+      <a href="${url('zkui.views.tree', id=cluster['id'], \
+          path=("%s/%s" % (path, child)).replace('//', '/'))}">
+      ${child}</a>
+    </td><td>
+      <a title="Delete ${child}" class="delete frame_tip confirm_and_post" alt="Are you sure you want to delete ${child}?" href="${url('zkui.views.delete', id=cluster['id'], \
+          path=("%s/%s" % (path, child)).replace('//', '/'))}">Delete</a>
+    </td></tr>
+  % endfor
+</table>
+<br />
+<span style="float: right">
+  ${shared.info_button(url('zkui.views.create', id=cluster['id'], path=path), 'Create New')}
+</span>
+
+<div style="clear: both"></div>
+
+<h2>data :: base64 :: length :: ${znode.get('dataLength', 0)}</h2>
+<br />
+
+<textarea name="data64" style="width: 100%;" rows="5" readonly="readonly">${znode.get('data64', '')}</textarea>
+<div style="clear: both"></div>
+<span style="float: right">
+  ${shared.info_button(url('zkui.views.edit_as_base64', id=cluster['id'], path=path), 'Edit as Base64')}
+  ${shared.info_button(url('zkui.views.edit_as_text', id=cluster['id'], path=path), 'Edit as Text')}
+</span>
+<div style="clear: both"></div>
+<br />
+
+<h2>stat information</h2>
+<br />
+
+<table data-filters="HtmlTable">
+  <thead><tr><th>Key</th>
+    <th width="80%">Value</th></tr></thead>
+  % for key in ('pzxid', 'ctime', 'aversion', 'mzxid', \
+      'ephemeralOwner', 'version', 'mtime', 'cversion', 'czxid'):
+    <tr><td>${key}</td><td>${znode[key]}</td></tr> 
+  % endfor
+</table>
+
+<br />
+<a target="_blank" href="http://hadoop.apache.org/zookeeper/docs/current/zookeeperProgrammers.html#sc_zkStatStructure">Details on stat information.</a>
+
+${shared.footer()}
+

+ 111 - 0
apps/zookeeper/src/zkui/templates/view.mako

@@ -0,0 +1,111 @@
+<%namespace name="shared" file="shared_components.mako" />
+
+${shared.header("ZooKeeper Browser > %s" % cluster['nice_name'])}
+
+<%def name="show_stats(stats)">
+    <thead>
+      <tr><th>Key</th>
+      <th width="100%">Value</th></tr>
+    </thead>
+
+    <tr><td>Version</td>
+      <td>${stats.get('zk_version')}</td>
+    </tr>
+
+    <tr><td>Latency</td><td>
+      Min: ${stats.get('zk_min_latency', '')}
+      Avg: ${stats.get('zk_avg_latency', '')}
+      Max: ${stats.get('zk_max_latency', '')}
+    </td></tr>
+
+    <tr><td>Packets</td>
+      <td>Sent: ${stats.get('zk_packets_sent', '')}
+      Received: ${stats.get('zk_packets_received', '')}
+      </td>
+    </tr>
+
+    <tr><td>Outstanding Requests</td>
+      <td>${stats.get('zk_outstanding_requests', '')}</td>
+    </tr>
+
+    <tr><td>Watch Count</td>
+      <td>${stats.get('zk_watch_count', '')}</td>
+    </tr>
+
+    <tr><td>Open FD Count</td>
+      <td>${stats.get('zk_open_file_descriptor_count', '')}</td>
+    </tr>
+
+    <tr><td>Max FD Count</td>
+      <td>${stats.get('zk_max_file_descriptor_count', '')}</td>
+    </tr>
+
+</%def> 
+
+<h2> ${cluster['nice_name']} Cluster Overview </h2>
+
+${shared.info_button(url('zkui.views.tree', id=cluster['id'], path='/'), 'View Znode Hierarchy')}
+
+<br /><br />
+
+% if leader:
+<h2>General</h2>
+
+<table data-filters="HtmlTable">
+  <thead>
+    <tr><th>Key</th><th width="100%">Value</th></tr>
+  </thead>
+
+  <tr><td>ZNode Count</td>
+    <td>${leader.get('zk_znode_count', '')}</td></tr>
+
+  <tr><td>Ephemerals Count</td>
+    <td>${leader.get('zk_ephemerals_count', '')}</td></tr>
+
+  <tr><td>Approximate Data Size</td>
+    <td>${leader.get('zk_approximate_data_size', '')} bytes</td></tr>
+
+</table>
+<br /><br />
+% endif
+
+% if leader:
+  <h2>node :: ${leader['host']} :: leader</h2>
+
+  ${shared.info_button(url('zkui.views.clients', host=leader['host']), 'View Client Connections')}
+
+  <br /><br />
+  <table data-filters="HtmlTable">
+    ${show_stats(leader)}
+    
+    <tr><td>Followers</td>
+      <td>${leader.get('zk_followers', '')}</td>
+    </tr>
+
+    <tr><td>Synced Followers</td>
+      <td>${leader.get('zk_synced_followers', '')}</td>
+    </tr>
+
+    <tr><td>Pending Syncs</td>
+      <td>${leader.get('zk_pending_syncs', '')}</td>
+    </tr>
+  
+  </table>
+<br /><br />
+% endif
+
+% for stats in followers:
+  <h2>node :: ${stats['host']} :: follower</h2>
+  <br />
+
+  ${shared.info_button(url('zkui.views.clients', host=stats['host']), 'View Client Connections')}
+
+  <br /><br />
+  <table data-filters="HtmlTable">
+    ${show_stats(stats)}
+  </table>
+  <br /><br />
+% endfor
+
+${shared.footer()}
+

+ 28 - 0
apps/zookeeper/src/zkui/urls.py

@@ -0,0 +1,28 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+
+#     http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from django.conf.urls.defaults import patterns, url
+
+urlpatterns = patterns('zkui',
+  url(r'^$', 'views.index'),
+  url(r'view/(?P<id>\d+)$', 'views.view'),
+  url(r'clients/(?P<host>.+)$', 'views.clients'),
+  url(r'tree/(?P<id>\d+)(?P<path>.+)$', 'views.tree'),
+  url(r'create/(?P<id>\d+)(?P<path>.*)$', 'views.create'),
+  url(r'delete/(?P<id>\d+)(?P<path>.*)$', 'views.delete'),
+  url(r'edit/base64/(?P<id>\d+)(?P<path>.*)$', 'views.edit_as_base64'),
+  url(r'edit/text/(?P<id>\d+)(?P<path>.*)$', 'views.edit_as_text')
+)

+ 33 - 0
apps/zookeeper/src/zkui/utils.py

@@ -0,0 +1,33 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+
+#     http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from zkui import settings
+
+from django.http import Http404
+
+def get_cluster_or_404(id):
+  try:
+    id = int(id)
+    if not (0 <= id < len(settings.CLUSTERS)):
+      raise ValueError, 'Undefined cluster id.'
+  except (TypeError, ValueError):
+    raise Http404()
+
+  cluster = settings.CLUSTERS[id]
+  cluster['id'] = id
+
+  return cluster
+

+ 165 - 0
apps/zookeeper/src/zkui/views.py

@@ -0,0 +1,165 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+
+#     http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from desktop.lib.django_util import render
+from django.http import Http404
+
+from zkui import settings
+from zkui.stats import ZooKeeperStats
+from zkui.rest import ZooKeeper
+from zkui.utils import get_cluster_or_404
+from zkui.forms import CreateZNodeForm, EditZNodeForm
+
+def _get_global_overview():
+  overview = []
+  for c in settings.CLUSTERS:
+    overview.append(_get_overview(c))
+  return overview
+
+def _get_overview(cluster):
+  stats = {}
+  for s in cluster['hostport'].split(','):
+    host, port = map(str.strip, s.split(':'))
+
+    zks = ZooKeeperStats(host, port)
+    stats[s] = zks.get_stats() or {}
+
+  cluster['stats'] = stats
+  return cluster
+
+def _group_stats_by_role(cluster):
+  leader, followers = None, []
+  for host, stats in cluster['stats'].items():
+    stats['host'] = host
+
+    if stats.get('zk_server_state') == 'leader':
+      leader = stats
+
+    elif stats.get('zk_server_state') == 'follower':
+      followers.append(stats) 
+
+  return leader, followers           
+ 
+def index(request):
+  overview = _get_global_overview()  
+  return render('index.mako', request, 
+    dict(overview=overview))
+
+def view(request, id):
+  cluster = get_cluster_or_404(id)
+
+  cluster = _get_overview(cluster)
+  leader, followers = _group_stats_by_role(cluster)
+
+  return render('view.mako', request, 
+    dict(cluster=cluster, leader=leader, followers=followers))
+
+def clients(request, host):
+  parts = host.split(':')  
+  if len(parts) != 2:
+    raise Http404
+
+  host, port = parts
+  zks = ZooKeeperStats(host, port)
+  clients = zks.get_clients()
+
+  return render('clients.mako', request,
+    dict(host=host, port=port, clients=clients))
+
+def tree(request, id, path):
+  cluster = get_cluster_or_404(id)
+  zk = ZooKeeper(cluster['rest_gateway'])
+
+  znode = zk.get(path)
+  children = sorted(zk.get_children_paths(path))
+  
+  return render('tree.mako', request,
+    dict(cluster=cluster, path=path, \
+      znode=znode, children=children))
+
+def delete(request, id, path):
+  cluster = get_cluster_or_404(id)
+  if request.method == 'POST':
+    zk = ZooKeeper(cluster['rest_gateway'])
+    try:
+      zk.recursive_delete(path)
+    except ZooKeeper.NotFound:
+      pass
+
+  return tree(request, id, path[:path.rindex('/')] or '/')
+
+def create(request, id, path):
+  cluster = get_cluster_or_404(id)
+
+  if request.method == 'POST':
+    form = CreateZNodeForm(request.POST)
+    if form.is_valid():
+      zk = ZooKeeper(cluster['rest_gateway'])
+
+      full_path = ("%s/%s" % (path, form.cleaned_data['name']))\
+        .replace('//', '/')
+
+      zk.create(full_path, \
+        form.cleaned_data['data'], \
+        sequence = form.cleaned_data['sequence'])
+      return tree(request, id, path)
+  else:
+    form = CreateZNodeForm()
+
+  return render('create.mako', request, 
+    dict(path=path, form=form))
+
+def edit_as_base64(request, id, path):
+  cluster = get_cluster_or_404(id)
+  zk = ZooKeeper(cluster['rest_gateway'])
+  node = zk.get(path)
+
+  if request.method == 'POST':
+    form = EditZNodeForm(request.POST)
+    if form.is_valid():
+      # TODO is valid base64 string?
+      data = form.cleaned_data['data'].decode('base64')
+      zk.set(path, data, form.cleaned_data['version'])
+
+    return tree(request, id, path)
+  else:
+    form = EditZNodeForm(dict(\
+      data=node.get('data64', ''), 
+      version=node.get('version', '-1')))
+
+  return render('edit.mako', request,
+    dict(path=path, form=form))
+
+def edit_as_text(request, id, path):
+  cluster = get_cluster_or_404(id)
+  zk = ZooKeeper(cluster['rest_gateway'])
+  node = zk.get(path)
+
+  if request.method == 'POST':
+    form = EditZNodeForm(request.POST)
+    if form.is_valid():
+      zk.set(path, form.cleaned_data['data'])
+
+    return tree(request, id, path)
+  else:
+    form = EditZNodeForm(dict(data=node.get('data64', '')\
+      .decode('base64').strip(), 
+      version=node.get('version', '-1')))
+
+  return render('edit.mako', request,
+    dict(path=path, form=form))
+
+

+ 23 - 0
apps/zookeeper/src/zkui/windmilltests.py

@@ -0,0 +1,23 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+
+#     http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from desktop.lib.windmill_util import logged_in_client
+
+def test_zkui():
+  """ launches the default view for zkui """
+  client = logged_in_client()
+  client.click(id='ccs-zkui-menu')
+  client.waits.forElement(classname='CCS-ZKUI', timeout='2000')