wui: improved columns in welcome page server list

As discussed at https://tahoe-lafs.org/trac/tahoe-lafs/ticket/1973 and in
previous pull request #129.

 - replace lengthy timestamps with human-readable deltas (eg 1h 2m 3s)
 - replace "announced" column with "Last RX" column
 - remove service column (it always said the same thing, "storage")
 - fix colspan on 'You are not presently connected' message

Previous versions, some with github comments: 3fe9053134 , 486dbfc7bd , and c89ea62580, 9fabb92486, bbd8b42a25

Unlike previous attempts, the tests on this one should pass in any timezone.
(But like current master, will fail with Nevow >=0.12...)

Thanks to an anonymous contributor who wrote some of the tests.
This commit is contained in:
Daira Hopwood 2016-01-15 20:02:19 +00:00
parent 6226f6b497
commit a2d724aab7
11 changed files with 158 additions and 41 deletions

View File

@ -417,7 +417,6 @@ class IStorageBroker(Interface):
public attributes:: public attributes::
service_name: the type of service provided, like 'storage' service_name: the type of service provided, like 'storage'
announcement_time: when we first heard about this service
last_connect_time: when we last established a connection last_connect_time: when we last established a connection
last_loss_time: when we last lost a connection last_loss_time: when we last lost a connection

View File

@ -168,7 +168,6 @@ class NativeStorageServer:
the their version information. I remember information about when we were the their version information. I remember information about when we were
last connected too, even if we aren't currently connected. last connected too, even if we aren't currently connected.
@ivar announcement_time: when we first heard about this service
@ivar last_connect_time: when we last established a connection @ivar last_connect_time: when we last established a connection
@ivar last_loss_time: when we last lost a connection @ivar last_loss_time: when we last lost a connection
@ -216,7 +215,6 @@ class NativeStorageServer:
self._long_description = tubid_s self._long_description = tubid_s
self._short_description = tubid_s[:6] self._short_description = tubid_s[:6]
self.announcement_time = time.time()
self.last_connect_time = None self.last_connect_time = None
self.last_loss_time = None self.last_loss_time = None
self.remote_host = None self.remote_host = None
@ -267,8 +265,11 @@ class NativeStorageServer:
return self.last_connect_time return self.last_connect_time
def get_last_loss_time(self): def get_last_loss_time(self):
return self.last_loss_time return self.last_loss_time
def get_announcement_time(self): def get_last_received_data_time(self):
return self.announcement_time if self.rref is None:
return None
else:
return self.rref.getDataLastReceivedAt()
def get_available_space(self): def get_available_space(self):
version = self.get_version() version = self.get_version()

View File

@ -173,6 +173,24 @@ class TestMixin(SignalMixin):
if required_to_quiesce and active: if required_to_quiesce and active:
self.fail("Reactor was still active when it was required to be quiescent.") self.fail("Reactor was still active when it was required to be quiescent.")
class TimezoneMixin(object):
def setTimezone(self, timezone):
unset = object()
originalTimezone = os.environ.get('TZ', unset)
def restoreTimezone():
if originalTimezone is unset:
del os.environ['TZ']
time.tzset()
else:
os.environ['TZ'] = originalTimezone
time.tzset()
os.environ['TZ'] = timezone
time.tzset()
self.addCleanup(restoreTimezone)
try: try:
import win32file import win32file
import win32con import win32con

View File

