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()
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>';
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;
}
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>
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()
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();
}
}
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>
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>')
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();
}
}
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()
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()
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.