Basic uploading and attribute configs work.
diff --git a/app.py b/app.py
index 1649113..9a87349 100644
--- a/app.py
+++ b/app.py
@@ -1,22 +1,50 @@
 #!/usr/bin/env python
+import collections
 import json
 import os.path
 import random
 import re
 import sys
+import traceback
 import urllib
 import wsgiref.handlers
 import wsgiref.simple_server
 import webapp2
 import tornado.template
+from google.appengine.api import memcache
+from google.appengine.api import users
 from google.appengine.ext import blobstore
+from google.appengine.ext import ndb
 from google.appengine.ext.webapp import blobstore_handlers
+from google.appengine.ext.webapp import util
 import wifipacket
 
 
+BROADCAST = 'ff:ff:ff:ff:ff:ff'
+
 loader = tornado.template.Loader('.')
 
 
+def GoogleLoginRequired(func):
+  def Handler(self, *args, **kwargs):
+    user = users.get_current_user()
+    if not user:
+      self.redirect(users.create_login_url('/'))
+    elif not user.email().endswith('@google.com'):
+      self.response.set_status(401, 'Unauthorized')
+      self.response.write("Sorry.  You're not an authorized user.")
+    else:
+      return func(self, *args, **kwargs)
+  return Handler
+
+
+class PcapData(ndb.Model):
+  create_time = ndb.DateTimeProperty(auto_now_add=True)
+  filename = ndb.StringProperty()
+  show_hosts = ndb.StringProperty(repeated=True)
+  aliases = ndb.PickleProperty()
+
+
 class _BaseHandler(webapp2.RequestHandler):
   def render(self, template, **kwargs):
     d = dict()
@@ -25,9 +53,13 @@
 
 
 class MainHandler(_BaseHandler):
+  @GoogleLoginRequired
   def get(self):
     upload_url = blobstore.create_upload_url('/upload')
-    self.render('index.html', upload_url=upload_url)
+    q = PcapData.query().order(-PcapData.create_time).fetch(10)
+    self.render('index.html',
+                upload_url=upload_url,
+                recents=q)
 
 
 class UploadHandler(blobstore_handlers.BlobstoreUploadHandler):
@@ -38,13 +70,81 @@
     self.redirect('/view/%s' % blob_info.key())
 
 
-class ViewHandler(_BaseHandler):
-  def get(self, blobres):
-    blobres = str(urllib.unquote(blobres))
-    blob_info = blobstore.BlobInfo.get(blobres)
+def _Boxes(blob_info):
+  boxes = memcache.get(str(blob_info.key()), namespace='boxes')
+  if not boxes:
     reader = blob_info.open()