@ -1019,7 +1019,41 @@ class TimeFormat(unittest.TestCase):
leap_years_1970_to_2047_inclusive = ((2044 - 1968) // 4) leap_years_1970_to_2047_inclusive = ((2044 - 1968) // 4)
self.failUnlessEqual(time_format.format_time(time.gmtime(seconds_per_day*((2048 - 1970)*365+leap_years_1970_to_2047_inclusive))), '2048-01-01 00:00:00') self.failUnlessEqual(time_format.format_time(time.gmtime(seconds_per_day*((2048 - 1970)*365+leap_years_1970_to_2047_inclusive))), '2048-01-01 00:00:00')
test_format_time_y2038.todo = "one day we'll move beyond 32-bit time" test_format_time_y2038.todo = "This test is known to fail on systems with 32-bit time_t."
def test_format_delta(self):
time_1 = 1389812723
time_5s_delta = 1389812728
time_28m7s_delta = 1389814410
time_1h_delta = 1389816323
time_1d21h46m49s_delta = 1389977532
self.failUnlessEqual(
time_format.format_delta(time_1, time_1), '0s')
self.failUnlessEqual(
time_format.format_delta(time_1, time_5s_delta), '5s')
self.failUnlessEqual(
time_format.format_delta(time_1, time_28m7s_delta), '28m 7s')
self.failUnlessEqual(
time_format.format_delta(time_1, time_1h_delta), '1h 0m 0s')
self.failUnlessEqual(
time_format.format_delta(time_1, time_1d21h46m49s_delta), '1d 21h 46m 49s')
self.failUnlessEqual(
time_format.format_delta(time_1d21h46m49s_delta, time_1), '-')
# time_1 with a decimal fraction will make the delta 1s less
time_1decimal = 1389812723.383963
self.failUnlessEqual(
time_format.format_delta(time_1decimal, time_5s_delta), '4s')
self.failUnlessEqual(
time_format.format_delta(time_1decimal, time_28m7s_delta), '28m 6s')
self.failUnlessEqual(
time_format.format_delta(time_1decimal, time_1h_delta), '59m 59s')
self.failUnlessEqual(
time_format.format_delta(time_1decimal, time_1d21h46m49s_delta), '1d 21h 46m 48s')
class CacheDir(unittest.TestCase): class CacheDir(unittest.TestCase):
def test_basic(self): def test_basic(self):

View File

@ -172,21 +172,28 @@ class FakeHistory:
return [] return []
class FakeDisplayableServer(StubServer): class FakeDisplayableServer(StubServer):
def __init__(self, serverid, nickname): def __init__(self, serverid, nickname, connected,
last_connect_time, last_loss_time, last_rx_time):
StubServer.__init__(self, serverid) StubServer.__init__(self, serverid)
self.announcement = {"my-version": "allmydata-tahoe-fake", self.announcement = {"my-version": "allmydata-tahoe-fake",
"service-name": "storage", "service-name": "storage",
"nickname": nickname} "nickname": nickname}
self.connected = connected
self.last_loss_time = last_loss_time
self.last_rx_time = last_rx_time
self.last_connect_time = last_connect_time
def is_connected(self): def is_connected(self):
return True return self.connected
def get_permutation_seed(self): def get_permutation_seed(self):
return "" return ""
def get_remote_host(self): def get_remote_host(self):
return "" return ""
def get_last_loss_time(self): def get_last_loss_time(self):
return None return self.last_loss_time
def get_announcement_time(self): def get_last_received_data_time(self):
return None return self.last_rx_time
def get_last_connect_time(self):
return self.last_connect_time
def get_announcement(self): def get_announcement(self):
return self.announcement return self.announcement
def get_nickname(self): def get_nickname(self):
@ -242,7 +249,13 @@ class FakeClient(Client):
self.storage_broker = StorageFarmBroker(None, permute_peers=True) self.storage_broker = StorageFarmBroker(None, permute_peers=True)
# fake knowledge of another server # fake knowledge of another server
self.storage_broker.test_add_server("other_nodeid", self.storage_broker.test_add_server("other_nodeid",
FakeDisplayableServer("other_nodeid", u"other_nickname \u263B")) FakeDisplayableServer(
serverid="other_nodeid", nickname=u"other_nickname \u263B", connected = True,
last_connect_time = 10, last_loss_time = 20, last_rx_time = 30))
self.storage_broker.test_add_server("disconnected_nodeid",
FakeDisplayableServer(
serverid="other_nodeid", nickname=u"disconnected_nickname \u263B", connected = False,
last_connect_time = 15, last_loss_time = 25, last_rx_time = 35))
self.introducer_client = None self.introducer_client = None
self.history = FakeHistory() self.history = FakeHistory()
self.uploader = FakeUploader() self.uploader = FakeUploader()
@ -268,14 +281,16 @@ class FakeClient(Client):
MUTABLE_SIZELIMIT = FakeMutableFileNode.MUTABLE_SIZELIMIT MUTABLE_SIZELIMIT = FakeMutableFileNode.MUTABLE_SIZELIMIT
class WebMixin(object): class WebMixin(testutil.TimezoneMixin):
def setUp(self): def setUp(self):
self.setTimezone('UTC-13:00')
self.s = FakeClient() self.s = FakeClient()
self.s.startService() self.s.startService()
self.staticdir = self.mktemp() self.staticdir = self.mktemp()
self.clock = Clock() self.clock = Clock()
self.fakeTime = 86460 # 1d 0h 1m 0s
self.ws = webish.WebishServer(self.s, "0", staticdir=self.staticdir, self.ws = webish.WebishServer(self.s, "0", staticdir=self.staticdir,
clock=self.clock) clock=self.clock, now_fn=lambda:self.fakeTime)
self.ws.setServiceParent(self.s) self.ws.setServiceParent(self.s)
self.webish_port = self.ws.getPortnum() self.webish_port = self.ws.getPortnum()
self.webish_url = self.ws.getURL() self.webish_url = self.ws.getURL()
@ -601,7 +616,6 @@ class WebMixin(object):
self.fail("%s was supposed to Error(302), not get '%s'" % self.fail("%s was supposed to Error(302), not get '%s'" %
(which, res)) (which, res))
class Web(WebMixin, WebErrorMixin, testutil.StallMixin, testutil.ReallyEqualMixin, unittest.TestCase): class Web(WebMixin, WebErrorMixin, testutil.StallMixin, testutil.ReallyEqualMixin, unittest.TestCase):
def test_create(self): def test_create(self):
pass pass
@ -619,6 +633,11 @@ class Web(WebMixin, WebErrorMixin, testutil.StallMixin, testutil.ReallyEqualMixi
res_u = res.decode('utf-8') res_u = res.decode('utf-8')
self.failUnlessIn(u'<td>fake_nickname \u263A</td>', res_u) self.failUnlessIn(u'<td>fake_nickname \u263A</td>', res_u)
self.failUnlessIn(u'<div class="nickname">other_nickname \u263B</div>', res_u) self.failUnlessIn(u'<div class="nickname">other_nickname \u263B</div>', res_u)
self.failUnlessIn(u'Connected to <span>1</span>\n of <span>2</span> known storage servers', res_u)
self.failUnlessIn(u'<div class="status-indicator"><img src="img/connected-yes.png" alt="Connected" /></div>\n <a class="timestamp" title="1970-01-01 13:00:10">1d\u00A00h\u00A00m\u00A050s</a>', res_u)
self.failUnlessIn(u'<div class="status-indicator"><img src="img/connected-no.png" alt="Disconnected" /></div>\n <a class="timestamp" title="1970-01-01 13:00:25">1d\u00A00h\u00A00m\u00A035s</a>', res_u)
self.failUnlessIn(u'<td class="service-last-received-data"><a class="timestamp" title="1970-01-01 13:00:30">1d\u00A00h\u00A00m\u00A030s</a></td>', res_u)
self.failUnlessIn(u'<td class="service-last-received-data"><a class="timestamp" title="1970-01-01 13:00:35">1d\u00A00h\u00A00m\u00A025s</a></td>', res_u)
self.failUnlessIn(u'\u00A9 <a href="https://tahoe-lafs.org/">Tahoe-LAFS Software Foundation', res_u) self.failUnlessIn(u'\u00A9 <a href="https://tahoe-lafs.org/">Tahoe-LAFS Software Foundation', res_u)
self.failUnlessIn('<td><h3>Available</h3></td>', res) self.failUnlessIn('<td><h3>Available</h3></td>', res)
self.failUnlessIn('123.5kB', res) self.failUnlessIn('123.5kB', res)

View File

@ -66,3 +66,27 @@ def parse_date(s):
# day # day
return int(iso_utc_time_to_seconds(s + "T00:00:00")) return int(iso_utc_time_to_seconds(s + "T00:00:00"))
def format_delta(time_1, time_2):
if time_1 is None:
return "N/A"
if time_1 > time_2:
return '-'
delta = int(time_2 - time_1)
seconds = delta % 60
delta -= seconds
minutes = (delta / 60) % 60
delta -= minutes * 60
hours = delta / (60*60) % 24
delta -= hours * 24
days = delta / (24*60*60)
if not days:
if not hours:
if not minutes:
return "%ss" % (seconds)
else:
return "%sm %ss" % (minutes, seconds)
else:
return "%sh %sm %ss" % (hours, minutes, seconds)
else:
return "%sd %sh %sm %ss" % (days, hours, minutes, seconds)

View File

@ -15,7 +15,7 @@ from allmydata.interfaces import ExistingChildError, NoSuchChildError, \
MustBeReadonlyError, MustNotBeUnknownRWError, SDMF_VERSION, MDMF_VERSION MustBeReadonlyError, MustNotBeUnknownRWError, SDMF_VERSION, MDMF_VERSION
from allmydata.mutable.common import UnrecoverableFileError from allmydata.mutable.common import UnrecoverableFileError
from allmydata.util import abbreviate from allmydata.util import abbreviate
from allmydata.util.time_format import format_time from allmydata.util.time_format import format_time, format_delta
from allmydata.util.encodingutil import to_str, quote_output from allmydata.util.encodingutil import to_str, quote_output
@ -213,9 +213,15 @@ def text_plain(text, ctx):
def spaces_to_nbsp(text): def spaces_to_nbsp(text):
return unicode(text).replace(u' ', u'\u00A0') return unicode(text).replace(u' ', u'\u00A0')
def render_time_delta(time_1, time_2):
return spaces_to_nbsp(format_delta(time_1, time_2))
def render_time(t): def render_time(t):
return spaces_to_nbsp(format_time(time.localtime(t))) return spaces_to_nbsp(format_time(time.localtime(t)))
def render_time_attr(t):
return format_time(time.localtime(t))
class WebError(Exception): class WebError(Exception):
def __init__(self, text, code=http.BAD_REQUEST): def __init__(self, text, code=http.BAD_REQUEST):
self.text = text self.text = text

View File

@ -14,7 +14,7 @@ from allmydata.interfaces import IFileNode
from allmydata.web import filenode, directory, unlinked, status, operations from allmydata.web import filenode, directory, unlinked, status, operations
from allmydata.web import storage from allmydata.web import storage
from allmydata.web.common import abbreviate_size, getxmlfile, WebError, \ from allmydata.web.common import abbreviate_size, getxmlfile, WebError, \
get_arg, RenderMixin, get_format, get_mutable_type, render_time get_arg, RenderMixin, get_format, get_mutable_type, render_time_delta, render_time, render_time_attr
class URIHandler(RenderMixin, rend.Page): class URIHandler(RenderMixin, rend.Page):
@ -138,12 +138,13 @@ class Root(rend.Page):
"no": "Disconnected", "no": "Disconnected",
} }
def __init__(self, client, clock=None): def __init__(self, client, clock=None, now_fn=None):
rend.Page.__init__(self, client) rend.Page.__init__(self, client)
self.client = client self.client = client
# If set, clock is a twisted.internet.task.Clock that the tests # If set, clock is a twisted.internet.task.Clock that the tests
# use to test ophandle expiration. # use to test ophandle expiration.
self.child_operations = operations.OphandleTable(clock) self.child_operations = operations.OphandleTable(clock)
self.now_fn = now_fn
try: try:
s = client.getServiceNamed("storage") s = client.getServiceNamed("storage")
except KeyError: except KeyError:
@ -282,7 +283,7 @@ class Root(rend.Page):
ctx.fillSlots("peerid", server.get_longname()) ctx.fillSlots("peerid", server.get_longname())
ctx.fillSlots("nickname", server.get_nickname()) ctx.fillSlots("nickname", server.get_nickname())
rhost = server.get_remote_host() rhost = server.get_remote_host()
if rhost: if server.is_connected():
if nodeid == self.client.nodeid: if nodeid == self.client.nodeid:
rhost_s = "(loopback)" rhost_s = "(loopback)"
elif isinstance(rhost, address.IPv4Address): elif isinstance(rhost, address.IPv4Address):
@ -290,29 +291,37 @@ class Root(rend.Page):
else: else:
rhost_s = str(rhost) rhost_s = str(rhost)
addr = rhost_s addr = rhost_s
connected = "yes" service_connection_status = "yes"
since = server.get_last_connect_time() last_connect_time = server.get_last_connect_time()
service_connection_status_rel_time = render_time_delta(last_connect_time, self.now_fn())
service_connection_status_abs_time = render_time_attr(last_connect_time)
else: else:
addr = "N/A" addr = "N/A"
connected = "no" service_connection_status = "no"
since = server.get_last_loss_time() last_loss_time = server.get_last_loss_time()
announced = server.get_announcement_time() service_connection_status_rel_time = render_time_delta(last_loss_time, self.now_fn())
service_connection_status_abs_time = render_time_attr(last_loss_time)
last_received_data_time = server.get_last_received_data_time()
last_received_data_rel_time = render_time_delta(last_received_data_time, self.now_fn())
last_received_data_abs_time = render_time_attr(last_received_data_time)
announcement = server.get_announcement() announcement = server.get_announcement()
version = announcement["my-version"] version = announcement["my-version"]
service_name = announcement["service-name"]
available_space = server.get_available_space() available_space = server.get_available_space()
if available_space is None: if available_space is None:
available_space = "N/A" available_space = "N/A"
else: else:
available_space = abbreviate_size(available_space) available_space = abbreviate_size(available_space)
ctx.fillSlots("address", addr) ctx.fillSlots("address", addr)
ctx.fillSlots("connected", connected) ctx.fillSlots("service_connection_status", service_connection_status)
ctx.fillSlots("connected_alt", self._connectedalts[connected]) ctx.fillSlots("service_connection_status_alt", self._connectedalts[service_connection_status])
ctx.fillSlots("connected-bool", bool(rhost)) ctx.fillSlots("connected-bool", bool(rhost))
ctx.fillSlots("since", render_time(since)) ctx.fillSlots("service_connection_status_abs_time", service_connection_status_abs_time)
ctx.fillSlots("announced", render_time(announced)) ctx.fillSlots("service_connection_status_rel_time", service_connection_status_rel_time)
ctx.fillSlots("last_received_data_abs_time", last_received_data_abs_time)
ctx.fillSlots("last_received_data_rel_time", last_received_data_rel_time)
ctx.fillSlots("version", version) ctx.fillSlots("version", version)
ctx.fillSlots("service_name", service_name)
ctx.fillSlots("available_space", available_space) ctx.fillSlots("available_space", available_space)
return ctx.tag return ctx.tag

