changeset 675:9ae34280218b

kind of running with lit-element and polymer together. lots of data missing from table still Ignore-this: db24e7b633929b01430b0794c1a065dc
author drewp@bigasterisk.com
date Sun, 05 Jan 2020 23:18:27 -0800
parents 2b9865bf1737
children 4bd77a9548d6
files service/wifi/index.html service/wifi/package-lock.json service/wifi/package.json service/wifi/src/index.ts service/wifi/src/style.ts service/wifi/src/wifi-table.ts service/wifi/tsconfig.json
diffstat 7 files changed, 479 insertions(+), 116 deletions(-) [+]
line wrap: on
line diff
--- a/service/wifi/index.html	Thu Jan 02 00:23:36 2020 -0800
+++ b/service/wifi/index.html	Sun Jan 05 23:18:27 2020 -0800
@@ -9,7 +9,6 @@
   </head>
   <link rel="stylesheet" href="/rdf/streamed-graph.css" />
   <body class="rdfBrowsePage">
-    <hr />
     <dom-bind>
       <template>
         <style>
@@ -25,10 +24,8 @@
           n="network"
           static="['demo.n3']"
           graph="{{graph}}"
-          expanded="{{e}}"
         ></streamed-graph>
-        g={{graph.version}} e={{e}}
-        <wifi-display graph="{{graph}}" show-groups="true"></wifi-display>
+        <wifi-display graph="[[g2]]" show-groups="true"></wifi-display>
       </template>
     </dom-bind>
     <form method="POST" action="remoteSuspend">
--- a/service/wifi/package-lock.json	Thu Jan 02 00:23:36 2020 -0800
+++ b/service/wifi/package-lock.json	Sun Jan 05 23:18:27 2020 -0800
@@ -5863,6 +5863,14 @@
         "type-check": "~0.3.2"
       }
     },
+    "lit-element": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-2.2.1.tgz",
+      "integrity": "sha512-ipDcgQ1EpW6Va2Z6dWm79jYdimVepO5GL0eYkZrFvdr0OD/1N260Q9DH+K5HXHFrRoC7dOg+ZpED2XE0TgGdXw==",
+      "requires": {
+        "lit-html": "^1.0.0"
+      }
+    },
     "lit-html": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-1.1.2.tgz",
--- a/service/wifi/package.json	Thu Jan 02 00:23:36 2020 -0800
+++ b/service/wifi/package.json	Sun Jan 05 23:18:27 2020 -0800
@@ -16,6 +16,7 @@
     "@polymer/decorators": "^3.0.0",
     "@polymer/polymer": "^3.3.1",
     "@types/n3": "^1.1.1",
+    "lit-element": "^2.2.1",
     "lit-html": "^1.1.2",
     "n3": "^1.3.5",
     "streamed-graph": "file:/my/proj/streamed-graph"
--- a/service/wifi/src/index.ts	Thu Jan 02 00:23:36 2020 -0800
+++ b/service/wifi/src/index.ts	Sun Jan 05 23:18:27 2020 -0800
@@ -1,37 +1,281 @@
-import { PolymerElement, html } from "@polymer/polymer";
-import {
-  customElement,
-  property,
-  computed,
-  observe,
-} from "@polymer/decorators";
-import { N3Store } from "n3";
-//import {* as wt} from './wifi-table';
-import { VersionedGraph, StreamedGraph } from "streamed-graph";
+// for the web page
 export { DomBind } from "@polymer/polymer/lib/elements/dom-bind.js";
 
-console.log("here is a real dependency on ", StreamedGraph.name);
+import { LitElement, property, html, customElement } from "lit-element";
+
+import { Literal, Term, N3Store, Util } from "n3";
+import { NamedNode, DataFactory } from "n3";
+const { literal, quad, namedNode } = DataFactory;
+
+import { VersionedGraph } from "streamed-graph";
+export { StreamedGraph } from "streamed-graph";
+import { style } from "./style";
+
+interface DevGroup {
+  connectedToAp: NamedNode;
+  wifiBand: NamedNode;
+  devs: Array<Dev>;
+}
+interface Dev {
+  agoMin: number | undefined;
+  ipAddress: Literal;
+  dhcpHostname: string;
+  macAddress: Literal;
+  packetsPerSec: string; //number; todo
+  bytesPerSec: string; //number;
+}
+
+// workaround for uris that don't have good labels in the graph
+function labelFromUri(
+  uri: NamedNode,
+  prefix: string,
+  tailsToLabels: any,
+  defaultLabel: string
+) {
+  let label = defaultLabel === undefined ? uri.value : defaultLabel;
+  Object.entries(tailsToLabels).forEach(([tail, useLabel]) => {
+    if (uri.equals(namedNode(prefix + tail))) {
+      label = useLabel as string;
+    }
+  });
+  return label;
+}
 
 @customElement("wifi-display")