-    packets = wifipacket.Packetize(reader)
-    self.render('view.html', blob=blob_info, packets=packets)
+    boxes = collections.defaultdict(lambda: 0)
+    for p, frame in wifipacket.Packetize(reader):
+      if 'flags' in p and p.flags & wifipacket.Flags.BAD_FCS: continue
+      if 'ta' in p and 'ra' in p:
+        boxes[p.ta] += 1
+        boxes[p.ra] += 1
+    memcache.add(key=str(blob_info.key()), value=dict(boxes),
+                 namespace='boxes')
+  return boxes
+
+
+class ViewHandler(_BaseHandler):
+  @GoogleLoginRequired
+  def get(self, blobres):
+    blob_info = blobstore.BlobInfo.get(str(urllib.unquote(blobres)))
+    capdefault = PcapData.get_or_insert(str('*'), show_hosts=[], aliases={})
+    pcapdata = PcapData.get_or_insert(str(blob_info.key()),
+                                      filename=blob_info.filename,
+                                      show_hosts=[], aliases={})
+    try:
+      boxes = _Boxes(blob_info)
+    except ValueError as e:
+      self.response.set_status(500, 'Server error')
+      self.response.write('<pre>%s</pre>' % traceback.format_exc())
+      return
+
+    cutoff = max(boxes.itervalues()) * 0.01
+    cutboxes = [(b, n)
+                for b, n
+                in sorted(boxes.iteritems(), key=lambda x: -x[1])
+                if n >= cutoff and b != BROADCAST]
+    other = sum((n for n in boxes.itervalues() if n < cutoff))
+    aliases = pcapdata.aliases
+    if pcapdata.show_hosts:
+      checked = dict((h, 1) for h in pcapdata.show_hosts)
+    else:
+      checked = {}
+      for b, n in cutboxes:
+        checked[b] = (n > cutoff * 10)
+    for b in boxes.keys():
+      if b not in aliases:
+        aliases[b] = capdefault.aliases.get(b, b)
+    self.render('view.html',
+                blob=blob_info,
+                boxes=cutboxes,
+                other=other,
+                aliases=aliases,
+                checked=checked)
+
+
+class SaveHandler(_BaseHandler):
+  @GoogleLoginRequired
+  def post(self, blobres):
+    blob_info = blobstore.BlobInfo.get(str(urllib.unquote(blobres)))
+    capdefault = PcapData.get_or_insert(str('*'), show_hosts=[], aliases={})
+    pcapdata = PcapData.get_or_insert(str(blob_info.key()),
+                                      show_hosts=[], aliases={})
+    boxes = _Boxes(blob_info)
+    pcapdata.show_hosts = []
+    for b in boxes.keys():
+      alias = self.request.get('name-%s' % b)
+      if alias:
+        pcapdata.aliases[b] = alias
+        capdefault.aliases[b] = alias
+      else:
+        pcapdata.aliases[b] = b
+      if self.request.get('show-%s' % b):
+        pcapdata.show_hosts.append(b)
+    capdefault.put()
+    pcapdata.put()
+    self.response.write('done')
 
 
 settings = dict(
@@ -55,4 +155,5 @@
     (r'/', MainHandler),
     (r'/upload', UploadHandler),
     (r'/view/([^/]+)$', ViewHandler),
+    (r'/save/([^/]+)$', SaveHandler),
 ], **settings)
diff --git a/index.html b/index.html
index ee5f0e4..f7f72a5 100644
--- a/index.html
+++ b/index.html
@@ -1,12 +1,33 @@
 <html>
+<head>
+  <style>
+  * {
+    font-family: monospace;
+  }
+  td {
+    padding-right: 1em;
+    padding-left: 1em;
+  }
+  </style>
+</head>
+
 <body>
 {% block body %}
-  This is text.
+  <div style='float: right; border: 1px solid black; padding: 1em;'>
+  Upload a wifi packet capture!<p>
 
   <form action="{{upload_url}}" method="POST" enctype="multipart/form-data">
     <input name="capfile" type="file" />
-    <input type="submit" />
+    <input type="submit" value="Upload" />
   </form>
+  </div>
+
+  Recent uploads:<ul>
+    {% for r in recents %}
+      <li>{{r.create_time}}
+          <a href="/view/{{r.key.id()}}">{{r.filename}}</a></li>
+    {% end %}
+  </ul>
 {% end %}
 </body>
 </html>
diff --git a/view.html b/view.html
index 068275c..ec8003b 100644
--- a/view.html
+++ b/view.html
@@ -3,12 +3,33 @@
 {% block body %}
   <a href='/'>&larr; Index</a><p>
  
-  This is viewer.<p>
+  Filename: {{blob.filename}}<p>
+  Size: {{'%.2f' % (blob.size/1e6)}} Mbytes<p>
 
-  filename: {{blob.filename}}<p>
-  size: {{blob.size}}<p>
-
-  {% for p, frame in packets %}
-    {{p.get('ta')}}<br>
-  {% end %}
+  <form action="/save/{{blob.key()}}" method="POST">
+  <table>
+    <tr>
+      <th>Show?</th>
+      <th>Pkts</th>
+      <th>MAC Addr</th>
+      <th>Alias</th>
+    </tr>
+    {% for b, n in boxes %}
+    <tr>
+      <td><input type='checkbox' name='show-{{b}}'
+             {{'checked' if checked.get(b) else ''}} /></td>
+      <td>{{n}}</td>
+      <td>{{b}}</td>
+      <td><input name='name-{{b}}' value='{{aliases.get(b, "")}}' /></td>
+    </tr>
+    {% end %}
+    <tr>
+      <td><input type='checkbox' name='show-other' /></td>
+      <td>{{other}}</td>
+      <td>(other)</td>
+      <td></td>
+    </tr>
+  </table>
+  <input type='submit' value='Save and View' style='margin: 1em' />
+  </form>
 {% end %}