View File

@ -77,3 +77,12 @@ body {
float: left; float: left;
margin: 5px; margin: 5px;
} }
.nickname-and-peerid .timestamp {
float: right;
}
a.timestamp {
color: inherit;
text-decoration:none;
}

View File

@ -1,4 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0
Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html lang="en" xmlns:n="http://nevow.com/ns/nevow/0.1"> <html lang="en" xmlns:n="http://nevow.com/ns/nevow/0.1">
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8"/>
@ -169,27 +170,24 @@
<tr n:pattern="header"> <tr n:pattern="header">
<td><h3>Nickname</h3></td> <td><h3>Nickname</h3></td>
<td><h3>Address</h3></td> <td><h3>Address</h3></td>
<td><h3>Service</h3></td> <td><h3>Last&nbsp;RX</h3></td>
<td><h3>Since</h3></td>
<td><h3>Announced</h3></td>
<td><h3>Version</h3></td> <td><h3>Version</h3></td>
<td><h3>Available</h3></td> <td><h3>Available</h3></td>
</tr> </tr>
</thead> </thead>
<tr n:pattern="item" n:render="service_row"> <tr n:pattern="item" n:render="service_row">
<td class="nickname-and-peerid"> <td class="nickname-and-peerid">
<div class="status-indicator"><img><n:attr name="src">img/connected-<n:slot name="connected" />.png</n:attr><n:attr name="alt"><n:slot name="connected_alt" /></n:attr></img></div> <div class="status-indicator"><img><n:attr name="src">img/connected-<n:slot name="service_connection_status" />.png</n:attr><n:attr name="alt"><n:slot name="service_connection_status_alt" /></n:attr></img></div>
<a class="timestamp"><n:attr name="title"><n:slot name="service_connection_status_abs_time"/></n:attr><n:slot name="service_connection_status_rel_time"/></a>
<div class="nickname"><n:slot name="nickname"/></div> <div class="nickname"><n:slot name="nickname"/></div>
<div class="nodeid"><n:slot name="peerid"/></div> <div class="nodeid"><n:slot name="peerid"/></div>
</td> </td>
<td class="address"><n:slot name="address"/></td> <td class="address"><n:slot name="address"/></td>
<td class="service-service-name"><n:slot name="service_name"/></td> <td class="service-last-received-data"><a class="timestamp"><n:attr name="title"><n:slot name="last_received_data_abs_time"/></n:attr><n:slot name="last_received_data_rel_time"/></a></td>
<td class="service-since timestamp"><n:slot name="since"/></td>
<td class="service-announced timestamp"><n:slot name="announced"/></td>
<td class="service-version"><n:slot name="version"/></td> <td class="service-version"><n:slot name="version"/></td>
<td class="service-available-space"><n:slot name="available_space"/></td> <td class="service-available-space"><n:slot name="available_space"/></td>
</tr> </tr>
<tr n:pattern="empty"><td>You are not presently connected to any peers</td></tr> <tr n:pattern="empty"><td colspan="5">You are not presently connected to any peers</td></tr>
</table> </table>
</div><!--/span--> </div><!--/span-->
</div><!--/row--> </div><!--/row-->

View File

@ -129,14 +129,14 @@ class WebishServer(service.MultiService):
name = "webish" name = "webish"
def __init__(self, client, webport, nodeurl_path=None, staticdir=None, def __init__(self, client, webport, nodeurl_path=None, staticdir=None,
clock=None): clock=None, now_fn=time.time):
service.MultiService.__init__(self) service.MultiService.__init__(self)
# the 'data' argument to all render() methods default to the Client # the 'data' argument to all render() methods default to the Client
# the 'clock' argument to root.Root is, if set, a # the 'clock' argument to root.Root is, if set, a
# twisted.internet.task.Clock that is provided by the unit tests # twisted.internet.task.Clock that is provided by the unit tests
# so that they can test features that involve the passage of # so that they can test features that involve the passage of
# time in a deterministic manner. # time in a deterministic manner.
self.root = root.Root(client, clock) self.root = root.Root(client, clock, now_fn)
self.buildServer(webport, nodeurl_path, staticdir) self.buildServer(webport, nodeurl_path, staticdir)
if self.root.child_operations: if self.root.child_operations:
self.site.remember(self.root.child_operations, IOpHandleTable) self.site.remember(self.root.child_operations, IOpHandleTable)