1# Copyright 2009 Google Inc. All Rights Reserved.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7#      http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15"""Tk user interface implementation for namebench."""
16
17__author__ = 'tstromberg@google.com (Thomas Stromberg)'
18
19import datetime
20import os
21import Queue
22import sys
23import threading
24import tkFont
25# Wildcard imports are evil.
26from Tkinter import *
27import tkMessageBox
28import traceback
29
30import addr_util
31import base_ui
32import conn_quality
33import nameserver_list
34import util
35
36THREAD_UNSAFE_TK = 0
37LOG_FILE_PATH = util.GenerateOutputFilename('log')
38
39
40def closedWindowHandler():
41  print 'Au revoir, mes amis!'
42  sys.exit(1)
43
44global_message_queue = Queue.Queue()
45global_last_message = None
46
47
48def AddMsg(message, master=None, backup_notifier=None, **kwargs):
49  """Add a message to the global queue for output."""
50  global global_message_queue
51  global global_last_message
52  global THREAD_UNSAFE_TK
53
54  new_message = StatusMessage(message, **kwargs)
55  if new_message != global_last_message:
56    global_message_queue.put(new_message)
57
58    if master:
59      try:
60        master.event_generate('<<msg>>', when='tail')
61        global_last_message = new_message
62      # Tk thread-safety workaround #1
63      except TclError:
64        # If we aren't thread safe, we already assume this won't work.
65        if not THREAD_UNSAFE_TK:
66          print 'First TCL Error:'
67          traceback.print_exc()
68        try:
69          backup_notifier(-1)
70          THREAD_UNSAFE_TK = 1
71        except:
72          print 'Backup notifier failure:'
73          traceback.print_exc()
74
75
76class StatusMessage(object):
77  """Messages to be passed from to the main thread from children.
78
79  Used to avoid thread issues inherent with Tk.
80  """
81
82  def __init__(self, message, error=False, count=False, total=False,
83               enable_button=None, debug=False):
84    self.message = message
85    self.error = error
86    self.count = count
87    self.debug = debug
88    self.total = total
89    self.enable_button = enable_button
90
91
92class WorkerThread(threading.Thread, base_ui.BaseUI):
93  """Handle benchmarking and preparation in a separate UI thread."""
94
95  def __init__(self, supplied_ns, global_ns, regional_ns, options, data_source=None, master=None,
96               backup_notifier=None):
97    threading.Thread.__init__(self)
98    self.SetupDataStructures()
99    self.status_callback = self.msg
100    self.data_src = data_source
101    self.backup_notifier = backup_notifier
102    self.include_internal = False
103    self.supplied_ns = supplied_ns
104    self.global_ns = global_ns
105    self.regional_ns = regional_ns
106    self.master = master
107    self.options = options
108    self.resource_dir = os.path.dirname(os.path.dirname(__file__))
109
110  def msg(self, message, **kwargs):
111    """Add messages to the main queue."""
112    return AddMsg(message, master=self.master, backup_notifier=self.backup_notifier, **kwargs)
113
114  def run(self):
115    self.msg('Started thread', enable_button=False)
116    try:
117      self.PrepareTestRecords()
118      self.PrepareNameServers()
119      self.PrepareBenchmark()
120      self.RunAndOpenReports()
121    except nameserver_list.OutgoingUdpInterception:
122      (exc_type, exception, tb) = sys.exc_info()
123      self.msg('Outgoing requests were intercepted!', error=exception)
124    except nameserver_list.TooFewNameservers:
125      (exc_type, exception, tb) = sys.exc_info()
126      self.msg('Too few nameservers to test', error=exception)
127    except conn_quality.OfflineConnection:
128      (exc_type, exception, tb) = sys.exc_info()
129      self.msg('The connection appears to be offline!', error=exception)
130    except:
131      (exc_type, exception, tb) = sys.exc_info()
132      traceback.print_exc(tb)
133      error_msg = '\n'.join(traceback.format_tb(tb)[-4:])
134      self.msg(exception, error=error_msg)
135    self.msg(None, enable_button=True)
136
137
138class NameBenchGui(object):
139  """The main GUI."""
140
141  def __init__(self, options, supplied_ns, global_ns, regional_ns, version=None):
142    self.options = options
143    self.supplied_ns = supplied_ns
144    self.global_ns = global_ns
145    self.regional_ns = regional_ns
146    self.version = version
147
148  def Execute(self):
149    self.root = Tk()
150    app = MainWindow(self.root, self.options, self.supplied_ns, self.global_ns,
151                     self.regional_ns, self.version)
152    app.DrawWindow()
153    self.root.bind('<<msg>>', app.MessageHandler)
154    self.root.mainloop()
155
156
157class MainWindow(Frame, base_ui.BaseUI):
158  """The main Tk GUI class."""
159
160  def __init__(self, master, options, supplied_ns, global_ns, regional_ns, version=None):
161    """TODO(tstromberg): Remove duplication from NameBenchGui class."""
162    Frame.__init__(self)
163    self.SetupDataStructures()
164    self.master = master
165    self.options = options
166    self.supplied_ns = supplied_ns
167    self.global_ns = global_ns
168    self.regional_ns = regional_ns
169    self.version = version
170    try:
171      self.log_file = open(LOG_FILE_PATH, 'w')
172    except:
173      print 'Failed to open %s for write' % LOG_FILE_PATH
174    self.master.protocol('WM_DELETE_WINDOW', closedWindowHandler)
175
176  def UpdateStatus(self, message, count=None, total=None, error=None, debug=False):
177    """Update our little status window."""
178    if not message:
179      return None
180
181    if total:
182      state = '%s... [%s/%s]' % (message, count, total)
183    elif count:
184      state = '%s%s' % (message, '.' * count)
185    else:
186      state = message
187
188    print '> %s' % str(state)
189    try:
190      self.log_file.write('%s: %s\r\n' % (datetime.datetime.now(), state))
191      self.log_file.flush()
192    except:
193      pass
194    if not debug:
195      self.status.set(state[0:75])
196
197  def DrawWindow(self):
198    """Draws the user interface."""
199    self.nameserver_form = StringVar()
200    self.status = StringVar()
201    self.query_count = IntVar()
202    self.data_source = StringVar()
203    self.health_performance = StringVar()
204    self.location = StringVar()
205    self.use_global = IntVar()
206    self.use_regional = IntVar()
207    self.use_censor_checks = IntVar()
208    self.share_results = IntVar()
209
210    self.master.title('namebench')
211    outer_frame = Frame(self.master)
212    outer_frame.grid(row=0, padx=16, pady=16)
213    inner_frame = Frame(outer_frame, relief=GROOVE, bd=2, padx=12, pady=12)
214    inner_frame.grid(row=0, columnspan=2)
215    status = Label(outer_frame, text='...', textvariable=self.status)
216    status.grid(row=15, sticky=W, column=0)
217
218    if sys.platform[:3] == 'win':
219      seperator_width = 490
220    else:
221      seperator_width = 585
222
223    bold_font = tkFont.Font(font=status['font'])
224    bold_font['weight'] = 'bold'
225
226    ns_label = Label(inner_frame, text='Nameservers')
227    ns_label.grid(row=0, columnspan=2, sticky=W)
228    ns_label['font'] = bold_font
229
230    nameservers = Entry(inner_frame, bg='white',
231                        textvariable=self.nameserver_form,
232                        width=80)
233    nameservers.grid(row=1, columnspan=2, sticky=W, padx=4, pady=2)
234    self.nameserver_form.set(', '.join(nameserver_list.InternalNameServers()))
235
236    global_button = Checkbutton(inner_frame,
237                                text='Include global DNS providers (Google Public DNS, OpenDNS, UltraDNS, etc.)',
238                                variable=self.use_global)
239    global_button.grid(row=2, columnspan=2, sticky=W)
240    global_button.toggle()
241
242    regional_button = Checkbutton(inner_frame,
243                                  text='Include best available regional DNS services',
244                                  variable=self.use_regional)
245    regional_button.grid(row=3, columnspan=2, sticky=W)
246    regional_button.toggle()
247
248    separator = Frame(inner_frame, height=2, width=seperator_width, bd=1, relief=SUNKEN)
249    separator.grid(row=4, padx=5, pady=5, columnspan=2)
250
251    ds_label = Label(inner_frame, text='Options')
252    ds_label.grid(row=5, column=0, sticky=W)
253    ds_label['font'] = bold_font
254
255    censorship_button = Checkbutton(inner_frame, text='Include censorship checks',
256                                    variable=self.use_censor_checks)
257    censorship_button.grid(row=6, columnspan=2, sticky=W)
258
259    share_button = Checkbutton(inner_frame,
260                               text='Upload and share your anonymized results (help speed up the internet!)',
261                               variable=self.share_results)
262
263    # Old versions of Tk do not support two-dimensional padding.
264    try:
265      share_button.grid(row=7, columnspan=2, sticky=W, pady=[0,10])
266    except TclError:
267      share_button.grid(row=7, columnspan=2, sticky=W)
268
269    loc_label = Label(inner_frame, text='Your location')
270    loc_label.grid(row=10, column=0, sticky=W)
271    loc_label['font'] = bold_font
272
273    run_count_label = Label(inner_frame, text='Health Check Performance')
274    run_count_label.grid(row=10, column=1, sticky=W)
275    run_count_label['font'] = bold_font
276
277    self.DiscoverLocation()
278    self.LoadDataSources()
279    source_titles = self.data_src.ListSourceTitles()
280    left_dropdown_width = max([len(x) for x in source_titles]) - 3
281
282    location_choices = [self.country, '(Other)']
283    location = OptionMenu(inner_frame, self.location, *location_choices)
284    location.configure(width=left_dropdown_width)
285    location.grid(row=11, column=0, sticky=W)
286    self.location.set(location_choices[0])
287
288    mode_choices = ['Fast', 'Slow (unstable network)']
289    right_dropdown_width = max([len(x) for x in mode_choices]) - 3
290    health_performance = OptionMenu(inner_frame, self.health_performance, *mode_choices)
291    health_performance.configure(width=right_dropdown_width)
292    health_performance.grid(row=11, column=1, sticky=W)
293    self.health_performance.set(mode_choices[0])
294
295    ds_label = Label(inner_frame, text='Query Data Source')
296    ds_label.grid(row=12, column=0, sticky=W)
297    ds_label['font'] = bold_font
298
299    numqueries_label = Label(inner_frame, text='Number of queries')
300    numqueries_label.grid(row=12, column=1, sticky=W)
301    numqueries_label['font'] = bold_font
302
303    data_source = OptionMenu(inner_frame, self.data_source, *source_titles)
304    data_source.configure(width=left_dropdown_width)
305    data_source.grid(row=13, column=0, sticky=W)
306    self.data_source.set(source_titles[0])
307
308    query_count = Entry(inner_frame, bg='white', textvariable=self.query_count)
309    query_count.grid(row=13, column=1, sticky=W, padx=4)
310    query_count.configure(width=right_dropdown_width + 6)
311    self.query_count.set(self.options.query_count)
312
313    self.button = Button(outer_frame, command=self.StartJob)
314    self.button.grid(row=15, sticky=E, column=1, pady=4, padx=1)
315    self.UpdateRunState(running=True)
316    self.UpdateRunState(running=False)
317    self.UpdateStatus('namebench %s is ready!' % self.version)
318
319  def MessageHandler(self, unused_event):
320    """Pinged when there is a new message in our queue to handle."""
321    while global_message_queue.qsize():
322      m = global_message_queue.get()
323      if m.error:
324        self.ErrorPopup(m.message, m.error)
325      elif m.enable_button == False:
326        self.UpdateRunState(running=True)
327      elif m.enable_button == True:
328        self.UpdateRunState(running=False)
329      self.UpdateStatus(m.message, count=m.count, total=m.total, error=m.error, debug=m.debug)
330
331  def ErrorPopup(self, title, message):
332    print 'Showing popup: %s' % title
333    tkMessageBox.showerror(str(title), str(message), master=self.master)
334
335  def UpdateRunState(self, running=True):
336    """Update the run state of the window, using nasty threading hacks."""
337
338    global THREAD_UNSAFE_TK
339    # try/except blocks added to work around broken Tcl/Tk libraries
340    # shipped with Fedora 11 (not thread-safe).
341    # See http://code.google.com/p/namebench/issues/detail?id=23'
342    if THREAD_UNSAFE_TK:
343      return
344
345    if running:
346      try:
347        self.button.config(state=DISABLED)
348        self.button.config(text='Running')
349      except TclError:
350        THREAD_UNSAFE_TK = True
351        self.UpdateStatus('Unable to disable button due to broken Tk library')
352      self.UpdateStatus('Running...')
353    else:
354      try:
355        self.button.config(state=NORMAL)
356        self.button.config(text='Start Benchmark')
357      except TclError:
358        pass
359
360  def StartJob(self):
361    """Events that get called when the Start button is pressed."""
362
363    self.ProcessForm()
364    thread = WorkerThread(self.supplied_ns, self.global_ns, self.regional_ns, self.options,
365                          data_source=self.data_src,
366                          master=self.master, backup_notifier=self.MessageHandler)
367    thread.start()
368
369  def ProcessForm(self):
370    """Read form and populate instance variables."""
371
372    self.supplied_ns = addr_util.ExtractIPTuplesFromString(self.nameserver_form.get())
373    if not self.use_global.get():
374      self.global_ns = []
375    if not self.use_regional.get():
376      self.regional_ns = []
377
378    if 'Slow' in self.health_performance.get():
379      self.options.health_thread_count = 10
380
381    self.options.query_count = self.query_count.get()
382    self.options.input_source = self.data_src.ConvertSourceTitleToType(self.data_source.get())
383    self.options.enable_censorship_checks = self.use_censor_checks.get()
384    self.options.upload_results = self.share_results.get()
385