A Web Admin Console for Redis, Part Three

My past two posts walked through the development of a local web-based app that simplifies matching redis values by key pattern and exploring the various data types. To round this out, I'll make this maximally useful by including modify and delete support here.

When last we spoke, the server showed keys and values pretty comprehensively, but didn't include any way to interact with the cache contents. Adding delete support is particularly straightforward — I need a new route that permits key deletion and a link next to each entry that initiates it.

  def do_GET(self):
    if self.path.startswith('/get'):
      ...
    elif self.path.startswith('/delete'):
        params = self.parseQuery(self.path)
        if self.ensure_requried_params(params, ['key']):
          val = redisConn.delete(params['key'])
          self.send_response(204, 'No Content')
          self.end_headers()

Listing 1: Delete support, server side

function scan(page) {
  issueGetRequest('/scan?pattern=' + query + '&size=' + size + '&page=' + page, "tbl", function(txt)  {
    ...
    switch(result.page[i]['type']) {
      ...
    }
    <b>tblHtml += '<td><a href="/delete?key=' + result.page[i]['key'] + '">delete</a>';</b>
    tblHtml += '</tr>';

Listing 2: Delete support, client side

This works, but doesn't make for a satisfying user experience - since the delete action is a simple link, the current page disappears on success. This is better handled as a function (named remove in this case, since delete is a reserved word in Javascript):

    <b>tblHtml += '<td><a href="#" onclick="return remove(\'' + result.page[i]['key'] + '\')">delete</a>';</b>
    ...
function remove(key) {
  var req = new XMLHttpRequest();
  req.open('GET', '/delete?key=' + key);
  req.onreadystatechange = function(event) {
    if (event.target.readyState == 4) {
      if (event.target.status == 204) {
        var rowToRemove = document.getElementById(key);
        rowToRemove.parentElement.removeChild(rowToRemove);
      }
    }
  };
  req.send();

  return false;
}

Listing 3: Delete as a function

I don't bother handling the case where the deletion failed - that means there's an underlying server problem or the key has already been removed.

Edit support is a bit more interesting, since I want to go ahead and handle it "inline": I want the user to be able to double click the value, edit in place, and either save or discard the changes. To support that, I'll attach a double-click handler to the table rather than the cells and mark each value cell as editable with a new class. When the users double-clicks on an editable table cell, the table cell contents are changed to an edit box and when the users hits the Enter key, the change is committed. Pressing the Escape key discards the pending change.

I tried it first by adding "save"/"cancel" links next to the input box, but the experience was jarring since everything shifted too far to the right to make the save/cancel links pop up. I opted instead to use the Enter and Escape key presses as save and cancel triggers, instead.

function scan(page) {
  ...
      switch (result.page[i]['type']) {
        case 'string':
          tblHtml += '<td class="editable">' + result.page[i]['value'] + '</td>';
  ...
function edit(event) {
  var target = event.target;
  if (target.className == 'editable') {
    var targetText = target.childNodes[0];
    var originalContent = targetText.textContent;
    var parent = targetText.parentElement;
    var inputNode = document.createElement('input');
    inputNode.setAttribute('type', 'text');
    inputNode.setAttribute('value', originalContent);
    inputNode.setAttribute('size', originalContent.length);
    inputNode.addEventListener('keydown', function(evt) {
      if (evt.key  == 'Enter') {
        var textContent = document.createTextNode(inputNode.value);
        textContent.className = 'editable';
        inputNode.parentNode.replaceChild(textContent, inputNode);
      } else if (evt.key == 'Escape')  {
        var textContent = document.createTextNode(originalContent);
        textContent.className = 'editable';
        inputNode.parentNode.replaceChild(textContent, inputNode);
      }
    });
    parent.replaceChild(inputNode, targetText);
    inputNode.focus();
    inputNode.select();
  }
}
...
<div id="tbl" ondblclick="edit(event)"></div>

Listing 4: make values editable

Since Javascript event handlers bubble up to their parents, any double-click inside the table will trigger this handler. If the node is marked as editable (via a new class declaration), the table cell's contents are replaced with an edit box containing the cell's original contents. If the user presses the enter key, the cell is replaced with the new contents, if the user presses the escape key, the old contents are restored. This function is a bit longer than you'd think it would need to be due to the unwieldy DOM API, but it should be easy enough to follow what's going on here.

Of course, at this point, the changes don't persist - to make that happen, I need a new route in the server that allows keys to be reset. The redis command is set, but I've already used that for the set datatype, so I'll use save here instead.

    elif self.path.startswith('/save'):
      params = self.parseQuery(self.path)
      if self.ensure_required_params(params, ['key', 'value']):
        if redisConn.set(params['key', 'value']):
          self.send_response(204, 'No Content')
        else:
          self.send_response(500, 'Error')
        self.end_headers()

Listing 5: server side edit support

Along with a corresponding change to the save case of the edit event handler (no server side action is needed in case of a cancel, since nothing changes). There's only one problem here - I don't know the key whose value I'm replacing! I could traverse the DOM tree here and find the sibling node, but that's hardly robust. A better solution is to keep track of it with the value node itself in an x-attr attribute.

function scan(page) {
  ...
        case 'string':
          tblHtml += '<td x-attr-key="' + result.page[i]['key'] + '" class="editable">' + 
		result.page[i]['value'] + '</td>';
          break;
  ...
function edit(event)  {
  ...
  var targetKey = target.getAttribute('x-attr-key');
  inputNode.addEventListener('keydown', function(evt) {
    if (evt.key  == 'Enter') {
      var req = new XMLHttpRequest();
        req.open('GET', '/save?key=' + targetKey + '&value=' + inputNode.value);
        req.onreadystatechange = function(event) {
          if (event.target.readyState == 4) {
            if (event.target.status == 204) {
              var textContent = document.createTextNode(inputNode.value);
              textContent.className = 'editable';
              inputNode.parentNode.replaceChild(textContent, inputNode);
            }
          }
        };
        req.send();
    }
}

Listing 6: persist edits

So far, so good - what about the other data types: lists, sets, and hashes? I can reuse the same basic framework, I just have to expand remove and edit just a bit to identify which Redis type I'm looking at.

function remove(key, index, type)  {
  issueGetRequest('/delete?key=' + key + '&index=' + index + '&type=' + type, null, function() {
  ...

function edit(event, type) {
  ...
  var targetIndex = target.getAttribute('x-attr-index');
  var targetIndex = target.getAttribute('x-attr-index');
  ...
  var req = new XMLHttpRequest();
  req.open('GET', '/save?key=' + targetKey + '&index=' + targetIndex + '&type=' + type + '&value=' + inputNode.value);
  ...
function scan(page) {
  ...
  tblHtml += '<td><a href="#" onclick="remove(\'' + result.page[i]['key'] + '\', null, \'string\')">delete</a>';
  ...
<div id="tbl" ondblclick="edit(event, 'string')")></div>

Listing 7: Include type in remove and edit

Now all I have to do is include the edit script, the double-click handler, and the editable class on the editable values.

    elif self.path.startswith('/list'):
      params = self.parseQuery(self.path)
      if self.ensure_required_params(params, ['key', 'size', 'page']):
        key = params['key']
        size = int(params['size'])
        page = int(params['page'])
        llen = redisConn.llen(key)
        values = redisConn.lrange(key, page * size, ((page + 1) * size) - 1)
        values = [value.decode('UTF-8') if value.isascii() else str(value) for value in values]
        table = ['<tr id="' + value + '">' +
          '<td x-attr-key="' + key + '" x-attr-index="' + str(i) + '" class="editable">' + value + '</td>' +
          '<td><a href="#" onclick="return remove(\'' + key + '\', \'' + value + '\', \'list\')">delete</a></td>' + 
          '</tr>' for (value, i) in zip(values, range(len(values)))]
        table += ('<tr><td>' +
          ('<a href="/list?key=' + key + '&page=' + str(page - 1) + '&size=' + str(size) + 
		'">prev</a>' if page > 0 else '') +
          '</td><td>' +
          ('<a href="/list?key=' + key + '&page=' + str(page + 1) + '&size=' + str(size) + 
		'">next</a>' if ((page + 1) * size) < llen else '') +
          '</td></tr>');

        self.send_html('<html><head><script src="edit_redis.js"></script></head><body>' + 
		'<table ondblclick="edit(event, \'list\')">' + ''.join(table) + '</table></body></html>')

Listing 8: Edit and remove support for lists

I'm able to reuse most of the same infrastructue to support edit and delete in lists that I did for string types. In particular, I'm able to mark the table cells with editable class and include an x-attr-key to identify the key (that is, the name of the list itself) for editing. I have to include a second parameter here, though: the index of the entry which I'm trying to change. Also notice that there's a slight incongruity in the Redis API for lists: entries are removed by value, but updated by position. I have to make a handful of changes to the remove and edit functions:

function remove(key, index, type) {
  var req = new XMLHttpRequest();
  req.open('GET', '/delete?key=' + key + '&index=' + index +  '&type=' + type);
  req.onreadystatechange = function(event) {
    if (event.target.readyState == 4) {
      if (event.target.status == 204) {
        var rowToRemove = document.getElementById(type == 'string' ? key : index);
        rowToRemove.parentElement.removeChild(rowToRemove);
      }
    }
  };
  req.send();

  return false;
}

function edit(event, type) {
  var target = event.target;
  if (target.className == 'editable') {
    var targetText = target.childNodes[0];
    var targetKey = target.getAttribute('x-attr-key');
    var targetIndex = target.getAttribute('x-attr-index');
    var originalContent = targetText.textContent;
    var parent = targetText.parentElement;
    var inputNode = document.createElement('input');
    inputNode.setAttribute('type', 'text');
    inputNode.setAttribute('value', originalContent);
    inputNode.setAttribute('size', originalContent.length);
    inputNode.addEventListener('keydown', function(evt) {
      if (evt.key  == 'Enter') {
        var req = new XMLHttpRequest();
        req.open('GET', '/save?key=' + targetKey + '&index=' + targetIndex + '&type=' + type + 
		'&value=' + inputNode.value);
        req.onreadystatechange = function(event) {
          if (event.target.readyState == 4) {
            if (event.target.status == 204) {
              var textContent = document.createTextNode(inputNode.value);
              textContent.className = 'editable';
              inputNode.parentNode.replaceChild(textContent, inputNode);
            }
          }
        };
        req.send();

      } else if (evt.key == 'Escape')  {
        var textContent = document.createTextNode(originalContent);
        textContent.className = 'editable';
        inputNode.parentNode.replaceChild(textContent, inputNode);
      }
    });
    parent.replaceChild(inputNode, targetText);
    inputNode.focus();
    inputNode.select();
  }
}

Listing 9: List support in edit and remove functions

You can see I only had to make a handful of changes to extend edit and delete support to lists: other than passing a couple of extra parameters to the server, I also have to remove rows by index number rather than key, since the key parameter is the name of the list I'm manipulating.

Finally, I have to add server-side support to actually update the entries:

    elif self.path.startswith('/delete'):
      params = self.parseQuery(self.path)
      if self.ensure_required_params(params, ['key', 'index', 'type']):
        type = params['type']
        if type == 'string':
          val = redisConn.delete(params['key'])
        elif type == 'list':
          val = redisConn.lrem(params['key'], 0, params['index'])
        self.send_response(204, 'No Content')
        self.end_headers()
    elif self.path.startswith('/save'):
      params = self.parseQuery(self.path)
      if self.ensure_required_params(params, ['key', 'index', 'type', 'value']):
        type = params['type']
        updated = False
        if type == 'string':
          updated = redisConn.set(params['key'], params['value'])
        if type == 'list':
          updated = redisConn.lset(params['key'], params['index'], params['value'])

        if updated:
          self.send_response(204, 'No Content')
        else:
          self.send_response(500, 'Error')
        self.end_headers()

Listing 10: Edit and remove lists server-side support

Again, there's not much required here except to process the two new parameters and, of course, call the correct Redis API endpoint: lrem instead of delete and lset instead of set.

And that's it! List items can now be edited in place or removed just as if they were top-level keys. You may recall that there are three other native types that I support here: set, zset and hash. There's really no meaningful concept of "editing" a set or zset entry, but they can be meaningfully removed. hash objects can have entries edited or removed, and the list infrastructure can be essentially reused nearly as is to support these:

    elif self.path.startswith('/set') or self.path.startswith('/zset') or self.path.startswith('/hash'):
      params = self.parseQuery(self.path)
      if self.ensure_required_params(params, ['key', 'cursor', 'size']):
        key = params['key']
        size = int(params['size'])
        cursor = int(params['cursor'])
        edittype = ''
        if self.path.startswith('/set'):
          (cursor, values) = redisConn.sscan(key, cursor, '*', size)
          values = [value.decode('UTF-8') if value.isascii() else str(value) for value in values]
          edittype = 'set'
          table = ['<tr id="' + value + '">' +
            '<td>' + value + '</td>' +
            '<td><a href="#" onclick="return remove(\'' + key + '\',\'' + value + '\',\'set\')">delete</a></td>' +
          '</tr>' for value in values]
        elif self.path.startswith('/zset'):
          (cursor, values) = redisConn.zscan(key, cursor, '*', size)
          values = [(value[0].decode('UTF-8'), value[1]) if value[0].isascii() else str(value) for value in values]
          edittype = 'zset'
          table = ['<tr><td>' + value[0] + '</td><td>' + str(value[1]) + '</td></tr>' for value in values]
          table = ['<tr id="' + value[0] + '">' +
            '<td>' + str(value[1]) + '</td>' +
            '<td>' + value + '</td>' +
            '<td><a href="#" onclick="return remove(\'' + key + '\',\'' + value[0] + '\',\'zset\')">delete</a></td>' +
          '</tr>' for value in values]
        elif self.path.startswith('/hash'):
          (cursor, values) = redisConn.hscan(key, cursor, '*', size)
          edittype = 'hash'
          table = ['<tr id="' + hkey.decode('UTF-8') + '">' +
            '<td>' + hkey.decode('UTF-8') + '</td>' +
            '<td x-attr-key="' + key + '" x-attr-index="' + hkey.decode('UTF-8') + '" class="editable">' + 
		values[hkey].decode('UTF-8') + '</td>' +
            '<td><a href="#" onclick="return remove(\'' + key + '\',\'' + hkey.decode('UTF-8') + '\', \'hash\')">' + 
		'delete</a></td>' +
          '</tr>' if hkey.isascii() else '' for hkey in values.keys()]
        table += ('<tr><td></td><td>' +
          ('<a href="/set?key=' + key + '&size=' + str(size) + '&cursor=' + str(cursor)  + '">next</a>'
            if cursor != 0 else '') +
            '</td></tr>')
        self.send_html('<html><head><script src="edit_redis.js"></script></head><body>' + 
		'<table ondblclick="edit(event, \'' + edittype + '\')">' + ''.join(table) + '</table></body></html>')
            elif self.path.startswith('/delete'):
      params = self.parseQuery(self.path)
      if self.ensure_required_params(params, ['key', 'index', 'type']):
        type = params['type']
        if type == 'string':
          val = redisConn.delete(params['key'])
        elif type == 'list':
          val = redisConn.lrem(params['key'], 0, params['index'])
        elif type == 'set':
          val = redisConn.srem(params['key'], params['index'])
        elif type == 'hash':
          val = redisConn.hdel(params['key'], params['index'])
        elif type == 'zset':
          val = redisConn.zrem(params['key'], params['index'])
        self.send_response(204, 'No Content')
        self.end_headers()
    elif self.path.startswith('/save'):
      params = self.parseQuery(self.path)
      if self.ensure_required_params(params, ['key', 'index', 'type', 'value']):
        type = params['type']
        updated = False
        if type == 'string':
          updated = redisConn.set(params['key'], params['value'])
        if type == 'list':
          updated = redisConn.lset(params['key'], params['index'], params['value'])
        if type == 'hash':
          # This indicates success...
          updated = (redisConn.hset(params['key'], params['index'], params['value']) == 0)

        if updated:
          self.send_response(204, 'No Content')
        else:
          self.send_response(500, 'Error')
        self.end_headers()

Listing 11: Edit and remove set, zset and hash elements

And that's it! The changes I made to the edit and remove Javascript functions for lists support the remaining types as is.

Returning full HTML in response to list, set, hash and zset gets to be a bit unwieldy here - if I expand the functionality of this service any more, I'll probably implement a templating structure or change them to be pure AJAX, but here they work well enough for routine Redis maintenance tasks, which is my goal. I could probably use an "are you sure" prompt on delete and maybe "add" functionality as well, but this definitely beats trying to navigate the redis-cli tool.

Add a comment:

Completely off-topic or spam comments will be removed at the discretion of the moderator.

You may preserve formatting (e.g. a code sample) by indenting with four spaces preceding the formatted line(s)

Name: Name is required
Email (will not be displayed publicly):
Comment:
Comment is required
My Book

I'm the author of the book "Implementing SSL/TLS Using Cryptography and PKI". Like the title says, this is a from-the-ground-up examination of the SSL protocol that provides security, integrity and privacy to most application-level internet protocols, most notably HTTP. I include the source code to a complete working SSL implementation, including the most popular cryptographic algorithms (DES, 3DES, RC4, AES, RSA, DSA, Diffie-Hellman, HMAC, MD5, SHA-1, SHA-256, and ECC), and show how they all fit together to provide transport-layer security.

My Picture

Joshua Davies

Past Posts