-class WifiDisplay extends PolymerElement {
-  @property({ type: Object, observer: WifiDisplay.prototype.onGraphChanged})
+class WifiDisplay extends LitElement {
+  static get styles() {
+    return [style];
+  }
+
+  @property({
+    type: Object,
+    //    observer: WifiDisplay.prototype.onGraphChanged,
+  })
   graph!: VersionedGraph;
 
+  connectedCallback() {
+    super.connectedCallback();
+    console.log("wifidisplay connected");
+    const sg = this.ownerDocument!.querySelector("streamed-graph");
+    sg?.addEventListener("graph-changed", ((ev: CustomEvent) => {
+      this.graph = ev.detail!.value as VersionedGraph;
+      console.log("wifidisplay got new graph", this.graph);
+    }) as EventListener);
+  }
 
-  onGraphChanged() {
-    console.log("new graph", this.graph);
+  static get observers() {
+    return ["onGraphChanged(graph)"];
+  }
+
+  @property({ type: Object }) //no longer a prop?
+  grouped: Map<string, DevGroup> = new Map();
+
+  render() {
+    const grouped = this.graphView(this.graph.store!, false);
+
+    return html`
+      <div class="report">
+        report at graph version ${this.graph.version} grouped:
+        ${Array.from(grouped.entries()).length}
+        <table>
+          ${Array.from(grouped.entries()).map((row: [string, DevGroup]) => {
+            return this.renderGroup(row[0], row[1]);
+          })}
+        </table>
+      </div>
+    `;
   }
 
-  ready() {
-    super.ready();
+  onGraphChanged(val: VersionedGraph, old: VersionedGraph) {
+    console.log("new graph value", this.graph);
+    this.grouped = this.graphView((val as VersionedGraph).store!, false);
+  }
+
+  renderDevice(dev: Dev) {
+    let agoReport = "";
+    if (dev.agoMin === undefined) {
+      agoReport = "unknown";
+    } else {
+      const glow = Math.max(0, 1 - dev.agoMin! / 60);
+      agoReport =
+        dev.agoMin! < 360
+          ? ` (${Math.ceil(dev.agoMin! * 10) / 10} minutes ago)`
+          : "";
+    }
+    const glow = ""; //todo
+    return html`
+      <div class="dev" style="background: rgba(185, 5, 138, ${glow});">
+        <span class="mac">${dev.macAddress && dev.macAddress.value}</span>
+        <span class="ip"
+          ><a href="http://${dev.ipAddress && dev.ipAddress.value}/"
+            >${dev.ipAddress && dev.ipAddress.value}</a
+          ></span
+        >
+        <span class="packets">${dev.packetsPerSec}</span>
+        <span class="bytes">${dev.bytesPerSec}</span>
+        <span class="hostname">${dev.dhcpHostname}</span>
+        <span class="ago">${agoReport}</span>
+        <span class="links">
+          <a
+            href="https://bigasterisk.com/ntop/lua/host_details.lua?ifid=17&amp;host=${dev.ipAddress &&
+              dev.ipAddress.value}&amp;page=flows"
+            >[flows]</a
+          >
+        </span>
+      </div>
+    `;
   }
 
-  // redraw() {
-  //     wt.render(this.graph.graph);
-  // }
-  static get template() {
+  renderGroup(key: string, group: DevGroup) {
+    let label;
+    if (key != "all") {
+      label = labelFromUri(
+        group.connectedToAp,
+        "http://bigasterisk.com/mac/",
+        {
+          "a0:40:a0:6f:96:d5": "Main router (d5)",
+          "8c:3b:ad:c4:8d:ce": "Downstairs satellite (ce)",
+          "a0:40:a0:6f:aa:f8": "Upstairs satellite (f8)",
+        },
+        "unknown"
+      );
+
+      label += labelFromUri(
+        group.wifiBand,
+        "http://projects.bigasterisk.com/room/wifiBand/",
+        {
+          "5G": " 5G",
+          "2.4G": " 2.4G",
+        },
+        "unknown"
+      );
+    }
+
+    const devs = group.devs;
+    function padIp(ip: string) {
+      return ip.replace(/(\d+)/g, m => ("00" + m).slice(-3));
+    }
+    devs.sort((a, b) => {
+      return 0; //todo
+      return padIp(a.ipAddress.value) > padIp(b.ipAddress.value) ? 1 : -1;
+    });
     return html`
-      here
+      <tr>
+        <th>${label}</th>
+        <td>
+          <div>Devices:</div>
+          ${devs.map(d => {
+            return this.renderDevice(d);
+          })}
+        </td>
+      </tr>
     `;
   }
+
+  graphView(store: N3Store, showGroups: boolean): Map<string, DevGroup> {
+    const grouped: Map<string, DevGroup> = new Map();
+    const room = "http://projects.bigasterisk.com/room/";
+    store.forEach(
+      q => {
+        const devUri: NamedNode = q.subject as NamedNode;
+
+        const graphLiteral = (
+          store: N3Store,
+          devUri: NamedNode,
+          pred: string,
+          notFoundResult?: string
+        ): Literal => {
+          const keep: Array<Literal> = [];
+          store.forEach(
+            q => {
+              if (!Util.isLiteral(q.object)) {
+                throw new Error("non literal found");
+              }
+              keep.push(q.object as Literal);
+            },
+            devUri,
+            namedNode(pred),
+            null,
+            null
+          );
+          if (keep.length == 0) {
+            return literal(notFoundResult || "(missing)");
+          }
+          if (keep.length == 1) {
+            return keep[0];
+          }
+          throw new Error("found multiple matches for pred");
+        };
+
+        const getAgoMin = (
+          connected: Literal | undefined
+        ): number | undefined => {
+          if (connected) {
+            const t = new Date(connected.value);
+            const agoMs = Date.now() - t.valueOf();
+            return agoMs / 1000 / 60;
+          }
+          return undefined;
+        };
+
+        const asString = (x: Literal | undefined): string => {
+          if (x && x.value) {
+            return x.value;
+          }
+          return "(unknown)";
+        };
+        const row: Dev = {
+          agoMin: getAgoMin(
+            literal("2020-01-01") //  graphLiteral(store, devUri, room + "connectedToAp")
+          ),
+          ipAddress: graphLiteral(
+            store,
+            devUri,
+            room + "ipAddress",
+            "(unknown)"
+          ),
+          dhcpHostname: asString(
+            graphLiteral(store, devUri, room + "dhcpHostname")
+          ),
+          macAddress: graphLiteral(store, devUri, room + "macAddress"),
+          packetsPerSec:
+            graphLiteral(store, devUri, room + "packetsPerSec", "? ").value +
+            " P/s", //number; todo
+          bytesPerSec:
+            graphLiteral(store, devUri, room + "bytesPerSec", "? ").value +
+            "B/s", //number;
+        };
+        if (row.dhcpHostname && (row.dhcpHostname as any).value) {
+          row.dhcpHostname = (row.dhcpHostname as any).value;
+        }
+        if (!showGroups || (row as any).connectedToAp) {
+          const key = showGroups
+            ? `${(row as any).connectedToAp.toNT()}-${(row as any).wifiBand.toNT()}`
+            : "all";
+          if (!grouped.has(key)) {
+            grouped.set(key, {
+              connectedToAp: (row as any).connectedToAp,
+              wifiBand: (row as any).wifiBand,
+              devs: [],
+            });
+          }
+          grouped.get(key)!.devs.push(row);
+        } else {
+          console.log("lost row", row);
+        }
+
+        if (row.bytesPerSec) {
+          row.bytesPerSec = row.bytesPerSec.valueOf() + " B/s";
+        }
+        if (row.packetsPerSec) {
+          row.packetsPerSec = row.packetsPerSec.valueOf() + " p/s";
+        }
+      },
+      null,
+      namedNode("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"),
+      namedNode(room + "NetworkedDevice"),
+      null
+    );
+    return grouped;
+  }
 }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/service/wifi/src/style.ts	Sun Jan 05 23:18:27 2020 -0800
@@ -0,0 +1,51 @@
+import { css } from "lit-element";
+
+export const style = css`
+  .report {
+    font-family: sans-serif;
+  }
+  section {
+    margin-left: 1em;
+  }
+  .dev {
+    margin-bottom: 2px;
+  }
+  .ip,
+  .mac,
+  .packets,
+  .bytes {
+    display: inline-block;
+    font-family: monospace;
+  }
+  .packets,
+  .bytes {
+    text-align: right;
+    padding-right: 1em;
+  }
+  .mac {
+    color: #ccffcc;
+    width: 11em;
+  }
+  .ip {
+    color: #b5b5d4;
+    width: 6em;
+  }
+  .packets {
+    color: #2da1a5;
+    width: 6em;
+  }
+  .bytes {
+    color: #a5912d;
+    width: 9em;
+  }
+  th,
+  td {
+    vertical-align: top;
+  }
+  th {
+    background: #333;
+  }
+  td {
+    background: #252525;
+  }
+`;
--- a/service/wifi/src/wifi-table.ts	Thu Jan 02 00:23:36 2020 -0800
+++ b/service/wifi/src/wifi-table.ts	Sun Jan 05 23:18:27 2020 -0800
@@ -1,49 +1,51 @@
-import { html } from 'lit-html';
-import { NamedNode, DataFactory } from 'n3';
+import { html } from "lit-html";
+import { NamedNode, DataFactory } from "n3";
 const { literal, quad, namedNode } = DataFactory;
-import * as sgmod from 'streamed-graph';
-import { Literal, Term, N3Store } from 'n3';
+import * as sgmod from "streamed-graph";
+import { Literal, Term, N3Store } from "n3";
 
 interface DevGroup {
-  connectedToAp: NamedNode,
-  wifiBand: NamedNode,
-  devs: Array<Dev>
+  connectedToAp: NamedNode;
+  wifiBand: NamedNode;
+  devs: Array<Dev>;
 }
 interface Dev {
-  agoMin: number,
-  ipAddress: Literal,
-  dhcpHostname: string,
-  macAddress: Literal,
-  packetsPerSec: number,
-  bytesPerSec: number
+  agoMin: number;
+  ipAddress: Literal;
+  dhcpHostname: string;
+  macAddress: Literal;
+  packetsPerSec: number;
+  bytesPerSec: number;
 }
 
-console.log('got', sgmod);
+console.log("got", sgmod);
 
 const NS: any = {
-  room: 'http://projects.bigasterisk.com/room/'
-}
+  room: "http://projects.bigasterisk.com/room/",
+};
 
 // from rdf-uri.html
 const BigastUri = {
   // not well defined for uri prefixes that are string prefixes of each other
-  compactUri: function (uri: string) {
+  compactUri: function(uri: string) {
     if (uri === undefined) {
       return uri;
     }
-    if (typeof (uri) == "object") { throw new Error("type"); }
+    if (typeof uri == "object") {
+      throw new Error("type");
+    }
     if (uri == "http://www.w3.org/1999/02/22-rdf-syntax-ns#type") {
       return "a";
     }
     for (var short of Object.keys(NS as any)) {
       var prefix = NS[short];
       if (uri.indexOf(prefix) == 0) {
-        return short + ':' + uri.substr(prefix.length);
+        return short + ":" + uri.substr(prefix.length);
       }
     }
     return uri;
   },
-  expandUri: function (s: string) {
+  expandUri: function(s: string) {
     for (var short of Object.keys(NS)) {
       var prefix = NS[short];
       if (s.indexOf(short + ":") == 0) {
@@ -55,22 +57,39 @@
 };
 
 // workaround for uris that don't have good labels in the graph
-function labelFromUri(uri: NamedNode, prefix: string, tailsToLabels: any, defaultLabel: string) {
+function labelFromUri(
+  uri: NamedNode,
+  prefix: string,
+  tailsToLabels: any,
+  defaultLabel: string
+) {
   let label = defaultLabel === undefined ? uri.value : defaultLabel;
   Object.entries(tailsToLabels).forEach(([tail, useLabel]) => {
     if (uri.equals(namedNode(prefix + tail))) {
       label = useLabel as string;
     }
   });
-  return label
+  return label;
 }
 
 // set out[suffix] = graph.get(subj, predPrefix+suffix) for all suffixes
-function getProperties(store: N3Store, out: any, subject: Term, predPrefix: string, predSuffixes: Array<string>) {
-  predSuffixes.forEach((term) => {
-    store.forEach((q) => {
-      out[term] = q.object;
-    }, subject, namedNode(predPrefix + term), null, null);
+function getProperties(
+  store: N3Store,
+  out: any,
+  subject: Term,
+  predPrefix: string,
+  predSuffixes: Array<string>
+) {
+  predSuffixes.forEach(term => {
+    store.forEach(
+      q => {
+        out[term] = q.object;
+      },
+      subject,
+      namedNode(predPrefix + term),
+      null,
+      null
+    );
 
     return out;
   });
@@ -78,84 +97,127 @@
 
 function graphView(store: N3Store, showGroups: boolean): void {
   const grouped: Map<string, DevGroup> = new Map();
-  store.forEach((q) => {
-    const row: any = { uri: q.subject };
+  store.forEach(
+    q => {
+      const row: any = { uri: q.subject };
 
-    getProperties(store, row, row.uri, 'room:',
-      ['dhcpHostname', 'ipAddress', 'macAddress', 'connectedToAp', 'wifiBand', 'connected', 'bytesPerSec', 'packetsPerSec']);
-    if (row.dhcpHostname && row.dhcpHostname.value) {
-      row.dhcpHostname = row.dhcpHostname.value;
-    }
-    if (!showGroups || row.connectedToAp) {
-      const key = (showGroups ? `${row.connectedToAp.toNT()}-${row.wifiBand.toNT()}` : 'all');
-      if (!grouped.has(key)) {
-        grouped.set(key, { connectedToAp: row.connectedToAp, wifiBand: row.wifiBand, devs: [] });
+      getProperties(store, row, row.uri, "room:", [
+        "dhcpHostname",
+        "ipAddress",
+        "macAddress",
+        "connectedToAp",
+        "wifiBand",
+        "connected",
+        "bytesPerSec",
+        "packetsPerSec",
+      ]);
+      if (row.dhcpHostname && row.dhcpHostname.value) {
+        row.dhcpHostname = row.dhcpHostname.value;
       }
-      grouped.get(key)!.devs.push(row);
-    } else {
-      console.log('lost row', row);
-    }
-    if (row.connected) {
-      const t = new Date(row.connected.value);
-      const agoMs = (Date.now() as number) - (t as unknown as number);
-      row.agoMin = agoMs / 1000 / 60;
-    }
-    if (row.bytesPerSec) { row.bytesPerSec = row.bytesPerSec.valueOf() + ' B/s'; }
-    if (row.packetsPerSec) { row.packetsPerSec = row.packetsPerSec.valueOf() + ' p/s'; }
-  }, null, namedNode('rdf:type'), namedNode('room:NetworkedDevice'), null);
+      if (!showGroups || row.connectedToAp) {
+        const key = showGroups
+          ? `${row.connectedToAp.toNT()}-${row.wifiBand.toNT()}`
+          : "all";
+        if (!grouped.has(key)) {
+          grouped.set(key, {
+            connectedToAp: row.connectedToAp,
+            wifiBand: row.wifiBand,
+            devs: [],
+          });
+        }
+        grouped.get(key)!.devs.push(row);
+      } else {
+        console.log("lost row", row);
+      }
+      if (row.connected) {
+        const t = new Date(row.connected.value);
+        const agoMs = (Date.now() as number) - ((t as unknown) as number);
+        row.agoMin = agoMs / 1000 / 60;
+      }
+      if (row.bytesPerSec) {
+        row.bytesPerSec = row.bytesPerSec.valueOf() + " B/s";
+      }
+      if (row.packetsPerSec) {
+        row.packetsPerSec = row.packetsPerSec.valueOf() + " p/s";
+      }
+    },
+    null,
+    namedNode("rdf:type"),
+    namedNode("room:NetworkedDevice"),
+    null
+  );
 }
 
 const renderDevice = (dev: Dev) => {
   const glow = Math.max(0, 1 - dev.agoMin / 60);
-  const agoReport = dev.agoMin < 360 ? ` (${Math.ceil(dev.agoMin * 10) / 10} minutes ago)` : '';
+  const agoReport =
+    dev.agoMin < 360 ? ` (${Math.ceil(dev.agoMin * 10) / 10} minutes ago)` : "";
   return html`
-      <div class="dev" style="background: rgba(185, 5, 138, ${glow});">
-        <span class="mac">${dev.macAddress.value}</span>
-        <span class="ip"><a href="http://${dev.ipAddress.value}/">${dev.ipAddress.value}</a></span>
-        <span class="packets">${dev.packetsPerSec}</span>
-        <span class="bytes">${dev.bytesPerSec}</span>
-        <span class="hostname">${dev.dhcpHostname}</span>
-        <span class="ago">${agoReport}</span>
-        <span class="links">
-          <a href="https://bigasterisk.com/ntop/lua/host_details.lua?ifid=17&amp;host=${dev.ipAddress.value}&amp;page=flows">[flows]</a>
-        </span>
-      </div>
-    `;
+    <div class="dev" style="background: rgba(185, 5, 138, ${glow});">
+      <span class="mac">${dev.macAddress.value}</span>
+      <span class="ip"
+        ><a href="http://${dev.ipAddress.value}/"
+          >${dev.ipAddress.value}</a
+        ></span
+      >
+      <span class="packets">${dev.packetsPerSec}</span>
+      <span class="bytes">${dev.bytesPerSec}</span>
+      <span class="hostname">${dev.dhcpHostname}</span>
+      <span class="ago">${agoReport}</span>
+      <span class="links">
+        <a
+          href="https://bigasterisk.com/ntop/lua/host_details.lua?ifid=17&amp;host=${dev
+            .ipAddress.value}&amp;page=flows"
+          >[flows]</a
+        >
+      </span>
+    </div>
+  `;
 };
 
 const renderGroup = (key: string, group: DevGroup) => {
   let label;
-  if (key != 'all') {
-    label = labelFromUri(group.connectedToAp,
-      'http://bigasterisk.com/mac/',
+  if (key != "all") {
+    label = labelFromUri(
+      group.connectedToAp,
+      "http://bigasterisk.com/mac/",
       {
-        'a0:40:a0:6f:96:d5': "Main router (d5)",
-        '8c:3b:ad:c4:8d:ce': "Downstairs satellite (ce)",
-        'a0:40:a0:6f:aa:f8': "Upstairs satellite (f8)",
+        "a0:40:a0:6f:96:d5": "Main router (d5)",
+        "8c:3b:ad:c4:8d:ce": "Downstairs satellite (ce)",
+        "a0:40:a0:6f:aa:f8": "Upstairs satellite (f8)",
       },
-      "unknown");
+      "unknown"
+    );
 
-    label += labelFromUri(group.wifiBand,
-      'http://projects.bigasterisk.com/room/wifiBand/',
+    label += labelFromUri(
+      group.wifiBand,
+      "http://projects.bigasterisk.com/room/wifiBand/",
       {
-        '5G': ' 5G',
-        '2.4G': ' 2.4G',
+        "5G": " 5G",
+        "2.4G": " 2.4G",
       },
-      "unknown");
+      "unknown"
+    );
   }
 
   const devs = group.devs;
-  function padIp(ip: string) { return ip.replace(/(\d+)/g, (m) => ('00' + m).slice(-3)); }
-  devs.sort((a, b) => { return padIp(a.ipAddress.value) > padIp(b.ipAddress.value) ? 1 : -1; });
+  function padIp(ip: string) {
+    return ip.replace(/(\d+)/g, m => ("00" + m).slice(-3));
+  }
+  devs.sort((a, b) => {
+    return padIp(a.ipAddress.value) > padIp(b.ipAddress.value) ? 1 : -1;
+  });
   return html`
-      <tr>
-        <th>${label}</th>
-        <td>
-          <div>Devices:</div>
-          ${devs.map((d) => { return renderDevice(d); })}
-        </td>
-      </tr>
-      `;
+    <tr>
+      <th>${label}</th>
+      <td>
+        <div>Devices:</div>
+        ${devs.map(d => {
+          return renderDevice(d);
+        })}
+      </td>
+    </tr>
+  `;
   /*
 let groups=['?'];
 const out = html`
@@ -189,5 +251,5 @@
 </div>
 `;
 return out;*/
-}
-export { graphView }
+};
+export { graphView };
--- a/service/wifi/tsconfig.json	Thu Jan 02 00:23:36 2020 -0800
+++ b/service/wifi/tsconfig.json	Sun Jan 05 23:18:27 2020 -0800
@@ -25,10 +25,10 @@
     // "outDir": "./build",
     "baseUrl": ".",
     // "emitDecoratorMetadata": true,
-    // "lib": [ "es6", "dom" ],
+   // "lib": [ "dom" ],
 
-    "diagnostics": true,
-    "traceResolution": true
+    // "diagnostics": true,
+    // "traceResolution": true
 
   },
   "include": ["src/**/*.ts"],