From f0daa9371ec0a19c419f09706698146ef6681500 Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Wed, 3 May 2023 12:42:56 -0400
Subject: [PATCH 01/49] initial layout and design tweaks (#234)

---
 ui/src/console/Console.js            | 26 +++++++++++++++---------
 ui/src/console/visualizer/Network.js | 30 ++++------------------------
 ui/src/console/visualizer/graph.js   |  4 ++--
 ui/src/index.css                     | 11 +++++++++-
 4 files changed, 33 insertions(+), 38 deletions(-)

diff --git a/ui/src/console/Console.js b/ui/src/console/Console.js
index ba8df307..5dae41cd 100644
--- a/ui/src/console/Console.js
+++ b/ui/src/console/Console.js
@@ -1,4 +1,4 @@
-import {Container, Nav, Navbar, NavDropdown} from "react-bootstrap";
+import {Col, Container, Nav, Navbar, NavDropdown, Row} from "react-bootstrap";
 import {useEffect, useState} from "react";
 import Visualizer from "./visualizer/Visualizer";
 import Enable from "./modals/Enable";
@@ -64,14 +64,22 @@ const Console = (props) => {
                     </Navbar.Collapse>
                 </Container>
             </Navbar>
-            <Visualizer
-                user={props.user}
-                overview={overview}
-                defaultSelection={defaultSelection}
-                selection={selection}
-                setSelection={setSelection}
-            />
-            <Detail user={props.user} selection={selection} />
+            <Container fluid={"xl"}>
+                <Row id={"controls-row"}>
+                    <Col>
+                        <Visualizer
+                            user={props.user}
+                            overview={overview}
+                            defaultSelection={defaultSelection}
+                            selection={selection}
+                            setSelection={setSelection}
+                        />
+                    </Col>
+                    <Col>
+                        <Detail user={props.user} selection={selection} />
+                    </Col>
+                </Row>
+            </Container>
             <Enable show={showEnableModal} onHide={closeEnableModal} token={props.user.token} />
             <Version show={showVersionModal} onHide={closeVersionModal} />
         </Container>
diff --git a/ui/src/console/visualizer/Network.js b/ui/src/console/visualizer/Network.js
index 62819e59..7291e95b 100644
--- a/ui/src/console/visualizer/Network.js
+++ b/ui/src/console/visualizer/Network.js
@@ -16,20 +16,8 @@ const Network = (props) => {
     }, []);
 
     const paintNode = (node, ctx) => {
-        let nodeColor = "#636363";
-        let textColor = "#ccc";
-        switch(node.type) {
-            case "environment":
-                nodeColor = "#444";
-                break;
-
-            case "share": // share
-                nodeColor = "#291A66";
-                break;
-
-            default:
-                //
-        }
+        let nodeColor = node.selected ? "#04adef" : "#9BF316";
+        let textColor = node.selected ? "white" : "black";
 
         ctx.textBaseline = "middle";
         ctx.textAlign = "center";
@@ -41,16 +29,6 @@ const Network = (props) => {
         roundRect(ctx, node.x - (nodeWidth / 2), node.y - 7, nodeWidth, 14, 1.25);
         ctx.fill();
 
-        if(node.selected) {
-            ctx.strokeStyle = "#c4bdde";
-            ctx.stroke();
-        } else {
-            if(node.type === "share") {
-                ctx.strokeStyle = "#433482";
-                ctx.stroke();
-            }
-        }
-
         ctx.fillStyle = textColor;
         ctx.fillText(node.label, node.x, node.y);
     }
@@ -64,12 +42,12 @@ const Network = (props) => {
             ref={targetRef}
             graphData={props.networkGraph}
             width={props.size.width}
-            height={500}
+            height={800}
             onNodeClick={nodeClicked}
             linkOpacity={.75}
             linkWidth={1.5}
             nodeCanvasObject={paintNode}
-            backgroundColor={"#3b2693"}
+            backgroundColor={"linear-gradient(180deg, #0E0238 0%, #231069 100%);"}
             cooldownTicks={300}
         />
     )
diff --git a/ui/src/console/visualizer/graph.js b/ui/src/console/visualizer/graph.js
index 3661c04b..c2565ea8 100644
--- a/ui/src/console/visualizer/graph.js
+++ b/ui/src/console/visualizer/graph.js
@@ -40,7 +40,7 @@ export const mergeGraph = (oldGraph, user, newOverview) => {
         newGraph.links.push({
             target: accountNode.id,
             source: envNode.id,
-            color: "#777"
+            color: "#9BF316"
         });
         if(env.shares) {
             env.shares.forEach(shr => {
@@ -59,7 +59,7 @@ export const mergeGraph = (oldGraph, user, newOverview) => {
                 newGraph.links.push({
                     target: envNode.id,
                     source: shrNode.id,
-                    color: "#777"
+                    color: "#9BF316"
                 });
             });
         }
diff --git a/ui/src/index.css b/ui/src/index.css
index a7713c43..80c1f048 100644
--- a/ui/src/index.css
+++ b/ui/src/index.css
@@ -25,7 +25,7 @@ code, pre {
 
 .visualizer-container {
     padding: 10px;
-    background: #3b2693;
+    background: linear-gradient(180deg, #0E0238 0%, #231069 100%);
     border-radius: 15px;
     margin-top: 15px;
 }
@@ -120,4 +120,13 @@ code, pre {
 
 #zrok-tou {
     margin-top: 15px;
+}
+
+#controls-row {
+    margin-left: -30px;
+    margin-right: -30px;
+}
+
+#navbar {
+    background: linear-gradient(180deg, #0E0238 0%, #231069 100%);
 }
\ No newline at end of file

From 0f4a97549591648995bf6fc61d196c47c7e6e479 Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Wed, 3 May 2023 14:11:09 -0400
Subject: [PATCH 02/49] responsive layout (#234)

---
 ui/src/console/Console.js                      |  4 ++--
 ui/src/console/detail/environment/SharesTab.js | 11 -----------
 ui/src/index.css                               |  2 +-
 3 files changed, 3 insertions(+), 14 deletions(-)

diff --git a/ui/src/console/Console.js b/ui/src/console/Console.js
index 5dae41cd..e615daca 100644
--- a/ui/src/console/Console.js
+++ b/ui/src/console/Console.js
@@ -66,7 +66,7 @@ const Console = (props) => {
             </Navbar>
             <Container fluid={"xl"}>
                 <Row id={"controls-row"}>
-                    <Col>
+                    <Col lg={6}>
                         <Visualizer
                             user={props.user}
                             overview={overview}
@@ -75,7 +75,7 @@ const Console = (props) => {
                             setSelection={setSelection}
                         />
                     </Col>
-                    <Col>
+                    <Col lg={6}>
                         <Detail user={props.user} selection={selection} />
                     </Col>
                 </Row>
diff --git a/ui/src/console/detail/environment/SharesTab.js b/ui/src/console/detail/environment/SharesTab.js
index 333c8c7c..478d8c37 100644
--- a/ui/src/console/detail/environment/SharesTab.js
+++ b/ui/src/console/detail/environment/SharesTab.js
@@ -34,22 +34,11 @@ const SharesTab = (props) => {
             name: "Frontend",
             selector: row => <a href={row.frontendEndpoint} target={"_"}>{row.frontendEndpoint}</a>,
             sortable: true,
-            hide: "md"
         },
         {
             name: "Backend",
             selector: row => row.backendProxyEndpoint,
             sortable: true,
-        },
-        {
-            name: "Share Mode",
-            selector: row => row.shareMode,
-            hide: "md"
-        },
-        {
-            name: "Token",
-            selector: row => row.token,
-            sortable: true,
             hide: "md"
         },
         {
diff --git a/ui/src/index.css b/ui/src/index.css
index 80c1f048..7d4e69f7 100644
--- a/ui/src/index.css
+++ b/ui/src/index.css
@@ -64,7 +64,7 @@ code, pre {
 }
 
 .fullscreen {
-    background-color: #3b2693;
+    background: linear-gradient(180deg, #0E0238 0%, #231069 100%);
     padding: 25px;
     color: white;
     display: flex;

From 2da4f58f8119cb7e9c1fa41b6b82fd113ece6977 Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Thu, 4 May 2023 14:44:26 -0400
Subject: [PATCH 03/49] explorer (visualizer) updates to include overlaid
 icons; updated react-force-graph (#234)

---
 ui/package-lock.json                          | 53 ++++++++++---------
 ui/package.json                               |  2 +-
 .../console/detail/account/AccountDetail.js   |  4 +-
 ui/src/console/visualizer/Network.js          | 26 +++++++++
 ui/src/console/visualizer/graph.js            |  1 +
 5 files changed, 58 insertions(+), 28 deletions(-)

diff --git a/ui/package-lock.json b/ui/package-lock.json
index 9a7cb891..b09c8141 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -21,7 +21,7 @@
         "react-bootstrap": "^2.7.0",
         "react-data-table-component": "^7.5.2",
         "react-dom": "^18.2.0",
-        "react-force-graph": "^1.41.20",
+        "react-force-graph": "^1.43.0",
         "react-router-dom": "^6.4.0",
         "react-sizeme": "^3.0.2",
         "react-sparklines": "^1.7.0",
@@ -16162,16 +16162,19 @@
       "dev": true
     },
     "node_modules/react-force-graph": {
-      "version": "1.41.20",
-      "resolved": "https://registry.npmjs.org/react-force-graph/-/react-force-graph-1.41.20.tgz",
-      "integrity": "sha512-PdhbYTdvciKJLv2tePTHY+fTzrLEhfcji6/lijPc9GVQfJOLofssAYz+HgcWCKCRj1CIT8M/J7FICXX3ALXpbw==",
+      "version": "1.43.0",
+      "resolved": "https://registry.npmjs.org/react-force-graph/-/react-force-graph-1.43.0.tgz",
+      "integrity": "sha512-g59ZWGrR6hkokY8RMO6FQHbltaIZ3+AGf9mrQs+s1+J26Sc2Wc6aro4cLW8PTHMIHgX/zml44yp60gRbzdFSMw==",
       "dependencies": {
-        "3d-force-graph": "^1.70",
-        "3d-force-graph-ar": "^1.7",
-        "3d-force-graph-vr": "^2.0",
-        "force-graph": "^1.42",
-        "prop-types": "^15.8",
-        "react-kapsule": "^2.2"
+        "3d-force-graph": "1",
+        "3d-force-graph-ar": "1",
+        "3d-force-graph-vr": "2",
+        "force-graph": "1",
+        "prop-types": "15",
+        "react-kapsule": "2"
+      },
+      "engines": {
+        "node": ">=12"
       },
       "peerDependencies": {
         "react": "*"
@@ -18018,9 +18021,9 @@
       "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="
     },
     "node_modules/three": {
-      "version": "0.149.0",
-      "resolved": "https://registry.npmjs.org/three/-/three-0.149.0.tgz",
-      "integrity": "sha512-tohpUxPDht0qExRLDTM8sjRLc5d9STURNrdnK3w9A+V4pxaTBfKWWT/IqtiLfg23Vfc3Z+ImNfvRw1/0CtxrkQ=="
+      "version": "0.152.2",
+      "resolved": "https://registry.npmjs.org/three/-/three-0.152.2.tgz",
+      "integrity": "sha512-Ff9zIpSfkkqcBcpdiFo2f35vA9ZucO+N8TNacJOqaEE6DrB0eufItVMib8bK8Pcju/ZNT6a7blE1GhTpkdsILw=="
     },
     "node_modules/three-bmfont-text": {
       "version": "2.4.0",
@@ -31290,16 +31293,16 @@
       "dev": true
     },
     "react-force-graph": {
-      "version": "1.41.20",
-      "resolved": "https://registry.npmjs.org/react-force-graph/-/react-force-graph-1.41.20.tgz",
-      "integrity": "sha512-PdhbYTdvciKJLv2tePTHY+fTzrLEhfcji6/lijPc9GVQfJOLofssAYz+HgcWCKCRj1CIT8M/J7FICXX3ALXpbw==",
+      "version": "1.43.0",
+      "resolved": "https://registry.npmjs.org/react-force-graph/-/react-force-graph-1.43.0.tgz",
+      "integrity": "sha512-g59ZWGrR6hkokY8RMO6FQHbltaIZ3+AGf9mrQs+s1+J26Sc2Wc6aro4cLW8PTHMIHgX/zml44yp60gRbzdFSMw==",
       "requires": {
-        "3d-force-graph": "^1.70",
-        "3d-force-graph-ar": "^1.7",
-        "3d-force-graph-vr": "^2.0",
-        "force-graph": "^1.42",
-        "prop-types": "^15.8",
-        "react-kapsule": "^2.2"
+        "3d-force-graph": "1",
+        "3d-force-graph-ar": "1",
+        "3d-force-graph-vr": "2",
+        "force-graph": "1",
+        "prop-types": "15",
+        "react-kapsule": "2"
       }
     },
     "react-is": {
@@ -32658,9 +32661,9 @@
       "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="
     },
     "three": {
-      "version": "0.149.0",
-      "resolved": "https://registry.npmjs.org/three/-/three-0.149.0.tgz",
-      "integrity": "sha512-tohpUxPDht0qExRLDTM8sjRLc5d9STURNrdnK3w9A+V4pxaTBfKWWT/IqtiLfg23Vfc3Z+ImNfvRw1/0CtxrkQ=="
+      "version": "0.152.2",
+      "resolved": "https://registry.npmjs.org/three/-/three-0.152.2.tgz",
+      "integrity": "sha512-Ff9zIpSfkkqcBcpdiFo2f35vA9ZucO+N8TNacJOqaEE6DrB0eufItVMib8bK8Pcju/ZNT6a7blE1GhTpkdsILw=="
     },
     "three-bmfont-text": {
       "version": "git+ssh://git@github.com/dmarcos/three-bmfont-text.git#21d017046216e318362c48abd1a48bddfb6e0733",
diff --git a/ui/package.json b/ui/package.json
index c662144d..c465eb15 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -16,7 +16,7 @@
     "react-bootstrap": "^2.7.0",
     "react-data-table-component": "^7.5.2",
     "react-dom": "^18.2.0",
-    "react-force-graph": "^1.41.20",
+    "react-force-graph": "^1.43.0",
     "react-router-dom": "^6.4.0",
     "react-sizeme": "^3.0.2",
     "react-sparklines": "^1.7.0",
diff --git a/ui/src/console/detail/account/AccountDetail.js b/ui/src/console/detail/account/AccountDetail.js
index cad7bcef..679b51aa 100644
--- a/ui/src/console/detail/account/AccountDetail.js
+++ b/ui/src/console/detail/account/AccountDetail.js
@@ -1,4 +1,4 @@
-import {mdiCardAccountDetails} from "@mdi/js";
+import {mdiAccountBox} from "@mdi/js";
 import Icon from "@mdi/react";
 import PropertyTable from "../../PropertyTable";
 import {Tab, Tabs} from "react-bootstrap";
@@ -11,7 +11,7 @@ const AccountDetail = (props) => {
 
     return (
         <div>
-            <h2><Icon path={mdiCardAccountDetails} size={2} />{" "}{props.user.email}</h2>
+            <h2><Icon path={mdiAccountBox} size={2} />{" "}{props.user.email}</h2>
             <Tabs defaultActiveKey={"detail"}>
                 <Tab eventKey={"detail"} title={"Detail"}>
                     <PropertyTable object={props.user} custom={customProperties}/>
diff --git a/ui/src/console/visualizer/Network.js b/ui/src/console/visualizer/Network.js
index 7291e95b..3de59eb9 100644
--- a/ui/src/console/visualizer/Network.js
+++ b/ui/src/console/visualizer/Network.js
@@ -3,6 +3,11 @@ import {useEffect, useRef} from "react";
 import {ForceGraph2D} from "react-force-graph";
 import * as d3 from "d3-force-3d";
 import {roundRect} from "./draw";
+import {mdiShareVariant, mdiConsoleNetwork, mdiAccountBox} from "@mdi/js";
+
+const accountIcon = new Path2D(mdiAccountBox);
+const environmentIcon = new Path2D(mdiConsoleNetwork);
+const shareIcon = new Path2D(mdiShareVariant);
 
 const Network = (props) => {
     const targetRef = useRef();
@@ -29,6 +34,27 @@ const Network = (props) => {
         roundRect(ctx, node.x - (nodeWidth / 2), node.y - 7, nodeWidth, 14, 1.25);
         ctx.fill();
 
+        const nodeIcon = new Path2D();
+        let xform = new DOMMatrix();
+        xform.translateSelf(node.x - (nodeWidth / 2) - 6, node.y - 13);
+        xform.scaleSelf(0.5, 0.5);
+        switch(node.type) {
+            case "share":
+                nodeIcon.addPath(shareIcon, xform);
+                break;
+            case "environment":
+                nodeIcon.addPath(environmentIcon, xform);
+                break;
+            case "account":
+                nodeIcon.addPath(accountIcon, xform);
+                break;
+        }
+
+        ctx.fill(nodeIcon);
+        ctx.strokeStyle = "black";
+        ctx.lineWidth = 0.5;
+        ctx.stroke(nodeIcon);
+
         ctx.fillStyle = textColor;
         ctx.fillText(node.label, node.x, node.y);
     }
diff --git a/ui/src/console/visualizer/graph.js b/ui/src/console/visualizer/graph.js
index c2565ea8..4e068555 100644
--- a/ui/src/console/visualizer/graph.js
+++ b/ui/src/console/visualizer/graph.js
@@ -55,6 +55,7 @@ export const mergeGraph = (oldGraph, user, newOverview) => {
                     type: "share",
                     val: 50
                 };
+                console.log('share', shrNode.label);
                 newGraph.nodes.push(shrNode);
                 newGraph.links.push({
                     target: envNode.id,

From 94d02dd64cd833c84fe459d724d63988cb14ce51 Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Mon, 8 May 2023 13:49:20 -0400
Subject: [PATCH 04/49] replace 'react-sparklines' with 'recharts' to handle
 more intensive metrics representations (#234)

---
 ui/package-lock.json                          | 409 +++++++++++++++++-
 ui/package.json                               |   2 +-
 .../console/detail/environment/SharesTab.js   |  10 +-
 ui/src/console/detail/share/ShareDetail.js    |  13 +-
 4 files changed, 407 insertions(+), 27 deletions(-)

diff --git a/ui/package-lock.json b/ui/package-lock.json
index b09c8141..845ba995 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -24,7 +24,7 @@
         "react-force-graph": "^1.43.0",
         "react-router-dom": "^6.4.0",
         "react-sizeme": "^3.0.2",
-        "react-sparklines": "^1.7.0",
+        "recharts": "^2.5.0",
         "styled-components": "^5.3.5",
         "svgo": "^3.0.2"
       },
@@ -4145,6 +4145,60 @@
         "@types/node": "*"
       }
     },
+    "node_modules/@types/d3-array": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.4.tgz",
+      "integrity": "sha512-nwvEkG9vYOc0Ic7G7kwgviY4AQlTfYGIZ0fqB7CQHXGyYM6nO7kJh5EguSNA3jfh4rq7Sb7eMVq8isuvg2/miQ=="
+    },
+    "node_modules/@types/d3-color": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.0.tgz",
+      "integrity": "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA=="
+    },
+    "node_modules/@types/d3-ease": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.0.tgz",
+      "integrity": "sha512-aMo4eaAOijJjA6uU+GIeW018dvy9+oH5Y2VPPzjjfxevvGQ/oRDs+tfYC9b50Q4BygRR8yE2QCLsrT0WtAVseA=="
+    },
+    "node_modules/@types/d3-interpolate": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+      "integrity": "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==",
+      "dependencies": {
+        "@types/d3-color": "*"
+      }
+    },
+    "node_modules/@types/d3-path": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.0.0.tgz",
+      "integrity": "sha512-0g/A+mZXgFkQxN3HniRDbXMN79K3CdTpLsevj+PXiTcb2hVyvkZUBg37StmgCQkaD84cUJ4uaDAWq7UJOQy2Tg=="
+    },
+    "node_modules/@types/d3-scale": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.3.tgz",
+      "integrity": "sha512-PATBiMCpvHJSMtZAMEhc2WyL+hnzarKzI6wAHYjhsonjWJYGq5BXTzQjv4l8m2jO183/4wZ90rKvSeT7o72xNQ==",
+      "dependencies": {
+        "@types/d3-time": "*"
+      }
+    },
+    "node_modules/@types/d3-shape": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.1.tgz",
+      "integrity": "sha512-6Uh86YFF7LGg4PQkuO2oG6EMBRLuW9cbavUW46zkIO5kuS2PfTqo2o9SkgtQzguBHbLgNnU90UNsITpsX1My+A==",
+      "dependencies": {
+        "@types/d3-path": "*"
+      }
+    },
+    "node_modules/@types/d3-time": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz",
+      "integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg=="
+    },
+    "node_modules/@types/d3-timer": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.0.tgz",
+      "integrity": "sha512-HNB/9GHqu7Fo8AQiugyJbv6ZxYz58wef0esl4Mv828w1ZKpAshw/uFWVDUcIB9KKFeFKoxS3cHY07FFgtTRZ1g=="
+    },
     "node_modules/@types/eslint": {
       "version": "8.21.0",
       "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.21.0.tgz",
@@ -6879,6 +6933,11 @@
         "postcss-value-parser": "^4.0.2"
       }
     },
+    "node_modules/css-unit-converter": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/css-unit-converter/-/css-unit-converter-1.1.2.tgz",
+      "integrity": "sha512-IiJwMC8rdZE0+xiEZHeru6YoONC4rfPMqGm2W85jMIbkFvv5nFTwJVFHam2eFrN6txmoUYFAFXiv8ICVeTO0MA=="
+    },
     "node_modules/css-what": {
       "version": "6.1.0",
       "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz",
@@ -7153,6 +7212,14 @@
       "resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-0.2.2.tgz",
       "integrity": "sha512-ysk9uSPAhZVb0Gq4GXzghl/Yqxu80dHrq55I53qaIMdGB65+0UfO84sr4Fci2JHumcgh6H4WE0r8LwxPagkE+g=="
     },
+    "node_modules/d3-path": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+      "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+      "engines": {
+        "node": ">=12"
+      }
+    },
     "node_modules/d3-quadtree": {
       "version": "3.0.1",
       "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
@@ -7196,6 +7263,17 @@
         "node": ">=12"
       }
     },
+    "node_modules/d3-shape": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+      "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+      "dependencies": {
+        "d3-path": "^3.1.0"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
     "node_modules/d3-time": {
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
@@ -7322,6 +7400,11 @@
       "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==",
       "dev": true
     },
+    "node_modules/decimal.js-light": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
+      "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="
+    },
     "node_modules/decode-uri-component": {
       "version": "0.2.2",
       "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
@@ -8726,8 +8809,7 @@
     "node_modules/eventemitter3": {
       "version": "4.0.7",
       "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
-      "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
-      "dev": true
+      "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
     },
     "node_modules/events": {
       "version": "3.3.0",
@@ -8853,6 +8935,11 @@
       "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
       "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
     },
+    "node_modules/fast-equals": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz",
+      "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg=="
+    },
     "node_modules/fast-glob": {
       "version": "3.2.12",
       "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz",
@@ -16211,6 +16298,18 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/react-resize-detector": {
+      "version": "8.1.0",
+      "resolved": "https://registry.npmjs.org/react-resize-detector/-/react-resize-detector-8.1.0.tgz",
+      "integrity": "sha512-S7szxlaIuiy5UqLhLL1KY3aoyGHbZzsTpYal9eYMwCyKqoqoVLCmIgAgNyIM1FhnP2KyBygASJxdhejrzjMb+w==",
+      "dependencies": {
+        "lodash": "^4.17.21"
+      },
+      "peerDependencies": {
+        "react": "^16.0.0 || ^17.0.0 || ^18.0.0",
+        "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0"
+      }
+    },
     "node_modules/react-router": {
       "version": "6.8.0",
       "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.8.0.tgz",
@@ -16358,16 +16457,41 @@
         "throttle-debounce": "^3.0.1"
       }
     },
-    "node_modules/react-sparklines": {
-      "version": "1.7.0",
-      "resolved": "https://registry.npmjs.org/react-sparklines/-/react-sparklines-1.7.0.tgz",
-      "integrity": "sha512-bJFt9K4c5Z0k44G8KtxIhbG+iyxrKjBZhdW6afP+R7EnIq+iKjbWbEFISrf3WKNFsda+C46XAfnX0StS5fbDcg==",
+    "node_modules/react-smooth": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-2.0.2.tgz",
+      "integrity": "sha512-pgqSp1q8rAGtF1bXQE0m3CHGLNfZZh5oA5o1tsPLXRHnKtkujMIJ8Ws5nO1mTySZf1c4vgwlEk+pHi3Ln6eYLw==",
       "dependencies": {
-        "prop-types": "^15.5.10"
+        "fast-equals": "^4.0.3",
+        "react-transition-group": "2.9.0"
       },
       "peerDependencies": {
-        "react": "*",
-        "react-dom": "*"
+        "prop-types": "^15.6.0",
+        "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0",
+        "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0"
+      }
+    },
+    "node_modules/react-smooth/node_modules/dom-helpers": {
+      "version": "3.4.0",
+      "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz",
+      "integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==",
+      "dependencies": {
+        "@babel/runtime": "^7.1.2"
+      }
+    },
+    "node_modules/react-smooth/node_modules/react-transition-group": {
+      "version": "2.9.0",
+      "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz",
+      "integrity": "sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==",
+      "dependencies": {
+        "dom-helpers": "^3.4.0",
+        "loose-envify": "^1.4.0",
+        "prop-types": "^15.6.2",
+        "react-lifecycles-compat": "^3.0.4"
+      },
+      "peerDependencies": {
+        "react": ">=15.0.0",
+        "react-dom": ">=15.0.0"
       }
     },
     "node_modules/react-transition-group": {
@@ -16420,6 +16544,43 @@
         "node": ">=8.10.0"
       }
     },
+    "node_modules/recharts": {
+      "version": "2.5.0",
+      "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.5.0.tgz",
+      "integrity": "sha512-0EQYz3iA18r1Uq8VqGZ4dABW52AKBnio37kJgnztIqprELJXpOEsa0SzkqU1vjAhpCXCv52Dx1hiL9119xsqsQ==",
+      "dependencies": {
+        "classnames": "^2.2.5",
+        "eventemitter3": "^4.0.1",
+        "lodash": "^4.17.19",
+        "react-is": "^16.10.2",
+        "react-resize-detector": "^8.0.4",
+        "react-smooth": "^2.0.2",
+        "recharts-scale": "^0.4.4",
+        "reduce-css-calc": "^2.1.8",
+        "victory-vendor": "^36.6.8"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "peerDependencies": {
+        "prop-types": "^15.6.0",
+        "react": "^16.0.0 || ^17.0.0 || ^18.0.0",
+        "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0"
+      }
+    },
+    "node_modules/recharts-scale": {
+      "version": "0.4.5",
+      "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
+      "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
+      "dependencies": {
+        "decimal.js-light": "^2.4.1"
+      }
+    },
+    "node_modules/recharts/node_modules/react-is": {
+      "version": "16.13.1",
+      "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+      "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
+    },
     "node_modules/recursive-readdir": {
       "version": "2.2.3",
       "resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.3.tgz",
@@ -16432,6 +16593,20 @@
         "node": ">=6.0.0"
       }
     },
+    "node_modules/reduce-css-calc": {
+      "version": "2.1.8",
+      "resolved": "https://registry.npmjs.org/reduce-css-calc/-/reduce-css-calc-2.1.8.tgz",
+      "integrity": "sha512-8liAVezDmUcH+tdzoEGrhfbGcP7nOV4NkGE3a74+qqvE7nt9i4sKLGBuZNOnpI4WiGksiNPklZxva80061QiPg==",
+      "dependencies": {
+        "css-unit-converter": "^1.1.1",
+        "postcss-value-parser": "^3.3.0"
+      }
+    },
+    "node_modules/reduce-css-calc/node_modules/postcss-value-parser": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz",
+      "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ=="
+    },
     "node_modules/regenerate": {
       "version": "1.4.2",
       "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
@@ -18569,6 +18744,27 @@
         "node": ">= 0.8"
       }
     },
+    "node_modules/victory-vendor": {
+      "version": "36.6.10",
+      "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.6.10.tgz",
+      "integrity": "sha512-7YqYGtsA4mByokBhCjk+ewwPhUfzhR1I3Da6/ZsZUv/31ceT77RKoaqrxRq5Ki+9we4uzf7+A+7aG2sfYhm7nA==",
+      "dependencies": {
+        "@types/d3-array": "^3.0.3",
+        "@types/d3-ease": "^3.0.0",
+        "@types/d3-interpolate": "^3.0.1",
+        "@types/d3-scale": "^4.0.2",
+        "@types/d3-shape": "^3.1.0",
+        "@types/d3-time": "^3.0.0",
+        "@types/d3-timer": "^3.0.0",
+        "d3-array": "^3.1.6",
+        "d3-ease": "^3.0.1",
+        "d3-interpolate": "^3.0.1",
+        "d3-scale": "^4.0.2",
+        "d3-shape": "^3.1.0",
+        "d3-time": "^3.0.0",
+        "d3-timer": "^3.0.1"
+      }
+    },
     "node_modules/w3c-hr-time": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",
@@ -22458,6 +22654,60 @@
         "@types/node": "*"
       }
     },
+    "@types/d3-array": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.4.tgz",
+      "integrity": "sha512-nwvEkG9vYOc0Ic7G7kwgviY4AQlTfYGIZ0fqB7CQHXGyYM6nO7kJh5EguSNA3jfh4rq7Sb7eMVq8isuvg2/miQ=="
+    },
+    "@types/d3-color": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.0.tgz",
+      "integrity": "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA=="
+    },
+    "@types/d3-ease": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.0.tgz",
+      "integrity": "sha512-aMo4eaAOijJjA6uU+GIeW018dvy9+oH5Y2VPPzjjfxevvGQ/oRDs+tfYC9b50Q4BygRR8yE2QCLsrT0WtAVseA=="
+    },
+    "@types/d3-interpolate": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+      "integrity": "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==",
+      "requires": {
+        "@types/d3-color": "*"
+      }
+    },
+    "@types/d3-path": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.0.0.tgz",
+      "integrity": "sha512-0g/A+mZXgFkQxN3HniRDbXMN79K3CdTpLsevj+PXiTcb2hVyvkZUBg37StmgCQkaD84cUJ4uaDAWq7UJOQy2Tg=="
+    },
+    "@types/d3-scale": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.3.tgz",
+      "integrity": "sha512-PATBiMCpvHJSMtZAMEhc2WyL+hnzarKzI6wAHYjhsonjWJYGq5BXTzQjv4l8m2jO183/4wZ90rKvSeT7o72xNQ==",
+      "requires": {
+        "@types/d3-time": "*"
+      }
+    },
+    "@types/d3-shape": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.1.tgz",
+      "integrity": "sha512-6Uh86YFF7LGg4PQkuO2oG6EMBRLuW9cbavUW46zkIO5kuS2PfTqo2o9SkgtQzguBHbLgNnU90UNsITpsX1My+A==",
+      "requires": {
+        "@types/d3-path": "*"
+      }
+    },
+    "@types/d3-time": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz",
+      "integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg=="
+    },
+    "@types/d3-timer": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.0.tgz",
+      "integrity": "sha512-HNB/9GHqu7Fo8AQiugyJbv6ZxYz58wef0esl4Mv828w1ZKpAshw/uFWVDUcIB9KKFeFKoxS3cHY07FFgtTRZ1g=="
+    },
     "@types/eslint": {
       "version": "8.21.0",
       "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.21.0.tgz",
@@ -24550,6 +24800,11 @@
         "postcss-value-parser": "^4.0.2"
       }
     },
+    "css-unit-converter": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/css-unit-converter/-/css-unit-converter-1.1.2.tgz",
+      "integrity": "sha512-IiJwMC8rdZE0+xiEZHeru6YoONC4rfPMqGm2W85jMIbkFvv5nFTwJVFHam2eFrN6txmoUYFAFXiv8ICVeTO0MA=="
+    },
     "css-what": {
       "version": "6.1.0",
       "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz",
@@ -24755,6 +25010,11 @@
       "resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-0.2.2.tgz",
       "integrity": "sha512-ysk9uSPAhZVb0Gq4GXzghl/Yqxu80dHrq55I53qaIMdGB65+0UfO84sr4Fci2JHumcgh6H4WE0r8LwxPagkE+g=="
     },
+    "d3-path": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+      "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="
+    },
     "d3-quadtree": {
       "version": "3.0.1",
       "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
@@ -24786,6 +25046,14 @@
       "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
       "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="
     },
+    "d3-shape": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+      "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+      "requires": {
+        "d3-path": "^3.1.0"
+      }
+    },
     "d3-time": {
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
@@ -24883,6 +25151,11 @@
       "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==",
       "dev": true
     },
+    "decimal.js-light": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
+      "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="
+    },
     "decode-uri-component": {
       "version": "0.2.2",
       "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
@@ -25927,8 +26200,7 @@
     "eventemitter3": {
       "version": "4.0.7",
       "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
-      "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
-      "dev": true
+      "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
     },
     "events": {
       "version": "3.3.0",
@@ -26038,6 +26310,11 @@
       "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
       "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
     },
+    "fast-equals": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz",
+      "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg=="
+    },
     "fast-glob": {
       "version": "3.2.12",
       "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz",
@@ -31330,6 +31607,14 @@
       "integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==",
       "dev": true
     },
+    "react-resize-detector": {
+      "version": "8.1.0",
+      "resolved": "https://registry.npmjs.org/react-resize-detector/-/react-resize-detector-8.1.0.tgz",
+      "integrity": "sha512-S7szxlaIuiy5UqLhLL1KY3aoyGHbZzsTpYal9eYMwCyKqoqoVLCmIgAgNyIM1FhnP2KyBygASJxdhejrzjMb+w==",
+      "requires": {
+        "lodash": "^4.17.21"
+      }
+    },
     "react-router": {
       "version": "6.8.0",
       "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.8.0.tgz",
@@ -31440,12 +31725,34 @@
         "throttle-debounce": "^3.0.1"
       }
     },
-    "react-sparklines": {
-      "version": "1.7.0",
-      "resolved": "https://registry.npmjs.org/react-sparklines/-/react-sparklines-1.7.0.tgz",
-      "integrity": "sha512-bJFt9K4c5Z0k44G8KtxIhbG+iyxrKjBZhdW6afP+R7EnIq+iKjbWbEFISrf3WKNFsda+C46XAfnX0StS5fbDcg==",
+    "react-smooth": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-2.0.2.tgz",
+      "integrity": "sha512-pgqSp1q8rAGtF1bXQE0m3CHGLNfZZh5oA5o1tsPLXRHnKtkujMIJ8Ws5nO1mTySZf1c4vgwlEk+pHi3Ln6eYLw==",
       "requires": {
-        "prop-types": "^15.5.10"
+        "fast-equals": "^4.0.3",
+        "react-transition-group": "2.9.0"
+      },
+      "dependencies": {
+        "dom-helpers": {
+          "version": "3.4.0",
+          "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz",
+          "integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==",
+          "requires": {
+            "@babel/runtime": "^7.1.2"
+          }
+        },
+        "react-transition-group": {
+          "version": "2.9.0",
+          "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz",
+          "integrity": "sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==",
+          "requires": {
+            "dom-helpers": "^3.4.0",
+            "loose-envify": "^1.4.0",
+            "prop-types": "^15.6.2",
+            "react-lifecycles-compat": "^3.0.4"
+          }
+        }
       }
     },
     "react-transition-group": {
@@ -31488,6 +31795,37 @@
         "picomatch": "^2.2.1"
       }
     },
+    "recharts": {
+      "version": "2.5.0",
+      "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.5.0.tgz",
+      "integrity": "sha512-0EQYz3iA18r1Uq8VqGZ4dABW52AKBnio37kJgnztIqprELJXpOEsa0SzkqU1vjAhpCXCv52Dx1hiL9119xsqsQ==",
+      "requires": {
+        "classnames": "^2.2.5",
+        "eventemitter3": "^4.0.1",
+        "lodash": "^4.17.19",
+        "react-is": "^16.10.2",
+        "react-resize-detector": "^8.0.4",
+        "react-smooth": "^2.0.2",
+        "recharts-scale": "^0.4.4",
+        "reduce-css-calc": "^2.1.8",
+        "victory-vendor": "^36.6.8"
+      },
+      "dependencies": {
+        "react-is": {
+          "version": "16.13.1",
+          "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+          "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
+        }
+      }
+    },
+    "recharts-scale": {
+      "version": "0.4.5",
+      "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
+      "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
+      "requires": {
+        "decimal.js-light": "^2.4.1"
+      }
+    },
     "recursive-readdir": {
       "version": "2.2.3",
       "resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.3.tgz",
@@ -31497,6 +31835,22 @@
         "minimatch": "^3.0.5"
       }
     },
+    "reduce-css-calc": {
+      "version": "2.1.8",
+      "resolved": "https://registry.npmjs.org/reduce-css-calc/-/reduce-css-calc-2.1.8.tgz",
+      "integrity": "sha512-8liAVezDmUcH+tdzoEGrhfbGcP7nOV4NkGE3a74+qqvE7nt9i4sKLGBuZNOnpI4WiGksiNPklZxva80061QiPg==",
+      "requires": {
+        "css-unit-converter": "^1.1.1",
+        "postcss-value-parser": "^3.3.0"
+      },
+      "dependencies": {
+        "postcss-value-parser": {
+          "version": "3.3.1",
+          "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz",
+          "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ=="
+        }
+      }
+    },
     "regenerate": {
       "version": "1.4.2",
       "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
@@ -33080,6 +33434,27 @@
       "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
       "dev": true
     },
+    "victory-vendor": {
+      "version": "36.6.10",
+      "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.6.10.tgz",
+      "integrity": "sha512-7YqYGtsA4mByokBhCjk+ewwPhUfzhR1I3Da6/ZsZUv/31ceT77RKoaqrxRq5Ki+9we4uzf7+A+7aG2sfYhm7nA==",
+      "requires": {
+        "@types/d3-array": "^3.0.3",
+        "@types/d3-ease": "^3.0.0",
+        "@types/d3-interpolate": "^3.0.1",
+        "@types/d3-scale": "^4.0.2",
+        "@types/d3-shape": "^3.1.0",
+        "@types/d3-time": "^3.0.0",
+        "@types/d3-timer": "^3.0.0",
+        "d3-array": "^3.1.6",
+        "d3-ease": "^3.0.1",
+        "d3-interpolate": "^3.0.1",
+        "d3-scale": "^4.0.2",
+        "d3-shape": "^3.1.0",
+        "d3-time": "^3.0.0",
+        "d3-timer": "^3.0.1"
+      }
+    },
     "w3c-hr-time": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",
diff --git a/ui/package.json b/ui/package.json
index c465eb15..782354c0 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -19,7 +19,7 @@
     "react-force-graph": "^1.43.0",
     "react-router-dom": "^6.4.0",
     "react-sizeme": "^3.0.2",
-    "react-sparklines": "^1.7.0",
+    "recharts": "^2.5.0",
     "styled-components": "^5.3.5",
     "svgo": "^3.0.2"
   },
diff --git a/ui/src/console/detail/environment/SharesTab.js b/ui/src/console/detail/environment/SharesTab.js
index 478d8c37..550a1e5c 100644
--- a/ui/src/console/detail/environment/SharesTab.js
+++ b/ui/src/console/detail/environment/SharesTab.js
@@ -1,7 +1,7 @@
 import * as metadata from "../../../api/metadata";
-import {useEffect, useState} from "react";
+import React, {useEffect, useState} from "react";
 import DataTable from 'react-data-table-component';
-import {Sparklines, SparklinesLine, SparklinesSpots} from "react-sparklines";
+import {Area, AreaChart, ResponsiveContainer} from "recharts";
 
 const SharesTab = (props) => {
     const [detail, setDetail] = useState({});
@@ -44,7 +44,11 @@ const SharesTab = (props) => {
         {
             name: "Activity",
             cell: row => {
-                return <Sparklines data={row.metrics} height={20} limit={60}><SparklinesLine color={"#3b2693"}/><SparklinesSpots/></Sparklines>;
+                return <ResponsiveContainer width={"100%"} height={"100%"}>
+                    <AreaChart data={row.metrics}>
+                        <Area type="linearClosed" dataKey={(v) => v} stroke={"#231069"} fillOpacity={1} fill={"#655796"} isAnimationActive={false} dot={false} />
+                    </AreaChart>
+                </ResponsiveContainer>
             }
         }
     ];
diff --git a/ui/src/console/detail/share/ShareDetail.js b/ui/src/console/detail/share/ShareDetail.js
index be78206b..0b5792b3 100644
--- a/ui/src/console/detail/share/ShareDetail.js
+++ b/ui/src/console/detail/share/ShareDetail.js
@@ -1,12 +1,12 @@
 import * as metadata from "../../../api/metadata";
-import {Sparklines, SparklinesLine, SparklinesSpots} from "react-sparklines";
-import {useEffect, useState} from "react";
+import React, {useEffect, useState} from "react";
 import {mdiShareVariant} from "@mdi/js";
 import Icon from "@mdi/react";
 import PropertyTable from "../../PropertyTable";
 import {Tab, Tabs} from "react-bootstrap";
 import ActionsTab from "./ActionsTab";
 import SecretToggle from "../../SecretToggle";
+import {Area, AreaChart, Line, LineChart, ResponsiveContainer, XAxis} from "recharts";
 
 const ShareDetail = (props) => {
     const [detail, setDetail] = useState({});
@@ -40,10 +40,11 @@ const ShareDetail = (props) => {
 
     const customProperties = {
         metrics: row => (
-            <Sparklines data={row.value} limit={60} height={10}>
-                <SparklinesLine color={"#3b2693"}/>
-                <SparklinesSpots/>
-            </Sparklines>
+            <ResponsiveContainer width={"100%"} height={"100%"}>
+                <AreaChart data={row.value}>
+                    <Area type="linearClosed" dataKey={(v) => v} stroke={"#231069"} fillOpacity={1} fill={"#04adef"} isAnimationActive={false} dot={false} />
+                </AreaChart>
+            </ResponsiveContainer>
         ),
         frontendEndpoint: row => (
             <a href={row.value} target="_">{row.value}</a>

From 645537934ee283a16fb108ba03d41623c638e488 Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Mon, 8 May 2023 13:51:47 -0400
Subject: [PATCH 05/49] colors; amirite? (#234)

---
 ui/src/console/detail/share/ShareDetail.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/ui/src/console/detail/share/ShareDetail.js b/ui/src/console/detail/share/ShareDetail.js
index 0b5792b3..8b86171c 100644
--- a/ui/src/console/detail/share/ShareDetail.js
+++ b/ui/src/console/detail/share/ShareDetail.js
@@ -42,7 +42,7 @@ const ShareDetail = (props) => {
         metrics: row => (
             <ResponsiveContainer width={"100%"} height={"100%"}>
                 <AreaChart data={row.value}>
-                    <Area type="linearClosed" dataKey={(v) => v} stroke={"#231069"} fillOpacity={1} fill={"#04adef"} isAnimationActive={false} dot={false} />
+                    <Area type="linearClosed" dataKey={(v) => v} stroke={"#231069"} fillOpacity={1} fill={"#655796"} isAnimationActive={false} dot={false} />
                 </AreaChart>
             </ResponsiveContainer>
         ),

From a4b6313b2d6949b4078e554aaaa90c6d1f1b024d Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Mon, 8 May 2023 13:56:27 -0400
Subject: [PATCH 06/49] tweaks (#234)

---
 ui/src/console/detail/environment/SharesTab.js | 2 +-
 ui/src/console/detail/share/ShareDetail.js     | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/ui/src/console/detail/environment/SharesTab.js b/ui/src/console/detail/environment/SharesTab.js
index 550a1e5c..e902529c 100644
--- a/ui/src/console/detail/environment/SharesTab.js
+++ b/ui/src/console/detail/environment/SharesTab.js
@@ -46,7 +46,7 @@ const SharesTab = (props) => {
             cell: row => {
                 return <ResponsiveContainer width={"100%"} height={"100%"}>
                     <AreaChart data={row.metrics}>
-                        <Area type="linearClosed" dataKey={(v) => v} stroke={"#231069"} fillOpacity={1} fill={"#655796"} isAnimationActive={false} dot={false} />
+                        <Area type="basis" dataKey={(v) => v} stroke={"#231069"} fillOpacity={1} fill={"#655796"} isAnimationActive={false} dot={false} />
                     </AreaChart>
                 </ResponsiveContainer>
             }
diff --git a/ui/src/console/detail/share/ShareDetail.js b/ui/src/console/detail/share/ShareDetail.js
index 8b86171c..78371e1f 100644
--- a/ui/src/console/detail/share/ShareDetail.js
+++ b/ui/src/console/detail/share/ShareDetail.js
@@ -42,7 +42,7 @@ const ShareDetail = (props) => {
         metrics: row => (
             <ResponsiveContainer width={"100%"} height={"100%"}>
                 <AreaChart data={row.value}>
-                    <Area type="linearClosed" dataKey={(v) => v} stroke={"#231069"} fillOpacity={1} fill={"#655796"} isAnimationActive={false} dot={false} />
+                    <Area type="basis" dataKey={(v) => v} stroke={"#231069"} fillOpacity={1} fill={"#655796"} isAnimationActive={false} dot={false} />
                 </AreaChart>
             </ResponsiveContainer>
         ),

From 58a225284d6742a1b71ec51dc8e31f4d1efb8884 Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Mon, 8 May 2023 14:33:15 -0400
Subject: [PATCH 07/49] new definitions for metrics api endpoints; strawman
 (#319)

---
 .../metrics/get_account_metrics_parameters.go | 128 ++++++++++
 .../metrics/get_account_metrics_responses.go  |  98 ++++++++
 .../get_environment_metrics_parameters.go     | 148 ++++++++++++
 .../get_environment_metrics_responses.go      | 155 ++++++++++++
 .../metrics/get_share_metrics_parameters.go   | 148 ++++++++++++
 .../metrics/get_share_metrics_responses.go    | 155 ++++++++++++
 rest_client_zrok/metrics/metrics_client.go    | 162 +++++++++++++
 rest_client_zrok/zrok_client.go               |   5 +
 rest_model_zrok/metrics.go                    |  62 +++++
 rest_server_zrok/embedded_spec.go             | 222 ++++++++++++++++++
 .../operations/metrics/get_account_metrics.go |  71 ++++++
 .../metrics/get_account_metrics_parameters.go |  46 ++++
 .../metrics/get_account_metrics_responses.go  |  59 +++++
 .../metrics/get_account_metrics_urlbuilder.go |  87 +++++++
 .../metrics/get_environment_metrics.go        |  71 ++++++
 .../get_environment_metrics_parameters.go     |  71 ++++++
 .../get_environment_metrics_responses.go      |  84 +++++++
 .../get_environment_metrics_urlbuilder.go     |  99 ++++++++
 .../operations/metrics/get_share_metrics.go   |  71 ++++++
 .../metrics/get_share_metrics_parameters.go   |  71 ++++++
 .../metrics/get_share_metrics_responses.go    |  84 +++++++
 .../metrics/get_share_metrics_urlbuilder.go   |  99 ++++++++
 rest_server_zrok/operations/zrok_api.go       |  37 +++
 specs/zrok.yml                                |  74 ++++++
 ui/src/api/metrics.js                         |  65 +++++
 ui/src/api/types.js                           |  11 +
 26 files changed, 2383 insertions(+)
 create mode 100644 rest_client_zrok/metrics/get_account_metrics_parameters.go
 create mode 100644 rest_client_zrok/metrics/get_account_metrics_responses.go
 create mode 100644 rest_client_zrok/metrics/get_environment_metrics_parameters.go
 create mode 100644 rest_client_zrok/metrics/get_environment_metrics_responses.go
 create mode 100644 rest_client_zrok/metrics/get_share_metrics_parameters.go
 create mode 100644 rest_client_zrok/metrics/get_share_metrics_responses.go
 create mode 100644 rest_client_zrok/metrics/metrics_client.go
 create mode 100644 rest_model_zrok/metrics.go
 create mode 100644 rest_server_zrok/operations/metrics/get_account_metrics.go
 create mode 100644 rest_server_zrok/operations/metrics/get_account_metrics_parameters.go
 create mode 100644 rest_server_zrok/operations/metrics/get_account_metrics_responses.go
 create mode 100644 rest_server_zrok/operations/metrics/get_account_metrics_urlbuilder.go
 create mode 100644 rest_server_zrok/operations/metrics/get_environment_metrics.go
 create mode 100644 rest_server_zrok/operations/metrics/get_environment_metrics_parameters.go
 create mode 100644 rest_server_zrok/operations/metrics/get_environment_metrics_responses.go
 create mode 100644 rest_server_zrok/operations/metrics/get_environment_metrics_urlbuilder.go
 create mode 100644 rest_server_zrok/operations/metrics/get_share_metrics.go
 create mode 100644 rest_server_zrok/operations/metrics/get_share_metrics_parameters.go
 create mode 100644 rest_server_zrok/operations/metrics/get_share_metrics_responses.go
 create mode 100644 rest_server_zrok/operations/metrics/get_share_metrics_urlbuilder.go
 create mode 100644 ui/src/api/metrics.js

diff --git a/rest_client_zrok/metrics/get_account_metrics_parameters.go b/rest_client_zrok/metrics/get_account_metrics_parameters.go
new file mode 100644
index 00000000..5af21fb7
--- /dev/null
+++ b/rest_client_zrok/metrics/get_account_metrics_parameters.go
@@ -0,0 +1,128 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metrics
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"context"
+	"net/http"
+	"time"
+
+	"github.com/go-openapi/errors"
+	"github.com/go-openapi/runtime"
+	cr "github.com/go-openapi/runtime/client"
+	"github.com/go-openapi/strfmt"
+)
+
+// NewGetAccountMetricsParams creates a new GetAccountMetricsParams object,
+// with the default timeout for this client.
+//
+// Default values are not hydrated, since defaults are normally applied by the API server side.
+//
+// To enforce default values in parameter, use SetDefaults or WithDefaults.
+func NewGetAccountMetricsParams() *GetAccountMetricsParams {
+	return &GetAccountMetricsParams{
+		timeout: cr.DefaultTimeout,
+	}
+}
+
+// NewGetAccountMetricsParamsWithTimeout creates a new GetAccountMetricsParams object
+// with the ability to set a timeout on a request.
+func NewGetAccountMetricsParamsWithTimeout(timeout time.Duration) *GetAccountMetricsParams {
+	return &GetAccountMetricsParams{
+		timeout: timeout,
+	}
+}
+
+// NewGetAccountMetricsParamsWithContext creates a new GetAccountMetricsParams object
+// with the ability to set a context for a request.
+func NewGetAccountMetricsParamsWithContext(ctx context.Context) *GetAccountMetricsParams {
+	return &GetAccountMetricsParams{
+		Context: ctx,
+	}
+}
+
+// NewGetAccountMetricsParamsWithHTTPClient creates a new GetAccountMetricsParams object
+// with the ability to set a custom HTTPClient for a request.
+func NewGetAccountMetricsParamsWithHTTPClient(client *http.Client) *GetAccountMetricsParams {
+	return &GetAccountMetricsParams{
+		HTTPClient: client,
+	}
+}
+
+/*
+GetAccountMetricsParams contains all the parameters to send to the API endpoint
+
+	for the get account metrics operation.
+
+	Typically these are written to a http.Request.
+*/
+type GetAccountMetricsParams struct {
+	timeout    time.Duration
+	Context    context.Context
+	HTTPClient *http.Client
+}
+
+// WithDefaults hydrates default values in the get account metrics params (not the query body).
+//
+// All values with no default are reset to their zero value.
+func (o *GetAccountMetricsParams) WithDefaults() *GetAccountMetricsParams {
+	o.SetDefaults()
+	return o
+}
+
+// SetDefaults hydrates default values in the get account metrics params (not the query body).
+//
+// All values with no default are reset to their zero value.
+func (o *GetAccountMetricsParams) SetDefaults() {
+	// no default values defined for this parameter
+}
+
+// WithTimeout adds the timeout to the get account metrics params
+func (o *GetAccountMetricsParams) WithTimeout(timeout time.Duration) *GetAccountMetricsParams {
+	o.SetTimeout(timeout)
+	return o
+}
+
+// SetTimeout adds the timeout to the get account metrics params
+func (o *GetAccountMetricsParams) SetTimeout(timeout time.Duration) {
+	o.timeout = timeout
+}
+
+// WithContext adds the context to the get account metrics params
+func (o *GetAccountMetricsParams) WithContext(ctx context.Context) *GetAccountMetricsParams {
+	o.SetContext(ctx)
+	return o
+}
+
+// SetContext adds the context to the get account metrics params
+func (o *GetAccountMetricsParams) SetContext(ctx context.Context) {
+	o.Context = ctx
+}
+
+// WithHTTPClient adds the HTTPClient to the get account metrics params
+func (o *GetAccountMetricsParams) WithHTTPClient(client *http.Client) *GetAccountMetricsParams {
+	o.SetHTTPClient(client)
+	return o
+}
+
+// SetHTTPClient adds the HTTPClient to the get account metrics params
+func (o *GetAccountMetricsParams) SetHTTPClient(client *http.Client) {
+	o.HTTPClient = client
+}
+
+// WriteToRequest writes these params to a swagger request
+func (o *GetAccountMetricsParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error {
+
+	if err := r.SetTimeout(o.timeout); err != nil {
+		return err
+	}
+	var res []error
+
+	if len(res) > 0 {
+		return errors.CompositeValidationError(res...)
+	}
+	return nil
+}
diff --git a/rest_client_zrok/metrics/get_account_metrics_responses.go b/rest_client_zrok/metrics/get_account_metrics_responses.go
new file mode 100644
index 00000000..4768551f
--- /dev/null
+++ b/rest_client_zrok/metrics/get_account_metrics_responses.go
@@ -0,0 +1,98 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metrics
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"fmt"
+	"io"
+
+	"github.com/go-openapi/runtime"
+	"github.com/go-openapi/strfmt"
+
+	"github.com/openziti/zrok/rest_model_zrok"
+)
+
+// GetAccountMetricsReader is a Reader for the GetAccountMetrics structure.
+type GetAccountMetricsReader struct {
+	formats strfmt.Registry
+}
+
+// ReadResponse reads a server response into the received o.
+func (o *GetAccountMetricsReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (interface{}, error) {
+	switch response.Code() {
+	case 200:
+		result := NewGetAccountMetricsOK()
+		if err := result.readResponse(response, consumer, o.formats); err != nil {
+			return nil, err
+		}
+		return result, nil
+	default:
+		return nil, runtime.NewAPIError("response status code does not match any response statuses defined for this endpoint in the swagger spec", response, response.Code())
+	}
+}
+
+// NewGetAccountMetricsOK creates a GetAccountMetricsOK with default headers values
+func NewGetAccountMetricsOK() *GetAccountMetricsOK {
+	return &GetAccountMetricsOK{}
+}
+
+/*
+GetAccountMetricsOK describes a response with status code 200, with default header values.
+
+account metrics
+*/
+type GetAccountMetricsOK struct {
+	Payload *rest_model_zrok.Metrics
+}
+
+// IsSuccess returns true when this get account metrics o k response has a 2xx status code
+func (o *GetAccountMetricsOK) IsSuccess() bool {
+	return true
+}
+
+// IsRedirect returns true when this get account metrics o k response has a 3xx status code
+func (o *GetAccountMetricsOK) IsRedirect() bool {
+	return false
+}
+
+// IsClientError returns true when this get account metrics o k response has a 4xx status code
+func (o *GetAccountMetricsOK) IsClientError() bool {
+	return false
+}
+
+// IsServerError returns true when this get account metrics o k response has a 5xx status code
+func (o *GetAccountMetricsOK) IsServerError() bool {
+	return false
+}
+
+// IsCode returns true when this get account metrics o k response a status code equal to that given
+func (o *GetAccountMetricsOK) IsCode(code int) bool {
+	return code == 200
+}
+
+func (o *GetAccountMetricsOK) Error() string {
+	return fmt.Sprintf("[GET /metrics/account][%d] getAccountMetricsOK  %+v", 200, o.Payload)
+}
+
+func (o *GetAccountMetricsOK) String() string {
+	return fmt.Sprintf("[GET /metrics/account][%d] getAccountMetricsOK  %+v", 200, o.Payload)
+}
+
+func (o *GetAccountMetricsOK) GetPayload() *rest_model_zrok.Metrics {
+	return o.Payload
+}
+
+func (o *GetAccountMetricsOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error {
+
+	o.Payload = new(rest_model_zrok.Metrics)
+
+	// response payload
+	if err := consumer.Consume(response.Body(), o.Payload); err != nil && err != io.EOF {
+		return err
+	}
+
+	return nil
+}
diff --git a/rest_client_zrok/metrics/get_environment_metrics_parameters.go b/rest_client_zrok/metrics/get_environment_metrics_parameters.go
new file mode 100644
index 00000000..538d0074
--- /dev/null
+++ b/rest_client_zrok/metrics/get_environment_metrics_parameters.go
@@ -0,0 +1,148 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metrics
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"context"
+	"net/http"
+	"time"
+
+	"github.com/go-openapi/errors"
+	"github.com/go-openapi/runtime"
+	cr "github.com/go-openapi/runtime/client"
+	"github.com/go-openapi/strfmt"
+)
+
+// NewGetEnvironmentMetricsParams creates a new GetEnvironmentMetricsParams object,
+// with the default timeout for this client.
+//
+// Default values are not hydrated, since defaults are normally applied by the API server side.
+//
+// To enforce default values in parameter, use SetDefaults or WithDefaults.
+func NewGetEnvironmentMetricsParams() *GetEnvironmentMetricsParams {
+	return &GetEnvironmentMetricsParams{
+		timeout: cr.DefaultTimeout,
+	}
+}
+
+// NewGetEnvironmentMetricsParamsWithTimeout creates a new GetEnvironmentMetricsParams object
+// with the ability to set a timeout on a request.
+func NewGetEnvironmentMetricsParamsWithTimeout(timeout time.Duration) *GetEnvironmentMetricsParams {
+	return &GetEnvironmentMetricsParams{
+		timeout: timeout,
+	}
+}
+
+// NewGetEnvironmentMetricsParamsWithContext creates a new GetEnvironmentMetricsParams object
+// with the ability to set a context for a request.
+func NewGetEnvironmentMetricsParamsWithContext(ctx context.Context) *GetEnvironmentMetricsParams {
+	return &GetEnvironmentMetricsParams{
+		Context: ctx,
+	}
+}
+
+// NewGetEnvironmentMetricsParamsWithHTTPClient creates a new GetEnvironmentMetricsParams object
+// with the ability to set a custom HTTPClient for a request.
+func NewGetEnvironmentMetricsParamsWithHTTPClient(client *http.Client) *GetEnvironmentMetricsParams {
+	return &GetEnvironmentMetricsParams{
+		HTTPClient: client,
+	}
+}
+
+/*
+GetEnvironmentMetricsParams contains all the parameters to send to the API endpoint
+
+	for the get environment metrics operation.
+
+	Typically these are written to a http.Request.
+*/
+type GetEnvironmentMetricsParams struct {
+
+	// EnvID.
+	EnvID string
+
+	timeout    time.Duration
+	Context    context.Context
+	HTTPClient *http.Client
+}
+
+// WithDefaults hydrates default values in the get environment metrics params (not the query body).
+//
+// All values with no default are reset to their zero value.
+func (o *GetEnvironmentMetricsParams) WithDefaults() *GetEnvironmentMetricsParams {
+	o.SetDefaults()
+	return o
+}
+
+// SetDefaults hydrates default values in the get environment metrics params (not the query body).
+//
+// All values with no default are reset to their zero value.
+func (o *GetEnvironmentMetricsParams) SetDefaults() {
+	// no default values defined for this parameter
+}
+
+// WithTimeout adds the timeout to the get environment metrics params
+func (o *GetEnvironmentMetricsParams) WithTimeout(timeout time.Duration) *GetEnvironmentMetricsParams {
+	o.SetTimeout(timeout)
+	return o
+}
+
+// SetTimeout adds the timeout to the get environment metrics params
+func (o *GetEnvironmentMetricsParams) SetTimeout(timeout time.Duration) {
+	o.timeout = timeout
+}
+
+// WithContext adds the context to the get environment metrics params
+func (o *GetEnvironmentMetricsParams) WithContext(ctx context.Context) *GetEnvironmentMetricsParams {
+	o.SetContext(ctx)
+	return o
+}
+
+// SetContext adds the context to the get environment metrics params
+func (o *GetEnvironmentMetricsParams) SetContext(ctx context.Context) {
+	o.Context = ctx
+}
+
+// WithHTTPClient adds the HTTPClient to the get environment metrics params
+func (o *GetEnvironmentMetricsParams) WithHTTPClient(client *http.Client) *GetEnvironmentMetricsParams {
+	o.SetHTTPClient(client)
+	return o
+}
+
+// SetHTTPClient adds the HTTPClient to the get environment metrics params
+func (o *GetEnvironmentMetricsParams) SetHTTPClient(client *http.Client) {
+	o.HTTPClient = client
+}
+
+// WithEnvID adds the envID to the get environment metrics params
+func (o *GetEnvironmentMetricsParams) WithEnvID(envID string) *GetEnvironmentMetricsParams {
+	o.SetEnvID(envID)
+	return o
+}
+
+// SetEnvID adds the envId to the get environment metrics params
+func (o *GetEnvironmentMetricsParams) SetEnvID(envID string) {
+	o.EnvID = envID
+}
+
+// WriteToRequest writes these params to a swagger request
+func (o *GetEnvironmentMetricsParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error {
+
+	if err := r.SetTimeout(o.timeout); err != nil {
+		return err
+	}
+	var res []error
+
+	// path param envId
+	if err := r.SetPathParam("envId", o.EnvID); err != nil {
+		return err
+	}
+
+	if len(res) > 0 {
+		return errors.CompositeValidationError(res...)
+	}
+	return nil
+}
diff --git a/rest_client_zrok/metrics/get_environment_metrics_responses.go b/rest_client_zrok/metrics/get_environment_metrics_responses.go
new file mode 100644
index 00000000..9f555e8b
--- /dev/null
+++ b/rest_client_zrok/metrics/get_environment_metrics_responses.go
@@ -0,0 +1,155 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metrics
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"fmt"
+	"io"
+
+	"github.com/go-openapi/runtime"
+	"github.com/go-openapi/strfmt"
+
+	"github.com/openziti/zrok/rest_model_zrok"
+)
+
+// GetEnvironmentMetricsReader is a Reader for the GetEnvironmentMetrics structure.
+type GetEnvironmentMetricsReader struct {
+	formats strfmt.Registry
+}
+
+// ReadResponse reads a server response into the received o.
+func (o *GetEnvironmentMetricsReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (interface{}, error) {
+	switch response.Code() {
+	case 200:
+		result := NewGetEnvironmentMetricsOK()
+		if err := result.readResponse(response, consumer, o.formats); err != nil {
+			return nil, err
+		}
+		return result, nil
+	case 401:
+		result := NewGetEnvironmentMetricsUnauthorized()
+		if err := result.readResponse(response, consumer, o.formats); err != nil {
+			return nil, err
+		}
+		return nil, result
+	default:
+		return nil, runtime.NewAPIError("response status code does not match any response statuses defined for this endpoint in the swagger spec", response, response.Code())
+	}
+}
+
+// NewGetEnvironmentMetricsOK creates a GetEnvironmentMetricsOK with default headers values
+func NewGetEnvironmentMetricsOK() *GetEnvironmentMetricsOK {
+	return &GetEnvironmentMetricsOK{}
+}
+
+/*
+GetEnvironmentMetricsOK describes a response with status code 200, with default header values.
+
+environment metrics
+*/
+type GetEnvironmentMetricsOK struct {
+	Payload *rest_model_zrok.Metrics
+}
+
+// IsSuccess returns true when this get environment metrics o k response has a 2xx status code
+func (o *GetEnvironmentMetricsOK) IsSuccess() bool {
+	return true
+}
+
+// IsRedirect returns true when this get environment metrics o k response has a 3xx status code
+func (o *GetEnvironmentMetricsOK) IsRedirect() bool {
+	return false
+}
+
+// IsClientError returns true when this get environment metrics o k response has a 4xx status code
+func (o *GetEnvironmentMetricsOK) IsClientError() bool {
+	return false
+}
+
+// IsServerError returns true when this get environment metrics o k response has a 5xx status code
+func (o *GetEnvironmentMetricsOK) IsServerError() bool {
+	return false
+}
+
+// IsCode returns true when this get environment metrics o k response a status code equal to that given
+func (o *GetEnvironmentMetricsOK) IsCode(code int) bool {
+	return code == 200
+}
+
+func (o *GetEnvironmentMetricsOK) Error() string {
+	return fmt.Sprintf("[GET /metrics/environment/{envId}][%d] getEnvironmentMetricsOK  %+v", 200, o.Payload)
+}
+
+func (o *GetEnvironmentMetricsOK) String() string {
+	return fmt.Sprintf("[GET /metrics/environment/{envId}][%d] getEnvironmentMetricsOK  %+v", 200, o.Payload)
+}
+
+func (o *GetEnvironmentMetricsOK) GetPayload() *rest_model_zrok.Metrics {
+	return o.Payload
+}
+
+func (o *GetEnvironmentMetricsOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error {
+
+	o.Payload = new(rest_model_zrok.Metrics)
+
+	// response payload
+	if err := consumer.Consume(response.Body(), o.Payload); err != nil && err != io.EOF {
+		return err
+	}
+
+	return nil
+}
+
+// NewGetEnvironmentMetricsUnauthorized creates a GetEnvironmentMetricsUnauthorized with default headers values
+func NewGetEnvironmentMetricsUnauthorized() *GetEnvironmentMetricsUnauthorized {
+	return &GetEnvironmentMetricsUnauthorized{}
+}
+
+/*
+GetEnvironmentMetricsUnauthorized describes a response with status code 401, with default header values.
+
+unauthorized
+*/
+type GetEnvironmentMetricsUnauthorized struct {
+}
+
+// IsSuccess returns true when this get environment metrics unauthorized response has a 2xx status code
+func (o *GetEnvironmentMetricsUnauthorized) IsSuccess() bool {
+	return false
+}
+
+// IsRedirect returns true when this get environment metrics unauthorized response has a 3xx status code
+func (o *GetEnvironmentMetricsUnauthorized) IsRedirect() bool {
+	return false
+}
+
+// IsClientError returns true when this get environment metrics unauthorized response has a 4xx status code
+func (o *GetEnvironmentMetricsUnauthorized) IsClientError() bool {
+	return true
+}
+
+// IsServerError returns true when this get environment metrics unauthorized response has a 5xx status code
+func (o *GetEnvironmentMetricsUnauthorized) IsServerError() bool {
+	return false
+}
+
+// IsCode returns true when this get environment metrics unauthorized response a status code equal to that given
+func (o *GetEnvironmentMetricsUnauthorized) IsCode(code int) bool {
+	return code == 401
+}
+
+func (o *GetEnvironmentMetricsUnauthorized) Error() string {
+	return fmt.Sprintf("[GET /metrics/environment/{envId}][%d] getEnvironmentMetricsUnauthorized ", 401)
+}
+
+func (o *GetEnvironmentMetricsUnauthorized) String() string {
+	return fmt.Sprintf("[GET /metrics/environment/{envId}][%d] getEnvironmentMetricsUnauthorized ", 401)
+}
+
+func (o *GetEnvironmentMetricsUnauthorized) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error {
+
+	return nil
+}
diff --git a/rest_client_zrok/metrics/get_share_metrics_parameters.go b/rest_client_zrok/metrics/get_share_metrics_parameters.go
new file mode 100644
index 00000000..690b280f
--- /dev/null
+++ b/rest_client_zrok/metrics/get_share_metrics_parameters.go
@@ -0,0 +1,148 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metrics
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"context"
+	"net/http"
+	"time"
+
+	"github.com/go-openapi/errors"
+	"github.com/go-openapi/runtime"
+	cr "github.com/go-openapi/runtime/client"
+	"github.com/go-openapi/strfmt"
+)
+
+// NewGetShareMetricsParams creates a new GetShareMetricsParams object,
+// with the default timeout for this client.
+//
+// Default values are not hydrated, since defaults are normally applied by the API server side.
+//
+// To enforce default values in parameter, use SetDefaults or WithDefaults.
+func NewGetShareMetricsParams() *GetShareMetricsParams {
+	return &GetShareMetricsParams{
+		timeout: cr.DefaultTimeout,
+	}
+}
+
+// NewGetShareMetricsParamsWithTimeout creates a new GetShareMetricsParams object
+// with the ability to set a timeout on a request.
+func NewGetShareMetricsParamsWithTimeout(timeout time.Duration) *GetShareMetricsParams {
+	return &GetShareMetricsParams{
+		timeout: timeout,
+	}
+}
+
+// NewGetShareMetricsParamsWithContext creates a new GetShareMetricsParams object
+// with the ability to set a context for a request.
+func NewGetShareMetricsParamsWithContext(ctx context.Context) *GetShareMetricsParams {
+	return &GetShareMetricsParams{
+		Context: ctx,
+	}
+}
+
+// NewGetShareMetricsParamsWithHTTPClient creates a new GetShareMetricsParams object
+// with the ability to set a custom HTTPClient for a request.
+func NewGetShareMetricsParamsWithHTTPClient(client *http.Client) *GetShareMetricsParams {
+	return &GetShareMetricsParams{
+		HTTPClient: client,
+	}
+}
+
+/*
+GetShareMetricsParams contains all the parameters to send to the API endpoint
+
+	for the get share metrics operation.
+
+	Typically these are written to a http.Request.
+*/
+type GetShareMetricsParams struct {
+
+	// ShrToken.
+	ShrToken string
+
+	timeout    time.Duration
+	Context    context.Context
+	HTTPClient *http.Client
+}
+
+// WithDefaults hydrates default values in the get share metrics params (not the query body).
+//
+// All values with no default are reset to their zero value.
+func (o *GetShareMetricsParams) WithDefaults() *GetShareMetricsParams {
+	o.SetDefaults()
+	return o
+}
+
+// SetDefaults hydrates default values in the get share metrics params (not the query body).
+//
+// All values with no default are reset to their zero value.
+func (o *GetShareMetricsParams) SetDefaults() {
+	// no default values defined for this parameter
+}
+
+// WithTimeout adds the timeout to the get share metrics params
+func (o *GetShareMetricsParams) WithTimeout(timeout time.Duration) *GetShareMetricsParams {
+	o.SetTimeout(timeout)
+	return o
+}
+
+// SetTimeout adds the timeout to the get share metrics params
+func (o *GetShareMetricsParams) SetTimeout(timeout time.Duration) {
+	o.timeout = timeout
+}
+
+// WithContext adds the context to the get share metrics params
+func (o *GetShareMetricsParams) WithContext(ctx context.Context) *GetShareMetricsParams {
+	o.SetContext(ctx)
+	return o
+}
+
+// SetContext adds the context to the get share metrics params
+func (o *GetShareMetricsParams) SetContext(ctx context.Context) {
+	o.Context = ctx
+}
+
+// WithHTTPClient adds the HTTPClient to the get share metrics params
+func (o *GetShareMetricsParams) WithHTTPClient(client *http.Client) *GetShareMetricsParams {
+	o.SetHTTPClient(client)
+	return o
+}
+
+// SetHTTPClient adds the HTTPClient to the get share metrics params
+func (o *GetShareMetricsParams) SetHTTPClient(client *http.Client) {
+	o.HTTPClient = client
+}
+
+// WithShrToken adds the shrToken to the get share metrics params
+func (o *GetShareMetricsParams) WithShrToken(shrToken string) *GetShareMetricsParams {
+	o.SetShrToken(shrToken)
+	return o
+}
+
+// SetShrToken adds the shrToken to the get share metrics params
+func (o *GetShareMetricsParams) SetShrToken(shrToken string) {
+	o.ShrToken = shrToken
+}
+
+// WriteToRequest writes these params to a swagger request
+func (o *GetShareMetricsParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error {
+
+	if err := r.SetTimeout(o.timeout); err != nil {
+		return err
+	}
+	var res []error
+
+	// path param shrToken
+	if err := r.SetPathParam("shrToken", o.ShrToken); err != nil {
+		return err
+	}
+
+	if len(res) > 0 {
+		return errors.CompositeValidationError(res...)
+	}
+	return nil
+}
diff --git a/rest_client_zrok/metrics/get_share_metrics_responses.go b/rest_client_zrok/metrics/get_share_metrics_responses.go
new file mode 100644
index 00000000..9f99df33
--- /dev/null
+++ b/rest_client_zrok/metrics/get_share_metrics_responses.go
@@ -0,0 +1,155 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metrics
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"fmt"
+	"io"
+
+	"github.com/go-openapi/runtime"
+	"github.com/go-openapi/strfmt"
+
+	"github.com/openziti/zrok/rest_model_zrok"
+)
+
+// GetShareMetricsReader is a Reader for the GetShareMetrics structure.
+type GetShareMetricsReader struct {
+	formats strfmt.Registry
+}
+
+// ReadResponse reads a server response into the received o.
+func (o *GetShareMetricsReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (interface{}, error) {
+	switch response.Code() {
+	case 200:
+		result := NewGetShareMetricsOK()
+		if err := result.readResponse(response, consumer, o.formats); err != nil {
+			return nil, err
+		}
+		return result, nil
+	case 401:
+		result := NewGetShareMetricsUnauthorized()
+		if err := result.readResponse(response, consumer, o.formats); err != nil {
+			return nil, err
+		}
+		return nil, result
+	default:
+		return nil, runtime.NewAPIError("response status code does not match any response statuses defined for this endpoint in the swagger spec", response, response.Code())
+	}
+}
+
+// NewGetShareMetricsOK creates a GetShareMetricsOK with default headers values
+func NewGetShareMetricsOK() *GetShareMetricsOK {
+	return &GetShareMetricsOK{}
+}
+
+/*
+GetShareMetricsOK describes a response with status code 200, with default header values.
+
+share metrics
+*/
+type GetShareMetricsOK struct {
+	Payload *rest_model_zrok.Metrics
+}
+
+// IsSuccess returns true when this get share metrics o k response has a 2xx status code
+func (o *GetShareMetricsOK) IsSuccess() bool {
+	return true
+}
+
+// IsRedirect returns true when this get share metrics o k response has a 3xx status code
+func (o *GetShareMetricsOK) IsRedirect() bool {
+	return false
+}
+
+// IsClientError returns true when this get share metrics o k response has a 4xx status code
+func (o *GetShareMetricsOK) IsClientError() bool {
+	return false
+}
+
+// IsServerError returns true when this get share metrics o k response has a 5xx status code
+func (o *GetShareMetricsOK) IsServerError() bool {
+	return false
+}
+
+// IsCode returns true when this get share metrics o k response a status code equal to that given
+func (o *GetShareMetricsOK) IsCode(code int) bool {
+	return code == 200
+}
+
+func (o *GetShareMetricsOK) Error() string {
+	return fmt.Sprintf("[GET /metrics/share/{shrToken}][%d] getShareMetricsOK  %+v", 200, o.Payload)
+}
+
+func (o *GetShareMetricsOK) String() string {
+	return fmt.Sprintf("[GET /metrics/share/{shrToken}][%d] getShareMetricsOK  %+v", 200, o.Payload)
+}
+
+func (o *GetShareMetricsOK) GetPayload() *rest_model_zrok.Metrics {
+	return o.Payload
+}
+
+func (o *GetShareMetricsOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error {
+
+	o.Payload = new(rest_model_zrok.Metrics)
+
+	// response payload
+	if err := consumer.Consume(response.Body(), o.Payload); err != nil && err != io.EOF {
+		return err
+	}
+
+	return nil
+}
+
+// NewGetShareMetricsUnauthorized creates a GetShareMetricsUnauthorized with default headers values
+func NewGetShareMetricsUnauthorized() *GetShareMetricsUnauthorized {
+	return &GetShareMetricsUnauthorized{}
+}
+
+/*
+GetShareMetricsUnauthorized describes a response with status code 401, with default header values.
+
+unauthorized
+*/
+type GetShareMetricsUnauthorized struct {
+}
+
+// IsSuccess returns true when this get share metrics unauthorized response has a 2xx status code
+func (o *GetShareMetricsUnauthorized) IsSuccess() bool {
+	return false
+}
+
+// IsRedirect returns true when this get share metrics unauthorized response has a 3xx status code
+func (o *GetShareMetricsUnauthorized) IsRedirect() bool {
+	return false
+}
+
+// IsClientError returns true when this get share metrics unauthorized response has a 4xx status code
+func (o *GetShareMetricsUnauthorized) IsClientError() bool {
+	return true
+}
+
+// IsServerError returns true when this get share metrics unauthorized response has a 5xx status code
+func (o *GetShareMetricsUnauthorized) IsServerError() bool {
+	return false
+}
+
+// IsCode returns true when this get share metrics unauthorized response a status code equal to that given
+func (o *GetShareMetricsUnauthorized) IsCode(code int) bool {
+	return code == 401
+}
+
+func (o *GetShareMetricsUnauthorized) Error() string {
+	return fmt.Sprintf("[GET /metrics/share/{shrToken}][%d] getShareMetricsUnauthorized ", 401)
+}
+
+func (o *GetShareMetricsUnauthorized) String() string {
+	return fmt.Sprintf("[GET /metrics/share/{shrToken}][%d] getShareMetricsUnauthorized ", 401)
+}
+
+func (o *GetShareMetricsUnauthorized) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error {
+
+	return nil
+}
diff --git a/rest_client_zrok/metrics/metrics_client.go b/rest_client_zrok/metrics/metrics_client.go
new file mode 100644
index 00000000..b1f4ab1f
--- /dev/null
+++ b/rest_client_zrok/metrics/metrics_client.go
@@ -0,0 +1,162 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metrics
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"fmt"
+
+	"github.com/go-openapi/runtime"
+	"github.com/go-openapi/strfmt"
+)
+
+// New creates a new metrics API client.
+func New(transport runtime.ClientTransport, formats strfmt.Registry) ClientService {
+	return &Client{transport: transport, formats: formats}
+}
+
+/*
+Client for metrics API
+*/
+type Client struct {
+	transport runtime.ClientTransport
+	formats   strfmt.Registry
+}
+
+// ClientOption is the option for Client methods
+type ClientOption func(*runtime.ClientOperation)
+
+// ClientService is the interface for Client methods
+type ClientService interface {
+	GetAccountMetrics(params *GetAccountMetricsParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*GetAccountMetricsOK, error)
+
+	GetEnvironmentMetrics(params *GetEnvironmentMetricsParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*GetEnvironmentMetricsOK, error)
+
+	GetShareMetrics(params *GetShareMetricsParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*GetShareMetricsOK, error)
+
+	SetTransport(transport runtime.ClientTransport)
+}
+
+/*
+GetAccountMetrics get account metrics API
+*/
+func (a *Client) GetAccountMetrics(params *GetAccountMetricsParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*GetAccountMetricsOK, error) {
+	// TODO: Validate the params before sending
+	if params == nil {
+		params = NewGetAccountMetricsParams()
+	}
+	op := &runtime.ClientOperation{
+		ID:                 "getAccountMetrics",
+		Method:             "GET",
+		PathPattern:        "/metrics/account",
+		ProducesMediaTypes: []string{"application/zrok.v1+json"},
+		ConsumesMediaTypes: []string{"application/zrok.v1+json"},
+		Schemes:            []string{"http"},
+		Params:             params,
+		Reader:             &GetAccountMetricsReader{formats: a.formats},
+		AuthInfo:           authInfo,
+		Context:            params.Context,
+		Client:             params.HTTPClient,
+	}
+	for _, opt := range opts {
+		opt(op)
+	}
+
+	result, err := a.transport.Submit(op)
+	if err != nil {
+		return nil, err
+	}
+	success, ok := result.(*GetAccountMetricsOK)
+	if ok {
+		return success, nil
+	}
+	// unexpected success response
+	// safeguard: normally, absent a default response, unknown success responses return an error above: so this is a codegen issue
+	msg := fmt.Sprintf("unexpected success response for getAccountMetrics: API contract not enforced by server. Client expected to get an error, but got: %T", result)
+	panic(msg)
+}
+
+/*
+GetEnvironmentMetrics get environment metrics API
+*/
+func (a *Client) GetEnvironmentMetrics(params *GetEnvironmentMetricsParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*GetEnvironmentMetricsOK, error) {
+	// TODO: Validate the params before sending
+	if params == nil {
+		params = NewGetEnvironmentMetricsParams()
+	}
+	op := &runtime.ClientOperation{
+		ID:                 "getEnvironmentMetrics",
+		Method:             "GET",
+		PathPattern:        "/metrics/environment/{envId}",
+		ProducesMediaTypes: []string{"application/zrok.v1+json"},
+		ConsumesMediaTypes: []string{"application/zrok.v1+json"},
+		Schemes:            []string{"http"},
+		Params:             params,
+		Reader:             &GetEnvironmentMetricsReader{formats: a.formats},
+		AuthInfo:           authInfo,
+		Context:            params.Context,
+		Client:             params.HTTPClient,
+	}
+	for _, opt := range opts {
+		opt(op)
+	}
+
+	result, err := a.transport.Submit(op)
+	if err != nil {
+		return nil, err
+	}
+	success, ok := result.(*GetEnvironmentMetricsOK)
+	if ok {
+		return success, nil
+	}
+	// unexpected success response
+	// safeguard: normally, absent a default response, unknown success responses return an error above: so this is a codegen issue
+	msg := fmt.Sprintf("unexpected success response for getEnvironmentMetrics: API contract not enforced by server. Client expected to get an error, but got: %T", result)
+	panic(msg)
+}
+
+/*
+GetShareMetrics get share metrics API
+*/
+func (a *Client) GetShareMetrics(params *GetShareMetricsParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*GetShareMetricsOK, error) {
+	// TODO: Validate the params before sending
+	if params == nil {
+		params = NewGetShareMetricsParams()
+	}
+	op := &runtime.ClientOperation{
+		ID:                 "getShareMetrics",
+		Method:             "GET",
+		PathPattern:        "/metrics/share/{shrToken}",
+		ProducesMediaTypes: []string{"application/zrok.v1+json"},
+		ConsumesMediaTypes: []string{"application/zrok.v1+json"},
+		Schemes:            []string{"http"},
+		Params:             params,
+		Reader:             &GetShareMetricsReader{formats: a.formats},
+		AuthInfo:           authInfo,
+		Context:            params.Context,
+		Client:             params.HTTPClient,
+	}
+	for _, opt := range opts {
+		opt(op)
+	}
+
+	result, err := a.transport.Submit(op)
+	if err != nil {
+		return nil, err
+	}
+	success, ok := result.(*GetShareMetricsOK)
+	if ok {
+		return success, nil
+	}
+	// unexpected success response
+	// safeguard: normally, absent a default response, unknown success responses return an error above: so this is a codegen issue
+	msg := fmt.Sprintf("unexpected success response for getShareMetrics: API contract not enforced by server. Client expected to get an error, but got: %T", result)
+	panic(msg)
+}
+
+// SetTransport changes the transport on the client
+func (a *Client) SetTransport(transport runtime.ClientTransport) {
+	a.transport = transport
+}
diff --git a/rest_client_zrok/zrok_client.go b/rest_client_zrok/zrok_client.go
index a4200e67..c5f02b8c 100644
--- a/rest_client_zrok/zrok_client.go
+++ b/rest_client_zrok/zrok_client.go
@@ -14,6 +14,7 @@ import (
 	"github.com/openziti/zrok/rest_client_zrok/admin"
 	"github.com/openziti/zrok/rest_client_zrok/environment"
 	"github.com/openziti/zrok/rest_client_zrok/metadata"
+	"github.com/openziti/zrok/rest_client_zrok/metrics"
 	"github.com/openziti/zrok/rest_client_zrok/share"
 )
 
@@ -63,6 +64,7 @@ func New(transport runtime.ClientTransport, formats strfmt.Registry) *Zrok {
 	cli.Admin = admin.New(transport, formats)
 	cli.Environment = environment.New(transport, formats)
 	cli.Metadata = metadata.New(transport, formats)
+	cli.Metrics = metrics.New(transport, formats)
 	cli.Share = share.New(transport, formats)
 	return cli
 }
@@ -116,6 +118,8 @@ type Zrok struct {
 
 	Metadata metadata.ClientService
 
+	Metrics metrics.ClientService
+
 	Share share.ClientService
 
 	Transport runtime.ClientTransport
@@ -128,5 +132,6 @@ func (c *Zrok) SetTransport(transport runtime.ClientTransport) {
 	c.Admin.SetTransport(transport)
 	c.Environment.SetTransport(transport)
 	c.Metadata.SetTransport(transport)
+	c.Metrics.SetTransport(transport)
 	c.Share.SetTransport(transport)
 }
diff --git a/rest_model_zrok/metrics.go b/rest_model_zrok/metrics.go
new file mode 100644
index 00000000..dfb9bf08
--- /dev/null
+++ b/rest_model_zrok/metrics.go
@@ -0,0 +1,62 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package rest_model_zrok
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"context"
+
+	"github.com/go-openapi/strfmt"
+	"github.com/go-openapi/swag"
+)
+
+// Metrics metrics
+//
+// swagger:model metrics
+type Metrics struct {
+
+	// id
+	ID string `json:"id,omitempty"`
+
+	// period
+	Period float64 `json:"period,omitempty"`
+
+	// rx
+	Rx []float64 `json:"rx"`
+
+	// scope
+	Scope string `json:"scope,omitempty"`
+
+	// tx
+	Tx []float64 `json:"tx"`
+}
+
+// Validate validates this metrics
+func (m *Metrics) Validate(formats strfmt.Registry) error {
+	return nil
+}
+
+// ContextValidate validates this metrics based on context it is used
+func (m *Metrics) ContextValidate(ctx context.Context, formats strfmt.Registry) error {
+	return nil
+}
+
+// MarshalBinary interface implementation
+func (m *Metrics) MarshalBinary() ([]byte, error) {
+	if m == nil {
+		return nil, nil
+	}
+	return swag.WriteJSON(m)
+}
+
+// UnmarshalBinary interface implementation
+func (m *Metrics) UnmarshalBinary(b []byte) error {
+	var res Metrics
+	if err := swag.ReadJSON(b, &res); err != nil {
+		return err
+	}
+	*m = res
+	return nil
+}
diff --git a/rest_server_zrok/embedded_spec.go b/rest_server_zrok/embedded_spec.go
index 5560d26d..59341826 100644
--- a/rest_server_zrok/embedded_spec.go
+++ b/rest_server_zrok/embedded_spec.go
@@ -520,6 +520,91 @@ func init() {
         }
       }
     },
+    "/metrics/account": {
+      "get": {
+        "security": [
+          {
+            "key": []
+          }
+        ],
+        "tags": [
+          "metrics"
+        ],
+        "operationId": "getAccountMetrics",
+        "responses": {
+          "200": {
+            "description": "account metrics",
+            "schema": {
+              "$ref": "#/definitions/metrics"
+            }
+          }
+        }
+      }
+    },
+    "/metrics/environment/{envId}": {
+      "get": {
+        "security": [
+          {
+            "key": []
+          }
+        ],
+        "tags": [
+          "metrics"
+        ],
+        "operationId": "getEnvironmentMetrics",
+        "parameters": [
+          {
+            "type": "string",
+            "name": "envId",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "environment metrics",
+            "schema": {
+              "$ref": "#/definitions/metrics"
+            }
+          },
+          "401": {
+            "description": "unauthorized"
+          }
+        }
+      }
+    },
+    "/metrics/share/{shrToken}": {
+      "get": {
+        "security": [
+          {
+            "key": []
+          }
+        ],
+        "tags": [
+          "metrics"
+        ],
+        "operationId": "getShareMetrics",
+        "parameters": [
+          {
+            "type": "string",
+            "name": "shrToken",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "share metrics",
+            "schema": {
+              "$ref": "#/definitions/metrics"
+            }
+          },
+          "401": {
+            "description": "unauthorized"
+          }
+        }
+      }
+    },
     "/overview": {
       "get": {
         "security": [
@@ -1030,6 +1115,32 @@ func init() {
     "loginResponse": {
       "type": "string"
     },
+    "metrics": {
+      "type": "object",
+      "properties": {
+        "id": {
+          "type": "string"
+        },
+        "period": {
+          "type": "number"
+        },
+        "rx": {
+          "type": "array",
+          "items": {
+            "type": "number"
+          }
+        },
+        "scope": {
+          "type": "string"
+        },
+        "tx": {
+          "type": "array",
+          "items": {
+            "type": "number"
+          }
+        }
+      }
+    },
     "principal": {
       "type": "object",
       "properties": {
@@ -1802,6 +1913,91 @@ func init() {
         }
       }
     },
+    "/metrics/account": {
+      "get": {
+        "security": [
+          {
+            "key": []
+          }
+        ],
+        "tags": [
+          "metrics"
+        ],
+        "operationId": "getAccountMetrics",
+        "responses": {
+          "200": {
+            "description": "account metrics",
+            "schema": {
+              "$ref": "#/definitions/metrics"
+            }
+          }
+        }
+      }
+    },
+    "/metrics/environment/{envId}": {
+      "get": {
+        "security": [
+          {
+            "key": []
+          }
+        ],
+        "tags": [
+          "metrics"
+        ],
+        "operationId": "getEnvironmentMetrics",
+        "parameters": [
+          {
+            "type": "string",
+            "name": "envId",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "environment metrics",
+            "schema": {
+              "$ref": "#/definitions/metrics"
+            }
+          },
+          "401": {
+            "description": "unauthorized"
+          }
+        }
+      }
+    },
+    "/metrics/share/{shrToken}": {
+      "get": {
+        "security": [
+          {
+            "key": []
+          }
+        ],
+        "tags": [
+          "metrics"
+        ],
+        "operationId": "getShareMetrics",
+        "parameters": [
+          {
+            "type": "string",
+            "name": "shrToken",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "share metrics",
+            "schema": {
+              "$ref": "#/definitions/metrics"
+            }
+          },
+          "401": {
+            "description": "unauthorized"
+          }
+        }
+      }
+    },
     "/overview": {
       "get": {
         "security": [
@@ -2312,6 +2508,32 @@ func init() {
     "loginResponse": {
       "type": "string"
     },
+    "metrics": {
+      "type": "object",
+      "properties": {
+        "id": {
+          "type": "string"
+        },
+        "period": {
+          "type": "number"
+        },
+        "rx": {
+          "type": "array",
+          "items": {
+            "type": "number"
+          }
+        },
+        "scope": {
+          "type": "string"
+        },
+        "tx": {
+          "type": "array",
+          "items": {
+            "type": "number"
+          }
+        }
+      }
+    },
     "principal": {
       "type": "object",
       "properties": {
diff --git a/rest_server_zrok/operations/metrics/get_account_metrics.go b/rest_server_zrok/operations/metrics/get_account_metrics.go
new file mode 100644
index 00000000..d3fcab7f
--- /dev/null
+++ b/rest_server_zrok/operations/metrics/get_account_metrics.go
@@ -0,0 +1,71 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metrics
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the generate command
+
+import (
+	"net/http"
+
+	"github.com/go-openapi/runtime/middleware"
+
+	"github.com/openziti/zrok/rest_model_zrok"
+)
+
+// GetAccountMetricsHandlerFunc turns a function with the right signature into a get account metrics handler
+type GetAccountMetricsHandlerFunc func(GetAccountMetricsParams, *rest_model_zrok.Principal) middleware.Responder
+
+// Handle executing the request and returning a response
+func (fn GetAccountMetricsHandlerFunc) Handle(params GetAccountMetricsParams, principal *rest_model_zrok.Principal) middleware.Responder {
+	return fn(params, principal)
+}
+
+// GetAccountMetricsHandler interface for that can handle valid get account metrics params
+type GetAccountMetricsHandler interface {
+	Handle(GetAccountMetricsParams, *rest_model_zrok.Principal) middleware.Responder
+}
+
+// NewGetAccountMetrics creates a new http.Handler for the get account metrics operation
+func NewGetAccountMetrics(ctx *middleware.Context, handler GetAccountMetricsHandler) *GetAccountMetrics {
+	return &GetAccountMetrics{Context: ctx, Handler: handler}
+}
+
+/*
+	GetAccountMetrics swagger:route GET /metrics/account metrics getAccountMetrics
+
+GetAccountMetrics get account metrics API
+*/
+type GetAccountMetrics struct {
+	Context *middleware.Context
+	Handler GetAccountMetricsHandler
+}
+
+func (o *GetAccountMetrics) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
+	route, rCtx, _ := o.Context.RouteInfo(r)
+	if rCtx != nil {
+		*r = *rCtx
+	}
+	var Params = NewGetAccountMetricsParams()
+	uprinc, aCtx, err := o.Context.Authorize(r, route)
+	if err != nil {
+		o.Context.Respond(rw, r, route.Produces, route, err)
+		return
+	}
+	if aCtx != nil {
+		*r = *aCtx
+	}
+	var principal *rest_model_zrok.Principal
+	if uprinc != nil {
+		principal = uprinc.(*rest_model_zrok.Principal) // this is really a rest_model_zrok.Principal, I promise
+	}
+
+	if err := o.Context.BindValidRequest(r, route, &Params); err != nil { // bind params
+		o.Context.Respond(rw, r, route.Produces, route, err)
+		return
+	}
+
+	res := o.Handler.Handle(Params, principal) // actually handle the request
+	o.Context.Respond(rw, r, route.Produces, route, res)
+
+}
diff --git a/rest_server_zrok/operations/metrics/get_account_metrics_parameters.go b/rest_server_zrok/operations/metrics/get_account_metrics_parameters.go
new file mode 100644
index 00000000..98bb9b8c
--- /dev/null
+++ b/rest_server_zrok/operations/metrics/get_account_metrics_parameters.go
@@ -0,0 +1,46 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metrics
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"net/http"
+
+	"github.com/go-openapi/errors"
+	"github.com/go-openapi/runtime/middleware"
+)
+
+// NewGetAccountMetricsParams creates a new GetAccountMetricsParams object
+//
+// There are no default values defined in the spec.
+func NewGetAccountMetricsParams() GetAccountMetricsParams {
+
+	return GetAccountMetricsParams{}
+}
+
+// GetAccountMetricsParams contains all the bound params for the get account metrics operation
+// typically these are obtained from a http.Request
+//
+// swagger:parameters getAccountMetrics
+type GetAccountMetricsParams struct {
+
+	// HTTP Request Object
+	HTTPRequest *http.Request `json:"-"`
+}
+
+// BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface
+// for simple values it will use straight method calls.
+//
+// To ensure default values, the struct must have been initialized with NewGetAccountMetricsParams() beforehand.
+func (o *GetAccountMetricsParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error {
+	var res []error
+
+	o.HTTPRequest = r
+
+	if len(res) > 0 {
+		return errors.CompositeValidationError(res...)
+	}
+	return nil
+}
diff --git a/rest_server_zrok/operations/metrics/get_account_metrics_responses.go b/rest_server_zrok/operations/metrics/get_account_metrics_responses.go
new file mode 100644
index 00000000..78480b51
--- /dev/null
+++ b/rest_server_zrok/operations/metrics/get_account_metrics_responses.go
@@ -0,0 +1,59 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metrics
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"net/http"
+
+	"github.com/go-openapi/runtime"
+
+	"github.com/openziti/zrok/rest_model_zrok"
+)
+
+// GetAccountMetricsOKCode is the HTTP code returned for type GetAccountMetricsOK
+const GetAccountMetricsOKCode int = 200
+
+/*
+GetAccountMetricsOK account metrics
+
+swagger:response getAccountMetricsOK
+*/
+type GetAccountMetricsOK struct {
+
+	/*
+	  In: Body
+	*/
+	Payload *rest_model_zrok.Metrics `json:"body,omitempty"`
+}
+
+// NewGetAccountMetricsOK creates GetAccountMetricsOK with default headers values
+func NewGetAccountMetricsOK() *GetAccountMetricsOK {
+
+	return &GetAccountMetricsOK{}
+}
+
+// WithPayload adds the payload to the get account metrics o k response
+func (o *GetAccountMetricsOK) WithPayload(payload *rest_model_zrok.Metrics) *GetAccountMetricsOK {
+	o.Payload = payload
+	return o
+}
+
+// SetPayload sets the payload to the get account metrics o k response
+func (o *GetAccountMetricsOK) SetPayload(payload *rest_model_zrok.Metrics) {
+	o.Payload = payload
+}
+
+// WriteResponse to the client
+func (o *GetAccountMetricsOK) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) {
+
+	rw.WriteHeader(200)
+	if o.Payload != nil {
+		payload := o.Payload
+		if err := producer.Produce(rw, payload); err != nil {
+			panic(err) // let the recovery middleware deal with this
+		}
+	}
+}
diff --git a/rest_server_zrok/operations/metrics/get_account_metrics_urlbuilder.go b/rest_server_zrok/operations/metrics/get_account_metrics_urlbuilder.go
new file mode 100644
index 00000000..16b79838
--- /dev/null
+++ b/rest_server_zrok/operations/metrics/get_account_metrics_urlbuilder.go
@@ -0,0 +1,87 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metrics
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the generate command
+
+import (
+	"errors"
+	"net/url"
+	golangswaggerpaths "path"
+)
+
+// GetAccountMetricsURL generates an URL for the get account metrics operation
+type GetAccountMetricsURL struct {
+	_basePath string
+}
+
+// WithBasePath sets the base path for this url builder, only required when it's different from the
+// base path specified in the swagger spec.
+// When the value of the base path is an empty string
+func (o *GetAccountMetricsURL) WithBasePath(bp string) *GetAccountMetricsURL {
+	o.SetBasePath(bp)
+	return o
+}
+
+// SetBasePath sets the base path for this url builder, only required when it's different from the
+// base path specified in the swagger spec.
+// When the value of the base path is an empty string
+func (o *GetAccountMetricsURL) SetBasePath(bp string) {
+	o._basePath = bp
+}
+
+// Build a url path and query string
+func (o *GetAccountMetricsURL) Build() (*url.URL, error) {
+	var _result url.URL
+
+	var _path = "/metrics/account"
+
+	_basePath := o._basePath
+	if _basePath == "" {
+		_basePath = "/api/v1"
+	}
+	_result.Path = golangswaggerpaths.Join(_basePath, _path)
+
+	return &_result, nil
+}
+
+// Must is a helper function to panic when the url builder returns an error
+func (o *GetAccountMetricsURL) Must(u *url.URL, err error) *url.URL {
+	if err != nil {
+		panic(err)
+	}
+	if u == nil {
+		panic("url can't be nil")
+	}
+	return u
+}
+
+// String returns the string representation of the path with query string
+func (o *GetAccountMetricsURL) String() string {
+	return o.Must(o.Build()).String()
+}
+
+// BuildFull builds a full url with scheme, host, path and query string
+func (o *GetAccountMetricsURL) BuildFull(scheme, host string) (*url.URL, error) {
+	if scheme == "" {
+		return nil, errors.New("scheme is required for a full url on GetAccountMetricsURL")
+	}
+	if host == "" {
+		return nil, errors.New("host is required for a full url on GetAccountMetricsURL")
+	}
+
+	base, err := o.Build()
+	if err != nil {
+		return nil, err
+	}
+
+	base.Scheme = scheme
+	base.Host = host
+	return base, nil
+}
+
+// StringFull returns the string representation of a complete url
+func (o *GetAccountMetricsURL) StringFull(scheme, host string) string {
+	return o.Must(o.BuildFull(scheme, host)).String()
+}
diff --git a/rest_server_zrok/operations/metrics/get_environment_metrics.go b/rest_server_zrok/operations/metrics/get_environment_metrics.go
new file mode 100644
index 00000000..b26b7535
--- /dev/null
+++ b/rest_server_zrok/operations/metrics/get_environment_metrics.go
@@ -0,0 +1,71 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metrics
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the generate command
+
+import (
+	"net/http"
+
+	"github.com/go-openapi/runtime/middleware"
+
+	"github.com/openziti/zrok/rest_model_zrok"
+)
+
+// GetEnvironmentMetricsHandlerFunc turns a function with the right signature into a get environment metrics handler
+type GetEnvironmentMetricsHandlerFunc func(GetEnvironmentMetricsParams, *rest_model_zrok.Principal) middleware.Responder
+
+// Handle executing the request and returning a response
+func (fn GetEnvironmentMetricsHandlerFunc) Handle(params GetEnvironmentMetricsParams, principal *rest_model_zrok.Principal) middleware.Responder {
+	return fn(params, principal)
+}
+
+// GetEnvironmentMetricsHandler interface for that can handle valid get environment metrics params
+type GetEnvironmentMetricsHandler interface {
+	Handle(GetEnvironmentMetricsParams, *rest_model_zrok.Principal) middleware.Responder
+}
+
+// NewGetEnvironmentMetrics creates a new http.Handler for the get environment metrics operation
+func NewGetEnvironmentMetrics(ctx *middleware.Context, handler GetEnvironmentMetricsHandler) *GetEnvironmentMetrics {
+	return &GetEnvironmentMetrics{Context: ctx, Handler: handler}
+}
+
+/*
+	GetEnvironmentMetrics swagger:route GET /metrics/environment/{envId} metrics getEnvironmentMetrics
+
+GetEnvironmentMetrics get environment metrics API
+*/
+type GetEnvironmentMetrics struct {
+	Context *middleware.Context
+	Handler GetEnvironmentMetricsHandler
+}
+
+func (o *GetEnvironmentMetrics) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
+	route, rCtx, _ := o.Context.RouteInfo(r)
+	if rCtx != nil {
+		*r = *rCtx
+	}
+	var Params = NewGetEnvironmentMetricsParams()
+	uprinc, aCtx, err := o.Context.Authorize(r, route)
+	if err != nil {
+		o.Context.Respond(rw, r, route.Produces, route, err)
+		return
+	}
+	if aCtx != nil {
+		*r = *aCtx
+	}
+	var principal *rest_model_zrok.Principal
+	if uprinc != nil {
+		principal = uprinc.(*rest_model_zrok.Principal) // this is really a rest_model_zrok.Principal, I promise
+	}
+
+	if err := o.Context.BindValidRequest(r, route, &Params); err != nil { // bind params
+		o.Context.Respond(rw, r, route.Produces, route, err)
+		return
+	}
+
+	res := o.Handler.Handle(Params, principal) // actually handle the request
+	o.Context.Respond(rw, r, route.Produces, route, res)
+
+}
diff --git a/rest_server_zrok/operations/metrics/get_environment_metrics_parameters.go b/rest_server_zrok/operations/metrics/get_environment_metrics_parameters.go
new file mode 100644
index 00000000..1f10409f
--- /dev/null
+++ b/rest_server_zrok/operations/metrics/get_environment_metrics_parameters.go
@@ -0,0 +1,71 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metrics
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"net/http"
+
+	"github.com/go-openapi/errors"
+	"github.com/go-openapi/runtime/middleware"
+	"github.com/go-openapi/strfmt"
+)
+
+// NewGetEnvironmentMetricsParams creates a new GetEnvironmentMetricsParams object
+//
+// There are no default values defined in the spec.
+func NewGetEnvironmentMetricsParams() GetEnvironmentMetricsParams {
+
+	return GetEnvironmentMetricsParams{}
+}
+
+// GetEnvironmentMetricsParams contains all the bound params for the get environment metrics operation
+// typically these are obtained from a http.Request
+//
+// swagger:parameters getEnvironmentMetrics
+type GetEnvironmentMetricsParams struct {
+
+	// HTTP Request Object
+	HTTPRequest *http.Request `json:"-"`
+
+	/*
+	  Required: true
+	  In: path
+	*/
+	EnvID string
+}
+
+// BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface
+// for simple values it will use straight method calls.
+//
+// To ensure default values, the struct must have been initialized with NewGetEnvironmentMetricsParams() beforehand.
+func (o *GetEnvironmentMetricsParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error {
+	var res []error
+
+	o.HTTPRequest = r
+
+	rEnvID, rhkEnvID, _ := route.Params.GetOK("envId")
+	if err := o.bindEnvID(rEnvID, rhkEnvID, route.Formats); err != nil {
+		res = append(res, err)
+	}
+	if len(res) > 0 {
+		return errors.CompositeValidationError(res...)
+	}
+	return nil
+}
+
+// bindEnvID binds and validates parameter EnvID from path.
+func (o *GetEnvironmentMetricsParams) bindEnvID(rawData []string, hasKey bool, formats strfmt.Registry) error {
+	var raw string
+	if len(rawData) > 0 {
+		raw = rawData[len(rawData)-1]
+	}
+
+	// Required: true
+	// Parameter is provided by construction from the route
+	o.EnvID = raw
+
+	return nil
+}
diff --git a/rest_server_zrok/operations/metrics/get_environment_metrics_responses.go b/rest_server_zrok/operations/metrics/get_environment_metrics_responses.go
new file mode 100644
index 00000000..d5e9897c
--- /dev/null
+++ b/rest_server_zrok/operations/metrics/get_environment_metrics_responses.go
@@ -0,0 +1,84 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metrics
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"net/http"
+
+	"github.com/go-openapi/runtime"
+
+	"github.com/openziti/zrok/rest_model_zrok"
+)
+
+// GetEnvironmentMetricsOKCode is the HTTP code returned for type GetEnvironmentMetricsOK
+const GetEnvironmentMetricsOKCode int = 200
+
+/*
+GetEnvironmentMetricsOK environment metrics
+
+swagger:response getEnvironmentMetricsOK
+*/
+type GetEnvironmentMetricsOK struct {
+
+	/*
+	  In: Body
+	*/
+	Payload *rest_model_zrok.Metrics `json:"body,omitempty"`
+}
+
+// NewGetEnvironmentMetricsOK creates GetEnvironmentMetricsOK with default headers values
+func NewGetEnvironmentMetricsOK() *GetEnvironmentMetricsOK {
+
+	return &GetEnvironmentMetricsOK{}
+}
+
+// WithPayload adds the payload to the get environment metrics o k response
+func (o *GetEnvironmentMetricsOK) WithPayload(payload *rest_model_zrok.Metrics) *GetEnvironmentMetricsOK {
+	o.Payload = payload
+	return o
+}
+
+// SetPayload sets the payload to the get environment metrics o k response
+func (o *GetEnvironmentMetricsOK) SetPayload(payload *rest_model_zrok.Metrics) {
+	o.Payload = payload
+}
+
+// WriteResponse to the client
+func (o *GetEnvironmentMetricsOK) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) {
+
+	rw.WriteHeader(200)
+	if o.Payload != nil {
+		payload := o.Payload
+		if err := producer.Produce(rw, payload); err != nil {
+			panic(err) // let the recovery middleware deal with this
+		}
+	}
+}
+
+// GetEnvironmentMetricsUnauthorizedCode is the HTTP code returned for type GetEnvironmentMetricsUnauthorized
+const GetEnvironmentMetricsUnauthorizedCode int = 401
+
+/*
+GetEnvironmentMetricsUnauthorized unauthorized
+
+swagger:response getEnvironmentMetricsUnauthorized
+*/
+type GetEnvironmentMetricsUnauthorized struct {
+}
+
+// NewGetEnvironmentMetricsUnauthorized creates GetEnvironmentMetricsUnauthorized with default headers values
+func NewGetEnvironmentMetricsUnauthorized() *GetEnvironmentMetricsUnauthorized {
+
+	return &GetEnvironmentMetricsUnauthorized{}
+}
+
+// WriteResponse to the client
+func (o *GetEnvironmentMetricsUnauthorized) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) {
+
+	rw.Header().Del(runtime.HeaderContentType) //Remove Content-Type on empty responses
+
+	rw.WriteHeader(401)
+}
diff --git a/rest_server_zrok/operations/metrics/get_environment_metrics_urlbuilder.go b/rest_server_zrok/operations/metrics/get_environment_metrics_urlbuilder.go
new file mode 100644
index 00000000..a31dfd88
--- /dev/null
+++ b/rest_server_zrok/operations/metrics/get_environment_metrics_urlbuilder.go
@@ -0,0 +1,99 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metrics
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the generate command
+
+import (
+	"errors"
+	"net/url"
+	golangswaggerpaths "path"
+	"strings"
+)
+
+// GetEnvironmentMetricsURL generates an URL for the get environment metrics operation
+type GetEnvironmentMetricsURL struct {
+	EnvID string
+
+	_basePath string
+	// avoid unkeyed usage
+	_ struct{}
+}
+
+// WithBasePath sets the base path for this url builder, only required when it's different from the
+// base path specified in the swagger spec.
+// When the value of the base path is an empty string
+func (o *GetEnvironmentMetricsURL) WithBasePath(bp string) *GetEnvironmentMetricsURL {
+	o.SetBasePath(bp)
+	return o
+}
+
+// SetBasePath sets the base path for this url builder, only required when it's different from the
+// base path specified in the swagger spec.
+// When the value of the base path is an empty string
+func (o *GetEnvironmentMetricsURL) SetBasePath(bp string) {
+	o._basePath = bp
+}
+
+// Build a url path and query string
+func (o *GetEnvironmentMetricsURL) Build() (*url.URL, error) {
+	var _result url.URL
+
+	var _path = "/metrics/environment/{envId}"
+
+	envID := o.EnvID
+	if envID != "" {
+		_path = strings.Replace(_path, "{envId}", envID, -1)
+	} else {
+		return nil, errors.New("envId is required on GetEnvironmentMetricsURL")
+	}
+
+	_basePath := o._basePath
+	if _basePath == "" {
+		_basePath = "/api/v1"
+	}
+	_result.Path = golangswaggerpaths.Join(_basePath, _path)
+
+	return &_result, nil
+}
+
+// Must is a helper function to panic when the url builder returns an error
+func (o *GetEnvironmentMetricsURL) Must(u *url.URL, err error) *url.URL {
+	if err != nil {
+		panic(err)
+	}
+	if u == nil {
+		panic("url can't be nil")
+	}
+	return u
+}
+
+// String returns the string representation of the path with query string
+func (o *GetEnvironmentMetricsURL) String() string {
+	return o.Must(o.Build()).String()
+}
+
+// BuildFull builds a full url with scheme, host, path and query string
+func (o *GetEnvironmentMetricsURL) BuildFull(scheme, host string) (*url.URL, error) {
+	if scheme == "" {
+		return nil, errors.New("scheme is required for a full url on GetEnvironmentMetricsURL")
+	}
+	if host == "" {
+		return nil, errors.New("host is required for a full url on GetEnvironmentMetricsURL")
+	}
+
+	base, err := o.Build()
+	if err != nil {
+		return nil, err
+	}
+
+	base.Scheme = scheme
+	base.Host = host
+	return base, nil
+}
+
+// StringFull returns the string representation of a complete url
+func (o *GetEnvironmentMetricsURL) StringFull(scheme, host string) string {
+	return o.Must(o.BuildFull(scheme, host)).String()
+}
diff --git a/rest_server_zrok/operations/metrics/get_share_metrics.go b/rest_server_zrok/operations/metrics/get_share_metrics.go
new file mode 100644
index 00000000..67470dfa
--- /dev/null
+++ b/rest_server_zrok/operations/metrics/get_share_metrics.go
@@ -0,0 +1,71 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metrics
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the generate command
+
+import (
+	"net/http"
+
+	"github.com/go-openapi/runtime/middleware"
+
+	"github.com/openziti/zrok/rest_model_zrok"
+)
+
+// GetShareMetricsHandlerFunc turns a function with the right signature into a get share metrics handler
+type GetShareMetricsHandlerFunc func(GetShareMetricsParams, *rest_model_zrok.Principal) middleware.Responder
+
+// Handle executing the request and returning a response
+func (fn GetShareMetricsHandlerFunc) Handle(params GetShareMetricsParams, principal *rest_model_zrok.Principal) middleware.Responder {
+	return fn(params, principal)
+}
+
+// GetShareMetricsHandler interface for that can handle valid get share metrics params
+type GetShareMetricsHandler interface {
+	Handle(GetShareMetricsParams, *rest_model_zrok.Principal) middleware.Responder
+}
+
+// NewGetShareMetrics creates a new http.Handler for the get share metrics operation
+func NewGetShareMetrics(ctx *middleware.Context, handler GetShareMetricsHandler) *GetShareMetrics {
+	return &GetShareMetrics{Context: ctx, Handler: handler}
+}
+
+/*
+	GetShareMetrics swagger:route GET /metrics/share/{shrToken} metrics getShareMetrics
+
+GetShareMetrics get share metrics API
+*/
+type GetShareMetrics struct {
+	Context *middleware.Context
+	Handler GetShareMetricsHandler
+}
+
+func (o *GetShareMetrics) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
+	route, rCtx, _ := o.Context.RouteInfo(r)
+	if rCtx != nil {
+		*r = *rCtx
+	}
+	var Params = NewGetShareMetricsParams()
+	uprinc, aCtx, err := o.Context.Authorize(r, route)
+	if err != nil {
+		o.Context.Respond(rw, r, route.Produces, route, err)
+		return
+	}
+	if aCtx != nil {
+		*r = *aCtx
+	}
+	var principal *rest_model_zrok.Principal
+	if uprinc != nil {
+		principal = uprinc.(*rest_model_zrok.Principal) // this is really a rest_model_zrok.Principal, I promise
+	}
+
+	if err := o.Context.BindValidRequest(r, route, &Params); err != nil { // bind params
+		o.Context.Respond(rw, r, route.Produces, route, err)
+		return
+	}
+
+	res := o.Handler.Handle(Params, principal) // actually handle the request
+	o.Context.Respond(rw, r, route.Produces, route, res)
+
+}
diff --git a/rest_server_zrok/operations/metrics/get_share_metrics_parameters.go b/rest_server_zrok/operations/metrics/get_share_metrics_parameters.go
new file mode 100644
index 00000000..1013bb7a
--- /dev/null
+++ b/rest_server_zrok/operations/metrics/get_share_metrics_parameters.go
@@ -0,0 +1,71 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metrics
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"net/http"
+
+	"github.com/go-openapi/errors"
+	"github.com/go-openapi/runtime/middleware"
+	"github.com/go-openapi/strfmt"
+)
+
+// NewGetShareMetricsParams creates a new GetShareMetricsParams object
+//
+// There are no default values defined in the spec.
+func NewGetShareMetricsParams() GetShareMetricsParams {
+
+	return GetShareMetricsParams{}
+}
+
+// GetShareMetricsParams contains all the bound params for the get share metrics operation
+// typically these are obtained from a http.Request
+//
+// swagger:parameters getShareMetrics
+type GetShareMetricsParams struct {
+
+	// HTTP Request Object
+	HTTPRequest *http.Request `json:"-"`
+
+	/*
+	  Required: true
+	  In: path
+	*/
+	ShrToken string
+}
+
+// BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface
+// for simple values it will use straight method calls.
+//
+// To ensure default values, the struct must have been initialized with NewGetShareMetricsParams() beforehand.
+func (o *GetShareMetricsParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error {
+	var res []error
+
+	o.HTTPRequest = r
+
+	rShrToken, rhkShrToken, _ := route.Params.GetOK("shrToken")
+	if err := o.bindShrToken(rShrToken, rhkShrToken, route.Formats); err != nil {
+		res = append(res, err)
+	}
+	if len(res) > 0 {
+		return errors.CompositeValidationError(res...)
+	}
+	return nil
+}
+
+// bindShrToken binds and validates parameter ShrToken from path.
+func (o *GetShareMetricsParams) bindShrToken(rawData []string, hasKey bool, formats strfmt.Registry) error {
+	var raw string
+	if len(rawData) > 0 {
+		raw = rawData[len(rawData)-1]
+	}
+
+	// Required: true
+	// Parameter is provided by construction from the route
+	o.ShrToken = raw
+
+	return nil
+}
diff --git a/rest_server_zrok/operations/metrics/get_share_metrics_responses.go b/rest_server_zrok/operations/metrics/get_share_metrics_responses.go
new file mode 100644
index 00000000..70e0c0e1
--- /dev/null
+++ b/rest_server_zrok/operations/metrics/get_share_metrics_responses.go
@@ -0,0 +1,84 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metrics
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"net/http"
+
+	"github.com/go-openapi/runtime"
+
+	"github.com/openziti/zrok/rest_model_zrok"
+)
+
+// GetShareMetricsOKCode is the HTTP code returned for type GetShareMetricsOK
+const GetShareMetricsOKCode int = 200
+
+/*
+GetShareMetricsOK share metrics
+
+swagger:response getShareMetricsOK
+*/
+type GetShareMetricsOK struct {
+
+	/*
+	  In: Body
+	*/
+	Payload *rest_model_zrok.Metrics `json:"body,omitempty"`
+}
+
+// NewGetShareMetricsOK creates GetShareMetricsOK with default headers values
+func NewGetShareMetricsOK() *GetShareMetricsOK {
+
+	return &GetShareMetricsOK{}
+}
+
+// WithPayload adds the payload to the get share metrics o k response
+func (o *GetShareMetricsOK) WithPayload(payload *rest_model_zrok.Metrics) *GetShareMetricsOK {
+	o.Payload = payload
+	return o
+}
+
+// SetPayload sets the payload to the get share metrics o k response
+func (o *GetShareMetricsOK) SetPayload(payload *rest_model_zrok.Metrics) {
+	o.Payload = payload
+}
+
+// WriteResponse to the client
+func (o *GetShareMetricsOK) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) {
+
+	rw.WriteHeader(200)
+	if o.Payload != nil {
+		payload := o.Payload
+		if err := producer.Produce(rw, payload); err != nil {
+			panic(err) // let the recovery middleware deal with this
+		}
+	}
+}
+
+// GetShareMetricsUnauthorizedCode is the HTTP code returned for type GetShareMetricsUnauthorized
+const GetShareMetricsUnauthorizedCode int = 401
+
+/*
+GetShareMetricsUnauthorized unauthorized
+
+swagger:response getShareMetricsUnauthorized
+*/
+type GetShareMetricsUnauthorized struct {
+}
+
+// NewGetShareMetricsUnauthorized creates GetShareMetricsUnauthorized with default headers values
+func NewGetShareMetricsUnauthorized() *GetShareMetricsUnauthorized {
+
+	return &GetShareMetricsUnauthorized{}
+}
+
+// WriteResponse to the client
+func (o *GetShareMetricsUnauthorized) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) {
+
+	rw.Header().Del(runtime.HeaderContentType) //Remove Content-Type on empty responses
+
+	rw.WriteHeader(401)
+}
diff --git a/rest_server_zrok/operations/metrics/get_share_metrics_urlbuilder.go b/rest_server_zrok/operations/metrics/get_share_metrics_urlbuilder.go
new file mode 100644
index 00000000..deeb05e3
--- /dev/null
+++ b/rest_server_zrok/operations/metrics/get_share_metrics_urlbuilder.go
@@ -0,0 +1,99 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metrics
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the generate command
+
+import (
+	"errors"
+	"net/url"
+	golangswaggerpaths "path"
+	"strings"
+)
+
+// GetShareMetricsURL generates an URL for the get share metrics operation
+type GetShareMetricsURL struct {
+	ShrToken string
+
+	_basePath string
+	// avoid unkeyed usage
+	_ struct{}
+}
+
+// WithBasePath sets the base path for this url builder, only required when it's different from the
+// base path specified in the swagger spec.
+// When the value of the base path is an empty string
+func (o *GetShareMetricsURL) WithBasePath(bp string) *GetShareMetricsURL {
+	o.SetBasePath(bp)
+	return o
+}
+
+// SetBasePath sets the base path for this url builder, only required when it's different from the
+// base path specified in the swagger spec.
+// When the value of the base path is an empty string
+func (o *GetShareMetricsURL) SetBasePath(bp string) {
+	o._basePath = bp
+}
+
+// Build a url path and query string
+func (o *GetShareMetricsURL) Build() (*url.URL, error) {
+	var _result url.URL
+
+	var _path = "/metrics/share/{shrToken}"
+
+	shrToken := o.ShrToken
+	if shrToken != "" {
+		_path = strings.Replace(_path, "{shrToken}", shrToken, -1)
+	} else {
+		return nil, errors.New("shrToken is required on GetShareMetricsURL")
+	}
+
+	_basePath := o._basePath
+	if _basePath == "" {
+		_basePath = "/api/v1"
+	}
+	_result.Path = golangswaggerpaths.Join(_basePath, _path)
+
+	return &_result, nil
+}
+
+// Must is a helper function to panic when the url builder returns an error
+func (o *GetShareMetricsURL) Must(u *url.URL, err error) *url.URL {
+	if err != nil {
+		panic(err)
+	}
+	if u == nil {
+		panic("url can't be nil")
+	}
+	return u
+}
+
+// String returns the string representation of the path with query string
+func (o *GetShareMetricsURL) String() string {
+	return o.Must(o.Build()).String()
+}
+
+// BuildFull builds a full url with scheme, host, path and query string
+func (o *GetShareMetricsURL) BuildFull(scheme, host string) (*url.URL, error) {
+	if scheme == "" {
+		return nil, errors.New("scheme is required for a full url on GetShareMetricsURL")
+	}
+	if host == "" {
+		return nil, errors.New("host is required for a full url on GetShareMetricsURL")
+	}
+
+	base, err := o.Build()
+	if err != nil {
+		return nil, err
+	}
+
+	base.Scheme = scheme
+	base.Host = host
+	return base, nil
+}
+
+// StringFull returns the string representation of a complete url
+func (o *GetShareMetricsURL) StringFull(scheme, host string) string {
+	return o.Must(o.BuildFull(scheme, host)).String()
+}
diff --git a/rest_server_zrok/operations/zrok_api.go b/rest_server_zrok/operations/zrok_api.go
index 3424c976..19a12af8 100644
--- a/rest_server_zrok/operations/zrok_api.go
+++ b/rest_server_zrok/operations/zrok_api.go
@@ -24,6 +24,7 @@ import (
 	"github.com/openziti/zrok/rest_server_zrok/operations/admin"
 	"github.com/openziti/zrok/rest_server_zrok/operations/environment"
 	"github.com/openziti/zrok/rest_server_zrok/operations/metadata"
+	"github.com/openziti/zrok/rest_server_zrok/operations/metrics"
 	"github.com/openziti/zrok/rest_server_zrok/operations/share"
 )
 
@@ -70,12 +71,21 @@ func NewZrokAPI(spec *loads.Document) *ZrokAPI {
 		EnvironmentEnableHandler: environment.EnableHandlerFunc(func(params environment.EnableParams, principal *rest_model_zrok.Principal) middleware.Responder {
 			return middleware.NotImplemented("operation environment.Enable has not yet been implemented")
 		}),
+		MetricsGetAccountMetricsHandler: metrics.GetAccountMetricsHandlerFunc(func(params metrics.GetAccountMetricsParams, principal *rest_model_zrok.Principal) middleware.Responder {
+			return middleware.NotImplemented("operation metrics.GetAccountMetrics has not yet been implemented")
+		}),
 		MetadataGetEnvironmentDetailHandler: metadata.GetEnvironmentDetailHandlerFunc(func(params metadata.GetEnvironmentDetailParams, principal *rest_model_zrok.Principal) middleware.Responder {
 			return middleware.NotImplemented("operation metadata.GetEnvironmentDetail has not yet been implemented")
 		}),
+		MetricsGetEnvironmentMetricsHandler: metrics.GetEnvironmentMetricsHandlerFunc(func(params metrics.GetEnvironmentMetricsParams, principal *rest_model_zrok.Principal) middleware.Responder {
+			return middleware.NotImplemented("operation metrics.GetEnvironmentMetrics has not yet been implemented")
+		}),
 		MetadataGetShareDetailHandler: metadata.GetShareDetailHandlerFunc(func(params metadata.GetShareDetailParams, principal *rest_model_zrok.Principal) middleware.Responder {
 			return middleware.NotImplemented("operation metadata.GetShareDetail has not yet been implemented")
 		}),
+		MetricsGetShareMetricsHandler: metrics.GetShareMetricsHandlerFunc(func(params metrics.GetShareMetricsParams, principal *rest_model_zrok.Principal) middleware.Responder {
+			return middleware.NotImplemented("operation metrics.GetShareMetrics has not yet been implemented")
+		}),
 		AccountInviteHandler: account.InviteHandlerFunc(func(params account.InviteParams) middleware.Responder {
 			return middleware.NotImplemented("operation account.Invite has not yet been implemented")
 		}),
@@ -185,10 +195,16 @@ type ZrokAPI struct {
 	EnvironmentDisableHandler environment.DisableHandler
 	// EnvironmentEnableHandler sets the operation handler for the enable operation
 	EnvironmentEnableHandler environment.EnableHandler
+	// MetricsGetAccountMetricsHandler sets the operation handler for the get account metrics operation
+	MetricsGetAccountMetricsHandler metrics.GetAccountMetricsHandler
 	// MetadataGetEnvironmentDetailHandler sets the operation handler for the get environment detail operation
 	MetadataGetEnvironmentDetailHandler metadata.GetEnvironmentDetailHandler
+	// MetricsGetEnvironmentMetricsHandler sets the operation handler for the get environment metrics operation
+	MetricsGetEnvironmentMetricsHandler metrics.GetEnvironmentMetricsHandler
 	// MetadataGetShareDetailHandler sets the operation handler for the get share detail operation
 	MetadataGetShareDetailHandler metadata.GetShareDetailHandler
+	// MetricsGetShareMetricsHandler sets the operation handler for the get share metrics operation
+	MetricsGetShareMetricsHandler metrics.GetShareMetricsHandler
 	// AccountInviteHandler sets the operation handler for the invite operation
 	AccountInviteHandler account.InviteHandler
 	// AdminInviteTokenGenerateHandler sets the operation handler for the invite token generate operation
@@ -321,12 +337,21 @@ func (o *ZrokAPI) Validate() error {
 	if o.EnvironmentEnableHandler == nil {
 		unregistered = append(unregistered, "environment.EnableHandler")
 	}
+	if o.MetricsGetAccountMetricsHandler == nil {
+		unregistered = append(unregistered, "metrics.GetAccountMetricsHandler")
+	}
 	if o.MetadataGetEnvironmentDetailHandler == nil {
 		unregistered = append(unregistered, "metadata.GetEnvironmentDetailHandler")
 	}
+	if o.MetricsGetEnvironmentMetricsHandler == nil {
+		unregistered = append(unregistered, "metrics.GetEnvironmentMetricsHandler")
+	}
 	if o.MetadataGetShareDetailHandler == nil {
 		unregistered = append(unregistered, "metadata.GetShareDetailHandler")
 	}
+	if o.MetricsGetShareMetricsHandler == nil {
+		unregistered = append(unregistered, "metrics.GetShareMetricsHandler")
+	}
 	if o.AccountInviteHandler == nil {
 		unregistered = append(unregistered, "account.InviteHandler")
 	}
@@ -502,11 +527,23 @@ func (o *ZrokAPI) initHandlerCache() {
 	if o.handlers["GET"] == nil {
 		o.handlers["GET"] = make(map[string]http.Handler)
 	}
+	o.handlers["GET"]["/metrics/account"] = metrics.NewGetAccountMetrics(o.context, o.MetricsGetAccountMetricsHandler)
+	if o.handlers["GET"] == nil {
+		o.handlers["GET"] = make(map[string]http.Handler)
+	}
 	o.handlers["GET"]["/detail/environment/{envZId}"] = metadata.NewGetEnvironmentDetail(o.context, o.MetadataGetEnvironmentDetailHandler)
 	if o.handlers["GET"] == nil {
 		o.handlers["GET"] = make(map[string]http.Handler)
 	}
+	o.handlers["GET"]["/metrics/environment/{envId}"] = metrics.NewGetEnvironmentMetrics(o.context, o.MetricsGetEnvironmentMetricsHandler)
+	if o.handlers["GET"] == nil {
+		o.handlers["GET"] = make(map[string]http.Handler)
+	}
 	o.handlers["GET"]["/detail/share/{shrToken}"] = metadata.NewGetShareDetail(o.context, o.MetadataGetShareDetailHandler)
+	if o.handlers["GET"] == nil {
+		o.handlers["GET"] = make(map[string]http.Handler)
+	}
+	o.handlers["GET"]["/metrics/share/{shrToken}"] = metrics.NewGetShareMetrics(o.context, o.MetricsGetShareMetricsHandler)
 	if o.handlers["POST"] == nil {
 		o.handlers["POST"] = make(map[string]http.Handler)
 	}
diff --git a/specs/zrok.yml b/specs/zrok.yml
index 9064aa36..ffe081b8 100644
--- a/specs/zrok.yml
+++ b/specs/zrok.yml
@@ -404,6 +404,62 @@ paths:
           description: current server version
           schema:
             $ref: "#/definitions/version"
+
+  #
+  # metrics
+  #
+  /metrics/account:
+    get:
+      tags:
+        - metrics
+      security:
+        - key: []
+      operationId: getAccountMetrics
+      responses:
+        200:
+          description: account metrics
+          schema:
+            $ref: "#/definitions/metrics"
+
+  /metrics/environment/{envId}:
+    get:
+      tags:
+        - metrics
+      security:
+        - key: []
+      operationId: getEnvironmentMetrics
+      parameters:
+        - name: envId
+          in: path
+          type: string
+          required: true
+      responses:
+        200:
+          description: environment metrics
+          schema:
+            $ref: "#/definitions/metrics"
+        401:
+          description: unauthorized
+
+  /metrics/share/{shrToken}:
+    get:
+      tags:
+        - metrics
+      security:
+        - key: []
+      operationId: getShareMetrics
+      parameters:
+        - name: shrToken
+          in: path
+          type: string
+          required: true
+      responses:
+        200:
+          description: share metrics
+          schema:
+            $ref: "#/definitions/metrics"
+        401:
+          description: unauthorized
   #
   # share
   #
@@ -666,6 +722,24 @@ definitions:
   loginResponse:
     type: string
 
+  metrics:
+    type: object
+    properties:
+      scope:
+        type: string
+      id:
+        type: string
+      period:
+        type: number
+      rx:
+        type: array
+        items:
+          type: number
+      tx:
+        type: array
+        items:
+          type: number
+
   principal:
     type: object
     properties:
diff --git a/ui/src/api/metrics.js b/ui/src/api/metrics.js
new file mode 100644
index 00000000..5c1111dd
--- /dev/null
+++ b/ui/src/api/metrics.js
@@ -0,0 +1,65 @@
+/** @module metrics */
+// Auto-generated, edits will be overwritten
+import * as gateway from './gateway'
+
+/**
+ */
+export function getAccountMetrics() {
+  return gateway.request(getAccountMetricsOperation)
+}
+
+/**
+ * @param {string} envId 
+ * @return {Promise<module:types.metrics>} environment metrics
+ */
+export function getEnvironmentMetrics(envId) {
+  const parameters = {
+    path: {
+      envId
+    }
+  }
+  return gateway.request(getEnvironmentMetricsOperation, parameters)
+}
+
+/**
+ * @param {string} shrToken 
+ * @return {Promise<module:types.metrics>} share metrics
+ */
+export function getShareMetrics(shrToken) {
+  const parameters = {
+    path: {
+      shrToken
+    }
+  }
+  return gateway.request(getShareMetricsOperation, parameters)
+}
+
+const getAccountMetricsOperation = {
+  path: '/metrics/account',
+  method: 'get',
+  security: [
+    {
+      id: 'key'
+    }
+  ]
+}
+
+const getEnvironmentMetricsOperation = {
+  path: '/metrics/environment/{envId}',
+  method: 'get',
+  security: [
+    {
+      id: 'key'
+    }
+  ]
+}
+
+const getShareMetricsOperation = {
+  path: '/metrics/share/{shrToken}',
+  method: 'get',
+  security: [
+    {
+      id: 'key'
+    }
+  ]
+}
diff --git a/ui/src/api/types.js b/ui/src/api/types.js
index 6196d926..6f5a6317 100644
--- a/ui/src/api/types.js
+++ b/ui/src/api/types.js
@@ -123,6 +123,17 @@
  * @property {string} password 
  */
 
+/**
+ * @typedef metrics
+ * @memberof module:types
+ * 
+ * @property {string} scope 
+ * @property {string} id 
+ * @property {number} period 
+ * @property {number[]} rx 
+ * @property {number[]} tx 
+ */
+
 /**
  * @typedef principal
  * @memberof module:types

From 21fdaf8e3ca4a1e9dbc0c2f20a8a7f609e5c614d Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Tue, 9 May 2023 11:36:53 -0400
Subject: [PATCH 08/49] duration parameter (#319)

---
 .../metrics/get_account_metrics_parameters.go | 33 +++++++++++++++++
 .../get_environment_metrics_parameters.go     | 32 ++++++++++++++++
 .../metrics/get_share_metrics_parameters.go   | 32 ++++++++++++++++
 rest_server_zrok/embedded_spec.go             | 34 +++++++++++++++++
 .../metrics/get_account_metrics_parameters.go | 37 +++++++++++++++++++
 .../metrics/get_account_metrics_urlbuilder.go | 18 +++++++++
 .../get_environment_metrics_parameters.go     | 36 ++++++++++++++++++
 .../get_environment_metrics_urlbuilder.go     | 16 ++++++++
 .../metrics/get_share_metrics_parameters.go   | 36 ++++++++++++++++++
 .../metrics/get_share_metrics_urlbuilder.go   | 16 ++++++++
 specs/zrok.yml                                | 10 +++++
 ui/src/api/metrics.js                         | 29 +++++++++++++--
 .../console/detail/environment/SharesTab.js   |  2 +-
 ui/src/console/detail/share/ShareDetail.js    |  2 +-
 ui/src/console/visualizer/Network.js          |  4 +-
 ui/src/console/visualizer/graph.js            |  4 +-
 16 files changed, 331 insertions(+), 10 deletions(-)

diff --git a/rest_client_zrok/metrics/get_account_metrics_parameters.go b/rest_client_zrok/metrics/get_account_metrics_parameters.go
index 5af21fb7..b06d0b6d 100644
--- a/rest_client_zrok/metrics/get_account_metrics_parameters.go
+++ b/rest_client_zrok/metrics/get_account_metrics_parameters.go
@@ -14,6 +14,7 @@ import (
 	"github.com/go-openapi/runtime"
 	cr "github.com/go-openapi/runtime/client"
 	"github.com/go-openapi/strfmt"
+	"github.com/go-openapi/swag"
 )
 
 // NewGetAccountMetricsParams creates a new GetAccountMetricsParams object,
@@ -60,6 +61,10 @@ GetAccountMetricsParams contains all the parameters to send to the API endpoint
 	Typically these are written to a http.Request.
 */
 type GetAccountMetricsParams struct {
+
+	// Duration.
+	Duration *float64
+
 	timeout    time.Duration
 	Context    context.Context
 	HTTPClient *http.Client
@@ -113,6 +118,17 @@ func (o *GetAccountMetricsParams) SetHTTPClient(client *http.Client) {
 	o.HTTPClient = client
 }
 
+// WithDuration adds the duration to the get account metrics params
+func (o *GetAccountMetricsParams) WithDuration(duration *float64) *GetAccountMetricsParams {
+	o.SetDuration(duration)
+	return o
+}
+
+// SetDuration adds the duration to the get account metrics params
+func (o *GetAccountMetricsParams) SetDuration(duration *float64) {
+	o.Duration = duration
+}
+
 // WriteToRequest writes these params to a swagger request
 func (o *GetAccountMetricsParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error {
 
@@ -121,6 +137,23 @@ func (o *GetAccountMetricsParams) WriteToRequest(r runtime.ClientRequest, reg st
 	}
 	var res []error
 
+	if o.Duration != nil {
+
+		// query param duration
+		var qrDuration float64
+
+		if o.Duration != nil {
+			qrDuration = *o.Duration
+		}
+		qDuration := swag.FormatFloat64(qrDuration)
+		if qDuration != "" {
+
+			if err := r.SetQueryParam("duration", qDuration); err != nil {
+				return err
+			}
+		}
+	}
+
 	if len(res) > 0 {
 		return errors.CompositeValidationError(res...)
 	}
diff --git a/rest_client_zrok/metrics/get_environment_metrics_parameters.go b/rest_client_zrok/metrics/get_environment_metrics_parameters.go
index 538d0074..796adaae 100644
--- a/rest_client_zrok/metrics/get_environment_metrics_parameters.go
+++ b/rest_client_zrok/metrics/get_environment_metrics_parameters.go
@@ -14,6 +14,7 @@ import (
 	"github.com/go-openapi/runtime"
 	cr "github.com/go-openapi/runtime/client"
 	"github.com/go-openapi/strfmt"
+	"github.com/go-openapi/swag"
 )
 
 // NewGetEnvironmentMetricsParams creates a new GetEnvironmentMetricsParams object,
@@ -61,6 +62,9 @@ GetEnvironmentMetricsParams contains all the parameters to send to the API endpo
 */
 type GetEnvironmentMetricsParams struct {
 
+	// Duration.
+	Duration *float64
+
 	// EnvID.
 	EnvID string
 
@@ -117,6 +121,17 @@ func (o *GetEnvironmentMetricsParams) SetHTTPClient(client *http.Client) {
 	o.HTTPClient = client
 }
 
+// WithDuration adds the duration to the get environment metrics params
+func (o *GetEnvironmentMetricsParams) WithDuration(duration *float64) *GetEnvironmentMetricsParams {
+	o.SetDuration(duration)
+	return o
+}
+
+// SetDuration adds the duration to the get environment metrics params
+func (o *GetEnvironmentMetricsParams) SetDuration(duration *float64) {
+	o.Duration = duration
+}
+
 // WithEnvID adds the envID to the get environment metrics params
 func (o *GetEnvironmentMetricsParams) WithEnvID(envID string) *GetEnvironmentMetricsParams {
 	o.SetEnvID(envID)
@@ -136,6 +151,23 @@ func (o *GetEnvironmentMetricsParams) WriteToRequest(r runtime.ClientRequest, re
 	}
 	var res []error
 
+	if o.Duration != nil {
+
+		// query param duration
+		var qrDuration float64
+
+		if o.Duration != nil {
+			qrDuration = *o.Duration
+		}
+		qDuration := swag.FormatFloat64(qrDuration)
+		if qDuration != "" {
+
+			if err := r.SetQueryParam("duration", qDuration); err != nil {
+				return err
+			}
+		}
+	}
+
 	// path param envId
 	if err := r.SetPathParam("envId", o.EnvID); err != nil {
 		return err
diff --git a/rest_client_zrok/metrics/get_share_metrics_parameters.go b/rest_client_zrok/metrics/get_share_metrics_parameters.go
index 690b280f..359c3c47 100644
--- a/rest_client_zrok/metrics/get_share_metrics_parameters.go
+++ b/rest_client_zrok/metrics/get_share_metrics_parameters.go
@@ -14,6 +14,7 @@ import (
 	"github.com/go-openapi/runtime"
 	cr "github.com/go-openapi/runtime/client"
 	"github.com/go-openapi/strfmt"
+	"github.com/go-openapi/swag"
 )
 
 // NewGetShareMetricsParams creates a new GetShareMetricsParams object,
@@ -61,6 +62,9 @@ GetShareMetricsParams contains all the parameters to send to the API endpoint
 */
 type GetShareMetricsParams struct {
 
+	// Duration.
+	Duration *float64
+
 	// ShrToken.
 	ShrToken string
 
@@ -117,6 +121,17 @@ func (o *GetShareMetricsParams) SetHTTPClient(client *http.Client) {
 	o.HTTPClient = client
 }
 
+// WithDuration adds the duration to the get share metrics params
+func (o *GetShareMetricsParams) WithDuration(duration *float64) *GetShareMetricsParams {
+	o.SetDuration(duration)
+	return o
+}
+
+// SetDuration adds the duration to the get share metrics params
+func (o *GetShareMetricsParams) SetDuration(duration *float64) {
+	o.Duration = duration
+}
+
 // WithShrToken adds the shrToken to the get share metrics params
 func (o *GetShareMetricsParams) WithShrToken(shrToken string) *GetShareMetricsParams {
 	o.SetShrToken(shrToken)
@@ -136,6 +151,23 @@ func (o *GetShareMetricsParams) WriteToRequest(r runtime.ClientRequest, reg strf
 	}
 	var res []error
 
+	if o.Duration != nil {
+
+		// query param duration
+		var qrDuration float64
+
+		if o.Duration != nil {
+			qrDuration = *o.Duration
+		}
+		qDuration := swag.FormatFloat64(qrDuration)
+		if qDuration != "" {
+
+			if err := r.SetQueryParam("duration", qDuration); err != nil {
+				return err
+			}
+		}
+	}
+
 	// path param shrToken
 	if err := r.SetPathParam("shrToken", o.ShrToken); err != nil {
 		return err
diff --git a/rest_server_zrok/embedded_spec.go b/rest_server_zrok/embedded_spec.go
index 59341826..46e97057 100644
--- a/rest_server_zrok/embedded_spec.go
+++ b/rest_server_zrok/embedded_spec.go
@@ -531,6 +531,13 @@ func init() {
           "metrics"
         ],
         "operationId": "getAccountMetrics",
+        "parameters": [
+          {
+            "type": "number",
+            "name": "duration",
+            "in": "query"
+          }
+        ],
         "responses": {
           "200": {
             "description": "account metrics",
@@ -558,6 +565,11 @@ func init() {
             "name": "envId",
             "in": "path",
             "required": true
+          },
+          {
+            "type": "number",
+            "name": "duration",
+            "in": "query"
           }
         ],
         "responses": {
@@ -590,6 +602,11 @@ func init() {
             "name": "shrToken",
             "in": "path",
             "required": true
+          },
+          {
+            "type": "number",
+            "name": "duration",
+            "in": "query"
           }
         ],
         "responses": {
@@ -1924,6 +1941,13 @@ func init() {
           "metrics"
         ],
         "operationId": "getAccountMetrics",
+        "parameters": [
+          {
+            "type": "number",
+            "name": "duration",
+            "in": "query"
+          }
+        ],
         "responses": {
           "200": {
             "description": "account metrics",
@@ -1951,6 +1975,11 @@ func init() {
             "name": "envId",
             "in": "path",
             "required": true
+          },
+          {
+            "type": "number",
+            "name": "duration",
+            "in": "query"
           }
         ],
         "responses": {
@@ -1983,6 +2012,11 @@ func init() {
             "name": "shrToken",
             "in": "path",
             "required": true
+          },
+          {
+            "type": "number",
+            "name": "duration",
+            "in": "query"
           }
         ],
         "responses": {
diff --git a/rest_server_zrok/operations/metrics/get_account_metrics_parameters.go b/rest_server_zrok/operations/metrics/get_account_metrics_parameters.go
index 98bb9b8c..afe23af1 100644
--- a/rest_server_zrok/operations/metrics/get_account_metrics_parameters.go
+++ b/rest_server_zrok/operations/metrics/get_account_metrics_parameters.go
@@ -9,7 +9,10 @@ import (
 	"net/http"
 
 	"github.com/go-openapi/errors"
+	"github.com/go-openapi/runtime"
 	"github.com/go-openapi/runtime/middleware"
+	"github.com/go-openapi/strfmt"
+	"github.com/go-openapi/swag"
 )
 
 // NewGetAccountMetricsParams creates a new GetAccountMetricsParams object
@@ -28,6 +31,11 @@ type GetAccountMetricsParams struct {
 
 	// HTTP Request Object
 	HTTPRequest *http.Request `json:"-"`
+
+	/*
+	  In: query
+	*/
+	Duration *float64
 }
 
 // BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface
@@ -39,8 +47,37 @@ func (o *GetAccountMetricsParams) BindRequest(r *http.Request, route *middleware
 
 	o.HTTPRequest = r
 
+	qs := runtime.Values(r.URL.Query())
+
+	qDuration, qhkDuration, _ := qs.GetOK("duration")
+	if err := o.bindDuration(qDuration, qhkDuration, route.Formats); err != nil {
+		res = append(res, err)
+	}
 	if len(res) > 0 {
 		return errors.CompositeValidationError(res...)
 	}
 	return nil
 }
+
+// bindDuration binds and validates parameter Duration from query.
+func (o *GetAccountMetricsParams) bindDuration(rawData []string, hasKey bool, formats strfmt.Registry) error {
+	var raw string
+	if len(rawData) > 0 {
+		raw = rawData[len(rawData)-1]
+	}
+
+	// Required: false
+	// AllowEmptyValue: false
+
+	if raw == "" { // empty values pass all other validations
+		return nil
+	}
+
+	value, err := swag.ConvertFloat64(raw)
+	if err != nil {
+		return errors.InvalidType("duration", "query", "float64", raw)
+	}
+	o.Duration = &value
+
+	return nil
+}
diff --git a/rest_server_zrok/operations/metrics/get_account_metrics_urlbuilder.go b/rest_server_zrok/operations/metrics/get_account_metrics_urlbuilder.go
index 16b79838..79001f68 100644
--- a/rest_server_zrok/operations/metrics/get_account_metrics_urlbuilder.go
+++ b/rest_server_zrok/operations/metrics/get_account_metrics_urlbuilder.go
@@ -9,11 +9,17 @@ import (
 	"errors"
 	"net/url"
 	golangswaggerpaths "path"
+
+	"github.com/go-openapi/swag"
 )
 
 // GetAccountMetricsURL generates an URL for the get account metrics operation
 type GetAccountMetricsURL struct {
+	Duration *float64
+
 	_basePath string
+	// avoid unkeyed usage
+	_ struct{}
 }
 
 // WithBasePath sets the base path for this url builder, only required when it's different from the
@@ -43,6 +49,18 @@ func (o *GetAccountMetricsURL) Build() (*url.URL, error) {
 	}
 	_result.Path = golangswaggerpaths.Join(_basePath, _path)
 
+	qs := make(url.Values)
+
+	var durationQ string
+	if o.Duration != nil {
+		durationQ = swag.FormatFloat64(*o.Duration)
+	}
+	if durationQ != "" {
+		qs.Set("duration", durationQ)
+	}
+
+	_result.RawQuery = qs.Encode()
+
 	return &_result, nil
 }
 
diff --git a/rest_server_zrok/operations/metrics/get_environment_metrics_parameters.go b/rest_server_zrok/operations/metrics/get_environment_metrics_parameters.go
index 1f10409f..69bfb627 100644
--- a/rest_server_zrok/operations/metrics/get_environment_metrics_parameters.go
+++ b/rest_server_zrok/operations/metrics/get_environment_metrics_parameters.go
@@ -9,8 +9,10 @@ import (
 	"net/http"
 
 	"github.com/go-openapi/errors"
+	"github.com/go-openapi/runtime"
 	"github.com/go-openapi/runtime/middleware"
 	"github.com/go-openapi/strfmt"
+	"github.com/go-openapi/swag"
 )
 
 // NewGetEnvironmentMetricsParams creates a new GetEnvironmentMetricsParams object
@@ -30,6 +32,10 @@ type GetEnvironmentMetricsParams struct {
 	// HTTP Request Object
 	HTTPRequest *http.Request `json:"-"`
 
+	/*
+	  In: query
+	*/
+	Duration *float64
 	/*
 	  Required: true
 	  In: path
@@ -46,6 +52,13 @@ func (o *GetEnvironmentMetricsParams) BindRequest(r *http.Request, route *middle
 
 	o.HTTPRequest = r
 
+	qs := runtime.Values(r.URL.Query())
+
+	qDuration, qhkDuration, _ := qs.GetOK("duration")
+	if err := o.bindDuration(qDuration, qhkDuration, route.Formats); err != nil {
+		res = append(res, err)
+	}
+
 	rEnvID, rhkEnvID, _ := route.Params.GetOK("envId")
 	if err := o.bindEnvID(rEnvID, rhkEnvID, route.Formats); err != nil {
 		res = append(res, err)
@@ -56,6 +69,29 @@ func (o *GetEnvironmentMetricsParams) BindRequest(r *http.Request, route *middle
 	return nil
 }
 
+// bindDuration binds and validates parameter Duration from query.
+func (o *GetEnvironmentMetricsParams) bindDuration(rawData []string, hasKey bool, formats strfmt.Registry) error {
+	var raw string
+	if len(rawData) > 0 {
+		raw = rawData[len(rawData)-1]
+	}
+
+	// Required: false
+	// AllowEmptyValue: false
+
+	if raw == "" { // empty values pass all other validations
+		return nil
+	}
+
+	value, err := swag.ConvertFloat64(raw)
+	if err != nil {
+		return errors.InvalidType("duration", "query", "float64", raw)
+	}
+	o.Duration = &value
+
+	return nil
+}
+
 // bindEnvID binds and validates parameter EnvID from path.
 func (o *GetEnvironmentMetricsParams) bindEnvID(rawData []string, hasKey bool, formats strfmt.Registry) error {
 	var raw string
diff --git a/rest_server_zrok/operations/metrics/get_environment_metrics_urlbuilder.go b/rest_server_zrok/operations/metrics/get_environment_metrics_urlbuilder.go
index a31dfd88..9693cf12 100644
--- a/rest_server_zrok/operations/metrics/get_environment_metrics_urlbuilder.go
+++ b/rest_server_zrok/operations/metrics/get_environment_metrics_urlbuilder.go
@@ -10,12 +10,16 @@ import (
 	"net/url"
 	golangswaggerpaths "path"
 	"strings"
+
+	"github.com/go-openapi/swag"
 )
 
 // GetEnvironmentMetricsURL generates an URL for the get environment metrics operation
 type GetEnvironmentMetricsURL struct {
 	EnvID string
 
+	Duration *float64
+
 	_basePath string
 	// avoid unkeyed usage
 	_ struct{}
@@ -55,6 +59,18 @@ func (o *GetEnvironmentMetricsURL) Build() (*url.URL, error) {
 	}
 	_result.Path = golangswaggerpaths.Join(_basePath, _path)
 
+	qs := make(url.Values)
+
+	var durationQ string
+	if o.Duration != nil {
+		durationQ = swag.FormatFloat64(*o.Duration)
+	}
+	if durationQ != "" {
+		qs.Set("duration", durationQ)
+	}
+
+	_result.RawQuery = qs.Encode()
+
 	return &_result, nil
 }
 
diff --git a/rest_server_zrok/operations/metrics/get_share_metrics_parameters.go b/rest_server_zrok/operations/metrics/get_share_metrics_parameters.go
index 1013bb7a..efc3d81d 100644
--- a/rest_server_zrok/operations/metrics/get_share_metrics_parameters.go
+++ b/rest_server_zrok/operations/metrics/get_share_metrics_parameters.go
@@ -9,8 +9,10 @@ import (
 	"net/http"
 
 	"github.com/go-openapi/errors"
+	"github.com/go-openapi/runtime"
 	"github.com/go-openapi/runtime/middleware"
 	"github.com/go-openapi/strfmt"
+	"github.com/go-openapi/swag"
 )
 
 // NewGetShareMetricsParams creates a new GetShareMetricsParams object
@@ -30,6 +32,10 @@ type GetShareMetricsParams struct {
 	// HTTP Request Object
 	HTTPRequest *http.Request `json:"-"`
 
+	/*
+	  In: query
+	*/
+	Duration *float64
 	/*
 	  Required: true
 	  In: path
@@ -46,6 +52,13 @@ func (o *GetShareMetricsParams) BindRequest(r *http.Request, route *middleware.M
 
 	o.HTTPRequest = r
 
+	qs := runtime.Values(r.URL.Query())
+
+	qDuration, qhkDuration, _ := qs.GetOK("duration")
+	if err := o.bindDuration(qDuration, qhkDuration, route.Formats); err != nil {
+		res = append(res, err)
+	}
+
 	rShrToken, rhkShrToken, _ := route.Params.GetOK("shrToken")
 	if err := o.bindShrToken(rShrToken, rhkShrToken, route.Formats); err != nil {
 		res = append(res, err)
@@ -56,6 +69,29 @@ func (o *GetShareMetricsParams) BindRequest(r *http.Request, route *middleware.M
 	return nil
 }
 
+// bindDuration binds and validates parameter Duration from query.
+func (o *GetShareMetricsParams) bindDuration(rawData []string, hasKey bool, formats strfmt.Registry) error {
+	var raw string
+	if len(rawData) > 0 {
+		raw = rawData[len(rawData)-1]
+	}
+
+	// Required: false
+	// AllowEmptyValue: false
+
+	if raw == "" { // empty values pass all other validations
+		return nil
+	}
+
+	value, err := swag.ConvertFloat64(raw)
+	if err != nil {
+		return errors.InvalidType("duration", "query", "float64", raw)
+	}
+	o.Duration = &value
+
+	return nil
+}
+
 // bindShrToken binds and validates parameter ShrToken from path.
 func (o *GetShareMetricsParams) bindShrToken(rawData []string, hasKey bool, formats strfmt.Registry) error {
 	var raw string
diff --git a/rest_server_zrok/operations/metrics/get_share_metrics_urlbuilder.go b/rest_server_zrok/operations/metrics/get_share_metrics_urlbuilder.go
index deeb05e3..3477a21a 100644
--- a/rest_server_zrok/operations/metrics/get_share_metrics_urlbuilder.go
+++ b/rest_server_zrok/operations/metrics/get_share_metrics_urlbuilder.go
@@ -10,12 +10,16 @@ import (
 	"net/url"
 	golangswaggerpaths "path"
 	"strings"
+
+	"github.com/go-openapi/swag"
 )
 
 // GetShareMetricsURL generates an URL for the get share metrics operation
 type GetShareMetricsURL struct {
 	ShrToken string
 
+	Duration *float64
+
 	_basePath string
 	// avoid unkeyed usage
 	_ struct{}
@@ -55,6 +59,18 @@ func (o *GetShareMetricsURL) Build() (*url.URL, error) {
 	}
 	_result.Path = golangswaggerpaths.Join(_basePath, _path)
 
+	qs := make(url.Values)
+
+	var durationQ string
+	if o.Duration != nil {
+		durationQ = swag.FormatFloat64(*o.Duration)
+	}
+	if durationQ != "" {
+		qs.Set("duration", durationQ)
+	}
+
+	_result.RawQuery = qs.Encode()
+
 	return &_result, nil
 }
 
diff --git a/specs/zrok.yml b/specs/zrok.yml
index ffe081b8..7c9f97fc 100644
--- a/specs/zrok.yml
+++ b/specs/zrok.yml
@@ -415,6 +415,10 @@ paths:
       security:
         - key: []
       operationId: getAccountMetrics
+      parameters:
+        - name: duration
+          in: query
+          type: number
       responses:
         200:
           description: account metrics
@@ -433,6 +437,9 @@ paths:
           in: path
           type: string
           required: true
+        - name: duration
+          in: query
+          type: number
       responses:
         200:
           description: environment metrics
@@ -453,6 +460,9 @@ paths:
           in: path
           type: string
           required: true
+        - name: duration
+          in: query
+          type: number
       responses:
         200:
           description: share metrics
diff --git a/ui/src/api/metrics.js b/ui/src/api/metrics.js
index 5c1111dd..cc0063de 100644
--- a/ui/src/api/metrics.js
+++ b/ui/src/api/metrics.js
@@ -3,19 +3,34 @@
 import * as gateway from './gateway'
 
 /**
+ * @param {object} options Optional options
+ * @param {number} [options.duration] 
+ * @return {Promise<module:types.metrics>} account metrics
  */
-export function getAccountMetrics() {
-  return gateway.request(getAccountMetricsOperation)
+export function getAccountMetrics(options) {
+  if (!options) options = {}
+  const parameters = {
+    query: {
+      duration: options.duration
+    }
+  }
+  return gateway.request(getAccountMetricsOperation, parameters)
 }
 
 /**
  * @param {string} envId 
+ * @param {object} options Optional options
+ * @param {number} [options.duration] 
  * @return {Promise<module:types.metrics>} environment metrics
  */
-export function getEnvironmentMetrics(envId) {
+export function getEnvironmentMetrics(envId, options) {
+  if (!options) options = {}
   const parameters = {
     path: {
       envId
+    },
+    query: {
+      duration: options.duration
     }
   }
   return gateway.request(getEnvironmentMetricsOperation, parameters)
@@ -23,12 +38,18 @@ export function getEnvironmentMetrics(envId) {
 
 /**
  * @param {string} shrToken 
+ * @param {object} options Optional options
+ * @param {number} [options.duration] 
  * @return {Promise<module:types.metrics>} share metrics
  */
-export function getShareMetrics(shrToken) {
+export function getShareMetrics(shrToken, options) {
+  if (!options) options = {}
   const parameters = {
     path: {
       shrToken
+    },
+    query: {
+      duration: options.duration
     }
   }
   return gateway.request(getShareMetricsOperation, parameters)
diff --git a/ui/src/console/detail/environment/SharesTab.js b/ui/src/console/detail/environment/SharesTab.js
index e902529c..6fb01439 100644
--- a/ui/src/console/detail/environment/SharesTab.js
+++ b/ui/src/console/detail/environment/SharesTab.js
@@ -46,7 +46,7 @@ const SharesTab = (props) => {
             cell: row => {
                 return <ResponsiveContainer width={"100%"} height={"100%"}>
                     <AreaChart data={row.metrics}>
-                        <Area type="basis" dataKey={(v) => v} stroke={"#231069"} fillOpacity={1} fill={"#655796"} isAnimationActive={false} dot={false} />
+                        <Area type="basis" dataKey={(v) => v} stroke={"#777"} fillOpacity={0.5} fill={"#04adef"} isAnimationActive={false} dot={false} />
                     </AreaChart>
                 </ResponsiveContainer>
             }
diff --git a/ui/src/console/detail/share/ShareDetail.js b/ui/src/console/detail/share/ShareDetail.js
index 78371e1f..31501afc 100644
--- a/ui/src/console/detail/share/ShareDetail.js
+++ b/ui/src/console/detail/share/ShareDetail.js
@@ -42,7 +42,7 @@ const ShareDetail = (props) => {
         metrics: row => (
             <ResponsiveContainer width={"100%"} height={"100%"}>
                 <AreaChart data={row.value}>
-                    <Area type="basis" dataKey={(v) => v} stroke={"#231069"} fillOpacity={1} fill={"#655796"} isAnimationActive={false} dot={false} />
+                    <Area type="basis" dataKey={(v) => v} stroke={"#777"} fillOpacity={0.5} fill={"#04adef"} isAnimationActive={false} dot={false} />
                 </AreaChart>
             </ResponsiveContainer>
         ),
diff --git a/ui/src/console/visualizer/Network.js b/ui/src/console/visualizer/Network.js
index 3de59eb9..c75421c4 100644
--- a/ui/src/console/visualizer/Network.js
+++ b/ui/src/console/visualizer/Network.js
@@ -21,8 +21,8 @@ const Network = (props) => {
     }, []);
 
     const paintNode = (node, ctx) => {
-        let nodeColor = node.selected ? "#04adef" : "#9BF316";
-        let textColor = node.selected ? "white" : "black";
+        let nodeColor = node.selected ? "#9BF316" : "#04adef";
+        let textColor = node.selected ? "black" : "white";
 
         ctx.textBaseline = "middle";
         ctx.textAlign = "center";
diff --git a/ui/src/console/visualizer/graph.js b/ui/src/console/visualizer/graph.js
index 4e068555..7a445081 100644
--- a/ui/src/console/visualizer/graph.js
+++ b/ui/src/console/visualizer/graph.js
@@ -40,7 +40,7 @@ export const mergeGraph = (oldGraph, user, newOverview) => {
         newGraph.links.push({
             target: accountNode.id,
             source: envNode.id,
-            color: "#9BF316"
+            color: "#04adef"
         });
         if(env.shares) {
             env.shares.forEach(shr => {
@@ -60,7 +60,7 @@ export const mergeGraph = (oldGraph, user, newOverview) => {
                 newGraph.links.push({
                     target: envNode.id,
                     source: shrNode.id,
-                    color: "#9BF316"
+                    color: "#04adef"
                 });
             });
         }

From 6b078abcd77ac7aeef6cc35f236b38e79592d8f6 Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Tue, 9 May 2023 14:16:01 -0400
Subject: [PATCH 09/49] account metrics endpoint (#319)

---
 controller/controller.go                      |   3 +
 controller/metrics.go                         |  84 +++++++
 .../get_account_metrics_parameters.go         | 160 +++++++++++++
 .../metadata/get_account_metrics_responses.go | 212 ++++++++++++++++++
 .../get_environment_metrics_parameters.go     | 179 +++++++++++++++
 .../get_environment_metrics_responses.go      | 155 +++++++++++++
 .../metadata/get_share_metrics_parameters.go  | 179 +++++++++++++++
 .../metadata/get_share_metrics_responses.go   | 155 +++++++++++++
 rest_client_zrok/metadata/metadata_client.go  | 123 ++++++++++
 rest_client_zrok/zrok_client.go               |   5 -
 rest_server_zrok/embedded_spec.go             |  36 ++-
 .../metadata/get_account_metrics.go           |  71 ++++++
 .../get_account_metrics_parameters.go         |  77 +++++++
 .../metadata/get_account_metrics_responses.go | 109 +++++++++
 .../get_account_metrics_urlbuilder.go         | 103 +++++++++
 .../metadata/get_environment_metrics.go       |  71 ++++++
 .../get_environment_metrics_parameters.go     | 101 +++++++++
 .../get_environment_metrics_responses.go      |  84 +++++++
 .../get_environment_metrics_urlbuilder.go     | 113 ++++++++++
 .../operations/metadata/get_share_metrics.go  |  71 ++++++
 .../metadata/get_share_metrics_parameters.go  | 101 +++++++++
 .../metadata/get_share_metrics_responses.go   |  84 +++++++
 .../metadata/get_share_metrics_urlbuilder.go  | 113 ++++++++++
 rest_server_zrok/operations/zrok_api.go       |  43 ++--
 specs/zrok.yml                                |  42 ++--
 ui/src/api/metadata.js                        |  83 +++++++
 ui/src/api/metrics.js                         |  86 -------
 27 files changed, 2498 insertions(+), 145 deletions(-)
 create mode 100644 controller/metrics.go
 create mode 100644 rest_client_zrok/metadata/get_account_metrics_parameters.go
 create mode 100644 rest_client_zrok/metadata/get_account_metrics_responses.go
 create mode 100644 rest_client_zrok/metadata/get_environment_metrics_parameters.go
 create mode 100644 rest_client_zrok/metadata/get_environment_metrics_responses.go
 create mode 100644 rest_client_zrok/metadata/get_share_metrics_parameters.go
 create mode 100644 rest_client_zrok/metadata/get_share_metrics_responses.go
 create mode 100644 rest_server_zrok/operations/metadata/get_account_metrics.go
 create mode 100644 rest_server_zrok/operations/metadata/get_account_metrics_parameters.go
 create mode 100644 rest_server_zrok/operations/metadata/get_account_metrics_responses.go
 create mode 100644 rest_server_zrok/operations/metadata/get_account_metrics_urlbuilder.go
 create mode 100644 rest_server_zrok/operations/metadata/get_environment_metrics.go
 create mode 100644 rest_server_zrok/operations/metadata/get_environment_metrics_parameters.go
 create mode 100644 rest_server_zrok/operations/metadata/get_environment_metrics_responses.go
 create mode 100644 rest_server_zrok/operations/metadata/get_environment_metrics_urlbuilder.go
 create mode 100644 rest_server_zrok/operations/metadata/get_share_metrics.go
 create mode 100644 rest_server_zrok/operations/metadata/get_share_metrics_parameters.go
 create mode 100644 rest_server_zrok/operations/metadata/get_share_metrics_responses.go
 create mode 100644 rest_server_zrok/operations/metadata/get_share_metrics_urlbuilder.go
 delete mode 100644 ui/src/api/metrics.js

diff --git a/controller/controller.go b/controller/controller.go
index 1d05e128..34905ed8 100644
--- a/controller/controller.go
+++ b/controller/controller.go
@@ -47,6 +47,9 @@ func Run(inCfg *config.Config) error {
 	api.EnvironmentEnableHandler = newEnableHandler()
 	api.EnvironmentDisableHandler = newDisableHandler()
 	api.MetadataConfigurationHandler = newConfigurationHandler(cfg)
+	if cfg.Metrics != nil && cfg.Metrics.Influx != nil {
+		api.MetadataGetAccountMetricsHandler = newGetAccountMetricsHandler(cfg.Metrics.Influx)
+	}
 	api.MetadataGetEnvironmentDetailHandler = newEnvironmentDetailHandler()
 	api.MetadataGetShareDetailHandler = newShareDetailHandler()
 	api.MetadataOverviewHandler = metadata.OverviewHandlerFunc(overviewHandler)
diff --git a/controller/metrics.go b/controller/metrics.go
new file mode 100644
index 00000000..a5091b0f
--- /dev/null
+++ b/controller/metrics.go
@@ -0,0 +1,84 @@
+package controller
+
+import (
+	"context"
+	"fmt"
+	"github.com/go-openapi/runtime/middleware"
+	influxdb2 "github.com/influxdata/influxdb-client-go/v2"
+	"github.com/influxdata/influxdb-client-go/v2/api"
+	"github.com/openziti/zrok/controller/metrics"
+	"github.com/openziti/zrok/rest_model_zrok"
+	"github.com/openziti/zrok/rest_server_zrok/operations/metadata"
+	"github.com/sirupsen/logrus"
+	"time"
+)
+
+type getAccountMetricsHandler struct {
+	cfg      *metrics.InfluxConfig
+	idb      influxdb2.Client
+	queryApi api.QueryAPI
+}
+
+func newGetAccountMetricsHandler(cfg *metrics.InfluxConfig) *getAccountMetricsHandler {
+	idb := influxdb2.NewClient(cfg.Url, cfg.Token)
+	queryApi := idb.QueryAPI(cfg.Org)
+	return &getAccountMetricsHandler{
+		cfg:      cfg,
+		idb:      idb,
+		queryApi: queryApi,
+	}
+}
+
+func (h *getAccountMetricsHandler) Handle(params metadata.GetAccountMetricsParams, principal *rest_model_zrok.Principal) middleware.Responder {
+	duration := 30 * 24 * time.Hour
+	if params.Duration != nil {
+		v, err := time.ParseDuration(*params.Duration)
+		if err != nil {
+			logrus.Errorf("bad duration '%v' for '%v': %v", params.Duration, principal.Email, err)
+			return metadata.NewGetAccountMetricsBadRequest()
+		}
+		duration = v
+	}
+	slice := duration / 200
+
+	query := fmt.Sprintf("from(bucket: \"%v\")\n", h.cfg.Bucket) +
+		fmt.Sprintf("|> range(start -%v)\n", duration) +
+		"|> filter(fn: (r) => r[\"_measurement\"] == \"xfer\")\n" +
+		"|> filter(fn: (r) => r[\"_field\"] == \"rx\" or r[\"_field\"] == \"tx\")\n" +
+		"|> filter(fn: (r) => r[\"namespace\"] == \"backend\")\n" +
+		fmt.Sprintf("|> filter(fn: (r) => r[\"acctId\"] == \"%d\")\n", principal.ID) +
+		"|> drop(columns: [\"share\", \"envId\"])\n" +
+		fmt.Sprintf("|> aggregateWindow(every: %v, fn: sum, createEmpty: true)", slice)
+
+	rx, tx, err := runFluxForRxTxArray(query, h.queryApi)
+	if err != nil {
+		logrus.Errorf("error running account metrics query for '%v': %v", principal.Email, err)
+		return metadata.NewGetAccountMetricsInternalServerError()
+	}
+
+	response := &rest_model_zrok.Metrics{
+		ID:     fmt.Sprintf("%d", principal.ID),
+		Period: duration.Seconds(),
+		Rx:     rx,
+		Tx:     tx,
+	}
+	return metadata.NewGetAccountMetricsOK().WithPayload(response)
+}
+
+func runFluxForRxTxArray(query string, queryApi api.QueryAPI) (rx, tx []float64, err error) {
+	result, err := queryApi.Query(context.Background(), query)
+	if err != nil {
+		return nil, nil, err
+	}
+	for result.Next() {
+		if v, ok := result.Record().Value().(int64); ok {
+			switch result.Record().Field() {
+			case "rx":
+				rx = append(rx, float64(v))
+			case "tx":
+				tx = append(tx, float64(v))
+			}
+		}
+	}
+	return rx, tx, nil
+}
diff --git a/rest_client_zrok/metadata/get_account_metrics_parameters.go b/rest_client_zrok/metadata/get_account_metrics_parameters.go
new file mode 100644
index 00000000..2c74d9b9
--- /dev/null
+++ b/rest_client_zrok/metadata/get_account_metrics_parameters.go
@@ -0,0 +1,160 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metadata
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"context"
+	"net/http"
+	"time"
+
+	"github.com/go-openapi/errors"
+	"github.com/go-openapi/runtime"
+	cr "github.com/go-openapi/runtime/client"
+	"github.com/go-openapi/strfmt"
+)
+
+// NewGetAccountMetricsParams creates a new GetAccountMetricsParams object,
+// with the default timeout for this client.
+//
+// Default values are not hydrated, since defaults are normally applied by the API server side.
+//
+// To enforce default values in parameter, use SetDefaults or WithDefaults.
+func NewGetAccountMetricsParams() *GetAccountMetricsParams {
+	return &GetAccountMetricsParams{
+		timeout: cr.DefaultTimeout,
+	}
+}
+
+// NewGetAccountMetricsParamsWithTimeout creates a new GetAccountMetricsParams object
+// with the ability to set a timeout on a request.
+func NewGetAccountMetricsParamsWithTimeout(timeout time.Duration) *GetAccountMetricsParams {
+	return &GetAccountMetricsParams{
+		timeout: timeout,
+	}
+}
+
+// NewGetAccountMetricsParamsWithContext creates a new GetAccountMetricsParams object
+// with the ability to set a context for a request.
+func NewGetAccountMetricsParamsWithContext(ctx context.Context) *GetAccountMetricsParams {
+	return &GetAccountMetricsParams{
+		Context: ctx,
+	}
+}
+
+// NewGetAccountMetricsParamsWithHTTPClient creates a new GetAccountMetricsParams object
+// with the ability to set a custom HTTPClient for a request.
+func NewGetAccountMetricsParamsWithHTTPClient(client *http.Client) *GetAccountMetricsParams {
+	return &GetAccountMetricsParams{
+		HTTPClient: client,
+	}
+}
+
+/*
+GetAccountMetricsParams contains all the parameters to send to the API endpoint
+
+	for the get account metrics operation.
+
+	Typically these are written to a http.Request.
+*/
+type GetAccountMetricsParams struct {
+
+	// Duration.
+	Duration *string
+
+	timeout    time.Duration
+	Context    context.Context
+	HTTPClient *http.Client
+}
+
+// WithDefaults hydrates default values in the get account metrics params (not the query body).
+//
+// All values with no default are reset to their zero value.
+func (o *GetAccountMetricsParams) WithDefaults() *GetAccountMetricsParams {
+	o.SetDefaults()
+	return o
+}
+
+// SetDefaults hydrates default values in the get account metrics params (not the query body).
+//
+// All values with no default are reset to their zero value.
+func (o *GetAccountMetricsParams) SetDefaults() {
+	// no default values defined for this parameter
+}
+
+// WithTimeout adds the timeout to the get account metrics params
+func (o *GetAccountMetricsParams) WithTimeout(timeout time.Duration) *GetAccountMetricsParams {
+	o.SetTimeout(timeout)
+	return o
+}
+
+// SetTimeout adds the timeout to the get account metrics params
+func (o *GetAccountMetricsParams) SetTimeout(timeout time.Duration) {
+	o.timeout = timeout
+}
+
+// WithContext adds the context to the get account metrics params
+func (o *GetAccountMetricsParams) WithContext(ctx context.Context) *GetAccountMetricsParams {
+	o.SetContext(ctx)
+	return o
+}
+
+// SetContext adds the context to the get account metrics params
+func (o *GetAccountMetricsParams) SetContext(ctx context.Context) {
+	o.Context = ctx
+}
+
+// WithHTTPClient adds the HTTPClient to the get account metrics params
+func (o *GetAccountMetricsParams) WithHTTPClient(client *http.Client) *GetAccountMetricsParams {
+	o.SetHTTPClient(client)
+	return o
+}
+
+// SetHTTPClient adds the HTTPClient to the get account metrics params
+func (o *GetAccountMetricsParams) SetHTTPClient(client *http.Client) {
+	o.HTTPClient = client
+}
+
+// WithDuration adds the duration to the get account metrics params
+func (o *GetAccountMetricsParams) WithDuration(duration *string) *GetAccountMetricsParams {
+	o.SetDuration(duration)
+	return o
+}
+
+// SetDuration adds the duration to the get account metrics params
+func (o *GetAccountMetricsParams) SetDuration(duration *string) {
+	o.Duration = duration
+}
+
+// WriteToRequest writes these params to a swagger request
+func (o *GetAccountMetricsParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error {
+
+	if err := r.SetTimeout(o.timeout); err != nil {
+		return err
+	}
+	var res []error
+
+	if o.Duration != nil {
+
+		// query param duration
+		var qrDuration string
+
+		if o.Duration != nil {
+			qrDuration = *o.Duration
+		}
+		qDuration := qrDuration
+		if qDuration != "" {
+
+			if err := r.SetQueryParam("duration", qDuration); err != nil {
+				return err
+			}
+		}
+	}
+
+	if len(res) > 0 {
+		return errors.CompositeValidationError(res...)
+	}
+	return nil
+}
diff --git a/rest_client_zrok/metadata/get_account_metrics_responses.go b/rest_client_zrok/metadata/get_account_metrics_responses.go
new file mode 100644
index 00000000..5613b3a5
--- /dev/null
+++ b/rest_client_zrok/metadata/get_account_metrics_responses.go
@@ -0,0 +1,212 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metadata
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"fmt"
+	"io"
+
+	"github.com/go-openapi/runtime"
+	"github.com/go-openapi/strfmt"
+
+	"github.com/openziti/zrok/rest_model_zrok"
+)
+
+// GetAccountMetricsReader is a Reader for the GetAccountMetrics structure.
+type GetAccountMetricsReader struct {
+	formats strfmt.Registry
+}
+
+// ReadResponse reads a server response into the received o.
+func (o *GetAccountMetricsReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (interface{}, error) {
+	switch response.Code() {
+	case 200:
+		result := NewGetAccountMetricsOK()
+		if err := result.readResponse(response, consumer, o.formats); err != nil {
+			return nil, err
+		}
+		return result, nil
+	case 400:
+		result := NewGetAccountMetricsBadRequest()
+		if err := result.readResponse(response, consumer, o.formats); err != nil {
+			return nil, err
+		}
+		return nil, result
+	case 500:
+		result := NewGetAccountMetricsInternalServerError()
+		if err := result.readResponse(response, consumer, o.formats); err != nil {
+			return nil, err
+		}
+		return nil, result
+	default:
+		return nil, runtime.NewAPIError("response status code does not match any response statuses defined for this endpoint in the swagger spec", response, response.Code())
+	}
+}
+
+// NewGetAccountMetricsOK creates a GetAccountMetricsOK with default headers values
+func NewGetAccountMetricsOK() *GetAccountMetricsOK {
+	return &GetAccountMetricsOK{}
+}
+
+/*
+GetAccountMetricsOK describes a response with status code 200, with default header values.
+
+account metrics
+*/
+type GetAccountMetricsOK struct {
+	Payload *rest_model_zrok.Metrics
+}
+
+// IsSuccess returns true when this get account metrics o k response has a 2xx status code
+func (o *GetAccountMetricsOK) IsSuccess() bool {
+	return true
+}
+
+// IsRedirect returns true when this get account metrics o k response has a 3xx status code
+func (o *GetAccountMetricsOK) IsRedirect() bool {
+	return false
+}
+
+// IsClientError returns true when this get account metrics o k response has a 4xx status code
+func (o *GetAccountMetricsOK) IsClientError() bool {
+	return false
+}
+
+// IsServerError returns true when this get account metrics o k response has a 5xx status code
+func (o *GetAccountMetricsOK) IsServerError() bool {
+	return false
+}
+
+// IsCode returns true when this get account metrics o k response a status code equal to that given
+func (o *GetAccountMetricsOK) IsCode(code int) bool {
+	return code == 200
+}
+
+func (o *GetAccountMetricsOK) Error() string {
+	return fmt.Sprintf("[GET /metrics/account][%d] getAccountMetricsOK  %+v", 200, o.Payload)
+}
+
+func (o *GetAccountMetricsOK) String() string {
+	return fmt.Sprintf("[GET /metrics/account][%d] getAccountMetricsOK  %+v", 200, o.Payload)
+}
+
+func (o *GetAccountMetricsOK) GetPayload() *rest_model_zrok.Metrics {
+	return o.Payload
+}
+
+func (o *GetAccountMetricsOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error {
+
+	o.Payload = new(rest_model_zrok.Metrics)
+
+	// response payload
+	if err := consumer.Consume(response.Body(), o.Payload); err != nil && err != io.EOF {
+		return err
+	}
+
+	return nil
+}
+
+// NewGetAccountMetricsBadRequest creates a GetAccountMetricsBadRequest with default headers values
+func NewGetAccountMetricsBadRequest() *GetAccountMetricsBadRequest {
+	return &GetAccountMetricsBadRequest{}
+}
+
+/*
+GetAccountMetricsBadRequest describes a response with status code 400, with default header values.
+
+bad request
+*/
+type GetAccountMetricsBadRequest struct {
+}
+
+// IsSuccess returns true when this get account metrics bad request response has a 2xx status code
+func (o *GetAccountMetricsBadRequest) IsSuccess() bool {
+	return false
+}
+
+// IsRedirect returns true when this get account metrics bad request response has a 3xx status code
+func (o *GetAccountMetricsBadRequest) IsRedirect() bool {
+	return false
+}
+
+// IsClientError returns true when this get account metrics bad request response has a 4xx status code
+func (o *GetAccountMetricsBadRequest) IsClientError() bool {
+	return true
+}
+
+// IsServerError returns true when this get account metrics bad request response has a 5xx status code
+func (o *GetAccountMetricsBadRequest) IsServerError() bool {
+	return false
+}
+
+// IsCode returns true when this get account metrics bad request response a status code equal to that given
+func (o *GetAccountMetricsBadRequest) IsCode(code int) bool {
+	return code == 400
+}
+
+func (o *GetAccountMetricsBadRequest) Error() string {
+	return fmt.Sprintf("[GET /metrics/account][%d] getAccountMetricsBadRequest ", 400)
+}
+
+func (o *GetAccountMetricsBadRequest) String() string {
+	return fmt.Sprintf("[GET /metrics/account][%d] getAccountMetricsBadRequest ", 400)
+}
+
+func (o *GetAccountMetricsBadRequest) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error {
+
+	return nil
+}
+
+// NewGetAccountMetricsInternalServerError creates a GetAccountMetricsInternalServerError with default headers values
+func NewGetAccountMetricsInternalServerError() *GetAccountMetricsInternalServerError {
+	return &GetAccountMetricsInternalServerError{}
+}
+
+/*
+GetAccountMetricsInternalServerError describes a response with status code 500, with default header values.
+
+internal server error
+*/
+type GetAccountMetricsInternalServerError struct {
+}
+
+// IsSuccess returns true when this get account metrics internal server error response has a 2xx status code
+func (o *GetAccountMetricsInternalServerError) IsSuccess() bool {
+	return false
+}
+
+// IsRedirect returns true when this get account metrics internal server error response has a 3xx status code
+func (o *GetAccountMetricsInternalServerError) IsRedirect() bool {
+	return false
+}
+
+// IsClientError returns true when this get account metrics internal server error response has a 4xx status code
+func (o *GetAccountMetricsInternalServerError) IsClientError() bool {
+	return false
+}
+
+// IsServerError returns true when this get account metrics internal server error response has a 5xx status code
+func (o *GetAccountMetricsInternalServerError) IsServerError() bool {
+	return true
+}
+
+// IsCode returns true when this get account metrics internal server error response a status code equal to that given
+func (o *GetAccountMetricsInternalServerError) IsCode(code int) bool {
+	return code == 500
+}
+
+func (o *GetAccountMetricsInternalServerError) Error() string {
+	return fmt.Sprintf("[GET /metrics/account][%d] getAccountMetricsInternalServerError ", 500)
+}
+
+func (o *GetAccountMetricsInternalServerError) String() string {
+	return fmt.Sprintf("[GET /metrics/account][%d] getAccountMetricsInternalServerError ", 500)
+}
+
+func (o *GetAccountMetricsInternalServerError) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error {
+
+	return nil
+}
diff --git a/rest_client_zrok/metadata/get_environment_metrics_parameters.go b/rest_client_zrok/metadata/get_environment_metrics_parameters.go
new file mode 100644
index 00000000..be7aae16
--- /dev/null
+++ b/rest_client_zrok/metadata/get_environment_metrics_parameters.go
@@ -0,0 +1,179 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metadata
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"context"
+	"net/http"
+	"time"
+
+	"github.com/go-openapi/errors"
+	"github.com/go-openapi/runtime"
+	cr "github.com/go-openapi/runtime/client"
+	"github.com/go-openapi/strfmt"
+)
+
+// NewGetEnvironmentMetricsParams creates a new GetEnvironmentMetricsParams object,
+// with the default timeout for this client.
+//
+// Default values are not hydrated, since defaults are normally applied by the API server side.
+//
+// To enforce default values in parameter, use SetDefaults or WithDefaults.
+func NewGetEnvironmentMetricsParams() *GetEnvironmentMetricsParams {
+	return &GetEnvironmentMetricsParams{
+		timeout: cr.DefaultTimeout,
+	}
+}
+
+// NewGetEnvironmentMetricsParamsWithTimeout creates a new GetEnvironmentMetricsParams object
+// with the ability to set a timeout on a request.
+func NewGetEnvironmentMetricsParamsWithTimeout(timeout time.Duration) *GetEnvironmentMetricsParams {
+	return &GetEnvironmentMetricsParams{
+		timeout: timeout,
+	}
+}
+
+// NewGetEnvironmentMetricsParamsWithContext creates a new GetEnvironmentMetricsParams object
+// with the ability to set a context for a request.
+func NewGetEnvironmentMetricsParamsWithContext(ctx context.Context) *GetEnvironmentMetricsParams {
+	return &GetEnvironmentMetricsParams{
+		Context: ctx,
+	}
+}
+
+// NewGetEnvironmentMetricsParamsWithHTTPClient creates a new GetEnvironmentMetricsParams object
+// with the ability to set a custom HTTPClient for a request.
+func NewGetEnvironmentMetricsParamsWithHTTPClient(client *http.Client) *GetEnvironmentMetricsParams {
+	return &GetEnvironmentMetricsParams{
+		HTTPClient: client,
+	}
+}
+
+/*
+GetEnvironmentMetricsParams contains all the parameters to send to the API endpoint
+
+	for the get environment metrics operation.
+
+	Typically these are written to a http.Request.
+*/
+type GetEnvironmentMetricsParams struct {
+
+	// Duration.
+	Duration *string
+
+	// EnvID.
+	EnvID string
+
+	timeout    time.Duration
+	Context    context.Context
+	HTTPClient *http.Client
+}
+
+// WithDefaults hydrates default values in the get environment metrics params (not the query body).
+//
+// All values with no default are reset to their zero value.
+func (o *GetEnvironmentMetricsParams) WithDefaults() *GetEnvironmentMetricsParams {
+	o.SetDefaults()
+	return o
+}
+
+// SetDefaults hydrates default values in the get environment metrics params (not the query body).
+//
+// All values with no default are reset to their zero value.
+func (o *GetEnvironmentMetricsParams) SetDefaults() {
+	// no default values defined for this parameter
+}
+
+// WithTimeout adds the timeout to the get environment metrics params
+func (o *GetEnvironmentMetricsParams) WithTimeout(timeout time.Duration) *GetEnvironmentMetricsParams {
+	o.SetTimeout(timeout)
+	return o
+}
+
+// SetTimeout adds the timeout to the get environment metrics params
+func (o *GetEnvironmentMetricsParams) SetTimeout(timeout time.Duration) {
+	o.timeout = timeout
+}
+
+// WithContext adds the context to the get environment metrics params
+func (o *GetEnvironmentMetricsParams) WithContext(ctx context.Context) *GetEnvironmentMetricsParams {
+	o.SetContext(ctx)
+	return o
+}
+
+// SetContext adds the context to the get environment metrics params
+func (o *GetEnvironmentMetricsParams) SetContext(ctx context.Context) {
+	o.Context = ctx
+}
+
+// WithHTTPClient adds the HTTPClient to the get environment metrics params
+func (o *GetEnvironmentMetricsParams) WithHTTPClient(client *http.Client) *GetEnvironmentMetricsParams {
+	o.SetHTTPClient(client)
+	return o
+}
+
+// SetHTTPClient adds the HTTPClient to the get environment metrics params
+func (o *GetEnvironmentMetricsParams) SetHTTPClient(client *http.Client) {
+	o.HTTPClient = client
+}
+
+// WithDuration adds the duration to the get environment metrics params
+func (o *GetEnvironmentMetricsParams) WithDuration(duration *string) *GetEnvironmentMetricsParams {
+	o.SetDuration(duration)
+	return o
+}
+
+// SetDuration adds the duration to the get environment metrics params
+func (o *GetEnvironmentMetricsParams) SetDuration(duration *string) {
+	o.Duration = duration
+}
+
+// WithEnvID adds the envID to the get environment metrics params
+func (o *GetEnvironmentMetricsParams) WithEnvID(envID string) *GetEnvironmentMetricsParams {
+	o.SetEnvID(envID)
+	return o
+}
+
+// SetEnvID adds the envId to the get environment metrics params
+func (o *GetEnvironmentMetricsParams) SetEnvID(envID string) {
+	o.EnvID = envID
+}
+
+// WriteToRequest writes these params to a swagger request
+func (o *GetEnvironmentMetricsParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error {
+
+	if err := r.SetTimeout(o.timeout); err != nil {
+		return err
+	}
+	var res []error
+
+	if o.Duration != nil {
+
+		// query param duration
+		var qrDuration string
+
+		if o.Duration != nil {
+			qrDuration = *o.Duration
+		}
+		qDuration := qrDuration
+		if qDuration != "" {
+
+			if err := r.SetQueryParam("duration", qDuration); err != nil {
+				return err
+			}
+		}
+	}
+
+	// path param envId
+	if err := r.SetPathParam("envId", o.EnvID); err != nil {
+		return err
+	}
+
+	if len(res) > 0 {
+		return errors.CompositeValidationError(res...)
+	}
+	return nil
+}
diff --git a/rest_client_zrok/metadata/get_environment_metrics_responses.go b/rest_client_zrok/metadata/get_environment_metrics_responses.go
new file mode 100644
index 00000000..36e1e2f6
--- /dev/null
+++ b/rest_client_zrok/metadata/get_environment_metrics_responses.go
@@ -0,0 +1,155 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metadata
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"fmt"
+	"io"
+
+	"github.com/go-openapi/runtime"
+	"github.com/go-openapi/strfmt"
+
+	"github.com/openziti/zrok/rest_model_zrok"
+)
+
+// GetEnvironmentMetricsReader is a Reader for the GetEnvironmentMetrics structure.
+type GetEnvironmentMetricsReader struct {
+	formats strfmt.Registry
+}
+
+// ReadResponse reads a server response into the received o.
+func (o *GetEnvironmentMetricsReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (interface{}, error) {
+	switch response.Code() {
+	case 200:
+		result := NewGetEnvironmentMetricsOK()
+		if err := result.readResponse(response, consumer, o.formats); err != nil {
+			return nil, err
+		}
+		return result, nil
+	case 401:
+		result := NewGetEnvironmentMetricsUnauthorized()
+		if err := result.readResponse(response, consumer, o.formats); err != nil {
+			return nil, err
+		}
+		return nil, result
+	default:
+		return nil, runtime.NewAPIError("response status code does not match any response statuses defined for this endpoint in the swagger spec", response, response.Code())
+	}
+}
+
+// NewGetEnvironmentMetricsOK creates a GetEnvironmentMetricsOK with default headers values
+func NewGetEnvironmentMetricsOK() *GetEnvironmentMetricsOK {
+	return &GetEnvironmentMetricsOK{}
+}
+
+/*
+GetEnvironmentMetricsOK describes a response with status code 200, with default header values.
+
+environment metrics
+*/
+type GetEnvironmentMetricsOK struct {
+	Payload *rest_model_zrok.Metrics
+}
+
+// IsSuccess returns true when this get environment metrics o k response has a 2xx status code
+func (o *GetEnvironmentMetricsOK) IsSuccess() bool {
+	return true
+}
+
+// IsRedirect returns true when this get environment metrics o k response has a 3xx status code
+func (o *GetEnvironmentMetricsOK) IsRedirect() bool {
+	return false
+}
+
+// IsClientError returns true when this get environment metrics o k response has a 4xx status code
+func (o *GetEnvironmentMetricsOK) IsClientError() bool {
+	return false
+}
+
+// IsServerError returns true when this get environment metrics o k response has a 5xx status code
+func (o *GetEnvironmentMetricsOK) IsServerError() bool {
+	return false
+}
+
+// IsCode returns true when this get environment metrics o k response a status code equal to that given
+func (o *GetEnvironmentMetricsOK) IsCode(code int) bool {
+	return code == 200
+}
+
+func (o *GetEnvironmentMetricsOK) Error() string {
+	return fmt.Sprintf("[GET /metrics/environment/{envId}][%d] getEnvironmentMetricsOK  %+v", 200, o.Payload)
+}
+
+func (o *GetEnvironmentMetricsOK) String() string {
+	return fmt.Sprintf("[GET /metrics/environment/{envId}][%d] getEnvironmentMetricsOK  %+v", 200, o.Payload)
+}
+
+func (o *GetEnvironmentMetricsOK) GetPayload() *rest_model_zrok.Metrics {
+	return o.Payload
+}
+
+func (o *GetEnvironmentMetricsOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error {
+
+	o.Payload = new(rest_model_zrok.Metrics)
+
+	// response payload
+	if err := consumer.Consume(response.Body(), o.Payload); err != nil && err != io.EOF {
+		return err
+	}
+
+	return nil
+}
+
+// NewGetEnvironmentMetricsUnauthorized creates a GetEnvironmentMetricsUnauthorized with default headers values
+func NewGetEnvironmentMetricsUnauthorized() *GetEnvironmentMetricsUnauthorized {
+	return &GetEnvironmentMetricsUnauthorized{}
+}
+
+/*
+GetEnvironmentMetricsUnauthorized describes a response with status code 401, with default header values.
+
+unauthorized
+*/
+type GetEnvironmentMetricsUnauthorized struct {
+}
+
+// IsSuccess returns true when this get environment metrics unauthorized response has a 2xx status code
+func (o *GetEnvironmentMetricsUnauthorized) IsSuccess() bool {
+	return false
+}
+
+// IsRedirect returns true when this get environment metrics unauthorized response has a 3xx status code
+func (o *GetEnvironmentMetricsUnauthorized) IsRedirect() bool {
+	return false
+}
+
+// IsClientError returns true when this get environment metrics unauthorized response has a 4xx status code
+func (o *GetEnvironmentMetricsUnauthorized) IsClientError() bool {
+	return true
+}
+
+// IsServerError returns true when this get environment metrics unauthorized response has a 5xx status code
+func (o *GetEnvironmentMetricsUnauthorized) IsServerError() bool {
+	return false
+}
+
+// IsCode returns true when this get environment metrics unauthorized response a status code equal to that given
+func (o *GetEnvironmentMetricsUnauthorized) IsCode(code int) bool {
+	return code == 401
+}
+
+func (o *GetEnvironmentMetricsUnauthorized) Error() string {
+	return fmt.Sprintf("[GET /metrics/environment/{envId}][%d] getEnvironmentMetricsUnauthorized ", 401)
+}
+
+func (o *GetEnvironmentMetricsUnauthorized) String() string {
+	return fmt.Sprintf("[GET /metrics/environment/{envId}][%d] getEnvironmentMetricsUnauthorized ", 401)
+}
+
+func (o *GetEnvironmentMetricsUnauthorized) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error {
+
+	return nil
+}
diff --git a/rest_client_zrok/metadata/get_share_metrics_parameters.go b/rest_client_zrok/metadata/get_share_metrics_parameters.go
new file mode 100644
index 00000000..96f5ac62
--- /dev/null
+++ b/rest_client_zrok/metadata/get_share_metrics_parameters.go
@@ -0,0 +1,179 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metadata
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"context"
+	"net/http"
+	"time"
+
+	"github.com/go-openapi/errors"
+	"github.com/go-openapi/runtime"
+	cr "github.com/go-openapi/runtime/client"
+	"github.com/go-openapi/strfmt"
+)
+
+// NewGetShareMetricsParams creates a new GetShareMetricsParams object,
+// with the default timeout for this client.
+//
+// Default values are not hydrated, since defaults are normally applied by the API server side.
+//
+// To enforce default values in parameter, use SetDefaults or WithDefaults.
+func NewGetShareMetricsParams() *GetShareMetricsParams {
+	return &GetShareMetricsParams{
+		timeout: cr.DefaultTimeout,
+	}
+}
+
+// NewGetShareMetricsParamsWithTimeout creates a new GetShareMetricsParams object
+// with the ability to set a timeout on a request.
+func NewGetShareMetricsParamsWithTimeout(timeout time.Duration) *GetShareMetricsParams {
+	return &GetShareMetricsParams{
+		timeout: timeout,
+	}
+}
+
+// NewGetShareMetricsParamsWithContext creates a new GetShareMetricsParams object
+// with the ability to set a context for a request.
+func NewGetShareMetricsParamsWithContext(ctx context.Context) *GetShareMetricsParams {
+	return &GetShareMetricsParams{
+		Context: ctx,
+	}
+}
+
+// NewGetShareMetricsParamsWithHTTPClient creates a new GetShareMetricsParams object
+// with the ability to set a custom HTTPClient for a request.
+func NewGetShareMetricsParamsWithHTTPClient(client *http.Client) *GetShareMetricsParams {
+	return &GetShareMetricsParams{
+		HTTPClient: client,
+	}
+}
+
+/*
+GetShareMetricsParams contains all the parameters to send to the API endpoint
+
+	for the get share metrics operation.
+
+	Typically these are written to a http.Request.
+*/
+type GetShareMetricsParams struct {
+
+	// Duration.
+	Duration *string
+
+	// ShrToken.
+	ShrToken string
+
+	timeout    time.Duration
+	Context    context.Context
+	HTTPClient *http.Client
+}
+
+// WithDefaults hydrates default values in the get share metrics params (not the query body).
+//
+// All values with no default are reset to their zero value.
+func (o *GetShareMetricsParams) WithDefaults() *GetShareMetricsParams {
+	o.SetDefaults()
+	return o
+}
+
+// SetDefaults hydrates default values in the get share metrics params (not the query body).
+//
+// All values with no default are reset to their zero value.
+func (o *GetShareMetricsParams) SetDefaults() {
+	// no default values defined for this parameter
+}
+
+// WithTimeout adds the timeout to the get share metrics params
+func (o *GetShareMetricsParams) WithTimeout(timeout time.Duration) *GetShareMetricsParams {
+	o.SetTimeout(timeout)
+	return o
+}
+
+// SetTimeout adds the timeout to the get share metrics params
+func (o *GetShareMetricsParams) SetTimeout(timeout time.Duration) {
+	o.timeout = timeout
+}
+
+// WithContext adds the context to the get share metrics params
+func (o *GetShareMetricsParams) WithContext(ctx context.Context) *GetShareMetricsParams {
+	o.SetContext(ctx)
+	return o
+}
+
+// SetContext adds the context to the get share metrics params
+func (o *GetShareMetricsParams) SetContext(ctx context.Context) {
+	o.Context = ctx
+}
+
+// WithHTTPClient adds the HTTPClient to the get share metrics params
+func (o *GetShareMetricsParams) WithHTTPClient(client *http.Client) *GetShareMetricsParams {
+	o.SetHTTPClient(client)
+	return o
+}
+
+// SetHTTPClient adds the HTTPClient to the get share metrics params
+func (o *GetShareMetricsParams) SetHTTPClient(client *http.Client) {
+	o.HTTPClient = client
+}
+
+// WithDuration adds the duration to the get share metrics params
+func (o *GetShareMetricsParams) WithDuration(duration *string) *GetShareMetricsParams {
+	o.SetDuration(duration)
+	return o
+}
+
+// SetDuration adds the duration to the get share metrics params
+func (o *GetShareMetricsParams) SetDuration(duration *string) {
+	o.Duration = duration
+}
+
+// WithShrToken adds the shrToken to the get share metrics params
+func (o *GetShareMetricsParams) WithShrToken(shrToken string) *GetShareMetricsParams {
+	o.SetShrToken(shrToken)
+	return o
+}
+
+// SetShrToken adds the shrToken to the get share metrics params
+func (o *GetShareMetricsParams) SetShrToken(shrToken string) {
+	o.ShrToken = shrToken
+}
+
+// WriteToRequest writes these params to a swagger request
+func (o *GetShareMetricsParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error {
+
+	if err := r.SetTimeout(o.timeout); err != nil {
+		return err
+	}
+	var res []error
+
+	if o.Duration != nil {
+
+		// query param duration
+		var qrDuration string
+
+		if o.Duration != nil {
+			qrDuration = *o.Duration
+		}
+		qDuration := qrDuration
+		if qDuration != "" {
+
+			if err := r.SetQueryParam("duration", qDuration); err != nil {
+				return err
+			}
+		}
+	}
+
+	// path param shrToken
+	if err := r.SetPathParam("shrToken", o.ShrToken); err != nil {
+		return err
+	}
+
+	if len(res) > 0 {
+		return errors.CompositeValidationError(res...)
+	}
+	return nil
+}
diff --git a/rest_client_zrok/metadata/get_share_metrics_responses.go b/rest_client_zrok/metadata/get_share_metrics_responses.go
new file mode 100644
index 00000000..2ed61ab8
--- /dev/null
+++ b/rest_client_zrok/metadata/get_share_metrics_responses.go
@@ -0,0 +1,155 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metadata
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"fmt"
+	"io"
+
+	"github.com/go-openapi/runtime"
+	"github.com/go-openapi/strfmt"
+
+	"github.com/openziti/zrok/rest_model_zrok"
+)
+
+// GetShareMetricsReader is a Reader for the GetShareMetrics structure.
+type GetShareMetricsReader struct {
+	formats strfmt.Registry
+}
+
+// ReadResponse reads a server response into the received o.
+func (o *GetShareMetricsReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (interface{}, error) {
+	switch response.Code() {
+	case 200:
+		result := NewGetShareMetricsOK()
+		if err := result.readResponse(response, consumer, o.formats); err != nil {
+			return nil, err
+		}
+		return result, nil
+	case 401:
+		result := NewGetShareMetricsUnauthorized()
+		if err := result.readResponse(response, consumer, o.formats); err != nil {
+			return nil, err
+		}
+		return nil, result
+	default:
+		return nil, runtime.NewAPIError("response status code does not match any response statuses defined for this endpoint in the swagger spec", response, response.Code())
+	}
+}
+
+// NewGetShareMetricsOK creates a GetShareMetricsOK with default headers values
+func NewGetShareMetricsOK() *GetShareMetricsOK {
+	return &GetShareMetricsOK{}
+}
+
+/*
+GetShareMetricsOK describes a response with status code 200, with default header values.
+
+share metrics
+*/
+type GetShareMetricsOK struct {
+	Payload *rest_model_zrok.Metrics
+}
+
+// IsSuccess returns true when this get share metrics o k response has a 2xx status code
+func (o *GetShareMetricsOK) IsSuccess() bool {
+	return true
+}
+
+// IsRedirect returns true when this get share metrics o k response has a 3xx status code
+func (o *GetShareMetricsOK) IsRedirect() bool {
+	return false
+}
+
+// IsClientError returns true when this get share metrics o k response has a 4xx status code
+func (o *GetShareMetricsOK) IsClientError() bool {
+	return false
+}
+
+// IsServerError returns true when this get share metrics o k response has a 5xx status code
+func (o *GetShareMetricsOK) IsServerError() bool {
+	return false
+}
+
+// IsCode returns true when this get share metrics o k response a status code equal to that given
+func (o *GetShareMetricsOK) IsCode(code int) bool {
+	return code == 200
+}
+
+func (o *GetShareMetricsOK) Error() string {
+	return fmt.Sprintf("[GET /metrics/share/{shrToken}][%d] getShareMetricsOK  %+v", 200, o.Payload)
+}
+
+func (o *GetShareMetricsOK) String() string {
+	return fmt.Sprintf("[GET /metrics/share/{shrToken}][%d] getShareMetricsOK  %+v", 200, o.Payload)
+}
+
+func (o *GetShareMetricsOK) GetPayload() *rest_model_zrok.Metrics {
+	return o.Payload
+}
+
+func (o *GetShareMetricsOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error {
+
+	o.Payload = new(rest_model_zrok.Metrics)
+
+	// response payload
+	if err := consumer.Consume(response.Body(), o.Payload); err != nil && err != io.EOF {
+		return err
+	}
+
+	return nil
+}
+
+// NewGetShareMetricsUnauthorized creates a GetShareMetricsUnauthorized with default headers values
+func NewGetShareMetricsUnauthorized() *GetShareMetricsUnauthorized {
+	return &GetShareMetricsUnauthorized{}
+}
+
+/*
+GetShareMetricsUnauthorized describes a response with status code 401, with default header values.
+
+unauthorized
+*/
+type GetShareMetricsUnauthorized struct {
+}
+
+// IsSuccess returns true when this get share metrics unauthorized response has a 2xx status code
+func (o *GetShareMetricsUnauthorized) IsSuccess() bool {
+	return false
+}
+
+// IsRedirect returns true when this get share metrics unauthorized response has a 3xx status code
+func (o *GetShareMetricsUnauthorized) IsRedirect() bool {
+	return false
+}
+
+// IsClientError returns true when this get share metrics unauthorized response has a 4xx status code
+func (o *GetShareMetricsUnauthorized) IsClientError() bool {
+	return true
+}
+
+// IsServerError returns true when this get share metrics unauthorized response has a 5xx status code
+func (o *GetShareMetricsUnauthorized) IsServerError() bool {
+	return false
+}
+
+// IsCode returns true when this get share metrics unauthorized response a status code equal to that given
+func (o *GetShareMetricsUnauthorized) IsCode(code int) bool {
+	return code == 401
+}
+
+func (o *GetShareMetricsUnauthorized) Error() string {
+	return fmt.Sprintf("[GET /metrics/share/{shrToken}][%d] getShareMetricsUnauthorized ", 401)
+}
+
+func (o *GetShareMetricsUnauthorized) String() string {
+	return fmt.Sprintf("[GET /metrics/share/{shrToken}][%d] getShareMetricsUnauthorized ", 401)
+}
+
+func (o *GetShareMetricsUnauthorized) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error {
+
+	return nil
+}
diff --git a/rest_client_zrok/metadata/metadata_client.go b/rest_client_zrok/metadata/metadata_client.go
index 0253fbf0..11e3a1ca 100644
--- a/rest_client_zrok/metadata/metadata_client.go
+++ b/rest_client_zrok/metadata/metadata_client.go
@@ -32,10 +32,16 @@ type ClientOption func(*runtime.ClientOperation)
 type ClientService interface {
 	Configuration(params *ConfigurationParams, opts ...ClientOption) (*ConfigurationOK, error)
 
+	GetAccountMetrics(params *GetAccountMetricsParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*GetAccountMetricsOK, error)
+
 	GetEnvironmentDetail(params *GetEnvironmentDetailParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*GetEnvironmentDetailOK, error)
 
+	GetEnvironmentMetrics(params *GetEnvironmentMetricsParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*GetEnvironmentMetricsOK, error)
+
 	GetShareDetail(params *GetShareDetailParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*GetShareDetailOK, error)
 
+	GetShareMetrics(params *GetShareMetricsParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*GetShareMetricsOK, error)
+
 	Overview(params *OverviewParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*OverviewOK, error)
 
 	Version(params *VersionParams, opts ...ClientOption) (*VersionOK, error)
@@ -81,6 +87,45 @@ func (a *Client) Configuration(params *ConfigurationParams, opts ...ClientOption
 	panic(msg)
 }
 
+/*
+GetAccountMetrics get account metrics API
+*/
+func (a *Client) GetAccountMetrics(params *GetAccountMetricsParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*GetAccountMetricsOK, error) {
+	// TODO: Validate the params before sending
+	if params == nil {
+		params = NewGetAccountMetricsParams()
+	}
+	op := &runtime.ClientOperation{
+		ID:                 "getAccountMetrics",
+		Method:             "GET",
+		PathPattern:        "/metrics/account",
+		ProducesMediaTypes: []string{"application/zrok.v1+json"},
+		ConsumesMediaTypes: []string{"application/zrok.v1+json"},
+		Schemes:            []string{"http"},
+		Params:             params,
+		Reader:             &GetAccountMetricsReader{formats: a.formats},
+		AuthInfo:           authInfo,
+		Context:            params.Context,
+		Client:             params.HTTPClient,
+	}
+	for _, opt := range opts {
+		opt(op)
+	}
+
+	result, err := a.transport.Submit(op)
+	if err != nil {
+		return nil, err
+	}
+	success, ok := result.(*GetAccountMetricsOK)
+	if ok {
+		return success, nil
+	}
+	// unexpected success response
+	// safeguard: normally, absent a default response, unknown success responses return an error above: so this is a codegen issue
+	msg := fmt.Sprintf("unexpected success response for getAccountMetrics: API contract not enforced by server. Client expected to get an error, but got: %T", result)
+	panic(msg)
+}
+
 /*
 GetEnvironmentDetail get environment detail API
 */
@@ -120,6 +165,45 @@ func (a *Client) GetEnvironmentDetail(params *GetEnvironmentDetailParams, authIn
 	panic(msg)
 }
 
+/*
+GetEnvironmentMetrics get environment metrics API
+*/
+func (a *Client) GetEnvironmentMetrics(params *GetEnvironmentMetricsParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*GetEnvironmentMetricsOK, error) {
+	// TODO: Validate the params before sending
+	if params == nil {
+		params = NewGetEnvironmentMetricsParams()
+	}
+	op := &runtime.ClientOperation{
+		ID:                 "getEnvironmentMetrics",
+		Method:             "GET",
+		PathPattern:        "/metrics/environment/{envId}",
+		ProducesMediaTypes: []string{"application/zrok.v1+json"},
+		ConsumesMediaTypes: []string{"application/zrok.v1+json"},
+		Schemes:            []string{"http"},
+		Params:             params,
+		Reader:             &GetEnvironmentMetricsReader{formats: a.formats},
+		AuthInfo:           authInfo,
+		Context:            params.Context,
+		Client:             params.HTTPClient,
+	}
+	for _, opt := range opts {
+		opt(op)
+	}
+
+	result, err := a.transport.Submit(op)
+	if err != nil {
+		return nil, err
+	}
+	success, ok := result.(*GetEnvironmentMetricsOK)
+	if ok {
+		return success, nil
+	}
+	// unexpected success response
+	// safeguard: normally, absent a default response, unknown success responses return an error above: so this is a codegen issue
+	msg := fmt.Sprintf("unexpected success response for getEnvironmentMetrics: API contract not enforced by server. Client expected to get an error, but got: %T", result)
+	panic(msg)
+}
+
 /*
 GetShareDetail get share detail API
 */
@@ -159,6 +243,45 @@ func (a *Client) GetShareDetail(params *GetShareDetailParams, authInfo runtime.C
 	panic(msg)
 }
 
+/*
+GetShareMetrics get share metrics API
+*/
+func (a *Client) GetShareMetrics(params *GetShareMetricsParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*GetShareMetricsOK, error) {
+	// TODO: Validate the params before sending
+	if params == nil {
+		params = NewGetShareMetricsParams()
+	}
+	op := &runtime.ClientOperation{
+		ID:                 "getShareMetrics",
+		Method:             "GET",
+		PathPattern:        "/metrics/share/{shrToken}",
+		ProducesMediaTypes: []string{"application/zrok.v1+json"},
+		ConsumesMediaTypes: []string{"application/zrok.v1+json"},
+		Schemes:            []string{"http"},
+		Params:             params,
+		Reader:             &GetShareMetricsReader{formats: a.formats},
+		AuthInfo:           authInfo,
+		Context:            params.Context,
+		Client:             params.HTTPClient,
+	}
+	for _, opt := range opts {
+		opt(op)
+	}
+
+	result, err := a.transport.Submit(op)
+	if err != nil {
+		return nil, err
+	}
+	success, ok := result.(*GetShareMetricsOK)
+	if ok {
+		return success, nil
+	}
+	// unexpected success response
+	// safeguard: normally, absent a default response, unknown success responses return an error above: so this is a codegen issue
+	msg := fmt.Sprintf("unexpected success response for getShareMetrics: API contract not enforced by server. Client expected to get an error, but got: %T", result)
+	panic(msg)
+}
+
 /*
 Overview overview API
 */
diff --git a/rest_client_zrok/zrok_client.go b/rest_client_zrok/zrok_client.go
index c5f02b8c..a4200e67 100644
--- a/rest_client_zrok/zrok_client.go
+++ b/rest_client_zrok/zrok_client.go
@@ -14,7 +14,6 @@ import (
 	"github.com/openziti/zrok/rest_client_zrok/admin"
 	"github.com/openziti/zrok/rest_client_zrok/environment"
 	"github.com/openziti/zrok/rest_client_zrok/metadata"
-	"github.com/openziti/zrok/rest_client_zrok/metrics"
 	"github.com/openziti/zrok/rest_client_zrok/share"
 )
 
@@ -64,7 +63,6 @@ func New(transport runtime.ClientTransport, formats strfmt.Registry) *Zrok {
 	cli.Admin = admin.New(transport, formats)
 	cli.Environment = environment.New(transport, formats)
 	cli.Metadata = metadata.New(transport, formats)
-	cli.Metrics = metrics.New(transport, formats)
 	cli.Share = share.New(transport, formats)
 	return cli
 }
@@ -118,8 +116,6 @@ type Zrok struct {
 
 	Metadata metadata.ClientService
 
-	Metrics metrics.ClientService
-
 	Share share.ClientService
 
 	Transport runtime.ClientTransport
@@ -132,6 +128,5 @@ func (c *Zrok) SetTransport(transport runtime.ClientTransport) {
 	c.Admin.SetTransport(transport)
 	c.Environment.SetTransport(transport)
 	c.Metadata.SetTransport(transport)
-	c.Metrics.SetTransport(transport)
 	c.Share.SetTransport(transport)
 }
diff --git a/rest_server_zrok/embedded_spec.go b/rest_server_zrok/embedded_spec.go
index 46e97057..2d8d53fc 100644
--- a/rest_server_zrok/embedded_spec.go
+++ b/rest_server_zrok/embedded_spec.go
@@ -528,12 +528,12 @@ func init() {
           }
         ],
         "tags": [
-          "metrics"
+          "metadata"
         ],
         "operationId": "getAccountMetrics",
         "parameters": [
           {
-            "type": "number",
+            "type": "string",
             "name": "duration",
             "in": "query"
           }
@@ -544,6 +544,12 @@ func init() {
             "schema": {
               "$ref": "#/definitions/metrics"
             }
+          },
+          "400": {
+            "description": "bad request"
+          },
+          "500": {
+            "description": "internal server error"
           }
         }
       }
@@ -556,7 +562,7 @@ func init() {
           }
         ],
         "tags": [
-          "metrics"
+          "metadata"
         ],
         "operationId": "getEnvironmentMetrics",
         "parameters": [
@@ -567,7 +573,7 @@ func init() {
             "required": true
           },
           {
-            "type": "number",
+            "type": "string",
             "name": "duration",
             "in": "query"
           }
@@ -593,7 +599,7 @@ func init() {
           }
         ],
         "tags": [
-          "metrics"
+          "metadata"
         ],
         "operationId": "getShareMetrics",
         "parameters": [
@@ -604,7 +610,7 @@ func init() {
             "required": true
           },
           {
-            "type": "number",
+            "type": "string",
             "name": "duration",
             "in": "query"
           }
@@ -1938,12 +1944,12 @@ func init() {
           }
         ],
         "tags": [
-          "metrics"
+          "metadata"
         ],
         "operationId": "getAccountMetrics",
         "parameters": [
           {
-            "type": "number",
+            "type": "string",
             "name": "duration",
             "in": "query"
           }
@@ -1954,6 +1960,12 @@ func init() {
             "schema": {
               "$ref": "#/definitions/metrics"
             }
+          },
+          "400": {
+            "description": "bad request"
+          },
+          "500": {
+            "description": "internal server error"
           }
         }
       }
@@ -1966,7 +1978,7 @@ func init() {
           }
         ],
         "tags": [
-          "metrics"
+          "metadata"
         ],
         "operationId": "getEnvironmentMetrics",
         "parameters": [
@@ -1977,7 +1989,7 @@ func init() {
             "required": true
           },
           {
-            "type": "number",
+            "type": "string",
             "name": "duration",
             "in": "query"
           }
@@ -2003,7 +2015,7 @@ func init() {
           }
         ],
         "tags": [
-          "metrics"
+          "metadata"
         ],
         "operationId": "getShareMetrics",
         "parameters": [
@@ -2014,7 +2026,7 @@ func init() {
             "required": true
           },
           {
-            "type": "number",
+            "type": "string",
             "name": "duration",
             "in": "query"
           }
diff --git a/rest_server_zrok/operations/metadata/get_account_metrics.go b/rest_server_zrok/operations/metadata/get_account_metrics.go
new file mode 100644
index 00000000..bc438177
--- /dev/null
+++ b/rest_server_zrok/operations/metadata/get_account_metrics.go
@@ -0,0 +1,71 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metadata
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the generate command
+
+import (
+	"net/http"
+
+	"github.com/go-openapi/runtime/middleware"
+
+	"github.com/openziti/zrok/rest_model_zrok"
+)
+
+// GetAccountMetricsHandlerFunc turns a function with the right signature into a get account metrics handler
+type GetAccountMetricsHandlerFunc func(GetAccountMetricsParams, *rest_model_zrok.Principal) middleware.Responder
+
+// Handle executing the request and returning a response
+func (fn GetAccountMetricsHandlerFunc) Handle(params GetAccountMetricsParams, principal *rest_model_zrok.Principal) middleware.Responder {
+	return fn(params, principal)
+}
+
+// GetAccountMetricsHandler interface for that can handle valid get account metrics params
+type GetAccountMetricsHandler interface {
+	Handle(GetAccountMetricsParams, *rest_model_zrok.Principal) middleware.Responder
+}
+
+// NewGetAccountMetrics creates a new http.Handler for the get account metrics operation
+func NewGetAccountMetrics(ctx *middleware.Context, handler GetAccountMetricsHandler) *GetAccountMetrics {
+	return &GetAccountMetrics{Context: ctx, Handler: handler}
+}
+
+/*
+	GetAccountMetrics swagger:route GET /metrics/account metadata getAccountMetrics
+
+GetAccountMetrics get account metrics API
+*/
+type GetAccountMetrics struct {
+	Context *middleware.Context
+	Handler GetAccountMetricsHandler
+}
+
+func (o *GetAccountMetrics) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
+	route, rCtx, _ := o.Context.RouteInfo(r)
+	if rCtx != nil {
+		*r = *rCtx
+	}
+	var Params = NewGetAccountMetricsParams()
+	uprinc, aCtx, err := o.Context.Authorize(r, route)
+	if err != nil {
+		o.Context.Respond(rw, r, route.Produces, route, err)
+		return
+	}
+	if aCtx != nil {
+		*r = *aCtx
+	}
+	var principal *rest_model_zrok.Principal
+	if uprinc != nil {
+		principal = uprinc.(*rest_model_zrok.Principal) // this is really a rest_model_zrok.Principal, I promise
+	}
+
+	if err := o.Context.BindValidRequest(r, route, &Params); err != nil { // bind params
+		o.Context.Respond(rw, r, route.Produces, route, err)
+		return
+	}
+
+	res := o.Handler.Handle(Params, principal) // actually handle the request
+	o.Context.Respond(rw, r, route.Produces, route, res)
+
+}
diff --git a/rest_server_zrok/operations/metadata/get_account_metrics_parameters.go b/rest_server_zrok/operations/metadata/get_account_metrics_parameters.go
new file mode 100644
index 00000000..705f0397
--- /dev/null
+++ b/rest_server_zrok/operations/metadata/get_account_metrics_parameters.go
@@ -0,0 +1,77 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metadata
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"net/http"
+
+	"github.com/go-openapi/errors"
+	"github.com/go-openapi/runtime"
+	"github.com/go-openapi/runtime/middleware"
+	"github.com/go-openapi/strfmt"
+)
+
+// NewGetAccountMetricsParams creates a new GetAccountMetricsParams object
+//
+// There are no default values defined in the spec.
+func NewGetAccountMetricsParams() GetAccountMetricsParams {
+
+	return GetAccountMetricsParams{}
+}
+
+// GetAccountMetricsParams contains all the bound params for the get account metrics operation
+// typically these are obtained from a http.Request
+//
+// swagger:parameters getAccountMetrics
+type GetAccountMetricsParams struct {
+
+	// HTTP Request Object
+	HTTPRequest *http.Request `json:"-"`
+
+	/*
+	  In: query
+	*/
+	Duration *string
+}
+
+// BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface
+// for simple values it will use straight method calls.
+//
+// To ensure default values, the struct must have been initialized with NewGetAccountMetricsParams() beforehand.
+func (o *GetAccountMetricsParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error {
+	var res []error
+
+	o.HTTPRequest = r
+
+	qs := runtime.Values(r.URL.Query())
+
+	qDuration, qhkDuration, _ := qs.GetOK("duration")
+	if err := o.bindDuration(qDuration, qhkDuration, route.Formats); err != nil {
+		res = append(res, err)
+	}
+	if len(res) > 0 {
+		return errors.CompositeValidationError(res...)
+	}
+	return nil
+}
+
+// bindDuration binds and validates parameter Duration from query.
+func (o *GetAccountMetricsParams) bindDuration(rawData []string, hasKey bool, formats strfmt.Registry) error {
+	var raw string
+	if len(rawData) > 0 {
+		raw = rawData[len(rawData)-1]
+	}
+
+	// Required: false
+	// AllowEmptyValue: false
+
+	if raw == "" { // empty values pass all other validations
+		return nil
+	}
+	o.Duration = &raw
+
+	return nil
+}
diff --git a/rest_server_zrok/operations/metadata/get_account_metrics_responses.go b/rest_server_zrok/operations/metadata/get_account_metrics_responses.go
new file mode 100644
index 00000000..9fb4bd4f
--- /dev/null
+++ b/rest_server_zrok/operations/metadata/get_account_metrics_responses.go
@@ -0,0 +1,109 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metadata
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"net/http"
+
+	"github.com/go-openapi/runtime"
+
+	"github.com/openziti/zrok/rest_model_zrok"
+)
+
+// GetAccountMetricsOKCode is the HTTP code returned for type GetAccountMetricsOK
+const GetAccountMetricsOKCode int = 200
+
+/*
+GetAccountMetricsOK account metrics
+
+swagger:response getAccountMetricsOK
+*/
+type GetAccountMetricsOK struct {
+
+	/*
+	  In: Body
+	*/
+	Payload *rest_model_zrok.Metrics `json:"body,omitempty"`
+}
+
+// NewGetAccountMetricsOK creates GetAccountMetricsOK with default headers values
+func NewGetAccountMetricsOK() *GetAccountMetricsOK {
+
+	return &GetAccountMetricsOK{}
+}
+
+// WithPayload adds the payload to the get account metrics o k response
+func (o *GetAccountMetricsOK) WithPayload(payload *rest_model_zrok.Metrics) *GetAccountMetricsOK {
+	o.Payload = payload
+	return o
+}
+
+// SetPayload sets the payload to the get account metrics o k response
+func (o *GetAccountMetricsOK) SetPayload(payload *rest_model_zrok.Metrics) {
+	o.Payload = payload
+}
+
+// WriteResponse to the client
+func (o *GetAccountMetricsOK) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) {
+
+	rw.WriteHeader(200)
+	if o.Payload != nil {
+		payload := o.Payload
+		if err := producer.Produce(rw, payload); err != nil {
+			panic(err) // let the recovery middleware deal with this
+		}
+	}
+}
+
+// GetAccountMetricsBadRequestCode is the HTTP code returned for type GetAccountMetricsBadRequest
+const GetAccountMetricsBadRequestCode int = 400
+
+/*
+GetAccountMetricsBadRequest bad request
+
+swagger:response getAccountMetricsBadRequest
+*/
+type GetAccountMetricsBadRequest struct {
+}
+
+// NewGetAccountMetricsBadRequest creates GetAccountMetricsBadRequest with default headers values
+func NewGetAccountMetricsBadRequest() *GetAccountMetricsBadRequest {
+
+	return &GetAccountMetricsBadRequest{}
+}
+
+// WriteResponse to the client
+func (o *GetAccountMetricsBadRequest) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) {
+
+	rw.Header().Del(runtime.HeaderContentType) //Remove Content-Type on empty responses
+
+	rw.WriteHeader(400)
+}
+
+// GetAccountMetricsInternalServerErrorCode is the HTTP code returned for type GetAccountMetricsInternalServerError
+const GetAccountMetricsInternalServerErrorCode int = 500
+
+/*
+GetAccountMetricsInternalServerError internal server error
+
+swagger:response getAccountMetricsInternalServerError
+*/
+type GetAccountMetricsInternalServerError struct {
+}
+
+// NewGetAccountMetricsInternalServerError creates GetAccountMetricsInternalServerError with default headers values
+func NewGetAccountMetricsInternalServerError() *GetAccountMetricsInternalServerError {
+
+	return &GetAccountMetricsInternalServerError{}
+}
+
+// WriteResponse to the client
+func (o *GetAccountMetricsInternalServerError) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) {
+
+	rw.Header().Del(runtime.HeaderContentType) //Remove Content-Type on empty responses
+
+	rw.WriteHeader(500)
+}
diff --git a/rest_server_zrok/operations/metadata/get_account_metrics_urlbuilder.go b/rest_server_zrok/operations/metadata/get_account_metrics_urlbuilder.go
new file mode 100644
index 00000000..b6065cf7
--- /dev/null
+++ b/rest_server_zrok/operations/metadata/get_account_metrics_urlbuilder.go
@@ -0,0 +1,103 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metadata
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the generate command
+
+import (
+	"errors"
+	"net/url"
+	golangswaggerpaths "path"
+)
+
+// GetAccountMetricsURL generates an URL for the get account metrics operation
+type GetAccountMetricsURL struct {
+	Duration *string
+
+	_basePath string
+	// avoid unkeyed usage
+	_ struct{}
+}
+
+// WithBasePath sets the base path for this url builder, only required when it's different from the
+// base path specified in the swagger spec.
+// When the value of the base path is an empty string
+func (o *GetAccountMetricsURL) WithBasePath(bp string) *GetAccountMetricsURL {
+	o.SetBasePath(bp)
+	return o
+}
+
+// SetBasePath sets the base path for this url builder, only required when it's different from the
+// base path specified in the swagger spec.
+// When the value of the base path is an empty string
+func (o *GetAccountMetricsURL) SetBasePath(bp string) {
+	o._basePath = bp
+}
+
+// Build a url path and query string
+func (o *GetAccountMetricsURL) Build() (*url.URL, error) {
+	var _result url.URL
+
+	var _path = "/metrics/account"
+
+	_basePath := o._basePath
+	if _basePath == "" {
+		_basePath = "/api/v1"
+	}
+	_result.Path = golangswaggerpaths.Join(_basePath, _path)
+
+	qs := make(url.Values)
+
+	var durationQ string
+	if o.Duration != nil {
+		durationQ = *o.Duration
+	}
+	if durationQ != "" {
+		qs.Set("duration", durationQ)
+	}
+
+	_result.RawQuery = qs.Encode()
+
+	return &_result, nil
+}
+
+// Must is a helper function to panic when the url builder returns an error
+func (o *GetAccountMetricsURL) Must(u *url.URL, err error) *url.URL {
+	if err != nil {
+		panic(err)
+	}
+	if u == nil {
+		panic("url can't be nil")
+	}
+	return u
+}
+
+// String returns the string representation of the path with query string
+func (o *GetAccountMetricsURL) String() string {
+	return o.Must(o.Build()).String()
+}
+
+// BuildFull builds a full url with scheme, host, path and query string
+func (o *GetAccountMetricsURL) BuildFull(scheme, host string) (*url.URL, error) {
+	if scheme == "" {
+		return nil, errors.New("scheme is required for a full url on GetAccountMetricsURL")
+	}
+	if host == "" {
+		return nil, errors.New("host is required for a full url on GetAccountMetricsURL")
+	}
+
+	base, err := o.Build()
+	if err != nil {
+		return nil, err
+	}
+
+	base.Scheme = scheme
+	base.Host = host
+	return base, nil
+}
+
+// StringFull returns the string representation of a complete url
+func (o *GetAccountMetricsURL) StringFull(scheme, host string) string {
+	return o.Must(o.BuildFull(scheme, host)).String()
+}
diff --git a/rest_server_zrok/operations/metadata/get_environment_metrics.go b/rest_server_zrok/operations/metadata/get_environment_metrics.go
new file mode 100644
index 00000000..244b28e1
--- /dev/null
+++ b/rest_server_zrok/operations/metadata/get_environment_metrics.go
@@ -0,0 +1,71 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metadata
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the generate command
+
+import (
+	"net/http"
+
+	"github.com/go-openapi/runtime/middleware"
+
+	"github.com/openziti/zrok/rest_model_zrok"
+)
+
+// GetEnvironmentMetricsHandlerFunc turns a function with the right signature into a get environment metrics handler
+type GetEnvironmentMetricsHandlerFunc func(GetEnvironmentMetricsParams, *rest_model_zrok.Principal) middleware.Responder
+
+// Handle executing the request and returning a response
+func (fn GetEnvironmentMetricsHandlerFunc) Handle(params GetEnvironmentMetricsParams, principal *rest_model_zrok.Principal) middleware.Responder {
+	return fn(params, principal)
+}
+
+// GetEnvironmentMetricsHandler interface for that can handle valid get environment metrics params
+type GetEnvironmentMetricsHandler interface {
+	Handle(GetEnvironmentMetricsParams, *rest_model_zrok.Principal) middleware.Responder
+}
+
+// NewGetEnvironmentMetrics creates a new http.Handler for the get environment metrics operation
+func NewGetEnvironmentMetrics(ctx *middleware.Context, handler GetEnvironmentMetricsHandler) *GetEnvironmentMetrics {
+	return &GetEnvironmentMetrics{Context: ctx, Handler: handler}
+}
+
+/*
+	GetEnvironmentMetrics swagger:route GET /metrics/environment/{envId} metadata getEnvironmentMetrics
+
+GetEnvironmentMetrics get environment metrics API
+*/
+type GetEnvironmentMetrics struct {
+	Context *middleware.Context
+	Handler GetEnvironmentMetricsHandler
+}
+
+func (o *GetEnvironmentMetrics) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
+	route, rCtx, _ := o.Context.RouteInfo(r)
+	if rCtx != nil {
+		*r = *rCtx
+	}
+	var Params = NewGetEnvironmentMetricsParams()
+	uprinc, aCtx, err := o.Context.Authorize(r, route)
+	if err != nil {
+		o.Context.Respond(rw, r, route.Produces, route, err)
+		return
+	}
+	if aCtx != nil {
+		*r = *aCtx
+	}
+	var principal *rest_model_zrok.Principal
+	if uprinc != nil {
+		principal = uprinc.(*rest_model_zrok.Principal) // this is really a rest_model_zrok.Principal, I promise
+	}
+
+	if err := o.Context.BindValidRequest(r, route, &Params); err != nil { // bind params
+		o.Context.Respond(rw, r, route.Produces, route, err)
+		return
+	}
+
+	res := o.Handler.Handle(Params, principal) // actually handle the request
+	o.Context.Respond(rw, r, route.Produces, route, res)
+
+}
diff --git a/rest_server_zrok/operations/metadata/get_environment_metrics_parameters.go b/rest_server_zrok/operations/metadata/get_environment_metrics_parameters.go
new file mode 100644
index 00000000..6ea94376
--- /dev/null
+++ b/rest_server_zrok/operations/metadata/get_environment_metrics_parameters.go
@@ -0,0 +1,101 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metadata
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"net/http"
+
+	"github.com/go-openapi/errors"
+	"github.com/go-openapi/runtime"
+	"github.com/go-openapi/runtime/middleware"
+	"github.com/go-openapi/strfmt"
+)
+
+// NewGetEnvironmentMetricsParams creates a new GetEnvironmentMetricsParams object
+//
+// There are no default values defined in the spec.
+func NewGetEnvironmentMetricsParams() GetEnvironmentMetricsParams {
+
+	return GetEnvironmentMetricsParams{}
+}
+
+// GetEnvironmentMetricsParams contains all the bound params for the get environment metrics operation
+// typically these are obtained from a http.Request
+//
+// swagger:parameters getEnvironmentMetrics
+type GetEnvironmentMetricsParams struct {
+
+	// HTTP Request Object
+	HTTPRequest *http.Request `json:"-"`
+
+	/*
+	  In: query
+	*/
+	Duration *string
+	/*
+	  Required: true
+	  In: path
+	*/
+	EnvID string
+}
+
+// BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface
+// for simple values it will use straight method calls.
+//
+// To ensure default values, the struct must have been initialized with NewGetEnvironmentMetricsParams() beforehand.
+func (o *GetEnvironmentMetricsParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error {
+	var res []error
+
+	o.HTTPRequest = r
+
+	qs := runtime.Values(r.URL.Query())
+
+	qDuration, qhkDuration, _ := qs.GetOK("duration")
+	if err := o.bindDuration(qDuration, qhkDuration, route.Formats); err != nil {
+		res = append(res, err)
+	}
+
+	rEnvID, rhkEnvID, _ := route.Params.GetOK("envId")
+	if err := o.bindEnvID(rEnvID, rhkEnvID, route.Formats); err != nil {
+		res = append(res, err)
+	}
+	if len(res) > 0 {
+		return errors.CompositeValidationError(res...)
+	}
+	return nil
+}
+
+// bindDuration binds and validates parameter Duration from query.
+func (o *GetEnvironmentMetricsParams) bindDuration(rawData []string, hasKey bool, formats strfmt.Registry) error {
+	var raw string
+	if len(rawData) > 0 {
+		raw = rawData[len(rawData)-1]
+	}
+
+	// Required: false
+	// AllowEmptyValue: false
+
+	if raw == "" { // empty values pass all other validations
+		return nil
+	}
+	o.Duration = &raw
+
+	return nil
+}
+
+// bindEnvID binds and validates parameter EnvID from path.
+func (o *GetEnvironmentMetricsParams) bindEnvID(rawData []string, hasKey bool, formats strfmt.Registry) error {
+	var raw string
+	if len(rawData) > 0 {
+		raw = rawData[len(rawData)-1]
+	}
+
+	// Required: true
+	// Parameter is provided by construction from the route
+	o.EnvID = raw
+
+	return nil
+}
diff --git a/rest_server_zrok/operations/metadata/get_environment_metrics_responses.go b/rest_server_zrok/operations/metadata/get_environment_metrics_responses.go
new file mode 100644
index 00000000..290fc938
--- /dev/null
+++ b/rest_server_zrok/operations/metadata/get_environment_metrics_responses.go
@@ -0,0 +1,84 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metadata
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"net/http"
+
+	"github.com/go-openapi/runtime"
+
+	"github.com/openziti/zrok/rest_model_zrok"
+)
+
+// GetEnvironmentMetricsOKCode is the HTTP code returned for type GetEnvironmentMetricsOK
+const GetEnvironmentMetricsOKCode int = 200
+
+/*
+GetEnvironmentMetricsOK environment metrics
+
+swagger:response getEnvironmentMetricsOK
+*/
+type GetEnvironmentMetricsOK struct {
+
+	/*
+	  In: Body
+	*/
+	Payload *rest_model_zrok.Metrics `json:"body,omitempty"`
+}
+
+// NewGetEnvironmentMetricsOK creates GetEnvironmentMetricsOK with default headers values
+func NewGetEnvironmentMetricsOK() *GetEnvironmentMetricsOK {
+
+	return &GetEnvironmentMetricsOK{}
+}
+
+// WithPayload adds the payload to the get environment metrics o k response
+func (o *GetEnvironmentMetricsOK) WithPayload(payload *rest_model_zrok.Metrics) *GetEnvironmentMetricsOK {
+	o.Payload = payload
+	return o
+}
+
+// SetPayload sets the payload to the get environment metrics o k response
+func (o *GetEnvironmentMetricsOK) SetPayload(payload *rest_model_zrok.Metrics) {
+	o.Payload = payload
+}
+
+// WriteResponse to the client
+func (o *GetEnvironmentMetricsOK) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) {
+
+	rw.WriteHeader(200)
+	if o.Payload != nil {
+		payload := o.Payload
+		if err := producer.Produce(rw, payload); err != nil {
+			panic(err) // let the recovery middleware deal with this
+		}
+	}
+}
+
+// GetEnvironmentMetricsUnauthorizedCode is the HTTP code returned for type GetEnvironmentMetricsUnauthorized
+const GetEnvironmentMetricsUnauthorizedCode int = 401
+
+/*
+GetEnvironmentMetricsUnauthorized unauthorized
+
+swagger:response getEnvironmentMetricsUnauthorized
+*/
+type GetEnvironmentMetricsUnauthorized struct {
+}
+
+// NewGetEnvironmentMetricsUnauthorized creates GetEnvironmentMetricsUnauthorized with default headers values
+func NewGetEnvironmentMetricsUnauthorized() *GetEnvironmentMetricsUnauthorized {
+
+	return &GetEnvironmentMetricsUnauthorized{}
+}
+
+// WriteResponse to the client
+func (o *GetEnvironmentMetricsUnauthorized) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) {
+
+	rw.Header().Del(runtime.HeaderContentType) //Remove Content-Type on empty responses
+
+	rw.WriteHeader(401)
+}
diff --git a/rest_server_zrok/operations/metadata/get_environment_metrics_urlbuilder.go b/rest_server_zrok/operations/metadata/get_environment_metrics_urlbuilder.go
new file mode 100644
index 00000000..a7787fa2
--- /dev/null
+++ b/rest_server_zrok/operations/metadata/get_environment_metrics_urlbuilder.go
@@ -0,0 +1,113 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metadata
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the generate command
+
+import (
+	"errors"
+	"net/url"
+	golangswaggerpaths "path"
+	"strings"
+)
+
+// GetEnvironmentMetricsURL generates an URL for the get environment metrics operation
+type GetEnvironmentMetricsURL struct {
+	EnvID string
+
+	Duration *string
+
+	_basePath string
+	// avoid unkeyed usage
+	_ struct{}
+}
+
+// WithBasePath sets the base path for this url builder, only required when it's different from the
+// base path specified in the swagger spec.
+// When the value of the base path is an empty string
+func (o *GetEnvironmentMetricsURL) WithBasePath(bp string) *GetEnvironmentMetricsURL {
+	o.SetBasePath(bp)
+	return o
+}
+
+// SetBasePath sets the base path for this url builder, only required when it's different from the
+// base path specified in the swagger spec.
+// When the value of the base path is an empty string
+func (o *GetEnvironmentMetricsURL) SetBasePath(bp string) {
+	o._basePath = bp
+}
+
+// Build a url path and query string
+func (o *GetEnvironmentMetricsURL) Build() (*url.URL, error) {
+	var _result url.URL
+
+	var _path = "/metrics/environment/{envId}"
+
+	envID := o.EnvID
+	if envID != "" {
+		_path = strings.Replace(_path, "{envId}", envID, -1)
+	} else {
+		return nil, errors.New("envId is required on GetEnvironmentMetricsURL")
+	}
+
+	_basePath := o._basePath
+	if _basePath == "" {
+		_basePath = "/api/v1"
+	}
+	_result.Path = golangswaggerpaths.Join(_basePath, _path)
+
+	qs := make(url.Values)
+
+	var durationQ string
+	if o.Duration != nil {
+		durationQ = *o.Duration
+	}
+	if durationQ != "" {
+		qs.Set("duration", durationQ)
+	}
+
+	_result.RawQuery = qs.Encode()
+
+	return &_result, nil
+}
+
+// Must is a helper function to panic when the url builder returns an error
+func (o *GetEnvironmentMetricsURL) Must(u *url.URL, err error) *url.URL {
+	if err != nil {
+		panic(err)
+	}
+	if u == nil {
+		panic("url can't be nil")
+	}
+	return u
+}
+
+// String returns the string representation of the path with query string
+func (o *GetEnvironmentMetricsURL) String() string {
+	return o.Must(o.Build()).String()
+}
+
+// BuildFull builds a full url with scheme, host, path and query string
+func (o *GetEnvironmentMetricsURL) BuildFull(scheme, host string) (*url.URL, error) {
+	if scheme == "" {
+		return nil, errors.New("scheme is required for a full url on GetEnvironmentMetricsURL")
+	}
+	if host == "" {
+		return nil, errors.New("host is required for a full url on GetEnvironmentMetricsURL")
+	}
+
+	base, err := o.Build()
+	if err != nil {
+		return nil, err
+	}
+
+	base.Scheme = scheme
+	base.Host = host
+	return base, nil
+}
+
+// StringFull returns the string representation of a complete url
+func (o *GetEnvironmentMetricsURL) StringFull(scheme, host string) string {
+	return o.Must(o.BuildFull(scheme, host)).String()
+}
diff --git a/rest_server_zrok/operations/metadata/get_share_metrics.go b/rest_server_zrok/operations/metadata/get_share_metrics.go
new file mode 100644
index 00000000..db0b2055
--- /dev/null
+++ b/rest_server_zrok/operations/metadata/get_share_metrics.go
@@ -0,0 +1,71 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metadata
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the generate command
+
+import (
+	"net/http"
+
+	"github.com/go-openapi/runtime/middleware"
+
+	"github.com/openziti/zrok/rest_model_zrok"
+)
+
+// GetShareMetricsHandlerFunc turns a function with the right signature into a get share metrics handler
+type GetShareMetricsHandlerFunc func(GetShareMetricsParams, *rest_model_zrok.Principal) middleware.Responder
+
+// Handle executing the request and returning a response
+func (fn GetShareMetricsHandlerFunc) Handle(params GetShareMetricsParams, principal *rest_model_zrok.Principal) middleware.Responder {
+	return fn(params, principal)
+}
+
+// GetShareMetricsHandler interface for that can handle valid get share metrics params
+type GetShareMetricsHandler interface {
+	Handle(GetShareMetricsParams, *rest_model_zrok.Principal) middleware.Responder
+}
+
+// NewGetShareMetrics creates a new http.Handler for the get share metrics operation
+func NewGetShareMetrics(ctx *middleware.Context, handler GetShareMetricsHandler) *GetShareMetrics {
+	return &GetShareMetrics{Context: ctx, Handler: handler}
+}
+
+/*
+	GetShareMetrics swagger:route GET /metrics/share/{shrToken} metadata getShareMetrics
+
+GetShareMetrics get share metrics API
+*/
+type GetShareMetrics struct {
+	Context *middleware.Context
+	Handler GetShareMetricsHandler
+}
+
+func (o *GetShareMetrics) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
+	route, rCtx, _ := o.Context.RouteInfo(r)
+	if rCtx != nil {
+		*r = *rCtx
+	}
+	var Params = NewGetShareMetricsParams()
+	uprinc, aCtx, err := o.Context.Authorize(r, route)
+	if err != nil {
+		o.Context.Respond(rw, r, route.Produces, route, err)
+		return
+	}
+	if aCtx != nil {
+		*r = *aCtx
+	}
+	var principal *rest_model_zrok.Principal
+	if uprinc != nil {
+		principal = uprinc.(*rest_model_zrok.Principal) // this is really a rest_model_zrok.Principal, I promise
+	}
+
+	if err := o.Context.BindValidRequest(r, route, &Params); err != nil { // bind params
+		o.Context.Respond(rw, r, route.Produces, route, err)
+		return
+	}
+
+	res := o.Handler.Handle(Params, principal) // actually handle the request
+	o.Context.Respond(rw, r, route.Produces, route, res)
+
+}
diff --git a/rest_server_zrok/operations/metadata/get_share_metrics_parameters.go b/rest_server_zrok/operations/metadata/get_share_metrics_parameters.go
new file mode 100644
index 00000000..f945c949
--- /dev/null
+++ b/rest_server_zrok/operations/metadata/get_share_metrics_parameters.go
@@ -0,0 +1,101 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metadata
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"net/http"
+
+	"github.com/go-openapi/errors"
+	"github.com/go-openapi/runtime"
+	"github.com/go-openapi/runtime/middleware"
+	"github.com/go-openapi/strfmt"
+)
+
+// NewGetShareMetricsParams creates a new GetShareMetricsParams object
+//
+// There are no default values defined in the spec.
+func NewGetShareMetricsParams() GetShareMetricsParams {
+
+	return GetShareMetricsParams{}
+}
+
+// GetShareMetricsParams contains all the bound params for the get share metrics operation
+// typically these are obtained from a http.Request
+//
+// swagger:parameters getShareMetrics
+type GetShareMetricsParams struct {
+
+	// HTTP Request Object
+	HTTPRequest *http.Request `json:"-"`
+
+	/*
+	  In: query
+	*/
+	Duration *string
+	/*
+	  Required: true
+	  In: path
+	*/
+	ShrToken string
+}
+
+// BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface
+// for simple values it will use straight method calls.
+//
+// To ensure default values, the struct must have been initialized with NewGetShareMetricsParams() beforehand.
+func (o *GetShareMetricsParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error {
+	var res []error
+
+	o.HTTPRequest = r
+
+	qs := runtime.Values(r.URL.Query())
+
+	qDuration, qhkDuration, _ := qs.GetOK("duration")
+	if err := o.bindDuration(qDuration, qhkDuration, route.Formats); err != nil {
+		res = append(res, err)
+	}
+
+	rShrToken, rhkShrToken, _ := route.Params.GetOK("shrToken")
+	if err := o.bindShrToken(rShrToken, rhkShrToken, route.Formats); err != nil {
+		res = append(res, err)
+	}
+	if len(res) > 0 {
+		return errors.CompositeValidationError(res...)
+	}
+	return nil
+}
+
+// bindDuration binds and validates parameter Duration from query.
+func (o *GetShareMetricsParams) bindDuration(rawData []string, hasKey bool, formats strfmt.Registry) error {
+	var raw string
+	if len(rawData) > 0 {
+		raw = rawData[len(rawData)-1]
+	}
+
+	// Required: false
+	// AllowEmptyValue: false
+
+	if raw == "" { // empty values pass all other validations
+		return nil
+	}
+	o.Duration = &raw
+
+	return nil
+}
+
+// bindShrToken binds and validates parameter ShrToken from path.
+func (o *GetShareMetricsParams) bindShrToken(rawData []string, hasKey bool, formats strfmt.Registry) error {
+	var raw string
+	if len(rawData) > 0 {
+		raw = rawData[len(rawData)-1]
+	}
+
+	// Required: true
+	// Parameter is provided by construction from the route
+	o.ShrToken = raw
+
+	return nil
+}
diff --git a/rest_server_zrok/operations/metadata/get_share_metrics_responses.go b/rest_server_zrok/operations/metadata/get_share_metrics_responses.go
new file mode 100644
index 00000000..9f08dc90
--- /dev/null
+++ b/rest_server_zrok/operations/metadata/get_share_metrics_responses.go
@@ -0,0 +1,84 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metadata
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"net/http"
+
+	"github.com/go-openapi/runtime"
+
+	"github.com/openziti/zrok/rest_model_zrok"
+)
+
+// GetShareMetricsOKCode is the HTTP code returned for type GetShareMetricsOK
+const GetShareMetricsOKCode int = 200
+
+/*
+GetShareMetricsOK share metrics
+
+swagger:response getShareMetricsOK
+*/
+type GetShareMetricsOK struct {
+
+	/*
+	  In: Body
+	*/
+	Payload *rest_model_zrok.Metrics `json:"body,omitempty"`
+}
+
+// NewGetShareMetricsOK creates GetShareMetricsOK with default headers values
+func NewGetShareMetricsOK() *GetShareMetricsOK {
+
+	return &GetShareMetricsOK{}
+}
+
+// WithPayload adds the payload to the get share metrics o k response
+func (o *GetShareMetricsOK) WithPayload(payload *rest_model_zrok.Metrics) *GetShareMetricsOK {
+	o.Payload = payload
+	return o
+}
+
+// SetPayload sets the payload to the get share metrics o k response
+func (o *GetShareMetricsOK) SetPayload(payload *rest_model_zrok.Metrics) {
+	o.Payload = payload
+}
+
+// WriteResponse to the client
+func (o *GetShareMetricsOK) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) {
+
+	rw.WriteHeader(200)
+	if o.Payload != nil {
+		payload := o.Payload
+		if err := producer.Produce(rw, payload); err != nil {
+			panic(err) // let the recovery middleware deal with this
+		}
+	}
+}
+
+// GetShareMetricsUnauthorizedCode is the HTTP code returned for type GetShareMetricsUnauthorized
+const GetShareMetricsUnauthorizedCode int = 401
+
+/*
+GetShareMetricsUnauthorized unauthorized
+
+swagger:response getShareMetricsUnauthorized
+*/
+type GetShareMetricsUnauthorized struct {
+}
+
+// NewGetShareMetricsUnauthorized creates GetShareMetricsUnauthorized with default headers values
+func NewGetShareMetricsUnauthorized() *GetShareMetricsUnauthorized {
+
+	return &GetShareMetricsUnauthorized{}
+}
+
+// WriteResponse to the client
+func (o *GetShareMetricsUnauthorized) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) {
+
+	rw.Header().Del(runtime.HeaderContentType) //Remove Content-Type on empty responses
+
+	rw.WriteHeader(401)
+}
diff --git a/rest_server_zrok/operations/metadata/get_share_metrics_urlbuilder.go b/rest_server_zrok/operations/metadata/get_share_metrics_urlbuilder.go
new file mode 100644
index 00000000..74258138
--- /dev/null
+++ b/rest_server_zrok/operations/metadata/get_share_metrics_urlbuilder.go
@@ -0,0 +1,113 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metadata
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the generate command
+
+import (
+	"errors"
+	"net/url"
+	golangswaggerpaths "path"
+	"strings"
+)
+
+// GetShareMetricsURL generates an URL for the get share metrics operation
+type GetShareMetricsURL struct {
+	ShrToken string
+
+	Duration *string
+
+	_basePath string
+	// avoid unkeyed usage
+	_ struct{}
+}
+
+// WithBasePath sets the base path for this url builder, only required when it's different from the
+// base path specified in the swagger spec.
+// When the value of the base path is an empty string
+func (o *GetShareMetricsURL) WithBasePath(bp string) *GetShareMetricsURL {
+	o.SetBasePath(bp)
+	return o
+}
+
+// SetBasePath sets the base path for this url builder, only required when it's different from the
+// base path specified in the swagger spec.
+// When the value of the base path is an empty string
+func (o *GetShareMetricsURL) SetBasePath(bp string) {
+	o._basePath = bp
+}
+
+// Build a url path and query string
+func (o *GetShareMetricsURL) Build() (*url.URL, error) {
+	var _result url.URL
+
+	var _path = "/metrics/share/{shrToken}"
+
+	shrToken := o.ShrToken
+	if shrToken != "" {
+		_path = strings.Replace(_path, "{shrToken}", shrToken, -1)
+	} else {
+		return nil, errors.New("shrToken is required on GetShareMetricsURL")
+	}
+
+	_basePath := o._basePath
+	if _basePath == "" {
+		_basePath = "/api/v1"
+	}
+	_result.Path = golangswaggerpaths.Join(_basePath, _path)
+
+	qs := make(url.Values)
+
+	var durationQ string
+	if o.Duration != nil {
+		durationQ = *o.Duration
+	}
+	if durationQ != "" {
+		qs.Set("duration", durationQ)
+	}
+
+	_result.RawQuery = qs.Encode()
+
+	return &_result, nil
+}
+
+// Must is a helper function to panic when the url builder returns an error
+func (o *GetShareMetricsURL) Must(u *url.URL, err error) *url.URL {
+	if err != nil {
+		panic(err)
+	}
+	if u == nil {
+		panic("url can't be nil")
+	}
+	return u
+}
+
+// String returns the string representation of the path with query string
+func (o *GetShareMetricsURL) String() string {
+	return o.Must(o.Build()).String()
+}
+
+// BuildFull builds a full url with scheme, host, path and query string
+func (o *GetShareMetricsURL) BuildFull(scheme, host string) (*url.URL, error) {
+	if scheme == "" {
+		return nil, errors.New("scheme is required for a full url on GetShareMetricsURL")
+	}
+	if host == "" {
+		return nil, errors.New("host is required for a full url on GetShareMetricsURL")
+	}
+
+	base, err := o.Build()
+	if err != nil {
+		return nil, err
+	}
+
+	base.Scheme = scheme
+	base.Host = host
+	return base, nil
+}
+
+// StringFull returns the string representation of a complete url
+func (o *GetShareMetricsURL) StringFull(scheme, host string) string {
+	return o.Must(o.BuildFull(scheme, host)).String()
+}
diff --git a/rest_server_zrok/operations/zrok_api.go b/rest_server_zrok/operations/zrok_api.go
index 19a12af8..61b9bad1 100644
--- a/rest_server_zrok/operations/zrok_api.go
+++ b/rest_server_zrok/operations/zrok_api.go
@@ -24,7 +24,6 @@ import (
 	"github.com/openziti/zrok/rest_server_zrok/operations/admin"
 	"github.com/openziti/zrok/rest_server_zrok/operations/environment"
 	"github.com/openziti/zrok/rest_server_zrok/operations/metadata"
-	"github.com/openziti/zrok/rest_server_zrok/operations/metrics"
 	"github.com/openziti/zrok/rest_server_zrok/operations/share"
 )
 
@@ -71,20 +70,20 @@ func NewZrokAPI(spec *loads.Document) *ZrokAPI {
 		EnvironmentEnableHandler: environment.EnableHandlerFunc(func(params environment.EnableParams, principal *rest_model_zrok.Principal) middleware.Responder {
 			return middleware.NotImplemented("operation environment.Enable has not yet been implemented")
 		}),
-		MetricsGetAccountMetricsHandler: metrics.GetAccountMetricsHandlerFunc(func(params metrics.GetAccountMetricsParams, principal *rest_model_zrok.Principal) middleware.Responder {
-			return middleware.NotImplemented("operation metrics.GetAccountMetrics has not yet been implemented")
+		MetadataGetAccountMetricsHandler: metadata.GetAccountMetricsHandlerFunc(func(params metadata.GetAccountMetricsParams, principal *rest_model_zrok.Principal) middleware.Responder {
+			return middleware.NotImplemented("operation metadata.GetAccountMetrics has not yet been implemented")
 		}),
 		MetadataGetEnvironmentDetailHandler: metadata.GetEnvironmentDetailHandlerFunc(func(params metadata.GetEnvironmentDetailParams, principal *rest_model_zrok.Principal) middleware.Responder {
 			return middleware.NotImplemented("operation metadata.GetEnvironmentDetail has not yet been implemented")
 		}),
-		MetricsGetEnvironmentMetricsHandler: metrics.GetEnvironmentMetricsHandlerFunc(func(params metrics.GetEnvironmentMetricsParams, principal *rest_model_zrok.Principal) middleware.Responder {
-			return middleware.NotImplemented("operation metrics.GetEnvironmentMetrics has not yet been implemented")
+		MetadataGetEnvironmentMetricsHandler: metadata.GetEnvironmentMetricsHandlerFunc(func(params metadata.GetEnvironmentMetricsParams, principal *rest_model_zrok.Principal) middleware.Responder {
+			return middleware.NotImplemented("operation metadata.GetEnvironmentMetrics has not yet been implemented")
 		}),
 		MetadataGetShareDetailHandler: metadata.GetShareDetailHandlerFunc(func(params metadata.GetShareDetailParams, principal *rest_model_zrok.Principal) middleware.Responder {
 			return middleware.NotImplemented("operation metadata.GetShareDetail has not yet been implemented")
 		}),
-		MetricsGetShareMetricsHandler: metrics.GetShareMetricsHandlerFunc(func(params metrics.GetShareMetricsParams, principal *rest_model_zrok.Principal) middleware.Responder {
-			return middleware.NotImplemented("operation metrics.GetShareMetrics has not yet been implemented")
+		MetadataGetShareMetricsHandler: metadata.GetShareMetricsHandlerFunc(func(params metadata.GetShareMetricsParams, principal *rest_model_zrok.Principal) middleware.Responder {
+			return middleware.NotImplemented("operation metadata.GetShareMetrics has not yet been implemented")
 		}),
 		AccountInviteHandler: account.InviteHandlerFunc(func(params account.InviteParams) middleware.Responder {
 			return middleware.NotImplemented("operation account.Invite has not yet been implemented")
@@ -195,16 +194,16 @@ type ZrokAPI struct {
 	EnvironmentDisableHandler environment.DisableHandler
 	// EnvironmentEnableHandler sets the operation handler for the enable operation
 	EnvironmentEnableHandler environment.EnableHandler
-	// MetricsGetAccountMetricsHandler sets the operation handler for the get account metrics operation
-	MetricsGetAccountMetricsHandler metrics.GetAccountMetricsHandler
+	// MetadataGetAccountMetricsHandler sets the operation handler for the get account metrics operation
+	MetadataGetAccountMetricsHandler metadata.GetAccountMetricsHandler
 	// MetadataGetEnvironmentDetailHandler sets the operation handler for the get environment detail operation
 	MetadataGetEnvironmentDetailHandler metadata.GetEnvironmentDetailHandler
-	// MetricsGetEnvironmentMetricsHandler sets the operation handler for the get environment metrics operation
-	MetricsGetEnvironmentMetricsHandler metrics.GetEnvironmentMetricsHandler
+	// MetadataGetEnvironmentMetricsHandler sets the operation handler for the get environment metrics operation
+	MetadataGetEnvironmentMetricsHandler metadata.GetEnvironmentMetricsHandler
 	// MetadataGetShareDetailHandler sets the operation handler for the get share detail operation
 	MetadataGetShareDetailHandler metadata.GetShareDetailHandler
-	// MetricsGetShareMetricsHandler sets the operation handler for the get share metrics operation
-	MetricsGetShareMetricsHandler metrics.GetShareMetricsHandler
+	// MetadataGetShareMetricsHandler sets the operation handler for the get share metrics operation
+	MetadataGetShareMetricsHandler metadata.GetShareMetricsHandler
 	// AccountInviteHandler sets the operation handler for the invite operation
 	AccountInviteHandler account.InviteHandler
 	// AdminInviteTokenGenerateHandler sets the operation handler for the invite token generate operation
@@ -337,20 +336,20 @@ func (o *ZrokAPI) Validate() error {
 	if o.EnvironmentEnableHandler == nil {
 		unregistered = append(unregistered, "environment.EnableHandler")
 	}
-	if o.MetricsGetAccountMetricsHandler == nil {
-		unregistered = append(unregistered, "metrics.GetAccountMetricsHandler")
+	if o.MetadataGetAccountMetricsHandler == nil {
+		unregistered = append(unregistered, "metadata.GetAccountMetricsHandler")
 	}
 	if o.MetadataGetEnvironmentDetailHandler == nil {
 		unregistered = append(unregistered, "metadata.GetEnvironmentDetailHandler")
 	}
-	if o.MetricsGetEnvironmentMetricsHandler == nil {
-		unregistered = append(unregistered, "metrics.GetEnvironmentMetricsHandler")
+	if o.MetadataGetEnvironmentMetricsHandler == nil {
+		unregistered = append(unregistered, "metadata.GetEnvironmentMetricsHandler")
 	}
 	if o.MetadataGetShareDetailHandler == nil {
 		unregistered = append(unregistered, "metadata.GetShareDetailHandler")
 	}
-	if o.MetricsGetShareMetricsHandler == nil {
-		unregistered = append(unregistered, "metrics.GetShareMetricsHandler")
+	if o.MetadataGetShareMetricsHandler == nil {
+		unregistered = append(unregistered, "metadata.GetShareMetricsHandler")
 	}
 	if o.AccountInviteHandler == nil {
 		unregistered = append(unregistered, "account.InviteHandler")
@@ -527,7 +526,7 @@ func (o *ZrokAPI) initHandlerCache() {
 	if o.handlers["GET"] == nil {
 		o.handlers["GET"] = make(map[string]http.Handler)
 	}
-	o.handlers["GET"]["/metrics/account"] = metrics.NewGetAccountMetrics(o.context, o.MetricsGetAccountMetricsHandler)
+	o.handlers["GET"]["/metrics/account"] = metadata.NewGetAccountMetrics(o.context, o.MetadataGetAccountMetricsHandler)
 	if o.handlers["GET"] == nil {
 		o.handlers["GET"] = make(map[string]http.Handler)
 	}
@@ -535,7 +534,7 @@ func (o *ZrokAPI) initHandlerCache() {
 	if o.handlers["GET"] == nil {
 		o.handlers["GET"] = make(map[string]http.Handler)
 	}
-	o.handlers["GET"]["/metrics/environment/{envId}"] = metrics.NewGetEnvironmentMetrics(o.context, o.MetricsGetEnvironmentMetricsHandler)
+	o.handlers["GET"]["/metrics/environment/{envId}"] = metadata.NewGetEnvironmentMetrics(o.context, o.MetadataGetEnvironmentMetricsHandler)
 	if o.handlers["GET"] == nil {
 		o.handlers["GET"] = make(map[string]http.Handler)
 	}
@@ -543,7 +542,7 @@ func (o *ZrokAPI) initHandlerCache() {
 	if o.handlers["GET"] == nil {
 		o.handlers["GET"] = make(map[string]http.Handler)
 	}
-	o.handlers["GET"]["/metrics/share/{shrToken}"] = metrics.NewGetShareMetrics(o.context, o.MetricsGetShareMetricsHandler)
+	o.handlers["GET"]["/metrics/share/{shrToken}"] = metadata.NewGetShareMetrics(o.context, o.MetadataGetShareMetricsHandler)
 	if o.handlers["POST"] == nil {
 		o.handlers["POST"] = make(map[string]http.Handler)
 	}
diff --git a/specs/zrok.yml b/specs/zrok.yml
index 7c9f97fc..a73ab913 100644
--- a/specs/zrok.yml
+++ b/specs/zrok.yml
@@ -394,41 +394,31 @@ paths:
           schema:
             $ref: "#/definitions/errorMessage"
 
-  /version:
-    get:
-      tags:
-        - metadata
-      operationId: version
-      responses:
-        200:
-          description: current server version
-          schema:
-            $ref: "#/definitions/version"
-
-  #
-  # metrics
-  #
   /metrics/account:
     get:
       tags:
-        - metrics
+        - metadata
       security:
         - key: []
       operationId: getAccountMetrics
       parameters:
         - name: duration
           in: query
-          type: number
+          type: string
       responses:
         200:
           description: account metrics
           schema:
             $ref: "#/definitions/metrics"
+        400:
+          description: bad request
+        500:
+          description: internal server error
 
   /metrics/environment/{envId}:
     get:
       tags:
-        - metrics
+        - metadata
       security:
         - key: []
       operationId: getEnvironmentMetrics
@@ -439,7 +429,7 @@ paths:
           required: true
         - name: duration
           in: query
-          type: number
+          type: string
       responses:
         200:
           description: environment metrics
@@ -451,7 +441,7 @@ paths:
   /metrics/share/{shrToken}:
     get:
       tags:
-        - metrics
+        - metadata
       security:
         - key: []
       operationId: getShareMetrics
@@ -462,7 +452,7 @@ paths:
           required: true
         - name: duration
           in: query
-          type: number
+          type: string
       responses:
         200:
           description: share metrics
@@ -470,6 +460,18 @@ paths:
             $ref: "#/definitions/metrics"
         401:
           description: unauthorized
+
+  /version:
+    get:
+      tags:
+        - metadata
+      operationId: version
+      responses:
+        200:
+          description: current server version
+          schema:
+            $ref: "#/definitions/version"
+
   #
   # share
   #
diff --git a/ui/src/api/metadata.js b/ui/src/api/metadata.js
index 16b2d86e..7b8af17a 100644
--- a/ui/src/api/metadata.js
+++ b/ui/src/api/metadata.js
@@ -40,6 +40,59 @@ export function overview() {
   return gateway.request(overviewOperation)
 }
 
+/**
+ * @param {object} options Optional options
+ * @param {string} [options.duration] 
+ * @return {Promise<module:types.metrics>} account metrics
+ */
+export function getAccountMetrics(options) {
+  if (!options) options = {}
+  const parameters = {
+    query: {
+      duration: options.duration
+    }
+  }
+  return gateway.request(getAccountMetricsOperation, parameters)
+}
+
+/**
+ * @param {string} envId 
+ * @param {object} options Optional options
+ * @param {string} [options.duration] 
+ * @return {Promise<module:types.metrics>} environment metrics
+ */
+export function getEnvironmentMetrics(envId, options) {
+  if (!options) options = {}
+  const parameters = {
+    path: {
+      envId
+    },
+    query: {
+      duration: options.duration
+    }
+  }
+  return gateway.request(getEnvironmentMetricsOperation, parameters)
+}
+
+/**
+ * @param {string} shrToken 
+ * @param {object} options Optional options
+ * @param {string} [options.duration] 
+ * @return {Promise<module:types.metrics>} share metrics
+ */
+export function getShareMetrics(shrToken, options) {
+  if (!options) options = {}
+  const parameters = {
+    path: {
+      shrToken
+    },
+    query: {
+      duration: options.duration
+    }
+  }
+  return gateway.request(getShareMetricsOperation, parameters)
+}
+
 /**
  */
 export function version() {
@@ -81,6 +134,36 @@ const overviewOperation = {
   ]
 }
 
+const getAccountMetricsOperation = {
+  path: '/metrics/account',
+  method: 'get',
+  security: [
+    {
+      id: 'key'
+    }
+  ]
+}
+
+const getEnvironmentMetricsOperation = {
+  path: '/metrics/environment/{envId}',
+  method: 'get',
+  security: [
+    {
+      id: 'key'
+    }
+  ]
+}
+
+const getShareMetricsOperation = {
+  path: '/metrics/share/{shrToken}',
+  method: 'get',
+  security: [
+    {
+      id: 'key'
+    }
+  ]
+}
+
 const versionOperation = {
   path: '/version',
   method: 'get'
diff --git a/ui/src/api/metrics.js b/ui/src/api/metrics.js
deleted file mode 100644
index cc0063de..00000000
--- a/ui/src/api/metrics.js
+++ /dev/null
@@ -1,86 +0,0 @@
-/** @module metrics */
-// Auto-generated, edits will be overwritten
-import * as gateway from './gateway'
-
-/**
- * @param {object} options Optional options
- * @param {number} [options.duration] 
- * @return {Promise<module:types.metrics>} account metrics
- */
-export function getAccountMetrics(options) {
-  if (!options) options = {}
-  const parameters = {
-    query: {
-      duration: options.duration
-    }
-  }
-  return gateway.request(getAccountMetricsOperation, parameters)
-}
-
-/**
- * @param {string} envId 
- * @param {object} options Optional options
- * @param {number} [options.duration] 
- * @return {Promise<module:types.metrics>} environment metrics
- */
-export function getEnvironmentMetrics(envId, options) {
-  if (!options) options = {}
-  const parameters = {
-    path: {
-      envId
-    },
-    query: {
-      duration: options.duration
-    }
-  }
-  return gateway.request(getEnvironmentMetricsOperation, parameters)
-}
-
-/**
- * @param {string} shrToken 
- * @param {object} options Optional options
- * @param {number} [options.duration] 
- * @return {Promise<module:types.metrics>} share metrics
- */
-export function getShareMetrics(shrToken, options) {
-  if (!options) options = {}
-  const parameters = {
-    path: {
-      shrToken
-    },
-    query: {
-      duration: options.duration
-    }
-  }
-  return gateway.request(getShareMetricsOperation, parameters)
-}
-
-const getAccountMetricsOperation = {
-  path: '/metrics/account',
-  method: 'get',
-  security: [
-    {
-      id: 'key'
-    }
-  ]
-}
-
-const getEnvironmentMetricsOperation = {
-  path: '/metrics/environment/{envId}',
-  method: 'get',
-  security: [
-    {
-      id: 'key'
-    }
-  ]
-}
-
-const getShareMetricsOperation = {
-  path: '/metrics/share/{shrToken}',
-  method: 'get',
-  security: [
-    {
-      id: 'key'
-    }
-  ]
-}

From 9f29bb59c7340d5e43044d0a402f0a1ca9e8e3f6 Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Tue, 9 May 2023 16:22:30 -0400
Subject: [PATCH 10/49] alternate metrics model with sample objects (#319)

---
 controller/metrics.go                         | 21 +++--
 rest_model_zrok/metrics.go                    | 75 ++++++++++++++--
 rest_model_zrok/metrics_sample.go             | 56 ++++++++++++
 rest_server_zrok/embedded_spec.go             | 40 ++++++---
 specs/zrok.yml                                | 18 ++--
 ui/src/api/types.js                           | 12 ++-
 .../console/detail/account/AccountDetail.js   | 89 ++++++++++++++++++-
 7 files changed, 277 insertions(+), 34 deletions(-)
 create mode 100644 rest_model_zrok/metrics_sample.go

diff --git a/controller/metrics.go b/controller/metrics.go
index a5091b0f..0197dbce 100644
--- a/controller/metrics.go
+++ b/controller/metrics.go
@@ -42,7 +42,7 @@ func (h *getAccountMetricsHandler) Handle(params metadata.GetAccountMetricsParam
 	slice := duration / 200
 
 	query := fmt.Sprintf("from(bucket: \"%v\")\n", h.cfg.Bucket) +
-		fmt.Sprintf("|> range(start -%v)\n", duration) +
+		fmt.Sprintf("|> range(start: -%v)\n", duration) +
 		"|> filter(fn: (r) => r[\"_measurement\"] == \"xfer\")\n" +
 		"|> filter(fn: (r) => r[\"_field\"] == \"rx\" or r[\"_field\"] == \"tx\")\n" +
 		"|> filter(fn: (r) => r[\"namespace\"] == \"backend\")\n" +
@@ -50,35 +50,42 @@ func (h *getAccountMetricsHandler) Handle(params metadata.GetAccountMetricsParam
 		"|> drop(columns: [\"share\", \"envId\"])\n" +
 		fmt.Sprintf("|> aggregateWindow(every: %v, fn: sum, createEmpty: true)", slice)
 
-	rx, tx, err := runFluxForRxTxArray(query, h.queryApi)
+	rx, tx, timestamps, err := runFluxForRxTxArray(query, h.queryApi)
 	if err != nil {
 		logrus.Errorf("error running account metrics query for '%v': %v", principal.Email, err)
 		return metadata.NewGetAccountMetricsInternalServerError()
 	}
 
 	response := &rest_model_zrok.Metrics{
+		Scope:  "account",
 		ID:     fmt.Sprintf("%d", principal.ID),
 		Period: duration.Seconds(),
-		Rx:     rx,
-		Tx:     tx,
+	}
+	for i := 0; i < len(rx) && i < len(tx) && i < len(timestamps); i++ {
+		response.Samples = append(response.Samples, &rest_model_zrok.MetricsSample{
+			Rx:        rx[i],
+			Tx:        tx[i],
+			Timestamp: timestamps[i],
+		})
 	}
 	return metadata.NewGetAccountMetricsOK().WithPayload(response)
 }
 
-func runFluxForRxTxArray(query string, queryApi api.QueryAPI) (rx, tx []float64, err error) {
+func runFluxForRxTxArray(query string, queryApi api.QueryAPI) (rx, tx, timestamps []float64, err error) {
 	result, err := queryApi.Query(context.Background(), query)
 	if err != nil {
-		return nil, nil, err
+		return nil, nil, nil, err
 	}
 	for result.Next() {
 		if v, ok := result.Record().Value().(int64); ok {
 			switch result.Record().Field() {
 			case "rx":
 				rx = append(rx, float64(v))
+				timestamps = append(timestamps, float64(result.Record().Time().UnixMilli()))
 			case "tx":
 				tx = append(tx, float64(v))
 			}
 		}
 	}
-	return rx, tx, nil
+	return rx, tx, timestamps, nil
 }
diff --git a/rest_model_zrok/metrics.go b/rest_model_zrok/metrics.go
index dfb9bf08..1aa24172 100644
--- a/rest_model_zrok/metrics.go
+++ b/rest_model_zrok/metrics.go
@@ -7,7 +7,9 @@ package rest_model_zrok
 
 import (
 	"context"
+	"strconv"
 
+	"github.com/go-openapi/errors"
 	"github.com/go-openapi/strfmt"
 	"github.com/go-openapi/swag"
 )
@@ -23,23 +25,84 @@ type Metrics struct {
 	// period
 	Period float64 `json:"period,omitempty"`
 
-	// rx
-	Rx []float64 `json:"rx"`
+	// samples
+	Samples []*MetricsSample `json:"samples"`
 
 	// scope
 	Scope string `json:"scope,omitempty"`
-
-	// tx
-	Tx []float64 `json:"tx"`
 }
 
 // Validate validates this metrics
 func (m *Metrics) Validate(formats strfmt.Registry) error {
+	var res []error
+
+	if err := m.validateSamples(formats); err != nil {
+		res = append(res, err)
+	}
+
+	if len(res) > 0 {
+		return errors.CompositeValidationError(res...)
+	}
 	return nil
 }
 
-// ContextValidate validates this metrics based on context it is used
+func (m *Metrics) validateSamples(formats strfmt.Registry) error {
+	if swag.IsZero(m.Samples) { // not required
+		return nil
+	}
+
+	for i := 0; i < len(m.Samples); i++ {
+		if swag.IsZero(m.Samples[i]) { // not required
+			continue
+		}
+
+		if m.Samples[i] != nil {
+			if err := m.Samples[i].Validate(formats); err != nil {
+				if ve, ok := err.(*errors.Validation); ok {
+					return ve.ValidateName("samples" + "." + strconv.Itoa(i))
+				} else if ce, ok := err.(*errors.CompositeError); ok {
+					return ce.ValidateName("samples" + "." + strconv.Itoa(i))
+				}
+				return err
+			}
+		}
+
+	}
+
+	return nil
+}
+
+// ContextValidate validate this metrics based on the context it is used
 func (m *Metrics) ContextValidate(ctx context.Context, formats strfmt.Registry) error {
+	var res []error
+
+	if err := m.contextValidateSamples(ctx, formats); err != nil {
+		res = append(res, err)
+	}
+
+	if len(res) > 0 {
+		return errors.CompositeValidationError(res...)
+	}
+	return nil
+}
+
+func (m *Metrics) contextValidateSamples(ctx context.Context, formats strfmt.Registry) error {
+
+	for i := 0; i < len(m.Samples); i++ {
+
+		if m.Samples[i] != nil {
+			if err := m.Samples[i].ContextValidate(ctx, formats); err != nil {
+				if ve, ok := err.(*errors.Validation); ok {
+					return ve.ValidateName("samples" + "." + strconv.Itoa(i))
+				} else if ce, ok := err.(*errors.CompositeError); ok {
+					return ce.ValidateName("samples" + "." + strconv.Itoa(i))
+				}
+				return err
+			}
+		}
+
+	}
+
 	return nil
 }
 
diff --git a/rest_model_zrok/metrics_sample.go b/rest_model_zrok/metrics_sample.go
new file mode 100644
index 00000000..84869de4
--- /dev/null
+++ b/rest_model_zrok/metrics_sample.go
@@ -0,0 +1,56 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package rest_model_zrok
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"context"
+
+	"github.com/go-openapi/strfmt"
+	"github.com/go-openapi/swag"
+)
+
+// MetricsSample metrics sample
+//
+// swagger:model metricsSample
+type MetricsSample struct {
+
+	// rx
+	Rx float64 `json:"rx,omitempty"`
+
+	// timestamp
+	Timestamp float64 `json:"timestamp,omitempty"`
+
+	// tx
+	Tx float64 `json:"tx,omitempty"`
+}
+
+// Validate validates this metrics sample
+func (m *MetricsSample) Validate(formats strfmt.Registry) error {
+	return nil
+}
+
+// ContextValidate validates this metrics sample based on context it is used
+func (m *MetricsSample) ContextValidate(ctx context.Context, formats strfmt.Registry) error {
+	return nil
+}
+
+// MarshalBinary interface implementation
+func (m *MetricsSample) MarshalBinary() ([]byte, error) {
+	if m == nil {
+		return nil, nil
+	}
+	return swag.WriteJSON(m)
+}
+
+// UnmarshalBinary interface implementation
+func (m *MetricsSample) UnmarshalBinary(b []byte) error {
+	var res MetricsSample
+	if err := swag.ReadJSON(b, &res); err != nil {
+		return err
+	}
+	*m = res
+	return nil
+}
diff --git a/rest_server_zrok/embedded_spec.go b/rest_server_zrok/embedded_spec.go
index 2d8d53fc..d268d47c 100644
--- a/rest_server_zrok/embedded_spec.go
+++ b/rest_server_zrok/embedded_spec.go
@@ -1147,20 +1147,28 @@ func init() {
         "period": {
           "type": "number"
         },
-        "rx": {
+        "samples": {
           "type": "array",
           "items": {
-            "type": "number"
+            "$ref": "#/definitions/metricsSample"
           }
         },
         "scope": {
           "type": "string"
+        }
+      }
+    },
+    "metricsSample": {
+      "type": "object",
+      "properties": {
+        "rx": {
+          "type": "number"
+        },
+        "timestamp": {
+          "type": "number"
         },
         "tx": {
-          "type": "array",
-          "items": {
-            "type": "number"
-          }
+          "type": "number"
         }
       }
     },
@@ -2563,20 +2571,28 @@ func init() {
         "period": {
           "type": "number"
         },
-        "rx": {
+        "samples": {
           "type": "array",
           "items": {
-            "type": "number"
+            "$ref": "#/definitions/metricsSample"
           }
         },
         "scope": {
           "type": "string"
+        }
+      }
+    },
+    "metricsSample": {
+      "type": "object",
+      "properties": {
+        "rx": {
+          "type": "number"
+        },
+        "timestamp": {
+          "type": "number"
         },
         "tx": {
-          "type": "array",
-          "items": {
-            "type": "number"
-          }
+          "type": "number"
         }
       }
     },
diff --git a/specs/zrok.yml b/specs/zrok.yml
index a73ab913..fe129fc8 100644
--- a/specs/zrok.yml
+++ b/specs/zrok.yml
@@ -743,14 +743,20 @@ definitions:
         type: string
       period:
         type: number
+      samples:
+        type: array
+        items:
+          $ref: "#/definitions/metricsSample"
+
+  metricsSample:
+    type: object
+    properties:
       rx:
-        type: array
-        items:
-          type: number
+        type: number
       tx:
-        type: array
-        items:
-          type: number
+        type: number
+      timestamp:
+        type: number
 
   principal:
     type: object
diff --git a/ui/src/api/types.js b/ui/src/api/types.js
index 6f5a6317..01c83b83 100644
--- a/ui/src/api/types.js
+++ b/ui/src/api/types.js
@@ -130,8 +130,16 @@
  * @property {string} scope 
  * @property {string} id 
  * @property {number} period 
- * @property {number[]} rx 
- * @property {number[]} tx 
+ * @property {module:types.metricsSample[]} samples 
+ */
+
+/**
+ * @typedef metricsSample
+ * @memberof module:types
+ * 
+ * @property {number} rx 
+ * @property {number} tx 
+ * @property {number} timestamp 
  */
 
 /**
diff --git a/ui/src/console/detail/account/AccountDetail.js b/ui/src/console/detail/account/AccountDetail.js
index 679b51aa..bd39617d 100644
--- a/ui/src/console/detail/account/AccountDetail.js
+++ b/ui/src/console/detail/account/AccountDetail.js
@@ -1,8 +1,11 @@
 import {mdiAccountBox} from "@mdi/js";
 import Icon from "@mdi/react";
 import PropertyTable from "../../PropertyTable";
-import {Tab, Tabs} from "react-bootstrap";
+import {Tab, Tabs, Tooltip} from "react-bootstrap";
 import SecretToggle from "../../SecretToggle";
+import React, {useEffect, useState} from "react";
+import * as metadata from "../../../api/metadata";
+import {Area, AreaChart, CartesianGrid, Line, LineChart, ResponsiveContainer, XAxis, YAxis} from "recharts";
 
 const AccountDetail = (props) => {
     const customProperties = {
@@ -16,9 +19,93 @@ const AccountDetail = (props) => {
                 <Tab eventKey={"detail"} title={"Detail"}>
                     <PropertyTable object={props.user} custom={customProperties}/>
                 </Tab>
+                <Tab eventKey={"metrics"} title={"Metrics"}>
+                    <MetricsTab />
+                </Tab>
             </Tabs>
         </div>
     );
 }
 
+const MetricsTab = (props) => {
+    const [metrics, setMetrics] = useState({});
+    const [tx, setTx] = useState(0);
+    const [rx, setRx] = useState(0)
+
+    useEffect(() => {
+        metadata.getAccountMetrics()
+            .then(resp => {
+                setMetrics(resp.data);
+            });
+    }, []);
+
+    useEffect(() => {
+        let mounted = true;
+        let interval = setInterval(() => {
+            metadata.getAccountMetrics()
+                .then(resp => {
+                    if(mounted) {
+                        setMetrics(resp.data);
+                    }
+                });
+        }, 1000);
+        return () => {
+            mounted = false;
+            clearInterval(interval);
+        }
+    }, []);
+
+    useEffect(() => {
+        let txAccum = 0
+        let rxAccum = 0
+        if(metrics.samples) {
+            metrics.samples.forEach(sample => {
+                txAccum += sample.tx
+                rxAccum += sample.rx
+            })
+        }
+        setTx(txAccum);
+        setRx(rxAccum);
+    }, [metrics])
+
+    console.log(metrics);
+
+    return (
+        <div>
+            <div>
+                <h1>RX: {bytesToSize(rx)}, TX: {bytesToSize(tx)}</h1>
+            </div>
+            <ResponsiveContainer width={"100%"} height={300}>
+                <LineChart data={metrics.samples}>
+                    <CartesianGrid strokeDasharay={"3 3"} />
+                    <XAxis dataKey={(v) => new Date(v.timestamp)} />
+                    <YAxis />
+                    <Line type={"linear"} stroke={"red"} dataKey={"rx"} activeDot={{ r: 8 }}/>
+                    <Line type={"linear"} stroke={"green"} dataKey={"tx"} />
+                    <Tooltip />
+                </LineChart>
+            </ResponsiveContainer>
+        </div>
+    );
+}
+
+const bytesToSize = (sz) => {
+    let absSz = sz;
+    if(absSz < 0) {
+        absSz *= -1;
+    }
+    const unit = 1000
+    if(absSz < unit) {
+        return '' + absSz + ' B';
+    }
+    let div = unit
+    let exp = 0
+    for(let n = absSz / unit; n >= unit; n /= unit) {
+        div *= unit;
+        exp++;
+    }
+
+    return '' + (sz / div).toFixed(2) + "kMGTPE"[exp];
+}
+
 export default AccountDetail;
\ No newline at end of file

From 02c996b54563873e9903698a2e6c521ae74ad0ab Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Tue, 9 May 2023 16:36:50 -0400
Subject: [PATCH 11/49] environment metrics handler (#319)

---
 controller/metrics.go                         |  75 ++++++++++++
 .../get_environment_metrics_parameters.go     |   9 +-
 .../get_environment_metrics_responses.go      | 114 ++++++++++++++++++
 .../metadata/get_share_metrics_responses.go   | 114 ++++++++++++++++++
 rest_server_zrok/embedded_spec.go             |  28 ++++-
 .../get_environment_metrics_parameters.go     |  10 +-
 .../get_environment_metrics_responses.go      |  50 ++++++++
 .../get_environment_metrics_urlbuilder.go     |   6 +-
 .../metadata/get_share_metrics_responses.go   |  50 ++++++++
 specs/zrok.yml                                |  12 +-
 ui/src/api/metadata.js                        |   2 +-
 11 files changed, 458 insertions(+), 12 deletions(-)

diff --git a/controller/metrics.go b/controller/metrics.go
index 0197dbce..355a651a 100644
--- a/controller/metrics.go
+++ b/controller/metrics.go
@@ -71,6 +71,81 @@ func (h *getAccountMetricsHandler) Handle(params metadata.GetAccountMetricsParam
 	return metadata.NewGetAccountMetricsOK().WithPayload(response)
 }
 
+type getEnvironmentMetricsHandler struct {
+	cfg      *metrics.InfluxConfig
+	idb      influxdb2.Client
+	queryApi api.QueryAPI
+}
+
+func newGetEnvironmentMetricsHAndler(cfg *metrics.InfluxConfig) *getEnvironmentMetricsHandler {
+	idb := influxdb2.NewClient(cfg.Url, cfg.Token)
+	queryApi := idb.QueryAPI(cfg.Org)
+	return &getEnvironmentMetricsHandler{
+		cfg:      cfg,
+		idb:      idb,
+		queryApi: queryApi,
+	}
+}
+
+func (h *getEnvironmentMetricsHandler) Handle(params metadata.GetEnvironmentMetricsParams, principal *rest_model_zrok.Principal) middleware.Responder {
+	trx, err := str.Begin()
+	if err != nil {
+		logrus.Errorf("error starting transaction: %v", err)
+		return metadata.NewGetEnvironmentMetricsInternalServerError()
+	}
+	defer func() { _ = trx.Rollback() }()
+	env, err := str.GetEnvironment(int(params.EnvID), trx)
+	if err != nil {
+		logrus.Errorf("error finding environment '%d': %v", int(params.EnvID), err)
+		return metadata.NewGetEnvironmentMetricsUnauthorized()
+	}
+	if int64(env.Id) != principal.ID {
+		logrus.Errorf("unauthorized environemnt '%d' for '%v'", int(params.EnvID), principal.Email)
+		return metadata.NewGetEnvironmentMetricsUnauthorized()
+	}
+
+	duration := 30 * 24 * time.Hour
+	if params.Duration != nil {
+		v, err := time.ParseDuration(*params.Duration)
+		if err != nil {
+			logrus.Errorf("bad duration '%v' for '%v': %v", params.Duration, principal.Email, err)
+			return metadata.NewGetAccountMetricsBadRequest()
+		}
+		duration = v
+	}
+	slice := duration / 200
+
+	query := fmt.Sprintf("from(bucket: \"%v\")\n", h.cfg.Bucket) +
+		fmt.Sprintf("|> range(start: -%v)\n", duration) +
+		"|> filter(fn: (r) => r[\"_measurement\"] == \"xfer\")\n" +
+		"|> filter(fn: (r) => r[\"_field\"] == \"rx\" or r[\"_field\"] == \"tx\")\n" +
+		"|> filter(fn: (r) => r[\"namespace\"] == \"backend\")\n" +
+		fmt.Sprintf("|> filter(fn: (r) => r[\"envId\"] == \"%d\")\n", int64(env.Id)) +
+		"|> drop(columns: [\"share\", \"acctId\"])\n" +
+		fmt.Sprintf("|> aggregateWindow(every: %v, fn: sum, createEmpty: true)", slice)
+
+	rx, tx, timestamps, err := runFluxForRxTxArray(query, h.queryApi)
+	if err != nil {
+		logrus.Errorf("error running account metrics query for '%v': %v", principal.Email, err)
+		return metadata.NewGetAccountMetricsInternalServerError()
+	}
+
+	response := &rest_model_zrok.Metrics{
+		Scope:  "account",
+		ID:     fmt.Sprintf("%d", principal.ID),
+		Period: duration.Seconds(),
+	}
+	for i := 0; i < len(rx) && i < len(tx) && i < len(timestamps); i++ {
+		response.Samples = append(response.Samples, &rest_model_zrok.MetricsSample{
+			Rx:        rx[i],
+			Tx:        tx[i],
+			Timestamp: timestamps[i],
+		})
+	}
+
+	return metadata.NewGetEnvironmentMetricsOK().WithPayload(response)
+}
+
 func runFluxForRxTxArray(query string, queryApi api.QueryAPI) (rx, tx, timestamps []float64, err error) {
 	result, err := queryApi.Query(context.Background(), query)
 	if err != nil {
diff --git a/rest_client_zrok/metadata/get_environment_metrics_parameters.go b/rest_client_zrok/metadata/get_environment_metrics_parameters.go
index be7aae16..6bdd8404 100644
--- a/rest_client_zrok/metadata/get_environment_metrics_parameters.go
+++ b/rest_client_zrok/metadata/get_environment_metrics_parameters.go
@@ -14,6 +14,7 @@ import (
 	"github.com/go-openapi/runtime"
 	cr "github.com/go-openapi/runtime/client"
 	"github.com/go-openapi/strfmt"
+	"github.com/go-openapi/swag"
 )
 
 // NewGetEnvironmentMetricsParams creates a new GetEnvironmentMetricsParams object,
@@ -65,7 +66,7 @@ type GetEnvironmentMetricsParams struct {
 	Duration *string
 
 	// EnvID.
-	EnvID string
+	EnvID float64
 
 	timeout    time.Duration
 	Context    context.Context
@@ -132,13 +133,13 @@ func (o *GetEnvironmentMetricsParams) SetDuration(duration *string) {
 }
 
 // WithEnvID adds the envID to the get environment metrics params
-func (o *GetEnvironmentMetricsParams) WithEnvID(envID string) *GetEnvironmentMetricsParams {
+func (o *GetEnvironmentMetricsParams) WithEnvID(envID float64) *GetEnvironmentMetricsParams {
 	o.SetEnvID(envID)
 	return o
 }
 
 // SetEnvID adds the envId to the get environment metrics params
-func (o *GetEnvironmentMetricsParams) SetEnvID(envID string) {
+func (o *GetEnvironmentMetricsParams) SetEnvID(envID float64) {
 	o.EnvID = envID
 }
 
@@ -168,7 +169,7 @@ func (o *GetEnvironmentMetricsParams) WriteToRequest(r runtime.ClientRequest, re
 	}
 
 	// path param envId
-	if err := r.SetPathParam("envId", o.EnvID); err != nil {
+	if err := r.SetPathParam("envId", swag.FormatFloat64(o.EnvID)); err != nil {
 		return err
 	}
 
diff --git a/rest_client_zrok/metadata/get_environment_metrics_responses.go b/rest_client_zrok/metadata/get_environment_metrics_responses.go
index 36e1e2f6..e0fb3eac 100644
--- a/rest_client_zrok/metadata/get_environment_metrics_responses.go
+++ b/rest_client_zrok/metadata/get_environment_metrics_responses.go
@@ -29,12 +29,24 @@ func (o *GetEnvironmentMetricsReader) ReadResponse(response runtime.ClientRespon
 			return nil, err
 		}
 		return result, nil
+	case 400:
+		result := NewGetEnvironmentMetricsBadRequest()
+		if err := result.readResponse(response, consumer, o.formats); err != nil {
+			return nil, err
+		}
+		return nil, result
 	case 401:
 		result := NewGetEnvironmentMetricsUnauthorized()
 		if err := result.readResponse(response, consumer, o.formats); err != nil {
 			return nil, err
 		}
 		return nil, result
+	case 500:
+		result := NewGetEnvironmentMetricsInternalServerError()
+		if err := result.readResponse(response, consumer, o.formats); err != nil {
+			return nil, err
+		}
+		return nil, result
 	default:
 		return nil, runtime.NewAPIError("response status code does not match any response statuses defined for this endpoint in the swagger spec", response, response.Code())
 	}
@@ -103,6 +115,57 @@ func (o *GetEnvironmentMetricsOK) readResponse(response runtime.ClientResponse,
 	return nil
 }
 
+// NewGetEnvironmentMetricsBadRequest creates a GetEnvironmentMetricsBadRequest with default headers values
+func NewGetEnvironmentMetricsBadRequest() *GetEnvironmentMetricsBadRequest {
+	return &GetEnvironmentMetricsBadRequest{}
+}
+
+/*
+GetEnvironmentMetricsBadRequest describes a response with status code 400, with default header values.
+
+bad request
+*/
+type GetEnvironmentMetricsBadRequest struct {
+}
+
+// IsSuccess returns true when this get environment metrics bad request response has a 2xx status code
+func (o *GetEnvironmentMetricsBadRequest) IsSuccess() bool {
+	return false
+}
+
+// IsRedirect returns true when this get environment metrics bad request response has a 3xx status code
+func (o *GetEnvironmentMetricsBadRequest) IsRedirect() bool {
+	return false
+}
+
+// IsClientError returns true when this get environment metrics bad request response has a 4xx status code
+func (o *GetEnvironmentMetricsBadRequest) IsClientError() bool {
+	return true
+}
+
+// IsServerError returns true when this get environment metrics bad request response has a 5xx status code
+func (o *GetEnvironmentMetricsBadRequest) IsServerError() bool {
+	return false
+}
+
+// IsCode returns true when this get environment metrics bad request response a status code equal to that given
+func (o *GetEnvironmentMetricsBadRequest) IsCode(code int) bool {
+	return code == 400
+}
+
+func (o *GetEnvironmentMetricsBadRequest) Error() string {
+	return fmt.Sprintf("[GET /metrics/environment/{envId}][%d] getEnvironmentMetricsBadRequest ", 400)
+}
+
+func (o *GetEnvironmentMetricsBadRequest) String() string {
+	return fmt.Sprintf("[GET /metrics/environment/{envId}][%d] getEnvironmentMetricsBadRequest ", 400)
+}
+
+func (o *GetEnvironmentMetricsBadRequest) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error {
+
+	return nil
+}
+
 // NewGetEnvironmentMetricsUnauthorized creates a GetEnvironmentMetricsUnauthorized with default headers values
 func NewGetEnvironmentMetricsUnauthorized() *GetEnvironmentMetricsUnauthorized {
 	return &GetEnvironmentMetricsUnauthorized{}
@@ -153,3 +216,54 @@ func (o *GetEnvironmentMetricsUnauthorized) readResponse(response runtime.Client
 
 	return nil
 }
+
+// NewGetEnvironmentMetricsInternalServerError creates a GetEnvironmentMetricsInternalServerError with default headers values
+func NewGetEnvironmentMetricsInternalServerError() *GetEnvironmentMetricsInternalServerError {
+	return &GetEnvironmentMetricsInternalServerError{}
+}
+
+/*
+GetEnvironmentMetricsInternalServerError describes a response with status code 500, with default header values.
+
+internal server error
+*/
+type GetEnvironmentMetricsInternalServerError struct {
+}
+
+// IsSuccess returns true when this get environment metrics internal server error response has a 2xx status code
+func (o *GetEnvironmentMetricsInternalServerError) IsSuccess() bool {
+	return false
+}
+
+// IsRedirect returns true when this get environment metrics internal server error response has a 3xx status code
+func (o *GetEnvironmentMetricsInternalServerError) IsRedirect() bool {
+	return false
+}
+
+// IsClientError returns true when this get environment metrics internal server error response has a 4xx status code
+func (o *GetEnvironmentMetricsInternalServerError) IsClientError() bool {
+	return false
+}
+
+// IsServerError returns true when this get environment metrics internal server error response has a 5xx status code
+func (o *GetEnvironmentMetricsInternalServerError) IsServerError() bool {
+	return true
+}
+
+// IsCode returns true when this get environment metrics internal server error response a status code equal to that given
+func (o *GetEnvironmentMetricsInternalServerError) IsCode(code int) bool {
+	return code == 500
+}
+
+func (o *GetEnvironmentMetricsInternalServerError) Error() string {
+	return fmt.Sprintf("[GET /metrics/environment/{envId}][%d] getEnvironmentMetricsInternalServerError ", 500)
+}
+
+func (o *GetEnvironmentMetricsInternalServerError) String() string {
+	return fmt.Sprintf("[GET /metrics/environment/{envId}][%d] getEnvironmentMetricsInternalServerError ", 500)
+}
+
+func (o *GetEnvironmentMetricsInternalServerError) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error {
+
+	return nil
+}
diff --git a/rest_client_zrok/metadata/get_share_metrics_responses.go b/rest_client_zrok/metadata/get_share_metrics_responses.go
index 2ed61ab8..d6d0500d 100644
--- a/rest_client_zrok/metadata/get_share_metrics_responses.go
+++ b/rest_client_zrok/metadata/get_share_metrics_responses.go
@@ -29,12 +29,24 @@ func (o *GetShareMetricsReader) ReadResponse(response runtime.ClientResponse, co
 			return nil, err
 		}
 		return result, nil
+	case 400:
+		result := NewGetShareMetricsBadRequest()
+		if err := result.readResponse(response, consumer, o.formats); err != nil {
+			return nil, err
+		}
+		return nil, result
 	case 401:
 		result := NewGetShareMetricsUnauthorized()
 		if err := result.readResponse(response, consumer, o.formats); err != nil {
 			return nil, err
 		}
 		return nil, result
+	case 500:
+		result := NewGetShareMetricsInternalServerError()
+		if err := result.readResponse(response, consumer, o.formats); err != nil {
+			return nil, err
+		}
+		return nil, result
 	default:
 		return nil, runtime.NewAPIError("response status code does not match any response statuses defined for this endpoint in the swagger spec", response, response.Code())
 	}
@@ -103,6 +115,57 @@ func (o *GetShareMetricsOK) readResponse(response runtime.ClientResponse, consum
 	return nil
 }
 
+// NewGetShareMetricsBadRequest creates a GetShareMetricsBadRequest with default headers values
+func NewGetShareMetricsBadRequest() *GetShareMetricsBadRequest {
+	return &GetShareMetricsBadRequest{}
+}
+
+/*
+GetShareMetricsBadRequest describes a response with status code 400, with default header values.
+
+bad request
+*/
+type GetShareMetricsBadRequest struct {
+}
+
+// IsSuccess returns true when this get share metrics bad request response has a 2xx status code
+func (o *GetShareMetricsBadRequest) IsSuccess() bool {
+	return false
+}
+
+// IsRedirect returns true when this get share metrics bad request response has a 3xx status code
+func (o *GetShareMetricsBadRequest) IsRedirect() bool {
+	return false
+}
+
+// IsClientError returns true when this get share metrics bad request response has a 4xx status code
+func (o *GetShareMetricsBadRequest) IsClientError() bool {
+	return true
+}
+
+// IsServerError returns true when this get share metrics bad request response has a 5xx status code
+func (o *GetShareMetricsBadRequest) IsServerError() bool {
+	return false
+}
+
+// IsCode returns true when this get share metrics bad request response a status code equal to that given
+func (o *GetShareMetricsBadRequest) IsCode(code int) bool {
+	return code == 400
+}
+
+func (o *GetShareMetricsBadRequest) Error() string {
+	return fmt.Sprintf("[GET /metrics/share/{shrToken}][%d] getShareMetricsBadRequest ", 400)
+}
+
+func (o *GetShareMetricsBadRequest) String() string {
+	return fmt.Sprintf("[GET /metrics/share/{shrToken}][%d] getShareMetricsBadRequest ", 400)
+}
+
+func (o *GetShareMetricsBadRequest) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error {
+
+	return nil
+}
+
 // NewGetShareMetricsUnauthorized creates a GetShareMetricsUnauthorized with default headers values
 func NewGetShareMetricsUnauthorized() *GetShareMetricsUnauthorized {
 	return &GetShareMetricsUnauthorized{}
@@ -153,3 +216,54 @@ func (o *GetShareMetricsUnauthorized) readResponse(response runtime.ClientRespon
 
 	return nil
 }
+
+// NewGetShareMetricsInternalServerError creates a GetShareMetricsInternalServerError with default headers values
+func NewGetShareMetricsInternalServerError() *GetShareMetricsInternalServerError {
+	return &GetShareMetricsInternalServerError{}
+}
+
+/*
+GetShareMetricsInternalServerError describes a response with status code 500, with default header values.
+
+internal server error
+*/
+type GetShareMetricsInternalServerError struct {
+}
+
+// IsSuccess returns true when this get share metrics internal server error response has a 2xx status code
+func (o *GetShareMetricsInternalServerError) IsSuccess() bool {
+	return false
+}
+
+// IsRedirect returns true when this get share metrics internal server error response has a 3xx status code
+func (o *GetShareMetricsInternalServerError) IsRedirect() bool {
+	return false
+}
+
+// IsClientError returns true when this get share metrics internal server error response has a 4xx status code
+func (o *GetShareMetricsInternalServerError) IsClientError() bool {
+	return false
+}
+
+// IsServerError returns true when this get share metrics internal server error response has a 5xx status code
+func (o *GetShareMetricsInternalServerError) IsServerError() bool {
+	return true
+}
+
+// IsCode returns true when this get share metrics internal server error response a status code equal to that given
+func (o *GetShareMetricsInternalServerError) IsCode(code int) bool {
+	return code == 500
+}
+
+func (o *GetShareMetricsInternalServerError) Error() string {
+	return fmt.Sprintf("[GET /metrics/share/{shrToken}][%d] getShareMetricsInternalServerError ", 500)
+}
+
+func (o *GetShareMetricsInternalServerError) String() string {
+	return fmt.Sprintf("[GET /metrics/share/{shrToken}][%d] getShareMetricsInternalServerError ", 500)
+}
+
+func (o *GetShareMetricsInternalServerError) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error {
+
+	return nil
+}
diff --git a/rest_server_zrok/embedded_spec.go b/rest_server_zrok/embedded_spec.go
index d268d47c..0b5fcdb8 100644
--- a/rest_server_zrok/embedded_spec.go
+++ b/rest_server_zrok/embedded_spec.go
@@ -567,7 +567,7 @@ func init() {
         "operationId": "getEnvironmentMetrics",
         "parameters": [
           {
-            "type": "string",
+            "type": "number",
             "name": "envId",
             "in": "path",
             "required": true
@@ -585,8 +585,14 @@ func init() {
               "$ref": "#/definitions/metrics"
             }
           },
+          "400": {
+            "description": "bad request"
+          },
           "401": {
             "description": "unauthorized"
+          },
+          "500": {
+            "description": "internal server error"
           }
         }
       }
@@ -622,8 +628,14 @@ func init() {
               "$ref": "#/definitions/metrics"
             }
           },
+          "400": {
+            "description": "bad request"
+          },
           "401": {
             "description": "unauthorized"
+          },
+          "500": {
+            "description": "internal server error"
           }
         }
       }
@@ -1991,7 +2003,7 @@ func init() {
         "operationId": "getEnvironmentMetrics",
         "parameters": [
           {
-            "type": "string",
+            "type": "number",
             "name": "envId",
             "in": "path",
             "required": true
@@ -2009,8 +2021,14 @@ func init() {
               "$ref": "#/definitions/metrics"
             }
           },
+          "400": {
+            "description": "bad request"
+          },
           "401": {
             "description": "unauthorized"
+          },
+          "500": {
+            "description": "internal server error"
           }
         }
       }
@@ -2046,8 +2064,14 @@ func init() {
               "$ref": "#/definitions/metrics"
             }
           },
+          "400": {
+            "description": "bad request"
+          },
           "401": {
             "description": "unauthorized"
+          },
+          "500": {
+            "description": "internal server error"
           }
         }
       }
diff --git a/rest_server_zrok/operations/metadata/get_environment_metrics_parameters.go b/rest_server_zrok/operations/metadata/get_environment_metrics_parameters.go
index 6ea94376..9b6a9423 100644
--- a/rest_server_zrok/operations/metadata/get_environment_metrics_parameters.go
+++ b/rest_server_zrok/operations/metadata/get_environment_metrics_parameters.go
@@ -12,6 +12,7 @@ import (
 	"github.com/go-openapi/runtime"
 	"github.com/go-openapi/runtime/middleware"
 	"github.com/go-openapi/strfmt"
+	"github.com/go-openapi/swag"
 )
 
 // NewGetEnvironmentMetricsParams creates a new GetEnvironmentMetricsParams object
@@ -39,7 +40,7 @@ type GetEnvironmentMetricsParams struct {
 	  Required: true
 	  In: path
 	*/
-	EnvID string
+	EnvID float64
 }
 
 // BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface
@@ -95,7 +96,12 @@ func (o *GetEnvironmentMetricsParams) bindEnvID(rawData []string, hasKey bool, f
 
 	// Required: true
 	// Parameter is provided by construction from the route
-	o.EnvID = raw
+
+	value, err := swag.ConvertFloat64(raw)
+	if err != nil {
+		return errors.InvalidType("envId", "path", "float64", raw)
+	}
+	o.EnvID = value
 
 	return nil
 }
diff --git a/rest_server_zrok/operations/metadata/get_environment_metrics_responses.go b/rest_server_zrok/operations/metadata/get_environment_metrics_responses.go
index 290fc938..945fe314 100644
--- a/rest_server_zrok/operations/metadata/get_environment_metrics_responses.go
+++ b/rest_server_zrok/operations/metadata/get_environment_metrics_responses.go
@@ -58,6 +58,31 @@ func (o *GetEnvironmentMetricsOK) WriteResponse(rw http.ResponseWriter, producer
 	}
 }
 
+// GetEnvironmentMetricsBadRequestCode is the HTTP code returned for type GetEnvironmentMetricsBadRequest
+const GetEnvironmentMetricsBadRequestCode int = 400
+
+/*
+GetEnvironmentMetricsBadRequest bad request
+
+swagger:response getEnvironmentMetricsBadRequest
+*/
+type GetEnvironmentMetricsBadRequest struct {
+}
+
+// NewGetEnvironmentMetricsBadRequest creates GetEnvironmentMetricsBadRequest with default headers values
+func NewGetEnvironmentMetricsBadRequest() *GetEnvironmentMetricsBadRequest {
+
+	return &GetEnvironmentMetricsBadRequest{}
+}
+
+// WriteResponse to the client
+func (o *GetEnvironmentMetricsBadRequest) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) {
+
+	rw.Header().Del(runtime.HeaderContentType) //Remove Content-Type on empty responses
+
+	rw.WriteHeader(400)
+}
+
 // GetEnvironmentMetricsUnauthorizedCode is the HTTP code returned for type GetEnvironmentMetricsUnauthorized
 const GetEnvironmentMetricsUnauthorizedCode int = 401
 
@@ -82,3 +107,28 @@ func (o *GetEnvironmentMetricsUnauthorized) WriteResponse(rw http.ResponseWriter
 
 	rw.WriteHeader(401)
 }
+
+// GetEnvironmentMetricsInternalServerErrorCode is the HTTP code returned for type GetEnvironmentMetricsInternalServerError
+const GetEnvironmentMetricsInternalServerErrorCode int = 500
+
+/*
+GetEnvironmentMetricsInternalServerError internal server error
+
+swagger:response getEnvironmentMetricsInternalServerError
+*/
+type GetEnvironmentMetricsInternalServerError struct {
+}
+
+// NewGetEnvironmentMetricsInternalServerError creates GetEnvironmentMetricsInternalServerError with default headers values
+func NewGetEnvironmentMetricsInternalServerError() *GetEnvironmentMetricsInternalServerError {
+
+	return &GetEnvironmentMetricsInternalServerError{}
+}
+
+// WriteResponse to the client
+func (o *GetEnvironmentMetricsInternalServerError) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) {
+
+	rw.Header().Del(runtime.HeaderContentType) //Remove Content-Type on empty responses
+
+	rw.WriteHeader(500)
+}
diff --git a/rest_server_zrok/operations/metadata/get_environment_metrics_urlbuilder.go b/rest_server_zrok/operations/metadata/get_environment_metrics_urlbuilder.go
index a7787fa2..fc8d98fe 100644
--- a/rest_server_zrok/operations/metadata/get_environment_metrics_urlbuilder.go
+++ b/rest_server_zrok/operations/metadata/get_environment_metrics_urlbuilder.go
@@ -10,11 +10,13 @@ import (
 	"net/url"
 	golangswaggerpaths "path"
 	"strings"
+
+	"github.com/go-openapi/swag"
 )
 
 // GetEnvironmentMetricsURL generates an URL for the get environment metrics operation
 type GetEnvironmentMetricsURL struct {
-	EnvID string
+	EnvID float64
 
 	Duration *string
 
@@ -44,7 +46,7 @@ func (o *GetEnvironmentMetricsURL) Build() (*url.URL, error) {
 
 	var _path = "/metrics/environment/{envId}"
 
-	envID := o.EnvID
+	envID := swag.FormatFloat64(o.EnvID)
 	if envID != "" {
 		_path = strings.Replace(_path, "{envId}", envID, -1)
 	} else {
diff --git a/rest_server_zrok/operations/metadata/get_share_metrics_responses.go b/rest_server_zrok/operations/metadata/get_share_metrics_responses.go
index 9f08dc90..5259fa64 100644
--- a/rest_server_zrok/operations/metadata/get_share_metrics_responses.go
+++ b/rest_server_zrok/operations/metadata/get_share_metrics_responses.go
@@ -58,6 +58,31 @@ func (o *GetShareMetricsOK) WriteResponse(rw http.ResponseWriter, producer runti
 	}
 }
 
+// GetShareMetricsBadRequestCode is the HTTP code returned for type GetShareMetricsBadRequest
+const GetShareMetricsBadRequestCode int = 400
+
+/*
+GetShareMetricsBadRequest bad request
+
+swagger:response getShareMetricsBadRequest
+*/
+type GetShareMetricsBadRequest struct {
+}
+
+// NewGetShareMetricsBadRequest creates GetShareMetricsBadRequest with default headers values
+func NewGetShareMetricsBadRequest() *GetShareMetricsBadRequest {
+
+	return &GetShareMetricsBadRequest{}
+}
+
+// WriteResponse to the client
+func (o *GetShareMetricsBadRequest) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) {
+
+	rw.Header().Del(runtime.HeaderContentType) //Remove Content-Type on empty responses
+
+	rw.WriteHeader(400)
+}
+
 // GetShareMetricsUnauthorizedCode is the HTTP code returned for type GetShareMetricsUnauthorized
 const GetShareMetricsUnauthorizedCode int = 401
 
@@ -82,3 +107,28 @@ func (o *GetShareMetricsUnauthorized) WriteResponse(rw http.ResponseWriter, prod
 
 	rw.WriteHeader(401)
 }
+
+// GetShareMetricsInternalServerErrorCode is the HTTP code returned for type GetShareMetricsInternalServerError
+const GetShareMetricsInternalServerErrorCode int = 500
+
+/*
+GetShareMetricsInternalServerError internal server error
+
+swagger:response getShareMetricsInternalServerError
+*/
+type GetShareMetricsInternalServerError struct {
+}
+
+// NewGetShareMetricsInternalServerError creates GetShareMetricsInternalServerError with default headers values
+func NewGetShareMetricsInternalServerError() *GetShareMetricsInternalServerError {
+
+	return &GetShareMetricsInternalServerError{}
+}
+
+// WriteResponse to the client
+func (o *GetShareMetricsInternalServerError) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) {
+
+	rw.Header().Del(runtime.HeaderContentType) //Remove Content-Type on empty responses
+
+	rw.WriteHeader(500)
+}
diff --git a/specs/zrok.yml b/specs/zrok.yml
index fe129fc8..a83947ab 100644
--- a/specs/zrok.yml
+++ b/specs/zrok.yml
@@ -425,7 +425,7 @@ paths:
       parameters:
         - name: envId
           in: path
-          type: string
+          type: number
           required: true
         - name: duration
           in: query
@@ -435,8 +435,13 @@ paths:
           description: environment metrics
           schema:
             $ref: "#/definitions/metrics"
+        400:
+          description: bad request
         401:
           description: unauthorized
+        500:
+          description: internal server error
+
 
   /metrics/share/{shrToken}:
     get:
@@ -458,8 +463,13 @@ paths:
           description: share metrics
           schema:
             $ref: "#/definitions/metrics"
+        400:
+          description: bad request
         401:
           description: unauthorized
+        500:
+          description: internal server error
+
 
   /version:
     get:
diff --git a/ui/src/api/metadata.js b/ui/src/api/metadata.js
index 7b8af17a..7a59d7fc 100644
--- a/ui/src/api/metadata.js
+++ b/ui/src/api/metadata.js
@@ -56,7 +56,7 @@ export function getAccountMetrics(options) {
 }
 
 /**
- * @param {string} envId 
+ * @param {number} envId 
  * @param {object} options Optional options
  * @param {string} [options.duration] 
  * @return {Promise<module:types.metrics>} environment metrics

From 3f8c939adb3563defa5796824b7dac28b91ae1cb Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Tue, 9 May 2023 16:47:22 -0400
Subject: [PATCH 12/49] share metrics handler (#319)

---
 controller/metrics.go | 81 ++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 80 insertions(+), 1 deletion(-)

diff --git a/controller/metrics.go b/controller/metrics.go
index 355a651a..2c8dd5d6 100644
--- a/controller/metrics.go
+++ b/controller/metrics.go
@@ -77,7 +77,7 @@ type getEnvironmentMetricsHandler struct {
 	queryApi api.QueryAPI
 }
 
-func newGetEnvironmentMetricsHAndler(cfg *metrics.InfluxConfig) *getEnvironmentMetricsHandler {
+func newGetEnvironmentMetricsHandler(cfg *metrics.InfluxConfig) *getEnvironmentMetricsHandler {
 	idb := influxdb2.NewClient(cfg.Url, cfg.Token)
 	queryApi := idb.QueryAPI(cfg.Org)
 	return &getEnvironmentMetricsHandler{
@@ -146,6 +146,85 @@ func (h *getEnvironmentMetricsHandler) Handle(params metadata.GetEnvironmentMetr
 	return metadata.NewGetEnvironmentMetricsOK().WithPayload(response)
 }
 
+type getShareMetricsHandler struct {
+	cfg      *metrics.InfluxConfig
+	idb      influxdb2.Client
+	queryApi api.QueryAPI
+}
+
+func newGetShareMetricsHandler(cfg *metrics.InfluxConfig) *getShareMetricsHandler {
+	idb := influxdb2.NewClient(cfg.Url, cfg.Token)
+	queryApi := idb.QueryAPI(cfg.Org)
+	return &getShareMetricsHandler{
+		cfg:      cfg,
+		idb:      idb,
+		queryApi: queryApi,
+	}
+}
+
+func (h *getShareMetricsHandler) Handle(params metadata.GetShareMetricsParams, principal *rest_model_zrok.Principal) middleware.Responder {
+	trx, err := str.Begin()
+	if err != nil {
+		logrus.Errorf("error starting transaction: %v", err)
+		return metadata.NewGetEnvironmentMetricsInternalServerError()
+	}
+	defer func() { _ = trx.Rollback() }()
+	shr, err := str.FindShareWithToken(params.ShrToken, trx)
+	if err != nil {
+		logrus.Errorf("error finding share '%v' for '%v': %v", params.ShrToken, principal.Email, err)
+		return metadata.NewGetShareMetricsUnauthorized()
+	}
+	env, err := str.GetEnvironment(shr.EnvironmentId, trx)
+	if err != nil {
+		logrus.Errorf("error finding environment '%d' for '%v': %v", shr.EnvironmentId, principal.Email, err)
+		return metadata.NewGetShareMetricsUnauthorized()
+	}
+	if int64(env.Id) != principal.ID {
+		logrus.Errorf("user '%v' does not own share '%v'", principal.Email, params.ShrToken)
+		return metadata.NewGetShareMetricsUnauthorized()
+	}
+
+	duration := 30 * 24 * time.Hour
+	if params.Duration != nil {
+		v, err := time.ParseDuration(*params.Duration)
+		if err != nil {
+			logrus.Errorf("bad duration '%v' for '%v': %v", params.Duration, principal.Email, err)
+			return metadata.NewGetAccountMetricsBadRequest()
+		}
+		duration = v
+	}
+	slice := duration / 200
+
+	query := fmt.Sprintf("from(bucket: \"%v\")\n", h.cfg.Bucket) +
+		fmt.Sprintf("|> range(start: -%v)\n", duration) +
+		"|> filter(fn: (r) => r[\"_measurement\"] == \"xfer\")\n" +
+		"|> filter(fn: (r) => r[\"_field\"] == \"rx\" or r[\"_field\"] == \"tx\")\n" +
+		"|> filter(fn: (r) => r[\"namespace\"] == \"backend\")\n" +
+		fmt.Sprintf("|> filter(fn: (r) => r[\"share\"] == \"%v\")\n", shr.Token) +
+		fmt.Sprintf("|> aggregateWindow(every: %v, fn: sum, createEmpty: true)", slice)
+
+	rx, tx, timestamps, err := runFluxForRxTxArray(query, h.queryApi)
+	if err != nil {
+		logrus.Errorf("error running account metrics query for '%v': %v", principal.Email, err)
+		return metadata.NewGetAccountMetricsInternalServerError()
+	}
+
+	response := &rest_model_zrok.Metrics{
+		Scope:  "account",
+		ID:     fmt.Sprintf("%d", principal.ID),
+		Period: duration.Seconds(),
+	}
+	for i := 0; i < len(rx) && i < len(tx) && i < len(timestamps); i++ {
+		response.Samples = append(response.Samples, &rest_model_zrok.MetricsSample{
+			Rx:        rx[i],
+			Tx:        tx[i],
+			Timestamp: timestamps[i],
+		})
+	}
+
+	return metadata.NewGetShareMetricsOK().WithPayload(response)
+}
+
 func runFluxForRxTxArray(query string, queryApi api.QueryAPI) (rx, tx, timestamps []float64, err error) {
 	result, err := queryApi.Query(context.Background(), query)
 	if err != nil {

From e7048e40511374a91d285992fd7bfa718dcc541d Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Tue, 9 May 2023 16:48:11 -0400
Subject: [PATCH 13/49] wire in environment and share handlers (#319)

---
 controller/controller.go | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/controller/controller.go b/controller/controller.go
index 34905ed8..706bf16f 100644
--- a/controller/controller.go
+++ b/controller/controller.go
@@ -49,6 +49,8 @@ func Run(inCfg *config.Config) error {
 	api.MetadataConfigurationHandler = newConfigurationHandler(cfg)
 	if cfg.Metrics != nil && cfg.Metrics.Influx != nil {
 		api.MetadataGetAccountMetricsHandler = newGetAccountMetricsHandler(cfg.Metrics.Influx)
+		api.MetadataGetEnvironmentMetricsHandler = newGetEnvironmentMetricsHandler(cfg.Metrics.Influx)
+		api.MetadataGetShareMetricsHandler = newGetShareMetricsHandler(cfg.Metrics.Influx)
 	}
 	api.MetadataGetEnvironmentDetailHandler = newEnvironmentDetailHandler()
 	api.MetadataGetShareDetailHandler = newShareDetailHandler()

From 7d611fda30fdff67d891e0c6a5867a37061bc916 Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Wed, 10 May 2023 13:37:07 -0400
Subject: [PATCH 14/49] less timeslices in metrics output (#321)

---
 controller/metrics.go | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/controller/metrics.go b/controller/metrics.go
index 2c8dd5d6..65a29f19 100644
--- a/controller/metrics.go
+++ b/controller/metrics.go
@@ -39,7 +39,7 @@ func (h *getAccountMetricsHandler) Handle(params metadata.GetAccountMetricsParam
 		}
 		duration = v
 	}
-	slice := duration / 200
+	slice := duration / 50
 
 	query := fmt.Sprintf("from(bucket: \"%v\")\n", h.cfg.Bucket) +
 		fmt.Sprintf("|> range(start: -%v)\n", duration) +
@@ -113,7 +113,7 @@ func (h *getEnvironmentMetricsHandler) Handle(params metadata.GetEnvironmentMetr
 		}
 		duration = v
 	}
-	slice := duration / 200
+	slice := duration / 50
 
 	query := fmt.Sprintf("from(bucket: \"%v\")\n", h.cfg.Bucket) +
 		fmt.Sprintf("|> range(start: -%v)\n", duration) +
@@ -193,7 +193,7 @@ func (h *getShareMetricsHandler) Handle(params metadata.GetShareMetricsParams, p
 		}
 		duration = v
 	}
-	slice := duration / 200
+	slice := duration / 50
 
 	query := fmt.Sprintf("from(bucket: \"%v\")\n", h.cfg.Bucket) +
 		fmt.Sprintf("|> range(start: -%v)\n", duration) +

From b5b3385b46622b6497ae06d1ae86af4806767c88 Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Wed, 10 May 2023 14:17:10 -0400
Subject: [PATCH 15/49] recharts; account metrics (#319, #321)

---
 controller/metrics.go                         |  12 +-
 ui/package-lock.json                          |  28 +++-
 ui/package.json                               |   3 +-
 .../console/detail/account/AccountDetail.js   | 151 +++++++++++++-----
 4 files changed, 141 insertions(+), 53 deletions(-)

diff --git a/controller/metrics.go b/controller/metrics.go
index 65a29f19..c823ec7f 100644
--- a/controller/metrics.go
+++ b/controller/metrics.go
@@ -34,12 +34,12 @@ func (h *getAccountMetricsHandler) Handle(params metadata.GetAccountMetricsParam
 	if params.Duration != nil {
 		v, err := time.ParseDuration(*params.Duration)
 		if err != nil {
-			logrus.Errorf("bad duration '%v' for '%v': %v", params.Duration, principal.Email, err)
+			logrus.Errorf("bad duration '%v' for '%v': %v", *params.Duration, principal.Email, err)
 			return metadata.NewGetAccountMetricsBadRequest()
 		}
 		duration = v
 	}
-	slice := duration / 50
+	slice := duration / 30
 
 	query := fmt.Sprintf("from(bucket: \"%v\")\n", h.cfg.Bucket) +
 		fmt.Sprintf("|> range(start: -%v)\n", duration) +
@@ -108,12 +108,12 @@ func (h *getEnvironmentMetricsHandler) Handle(params metadata.GetEnvironmentMetr
 	if params.Duration != nil {
 		v, err := time.ParseDuration(*params.Duration)
 		if err != nil {
-			logrus.Errorf("bad duration '%v' for '%v': %v", params.Duration, principal.Email, err)
+			logrus.Errorf("bad duration '%v' for '%v': %v", *params.Duration, principal.Email, err)
 			return metadata.NewGetAccountMetricsBadRequest()
 		}
 		duration = v
 	}
-	slice := duration / 50
+	slice := duration / 30
 
 	query := fmt.Sprintf("from(bucket: \"%v\")\n", h.cfg.Bucket) +
 		fmt.Sprintf("|> range(start: -%v)\n", duration) +
@@ -188,12 +188,12 @@ func (h *getShareMetricsHandler) Handle(params metadata.GetShareMetricsParams, p
 	if params.Duration != nil {
 		v, err := time.ParseDuration(*params.Duration)
 		if err != nil {
-			logrus.Errorf("bad duration '%v' for '%v': %v", params.Duration, principal.Email, err)
+			logrus.Errorf("bad duration '%v' for '%v': %v", *params.Duration, principal.Email, err)
 			return metadata.NewGetAccountMetricsBadRequest()
 		}
 		duration = v
 	}
-	slice := duration / 50
+	slice := duration / 30
 
 	query := fmt.Sprintf("from(bucket: \"%v\")\n", h.cfg.Bucket) +
 		fmt.Sprintf("|> range(start: -%v)\n", duration) +
diff --git a/ui/package-lock.json b/ui/package-lock.json
index 845ba995..316469bd 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -17,6 +17,7 @@
         "dagre": "^0.8.5",
         "eslint-config-react-app": "^7.0.1",
         "humanize-duration": "^3.27.3",
+        "moment": "^2.29.4",
         "react": "^18.2.0",
         "react-bootstrap": "^2.7.0",
         "react-data-table-component": "^7.5.2",
@@ -24,7 +25,7 @@
         "react-force-graph": "^1.43.0",
         "react-router-dom": "^6.4.0",
         "react-sizeme": "^3.0.2",
-        "recharts": "^2.5.0",
+        "recharts": "^2.6.1",
         "styled-components": "^5.3.5",
         "svgo": "^3.0.2"
       },
@@ -13651,6 +13652,14 @@
         "mkdirp": "bin/cmd.js"
       }
     },
+    "node_modules/moment": {
+      "version": "2.29.4",
+      "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",
+      "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==",
+      "engines": {
+        "node": "*"
+      }
+    },
     "node_modules/ms": {
       "version": "2.1.2",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@@ -16545,9 +16554,9 @@
       }
     },
     "node_modules/recharts": {
-      "version": "2.5.0",
-      "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.5.0.tgz",
-      "integrity": "sha512-0EQYz3iA18r1Uq8VqGZ4dABW52AKBnio37kJgnztIqprELJXpOEsa0SzkqU1vjAhpCXCv52Dx1hiL9119xsqsQ==",
+      "version": "2.6.1",
+      "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.6.1.tgz",
+      "integrity": "sha512-eGNNqQTSg737HB0tfFkPZbPW8ji7Q8joQM0P2yAEkJkB8CO+LJPgLpx/NUxNHJsxoXvSblMFoy5RSVBYfLU+HA==",
       "dependencies": {
         "classnames": "^2.2.5",
         "eventemitter3": "^4.0.1",
@@ -29808,6 +29817,11 @@
         "minimist": "^1.2.6"
       }
     },
+    "moment": {
+      "version": "2.29.4",
+      "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",
+      "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w=="
+    },
     "ms": {
       "version": "2.1.2",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@@ -31796,9 +31810,9 @@
       }
     },
     "recharts": {
-      "version": "2.5.0",
-      "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.5.0.tgz",
-      "integrity": "sha512-0EQYz3iA18r1Uq8VqGZ4dABW52AKBnio37kJgnztIqprELJXpOEsa0SzkqU1vjAhpCXCv52Dx1hiL9119xsqsQ==",
+      "version": "2.6.1",
+      "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.6.1.tgz",
+      "integrity": "sha512-eGNNqQTSg737HB0tfFkPZbPW8ji7Q8joQM0P2yAEkJkB8CO+LJPgLpx/NUxNHJsxoXvSblMFoy5RSVBYfLU+HA==",
       "requires": {
         "classnames": "^2.2.5",
         "eventemitter3": "^4.0.1",
diff --git a/ui/package.json b/ui/package.json
index 782354c0..a585b11d 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -12,6 +12,7 @@
     "dagre": "^0.8.5",
     "eslint-config-react-app": "^7.0.1",
     "humanize-duration": "^3.27.3",
+    "moment": "^2.29.4",
     "react": "^18.2.0",
     "react-bootstrap": "^2.7.0",
     "react-data-table-component": "^7.5.2",
@@ -19,7 +20,7 @@
     "react-force-graph": "^1.43.0",
     "react-router-dom": "^6.4.0",
     "react-sizeme": "^3.0.2",
-    "recharts": "^2.5.0",
+    "recharts": "^2.6.1",
     "styled-components": "^5.3.5",
     "svgo": "^3.0.2"
   },
diff --git a/ui/src/console/detail/account/AccountDetail.js b/ui/src/console/detail/account/AccountDetail.js
index bd39617d..ec614835 100644
--- a/ui/src/console/detail/account/AccountDetail.js
+++ b/ui/src/console/detail/account/AccountDetail.js
@@ -1,11 +1,12 @@
 import {mdiAccountBox} from "@mdi/js";
 import Icon from "@mdi/react";
 import PropertyTable from "../../PropertyTable";
-import {Tab, Tabs, Tooltip} from "react-bootstrap";
+import {Col, Container, Row, Tab, Tabs, Tooltip} from "react-bootstrap";
 import SecretToggle from "../../SecretToggle";
 import React, {useEffect, useState} from "react";
 import * as metadata from "../../../api/metadata";
-import {Area, AreaChart, CartesianGrid, Line, LineChart, ResponsiveContainer, XAxis, YAxis} from "recharts";
+import { Bar, BarChart, CartesianGrid, ResponsiveContainer, XAxis, YAxis } from "recharts";
+import moment from "moment";
 
 const AccountDetail = (props) => {
     const customProperties = {
@@ -28,14 +29,22 @@ const AccountDetail = (props) => {
 }
 
 const MetricsTab = (props) => {
-    const [metrics, setMetrics] = useState({});
-    const [tx, setTx] = useState(0);
-    const [rx, setRx] = useState(0)
+    const [metrics30, setMetrics30] = useState(buildMetrics([]));
+    const [metrics7, setMetrics7] = useState(buildMetrics([]));
+    const [metrics1, setMetrics1] = useState(buildMetrics([]));
 
     useEffect(() => {
         metadata.getAccountMetrics()
             .then(resp => {
-                setMetrics(resp.data);
+                setMetrics30(buildMetrics(resp.data));
+            });
+        metadata.getAccountMetrics({duration: "168h"})
+            .then(resp => {
+                setMetrics7(buildMetrics(resp.data));
+            });
+        metadata.getAccountMetrics({duration: "24h"})
+            .then(resp => {
+                setMetrics1(buildMetrics(resp.data));
             });
     }, []);
 
@@ -45,50 +54,114 @@ const MetricsTab = (props) => {
             metadata.getAccountMetrics()
                 .then(resp => {
                     if(mounted) {
-                        setMetrics(resp.data);
+                        setMetrics30(buildMetrics(resp.data));
                     }
                 });
-        }, 1000);
+            metadata.getAccountMetrics({duration: "168h"})
+                .then(resp => {
+                    setMetrics7(buildMetrics(resp.data));
+                });
+            metadata.getAccountMetrics({duration: "24h"})
+                .then(resp => {
+                    setMetrics1(buildMetrics(resp.data));
+                });
+        }, 5000);
         return () => {
             mounted = false;
             clearInterval(interval);
         }
     }, []);
 
-    useEffect(() => {
-        let txAccum = 0
-        let rxAccum = 0
-        if(metrics.samples) {
-            metrics.samples.forEach(sample => {
-                txAccum += sample.tx
-                rxAccum += sample.rx
-            })
-        }
-        setTx(txAccum);
-        setRx(rxAccum);
-    }, [metrics])
-
-    console.log(metrics);
-
     return (
-        <div>
-            <div>
-                <h1>RX: {bytesToSize(rx)}, TX: {bytesToSize(tx)}</h1>
-            </div>
-            <ResponsiveContainer width={"100%"} height={300}>
-                <LineChart data={metrics.samples}>
-                    <CartesianGrid strokeDasharay={"3 3"} />
-                    <XAxis dataKey={(v) => new Date(v.timestamp)} />
-                    <YAxis />
-                    <Line type={"linear"} stroke={"red"} dataKey={"rx"} activeDot={{ r: 8 }}/>
-                    <Line type={"linear"} stroke={"green"} dataKey={"tx"} />
-                    <Tooltip />
-                </LineChart>
-            </ResponsiveContainer>
-        </div>
+        <Container>
+            <Row>
+                <Col>
+                    <h3>Last 30 Days:</h3>
+                </Col>
+            </Row>
+            <Row>
+                <Col><p>Received: {bytesToSize(metrics30.rx)}</p></Col>
+                <Col><p>Sent: {bytesToSize(metrics30.tx)}</p></Col>
+            </Row>
+            <Row>
+                <Col>
+                    <ResponsiveContainer width={"100%"} height={150}>
+                        <BarChart data={metrics30.data}>
+                            <CartesianGrid strokeDasharay={"3 3"} />
+                            <XAxis dataKey={(v) => v.timestamp} scale={"time"} tickFormatter={(v) => moment(v).format("MMM DD") } style={{ fontSize: '75%'}}/>
+                            <YAxis tickFormatter={(v) => bytesToSize(v)} style={{ fontSize: '75%' }}/>
+                            <Bar stroke={"#231069"} fill={"#04adef"} dataKey={"rx"} legendType={"circle"}/>
+                            <Bar stroke={"#231069"} fill={"#9BF316"} dataKey={"tx"} />
+                            <Tooltip />
+                        </BarChart>
+                    </ResponsiveContainer>
+                </Col>
+            </Row>
+            <Row>
+                <Col>
+                    <h3>Last 7 Days:</h3>
+                </Col>
+            </Row>
+            <Row>
+                <Col><p>Received: {bytesToSize(metrics7.rx)}</p></Col>
+                <Col><p>Sent: {bytesToSize(metrics7.tx)}</p></Col>
+            </Row>
+            <Row>
+                <Col>
+                    <ResponsiveContainer width={"100%"} height={150}>
+                        <BarChart data={metrics7.data}>
+                            <CartesianGrid strokeDasharay={"3 3"} />
+                            <XAxis dataKey={(v) => v.timestamp} scale={"time"} tickFormatter={(v) => moment(v).format("MMM DD") } style={{ fontSize: '75%'}}/>
+                            <YAxis tickFormatter={(v) => bytesToSize(v)} style={{ fontSize: '75%' }}/>
+                            <Bar stroke={"#231069"} fill={"#04adef"} dataKey={"rx"} legendType={"circle"}/>
+                            <Bar stroke={"#231069"} fill={"#9BF316"} dataKey={"tx"} />
+                            <Tooltip />
+                        </BarChart>
+                    </ResponsiveContainer>
+                </Col>
+            </Row>
+            <Row>
+                <Col>
+                    <h3>Last 24 Hours:</h3>
+                </Col>
+            </Row>
+            <Row>
+                <Col><p>Received: {bytesToSize(metrics1.rx)}</p></Col>
+                <Col><p>Sent: {bytesToSize(metrics1.tx)}</p></Col>
+            </Row>
+            <Row>
+                <Col>
+                    <ResponsiveContainer width={"100%"} height={150}>
+                        <BarChart data={metrics1.data}>
+                            <CartesianGrid strokeDasharay={"3 3"} />
+                            <XAxis dataKey={(v) => v.timestamp} scale={"time"} tickFormatter={(v) => moment(v).format("MMM DD") } style={{ fontSize: '75%'}}/>
+                            <YAxis tickFormatter={(v) => bytesToSize(v)} style={{ fontSize: '75%' }}/>
+                            <Bar stroke={"#231069"} fill={"#04adef"} dataKey={"rx"} legendType={"circle"}/>
+                            <Bar stroke={"#231069"} fill={"#9BF316"} dataKey={"tx"} />
+                            <Tooltip />
+                        </BarChart>
+                    </ResponsiveContainer>
+                </Col>
+            </Row>
+        </Container>
     );
 }
 
+const buildMetrics = (m) => {
+    let metrics = {
+        data: m.samples,
+        rx: 0,
+        tx: 0
+    }
+    if(m.samples) {
+        m.samples.forEach(s => {
+            metrics.rx += s.rx;
+            metrics.tx += s.tx;
+        });
+    }
+    return metrics;
+}
+
 const bytesToSize = (sz) => {
     let absSz = sz;
     if(absSz < 0) {
@@ -105,7 +178,7 @@ const bytesToSize = (sz) => {
         exp++;
     }
 
-    return '' + (sz / div).toFixed(2) + "kMGTPE"[exp];
+    return '' + (sz / div).toFixed(1) + ' ' + "kMGTPE"[exp] + 'B';
 }
 
 export default AccountDetail;
\ No newline at end of file

From 0f6d05f449e36aa88c2bfb5be813c021ebd310f3 Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Wed, 10 May 2023 14:23:32 -0400
Subject: [PATCH 16/49] metrics tab first, active by default (#234)

---
 ui/src/console/detail/account/AccountDetail.js | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/ui/src/console/detail/account/AccountDetail.js b/ui/src/console/detail/account/AccountDetail.js
index ec614835..5b2b3675 100644
--- a/ui/src/console/detail/account/AccountDetail.js
+++ b/ui/src/console/detail/account/AccountDetail.js
@@ -16,13 +16,13 @@ const AccountDetail = (props) => {
     return (
         <div>
             <h2><Icon path={mdiAccountBox} size={2} />{" "}{props.user.email}</h2>
-            <Tabs defaultActiveKey={"detail"}>
-                <Tab eventKey={"detail"} title={"Detail"}>
-                    <PropertyTable object={props.user} custom={customProperties}/>
-                </Tab>
+            <Tabs defaultActiveKey={"metrics"}>
                 <Tab eventKey={"metrics"} title={"Metrics"}>
                     <MetricsTab />
                 </Tab>
+                <Tab eventKey={"detail"} title={"Detail"}>
+                    <PropertyTable object={props.user} custom={customProperties}/>
+                </Tab>
             </Tabs>
         </div>
     );

From 43e6c56ec10fb18454b6a427237e6428ac594287 Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Wed, 10 May 2023 14:51:46 -0400
Subject: [PATCH 17/49] environment metrics wired in and working (#324)

---
 controller/metrics.go                         |   8 +-
 .../get_environment_metrics_parameters.go     |   9 +-
 rest_server_zrok/embedded_spec.go             |   4 +-
 .../get_environment_metrics_parameters.go     |  10 +-
 .../get_environment_metrics_urlbuilder.go     |   6 +-
 specs/zrok.yml                                |   2 +-
 ui/src/api/metadata.js                        |   2 +-
 .../console/detail/account/AccountDetail.js   |  37 +----
 .../detail/environment/EnvironmentDetail.js   |   4 +
 .../console/detail/environment/MetricsTab.js  | 129 ++++++++++++++++++
 ui/src/console/metrics.js                     |  33 +++++
 11 files changed, 182 insertions(+), 62 deletions(-)
 create mode 100644 ui/src/console/detail/environment/MetricsTab.js
 create mode 100644 ui/src/console/metrics.js

diff --git a/controller/metrics.go b/controller/metrics.go
index c823ec7f..d4582002 100644
--- a/controller/metrics.go
+++ b/controller/metrics.go
@@ -94,13 +94,9 @@ func (h *getEnvironmentMetricsHandler) Handle(params metadata.GetEnvironmentMetr
 		return metadata.NewGetEnvironmentMetricsInternalServerError()
 	}
 	defer func() { _ = trx.Rollback() }()
-	env, err := str.GetEnvironment(int(params.EnvID), trx)
+	env, err := str.FindEnvironmentForAccount(params.EnvID, int(principal.ID), trx)
 	if err != nil {
-		logrus.Errorf("error finding environment '%d': %v", int(params.EnvID), err)
-		return metadata.NewGetEnvironmentMetricsUnauthorized()
-	}
-	if int64(env.Id) != principal.ID {
-		logrus.Errorf("unauthorized environemnt '%d' for '%v'", int(params.EnvID), principal.Email)
+		logrus.Errorf("error finding environment '%s' for '%s': %v", params.EnvID, principal.Email, err)
 		return metadata.NewGetEnvironmentMetricsUnauthorized()
 	}
 
diff --git a/rest_client_zrok/metadata/get_environment_metrics_parameters.go b/rest_client_zrok/metadata/get_environment_metrics_parameters.go
index 6bdd8404..be7aae16 100644
--- a/rest_client_zrok/metadata/get_environment_metrics_parameters.go
+++ b/rest_client_zrok/metadata/get_environment_metrics_parameters.go
@@ -14,7 +14,6 @@ import (
 	"github.com/go-openapi/runtime"
 	cr "github.com/go-openapi/runtime/client"
 	"github.com/go-openapi/strfmt"
-	"github.com/go-openapi/swag"
 )
 
 // NewGetEnvironmentMetricsParams creates a new GetEnvironmentMetricsParams object,
@@ -66,7 +65,7 @@ type GetEnvironmentMetricsParams struct {
 	Duration *string
 
 	// EnvID.
-	EnvID float64
+	EnvID string
 
 	timeout    time.Duration
 	Context    context.Context
@@ -133,13 +132,13 @@ func (o *GetEnvironmentMetricsParams) SetDuration(duration *string) {
 }
 
 // WithEnvID adds the envID to the get environment metrics params
-func (o *GetEnvironmentMetricsParams) WithEnvID(envID float64) *GetEnvironmentMetricsParams {
+func (o *GetEnvironmentMetricsParams) WithEnvID(envID string) *GetEnvironmentMetricsParams {
 	o.SetEnvID(envID)
 	return o
 }
 
 // SetEnvID adds the envId to the get environment metrics params
-func (o *GetEnvironmentMetricsParams) SetEnvID(envID float64) {
+func (o *GetEnvironmentMetricsParams) SetEnvID(envID string) {
 	o.EnvID = envID
 }
 
@@ -169,7 +168,7 @@ func (o *GetEnvironmentMetricsParams) WriteToRequest(r runtime.ClientRequest, re
 	}
 
 	// path param envId
-	if err := r.SetPathParam("envId", swag.FormatFloat64(o.EnvID)); err != nil {
+	if err := r.SetPathParam("envId", o.EnvID); err != nil {
 		return err
 	}
 
diff --git a/rest_server_zrok/embedded_spec.go b/rest_server_zrok/embedded_spec.go
index 0b5fcdb8..fc9e8858 100644
--- a/rest_server_zrok/embedded_spec.go
+++ b/rest_server_zrok/embedded_spec.go
@@ -567,7 +567,7 @@ func init() {
         "operationId": "getEnvironmentMetrics",
         "parameters": [
           {
-            "type": "number",
+            "type": "string",
             "name": "envId",
             "in": "path",
             "required": true
@@ -2003,7 +2003,7 @@ func init() {
         "operationId": "getEnvironmentMetrics",
         "parameters": [
           {
-            "type": "number",
+            "type": "string",
             "name": "envId",
             "in": "path",
             "required": true
diff --git a/rest_server_zrok/operations/metadata/get_environment_metrics_parameters.go b/rest_server_zrok/operations/metadata/get_environment_metrics_parameters.go
index 9b6a9423..6ea94376 100644
--- a/rest_server_zrok/operations/metadata/get_environment_metrics_parameters.go
+++ b/rest_server_zrok/operations/metadata/get_environment_metrics_parameters.go
@@ -12,7 +12,6 @@ import (
 	"github.com/go-openapi/runtime"
 	"github.com/go-openapi/runtime/middleware"
 	"github.com/go-openapi/strfmt"
-	"github.com/go-openapi/swag"
 )
 
 // NewGetEnvironmentMetricsParams creates a new GetEnvironmentMetricsParams object
@@ -40,7 +39,7 @@ type GetEnvironmentMetricsParams struct {
 	  Required: true
 	  In: path
 	*/
-	EnvID float64
+	EnvID string
 }
 
 // BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface
@@ -96,12 +95,7 @@ func (o *GetEnvironmentMetricsParams) bindEnvID(rawData []string, hasKey bool, f
 
 	// Required: true
 	// Parameter is provided by construction from the route
-
-	value, err := swag.ConvertFloat64(raw)
-	if err != nil {
-		return errors.InvalidType("envId", "path", "float64", raw)
-	}
-	o.EnvID = value
+	o.EnvID = raw
 
 	return nil
 }
diff --git a/rest_server_zrok/operations/metadata/get_environment_metrics_urlbuilder.go b/rest_server_zrok/operations/metadata/get_environment_metrics_urlbuilder.go
index fc8d98fe..a7787fa2 100644
--- a/rest_server_zrok/operations/metadata/get_environment_metrics_urlbuilder.go
+++ b/rest_server_zrok/operations/metadata/get_environment_metrics_urlbuilder.go
@@ -10,13 +10,11 @@ import (
 	"net/url"
 	golangswaggerpaths "path"
 	"strings"
-
-	"github.com/go-openapi/swag"
 )
 
 // GetEnvironmentMetricsURL generates an URL for the get environment metrics operation
 type GetEnvironmentMetricsURL struct {
-	EnvID float64
+	EnvID string
 
 	Duration *string
 
@@ -46,7 +44,7 @@ func (o *GetEnvironmentMetricsURL) Build() (*url.URL, error) {
 
 	var _path = "/metrics/environment/{envId}"
 
-	envID := swag.FormatFloat64(o.EnvID)
+	envID := o.EnvID
 	if envID != "" {
 		_path = strings.Replace(_path, "{envId}", envID, -1)
 	} else {
diff --git a/specs/zrok.yml b/specs/zrok.yml
index a83947ab..d83417e0 100644
--- a/specs/zrok.yml
+++ b/specs/zrok.yml
@@ -425,7 +425,7 @@ paths:
       parameters:
         - name: envId
           in: path
-          type: number
+          type: string
           required: true
         - name: duration
           in: query
diff --git a/ui/src/api/metadata.js b/ui/src/api/metadata.js
index 7a59d7fc..7b8af17a 100644
--- a/ui/src/api/metadata.js
+++ b/ui/src/api/metadata.js
@@ -56,7 +56,7 @@ export function getAccountMetrics(options) {
 }
 
 /**
- * @param {number} envId 
+ * @param {string} envId 
  * @param {object} options Optional options
  * @param {string} [options.duration] 
  * @return {Promise<module:types.metrics>} environment metrics
diff --git a/ui/src/console/detail/account/AccountDetail.js b/ui/src/console/detail/account/AccountDetail.js
index 5b2b3675..94b85b8d 100644
--- a/ui/src/console/detail/account/AccountDetail.js
+++ b/ui/src/console/detail/account/AccountDetail.js
@@ -7,6 +7,7 @@ import React, {useEffect, useState} from "react";
 import * as metadata from "../../../api/metadata";
 import { Bar, BarChart, CartesianGrid, ResponsiveContainer, XAxis, YAxis } from "recharts";
 import moment from "moment";
+import {buildMetrics, bytesToSize} from "../../metrics";
 
 const AccountDetail = (props) => {
     const customProperties = {
@@ -28,7 +29,7 @@ const AccountDetail = (props) => {
     );
 }
 
-const MetricsTab = (props) => {
+const MetricsTab = () => {
     const [metrics30, setMetrics30] = useState(buildMetrics([]));
     const [metrics7, setMetrics7] = useState(buildMetrics([]));
     const [metrics1, setMetrics1] = useState(buildMetrics([]));
@@ -147,38 +148,4 @@ const MetricsTab = (props) => {
     );
 }
 
-const buildMetrics = (m) => {
-    let metrics = {
-        data: m.samples,
-        rx: 0,
-        tx: 0
-    }
-    if(m.samples) {
-        m.samples.forEach(s => {
-            metrics.rx += s.rx;
-            metrics.tx += s.tx;
-        });
-    }
-    return metrics;
-}
-
-const bytesToSize = (sz) => {
-    let absSz = sz;
-    if(absSz < 0) {
-        absSz *= -1;
-    }
-    const unit = 1000
-    if(absSz < unit) {
-        return '' + absSz + ' B';
-    }
-    let div = unit
-    let exp = 0
-    for(let n = absSz / unit; n >= unit; n /= unit) {
-        div *= unit;
-        exp++;
-    }
-
-    return '' + (sz / div).toFixed(1) + ' ' + "kMGTPE"[exp] + 'B';
-}
-
 export default AccountDetail;
\ No newline at end of file
diff --git a/ui/src/console/detail/environment/EnvironmentDetail.js b/ui/src/console/detail/environment/EnvironmentDetail.js
index af8865e3..e3861f53 100644
--- a/ui/src/console/detail/environment/EnvironmentDetail.js
+++ b/ui/src/console/detail/environment/EnvironmentDetail.js
@@ -6,6 +6,7 @@ import {mdiConsoleNetwork} from "@mdi/js";
 import {getEnvironmentDetail} from "../../../api/metadata";
 import DetailTab from "./DetailTab";
 import ActionsTab from "./ActionsTab";
+import MetricsTab from "./MetricsTab";
 
 const EnvironmentDetail = (props) => {
     const [detail, setDetail] = useState({});
@@ -25,6 +26,9 @@ const EnvironmentDetail = (props) => {
                     <Tab eventKey={"shares"} title={"Shares"}>
                         <SharesTab selection={props.selection} />
                     </Tab>
+                    <Tab eventKey={"metrics"} title={"Metrics"}>
+                        <MetricsTab selection={props.selection} />
+                    </Tab>
                     <Tab eventKey={"detail"} title={"Detail"}>
                         <DetailTab environment={detail.environment} />
                     </Tab>
diff --git a/ui/src/console/detail/environment/MetricsTab.js b/ui/src/console/detail/environment/MetricsTab.js
new file mode 100644
index 00000000..8cee2249
--- /dev/null
+++ b/ui/src/console/detail/environment/MetricsTab.js
@@ -0,0 +1,129 @@
+import React, {useEffect, useState} from "react";
+import {buildMetrics, bytesToSize} from "../../metrics";
+import * as metadata from "../../../api/metadata";
+import {Col, Container, Row, Tooltip} from "react-bootstrap";
+import {Bar, BarChart, CartesianGrid, ResponsiveContainer, XAxis, YAxis} from "recharts";
+import moment from "moment/moment";
+
+const MetricsTab = (props) => {
+	const [metrics30, setMetrics30] = useState(buildMetrics([]));
+	const [metrics7, setMetrics7] = useState(buildMetrics([]));
+	const [metrics1, setMetrics1] = useState(buildMetrics([]));
+
+	console.log("selection", props.selection);
+
+	useEffect(() => {
+		metadata.getEnvironmentMetrics(props.selection.id)
+			.then(resp => {
+				setMetrics30(buildMetrics(resp.data));
+			});
+		metadata.getEnvironmentMetrics(props.selection.id, {duration: "168h"})
+			.then(resp => {
+				setMetrics7(buildMetrics(resp.data));
+			});
+		metadata.getEnvironmentMetrics(props.selection.id, {duration: "24h"})
+			.then(resp => {
+				setMetrics1(buildMetrics(resp.data));
+			});
+	}, []);
+
+	useEffect(() => {
+		let mounted = true;
+		let interval = setInterval(() => {
+			metadata.getEnvironmentMetrics(props.selection.id)
+				.then(resp => {
+					if(mounted) {
+						setMetrics30(buildMetrics(resp.data));
+					}
+				});
+			metadata.getEnvironmentMetrics(props.selection.id, {duration: "168h"})
+				.then(resp => {
+					setMetrics7(buildMetrics(resp.data));
+				});
+			metadata.getEnvironmentMetrics(props.selection.id, {duration: "24h"})
+				.then(resp => {
+					setMetrics1(buildMetrics(resp.data));
+				});
+		}, 5000);
+		return () => {
+			mounted = false;
+			clearInterval(interval);
+		}
+	}, []);
+
+	return (
+		<Container>
+			<Row>
+				<Col>
+					<h3>Last 30 Days:</h3>
+				</Col>
+			</Row>
+			<Row>
+				<Col><p>Received: {bytesToSize(metrics30.rx)}</p></Col>
+				<Col><p>Sent: {bytesToSize(metrics30.tx)}</p></Col>
+			</Row>
+			<Row>
+				<Col>
+					<ResponsiveContainer width={"100%"} height={150}>
+						<BarChart data={metrics30.data}>
+							<CartesianGrid strokeDasharay={"3 3"} />
+							<XAxis dataKey={(v) => v.timestamp} scale={"time"} tickFormatter={(v) => moment(v).format("MMM DD") } style={{ fontSize: '75%'}}/>
+							<YAxis tickFormatter={(v) => bytesToSize(v)} style={{ fontSize: '75%' }}/>
+							<Bar stroke={"#231069"} fill={"#04adef"} dataKey={"rx"} legendType={"circle"}/>
+							<Bar stroke={"#231069"} fill={"#9BF316"} dataKey={"tx"} />
+							<Tooltip />
+						</BarChart>
+					</ResponsiveContainer>
+				</Col>
+			</Row>
+			<Row>
+				<Col>
+					<h3>Last 7 Days:</h3>
+				</Col>
+			</Row>
+			<Row>
+				<Col><p>Received: {bytesToSize(metrics7.rx)}</p></Col>
+				<Col><p>Sent: {bytesToSize(metrics7.tx)}</p></Col>
+			</Row>
+			<Row>
+				<Col>
+					<ResponsiveContainer width={"100%"} height={150}>
+						<BarChart data={metrics7.data}>
+							<CartesianGrid strokeDasharay={"3 3"} />
+							<XAxis dataKey={(v) => v.timestamp} scale={"time"} tickFormatter={(v) => moment(v).format("MMM DD") } style={{ fontSize: '75%'}}/>
+							<YAxis tickFormatter={(v) => bytesToSize(v)} style={{ fontSize: '75%' }}/>
+							<Bar stroke={"#231069"} fill={"#04adef"} dataKey={"rx"} legendType={"circle"}/>
+							<Bar stroke={"#231069"} fill={"#9BF316"} dataKey={"tx"} />
+							<Tooltip />
+						</BarChart>
+					</ResponsiveContainer>
+				</Col>
+			</Row>
+			<Row>
+				<Col>
+					<h3>Last 24 Hours:</h3>
+				</Col>
+			</Row>
+			<Row>
+				<Col><p>Received: {bytesToSize(metrics1.rx)}</p></Col>
+				<Col><p>Sent: {bytesToSize(metrics1.tx)}</p></Col>
+			</Row>
+			<Row>
+				<Col>
+					<ResponsiveContainer width={"100%"} height={150}>
+						<BarChart data={metrics1.data}>
+							<CartesianGrid strokeDasharay={"3 3"} />
+							<XAxis dataKey={(v) => v.timestamp} scale={"time"} tickFormatter={(v) => moment(v).format("MMM DD") } style={{ fontSize: '75%'}}/>
+							<YAxis tickFormatter={(v) => bytesToSize(v)} style={{ fontSize: '75%' }}/>
+							<Bar stroke={"#231069"} fill={"#04adef"} dataKey={"rx"} legendType={"circle"}/>
+							<Bar stroke={"#231069"} fill={"#9BF316"} dataKey={"tx"} />
+							<Tooltip />
+						</BarChart>
+					</ResponsiveContainer>
+				</Col>
+			</Row>
+		</Container>
+	);
+};
+
+export default MetricsTab;
\ No newline at end of file
diff --git a/ui/src/console/metrics.js b/ui/src/console/metrics.js
new file mode 100644
index 00000000..b316a14a
--- /dev/null
+++ b/ui/src/console/metrics.js
@@ -0,0 +1,33 @@
+export const buildMetrics = (m) => {
+    let metrics = {
+        data: m.samples,
+        rx: 0,
+        tx: 0
+    }
+    if(m.samples) {
+        m.samples.forEach(s => {
+            metrics.rx += s.rx;
+            metrics.tx += s.tx;
+        });
+    }
+    return metrics;
+}
+
+export const bytesToSize = (sz) => {
+    let absSz = sz;
+    if(absSz < 0) {
+        absSz *= -1;
+    }
+    const unit = 1000
+    if(absSz < unit) {
+        return '' + absSz + ' B';
+    }
+    let div = unit
+    let exp = 0
+    for(let n = absSz / unit; n >= unit; n /= unit) {
+        div *= unit;
+        exp++;
+    }
+
+    return '' + (sz / div).toFixed(1) + ' ' + "kMGTPE"[exp] + 'B';
+}
\ No newline at end of file

From c193482171f7d9a0694ea9fe2bc4c415ed92e912 Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Wed, 10 May 2023 14:59:26 -0400
Subject: [PATCH 18/49] better slice size management? (#324)

---
 controller/metrics.go | 19 ++++++++++++++++---
 1 file changed, 16 insertions(+), 3 deletions(-)

diff --git a/controller/metrics.go b/controller/metrics.go
index d4582002..0dda77b0 100644
--- a/controller/metrics.go
+++ b/controller/metrics.go
@@ -39,7 +39,7 @@ func (h *getAccountMetricsHandler) Handle(params metadata.GetAccountMetricsParam
 		}
 		duration = v
 	}
-	slice := duration / 30
+	slice := sliceSize(duration)
 
 	query := fmt.Sprintf("from(bucket: \"%v\")\n", h.cfg.Bucket) +
 		fmt.Sprintf("|> range(start: -%v)\n", duration) +
@@ -109,7 +109,7 @@ func (h *getEnvironmentMetricsHandler) Handle(params metadata.GetEnvironmentMetr
 		}
 		duration = v
 	}
-	slice := duration / 30
+	slice := sliceSize(duration)
 
 	query := fmt.Sprintf("from(bucket: \"%v\")\n", h.cfg.Bucket) +
 		fmt.Sprintf("|> range(start: -%v)\n", duration) +
@@ -189,7 +189,7 @@ func (h *getShareMetricsHandler) Handle(params metadata.GetShareMetricsParams, p
 		}
 		duration = v
 	}
-	slice := duration / 30
+	slice := sliceSize(duration)
 
 	query := fmt.Sprintf("from(bucket: \"%v\")\n", h.cfg.Bucket) +
 		fmt.Sprintf("|> range(start: -%v)\n", duration) +
@@ -239,3 +239,16 @@ func runFluxForRxTxArray(query string, queryApi api.QueryAPI) (rx, tx, timestamp
 	}
 	return rx, tx, timestamps, nil
 }
+
+func sliceSize(duration time.Duration) time.Duration {
+	switch duration {
+	case 30 * 24 * time.Hour:
+		return 24 * time.Hour
+	case 7 * 24 * time.Hour:
+		return 20 * time.Minute
+	case 24 * time.Hour:
+		return 5 * time.Minute
+	default:
+		return duration
+	}
+}

From 945090f3e85583297ef94be53685a67c386a85d1 Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Wed, 10 May 2023 15:15:09 -0400
Subject: [PATCH 19/49] share metrics (#324)

---
 controller/metrics.go                         |   2 +-
 .../console/detail/environment/MetricsTab.js  |   2 -
 ui/src/console/detail/share/MetricsTab.js     | 129 ++++++++++++++++++
 ui/src/console/detail/share/ShareDetail.js    |   6 +-
 ui/src/console/visualizer/graph.js            |   1 -
 5 files changed, 135 insertions(+), 5 deletions(-)
 create mode 100644 ui/src/console/detail/share/MetricsTab.js

diff --git a/controller/metrics.go b/controller/metrics.go
index 0dda77b0..81780e57 100644
--- a/controller/metrics.go
+++ b/controller/metrics.go
@@ -175,7 +175,7 @@ func (h *getShareMetricsHandler) Handle(params metadata.GetShareMetricsParams, p
 		logrus.Errorf("error finding environment '%d' for '%v': %v", shr.EnvironmentId, principal.Email, err)
 		return metadata.NewGetShareMetricsUnauthorized()
 	}
-	if int64(env.Id) != principal.ID {
+	if env.AccountId != nil && int64(*env.AccountId) != principal.ID {
 		logrus.Errorf("user '%v' does not own share '%v'", principal.Email, params.ShrToken)
 		return metadata.NewGetShareMetricsUnauthorized()
 	}
diff --git a/ui/src/console/detail/environment/MetricsTab.js b/ui/src/console/detail/environment/MetricsTab.js
index 8cee2249..8dfae829 100644
--- a/ui/src/console/detail/environment/MetricsTab.js
+++ b/ui/src/console/detail/environment/MetricsTab.js
@@ -10,8 +10,6 @@ const MetricsTab = (props) => {
 	const [metrics7, setMetrics7] = useState(buildMetrics([]));
 	const [metrics1, setMetrics1] = useState(buildMetrics([]));
 
-	console.log("selection", props.selection);
-
 	useEffect(() => {
 		metadata.getEnvironmentMetrics(props.selection.id)
 			.then(resp => {
diff --git a/ui/src/console/detail/share/MetricsTab.js b/ui/src/console/detail/share/MetricsTab.js
new file mode 100644
index 00000000..4f3b6126
--- /dev/null
+++ b/ui/src/console/detail/share/MetricsTab.js
@@ -0,0 +1,129 @@
+import React, {useEffect, useState} from "react";
+import {buildMetrics, bytesToSize} from "../../metrics";
+import * as metadata from "../../../api/metadata";
+import {Col, Container, Row, Tooltip} from "react-bootstrap";
+import {Bar, BarChart, CartesianGrid, ResponsiveContainer, XAxis, YAxis} from "recharts";
+import moment from "moment";
+
+const MetricsTab = (props) => {
+    const [metrics30, setMetrics30] = useState(buildMetrics([]));
+    const [metrics7, setMetrics7] = useState(buildMetrics([]));
+    const [metrics1, setMetrics1] = useState(buildMetrics([]));
+
+    useEffect(() => {
+        console.log("token", props.share.token);
+        metadata.getShareMetrics(props.share.token)
+            .then(resp => {
+                setMetrics30(buildMetrics(resp.data));
+            });
+        metadata.getShareMetrics(props.share.token, {duration: "168h"})
+            .then(resp => {
+                setMetrics7(buildMetrics(resp.data));
+            });
+        metadata.getShareMetrics(props.share.token, {duration: "24h"})
+            .then(resp => {
+                setMetrics1(buildMetrics(resp.data));
+            });
+    }, [props.share]);
+
+    useEffect(() => {
+        let mounted = true;
+        let interval = setInterval(() => {
+            console.log("token", props.share.token);
+            metadata.getShareMetrics(props.share.token)
+                .then(resp => {
+                    if(mounted) {
+                        setMetrics30(buildMetrics(resp.data));
+                    }
+                });
+            metadata.getShareMetrics(props.share.token, {duration: "168h"})
+                .then(resp => {
+                    setMetrics7(buildMetrics(resp.data));
+                });
+            metadata.getShareMetrics(props.share.token, {duration: "24h"})
+                .then(resp => {
+                    setMetrics1(buildMetrics(resp.data));
+                });
+        }, 5000);
+        return () => {
+            mounted = false;
+            clearInterval(interval);
+        }
+    }, [props.share]);
+
+    return (
+        <Container>
+            <Row>
+                <Col>
+                    <h3>Last 30 Days:</h3>
+                </Col>
+            </Row>
+            <Row>
+                <Col><p>Received: {bytesToSize(metrics30.rx)}</p></Col>
+                <Col><p>Sent: {bytesToSize(metrics30.tx)}</p></Col>
+            </Row>
+            <Row>
+                <Col>
+                    <ResponsiveContainer width={"100%"} height={150}>
+                        <BarChart data={metrics30.data}>
+                            <CartesianGrid strokeDasharay={"3 3"} />
+                            <XAxis dataKey={(v) => v.timestamp} scale={"time"} tickFormatter={(v) => moment(v).format("MMM DD") } style={{ fontSize: '75%'}}/>
+                            <YAxis tickFormatter={(v) => bytesToSize(v)} style={{ fontSize: '75%' }}/>
+                            <Bar stroke={"#231069"} fill={"#04adef"} dataKey={"rx"} legendType={"circle"}/>
+                            <Bar stroke={"#231069"} fill={"#9BF316"} dataKey={"tx"} />
+                            <Tooltip />
+                        </BarChart>
+                    </ResponsiveContainer>
+                </Col>
+            </Row>
+            <Row>
+                <Col>
+                    <h3>Last 7 Days:</h3>
+                </Col>
+            </Row>
+            <Row>
+                <Col><p>Received: {bytesToSize(metrics7.rx)}</p></Col>
+                <Col><p>Sent: {bytesToSize(metrics7.tx)}</p></Col>
+            </Row>
+            <Row>
+                <Col>
+                    <ResponsiveContainer width={"100%"} height={150}>
+                        <BarChart data={metrics7.data}>
+                            <CartesianGrid strokeDasharay={"3 3"} />
+                            <XAxis dataKey={(v) => v.timestamp} scale={"time"} tickFormatter={(v) => moment(v).format("MMM DD") } style={{ fontSize: '75%'}}/>
+                            <YAxis tickFormatter={(v) => bytesToSize(v)} style={{ fontSize: '75%' }}/>
+                            <Bar stroke={"#231069"} fill={"#04adef"} dataKey={"rx"} legendType={"circle"}/>
+                            <Bar stroke={"#231069"} fill={"#9BF316"} dataKey={"tx"} />
+                            <Tooltip />
+                        </BarChart>
+                    </ResponsiveContainer>
+                </Col>
+            </Row>
+            <Row>
+                <Col>
+                    <h3>Last 24 Hours:</h3>
+                </Col>
+            </Row>
+            <Row>
+                <Col><p>Received: {bytesToSize(metrics1.rx)}</p></Col>
+                <Col><p>Sent: {bytesToSize(metrics1.tx)}</p></Col>
+            </Row>
+            <Row>
+                <Col>
+                    <ResponsiveContainer width={"100%"} height={150}>
+                        <BarChart data={metrics1.data}>
+                            <CartesianGrid strokeDasharay={"3 3"} />
+                            <XAxis dataKey={(v) => v.timestamp} scale={"time"} tickFormatter={(v) => moment(v).format("MMM DD") } style={{ fontSize: '75%'}}/>
+                            <YAxis tickFormatter={(v) => bytesToSize(v)} style={{ fontSize: '75%' }}/>
+                            <Bar stroke={"#231069"} fill={"#04adef"} dataKey={"rx"} legendType={"circle"}/>
+                            <Bar stroke={"#231069"} fill={"#9BF316"} dataKey={"tx"} />
+                            <Tooltip />
+                        </BarChart>
+                    </ResponsiveContainer>
+                </Col>
+            </Row>
+        </Container>
+    );
+}
+
+export default MetricsTab;
\ No newline at end of file
diff --git a/ui/src/console/detail/share/ShareDetail.js b/ui/src/console/detail/share/ShareDetail.js
index 31501afc..c841af7f 100644
--- a/ui/src/console/detail/share/ShareDetail.js
+++ b/ui/src/console/detail/share/ShareDetail.js
@@ -7,6 +7,7 @@ import {Tab, Tabs} from "react-bootstrap";
 import ActionsTab from "./ActionsTab";
 import SecretToggle from "../../SecretToggle";
 import {Area, AreaChart, Line, LineChart, ResponsiveContainer, XAxis} from "recharts";
+import MetricsTab from "./MetricsTab";
 
 const ShareDetail = (props) => {
     const [detail, setDetail] = useState({});
@@ -62,7 +63,10 @@ const ShareDetail = (props) => {
         return (
             <div>
                 <h2><Icon path={mdiShareVariant} size={2} />{" "}{detail.backendProxyEndpoint}</h2>
-                <Tabs defaultActiveKey={"detail"}>
+                <Tabs defaultActiveKey={"metrics"}>
+                    <Tab eventKey={"metrics"} title={"Metrics"}>
+                        <MetricsTab share={detail} />
+                    </Tab>
                     <Tab eventKey={"detail"} title={"Detail"}>
                         <PropertyTable object={detail} custom={customProperties} />
                     </Tab>
diff --git a/ui/src/console/visualizer/graph.js b/ui/src/console/visualizer/graph.js
index 7a445081..c10d0ab3 100644
--- a/ui/src/console/visualizer/graph.js
+++ b/ui/src/console/visualizer/graph.js
@@ -55,7 +55,6 @@ export const mergeGraph = (oldGraph, user, newOverview) => {
                     type: "share",
                     val: 50
                 };
-                console.log('share', shrNode.label);
                 newGraph.nodes.push(shrNode);
                 newGraph.links.push({
                     target: envNode.id,

From 273a680b04ca83fcdac963329442111a6eb32c88 Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Thu, 11 May 2023 11:50:42 -0400
Subject: [PATCH 20/49] reduce metrics view code duplication (#324)

---
 .../console/detail/account/AccountDetail.js   | 79 +-----------------
 .../console/detail/environment/MetricsTab.js  | 78 +----------------
 ui/src/console/detail/share/MetricsTab.js     | 76 +----------------
 ui/src/console/metrics/MetricsView.js         | 83 +++++++++++++++++++
 4 files changed, 92 insertions(+), 224 deletions(-)
 create mode 100644 ui/src/console/metrics/MetricsView.js

diff --git a/ui/src/console/detail/account/AccountDetail.js b/ui/src/console/detail/account/AccountDetail.js
index 94b85b8d..dec934b1 100644
--- a/ui/src/console/detail/account/AccountDetail.js
+++ b/ui/src/console/detail/account/AccountDetail.js
@@ -1,13 +1,12 @@
 import {mdiAccountBox} from "@mdi/js";
 import Icon from "@mdi/react";
 import PropertyTable from "../../PropertyTable";
-import {Col, Container, Row, Tab, Tabs, Tooltip} from "react-bootstrap";
+import {Tab, Tabs} from "react-bootstrap";
 import SecretToggle from "../../SecretToggle";
 import React, {useEffect, useState} from "react";
 import * as metadata from "../../../api/metadata";
-import { Bar, BarChart, CartesianGrid, ResponsiveContainer, XAxis, YAxis } from "recharts";
-import moment from "moment";
-import {buildMetrics, bytesToSize} from "../../metrics";
+import {buildMetrics} from "../../metrics";
+import MetricsView from "../../metrics/MetricsView";
 
 const AccountDetail = (props) => {
     const customProperties = {
@@ -74,77 +73,7 @@ const MetricsTab = () => {
     }, []);
 
     return (
-        <Container>
-            <Row>
-                <Col>
-                    <h3>Last 30 Days:</h3>
-                </Col>
-            </Row>
-            <Row>
-                <Col><p>Received: {bytesToSize(metrics30.rx)}</p></Col>
-                <Col><p>Sent: {bytesToSize(metrics30.tx)}</p></Col>
-            </Row>
-            <Row>
-                <Col>
-                    <ResponsiveContainer width={"100%"} height={150}>
-                        <BarChart data={metrics30.data}>
-                            <CartesianGrid strokeDasharay={"3 3"} />
-                            <XAxis dataKey={(v) => v.timestamp} scale={"time"} tickFormatter={(v) => moment(v).format("MMM DD") } style={{ fontSize: '75%'}}/>
-                            <YAxis tickFormatter={(v) => bytesToSize(v)} style={{ fontSize: '75%' }}/>
-                            <Bar stroke={"#231069"} fill={"#04adef"} dataKey={"rx"} legendType={"circle"}/>
-                            <Bar stroke={"#231069"} fill={"#9BF316"} dataKey={"tx"} />
-                            <Tooltip />
-                        </BarChart>
-                    </ResponsiveContainer>
-                </Col>
-            </Row>
-            <Row>
-                <Col>
-                    <h3>Last 7 Days:</h3>
-                </Col>
-            </Row>
-            <Row>
-                <Col><p>Received: {bytesToSize(metrics7.rx)}</p></Col>
-                <Col><p>Sent: {bytesToSize(metrics7.tx)}</p></Col>
-            </Row>
-            <Row>
-                <Col>
-                    <ResponsiveContainer width={"100%"} height={150}>
-                        <BarChart data={metrics7.data}>
-                            <CartesianGrid strokeDasharay={"3 3"} />
-                            <XAxis dataKey={(v) => v.timestamp} scale={"time"} tickFormatter={(v) => moment(v).format("MMM DD") } style={{ fontSize: '75%'}}/>
-                            <YAxis tickFormatter={(v) => bytesToSize(v)} style={{ fontSize: '75%' }}/>
-                            <Bar stroke={"#231069"} fill={"#04adef"} dataKey={"rx"} legendType={"circle"}/>
-                            <Bar stroke={"#231069"} fill={"#9BF316"} dataKey={"tx"} />
-                            <Tooltip />
-                        </BarChart>
-                    </ResponsiveContainer>
-                </Col>
-            </Row>
-            <Row>
-                <Col>
-                    <h3>Last 24 Hours:</h3>
-                </Col>
-            </Row>
-            <Row>
-                <Col><p>Received: {bytesToSize(metrics1.rx)}</p></Col>
-                <Col><p>Sent: {bytesToSize(metrics1.tx)}</p></Col>
-            </Row>
-            <Row>
-                <Col>
-                    <ResponsiveContainer width={"100%"} height={150}>
-                        <BarChart data={metrics1.data}>
-                            <CartesianGrid strokeDasharay={"3 3"} />
-                            <XAxis dataKey={(v) => v.timestamp} scale={"time"} tickFormatter={(v) => moment(v).format("MMM DD") } style={{ fontSize: '75%'}}/>
-                            <YAxis tickFormatter={(v) => bytesToSize(v)} style={{ fontSize: '75%' }}/>
-                            <Bar stroke={"#231069"} fill={"#04adef"} dataKey={"rx"} legendType={"circle"}/>
-                            <Bar stroke={"#231069"} fill={"#9BF316"} dataKey={"tx"} />
-                            <Tooltip />
-                        </BarChart>
-                    </ResponsiveContainer>
-                </Col>
-            </Row>
-        </Container>
+        <MetricsView metrics30={metrics30} metrics7={metrics7} metrics1={metrics1} />
     );
 }
 
diff --git a/ui/src/console/detail/environment/MetricsTab.js b/ui/src/console/detail/environment/MetricsTab.js
index 8dfae829..23e6c1de 100644
--- a/ui/src/console/detail/environment/MetricsTab.js
+++ b/ui/src/console/detail/environment/MetricsTab.js
@@ -1,9 +1,7 @@
 import React, {useEffect, useState} from "react";
-import {buildMetrics, bytesToSize} from "../../metrics";
+import {buildMetrics} from "../../metrics";
 import * as metadata from "../../../api/metadata";
-import {Col, Container, Row, Tooltip} from "react-bootstrap";
-import {Bar, BarChart, CartesianGrid, ResponsiveContainer, XAxis, YAxis} from "recharts";
-import moment from "moment/moment";
+import MetricsView from "../../metrics/MetricsView";
 
 const MetricsTab = (props) => {
 	const [metrics30, setMetrics30] = useState(buildMetrics([]));
@@ -50,77 +48,7 @@ const MetricsTab = (props) => {
 	}, []);
 
 	return (
-		<Container>
-			<Row>
-				<Col>
-					<h3>Last 30 Days:</h3>
-				</Col>
-			</Row>
-			<Row>
-				<Col><p>Received: {bytesToSize(metrics30.rx)}</p></Col>
-				<Col><p>Sent: {bytesToSize(metrics30.tx)}</p></Col>
-			</Row>
-			<Row>
-				<Col>
-					<ResponsiveContainer width={"100%"} height={150}>
-						<BarChart data={metrics30.data}>
-							<CartesianGrid strokeDasharay={"3 3"} />
-							<XAxis dataKey={(v) => v.timestamp} scale={"time"} tickFormatter={(v) => moment(v).format("MMM DD") } style={{ fontSize: '75%'}}/>
-							<YAxis tickFormatter={(v) => bytesToSize(v)} style={{ fontSize: '75%' }}/>
-							<Bar stroke={"#231069"} fill={"#04adef"} dataKey={"rx"} legendType={"circle"}/>
-							<Bar stroke={"#231069"} fill={"#9BF316"} dataKey={"tx"} />
-							<Tooltip />
-						</BarChart>
-					</ResponsiveContainer>
-				</Col>
-			</Row>
-			<Row>
-				<Col>
-					<h3>Last 7 Days:</h3>
-				</Col>
-			</Row>
-			<Row>
-				<Col><p>Received: {bytesToSize(metrics7.rx)}</p></Col>
-				<Col><p>Sent: {bytesToSize(metrics7.tx)}</p></Col>
-			</Row>
-			<Row>
-				<Col>
-					<ResponsiveContainer width={"100%"} height={150}>
-						<BarChart data={metrics7.data}>
-							<CartesianGrid strokeDasharay={"3 3"} />
-							<XAxis dataKey={(v) => v.timestamp} scale={"time"} tickFormatter={(v) => moment(v).format("MMM DD") } style={{ fontSize: '75%'}}/>
-							<YAxis tickFormatter={(v) => bytesToSize(v)} style={{ fontSize: '75%' }}/>
-							<Bar stroke={"#231069"} fill={"#04adef"} dataKey={"rx"} legendType={"circle"}/>
-							<Bar stroke={"#231069"} fill={"#9BF316"} dataKey={"tx"} />
-							<Tooltip />
-						</BarChart>
-					</ResponsiveContainer>
-				</Col>
-			</Row>
-			<Row>
-				<Col>
-					<h3>Last 24 Hours:</h3>
-				</Col>
-			</Row>
-			<Row>
-				<Col><p>Received: {bytesToSize(metrics1.rx)}</p></Col>
-				<Col><p>Sent: {bytesToSize(metrics1.tx)}</p></Col>
-			</Row>
-			<Row>
-				<Col>
-					<ResponsiveContainer width={"100%"} height={150}>
-						<BarChart data={metrics1.data}>
-							<CartesianGrid strokeDasharay={"3 3"} />
-							<XAxis dataKey={(v) => v.timestamp} scale={"time"} tickFormatter={(v) => moment(v).format("MMM DD") } style={{ fontSize: '75%'}}/>
-							<YAxis tickFormatter={(v) => bytesToSize(v)} style={{ fontSize: '75%' }}/>
-							<Bar stroke={"#231069"} fill={"#04adef"} dataKey={"rx"} legendType={"circle"}/>
-							<Bar stroke={"#231069"} fill={"#9BF316"} dataKey={"tx"} />
-							<Tooltip />
-						</BarChart>
-					</ResponsiveContainer>
-				</Col>
-			</Row>
-		</Container>
+		<MetricsView metrics30={metrics30} metrics7={metrics7} metrics1={metrics1} />
 	);
 };
 
diff --git a/ui/src/console/detail/share/MetricsTab.js b/ui/src/console/detail/share/MetricsTab.js
index 4f3b6126..e152ffbf 100644
--- a/ui/src/console/detail/share/MetricsTab.js
+++ b/ui/src/console/detail/share/MetricsTab.js
@@ -1,9 +1,7 @@
 import React, {useEffect, useState} from "react";
 import {buildMetrics, bytesToSize} from "../../metrics";
 import * as metadata from "../../../api/metadata";
-import {Col, Container, Row, Tooltip} from "react-bootstrap";
-import {Bar, BarChart, CartesianGrid, ResponsiveContainer, XAxis, YAxis} from "recharts";
-import moment from "moment";
+import MetricsView from "../../metrics/MetricsView";
 
 const MetricsTab = (props) => {
     const [metrics30, setMetrics30] = useState(buildMetrics([]));
@@ -52,77 +50,7 @@ const MetricsTab = (props) => {
     }, [props.share]);
 
     return (
-        <Container>
-            <Row>
-                <Col>
-                    <h3>Last 30 Days:</h3>
-                </Col>
-            </Row>
-            <Row>
-                <Col><p>Received: {bytesToSize(metrics30.rx)}</p></Col>
-                <Col><p>Sent: {bytesToSize(metrics30.tx)}</p></Col>
-            </Row>
-            <Row>
-                <Col>
-                    <ResponsiveContainer width={"100%"} height={150}>
-                        <BarChart data={metrics30.data}>
-                            <CartesianGrid strokeDasharay={"3 3"} />
-                            <XAxis dataKey={(v) => v.timestamp} scale={"time"} tickFormatter={(v) => moment(v).format("MMM DD") } style={{ fontSize: '75%'}}/>
-                            <YAxis tickFormatter={(v) => bytesToSize(v)} style={{ fontSize: '75%' }}/>
-                            <Bar stroke={"#231069"} fill={"#04adef"} dataKey={"rx"} legendType={"circle"}/>
-                            <Bar stroke={"#231069"} fill={"#9BF316"} dataKey={"tx"} />
-                            <Tooltip />
-                        </BarChart>
-                    </ResponsiveContainer>
-                </Col>
-            </Row>
-            <Row>
-                <Col>
-                    <h3>Last 7 Days:</h3>
-                </Col>
-            </Row>
-            <Row>
-                <Col><p>Received: {bytesToSize(metrics7.rx)}</p></Col>
-                <Col><p>Sent: {bytesToSize(metrics7.tx)}</p></Col>
-            </Row>
-            <Row>
-                <Col>
-                    <ResponsiveContainer width={"100%"} height={150}>
-                        <BarChart data={metrics7.data}>
-                            <CartesianGrid strokeDasharay={"3 3"} />
-                            <XAxis dataKey={(v) => v.timestamp} scale={"time"} tickFormatter={(v) => moment(v).format("MMM DD") } style={{ fontSize: '75%'}}/>
-                            <YAxis tickFormatter={(v) => bytesToSize(v)} style={{ fontSize: '75%' }}/>
-                            <Bar stroke={"#231069"} fill={"#04adef"} dataKey={"rx"} legendType={"circle"}/>
-                            <Bar stroke={"#231069"} fill={"#9BF316"} dataKey={"tx"} />
-                            <Tooltip />
-                        </BarChart>
-                    </ResponsiveContainer>
-                </Col>
-            </Row>
-            <Row>
-                <Col>
-                    <h3>Last 24 Hours:</h3>
-                </Col>
-            </Row>
-            <Row>
-                <Col><p>Received: {bytesToSize(metrics1.rx)}</p></Col>
-                <Col><p>Sent: {bytesToSize(metrics1.tx)}</p></Col>
-            </Row>
-            <Row>
-                <Col>
-                    <ResponsiveContainer width={"100%"} height={150}>
-                        <BarChart data={metrics1.data}>
-                            <CartesianGrid strokeDasharay={"3 3"} />
-                            <XAxis dataKey={(v) => v.timestamp} scale={"time"} tickFormatter={(v) => moment(v).format("MMM DD") } style={{ fontSize: '75%'}}/>
-                            <YAxis tickFormatter={(v) => bytesToSize(v)} style={{ fontSize: '75%' }}/>
-                            <Bar stroke={"#231069"} fill={"#04adef"} dataKey={"rx"} legendType={"circle"}/>
-                            <Bar stroke={"#231069"} fill={"#9BF316"} dataKey={"tx"} />
-                            <Tooltip />
-                        </BarChart>
-                    </ResponsiveContainer>
-                </Col>
-            </Row>
-        </Container>
+        <MetricsView metrics30={metrics30} metrics7={metrics7} metrics1={metrics1} />
     );
 }
 
diff --git a/ui/src/console/metrics/MetricsView.js b/ui/src/console/metrics/MetricsView.js
new file mode 100644
index 00000000..6454e3c1
--- /dev/null
+++ b/ui/src/console/metrics/MetricsView.js
@@ -0,0 +1,83 @@
+import {Col, Container, Row, Tooltip} from "react-bootstrap";
+import {bytesToSize} from "../metrics";
+import {Bar, BarChart, CartesianGrid, ResponsiveContainer, XAxis, YAxis} from "recharts";
+import moment from "moment/moment";
+import React from "react";
+
+const MetricsView = (props) => {
+    return (
+        <Container>
+            <Row>
+                <Col>
+                    <h3>Last 30 Days:</h3>
+                </Col>
+            </Row>
+            <Row>
+                <Col><p>Received: {bytesToSize(props.metrics30.rx)}</p></Col>
+                <Col><p>Sent: {bytesToSize(props.metrics30.tx)}</p></Col>
+            </Row>
+            <Row>
+                <Col>
+                    <ResponsiveContainer width={"100%"} height={150}>
+                        <BarChart data={props.metrics30.data}>
+                            <CartesianGrid strokeDasharay={"3 3"} />
+                            <XAxis dataKey={(v) => v.timestamp} scale={"time"} tickFormatter={(v) => moment(v).format("MMM DD") } style={{ fontSize: '75%'}}/>
+                            <YAxis tickFormatter={(v) => bytesToSize(v)} style={{ fontSize: '75%' }}/>
+                            <Bar stroke={"#231069"} fill={"#04adef"} dataKey={"rx"} legendType={"circle"}/>
+                            <Bar stroke={"#231069"} fill={"#9BF316"} dataKey={"tx"} />
+                            <Tooltip />
+                        </BarChart>
+                    </ResponsiveContainer>
+                </Col>
+            </Row>
+            <Row>
+                <Col>
+                    <h3>Last 7 Days:</h3>
+                </Col>
+            </Row>
+            <Row>
+                <Col><p>Received: {bytesToSize(props.metrics7.rx)}</p></Col>
+                <Col><p>Sent: {bytesToSize(props.metrics7.tx)}</p></Col>
+            </Row>
+            <Row>
+                <Col>
+                    <ResponsiveContainer width={"100%"} height={150}>
+                        <BarChart data={props.metrics7.data}>
+                            <CartesianGrid strokeDasharay={"3 3"} />
+                            <XAxis dataKey={(v) => v.timestamp} scale={"time"} tickFormatter={(v) => moment(v).format("MMM DD") } style={{ fontSize: '75%'}}/>
+                            <YAxis tickFormatter={(v) => bytesToSize(v)} style={{ fontSize: '75%' }}/>
+                            <Bar stroke={"#231069"} fill={"#04adef"} dataKey={"rx"} legendType={"circle"}/>
+                            <Bar stroke={"#231069"} fill={"#9BF316"} dataKey={"tx"} />
+                            <Tooltip />
+                        </BarChart>
+                    </ResponsiveContainer>
+                </Col>
+            </Row>
+            <Row>
+                <Col>
+                    <h3>Last 24 Hours:</h3>
+                </Col>
+            </Row>
+            <Row>
+                <Col><p>Received: {bytesToSize(props.metrics1.rx)}</p></Col>
+                <Col><p>Sent: {bytesToSize(props.metrics1.tx)}</p></Col>
+            </Row>
+            <Row>
+                <Col>
+                    <ResponsiveContainer width={"100%"} height={150}>
+                        <BarChart data={props.metrics1.data}>
+                            <CartesianGrid strokeDasharay={"3 3"} />
+                            <XAxis dataKey={(v) => v.timestamp} scale={"time"} tickFormatter={(v) => moment(v).format("MMM DD") } style={{ fontSize: '75%'}}/>
+                            <YAxis tickFormatter={(v) => bytesToSize(v)} style={{ fontSize: '75%' }}/>
+                            <Bar stroke={"#231069"} fill={"#04adef"} dataKey={"rx"} legendType={"circle"}/>
+                            <Bar stroke={"#231069"} fill={"#9BF316"} dataKey={"tx"} />
+                            <Tooltip />
+                        </BarChart>
+                    </ResponsiveContainer>
+                </Col>
+            </Row>
+        </Container>
+    );
+}
+
+export default MetricsView;
\ No newline at end of file

From 761f595c337fbb291a48ecbd59bf70a8a61a6d5f Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Thu, 11 May 2023 11:58:09 -0400
Subject: [PATCH 21/49] cleaner metrics component design (#324)

---
 ui/src/console/detail/share/MetricsTab.js |  2 +-
 ui/src/console/metrics/MetricsView.js     | 92 +++++++++--------------
 2 files changed, 37 insertions(+), 57 deletions(-)

diff --git a/ui/src/console/detail/share/MetricsTab.js b/ui/src/console/detail/share/MetricsTab.js
index e152ffbf..bf6f2454 100644
--- a/ui/src/console/detail/share/MetricsTab.js
+++ b/ui/src/console/detail/share/MetricsTab.js
@@ -1,5 +1,5 @@
 import React, {useEffect, useState} from "react";
-import {buildMetrics, bytesToSize} from "../../metrics";
+import {buildMetrics} from "../../metrics";
 import * as metadata from "../../../api/metadata";
 import MetricsView from "../../metrics/MetricsView";
 
diff --git a/ui/src/console/metrics/MetricsView.js b/ui/src/console/metrics/MetricsView.js
index 6454e3c1..9b71b5ac 100644
--- a/ui/src/console/metrics/MetricsView.js
+++ b/ui/src/console/metrics/MetricsView.js
@@ -4,7 +4,7 @@ import {Bar, BarChart, CartesianGrid, ResponsiveContainer, XAxis, YAxis} from "r
 import moment from "moment/moment";
 import React from "react";
 
-const MetricsView = (props) => {
+const MetricsViews = (props) => {
     return (
         <Container>
             <Row>
@@ -12,72 +12,52 @@ const MetricsView = (props) => {
                     <h3>Last 30 Days:</h3>
                 </Col>
             </Row>
-            <Row>
-                <Col><p>Received: {bytesToSize(props.metrics30.rx)}</p></Col>
-                <Col><p>Sent: {bytesToSize(props.metrics30.tx)}</p></Col>
-            </Row>
-            <Row>
-                <Col>
-                    <ResponsiveContainer width={"100%"} height={150}>
-                        <BarChart data={props.metrics30.data}>
-                            <CartesianGrid strokeDasharay={"3 3"} />
-                            <XAxis dataKey={(v) => v.timestamp} scale={"time"} tickFormatter={(v) => moment(v).format("MMM DD") } style={{ fontSize: '75%'}}/>
-                            <YAxis tickFormatter={(v) => bytesToSize(v)} style={{ fontSize: '75%' }}/>
-                            <Bar stroke={"#231069"} fill={"#04adef"} dataKey={"rx"} legendType={"circle"}/>
-                            <Bar stroke={"#231069"} fill={"#9BF316"} dataKey={"tx"} />
-                            <Tooltip />
-                        </BarChart>
-                    </ResponsiveContainer>
-                </Col>
-            </Row>
+            <MetricsSummary metrics={props.metrics30} />
+            <MetricsGraph metrics={props.metrics30} />
             <Row>
                 <Col>
                     <h3>Last 7 Days:</h3>
                 </Col>
             </Row>
-            <Row>
-                <Col><p>Received: {bytesToSize(props.metrics7.rx)}</p></Col>
-                <Col><p>Sent: {bytesToSize(props.metrics7.tx)}</p></Col>
-            </Row>
-            <Row>
-                <Col>
-                    <ResponsiveContainer width={"100%"} height={150}>
-                        <BarChart data={props.metrics7.data}>
-                            <CartesianGrid strokeDasharay={"3 3"} />
-                            <XAxis dataKey={(v) => v.timestamp} scale={"time"} tickFormatter={(v) => moment(v).format("MMM DD") } style={{ fontSize: '75%'}}/>
-                            <YAxis tickFormatter={(v) => bytesToSize(v)} style={{ fontSize: '75%' }}/>
-                            <Bar stroke={"#231069"} fill={"#04adef"} dataKey={"rx"} legendType={"circle"}/>
-                            <Bar stroke={"#231069"} fill={"#9BF316"} dataKey={"tx"} />
-                            <Tooltip />
-                        </BarChart>
-                    </ResponsiveContainer>
-                </Col>
-            </Row>
+            <MetricsSummary metrics={props.metrics7} />
+            <MetricsGraph metrics={props.metrics7} />
             <Row>
                 <Col>
                     <h3>Last 24 Hours:</h3>
                 </Col>
             </Row>
-            <Row>
-                <Col><p>Received: {bytesToSize(props.metrics1.rx)}</p></Col>
-                <Col><p>Sent: {bytesToSize(props.metrics1.tx)}</p></Col>
-            </Row>
-            <Row>
-                <Col>
-                    <ResponsiveContainer width={"100%"} height={150}>
-                        <BarChart data={props.metrics1.data}>
-                            <CartesianGrid strokeDasharay={"3 3"} />
-                            <XAxis dataKey={(v) => v.timestamp} scale={"time"} tickFormatter={(v) => moment(v).format("MMM DD") } style={{ fontSize: '75%'}}/>
-                            <YAxis tickFormatter={(v) => bytesToSize(v)} style={{ fontSize: '75%' }}/>
-                            <Bar stroke={"#231069"} fill={"#04adef"} dataKey={"rx"} legendType={"circle"}/>
-                            <Bar stroke={"#231069"} fill={"#9BF316"} dataKey={"tx"} />
-                            <Tooltip />
-                        </BarChart>
-                    </ResponsiveContainer>
-                </Col>
-            </Row>
+            <MetricsSummary metrics={props.metrics1} />
+            <MetricsGraph metrics={props.metrics1} />
         </Container>
     );
 }
 
-export default MetricsView;
\ No newline at end of file
+const MetricsSummary = (props) => {
+    return (
+        <Row>
+            <Col><p>Received: {bytesToSize(props.metrics.rx)}</p></Col>
+            <Col><p>Sent: {bytesToSize(props.metrics.tx)}</p></Col>
+        </Row>
+    );
+}
+
+const MetricsGraph = (props) => {
+    return (
+        <Row>
+            <Col>
+                <ResponsiveContainer width={"100%"} height={150}>
+                    <BarChart data={props.metrics.data}>
+                        <CartesianGrid strokeDasharay={"3 3"} />
+                        <XAxis dataKey={(v) => v.timestamp} scale={"time"} tickFormatter={(v) => moment(v).format("MMM DD") } style={{ fontSize: '75%'}}/>
+                        <YAxis tickFormatter={(v) => bytesToSize(v)} style={{ fontSize: '75%' }}/>
+                        <Bar stroke={"#231069"} fill={"#04adef"} dataKey={"rx"} />
+                        <Bar stroke={"#231069"} fill={"#9BF316"} dataKey={"tx"} />
+                        <Tooltip />
+                    </BarChart>
+                </ResponsiveContainer>
+            </Col>
+        </Row>
+    );
+}
+
+export default MetricsViews;
\ No newline at end of file

From 4c4f0c30f09f6d3edf351bc2ba65034765b1daf8 Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Thu, 11 May 2023 12:02:01 -0400
Subject: [PATCH 22/49] polish (#324)

---
 ui/src/console/detail/account/AccountDetail.js  | 2 +-
 ui/src/console/detail/environment/MetricsTab.js | 2 +-
 ui/src/console/detail/share/MetricsTab.js       | 2 +-
 ui/src/console/metrics/MetricsView.js           | 2 +-
 ui/src/console/{metrics.js => metrics/util.js}  | 0
 5 files changed, 4 insertions(+), 4 deletions(-)
 rename ui/src/console/{metrics.js => metrics/util.js} (100%)

diff --git a/ui/src/console/detail/account/AccountDetail.js b/ui/src/console/detail/account/AccountDetail.js
index dec934b1..df96c801 100644
--- a/ui/src/console/detail/account/AccountDetail.js
+++ b/ui/src/console/detail/account/AccountDetail.js
@@ -5,7 +5,7 @@ import {Tab, Tabs} from "react-bootstrap";
 import SecretToggle from "../../SecretToggle";
 import React, {useEffect, useState} from "react";
 import * as metadata from "../../../api/metadata";
-import {buildMetrics} from "../../metrics";
+import {buildMetrics} from "../../metrics/util";
 import MetricsView from "../../metrics/MetricsView";
 
 const AccountDetail = (props) => {
diff --git a/ui/src/console/detail/environment/MetricsTab.js b/ui/src/console/detail/environment/MetricsTab.js
index 23e6c1de..66e0463a 100644
--- a/ui/src/console/detail/environment/MetricsTab.js
+++ b/ui/src/console/detail/environment/MetricsTab.js
@@ -1,5 +1,5 @@
 import React, {useEffect, useState} from "react";
-import {buildMetrics} from "../../metrics";
+import {buildMetrics} from "../../metrics/util";
 import * as metadata from "../../../api/metadata";
 import MetricsView from "../../metrics/MetricsView";
 
diff --git a/ui/src/console/detail/share/MetricsTab.js b/ui/src/console/detail/share/MetricsTab.js
index bf6f2454..51143ffe 100644
--- a/ui/src/console/detail/share/MetricsTab.js
+++ b/ui/src/console/detail/share/MetricsTab.js
@@ -1,5 +1,5 @@
 import React, {useEffect, useState} from "react";
-import {buildMetrics} from "../../metrics";
+import {buildMetrics} from "../../metrics/util";
 import * as metadata from "../../../api/metadata";
 import MetricsView from "../../metrics/MetricsView";
 
diff --git a/ui/src/console/metrics/MetricsView.js b/ui/src/console/metrics/MetricsView.js
index 9b71b5ac..95b26f0c 100644
--- a/ui/src/console/metrics/MetricsView.js
+++ b/ui/src/console/metrics/MetricsView.js
@@ -1,5 +1,5 @@
 import {Col, Container, Row, Tooltip} from "react-bootstrap";
-import {bytesToSize} from "../metrics";
+import {bytesToSize} from "./util";
 import {Bar, BarChart, CartesianGrid, ResponsiveContainer, XAxis, YAxis} from "recharts";
 import moment from "moment/moment";
 import React from "react";
diff --git a/ui/src/console/metrics.js b/ui/src/console/metrics/util.js
similarity index 100%
rename from ui/src/console/metrics.js
rename to ui/src/console/metrics/util.js

From bb2b7c3da7e3ff6a3b47159beb934a61a7a5f902 Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Thu, 11 May 2023 15:21:10 -0400
Subject: [PATCH 23/49] new sparkline implementation (#325)

---
 controller/environmentDetail.go               | 11 ++-
 controller/shareDetail.go                     | 11 ++-
 controller/sparkData.go                       | 56 +++++++-------
 rest_model_zrok/share.go                      | 28 +++----
 rest_model_zrok/share_metrics.go              | 50 ++++++++++++-
 rest_model_zrok/share_metrics_sample.go       | 56 ++++++++++++++
 rest_model_zrok/spark_data.go                 | 73 +++++++++++++++++++
 rest_model_zrok/spark_data_sample.go          | 53 ++++++++++++++
 rest_server_zrok/embedded_spec.go             | 58 ++++++++++-----
 specs/zrok.yml                                | 16 +++-
 ui/src/api/types.js                           | 10 ++-
 .../console/detail/environment/SharesTab.js   |  5 +-
 ui/src/console/detail/share/ShareDetail.js    |  7 +-
 13 files changed, 358 insertions(+), 76 deletions(-)
 create mode 100644 rest_model_zrok/share_metrics_sample.go
 create mode 100644 rest_model_zrok/spark_data.go
 create mode 100644 rest_model_zrok/spark_data_sample.go

diff --git a/controller/environmentDetail.go b/controller/environmentDetail.go
index 145947c4..604eea41 100644
--- a/controller/environmentDetail.go
+++ b/controller/environmentDetail.go
@@ -40,9 +40,10 @@ func (h *environmentDetailHandler) Handle(params metadata.GetEnvironmentDetailPa
 		logrus.Errorf("error finding shares for environment '%v' for user '%v': %v", senv.ZId, principal.Email, err)
 		return metadata.NewGetEnvironmentDetailInternalServerError()
 	}
-	var sparkData map[string][]int64
+	sparkRx := make(map[string][]int64)
+	sparkTx := make(map[string][]int64)
 	if cfg.Metrics != nil && cfg.Metrics.Influx != nil {
-		sparkData, err = sparkDataForShares(shrs)
+		sparkRx, sparkTx, err = sparkDataForShares(shrs)
 		if err != nil {
 			logrus.Errorf("error querying spark data for shares for user '%v': %v", principal.Email, err)
 		}
@@ -62,6 +63,10 @@ func (h *environmentDetailHandler) Handle(params metadata.GetEnvironmentDetailPa
 		if shr.BackendProxyEndpoint != nil {
 			beProxyEndpoint = *shr.BackendProxyEndpoint
 		}
+		var sparkData []*rest_model_zrok.SparkDataSample
+		for i := 0; i < len(sparkRx[shr.Token]) && i < len(sparkTx[shr.Token]); i++ {
+			sparkData = append(sparkData, &rest_model_zrok.SparkDataSample{Rx: float64(sparkRx[shr.Token][i]), Tx: float64(sparkTx[shr.Token][i])})
+		}
 		es.Shares = append(es.Shares, &rest_model_zrok.Share{
 			Token:                shr.Token,
 			ZID:                  shr.ZId,
@@ -71,7 +76,7 @@ func (h *environmentDetailHandler) Handle(params metadata.GetEnvironmentDetailPa
 			FrontendEndpoint:     feEndpoint,
 			BackendProxyEndpoint: beProxyEndpoint,
 			Reserved:             shr.Reserved,
-			Metrics:              sparkData[shr.Token],
+			SparkData:            sparkData,
 			CreatedAt:            shr.CreatedAt.UnixMilli(),
 			UpdatedAt:            shr.UpdatedAt.UnixMilli(),
 		})
diff --git a/controller/shareDetail.go b/controller/shareDetail.go
index cc018d1f..43b0020b 100644
--- a/controller/shareDetail.go
+++ b/controller/shareDetail.go
@@ -42,9 +42,10 @@ func (h *shareDetailHandler) Handle(params metadata.GetShareDetailParams, princi
 		logrus.Errorf("environment not matched for share '%v' for account '%v'", params.ShrToken, principal.Email)
 		return metadata.NewGetShareDetailNotFound()
 	}
-	var sparkData map[string][]int64
+	sparkRx := make(map[string][]int64)
+	sparkTx := make(map[string][]int64)
 	if cfg.Metrics != nil && cfg.Metrics.Influx != nil {
-		sparkData, err = sparkDataForShares([]*store.Share{shr})
+		sparkRx, sparkTx, err = sparkDataForShares([]*store.Share{shr})
 		if err != nil {
 			logrus.Errorf("error querying spark data for share: %v", err)
 		}
@@ -63,6 +64,10 @@ func (h *shareDetailHandler) Handle(params metadata.GetShareDetailParams, princi
 	if shr.BackendProxyEndpoint != nil {
 		beProxyEndpoint = *shr.BackendProxyEndpoint
 	}
+	var sparkData []*rest_model_zrok.SparkDataSample
+	for i := 0; i < len(sparkRx[shr.Token]) && i < len(sparkTx[shr.Token]); i++ {
+		sparkData = append(sparkData, &rest_model_zrok.SparkDataSample{Rx: float64(sparkRx[shr.Token][i]), Tx: float64(sparkTx[shr.Token][i])})
+	}
 	return metadata.NewGetShareDetailOK().WithPayload(&rest_model_zrok.Share{
 		Token:                shr.Token,
 		ZID:                  shr.ZId,
@@ -72,7 +77,7 @@ func (h *shareDetailHandler) Handle(params metadata.GetShareDetailParams, princi
 		FrontendEndpoint:     feEndpoint,
 		BackendProxyEndpoint: beProxyEndpoint,
 		Reserved:             shr.Reserved,
-		Metrics:              sparkData[shr.Token],
+		SparkData:            sparkData,
 		CreatedAt:            shr.CreatedAt.UnixMilli(),
 		UpdatedAt:            shr.UpdatedAt.UnixMilli(),
 	})
diff --git a/controller/sparkData.go b/controller/sparkData.go
index 02b8b35d..c68107e9 100644
--- a/controller/sparkData.go
+++ b/controller/sparkData.go
@@ -6,37 +6,43 @@ import (
 	"github.com/openziti/zrok/controller/store"
 )
 
-func sparkDataForShares(shrs []*store.Share) (map[string][]int64, error) {
-	out := make(map[string][]int64)
-
+func sparkDataForShares(shrs []*store.Share) (rx, tx map[string][]int64, err error) {
+	rx = make(map[string][]int64)
+	tx = make(map[string][]int64)
 	if len(shrs) > 0 {
 		qapi := idb.QueryAPI(cfg.Metrics.Influx.Org)
 
-		result, err := qapi.Query(context.Background(), sparkFluxQuery(shrs))
+		query := sparkFluxQuery(shrs, cfg.Metrics.Influx.Bucket)
+		result, err := qapi.Query(context.Background(), query)
 		if err != nil {
-			return nil, err
+			return nil, nil, err
 		}
 
 		for result.Next() {
-			combinedRate := int64(0)
-			readRate := result.Record().ValueByKey("tx")
-			if readRate != nil {
-				combinedRate += readRate.(int64)
-			}
-			writeRate := result.Record().ValueByKey("tx")
-			if writeRate != nil {
-				combinedRate += writeRate.(int64)
-			}
 			shrToken := result.Record().ValueByKey("share").(string)
-			shrMetrics := out[shrToken]
-			shrMetrics = append(shrMetrics, combinedRate)
-			out[shrToken] = shrMetrics
+			switch result.Record().Field() {
+			case "rx":
+				rxV := int64(0)
+				if v, ok := result.Record().Value().(int64); ok {
+					rxV = v
+				}
+				rxData := append(rx[shrToken], rxV)
+				rx[shrToken] = rxData
+
+			case "tx":
+				txV := int64(0)
+				if v, ok := result.Record().Value().(int64); ok {
+					txV = v
+				}
+				txData := append(tx[shrToken], txV)
+				tx[shrToken] = txData
+			}
 		}
 	}
-	return out, nil
+	return rx, tx, nil
 }
 
-func sparkFluxQuery(shrs []*store.Share) string {
+func sparkFluxQuery(shrs []*store.Share, bucket string) string {
 	shrFilter := "|> filter(fn: (r) =>"
 	for i, shr := range shrs {
 		if i > 0 {
@@ -45,14 +51,12 @@ func sparkFluxQuery(shrs []*store.Share) string {
 		shrFilter += fmt.Sprintf(" r[\"share\"] == \"%v\"", shr.Token)
 	}
 	shrFilter += ")"
-	query := "read = from(bucket: \"zrok\")" +
-		"|> range(start: -5m)" +
-		"|> filter(fn: (r) => r[\"_measurement\"] == \"xfer\")" +
-		"|> filter(fn: (r) => r[\"_field\"] == \"rx\" or r[\"_field\"] == \"tx\")" +
+	query := fmt.Sprintf("from(bucket: \"%v\")\n", bucket) +
+		"|> range(start: -5m)\n" +
+		"|> filter(fn: (r) => r[\"_measurement\"] == \"xfer\")\n" +
+		"|> filter(fn: (r) => r[\"_field\"] == \"rx\" or r[\"_field\"] == \"tx\")\n" +
 		"|> filter(fn: (r) => r[\"namespace\"] == \"backend\")" +
 		shrFilter +
-		"|> aggregateWindow(every: 5s, fn: sum, createEmpty: true)\n" +
-		"|> pivot(rowKey:[\"_time\"], columnKey: [\"_field\"], valueColumn: \"_value\")" +
-		"|> yield(name: \"last\")"
+		"|> aggregateWindow(every: 10s, fn: sum, createEmpty: true)\n"
 	return query
 }
diff --git a/rest_model_zrok/share.go b/rest_model_zrok/share.go
index 7156fbb2..16563a1f 100644
--- a/rest_model_zrok/share.go
+++ b/rest_model_zrok/share.go
@@ -33,15 +33,15 @@ type Share struct {
 	// frontend selection
 	FrontendSelection string `json:"frontendSelection,omitempty"`
 
-	// metrics
-	Metrics ShareMetrics `json:"metrics,omitempty"`
-
 	// reserved
 	Reserved bool `json:"reserved,omitempty"`
 
 	// share mode
 	ShareMode string `json:"shareMode,omitempty"`
 
+	// spark data
+	SparkData SparkData `json:"sparkData,omitempty"`
+
 	// token
 	Token string `json:"token,omitempty"`
 
@@ -56,7 +56,7 @@ type Share struct {
 func (m *Share) Validate(formats strfmt.Registry) error {
 	var res []error
 
-	if err := m.validateMetrics(formats); err != nil {
+	if err := m.validateSparkData(formats); err != nil {
 		res = append(res, err)
 	}
 
@@ -66,16 +66,16 @@ func (m *Share) Validate(formats strfmt.Registry) error {
 	return nil
 }
 
-func (m *Share) validateMetrics(formats strfmt.Registry) error {
-	if swag.IsZero(m.Metrics) { // not required
+func (m *Share) validateSparkData(formats strfmt.Registry) error {
+	if swag.IsZero(m.SparkData) { // not required
 		return nil
 	}
 
-	if err := m.Metrics.Validate(formats); err != nil {
+	if err := m.SparkData.Validate(formats); err != nil {
 		if ve, ok := err.(*errors.Validation); ok {
-			return ve.ValidateName("metrics")
+			return ve.ValidateName("sparkData")
 		} else if ce, ok := err.(*errors.CompositeError); ok {
-			return ce.ValidateName("metrics")
+			return ce.ValidateName("sparkData")
 		}
 		return err
 	}
@@ -87,7 +87,7 @@ func (m *Share) validateMetrics(formats strfmt.Registry) error {
 func (m *Share) ContextValidate(ctx context.Context, formats strfmt.Registry) error {
 	var res []error
 
-	if err := m.contextValidateMetrics(ctx, formats); err != nil {
+	if err := m.contextValidateSparkData(ctx, formats); err != nil {
 		res = append(res, err)
 	}
 
@@ -97,13 +97,13 @@ func (m *Share) ContextValidate(ctx context.Context, formats strfmt.Registry) er
 	return nil
 }
 
-func (m *Share) contextValidateMetrics(ctx context.Context, formats strfmt.Registry) error {
+func (m *Share) contextValidateSparkData(ctx context.Context, formats strfmt.Registry) error {
 
-	if err := m.Metrics.ContextValidate(ctx, formats); err != nil {
+	if err := m.SparkData.ContextValidate(ctx, formats); err != nil {
 		if ve, ok := err.(*errors.Validation); ok {
-			return ve.ValidateName("metrics")
+			return ve.ValidateName("sparkData")
 		} else if ce, ok := err.(*errors.CompositeError); ok {
-			return ce.ValidateName("metrics")
+			return ce.ValidateName("sparkData")
 		}
 		return err
 	}
diff --git a/rest_model_zrok/share_metrics.go b/rest_model_zrok/share_metrics.go
index 7fd5aafb..e7a109eb 100644
--- a/rest_model_zrok/share_metrics.go
+++ b/rest_model_zrok/share_metrics.go
@@ -7,21 +7,67 @@ package rest_model_zrok
 
 import (
 	"context"
+	"strconv"
 
+	"github.com/go-openapi/errors"
 	"github.com/go-openapi/strfmt"
+	"github.com/go-openapi/swag"
 )
 
 // ShareMetrics share metrics
 //
 // swagger:model shareMetrics
-type ShareMetrics []int64
+type ShareMetrics []*ShareMetricsSample
 
 // Validate validates this share metrics
 func (m ShareMetrics) Validate(formats strfmt.Registry) error {
+	var res []error
+
+	for i := 0; i < len(m); i++ {
+		if swag.IsZero(m[i]) { // not required
+			continue
+		}
+
+		if m[i] != nil {
+			if err := m[i].Validate(formats); err != nil {
+				if ve, ok := err.(*errors.Validation); ok {
+					return ve.ValidateName(strconv.Itoa(i))
+				} else if ce, ok := err.(*errors.CompositeError); ok {
+					return ce.ValidateName(strconv.Itoa(i))
+				}
+				return err
+			}
+		}
+
+	}
+
+	if len(res) > 0 {
+		return errors.CompositeValidationError(res...)
+	}
 	return nil
 }
 
-// ContextValidate validates this share metrics based on context it is used
+// ContextValidate validate this share metrics based on the context it is used
 func (m ShareMetrics) ContextValidate(ctx context.Context, formats strfmt.Registry) error {
+	var res []error
+
+	for i := 0; i < len(m); i++ {
+
+		if m[i] != nil {
+			if err := m[i].ContextValidate(ctx, formats); err != nil {
+				if ve, ok := err.(*errors.Validation); ok {
+					return ve.ValidateName(strconv.Itoa(i))
+				} else if ce, ok := err.(*errors.CompositeError); ok {
+					return ce.ValidateName(strconv.Itoa(i))
+				}
+				return err
+			}
+		}
+
+	}
+
+	if len(res) > 0 {
+		return errors.CompositeValidationError(res...)
+	}
 	return nil
 }
diff --git a/rest_model_zrok/share_metrics_sample.go b/rest_model_zrok/share_metrics_sample.go
new file mode 100644
index 00000000..bc725aad
--- /dev/null
+++ b/rest_model_zrok/share_metrics_sample.go
@@ -0,0 +1,56 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package rest_model_zrok
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"context"
+
+	"github.com/go-openapi/strfmt"
+	"github.com/go-openapi/swag"
+)
+
+// ShareMetricsSample share metrics sample
+//
+// swagger:model shareMetricsSample
+type ShareMetricsSample struct {
+
+	// rx
+	Rx float64 `json:"rx,omitempty"`
+
+	// timestamp
+	Timestamp float64 `json:"timestamp,omitempty"`
+
+	// tx
+	Tx float64 `json:"tx,omitempty"`
+}
+
+// Validate validates this share metrics sample
+func (m *ShareMetricsSample) Validate(formats strfmt.Registry) error {
+	return nil
+}
+
+// ContextValidate validates this share metrics sample based on context it is used
+func (m *ShareMetricsSample) ContextValidate(ctx context.Context, formats strfmt.Registry) error {
+	return nil
+}
+
+// MarshalBinary interface implementation
+func (m *ShareMetricsSample) MarshalBinary() ([]byte, error) {
+	if m == nil {
+		return nil, nil
+	}
+	return swag.WriteJSON(m)
+}
+
+// UnmarshalBinary interface implementation
+func (m *ShareMetricsSample) UnmarshalBinary(b []byte) error {
+	var res ShareMetricsSample
+	if err := swag.ReadJSON(b, &res); err != nil {
+		return err
+	}
+	*m = res
+	return nil
+}
diff --git a/rest_model_zrok/spark_data.go b/rest_model_zrok/spark_data.go
new file mode 100644
index 00000000..1d0f6635
--- /dev/null
+++ b/rest_model_zrok/spark_data.go
@@ -0,0 +1,73 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package rest_model_zrok
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"context"
+	"strconv"
+
+	"github.com/go-openapi/errors"
+	"github.com/go-openapi/strfmt"
+	"github.com/go-openapi/swag"
+)
+
+// SparkData spark data
+//
+// swagger:model sparkData
+type SparkData []*SparkDataSample
+
+// Validate validates this spark data
+func (m SparkData) Validate(formats strfmt.Registry) error {
+	var res []error
+
+	for i := 0; i < len(m); i++ {
+		if swag.IsZero(m[i]) { // not required
+			continue
+		}
+
+		if m[i] != nil {
+			if err := m[i].Validate(formats); err != nil {
+				if ve, ok := err.(*errors.Validation); ok {
+					return ve.ValidateName(strconv.Itoa(i))
+				} else if ce, ok := err.(*errors.CompositeError); ok {
+					return ce.ValidateName(strconv.Itoa(i))
+				}
+				return err
+			}
+		}
+
+	}
+
+	if len(res) > 0 {
+		return errors.CompositeValidationError(res...)
+	}
+	return nil
+}
+
+// ContextValidate validate this spark data based on the context it is used
+func (m SparkData) ContextValidate(ctx context.Context, formats strfmt.Registry) error {
+	var res []error
+
+	for i := 0; i < len(m); i++ {
+
+		if m[i] != nil {
+			if err := m[i].ContextValidate(ctx, formats); err != nil {
+				if ve, ok := err.(*errors.Validation); ok {
+					return ve.ValidateName(strconv.Itoa(i))
+				} else if ce, ok := err.(*errors.CompositeError); ok {
+					return ce.ValidateName(strconv.Itoa(i))
+				}
+				return err
+			}
+		}
+
+	}
+
+	if len(res) > 0 {
+		return errors.CompositeValidationError(res...)
+	}
+	return nil
+}
diff --git a/rest_model_zrok/spark_data_sample.go b/rest_model_zrok/spark_data_sample.go
new file mode 100644
index 00000000..75ba458d
--- /dev/null
+++ b/rest_model_zrok/spark_data_sample.go
@@ -0,0 +1,53 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package rest_model_zrok
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"context"
+
+	"github.com/go-openapi/strfmt"
+	"github.com/go-openapi/swag"
+)
+
+// SparkDataSample spark data sample
+//
+// swagger:model sparkDataSample
+type SparkDataSample struct {
+
+	// rx
+	Rx float64 `json:"rx,omitempty"`
+
+	// tx
+	Tx float64 `json:"tx,omitempty"`
+}
+
+// Validate validates this spark data sample
+func (m *SparkDataSample) Validate(formats strfmt.Registry) error {
+	return nil
+}
+
+// ContextValidate validates this spark data sample based on context it is used
+func (m *SparkDataSample) ContextValidate(ctx context.Context, formats strfmt.Registry) error {
+	return nil
+}
+
+// MarshalBinary interface implementation
+func (m *SparkDataSample) MarshalBinary() ([]byte, error) {
+	if m == nil {
+		return nil, nil
+	}
+	return swag.WriteJSON(m)
+}
+
+// UnmarshalBinary interface implementation
+func (m *SparkDataSample) UnmarshalBinary(b []byte) error {
+	var res SparkDataSample
+	if err := swag.ReadJSON(b, &res); err != nil {
+		return err
+	}
+	*m = res
+	return nil
+}
diff --git a/rest_server_zrok/embedded_spec.go b/rest_server_zrok/embedded_spec.go
index fc9e8858..30b8845f 100644
--- a/rest_server_zrok/embedded_spec.go
+++ b/rest_server_zrok/embedded_spec.go
@@ -1281,15 +1281,15 @@ func init() {
         "frontendSelection": {
           "type": "string"
         },
-        "metrics": {
-          "$ref": "#/definitions/shareMetrics"
-        },
         "reserved": {
           "type": "boolean"
         },
         "shareMode": {
           "type": "string"
         },
+        "sparkData": {
+          "$ref": "#/definitions/sparkData"
+        },
         "token": {
           "type": "string"
         },
@@ -1301,12 +1301,6 @@ func init() {
         }
       }
     },
-    "shareMetrics": {
-      "type": "array",
-      "items": {
-        "type": "integer"
-      }
-    },
     "shareRequest": {
       "type": "object",
       "properties": {
@@ -1372,6 +1366,23 @@ func init() {
         "$ref": "#/definitions/share"
       }
     },
+    "sparkData": {
+      "type": "array",
+      "items": {
+        "$ref": "#/definitions/sparkDataSample"
+      }
+    },
+    "sparkDataSample": {
+      "type": "object",
+      "properties": {
+        "rx": {
+          "type": "number"
+        },
+        "tx": {
+          "type": "number"
+        }
+      }
+    },
     "unaccessRequest": {
       "type": "object",
       "properties": {
@@ -2717,15 +2728,15 @@ func init() {
         "frontendSelection": {
           "type": "string"
         },
-        "metrics": {
-          "$ref": "#/definitions/shareMetrics"
-        },
         "reserved": {
           "type": "boolean"
         },
         "shareMode": {
           "type": "string"
         },
+        "sparkData": {
+          "$ref": "#/definitions/sparkData"
+        },
         "token": {
           "type": "string"
         },
@@ -2737,12 +2748,6 @@ func init() {
         }
       }
     },
-    "shareMetrics": {
-      "type": "array",
-      "items": {
-        "type": "integer"
-      }
-    },
     "shareRequest": {
       "type": "object",
       "properties": {
@@ -2808,6 +2813,23 @@ func init() {
         "$ref": "#/definitions/share"
       }
     },
+    "sparkData": {
+      "type": "array",
+      "items": {
+        "$ref": "#/definitions/sparkDataSample"
+      }
+    },
+    "sparkDataSample": {
+      "type": "object",
+      "properties": {
+        "rx": {
+          "type": "number"
+        },
+        "tx": {
+          "type": "number"
+        }
+      }
+    },
     "unaccessRequest": {
       "type": "object",
       "properties": {
diff --git a/specs/zrok.yml b/specs/zrok.yml
index d83417e0..a8df51fe 100644
--- a/specs/zrok.yml
+++ b/specs/zrok.yml
@@ -844,8 +844,8 @@ definitions:
         type: string
       reserved:
         type: boolean
-      metrics:
-        $ref: "#/definitions/shareMetrics"
+      sparkData:
+        $ref: "#/definitions/sparkData"
       createdAt:
         type: integer
       updatedAt:
@@ -856,10 +856,18 @@ definitions:
     items:
       $ref: "#/definitions/share"
 
-  shareMetrics:
+  sparkData:
     type: array
     items:
-      type: integer
+      $ref: "#/definitions/sparkDataSample"
+
+  sparkDataSample:
+    type: object
+    properties:
+      rx:
+        type: number
+      tx:
+        type: number
 
   shareRequest:
     type: object
diff --git a/ui/src/api/types.js b/ui/src/api/types.js
index 01c83b83..50548bb4 100644
--- a/ui/src/api/types.js
+++ b/ui/src/api/types.js
@@ -200,11 +200,19 @@
  * @property {string} frontendEndpoint 
  * @property {string} backendProxyEndpoint 
  * @property {boolean} reserved 
- * @property {module:types.shareMetrics} metrics 
+ * @property {module:types.sparkData} sparkData 
  * @property {number} createdAt 
  * @property {number} updatedAt 
  */
 
+/**
+ * @typedef sparkDataSample
+ * @memberof module:types
+ * 
+ * @property {number} rx 
+ * @property {number} tx 
+ */
+
 /**
  * @typedef shareRequest
  * @memberof module:types
diff --git a/ui/src/console/detail/environment/SharesTab.js b/ui/src/console/detail/environment/SharesTab.js
index 6fb01439..5fabe9d8 100644
--- a/ui/src/console/detail/environment/SharesTab.js
+++ b/ui/src/console/detail/environment/SharesTab.js
@@ -45,8 +45,9 @@ const SharesTab = (props) => {
             name: "Activity",
             cell: row => {
                 return <ResponsiveContainer width={"100%"} height={"100%"}>
-                    <AreaChart data={row.metrics}>
-                        <Area type="basis" dataKey={(v) => v} stroke={"#777"} fillOpacity={0.5} fill={"#04adef"} isAnimationActive={false} dot={false} />
+                    <AreaChart data={row.sparkData}>
+                        <Area type={"linear"} dataKey={(v) => v.rx ? v.rx : 0} stroke={"#231069"} fill={"#04adef"} isAnimationActive={false} dot={false} />
+                        <Area type={"linear"} dataKey={(v) => v.tx ? v.tx * -1 : 0} stroke={"#231069"} fill={"#9BF316"} isAnimationActive={false} dot={false} />
                     </AreaChart>
                 </ResponsiveContainer>
             }
diff --git a/ui/src/console/detail/share/ShareDetail.js b/ui/src/console/detail/share/ShareDetail.js
index c841af7f..c82bdaff 100644
--- a/ui/src/console/detail/share/ShareDetail.js
+++ b/ui/src/console/detail/share/ShareDetail.js
@@ -6,7 +6,7 @@ import PropertyTable from "../../PropertyTable";
 import {Tab, Tabs} from "react-bootstrap";
 import ActionsTab from "./ActionsTab";
 import SecretToggle from "../../SecretToggle";
-import {Area, AreaChart, Line, LineChart, ResponsiveContainer, XAxis} from "recharts";
+import {Area, AreaChart, ResponsiveContainer} from "recharts";
 import MetricsTab from "./MetricsTab";
 
 const ShareDetail = (props) => {
@@ -40,10 +40,11 @@ const ShareDetail = (props) => {
     }, [props.selection]);
 
     const customProperties = {
-        metrics: row => (
+        sparkData: row => (
             <ResponsiveContainer width={"100%"} height={"100%"}>
                 <AreaChart data={row.value}>
-                    <Area type="basis" dataKey={(v) => v} stroke={"#777"} fillOpacity={0.5} fill={"#04adef"} isAnimationActive={false} dot={false} />
+                    <Area type={"basis"} dataKey={(v) => v.rx ? v.rx : 0} stroke={"#231069"} fill={"#04adef"} isAnimationActive={false} dot={false} />
+                    <Area type={"basis"} dataKey={(v) => v.tx ? v.tx * -1 : 0} stroke={"#231069"} fill={"#9BF316"} isAnimationActive={false} dot={false} />
                 </AreaChart>
             </ResponsiveContainer>
         ),

From 4341f60ce61e827ee62a9a5feeb40780d36cceb8 Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Thu, 11 May 2023 15:22:21 -0400
Subject: [PATCH 24/49] basis, oops (#325)

---
 ui/src/console/detail/environment/SharesTab.js | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/ui/src/console/detail/environment/SharesTab.js b/ui/src/console/detail/environment/SharesTab.js
index 5fabe9d8..21c9c48a 100644
--- a/ui/src/console/detail/environment/SharesTab.js
+++ b/ui/src/console/detail/environment/SharesTab.js
@@ -46,8 +46,8 @@ const SharesTab = (props) => {
             cell: row => {
                 return <ResponsiveContainer width={"100%"} height={"100%"}>
                     <AreaChart data={row.sparkData}>
-                        <Area type={"linear"} dataKey={(v) => v.rx ? v.rx : 0} stroke={"#231069"} fill={"#04adef"} isAnimationActive={false} dot={false} />
-                        <Area type={"linear"} dataKey={(v) => v.tx ? v.tx * -1 : 0} stroke={"#231069"} fill={"#9BF316"} isAnimationActive={false} dot={false} />
+                        <Area type={"basis"} dataKey={(v) => v.rx ? v.rx : 0} stroke={"#231069"} fill={"#04adef"} isAnimationActive={false} dot={false} />
+                        <Area type={"basis"} dataKey={(v) => v.tx ? v.tx * -1 : 0} stroke={"#231069"} fill={"#9BF316"} isAnimationActive={false} dot={false} />
                     </AreaChart>
                 </ResponsiveContainer>
             }

From 7f4517963bcda6e485fcd342b10e5ac5dce7b349 Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Thu, 11 May 2023 15:25:01 -0400
Subject: [PATCH 25/49] metrics tab; not the default (#324)

---
 ui/src/console/detail/account/AccountDetail.js         | 8 ++++----
 ui/src/console/detail/environment/EnvironmentDetail.js | 6 +++---
 ui/src/console/detail/share/ShareDetail.js             | 8 ++++----
 3 files changed, 11 insertions(+), 11 deletions(-)

diff --git a/ui/src/console/detail/account/AccountDetail.js b/ui/src/console/detail/account/AccountDetail.js
index df96c801..169984ed 100644
--- a/ui/src/console/detail/account/AccountDetail.js
+++ b/ui/src/console/detail/account/AccountDetail.js
@@ -16,13 +16,13 @@ const AccountDetail = (props) => {
     return (
         <div>
             <h2><Icon path={mdiAccountBox} size={2} />{" "}{props.user.email}</h2>
-            <Tabs defaultActiveKey={"metrics"}>
-                <Tab eventKey={"metrics"} title={"Metrics"}>
-                    <MetricsTab />
-                </Tab>
+            <Tabs defaultActiveKey={"detail"}>
                 <Tab eventKey={"detail"} title={"Detail"}>
                     <PropertyTable object={props.user} custom={customProperties}/>
                 </Tab>
+                <Tab eventKey={"metrics"} title={"Metrics"}>
+                    <MetricsTab />
+                </Tab>
             </Tabs>
         </div>
     );
diff --git a/ui/src/console/detail/environment/EnvironmentDetail.js b/ui/src/console/detail/environment/EnvironmentDetail.js
index e3861f53..25526520 100644
--- a/ui/src/console/detail/environment/EnvironmentDetail.js
+++ b/ui/src/console/detail/environment/EnvironmentDetail.js
@@ -26,12 +26,12 @@ const EnvironmentDetail = (props) => {
                     <Tab eventKey={"shares"} title={"Shares"}>
                         <SharesTab selection={props.selection} />
                     </Tab>
-                    <Tab eventKey={"metrics"} title={"Metrics"}>
-                        <MetricsTab selection={props.selection} />
-                    </Tab>
                     <Tab eventKey={"detail"} title={"Detail"}>
                         <DetailTab environment={detail.environment} />
                     </Tab>
+                    <Tab eventKey={"metrics"} title={"Metrics"}>
+                        <MetricsTab selection={props.selection} />
+                    </Tab>
                     <Tab eventKey={"actions"} title={"Actions"}>
                         <ActionsTab environment={detail.environment} />
                     </Tab>
diff --git a/ui/src/console/detail/share/ShareDetail.js b/ui/src/console/detail/share/ShareDetail.js
index c82bdaff..743418a6 100644
--- a/ui/src/console/detail/share/ShareDetail.js
+++ b/ui/src/console/detail/share/ShareDetail.js
@@ -64,13 +64,13 @@ const ShareDetail = (props) => {
         return (
             <div>
                 <h2><Icon path={mdiShareVariant} size={2} />{" "}{detail.backendProxyEndpoint}</h2>
-                <Tabs defaultActiveKey={"metrics"}>
-                    <Tab eventKey={"metrics"} title={"Metrics"}>
-                        <MetricsTab share={detail} />
-                    </Tab>
+                <Tabs defaultActiveKey={"detail"}>
                     <Tab eventKey={"detail"} title={"Detail"}>
                         <PropertyTable object={detail} custom={customProperties} />
                     </Tab>
+                    <Tab eventKey={"metrics"} title={"Metrics"}>
+                        <MetricsTab share={detail} />
+                    </Tab>
                     <Tab eventKey={"actions"} title={"Actions"}>
                         <ActionsTab share={detail} />
                     </Tab>

From d0a565d76985d5af483a70d8b53fb307db765740 Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Thu, 11 May 2023 15:38:18 -0400
Subject: [PATCH 26/49] better environment/shares tab responsive sizing (#234)

---
 ui/src/console/detail/environment/SharesTab.js | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/ui/src/console/detail/environment/SharesTab.js b/ui/src/console/detail/environment/SharesTab.js
index 21c9c48a..d7293eef 100644
--- a/ui/src/console/detail/environment/SharesTab.js
+++ b/ui/src/console/detail/environment/SharesTab.js
@@ -37,12 +37,14 @@ const SharesTab = (props) => {
         },
         {
             name: "Backend",
+            grow: 0.5,
             selector: row => row.backendProxyEndpoint,
             sortable: true,
-            hide: "md"
+            hide: "lg"
         },
         {
             name: "Activity",
+            grow: 0.5,
             cell: row => {
                 return <ResponsiveContainer width={"100%"} height={"100%"}>
                     <AreaChart data={row.sparkData}>

From d1688c450dc07c24866299817adb904f24e93462 Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Thu, 11 May 2023 15:41:21 -0400
Subject: [PATCH 27/49] 'activity' (#234)

---
 controller/environmentDetail.go               |  2 +-
 controller/shareDetail.go                     |  2 +-
 rest_model_zrok/share.go                      | 28 +++++++++----------
 rest_server_zrok/embedded_spec.go             | 12 ++++----
 specs/zrok.yml                                |  2 +-
 ui/src/api/types.js                           |  2 +-
 .../console/detail/environment/SharesTab.js   |  2 +-
 ui/src/console/detail/share/ShareDetail.js    |  2 +-
 8 files changed, 26 insertions(+), 26 deletions(-)

diff --git a/controller/environmentDetail.go b/controller/environmentDetail.go
index 604eea41..7b31a33d 100644
--- a/controller/environmentDetail.go
+++ b/controller/environmentDetail.go
@@ -76,7 +76,7 @@ func (h *environmentDetailHandler) Handle(params metadata.GetEnvironmentDetailPa
 			FrontendEndpoint:     feEndpoint,
 			BackendProxyEndpoint: beProxyEndpoint,
 			Reserved:             shr.Reserved,
-			SparkData:            sparkData,
+			Activity:             sparkData,
 			CreatedAt:            shr.CreatedAt.UnixMilli(),
 			UpdatedAt:            shr.UpdatedAt.UnixMilli(),
 		})
diff --git a/controller/shareDetail.go b/controller/shareDetail.go
index 43b0020b..096f67f3 100644
--- a/controller/shareDetail.go
+++ b/controller/shareDetail.go
@@ -77,7 +77,7 @@ func (h *shareDetailHandler) Handle(params metadata.GetShareDetailParams, princi
 		FrontendEndpoint:     feEndpoint,
 		BackendProxyEndpoint: beProxyEndpoint,
 		Reserved:             shr.Reserved,
-		SparkData:            sparkData,
+		Activity:             sparkData,
 		CreatedAt:            shr.CreatedAt.UnixMilli(),
 		UpdatedAt:            shr.UpdatedAt.UnixMilli(),
 	})
diff --git a/rest_model_zrok/share.go b/rest_model_zrok/share.go
index 16563a1f..983c11c6 100644
--- a/rest_model_zrok/share.go
+++ b/rest_model_zrok/share.go
@@ -18,6 +18,9 @@ import (
 // swagger:model share
 type Share struct {
 
+	// activity
+	Activity SparkData `json:"activity,omitempty"`
+
 	// backend mode
 	BackendMode string `json:"backendMode,omitempty"`
 
@@ -39,9 +42,6 @@ type Share struct {
 	// share mode
 	ShareMode string `json:"shareMode,omitempty"`
 
-	// spark data
-	SparkData SparkData `json:"sparkData,omitempty"`
-
 	// token
 	Token string `json:"token,omitempty"`
 
@@ -56,7 +56,7 @@ type Share struct {
 func (m *Share) Validate(formats strfmt.Registry) error {
 	var res []error
 
-	if err := m.validateSparkData(formats); err != nil {
+	if err := m.validateActivity(formats); err != nil {
 		res = append(res, err)
 	}
 
@@ -66,16 +66,16 @@ func (m *Share) Validate(formats strfmt.Registry) error {
 	return nil
 }
 
-func (m *Share) validateSparkData(formats strfmt.Registry) error {
-	if swag.IsZero(m.SparkData) { // not required
+func (m *Share) validateActivity(formats strfmt.Registry) error {
+	if swag.IsZero(m.Activity) { // not required
 		return nil
 	}
 
-	if err := m.SparkData.Validate(formats); err != nil {
+	if err := m.Activity.Validate(formats); err != nil {
 		if ve, ok := err.(*errors.Validation); ok {
-			return ve.ValidateName("sparkData")
+			return ve.ValidateName("activity")
 		} else if ce, ok := err.(*errors.CompositeError); ok {
-			return ce.ValidateName("sparkData")
+			return ce.ValidateName("activity")
 		}
 		return err
 	}
@@ -87,7 +87,7 @@ func (m *Share) validateSparkData(formats strfmt.Registry) error {
 func (m *Share) ContextValidate(ctx context.Context, formats strfmt.Registry) error {
 	var res []error
 
-	if err := m.contextValidateSparkData(ctx, formats); err != nil {
+	if err := m.contextValidateActivity(ctx, formats); err != nil {
 		res = append(res, err)
 	}
 
@@ -97,13 +97,13 @@ func (m *Share) ContextValidate(ctx context.Context, formats strfmt.Registry) er
 	return nil
 }
 
-func (m *Share) contextValidateSparkData(ctx context.Context, formats strfmt.Registry) error {
+func (m *Share) contextValidateActivity(ctx context.Context, formats strfmt.Registry) error {
 
-	if err := m.SparkData.ContextValidate(ctx, formats); err != nil {
+	if err := m.Activity.ContextValidate(ctx, formats); err != nil {
 		if ve, ok := err.(*errors.Validation); ok {
-			return ve.ValidateName("sparkData")
+			return ve.ValidateName("activity")
 		} else if ce, ok := err.(*errors.CompositeError); ok {
-			return ce.ValidateName("sparkData")
+			return ce.ValidateName("activity")
 		}
 		return err
 	}
diff --git a/rest_server_zrok/embedded_spec.go b/rest_server_zrok/embedded_spec.go
index 30b8845f..d41988ff 100644
--- a/rest_server_zrok/embedded_spec.go
+++ b/rest_server_zrok/embedded_spec.go
@@ -1266,6 +1266,9 @@ func init() {
     "share": {
       "type": "object",
       "properties": {
+        "activity": {
+          "$ref": "#/definitions/sparkData"
+        },
         "backendMode": {
           "type": "string"
         },
@@ -1287,9 +1290,6 @@ func init() {
         "shareMode": {
           "type": "string"
         },
-        "sparkData": {
-          "$ref": "#/definitions/sparkData"
-        },
         "token": {
           "type": "string"
         },
@@ -2713,6 +2713,9 @@ func init() {
     "share": {
       "type": "object",
       "properties": {
+        "activity": {
+          "$ref": "#/definitions/sparkData"
+        },
         "backendMode": {
           "type": "string"
         },
@@ -2734,9 +2737,6 @@ func init() {
         "shareMode": {
           "type": "string"
         },
-        "sparkData": {
-          "$ref": "#/definitions/sparkData"
-        },
         "token": {
           "type": "string"
         },
diff --git a/specs/zrok.yml b/specs/zrok.yml
index a8df51fe..39fe9033 100644
--- a/specs/zrok.yml
+++ b/specs/zrok.yml
@@ -844,7 +844,7 @@ definitions:
         type: string
       reserved:
         type: boolean
-      sparkData:
+      activity:
         $ref: "#/definitions/sparkData"
       createdAt:
         type: integer
diff --git a/ui/src/api/types.js b/ui/src/api/types.js
index 50548bb4..09093d0e 100644
--- a/ui/src/api/types.js
+++ b/ui/src/api/types.js
@@ -200,7 +200,7 @@
  * @property {string} frontendEndpoint 
  * @property {string} backendProxyEndpoint 
  * @property {boolean} reserved 
- * @property {module:types.sparkData} sparkData 
+ * @property {module:types.sparkData} activity 
  * @property {number} createdAt 
  * @property {number} updatedAt 
  */
diff --git a/ui/src/console/detail/environment/SharesTab.js b/ui/src/console/detail/environment/SharesTab.js
index d7293eef..a0926bce 100644
--- a/ui/src/console/detail/environment/SharesTab.js
+++ b/ui/src/console/detail/environment/SharesTab.js
@@ -47,7 +47,7 @@ const SharesTab = (props) => {
             grow: 0.5,
             cell: row => {
                 return <ResponsiveContainer width={"100%"} height={"100%"}>
-                    <AreaChart data={row.sparkData}>
+                    <AreaChart data={row.activity}>
                         <Area type={"basis"} dataKey={(v) => v.rx ? v.rx : 0} stroke={"#231069"} fill={"#04adef"} isAnimationActive={false} dot={false} />
                         <Area type={"basis"} dataKey={(v) => v.tx ? v.tx * -1 : 0} stroke={"#231069"} fill={"#9BF316"} isAnimationActive={false} dot={false} />
                     </AreaChart>
diff --git a/ui/src/console/detail/share/ShareDetail.js b/ui/src/console/detail/share/ShareDetail.js
index 743418a6..b942cd3e 100644
--- a/ui/src/console/detail/share/ShareDetail.js
+++ b/ui/src/console/detail/share/ShareDetail.js
@@ -40,7 +40,7 @@ const ShareDetail = (props) => {
     }, [props.selection]);
 
     const customProperties = {
-        sparkData: row => (
+        activity: row => (
             <ResponsiveContainer width={"100%"} height={"100%"}>
                 <AreaChart data={row.value}>
                     <Area type={"basis"} dataKey={(v) => v.rx ? v.rx : 0} stroke={"#231069"} fill={"#04adef"} isAnimationActive={false} dot={false} />

From ce4eac8e4c55937d6f1dadfa5b1388a2423dcc8a Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Thu, 11 May 2023 15:56:29 -0400
Subject: [PATCH 28/49] metrics improvements (#234)

---
 controller/metrics.go                 | 25 ++++++++++++++++---------
 ui/src/console/metrics/MetricsView.js |  4 ++--
 ui/src/console/metrics/util.js        |  5 +++--
 3 files changed, 21 insertions(+), 13 deletions(-)

diff --git a/controller/metrics.go b/controller/metrics.go
index 81780e57..f31f4657 100644
--- a/controller/metrics.go
+++ b/controller/metrics.go
@@ -227,14 +227,21 @@ func runFluxForRxTxArray(query string, queryApi api.QueryAPI) (rx, tx, timestamp
 		return nil, nil, nil, err
 	}
 	for result.Next() {
-		if v, ok := result.Record().Value().(int64); ok {
-			switch result.Record().Field() {
-			case "rx":
-				rx = append(rx, float64(v))
-				timestamps = append(timestamps, float64(result.Record().Time().UnixMilli()))
-			case "tx":
-				tx = append(tx, float64(v))
+		switch result.Record().Field() {
+		case "rx":
+			rxV := int64(0)
+			if v, ok := result.Record().Value().(int64); ok {
+				rxV = v
 			}
+			rx = append(rx, float64(rxV))
+			timestamps = append(timestamps, float64(result.Record().Time().UnixMilli()))
+
+		case "tx":
+			txV := int64(0)
+			if v, ok := result.Record().Value().(int64); ok {
+				txV = v
+			}
+			tx = append(tx, float64(txV))
 		}
 	}
 	return rx, tx, timestamps, nil
@@ -245,9 +252,9 @@ func sliceSize(duration time.Duration) time.Duration {
 	case 30 * 24 * time.Hour:
 		return 24 * time.Hour
 	case 7 * 24 * time.Hour:
-		return 20 * time.Minute
+		return 4 * time.Hour
 	case 24 * time.Hour:
-		return 5 * time.Minute
+		return 30 * time.Minute
 	default:
 		return duration
 	}
diff --git a/ui/src/console/metrics/MetricsView.js b/ui/src/console/metrics/MetricsView.js
index 95b26f0c..a34b56f2 100644
--- a/ui/src/console/metrics/MetricsView.js
+++ b/ui/src/console/metrics/MetricsView.js
@@ -50,8 +50,8 @@ const MetricsGraph = (props) => {
                         <CartesianGrid strokeDasharay={"3 3"} />
                         <XAxis dataKey={(v) => v.timestamp} scale={"time"} tickFormatter={(v) => moment(v).format("MMM DD") } style={{ fontSize: '75%'}}/>
                         <YAxis tickFormatter={(v) => bytesToSize(v)} style={{ fontSize: '75%' }}/>
-                        <Bar stroke={"#231069"} fill={"#04adef"} dataKey={"rx"} />
-                        <Bar stroke={"#231069"} fill={"#9BF316"} dataKey={"tx"} />
+                        <Bar stroke={"#231069"} fill={"#04adef"} dataKey={(v) => v.rx ? v.rx : 0} />
+                        <Bar stroke={"#231069"} fill={"#9BF316"} dataKey={(v) => v.tx ? v.tx : 0} />
                         <Tooltip />
                     </BarChart>
                 </ResponsiveContainer>
diff --git a/ui/src/console/metrics/util.js b/ui/src/console/metrics/util.js
index b316a14a..5c4e292f 100644
--- a/ui/src/console/metrics/util.js
+++ b/ui/src/console/metrics/util.js
@@ -1,4 +1,5 @@
 export const buildMetrics = (m) => {
+    console.log("build", m);
     let metrics = {
         data: m.samples,
         rx: 0,
@@ -6,8 +7,8 @@ export const buildMetrics = (m) => {
     }
     if(m.samples) {
         m.samples.forEach(s => {
-            metrics.rx += s.rx;
-            metrics.tx += s.tx;
+            metrics.rx += s.rx ? s.rx : 0;
+            metrics.tx += s.tx ? s.tx : 0;
         });
     }
     return metrics;

From 714b0b11d13ec42224d4fbc494759b90645196d2 Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Thu, 11 May 2023 16:18:22 -0400
Subject: [PATCH 29/49] lint

---
 ui/src/console/detail/environment/MetricsTab.js |  4 ++--
 ui/src/console/metrics/MetricsView.js           | 13 ++++++-------
 ui/src/console/visualizer/Network.js            |  2 ++
 3 files changed, 10 insertions(+), 9 deletions(-)

diff --git a/ui/src/console/detail/environment/MetricsTab.js b/ui/src/console/detail/environment/MetricsTab.js
index 66e0463a..37e447a0 100644
--- a/ui/src/console/detail/environment/MetricsTab.js
+++ b/ui/src/console/detail/environment/MetricsTab.js
@@ -21,7 +21,7 @@ const MetricsTab = (props) => {
 			.then(resp => {
 				setMetrics1(buildMetrics(resp.data));
 			});
-	}, []);
+	}, [props.selection.id]);
 
 	useEffect(() => {
 		let mounted = true;
@@ -45,7 +45,7 @@ const MetricsTab = (props) => {
 			mounted = false;
 			clearInterval(interval);
 		}
-	}, []);
+	}, [props.selection.id]);
 
 	return (
 		<MetricsView metrics30={metrics30} metrics7={metrics7} metrics1={metrics1} />
diff --git a/ui/src/console/metrics/MetricsView.js b/ui/src/console/metrics/MetricsView.js
index a34b56f2..5085f878 100644
--- a/ui/src/console/metrics/MetricsView.js
+++ b/ui/src/console/metrics/MetricsView.js
@@ -1,6 +1,6 @@
-import {Col, Container, Row, Tooltip} from "react-bootstrap";
+import {Col, Container, Row} from "react-bootstrap";
 import {bytesToSize} from "./util";
-import {Bar, BarChart, CartesianGrid, ResponsiveContainer, XAxis, YAxis} from "recharts";
+import {Area, AreaChart, CartesianGrid, ResponsiveContainer, XAxis, YAxis} from "recharts";
 import moment from "moment/moment";
 import React from "react";
 
@@ -46,14 +46,13 @@ const MetricsGraph = (props) => {
         <Row>
             <Col>
                 <ResponsiveContainer width={"100%"} height={150}>
-                    <BarChart data={props.metrics.data}>
+                    <AreaChart data={props.metrics.data}>
                         <CartesianGrid strokeDasharay={"3 3"} />
                         <XAxis dataKey={(v) => v.timestamp} scale={"time"} tickFormatter={(v) => moment(v).format("MMM DD") } style={{ fontSize: '75%'}}/>
                         <YAxis tickFormatter={(v) => bytesToSize(v)} style={{ fontSize: '75%' }}/>
-                        <Bar stroke={"#231069"} fill={"#04adef"} dataKey={(v) => v.rx ? v.rx : 0} />
-                        <Bar stroke={"#231069"} fill={"#9BF316"} dataKey={(v) => v.tx ? v.tx : 0} />
-                        <Tooltip />
-                    </BarChart>
+                        <Area type={"basis"} stroke={"#231069"} fill={"#9BF316"} dataKey={(v) => v.tx ? v.tx : 0} stackId={"1"} />
+                        <Area type={"basis"} stroke={"#231069"} fill={"#04adef"} dataKey={(v) => v.rx ? v.rx : 0} stackId={"1"} />
+                    </AreaChart>
                 </ResponsiveContainer>
             </Col>
         </Row>
diff --git a/ui/src/console/visualizer/Network.js b/ui/src/console/visualizer/Network.js
index c75421c4..8e81ca8d 100644
--- a/ui/src/console/visualizer/Network.js
+++ b/ui/src/console/visualizer/Network.js
@@ -48,6 +48,8 @@ const Network = (props) => {
             case "account":
                 nodeIcon.addPath(accountIcon, xform);
                 break;
+            default:
+                break;
         }
 
         ctx.fill(nodeIcon);

From 6259b62a8a9f7c89a3db2c2fe5f4a526717f96a0 Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Fri, 12 May 2023 11:10:29 -0400
Subject: [PATCH 30/49] separating out tabs in account detail (#327)

---
 .../console/detail/account/AccountDetail.js   | 53 +-----------------
 ui/src/console/detail/account/MetricsTab.js   | 55 +++++++++++++++++++
 2 files changed, 57 insertions(+), 51 deletions(-)
 create mode 100644 ui/src/console/detail/account/MetricsTab.js

diff --git a/ui/src/console/detail/account/AccountDetail.js b/ui/src/console/detail/account/AccountDetail.js
index 169984ed..23b8d679 100644
--- a/ui/src/console/detail/account/AccountDetail.js
+++ b/ui/src/console/detail/account/AccountDetail.js
@@ -3,10 +3,8 @@ import Icon from "@mdi/react";
 import PropertyTable from "../../PropertyTable";
 import {Tab, Tabs} from "react-bootstrap";
 import SecretToggle from "../../SecretToggle";
-import React, {useEffect, useState} from "react";
-import * as metadata from "../../../api/metadata";
-import {buildMetrics} from "../../metrics/util";
-import MetricsView from "../../metrics/MetricsView";
+import React from "react";
+import MetricsTab from "./MetricsTab";
 
 const AccountDetail = (props) => {
     const customProperties = {
@@ -28,53 +26,6 @@ const AccountDetail = (props) => {
     );
 }
 
-const MetricsTab = () => {
-    const [metrics30, setMetrics30] = useState(buildMetrics([]));
-    const [metrics7, setMetrics7] = useState(buildMetrics([]));
-    const [metrics1, setMetrics1] = useState(buildMetrics([]));
 
-    useEffect(() => {
-        metadata.getAccountMetrics()
-            .then(resp => {
-                setMetrics30(buildMetrics(resp.data));
-            });
-        metadata.getAccountMetrics({duration: "168h"})
-            .then(resp => {
-                setMetrics7(buildMetrics(resp.data));
-            });
-        metadata.getAccountMetrics({duration: "24h"})
-            .then(resp => {
-                setMetrics1(buildMetrics(resp.data));
-            });
-    }, []);
-
-    useEffect(() => {
-        let mounted = true;
-        let interval = setInterval(() => {
-            metadata.getAccountMetrics()
-                .then(resp => {
-                    if(mounted) {
-                        setMetrics30(buildMetrics(resp.data));
-                    }
-                });
-            metadata.getAccountMetrics({duration: "168h"})
-                .then(resp => {
-                    setMetrics7(buildMetrics(resp.data));
-                });
-            metadata.getAccountMetrics({duration: "24h"})
-                .then(resp => {
-                    setMetrics1(buildMetrics(resp.data));
-                });
-        }, 5000);
-        return () => {
-            mounted = false;
-            clearInterval(interval);
-        }
-    }, []);
-
-    return (
-        <MetricsView metrics30={metrics30} metrics7={metrics7} metrics1={metrics1} />
-    );
-}
 
 export default AccountDetail;
\ No newline at end of file
diff --git a/ui/src/console/detail/account/MetricsTab.js b/ui/src/console/detail/account/MetricsTab.js
new file mode 100644
index 00000000..fdcbab7d
--- /dev/null
+++ b/ui/src/console/detail/account/MetricsTab.js
@@ -0,0 +1,55 @@
+import React, {useEffect, useState} from "react";
+import {buildMetrics} from "../../metrics/util";
+import * as metadata from "../../../api/metadata";
+import MetricsView from "../../metrics/MetricsView";
+
+const MetricsTab = () => {
+    const [metrics30, setMetrics30] = useState(buildMetrics([]));
+    const [metrics7, setMetrics7] = useState(buildMetrics([]));
+    const [metrics1, setMetrics1] = useState(buildMetrics([]));
+
+    useEffect(() => {
+        metadata.getAccountMetrics()
+            .then(resp => {
+                setMetrics30(buildMetrics(resp.data));
+            });
+        metadata.getAccountMetrics({duration: "168h"})
+            .then(resp => {
+                setMetrics7(buildMetrics(resp.data));
+            });
+        metadata.getAccountMetrics({duration: "24h"})
+            .then(resp => {
+                setMetrics1(buildMetrics(resp.data));
+            });
+    }, []);
+
+    useEffect(() => {
+        let mounted = true;
+        let interval = setInterval(() => {
+            metadata.getAccountMetrics()
+                .then(resp => {
+                    if(mounted) {
+                        setMetrics30(buildMetrics(resp.data));
+                    }
+                });
+            metadata.getAccountMetrics({duration: "168h"})
+                .then(resp => {
+                    setMetrics7(buildMetrics(resp.data));
+                });
+            metadata.getAccountMetrics({duration: "24h"})
+                .then(resp => {
+                    setMetrics1(buildMetrics(resp.data));
+                });
+        }, 5000);
+        return () => {
+            mounted = false;
+            clearInterval(interval);
+        }
+    }, []);
+
+    return (
+        <MetricsView metrics30={metrics30} metrics7={metrics7} metrics1={metrics1} />
+    );
+}
+
+export default MetricsTab;
\ No newline at end of file

From 2655eaefc01726ffbb747db855a994b7babc4cb2 Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Fri, 12 May 2023 11:57:34 -0400
Subject: [PATCH 31/49] roughed in environment sparklines backend (#327)

---
 controller/accountDetail.go                   |  55 +++++++
 controller/controller.go                      |   1 +
 controller/sparkData.go                       |  87 +++++++---
 .../metadata/get_account_detail_parameters.go | 128 +++++++++++++++
 .../metadata/get_account_detail_responses.go  | 153 ++++++++++++++++++
 rest_client_zrok/metadata/metadata_client.go  |  41 +++++
 rest_model_zrok/environment.go                |  56 ++++++-
 rest_server_zrok/embedded_spec.go             |  56 ++++++-
 .../operations/metadata/get_account_detail.go |  71 ++++++++
 .../metadata/get_account_detail_parameters.go |  46 ++++++
 .../metadata/get_account_detail_responses.go  |  87 ++++++++++
 .../metadata/get_account_detail_urlbuilder.go |  87 ++++++++++
 rest_server_zrok/operations/zrok_api.go       |  12 ++
 specs/zrok.yml                                |  45 ++++--
 ui/src/api/metadata.js                        |  16 ++
 ui/src/api/types.js                           |  18 +--
 16 files changed, 908 insertions(+), 51 deletions(-)
 create mode 100644 controller/accountDetail.go
 create mode 100644 rest_client_zrok/metadata/get_account_detail_parameters.go
 create mode 100644 rest_client_zrok/metadata/get_account_detail_responses.go
 create mode 100644 rest_server_zrok/operations/metadata/get_account_detail.go
 create mode 100644 rest_server_zrok/operations/metadata/get_account_detail_parameters.go
 create mode 100644 rest_server_zrok/operations/metadata/get_account_detail_responses.go
 create mode 100644 rest_server_zrok/operations/metadata/get_account_detail_urlbuilder.go

diff --git a/controller/accountDetail.go b/controller/accountDetail.go
new file mode 100644
index 00000000..6bf4194b
--- /dev/null
+++ b/controller/accountDetail.go
@@ -0,0 +1,55 @@
+package controller
+
+import (
+	"github.com/go-openapi/runtime/middleware"
+	"github.com/openziti/zrok/rest_model_zrok"
+	"github.com/openziti/zrok/rest_server_zrok/operations/metadata"
+	"github.com/sirupsen/logrus"
+)
+
+type accountDetailHandler struct{}
+
+func newAccountDetailHandler() *accountDetailHandler {
+	return &accountDetailHandler{}
+}
+
+func (h *accountDetailHandler) Handle(params metadata.GetAccountDetailParams, principal *rest_model_zrok.Principal) middleware.Responder {
+	trx, err := str.Begin()
+	if err != nil {
+		logrus.Errorf("error stasrting transaction for '%v': %v", principal.Email, err)
+		return metadata.NewGetAccountDetailInternalServerError()
+	}
+	defer func() { _ = trx.Rollback() }()
+	envs, err := str.FindEnvironmentsForAccount(int(principal.ID), trx)
+	if err != nil {
+		logrus.Errorf("error retrieving environments for '%v': %v", principal.Email, err)
+		return metadata.NewGetAccountDetailInternalServerError()
+	}
+	sparkRx := make(map[int][]int64)
+	sparkTx := make(map[int][]int64)
+	if cfg.Metrics != nil && cfg.Metrics.Influx != nil {
+		sparkRx, sparkTx, err = sparkDataForEnvironments(envs)
+		if err != nil {
+			logrus.Errorf("error querying spark data for environments for '%v': %v", principal.Email, err)
+		}
+	} else {
+		logrus.Debug("skipping spark data for environments; no influx configuration")
+	}
+	var payload []*rest_model_zrok.Environment
+	for _, env := range envs {
+		var sparkData []*rest_model_zrok.SparkDataSample
+		for i := 0; i < len(sparkRx[env.Id]) && i < len(sparkTx[env.Id]); i++ {
+			sparkData = append(sparkData, &rest_model_zrok.SparkDataSample{Rx: float64(sparkRx[env.Id][i]), Tx: float64(sparkTx[env.Id][i])})
+		}
+		payload = append(payload, &rest_model_zrok.Environment{
+			Activity:    sparkData,
+			Address:     env.Address,
+			CreatedAt:   env.CreatedAt.UnixMilli(),
+			Description: env.Description,
+			Host:        env.Host,
+			UpdatedAt:   env.UpdatedAt.UnixMilli(),
+			ZID:         env.ZId,
+		})
+	}
+	return metadata.NewGetAccountDetailOK().WithPayload(payload)
+}
diff --git a/controller/controller.go b/controller/controller.go
index 706bf16f..18f189b7 100644
--- a/controller/controller.go
+++ b/controller/controller.go
@@ -46,6 +46,7 @@ func Run(inCfg *config.Config) error {
 	api.AdminUpdateFrontendHandler = newUpdateFrontendHandler()
 	api.EnvironmentEnableHandler = newEnableHandler()
 	api.EnvironmentDisableHandler = newDisableHandler()
+	api.MetadataGetAccountDetailHandler = newAccountDetailHandler()
 	api.MetadataConfigurationHandler = newConfigurationHandler(cfg)
 	if cfg.Metrics != nil && cfg.Metrics.Influx != nil {
 		api.MetadataGetAccountMetricsHandler = newGetAccountMetricsHandler(cfg.Metrics.Influx)
diff --git a/controller/sparkData.go b/controller/sparkData.go
index c68107e9..d8156c8d 100644
--- a/controller/sparkData.go
+++ b/controller/sparkData.go
@@ -6,13 +6,79 @@ import (
 	"github.com/openziti/zrok/controller/store"
 )
 
+func sparkDataForEnvironments(envs []*store.Environment) (rx, tx map[int][]int64, err error) {
+	rx = make(map[int][]int64)
+	tx = make(map[int][]int64)
+	if len(envs) > 0 {
+		qapi := idb.QueryAPI(cfg.Metrics.Influx.Org)
+
+		envFilter := "|> filter(fn: (r) =>"
+		for i, env := range envs {
+			if i > 0 {
+				envFilter += " or"
+			}
+			envFilter += fmt.Sprintf(" r[\"envId\"] == \"%d\"", env.Id)
+		}
+		envFilter += ")"
+		query := fmt.Sprintf("from(bucket: \"%v\")\n", cfg.Metrics.Influx.Bucket) +
+			"|> range(start: -5m)\n" +
+			"|> filter(fn: (r) => r[\"_measurement\"] == \"xfer\")\n" +
+			"|> filter(fn: (r) => r[\"_field\"] == \"rx\" or r[\"_field\"] == \"tx\")\n" +
+			"|> filter(fn: (r) => r[\"namespace\"] == \"backend\")\n" +
+			envFilter +
+			"|> aggregateWindow(every: 10s, fn: sum, createEmpty: true)\n"
+
+		result, err := qapi.Query(context.Background(), query)
+		if err != nil {
+			return nil, nil, err
+		}
+
+		for result.Next() {
+			envId := result.Record().ValueByKey("envId").(int64)
+			switch result.Record().Field() {
+			case "rx":
+				rxV := int64(0)
+				if v, ok := result.Record().Value().(int64); ok {
+					rxV = v
+				}
+				rxData := append(rx[int(envId)], rxV)
+				rx[int(envId)] = rxData
+
+			case "tx":
+				txV := int64(0)
+				if v, ok := result.Record().Value().(int64); ok {
+					txV = v
+				}
+				txData := append(tx[int(envId)], txV)
+				tx[int(envId)] = txData
+			}
+		}
+	}
+	return rx, tx, nil
+}
+
 func sparkDataForShares(shrs []*store.Share) (rx, tx map[string][]int64, err error) {
 	rx = make(map[string][]int64)
 	tx = make(map[string][]int64)
 	if len(shrs) > 0 {
 		qapi := idb.QueryAPI(cfg.Metrics.Influx.Org)
 
-		query := sparkFluxQuery(shrs, cfg.Metrics.Influx.Bucket)
+		shrFilter := "|> filter(fn: (r) =>"
+		for i, shr := range shrs {
+			if i > 0 {
+				shrFilter += " or"
+			}
+			shrFilter += fmt.Sprintf(" r[\"share\"] == \"%v\"", shr.Token)
+		}
+		shrFilter += ")"
+		query := fmt.Sprintf("from(bucket: \"%v\")\n", cfg.Metrics.Influx.Bucket) +
+			"|> range(start: -5m)\n" +
+			"|> filter(fn: (r) => r[\"_measurement\"] == \"xfer\")\n" +
+			"|> filter(fn: (r) => r[\"_field\"] == \"rx\" or r[\"_field\"] == \"tx\")\n" +
+			"|> filter(fn: (r) => r[\"namespace\"] == \"backend\")\n" +
+			shrFilter +
+			"|> aggregateWindow(every: 10s, fn: sum, createEmpty: true)\n"
+
 		result, err := qapi.Query(context.Background(), query)
 		if err != nil {
 			return nil, nil, err
@@ -41,22 +107,3 @@ func sparkDataForShares(shrs []*store.Share) (rx, tx map[string][]int64, err err
 	}
 	return rx, tx, nil
 }
-
-func sparkFluxQuery(shrs []*store.Share, bucket string) string {
-	shrFilter := "|> filter(fn: (r) =>"
-	for i, shr := range shrs {
-		if i > 0 {
-			shrFilter += " or"
-		}
-		shrFilter += fmt.Sprintf(" r[\"share\"] == \"%v\"", shr.Token)
-	}
-	shrFilter += ")"
-	query := fmt.Sprintf("from(bucket: \"%v\")\n", bucket) +
-		"|> range(start: -5m)\n" +
-		"|> filter(fn: (r) => r[\"_measurement\"] == \"xfer\")\n" +
-		"|> filter(fn: (r) => r[\"_field\"] == \"rx\" or r[\"_field\"] == \"tx\")\n" +
-		"|> filter(fn: (r) => r[\"namespace\"] == \"backend\")" +
-		shrFilter +
-		"|> aggregateWindow(every: 10s, fn: sum, createEmpty: true)\n"
-	return query
-}
diff --git a/rest_client_zrok/metadata/get_account_detail_parameters.go b/rest_client_zrok/metadata/get_account_detail_parameters.go
new file mode 100644
index 00000000..290957e7
--- /dev/null
+++ b/rest_client_zrok/metadata/get_account_detail_parameters.go
@@ -0,0 +1,128 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metadata
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"context"
+	"net/http"
+	"time"
+
+	"github.com/go-openapi/errors"
+	"github.com/go-openapi/runtime"
+	cr "github.com/go-openapi/runtime/client"
+	"github.com/go-openapi/strfmt"
+)
+
+// NewGetAccountDetailParams creates a new GetAccountDetailParams object,
+// with the default timeout for this client.
+//
+// Default values are not hydrated, since defaults are normally applied by the API server side.
+//
+// To enforce default values in parameter, use SetDefaults or WithDefaults.
+func NewGetAccountDetailParams() *GetAccountDetailParams {
+	return &GetAccountDetailParams{
+		timeout: cr.DefaultTimeout,
+	}
+}
+
+// NewGetAccountDetailParamsWithTimeout creates a new GetAccountDetailParams object
+// with the ability to set a timeout on a request.
+func NewGetAccountDetailParamsWithTimeout(timeout time.Duration) *GetAccountDetailParams {
+	return &GetAccountDetailParams{
+		timeout: timeout,
+	}
+}
+
+// NewGetAccountDetailParamsWithContext creates a new GetAccountDetailParams object
+// with the ability to set a context for a request.
+func NewGetAccountDetailParamsWithContext(ctx context.Context) *GetAccountDetailParams {
+	return &GetAccountDetailParams{
+		Context: ctx,
+	}
+}
+
+// NewGetAccountDetailParamsWithHTTPClient creates a new GetAccountDetailParams object
+// with the ability to set a custom HTTPClient for a request.
+func NewGetAccountDetailParamsWithHTTPClient(client *http.Client) *GetAccountDetailParams {
+	return &GetAccountDetailParams{
+		HTTPClient: client,
+	}
+}
+
+/*
+GetAccountDetailParams contains all the parameters to send to the API endpoint
+
+	for the get account detail operation.
+
+	Typically these are written to a http.Request.
+*/
+type GetAccountDetailParams struct {
+	timeout    time.Duration
+	Context    context.Context
+	HTTPClient *http.Client
+}
+
+// WithDefaults hydrates default values in the get account detail params (not the query body).
+//
+// All values with no default are reset to their zero value.
+func (o *GetAccountDetailParams) WithDefaults() *GetAccountDetailParams {
+	o.SetDefaults()
+	return o
+}
+
+// SetDefaults hydrates default values in the get account detail params (not the query body).
+//
+// All values with no default are reset to their zero value.
+func (o *GetAccountDetailParams) SetDefaults() {
+	// no default values defined for this parameter
+}
+
+// WithTimeout adds the timeout to the get account detail params
+func (o *GetAccountDetailParams) WithTimeout(timeout time.Duration) *GetAccountDetailParams {
+	o.SetTimeout(timeout)
+	return o
+}
+
+// SetTimeout adds the timeout to the get account detail params
+func (o *GetAccountDetailParams) SetTimeout(timeout time.Duration) {
+	o.timeout = timeout
+}
+
+// WithContext adds the context to the get account detail params
+func (o *GetAccountDetailParams) WithContext(ctx context.Context) *GetAccountDetailParams {
+	o.SetContext(ctx)
+	return o
+}
+
+// SetContext adds the context to the get account detail params
+func (o *GetAccountDetailParams) SetContext(ctx context.Context) {
+	o.Context = ctx
+}
+
+// WithHTTPClient adds the HTTPClient to the get account detail params
+func (o *GetAccountDetailParams) WithHTTPClient(client *http.Client) *GetAccountDetailParams {
+	o.SetHTTPClient(client)
+	return o
+}
+
+// SetHTTPClient adds the HTTPClient to the get account detail params
+func (o *GetAccountDetailParams) SetHTTPClient(client *http.Client) {
+	o.HTTPClient = client
+}
+
+// WriteToRequest writes these params to a swagger request
+func (o *GetAccountDetailParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error {
+
+	if err := r.SetTimeout(o.timeout); err != nil {
+		return err
+	}
+	var res []error
+
+	if len(res) > 0 {
+		return errors.CompositeValidationError(res...)
+	}
+	return nil
+}
diff --git a/rest_client_zrok/metadata/get_account_detail_responses.go b/rest_client_zrok/metadata/get_account_detail_responses.go
new file mode 100644
index 00000000..2d51df0d
--- /dev/null
+++ b/rest_client_zrok/metadata/get_account_detail_responses.go
@@ -0,0 +1,153 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metadata
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"fmt"
+	"io"
+
+	"github.com/go-openapi/runtime"
+	"github.com/go-openapi/strfmt"
+
+	"github.com/openziti/zrok/rest_model_zrok"
+)
+
+// GetAccountDetailReader is a Reader for the GetAccountDetail structure.
+type GetAccountDetailReader struct {
+	formats strfmt.Registry
+}
+
+// ReadResponse reads a server response into the received o.
+func (o *GetAccountDetailReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (interface{}, error) {
+	switch response.Code() {
+	case 200:
+		result := NewGetAccountDetailOK()
+		if err := result.readResponse(response, consumer, o.formats); err != nil {
+			return nil, err
+		}
+		return result, nil
+	case 500:
+		result := NewGetAccountDetailInternalServerError()
+		if err := result.readResponse(response, consumer, o.formats); err != nil {
+			return nil, err
+		}
+		return nil, result
+	default:
+		return nil, runtime.NewAPIError("response status code does not match any response statuses defined for this endpoint in the swagger spec", response, response.Code())
+	}
+}
+
+// NewGetAccountDetailOK creates a GetAccountDetailOK with default headers values
+func NewGetAccountDetailOK() *GetAccountDetailOK {
+	return &GetAccountDetailOK{}
+}
+
+/*
+GetAccountDetailOK describes a response with status code 200, with default header values.
+
+ok
+*/
+type GetAccountDetailOK struct {
+	Payload rest_model_zrok.Environments
+}
+
+// IsSuccess returns true when this get account detail o k response has a 2xx status code
+func (o *GetAccountDetailOK) IsSuccess() bool {
+	return true
+}
+
+// IsRedirect returns true when this get account detail o k response has a 3xx status code
+func (o *GetAccountDetailOK) IsRedirect() bool {
+	return false
+}
+
+// IsClientError returns true when this get account detail o k response has a 4xx status code
+func (o *GetAccountDetailOK) IsClientError() bool {
+	return false
+}
+
+// IsServerError returns true when this get account detail o k response has a 5xx status code
+func (o *GetAccountDetailOK) IsServerError() bool {
+	return false
+}
+
+// IsCode returns true when this get account detail o k response a status code equal to that given
+func (o *GetAccountDetailOK) IsCode(code int) bool {
+	return code == 200
+}
+
+func (o *GetAccountDetailOK) Error() string {
+	return fmt.Sprintf("[GET /detail/account][%d] getAccountDetailOK  %+v", 200, o.Payload)
+}
+
+func (o *GetAccountDetailOK) String() string {
+	return fmt.Sprintf("[GET /detail/account][%d] getAccountDetailOK  %+v", 200, o.Payload)
+}
+
+func (o *GetAccountDetailOK) GetPayload() rest_model_zrok.Environments {
+	return o.Payload
+}
+
+func (o *GetAccountDetailOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error {
+
+	// response payload
+	if err := consumer.Consume(response.Body(), &o.Payload); err != nil && err != io.EOF {
+		return err
+	}
+
+	return nil
+}
+
+// NewGetAccountDetailInternalServerError creates a GetAccountDetailInternalServerError with default headers values
+func NewGetAccountDetailInternalServerError() *GetAccountDetailInternalServerError {
+	return &GetAccountDetailInternalServerError{}
+}
+
+/*
+GetAccountDetailInternalServerError describes a response with status code 500, with default header values.
+
+internal server error
+*/
+type GetAccountDetailInternalServerError struct {
+}
+
+// IsSuccess returns true when this get account detail internal server error response has a 2xx status code
+func (o *GetAccountDetailInternalServerError) IsSuccess() bool {
+	return false
+}
+
+// IsRedirect returns true when this get account detail internal server error response has a 3xx status code
+func (o *GetAccountDetailInternalServerError) IsRedirect() bool {
+	return false
+}
+
+// IsClientError returns true when this get account detail internal server error response has a 4xx status code
+func (o *GetAccountDetailInternalServerError) IsClientError() bool {
+	return false
+}
+
+// IsServerError returns true when this get account detail internal server error response has a 5xx status code
+func (o *GetAccountDetailInternalServerError) IsServerError() bool {
+	return true
+}
+
+// IsCode returns true when this get account detail internal server error response a status code equal to that given
+func (o *GetAccountDetailInternalServerError) IsCode(code int) bool {
+	return code == 500
+}
+
+func (o *GetAccountDetailInternalServerError) Error() string {
+	return fmt.Sprintf("[GET /detail/account][%d] getAccountDetailInternalServerError ", 500)
+}
+
+func (o *GetAccountDetailInternalServerError) String() string {
+	return fmt.Sprintf("[GET /detail/account][%d] getAccountDetailInternalServerError ", 500)
+}
+
+func (o *GetAccountDetailInternalServerError) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error {
+
+	return nil
+}
diff --git a/rest_client_zrok/metadata/metadata_client.go b/rest_client_zrok/metadata/metadata_client.go
index 11e3a1ca..764216b3 100644
--- a/rest_client_zrok/metadata/metadata_client.go
+++ b/rest_client_zrok/metadata/metadata_client.go
@@ -32,6 +32,8 @@ type ClientOption func(*runtime.ClientOperation)
 type ClientService interface {
 	Configuration(params *ConfigurationParams, opts ...ClientOption) (*ConfigurationOK, error)
 
+	GetAccountDetail(params *GetAccountDetailParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*GetAccountDetailOK, error)
+
 	GetAccountMetrics(params *GetAccountMetricsParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*GetAccountMetricsOK, error)
 
 	GetEnvironmentDetail(params *GetEnvironmentDetailParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*GetEnvironmentDetailOK, error)
@@ -87,6 +89,45 @@ func (a *Client) Configuration(params *ConfigurationParams, opts ...ClientOption
 	panic(msg)
 }
 
+/*
+GetAccountDetail get account detail API
+*/
+func (a *Client) GetAccountDetail(params *GetAccountDetailParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*GetAccountDetailOK, error) {
+	// TODO: Validate the params before sending
+	if params == nil {
+		params = NewGetAccountDetailParams()
+	}
+	op := &runtime.ClientOperation{
+		ID:                 "getAccountDetail",
+		Method:             "GET",
+		PathPattern:        "/detail/account",
+		ProducesMediaTypes: []string{"application/zrok.v1+json"},
+		ConsumesMediaTypes: []string{"application/zrok.v1+json"},
+		Schemes:            []string{"http"},
+		Params:             params,
+		Reader:             &GetAccountDetailReader{formats: a.formats},
+		AuthInfo:           authInfo,
+		Context:            params.Context,
+		Client:             params.HTTPClient,
+	}
+	for _, opt := range opts {
+		opt(op)
+	}
+
+	result, err := a.transport.Submit(op)
+	if err != nil {
+		return nil, err
+	}
+	success, ok := result.(*GetAccountDetailOK)
+	if ok {
+		return success, nil
+	}
+	// unexpected success response
+	// safeguard: normally, absent a default response, unknown success responses return an error above: so this is a codegen issue
+	msg := fmt.Sprintf("unexpected success response for getAccountDetail: API contract not enforced by server. Client expected to get an error, but got: %T", result)
+	panic(msg)
+}
+
 /*
 GetAccountMetrics get account metrics API
 */
diff --git a/rest_model_zrok/environment.go b/rest_model_zrok/environment.go
index a6041314..19a354de 100644
--- a/rest_model_zrok/environment.go
+++ b/rest_model_zrok/environment.go
@@ -8,6 +8,7 @@ package rest_model_zrok
 import (
 	"context"
 
+	"github.com/go-openapi/errors"
 	"github.com/go-openapi/strfmt"
 	"github.com/go-openapi/swag"
 )
@@ -17,8 +18,8 @@ import (
 // swagger:model environment
 type Environment struct {
 
-	// active
-	Active bool `json:"active,omitempty"`
+	// activity
+	Activity SparkData `json:"activity,omitempty"`
 
 	// address
 	Address string `json:"address,omitempty"`
@@ -41,11 +42,60 @@ type Environment struct {
 
 // Validate validates this environment
 func (m *Environment) Validate(formats strfmt.Registry) error {
+	var res []error
+
+	if err := m.validateActivity(formats); err != nil {
+		res = append(res, err)
+	}
+
+	if len(res) > 0 {
+		return errors.CompositeValidationError(res...)
+	}
 	return nil
 }
 
-// ContextValidate validates this environment based on context it is used
+func (m *Environment) validateActivity(formats strfmt.Registry) error {
+	if swag.IsZero(m.Activity) { // not required
+		return nil
+	}
+
+	if err := m.Activity.Validate(formats); err != nil {
+		if ve, ok := err.(*errors.Validation); ok {
+			return ve.ValidateName("activity")
+		} else if ce, ok := err.(*errors.CompositeError); ok {
+			return ce.ValidateName("activity")
+		}
+		return err
+	}
+
+	return nil
+}
+
+// ContextValidate validate this environment based on the context it is used
 func (m *Environment) ContextValidate(ctx context.Context, formats strfmt.Registry) error {
+	var res []error
+
+	if err := m.contextValidateActivity(ctx, formats); err != nil {
+		res = append(res, err)
+	}
+
+	if len(res) > 0 {
+		return errors.CompositeValidationError(res...)
+	}
+	return nil
+}
+
+func (m *Environment) contextValidateActivity(ctx context.Context, formats strfmt.Registry) error {
+
+	if err := m.Activity.ContextValidate(ctx, formats); err != nil {
+		if ve, ok := err.(*errors.Validation); ok {
+			return ve.ValidateName("activity")
+		} else if ce, ok := err.(*errors.CompositeError); ok {
+			return ce.ValidateName("activity")
+		}
+		return err
+	}
+
 	return nil
 }
 
diff --git a/rest_server_zrok/embedded_spec.go b/rest_server_zrok/embedded_spec.go
index d41988ff..03a22b60 100644
--- a/rest_server_zrok/embedded_spec.go
+++ b/rest_server_zrok/embedded_spec.go
@@ -90,6 +90,30 @@ func init() {
         }
       }
     },
+    "/detail/account": {
+      "get": {
+        "security": [
+          {
+            "key": []
+          }
+        ],
+        "tags": [
+          "metadata"
+        ],
+        "operationId": "getAccountDetail",
+        "responses": {
+          "200": {
+            "description": "ok",
+            "schema": {
+              "$ref": "#/definitions/environments"
+            }
+          },
+          "500": {
+            "description": "internal server error"
+          }
+        }
+      }
+    },
     "/detail/environment/{envZId}": {
       "get": {
         "security": [
@@ -1065,8 +1089,8 @@ func init() {
     "environment": {
       "type": "object",
       "properties": {
-        "active": {
-          "type": "boolean"
+        "activity": {
+          "$ref": "#/definitions/sparkData"
         },
         "address": {
           "type": "string"
@@ -1537,6 +1561,30 @@ func init() {
         }
       }
     },
+    "/detail/account": {
+      "get": {
+        "security": [
+          {
+            "key": []
+          }
+        ],
+        "tags": [
+          "metadata"
+        ],
+        "operationId": "getAccountDetail",
+        "responses": {
+          "200": {
+            "description": "ok",
+            "schema": {
+              "$ref": "#/definitions/environments"
+            }
+          },
+          "500": {
+            "description": "internal server error"
+          }
+        }
+      }
+    },
     "/detail/environment/{envZId}": {
       "get": {
         "security": [
@@ -2512,8 +2560,8 @@ func init() {
     "environment": {
       "type": "object",
       "properties": {
-        "active": {
-          "type": "boolean"
+        "activity": {
+          "$ref": "#/definitions/sparkData"
         },
         "address": {
           "type": "string"
diff --git a/rest_server_zrok/operations/metadata/get_account_detail.go b/rest_server_zrok/operations/metadata/get_account_detail.go
new file mode 100644
index 00000000..3df45d07
--- /dev/null
+++ b/rest_server_zrok/operations/metadata/get_account_detail.go
@@ -0,0 +1,71 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metadata
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the generate command
+
+import (
+	"net/http"
+
+	"github.com/go-openapi/runtime/middleware"
+
+	"github.com/openziti/zrok/rest_model_zrok"
+)
+
+// GetAccountDetailHandlerFunc turns a function with the right signature into a get account detail handler
+type GetAccountDetailHandlerFunc func(GetAccountDetailParams, *rest_model_zrok.Principal) middleware.Responder
+
+// Handle executing the request and returning a response
+func (fn GetAccountDetailHandlerFunc) Handle(params GetAccountDetailParams, principal *rest_model_zrok.Principal) middleware.Responder {
+	return fn(params, principal)
+}
+
+// GetAccountDetailHandler interface for that can handle valid get account detail params
+type GetAccountDetailHandler interface {
+	Handle(GetAccountDetailParams, *rest_model_zrok.Principal) middleware.Responder
+}
+
+// NewGetAccountDetail creates a new http.Handler for the get account detail operation
+func NewGetAccountDetail(ctx *middleware.Context, handler GetAccountDetailHandler) *GetAccountDetail {
+	return &GetAccountDetail{Context: ctx, Handler: handler}
+}
+
+/*
+	GetAccountDetail swagger:route GET /detail/account metadata getAccountDetail
+
+GetAccountDetail get account detail API
+*/
+type GetAccountDetail struct {
+	Context *middleware.Context
+	Handler GetAccountDetailHandler
+}
+
+func (o *GetAccountDetail) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
+	route, rCtx, _ := o.Context.RouteInfo(r)
+	if rCtx != nil {
+		*r = *rCtx
+	}
+	var Params = NewGetAccountDetailParams()
+	uprinc, aCtx, err := o.Context.Authorize(r, route)
+	if err != nil {
+		o.Context.Respond(rw, r, route.Produces, route, err)
+		return
+	}
+	if aCtx != nil {
+		*r = *aCtx
+	}
+	var principal *rest_model_zrok.Principal
+	if uprinc != nil {
+		principal = uprinc.(*rest_model_zrok.Principal) // this is really a rest_model_zrok.Principal, I promise
+	}
+
+	if err := o.Context.BindValidRequest(r, route, &Params); err != nil { // bind params
+		o.Context.Respond(rw, r, route.Produces, route, err)
+		return
+	}
+
+	res := o.Handler.Handle(Params, principal) // actually handle the request
+	o.Context.Respond(rw, r, route.Produces, route, res)
+
+}
diff --git a/rest_server_zrok/operations/metadata/get_account_detail_parameters.go b/rest_server_zrok/operations/metadata/get_account_detail_parameters.go
new file mode 100644
index 00000000..2702d68e
--- /dev/null
+++ b/rest_server_zrok/operations/metadata/get_account_detail_parameters.go
@@ -0,0 +1,46 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metadata
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"net/http"
+
+	"github.com/go-openapi/errors"
+	"github.com/go-openapi/runtime/middleware"
+)
+
+// NewGetAccountDetailParams creates a new GetAccountDetailParams object
+//
+// There are no default values defined in the spec.
+func NewGetAccountDetailParams() GetAccountDetailParams {
+
+	return GetAccountDetailParams{}
+}
+
+// GetAccountDetailParams contains all the bound params for the get account detail operation
+// typically these are obtained from a http.Request
+//
+// swagger:parameters getAccountDetail
+type GetAccountDetailParams struct {
+
+	// HTTP Request Object
+	HTTPRequest *http.Request `json:"-"`
+}
+
+// BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface
+// for simple values it will use straight method calls.
+//
+// To ensure default values, the struct must have been initialized with NewGetAccountDetailParams() beforehand.
+func (o *GetAccountDetailParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error {
+	var res []error
+
+	o.HTTPRequest = r
+
+	if len(res) > 0 {
+		return errors.CompositeValidationError(res...)
+	}
+	return nil
+}
diff --git a/rest_server_zrok/operations/metadata/get_account_detail_responses.go b/rest_server_zrok/operations/metadata/get_account_detail_responses.go
new file mode 100644
index 00000000..78b271c3
--- /dev/null
+++ b/rest_server_zrok/operations/metadata/get_account_detail_responses.go
@@ -0,0 +1,87 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metadata
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"net/http"
+
+	"github.com/go-openapi/runtime"
+
+	"github.com/openziti/zrok/rest_model_zrok"
+)
+
+// GetAccountDetailOKCode is the HTTP code returned for type GetAccountDetailOK
+const GetAccountDetailOKCode int = 200
+
+/*
+GetAccountDetailOK ok
+
+swagger:response getAccountDetailOK
+*/
+type GetAccountDetailOK struct {
+
+	/*
+	  In: Body
+	*/
+	Payload rest_model_zrok.Environments `json:"body,omitempty"`
+}
+
+// NewGetAccountDetailOK creates GetAccountDetailOK with default headers values
+func NewGetAccountDetailOK() *GetAccountDetailOK {
+
+	return &GetAccountDetailOK{}
+}
+
+// WithPayload adds the payload to the get account detail o k response
+func (o *GetAccountDetailOK) WithPayload(payload rest_model_zrok.Environments) *GetAccountDetailOK {
+	o.Payload = payload
+	return o
+}
+
+// SetPayload sets the payload to the get account detail o k response
+func (o *GetAccountDetailOK) SetPayload(payload rest_model_zrok.Environments) {
+	o.Payload = payload
+}
+
+// WriteResponse to the client
+func (o *GetAccountDetailOK) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) {
+
+	rw.WriteHeader(200)
+	payload := o.Payload
+	if payload == nil {
+		// return empty array
+		payload = rest_model_zrok.Environments{}
+	}
+
+	if err := producer.Produce(rw, payload); err != nil {
+		panic(err) // let the recovery middleware deal with this
+	}
+}
+
+// GetAccountDetailInternalServerErrorCode is the HTTP code returned for type GetAccountDetailInternalServerError
+const GetAccountDetailInternalServerErrorCode int = 500
+
+/*
+GetAccountDetailInternalServerError internal server error
+
+swagger:response getAccountDetailInternalServerError
+*/
+type GetAccountDetailInternalServerError struct {
+}
+
+// NewGetAccountDetailInternalServerError creates GetAccountDetailInternalServerError with default headers values
+func NewGetAccountDetailInternalServerError() *GetAccountDetailInternalServerError {
+
+	return &GetAccountDetailInternalServerError{}
+}
+
+// WriteResponse to the client
+func (o *GetAccountDetailInternalServerError) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) {
+
+	rw.Header().Del(runtime.HeaderContentType) //Remove Content-Type on empty responses
+
+	rw.WriteHeader(500)
+}
diff --git a/rest_server_zrok/operations/metadata/get_account_detail_urlbuilder.go b/rest_server_zrok/operations/metadata/get_account_detail_urlbuilder.go
new file mode 100644
index 00000000..b7eda126
--- /dev/null
+++ b/rest_server_zrok/operations/metadata/get_account_detail_urlbuilder.go
@@ -0,0 +1,87 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metadata
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the generate command
+
+import (
+	"errors"
+	"net/url"
+	golangswaggerpaths "path"
+)
+
+// GetAccountDetailURL generates an URL for the get account detail operation
+type GetAccountDetailURL struct {
+	_basePath string
+}
+
+// WithBasePath sets the base path for this url builder, only required when it's different from the
+// base path specified in the swagger spec.
+// When the value of the base path is an empty string
+func (o *GetAccountDetailURL) WithBasePath(bp string) *GetAccountDetailURL {
+	o.SetBasePath(bp)
+	return o
+}
+
+// SetBasePath sets the base path for this url builder, only required when it's different from the
+// base path specified in the swagger spec.
+// When the value of the base path is an empty string
+func (o *GetAccountDetailURL) SetBasePath(bp string) {
+	o._basePath = bp
+}
+
+// Build a url path and query string
+func (o *GetAccountDetailURL) Build() (*url.URL, error) {
+	var _result url.URL
+
+	var _path = "/detail/account"
+
+	_basePath := o._basePath
+	if _basePath == "" {
+		_basePath = "/api/v1"
+	}
+	_result.Path = golangswaggerpaths.Join(_basePath, _path)
+
+	return &_result, nil
+}
+
+// Must is a helper function to panic when the url builder returns an error
+func (o *GetAccountDetailURL) Must(u *url.URL, err error) *url.URL {
+	if err != nil {
+		panic(err)
+	}
+	if u == nil {
+		panic("url can't be nil")
+	}
+	return u
+}
+
+// String returns the string representation of the path with query string
+func (o *GetAccountDetailURL) String() string {
+	return o.Must(o.Build()).String()
+}
+
+// BuildFull builds a full url with scheme, host, path and query string
+func (o *GetAccountDetailURL) BuildFull(scheme, host string) (*url.URL, error) {
+	if scheme == "" {
+		return nil, errors.New("scheme is required for a full url on GetAccountDetailURL")
+	}
+	if host == "" {
+		return nil, errors.New("host is required for a full url on GetAccountDetailURL")
+	}
+
+	base, err := o.Build()
+	if err != nil {
+		return nil, err
+	}
+
+	base.Scheme = scheme
+	base.Host = host
+	return base, nil
+}
+
+// StringFull returns the string representation of a complete url
+func (o *GetAccountDetailURL) StringFull(scheme, host string) string {
+	return o.Must(o.BuildFull(scheme, host)).String()
+}
diff --git a/rest_server_zrok/operations/zrok_api.go b/rest_server_zrok/operations/zrok_api.go
index 61b9bad1..ed5c7721 100644
--- a/rest_server_zrok/operations/zrok_api.go
+++ b/rest_server_zrok/operations/zrok_api.go
@@ -70,6 +70,9 @@ func NewZrokAPI(spec *loads.Document) *ZrokAPI {
 		EnvironmentEnableHandler: environment.EnableHandlerFunc(func(params environment.EnableParams, principal *rest_model_zrok.Principal) middleware.Responder {
 			return middleware.NotImplemented("operation environment.Enable has not yet been implemented")
 		}),
+		MetadataGetAccountDetailHandler: metadata.GetAccountDetailHandlerFunc(func(params metadata.GetAccountDetailParams, principal *rest_model_zrok.Principal) middleware.Responder {
+			return middleware.NotImplemented("operation metadata.GetAccountDetail has not yet been implemented")
+		}),
 		MetadataGetAccountMetricsHandler: metadata.GetAccountMetricsHandlerFunc(func(params metadata.GetAccountMetricsParams, principal *rest_model_zrok.Principal) middleware.Responder {
 			return middleware.NotImplemented("operation metadata.GetAccountMetrics has not yet been implemented")
 		}),
@@ -194,6 +197,8 @@ type ZrokAPI struct {
 	EnvironmentDisableHandler environment.DisableHandler
 	// EnvironmentEnableHandler sets the operation handler for the enable operation
 	EnvironmentEnableHandler environment.EnableHandler
+	// MetadataGetAccountDetailHandler sets the operation handler for the get account detail operation
+	MetadataGetAccountDetailHandler metadata.GetAccountDetailHandler
 	// MetadataGetAccountMetricsHandler sets the operation handler for the get account metrics operation
 	MetadataGetAccountMetricsHandler metadata.GetAccountMetricsHandler
 	// MetadataGetEnvironmentDetailHandler sets the operation handler for the get environment detail operation
@@ -336,6 +341,9 @@ func (o *ZrokAPI) Validate() error {
 	if o.EnvironmentEnableHandler == nil {
 		unregistered = append(unregistered, "environment.EnableHandler")
 	}
+	if o.MetadataGetAccountDetailHandler == nil {
+		unregistered = append(unregistered, "metadata.GetAccountDetailHandler")
+	}
 	if o.MetadataGetAccountMetricsHandler == nil {
 		unregistered = append(unregistered, "metadata.GetAccountMetricsHandler")
 	}
@@ -526,6 +534,10 @@ func (o *ZrokAPI) initHandlerCache() {
 	if o.handlers["GET"] == nil {
 		o.handlers["GET"] = make(map[string]http.Handler)
 	}
+	o.handlers["GET"]["/detail/account"] = metadata.NewGetAccountDetail(o.context, o.MetadataGetAccountDetailHandler)
+	if o.handlers["GET"] == nil {
+		o.handlers["GET"] = make(map[string]http.Handler)
+	}
 	o.handlers["GET"]["/metrics/account"] = metadata.NewGetAccountMetrics(o.context, o.MetadataGetAccountMetricsHandler)
 	if o.handlers["GET"] == nil {
 		o.handlers["GET"] = make(map[string]http.Handler)
diff --git a/specs/zrok.yml b/specs/zrok.yml
index 39fe9033..38897e48 100644
--- a/specs/zrok.yml
+++ b/specs/zrok.yml
@@ -329,6 +329,21 @@ paths:
           schema:
             $ref: "#/definitions/configuration"
 
+  /detail/account:
+    get:
+      tags:
+        - metadata
+      security:
+        - key: []
+      operationId: getAccountDetail
+      responses:
+        200:
+          description: ok
+          schema:
+            $ref: "#/definitions/environments"
+        500:
+          description: internal server error
+
   /detail/environment/{envZId}:
     get:
       tags:
@@ -689,8 +704,8 @@ definitions:
         type: string
       zId:
         type: string
-      active:
-        type: boolean
+      activity:
+        $ref: "#/definitions/sparkData"
       createdAt:
         type: integer
       updatedAt:
@@ -856,19 +871,6 @@ definitions:
     items:
       $ref: "#/definitions/share"
 
-  sparkData:
-    type: array
-    items:
-      $ref: "#/definitions/sparkDataSample"
-
-  sparkDataSample:
-    type: object
-    properties:
-      rx:
-        type: number
-      tx:
-        type: number
-
   shareRequest:
     type: object
     properties:
@@ -905,6 +907,19 @@ definitions:
       shrToken:
         type: string
 
+  sparkData:
+    type: array
+    items:
+      $ref: "#/definitions/sparkDataSample"
+
+  sparkDataSample:
+    type: object
+    properties:
+      rx:
+        type: number
+      tx:
+        type: number
+
   unaccessRequest:
     type: object
     properties:
diff --git a/ui/src/api/metadata.js b/ui/src/api/metadata.js
index 7b8af17a..1b50438f 100644
--- a/ui/src/api/metadata.js
+++ b/ui/src/api/metadata.js
@@ -8,6 +8,12 @@ export function configuration() {
   return gateway.request(configurationOperation)
 }
 
+/**
+ */
+export function getAccountDetail() {
+  return gateway.request(getAccountDetailOperation)
+}
+
 /**
  * @param {string} envZId 
  * @return {Promise<module:types.environmentShares>} ok
@@ -104,6 +110,16 @@ const configurationOperation = {
   method: 'get'
 }
 
+const getAccountDetailOperation = {
+  path: '/detail/account',
+  method: 'get',
+  security: [
+    {
+      id: 'key'
+    }
+  ]
+}
+
 const getEnvironmentDetailOperation = {
   path: '/detail/environment/{envZId}',
   method: 'get',
diff --git a/ui/src/api/types.js b/ui/src/api/types.js
index 09093d0e..4d5a07cb 100644
--- a/ui/src/api/types.js
+++ b/ui/src/api/types.js
@@ -87,7 +87,7 @@
  * @property {string} host 
  * @property {string} address 
  * @property {string} zId 
- * @property {boolean} active 
+ * @property {module:types.sparkData} activity 
  * @property {number} createdAt 
  * @property {number} updatedAt 
  */
@@ -205,14 +205,6 @@
  * @property {number} updatedAt 
  */
 
-/**
- * @typedef sparkDataSample
- * @memberof module:types
- * 
- * @property {number} rx 
- * @property {number} tx 
- */
-
 /**
  * @typedef shareRequest
  * @memberof module:types
@@ -235,6 +227,14 @@
  * @property {string} shrToken 
  */
 
+/**
+ * @typedef sparkDataSample
+ * @memberof module:types
+ * 
+ * @property {number} rx 
+ * @property {number} tx 
+ */
+
 /**
  * @typedef unaccessRequest
  * @memberof module:types

From 8bf2173c49d16c4a823dbc5e67a634ddebff96cb Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Fri, 12 May 2023 13:24:29 -0400
Subject: [PATCH 32/49] tweaks to get environment sparklines running (#327)

---
 controller/sparkData.go                       | 10 ++-
 go.mod                                        |  4 +-
 .../console/detail/account/AccountDetail.js   |  6 +-
 .../console/detail/account/EnvironmentsTab.js | 71 +++++++++++++++++++
 .../console/detail/environment/SharesTab.js   |  2 +-
 ui/src/console/metrics/util.js                |  1 -
 6 files changed, 88 insertions(+), 6 deletions(-)
 create mode 100644 ui/src/console/detail/account/EnvironmentsTab.js

diff --git a/controller/sparkData.go b/controller/sparkData.go
index d8156c8d..632baf24 100644
--- a/controller/sparkData.go
+++ b/controller/sparkData.go
@@ -4,6 +4,8 @@ import (
 	"context"
 	"fmt"
 	"github.com/openziti/zrok/controller/store"
+	"github.com/sirupsen/logrus"
+	"strconv"
 )
 
 func sparkDataForEnvironments(envs []*store.Environment) (rx, tx map[int][]int64, err error) {
@@ -26,6 +28,7 @@ func sparkDataForEnvironments(envs []*store.Environment) (rx, tx map[int][]int64
 			"|> filter(fn: (r) => r[\"_field\"] == \"rx\" or r[\"_field\"] == \"tx\")\n" +
 			"|> filter(fn: (r) => r[\"namespace\"] == \"backend\")\n" +
 			envFilter +
+			"|> drop(columns: [\"share\", \"acctId\"])\n" +
 			"|> aggregateWindow(every: 10s, fn: sum, createEmpty: true)\n"
 
 		result, err := qapi.Query(context.Background(), query)
@@ -34,7 +37,12 @@ func sparkDataForEnvironments(envs []*store.Environment) (rx, tx map[int][]int64
 		}
 
 		for result.Next() {
-			envId := result.Record().ValueByKey("envId").(int64)
+			envIdS := result.Record().ValueByKey("envId").(string)
+			envId, err := strconv.ParseInt(envIdS, 10, 32)
+			if err != nil {
+				logrus.Errorf("error parsing '%v': %v", envIdS, err)
+				continue
+			}
 			switch result.Record().Field() {
 			case "rx":
 				rxV := int64(0)
diff --git a/go.mod b/go.mod
index 7a4cf38c..9f5a34f5 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
 module github.com/openziti/zrok
 
-go 1.19
+go 1.20
 
 require (
 	github.com/charmbracelet/bubbles v0.14.0
@@ -31,6 +31,7 @@ require (
 	github.com/openziti/fabric v0.22.59
 	github.com/openziti/identity v1.0.37
 	github.com/openziti/sdk-golang v0.18.61
+	github.com/openziti/transport/v2 v2.0.63
 	github.com/pkg/errors v0.9.1
 	github.com/rabbitmq/amqp091-go v1.7.0
 	github.com/rubenv/sql-migrate v1.1.2
@@ -91,7 +92,6 @@ require (
 	github.com/opentracing/opentracing-go v1.2.0 // indirect
 	github.com/openziti/foundation/v2 v2.0.17 // indirect
 	github.com/openziti/metrics v1.2.10 // indirect
-	github.com/openziti/transport/v2 v2.0.63 // indirect
 	github.com/orcaman/concurrent-map/v2 v2.0.1 // indirect
 	github.com/parallaxsecond/parsec-client-go v0.0.0-20221025095442-f0a77d263cf9 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
diff --git a/ui/src/console/detail/account/AccountDetail.js b/ui/src/console/detail/account/AccountDetail.js
index 23b8d679..5ca50391 100644
--- a/ui/src/console/detail/account/AccountDetail.js
+++ b/ui/src/console/detail/account/AccountDetail.js
@@ -5,6 +5,7 @@ import {Tab, Tabs} from "react-bootstrap";
 import SecretToggle from "../../SecretToggle";
 import React from "react";
 import MetricsTab from "./MetricsTab";
+import EnvironmentsTab from "./EnvironmentsTab";
 
 const AccountDetail = (props) => {
     const customProperties = {
@@ -14,7 +15,10 @@ const AccountDetail = (props) => {
     return (
         <div>
             <h2><Icon path={mdiAccountBox} size={2} />{" "}{props.user.email}</h2>
-            <Tabs defaultActiveKey={"detail"}>
+            <Tabs defaultActiveKey={"environments"}>
+                <Tab eventKey={"environments"} title={"Environments"}>
+                    <EnvironmentsTab />
+                </Tab>
                 <Tab eventKey={"detail"} title={"Detail"}>
                     <PropertyTable object={props.user} custom={customProperties}/>
                 </Tab>
diff --git a/ui/src/console/detail/account/EnvironmentsTab.js b/ui/src/console/detail/account/EnvironmentsTab.js
new file mode 100644
index 00000000..9da48ce1
--- /dev/null
+++ b/ui/src/console/detail/account/EnvironmentsTab.js
@@ -0,0 +1,71 @@
+import React, {useEffect, useState} from "react";
+import * as metadata from "../../../api/metadata";
+import {Area, AreaChart, ResponsiveContainer} from "recharts";
+import DataTable from "react-data-table-component";
+
+const EnvironmentsTab = (props) => {
+    const [detail, setDetail] = useState([]);
+
+    useEffect(() => {
+        metadata.getAccountDetail()
+            .then(resp => {
+                setDetail(resp.data);
+            });
+    }, [props.selection]);
+
+    useEffect(() => {
+        let mounted = true;
+        let interval = setInterval(() => {
+            metadata.getAccountDetail()
+                .then(resp => {
+                    if(mounted) {
+                        setDetail(resp.data);
+                    }
+                });
+        }, 5000);
+        return () => {
+            mounted = false;
+            clearInterval(interval);
+        }
+    }, [props.selection]);
+
+    const columns = [
+        {
+            name: "Description",
+            selector: row => row.description,
+            sortable: true
+        },
+        {
+            name: "Address",
+            grow: 0.5,
+            selector: row => row.address,
+            sortable: true
+        },
+        {
+            name: "Activity",
+            grow: 0.5,
+            cell: row => {
+                return <ResponsiveContainer width={"100%"} height={"100%"}>
+                    <AreaChart data={row.activity}>
+                        <Area type={"basis"} dataKey={(v) => v.rx ? v.rx : 0} stroke={"#231069"} fill={"#04adef"} isAnimationActive={false} dot={false} />
+                        <Area type={"basis"} dataKey={(v) => v.tx ? v.tx * -1 : 0} stroke={"#231069"} fill={"#9BF316"} isAnimationActive={false} dot={false} />
+                    </AreaChart>
+                </ResponsiveContainer>
+            }
+        }
+    ];
+
+    return (
+        <div className={"zrok-datatable"}>
+            <DataTable
+                className={"zrok-datatable"}
+                data={detail}
+                columns={columns}
+                defaultSortField={1}
+                noDataComponent={<p>No environments in account</p>}
+            />
+        </div>
+    );
+}
+
+export default EnvironmentsTab;
\ No newline at end of file
diff --git a/ui/src/console/detail/environment/SharesTab.js b/ui/src/console/detail/environment/SharesTab.js
index a0926bce..7a297221 100644
--- a/ui/src/console/detail/environment/SharesTab.js
+++ b/ui/src/console/detail/environment/SharesTab.js
@@ -22,7 +22,7 @@ const SharesTab = (props) => {
                         setDetail(resp.data);
                     }
                 });
-        }, 1000);
+        }, 5000);
         return () => {
             mounted = false;
             clearInterval(interval);
diff --git a/ui/src/console/metrics/util.js b/ui/src/console/metrics/util.js
index 5c4e292f..d29de073 100644
--- a/ui/src/console/metrics/util.js
+++ b/ui/src/console/metrics/util.js
@@ -1,5 +1,4 @@
 export const buildMetrics = (m) => {
-    console.log("build", m);
     let metrics = {
         data: m.samples,
         rx: 0,

From ebcbeeb9008c6c3d390e1798bc99a17749b190ea Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Fri, 12 May 2023 13:30:14 -0400
Subject: [PATCH 33/49] visual lint

---
 ui/src/console/visualizer/Network.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/ui/src/console/visualizer/Network.js b/ui/src/console/visualizer/Network.js
index 8e81ca8d..a36b1283 100644
--- a/ui/src/console/visualizer/Network.js
+++ b/ui/src/console/visualizer/Network.js
@@ -22,7 +22,7 @@ const Network = (props) => {
 
     const paintNode = (node, ctx) => {
         let nodeColor = node.selected ? "#9BF316" : "#04adef";
-        let textColor = node.selected ? "black" : "white";
+        let textColor = "black";
 
         ctx.textBaseline = "middle";
         ctx.textAlign = "center";

From 7d48683df74418d0109791b1df9bb505f70be919 Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Mon, 15 May 2023 14:14:52 -0400
Subject: [PATCH 34/49] support for displaying limited shares in red in the
 visualizer (#320)

---
 controller/overview.go                    | 25 +++++++++++++++++---
 controller/store/shareLimitJournal.go     | 28 +++++++++++++++++++++++
 rest_model_zrok/environment.go            |  3 +++
 rest_model_zrok/share.go                  |  3 +++
 rest_server_zrok/embedded_spec.go         | 12 ++++++++++
 specs/zrok.yml                            |  4 ++++
 ui/src/api/types.js                       |  2 ++
 ui/src/console/detail/share/MetricsTab.js |  2 --
 ui/src/console/visualizer/Network.js      |  2 +-
 ui/src/console/visualizer/graph.js        | 10 ++++----
 10 files changed, 81 insertions(+), 10 deletions(-)

diff --git a/controller/overview.go b/controller/overview.go
index c85e2418..ee313ba6 100644
--- a/controller/overview.go
+++ b/controller/overview.go
@@ -2,6 +2,7 @@ package controller
 
 import (
 	"github.com/go-openapi/runtime/middleware"
+	"github.com/openziti/zrok/controller/store"
 	"github.com/openziti/zrok/rest_model_zrok"
 	"github.com/openziti/zrok/rest_server_zrok/operations/metadata"
 	"github.com/sirupsen/logrus"
@@ -36,7 +37,19 @@ func overviewHandler(_ metadata.OverviewParams, principal *rest_model_zrok.Princ
 				ZID:         env.ZId,
 			},
 		}
-
+		var shrIds []int
+		for i := range shrs {
+			shrIds = append(shrIds, shrs[i].Id)
+		}
+		shrsLimited, err := str.FindSelectedLatestShareLimitjournal(shrIds, tx)
+		if err != nil {
+			logrus.Errorf("error finding limited shares for environment '%v': %v", env.ZId, err)
+			return metadata.NewOverviewInternalServerError()
+		}
+		shrsLimitedMap := make(map[int]store.LimitJournalAction)
+		for i := range shrsLimited {
+			shrsLimitedMap[shrsLimited[i].ShareId] = shrsLimited[i].Action
+		}
 		for _, shr := range shrs {
 			feEndpoint := ""
 			if shr.FrontendEndpoint != nil {
@@ -50,7 +63,7 @@ func overviewHandler(_ metadata.OverviewParams, principal *rest_model_zrok.Princ
 			if shr.BackendProxyEndpoint != nil {
 				beProxyEndpoint = *shr.BackendProxyEndpoint
 			}
-			es.Shares = append(es.Shares, &rest_model_zrok.Share{
+			oshr := &rest_model_zrok.Share{
 				Token:                shr.Token,
 				ZID:                  shr.ZId,
 				ShareMode:            shr.ShareMode,
@@ -61,7 +74,13 @@ func overviewHandler(_ metadata.OverviewParams, principal *rest_model_zrok.Princ
 				Reserved:             shr.Reserved,
 				CreatedAt:            shr.CreatedAt.UnixMilli(),
 				UpdatedAt:            shr.UpdatedAt.UnixMilli(),
-			})
+			}
+			if action, found := shrsLimitedMap[shr.Id]; found {
+				if action == store.LimitAction {
+					oshr.Limited = true
+				}
+			}
+			es.Shares = append(es.Shares, oshr)
 		}
 		out = append(out, es)
 	}
diff --git a/controller/store/shareLimitJournal.go b/controller/store/shareLimitJournal.go
index 7dcc351f..2b935cc1 100644
--- a/controller/store/shareLimitJournal.go
+++ b/controller/store/shareLimitJournal.go
@@ -1,6 +1,7 @@
 package store
 
 import (
+	"fmt"
 	"github.com/jmoiron/sqlx"
 	"github.com/pkg/errors"
 )
@@ -41,6 +42,33 @@ func (str *Store) FindLatestShareLimitJournal(shrId int, trx *sqlx.Tx) (*ShareLi
 	return j, nil
 }
 
+func (str *Store) FindSelectedLatestShareLimitjournal(shrIds []int, trx *sqlx.Tx) ([]*ShareLimitJournal, error) {
+	if len(shrIds) < 1 {
+		return nil, nil
+	}
+	in := "("
+	for i := range shrIds {
+		if i > 0 {
+			in += ", "
+		}
+		in += fmt.Sprintf("%d", shrIds[i])
+	}
+	in += ")"
+	rows, err := trx.Queryx("select id, share_id, rx_bytes, tx_bytes, action, created_at, updated_at from share_limit_journal where id in (select max(id) as id from share_limit_journal group by share_id) and share_id in " + in)
+	if err != nil {
+		return nil, errors.Wrap(err, "error selecting all latest share_limit_journal")
+	}
+	var sljs []*ShareLimitJournal
+	for rows.Next() {
+		slj := &ShareLimitJournal{}
+		if err := rows.StructScan(slj); err != nil {
+			return nil, errors.Wrap(err, "error scanning share_limit_journal")
+		}
+		sljs = append(sljs, slj)
+	}
+	return sljs, nil
+}
+
 func (str *Store) FindAllLatestShareLimitJournal(trx *sqlx.Tx) ([]*ShareLimitJournal, error) {
 	rows, err := trx.Queryx("select id, share_id, rx_bytes, tx_bytes, action, created_at, updated_at from share_limit_journal where id in (select max(id) as id from share_limit_journal group by share_id)")
 	if err != nil {
diff --git a/rest_model_zrok/environment.go b/rest_model_zrok/environment.go
index 19a354de..17376949 100644
--- a/rest_model_zrok/environment.go
+++ b/rest_model_zrok/environment.go
@@ -33,6 +33,9 @@ type Environment struct {
 	// host
 	Host string `json:"host,omitempty"`
 
+	// limited
+	Limited bool `json:"limited,omitempty"`
+
 	// updated at
 	UpdatedAt int64 `json:"updatedAt,omitempty"`
 
diff --git a/rest_model_zrok/share.go b/rest_model_zrok/share.go
index 983c11c6..998744b4 100644
--- a/rest_model_zrok/share.go
+++ b/rest_model_zrok/share.go
@@ -36,6 +36,9 @@ type Share struct {
 	// frontend selection
 	FrontendSelection string `json:"frontendSelection,omitempty"`
 
+	// limited
+	Limited bool `json:"limited,omitempty"`
+
 	// reserved
 	Reserved bool `json:"reserved,omitempty"`
 
diff --git a/rest_server_zrok/embedded_spec.go b/rest_server_zrok/embedded_spec.go
index 03a22b60..1d9d36a4 100644
--- a/rest_server_zrok/embedded_spec.go
+++ b/rest_server_zrok/embedded_spec.go
@@ -1104,6 +1104,9 @@ func init() {
         "host": {
           "type": "string"
         },
+        "limited": {
+          "type": "boolean"
+        },
         "updatedAt": {
           "type": "integer"
         },
@@ -1308,6 +1311,9 @@ func init() {
         "frontendSelection": {
           "type": "string"
         },
+        "limited": {
+          "type": "boolean"
+        },
         "reserved": {
           "type": "boolean"
         },
@@ -2575,6 +2581,9 @@ func init() {
         "host": {
           "type": "string"
         },
+        "limited": {
+          "type": "boolean"
+        },
         "updatedAt": {
           "type": "integer"
         },
@@ -2779,6 +2788,9 @@ func init() {
         "frontendSelection": {
           "type": "string"
         },
+        "limited": {
+          "type": "boolean"
+        },
         "reserved": {
           "type": "boolean"
         },
diff --git a/specs/zrok.yml b/specs/zrok.yml
index 38897e48..2394046f 100644
--- a/specs/zrok.yml
+++ b/specs/zrok.yml
@@ -706,6 +706,8 @@ definitions:
         type: string
       activity:
         $ref: "#/definitions/sparkData"
+      limited:
+        type: boolean
       createdAt:
         type: integer
       updatedAt:
@@ -861,6 +863,8 @@ definitions:
         type: boolean
       activity:
         $ref: "#/definitions/sparkData"
+      limited:
+        type: boolean
       createdAt:
         type: integer
       updatedAt:
diff --git a/ui/src/api/types.js b/ui/src/api/types.js
index 4d5a07cb..0d2fb901 100644
--- a/ui/src/api/types.js
+++ b/ui/src/api/types.js
@@ -88,6 +88,7 @@
  * @property {string} address 
  * @property {string} zId 
  * @property {module:types.sparkData} activity 
+ * @property {boolean} limited 
  * @property {number} createdAt 
  * @property {number} updatedAt 
  */
@@ -201,6 +202,7 @@
  * @property {string} backendProxyEndpoint 
  * @property {boolean} reserved 
  * @property {module:types.sparkData} activity 
+ * @property {boolean} limited 
  * @property {number} createdAt 
  * @property {number} updatedAt 
  */
diff --git a/ui/src/console/detail/share/MetricsTab.js b/ui/src/console/detail/share/MetricsTab.js
index 51143ffe..d73bff6f 100644
--- a/ui/src/console/detail/share/MetricsTab.js
+++ b/ui/src/console/detail/share/MetricsTab.js
@@ -9,7 +9,6 @@ const MetricsTab = (props) => {
     const [metrics1, setMetrics1] = useState(buildMetrics([]));
 
     useEffect(() => {
-        console.log("token", props.share.token);
         metadata.getShareMetrics(props.share.token)
             .then(resp => {
                 setMetrics30(buildMetrics(resp.data));
@@ -27,7 +26,6 @@ const MetricsTab = (props) => {
     useEffect(() => {
         let mounted = true;
         let interval = setInterval(() => {
-            console.log("token", props.share.token);
             metadata.getShareMetrics(props.share.token)
                 .then(resp => {
                     if(mounted) {
diff --git a/ui/src/console/visualizer/Network.js b/ui/src/console/visualizer/Network.js
index a36b1283..9e8fda5f 100644
--- a/ui/src/console/visualizer/Network.js
+++ b/ui/src/console/visualizer/Network.js
@@ -21,7 +21,7 @@ const Network = (props) => {
     }, []);
 
     const paintNode = (node, ctx) => {
-        let nodeColor = node.selected ? "#9BF316" : "#04adef";
+        let nodeColor = node.selected ? "#9BF316" : node.limited ? "#f00": "#04adef";
         let textColor = "black";
 
         ctx.textBaseline = "middle";
diff --git a/ui/src/console/visualizer/graph.js b/ui/src/console/visualizer/graph.js
index c10d0ab3..f8d0006e 100644
--- a/ui/src/console/visualizer/graph.js
+++ b/ui/src/console/visualizer/graph.js
@@ -12,7 +12,7 @@ const sortNodes = (nodes) => {
 
 const nodesEqual = (a, b) => {
     if(a.length !== b.length) return false;
-    return a.every((e, i) => e.id === b[i].id);
+    return a.every((e, i) => e.id === b[i].id && e.limited === b[i].limited);
 }
 
 export const mergeGraph = (oldGraph, user, newOverview) => {
@@ -34,7 +34,8 @@ export const mergeGraph = (oldGraph, user, newOverview) => {
             id: env.environment.zId,
             label: env.environment.description,
             type: "environment",
-            val: 50
+            val: 50,
+            limited: env.limited
         };
         newGraph.nodes.push(envNode);
         newGraph.links.push({
@@ -53,6 +54,7 @@ export const mergeGraph = (oldGraph, user, newOverview) => {
                     envZId: env.environment.zId,
                     label: shrLabel,
                     type: "share",
+                    limited: !!shr.limited,
                     val: 50
                 };
                 newGraph.nodes.push(shrNode);
@@ -75,11 +77,11 @@ export const mergeGraph = (oldGraph, user, newOverview) => {
     // we're going to need to recompute a new graph... but we want to maintain the instances that already exist...
 
     // we want to preserve nodes that exist in the new graph, and remove those that don't.
-    let outputNodes = oldGraph.nodes.filter(oldNode => newGraph.nodes.find(newNode => newNode.id === oldNode.id));
+    let outputNodes = oldGraph.nodes.filter(oldNode => newGraph.nodes.find(newNode => newNode.id === oldNode.id && newNode.limited === oldNode.limited));
     let outputLinks = oldGraph.nodes.filter(oldLink => newGraph.links.find(newLink => newLink.target === oldLink.target && newLink.source === oldLink.source));
 
     // and then do the opposite; add any nodes that are in newGraph that are missing from oldGraph.
-    outputNodes.push(...newGraph.nodes.filter(newNode => !outputNodes.find(oldNode => oldNode.id === newNode.id)));
+    outputNodes.push(...newGraph.nodes.filter(newNode => !outputNodes.find(oldNode => oldNode.id === newNode.id && oldNode.limited === newNode.limited)));
     outputLinks.push(...newGraph.links.filter(newLink => !outputLinks.find(oldLink => oldLink.target === newLink.target && oldLink.source === newLink.source)));
 
     return {

From d718e8c9ffc0aba17b4c63e45ef5e09cb2e81b3b Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Mon, 15 May 2023 14:27:39 -0400
Subject: [PATCH 35/49] the icon overlay remains 'limited red', even when the
 node is selected (#320)

---
 ui/src/console/visualizer/Network.js | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/ui/src/console/visualizer/Network.js b/ui/src/console/visualizer/Network.js
index 9e8fda5f..eeee9af2 100644
--- a/ui/src/console/visualizer/Network.js
+++ b/ui/src/console/visualizer/Network.js
@@ -36,6 +36,9 @@ const Network = (props) => {
 
         const nodeIcon = new Path2D();
         let xform = new DOMMatrix();
+        if(node.limited) {
+            ctx.fillStyle = "#f00";
+        }
         xform.translateSelf(node.x - (nodeWidth / 2) - 6, node.y - 13);
         xform.scaleSelf(0.5, 0.5);
         switch(node.type) {
@@ -51,7 +54,6 @@ const Network = (props) => {
             default:
                 break;
         }
-
         ctx.fill(nodeIcon);
         ctx.strokeStyle = "black";
         ctx.lineWidth = 0.5;

From 9591f5150e3d66a411eee201a18ddac5b18b6054 Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Tue, 16 May 2023 11:51:03 -0400
Subject: [PATCH 36/49] new overview response for '/overview' endpoint (#320)

---
 controller/overview.go                        |  17 +--
 .../metadata/overview_responses.go            |   8 +-
 rest_model_zrok/overview.go                   | 103 ++++++++++++++++++
 rest_server_zrok/embedded_spec.go             |  26 ++++-
 .../operations/metadata/overview_responses.go |  19 ++--
 specs/zrok.yml                                |  10 +-
 ui/src/api/types.js                           |   8 ++
 ui/src/console/Console.js                     |   4 +-
 ui/src/console/visualizer/Visualizer.js       |   3 +-
 ui/src/console/visualizer/graph.js            |  74 +++++++------
 10 files changed, 210 insertions(+), 62 deletions(-)
 create mode 100644 rest_model_zrok/overview.go

diff --git a/controller/overview.go b/controller/overview.go
index ee313ba6..d7fabb82 100644
--- a/controller/overview.go
+++ b/controller/overview.go
@@ -20,14 +20,14 @@ func overviewHandler(_ metadata.OverviewParams, principal *rest_model_zrok.Princ
 		logrus.Errorf("error finding environments for '%v': %v", principal.Email, err)
 		return metadata.NewOverviewInternalServerError()
 	}
-	var out rest_model_zrok.EnvironmentSharesList
+	var envShrsList rest_model_zrok.EnvironmentSharesList
 	for _, env := range envs {
 		shrs, err := str.FindSharesForEnvironment(env.Id, tx)
 		if err != nil {
 			logrus.Errorf("error finding shares for environment '%v': %v", env.ZId, err)
 			return metadata.NewOverviewInternalServerError()
 		}
-		es := &rest_model_zrok.EnvironmentShares{
+		envShrs := &rest_model_zrok.EnvironmentShares{
 			Environment: &rest_model_zrok.Environment{
 				Address:     env.Address,
 				CreatedAt:   env.CreatedAt.UnixMilli(),
@@ -63,7 +63,7 @@ func overviewHandler(_ metadata.OverviewParams, principal *rest_model_zrok.Princ
 			if shr.BackendProxyEndpoint != nil {
 				beProxyEndpoint = *shr.BackendProxyEndpoint
 			}
-			oshr := &rest_model_zrok.Share{
+			envShr := &rest_model_zrok.Share{
 				Token:                shr.Token,
 				ZID:                  shr.ZId,
 				ShareMode:            shr.ShareMode,
@@ -77,12 +77,15 @@ func overviewHandler(_ metadata.OverviewParams, principal *rest_model_zrok.Princ
 			}
 			if action, found := shrsLimitedMap[shr.Id]; found {
 				if action == store.LimitAction {
-					oshr.Limited = true
+					envShr.Limited = true
 				}
 			}
-			es.Shares = append(es.Shares, oshr)
+			envShrs.Shares = append(envShrs.Shares, envShr)
 		}
-		out = append(out, es)
+		envShrsList = append(envShrsList, envShrs)
 	}
-	return metadata.NewOverviewOK().WithPayload(out)
+	return metadata.NewOverviewOK().WithPayload(&rest_model_zrok.Overview{
+		AccountLimited: false,
+		Environments:   envShrsList,
+	})
 }
diff --git a/rest_client_zrok/metadata/overview_responses.go b/rest_client_zrok/metadata/overview_responses.go
index af006875..c16b2a21 100644
--- a/rest_client_zrok/metadata/overview_responses.go
+++ b/rest_client_zrok/metadata/overview_responses.go
@@ -51,7 +51,7 @@ OverviewOK describes a response with status code 200, with default header values
 overview returned
 */
 type OverviewOK struct {
-	Payload rest_model_zrok.EnvironmentSharesList
+	Payload *rest_model_zrok.Overview
 }
 
 // IsSuccess returns true when this overview o k response has a 2xx status code
@@ -87,14 +87,16 @@ func (o *OverviewOK) String() string {
 	return fmt.Sprintf("[GET /overview][%d] overviewOK  %+v", 200, o.Payload)
 }
 
-func (o *OverviewOK) GetPayload() rest_model_zrok.EnvironmentSharesList {
+func (o *OverviewOK) GetPayload() *rest_model_zrok.Overview {
 	return o.Payload
 }
 
 func (o *OverviewOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error {
 
+	o.Payload = new(rest_model_zrok.Overview)
+
 	// response payload
-	if err := consumer.Consume(response.Body(), &o.Payload); err != nil && err != io.EOF {
+	if err := consumer.Consume(response.Body(), o.Payload); err != nil && err != io.EOF {
 		return err
 	}
 
diff --git a/rest_model_zrok/overview.go b/rest_model_zrok/overview.go
new file mode 100644
index 00000000..b4b7453e
--- /dev/null
+++ b/rest_model_zrok/overview.go
@@ -0,0 +1,103 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package rest_model_zrok
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"context"
+
+	"github.com/go-openapi/errors"
+	"github.com/go-openapi/strfmt"
+	"github.com/go-openapi/swag"
+)
+
+// Overview overview
+//
+// swagger:model overview
+type Overview struct {
+
+	// account limited
+	AccountLimited bool `json:"accountLimited,omitempty"`
+
+	// environments
+	Environments EnvironmentSharesList `json:"environments,omitempty"`
+}
+
+// Validate validates this overview
+func (m *Overview) Validate(formats strfmt.Registry) error {
+	var res []error
+
+	if err := m.validateEnvironments(formats); err != nil {
+		res = append(res, err)
+	}
+
+	if len(res) > 0 {
+		return errors.CompositeValidationError(res...)
+	}
+	return nil
+}
+
+func (m *Overview) validateEnvironments(formats strfmt.Registry) error {
+	if swag.IsZero(m.Environments) { // not required
+		return nil
+	}
+
+	if err := m.Environments.Validate(formats); err != nil {
+		if ve, ok := err.(*errors.Validation); ok {
+			return ve.ValidateName("environments")
+		} else if ce, ok := err.(*errors.CompositeError); ok {
+			return ce.ValidateName("environments")
+		}
+		return err
+	}
+
+	return nil
+}
+
+// ContextValidate validate this overview based on the context it is used
+func (m *Overview) ContextValidate(ctx context.Context, formats strfmt.Registry) error {
+	var res []error
+
+	if err := m.contextValidateEnvironments(ctx, formats); err != nil {
+		res = append(res, err)
+	}
+
+	if len(res) > 0 {
+		return errors.CompositeValidationError(res...)
+	}
+	return nil
+}
+
+func (m *Overview) contextValidateEnvironments(ctx context.Context, formats strfmt.Registry) error {
+
+	if err := m.Environments.ContextValidate(ctx, formats); err != nil {
+		if ve, ok := err.(*errors.Validation); ok {
+			return ve.ValidateName("environments")
+		} else if ce, ok := err.(*errors.CompositeError); ok {
+			return ce.ValidateName("environments")
+		}
+		return err
+	}
+
+	return nil
+}
+
+// MarshalBinary interface implementation
+func (m *Overview) MarshalBinary() ([]byte, error) {
+	if m == nil {
+		return nil, nil
+	}
+	return swag.WriteJSON(m)
+}
+
+// UnmarshalBinary interface implementation
+func (m *Overview) UnmarshalBinary(b []byte) error {
+	var res Overview
+	if err := swag.ReadJSON(b, &res); err != nil {
+		return err
+	}
+	*m = res
+	return nil
+}
diff --git a/rest_server_zrok/embedded_spec.go b/rest_server_zrok/embedded_spec.go
index 1d9d36a4..7d78648b 100644
--- a/rest_server_zrok/embedded_spec.go
+++ b/rest_server_zrok/embedded_spec.go
@@ -679,7 +679,7 @@ func init() {
           "200": {
             "description": "overview returned",
             "schema": {
-              "$ref": "#/definitions/environmentSharesList"
+              "$ref": "#/definitions/overview"
             }
           },
           "500": {
@@ -1211,6 +1211,17 @@ func init() {
         }
       }
     },
+    "overview": {
+      "type": "object",
+      "properties": {
+        "accountLimited": {
+          "type": "boolean"
+        },
+        "environments": {
+          "$ref": "#/definitions/environmentSharesList"
+        }
+      }
+    },
     "principal": {
       "type": "object",
       "properties": {
@@ -2156,7 +2167,7 @@ func init() {
           "200": {
             "description": "overview returned",
             "schema": {
-              "$ref": "#/definitions/environmentSharesList"
+              "$ref": "#/definitions/overview"
             }
           },
           "500": {
@@ -2688,6 +2699,17 @@ func init() {
         }
       }
     },
+    "overview": {
+      "type": "object",
+      "properties": {
+        "accountLimited": {
+          "type": "boolean"
+        },
+        "environments": {
+          "$ref": "#/definitions/environmentSharesList"
+        }
+      }
+    },
     "principal": {
       "type": "object",
       "properties": {
diff --git a/rest_server_zrok/operations/metadata/overview_responses.go b/rest_server_zrok/operations/metadata/overview_responses.go
index f64c45d9..57cd555d 100644
--- a/rest_server_zrok/operations/metadata/overview_responses.go
+++ b/rest_server_zrok/operations/metadata/overview_responses.go
@@ -26,7 +26,7 @@ type OverviewOK struct {
 	/*
 	  In: Body
 	*/
-	Payload rest_model_zrok.EnvironmentSharesList `json:"body,omitempty"`
+	Payload *rest_model_zrok.Overview `json:"body,omitempty"`
 }
 
 // NewOverviewOK creates OverviewOK with default headers values
@@ -36,13 +36,13 @@ func NewOverviewOK() *OverviewOK {
 }
 
 // WithPayload adds the payload to the overview o k response
-func (o *OverviewOK) WithPayload(payload rest_model_zrok.EnvironmentSharesList) *OverviewOK {
+func (o *OverviewOK) WithPayload(payload *rest_model_zrok.Overview) *OverviewOK {
 	o.Payload = payload
 	return o
 }
 
 // SetPayload sets the payload to the overview o k response
-func (o *OverviewOK) SetPayload(payload rest_model_zrok.EnvironmentSharesList) {
+func (o *OverviewOK) SetPayload(payload *rest_model_zrok.Overview) {
 	o.Payload = payload
 }
 
@@ -50,14 +50,11 @@ func (o *OverviewOK) SetPayload(payload rest_model_zrok.EnvironmentSharesList) {
 func (o *OverviewOK) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) {
 
 	rw.WriteHeader(200)
-	payload := o.Payload
-	if payload == nil {
-		// return empty array
-		payload = rest_model_zrok.EnvironmentSharesList{}
-	}
-
-	if err := producer.Produce(rw, payload); err != nil {
-		panic(err) // let the recovery middleware deal with this
+	if o.Payload != nil {
+		payload := o.Payload
+		if err := producer.Produce(rw, payload); err != nil {
+			panic(err) // let the recovery middleware deal with this
+		}
 	}
 }
 
diff --git a/specs/zrok.yml b/specs/zrok.yml
index 2394046f..634a01db 100644
--- a/specs/zrok.yml
+++ b/specs/zrok.yml
@@ -403,7 +403,7 @@ paths:
         200:
           description: overview returned
           schema:
-            $ref: "#/definitions/environmentSharesList"
+            $ref: "#/definitions/overview"
         500:
           description: internal server error
           schema:
@@ -785,6 +785,14 @@ definitions:
       timestamp:
         type: number
 
+  overview:
+    type: object
+    properties:
+      accountLimited:
+        type: boolean
+      environments:
+        $ref: "#/definitions/environmentSharesList"
+
   principal:
     type: object
     properties:
diff --git a/ui/src/api/types.js b/ui/src/api/types.js
index 0d2fb901..3c80956c 100644
--- a/ui/src/api/types.js
+++ b/ui/src/api/types.js
@@ -143,6 +143,14 @@
  * @property {number} timestamp 
  */
 
+/**
+ * @typedef overview
+ * @memberof module:types
+ * 
+ * @property {boolean} accountLimited 
+ * @property {module:types.environmentSharesList} environments 
+ */
+
 /**
  * @typedef principal
  * @memberof module:types
diff --git a/ui/src/console/Console.js b/ui/src/console/Console.js
index e615daca..12de5ed3 100644
--- a/ui/src/console/Console.js
+++ b/ui/src/console/Console.js
@@ -15,12 +15,13 @@ const Console = (props) => {
     const openVersionModal = () => setShowVersionModal(true);
     const closeVersionModal = () => setShowVersionModal(false);
 
-    const [overview, setOverview] = useState([]);
+    const [overview, setOverview] = useState({});
 
     useEffect(() => {
         let mounted = true;
         metadata.overview().then(resp => {
             if(mounted) {
+                console.log("init overview", resp.data);
                 setOverview(resp.data);
             }
         });
@@ -31,6 +32,7 @@ const Console = (props) => {
         let interval = setInterval(() => {
             metadata.overview().then(resp => {
                 if(mounted) {
+                    console.log("update overview", resp.data);
                     setOverview(resp.data);
                 }
             })
diff --git a/ui/src/console/visualizer/Visualizer.js b/ui/src/console/visualizer/Visualizer.js
index b3952a97..3341ea8f 100644
--- a/ui/src/console/visualizer/Visualizer.js
+++ b/ui/src/console/visualizer/Visualizer.js
@@ -10,7 +10,8 @@ const Visualizer = (props) => {
     const [networkGraph, setNetworkGraph] = useState({nodes: [], links: []});
 
     useEffect(() => {
-        setNetworkGraph(mergeGraph(networkGraph, props.user, props.overview));
+        console.log("visualizer overview", props.overview);
+        setNetworkGraph(mergeGraph(networkGraph, props.user, props.overview.environments));
 
         if(isSelectionGone(networkGraph, props.selection)) {
             // if the selection is no longer in the network graph...
diff --git a/ui/src/console/visualizer/graph.js b/ui/src/console/visualizer/graph.js
index f8d0006e..24b2c917 100644
--- a/ui/src/console/visualizer/graph.js
+++ b/ui/src/console/visualizer/graph.js
@@ -29,43 +29,45 @@ export const mergeGraph = (oldGraph, user, newOverview) => {
     }
     newGraph.nodes.push(accountNode);
 
-    newOverview.forEach(env => {
-        let envNode = {
-            id: env.environment.zId,
-            label: env.environment.description,
-            type: "environment",
-            val: 50,
-            limited: env.limited
-        };
-        newGraph.nodes.push(envNode);
-        newGraph.links.push({
-            target: accountNode.id,
-            source: envNode.id,
-            color: "#04adef"
-        });
-        if(env.shares) {
-            env.shares.forEach(shr => {
-                let shrLabel = shr.token;
-                if(shr.backendProxyEndpoint !== "") {
-                    shrLabel = shr.backendProxyEndpoint;
-                }
-                let shrNode = {
-                    id: shr.token,
-                    envZId: env.environment.zId,
-                    label: shrLabel,
-                    type: "share",
-                    limited: !!shr.limited,
-                    val: 50
-                };
-                newGraph.nodes.push(shrNode);
-                newGraph.links.push({
-                    target: envNode.id,
-                    source: shrNode.id,
-                    color: "#04adef"
-                });
+    if(newOverview) {
+        newOverview.forEach(env => {
+            let envNode = {
+                id: env.environment.zId,
+                label: env.environment.description,
+                type: "environment",
+                val: 50,
+                limited: env.limited
+            };
+            newGraph.nodes.push(envNode);
+            newGraph.links.push({
+                target: accountNode.id,
+                source: envNode.id,
+                color: "#04adef"
             });
-        }
-    });
+            if(env.shares) {
+                env.shares.forEach(shr => {
+                    let shrLabel = shr.token;
+                    if(shr.backendProxyEndpoint !== "") {
+                        shrLabel = shr.backendProxyEndpoint;
+                    }
+                    let shrNode = {
+                        id: shr.token,
+                        envZId: env.environment.zId,
+                        label: shrLabel,
+                        type: "share",
+                        limited: !!shr.limited,
+                        val: 50
+                    };
+                    newGraph.nodes.push(shrNode);
+                    newGraph.links.push({
+                        target: envNode.id,
+                        source: shrNode.id,
+                        color: "#04adef"
+                    });
+                });
+            }
+        });
+    }
     newGraph.nodes = sortNodes(newGraph.nodes);
 
     if(nodesEqual(oldGraph.nodes, newGraph.nodes)) {

From d0cedaf6e55619f24c33965426a9e36004e1adba Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Tue, 16 May 2023 12:05:30 -0400
Subject: [PATCH 37/49] infrastructure for detecting limited accounts (#320)

---
 controller/overview.go                  | 14 +++++++++++++-
 ui/src/console/visualizer/Visualizer.js |  2 +-
 ui/src/console/visualizer/graph.js      |  7 ++++---
 3 files changed, 18 insertions(+), 5 deletions(-)

diff --git a/controller/overview.go b/controller/overview.go
index d7fabb82..f557bc0d 100644
--- a/controller/overview.go
+++ b/controller/overview.go
@@ -84,8 +84,20 @@ func overviewHandler(_ metadata.OverviewParams, principal *rest_model_zrok.Princ
 		}
 		envShrsList = append(envShrsList, envShrs)
 	}
+	var alj *store.AccountLimitJournal
+	aljEmpty, err := str.IsAccountLimitJournalEmpty(int(principal.ID), tx)
+	if err != nil {
+		logrus.Errorf("error checking account limit journal for '%v': %v", principal.Email, err)
+	}
+	if !aljEmpty {
+		alj, err = str.FindLatestAccountLimitJournal(int(principal.ID), tx)
+		if err != nil {
+			logrus.Errorf("error getting latest account limit journal entry for '%v': %v", principal.Email, err)
+			return metadata.NewOverviewInternalServerError()
+		}
+	}
 	return metadata.NewOverviewOK().WithPayload(&rest_model_zrok.Overview{
-		AccountLimited: false,
+		AccountLimited: alj != nil && alj.Action == store.LimitAction,
 		Environments:   envShrsList,
 	})
 }
diff --git a/ui/src/console/visualizer/Visualizer.js b/ui/src/console/visualizer/Visualizer.js
index 3341ea8f..abb15db7 100644
--- a/ui/src/console/visualizer/Visualizer.js
+++ b/ui/src/console/visualizer/Visualizer.js
@@ -11,7 +11,7 @@ const Visualizer = (props) => {
 
     useEffect(() => {
         console.log("visualizer overview", props.overview);
-        setNetworkGraph(mergeGraph(networkGraph, props.user, props.overview.environments));
+        setNetworkGraph(mergeGraph(networkGraph, props.user, props.overview.accountLimited, props.overview.environments));
 
         if(isSelectionGone(networkGraph, props.selection)) {
             // if the selection is no longer in the network graph...
diff --git a/ui/src/console/visualizer/graph.js b/ui/src/console/visualizer/graph.js
index 24b2c917..0ea1b840 100644
--- a/ui/src/console/visualizer/graph.js
+++ b/ui/src/console/visualizer/graph.js
@@ -15,7 +15,7 @@ const nodesEqual = (a, b) => {
     return a.every((e, i) => e.id === b[i].id && e.limited === b[i].limited);
 }
 
-export const mergeGraph = (oldGraph, user, newOverview) => {
+export const mergeGraph = (oldGraph, user, accountLimited, newOverview) => {
     let newGraph = {
         nodes: [],
         links: []
@@ -25,6 +25,7 @@ export const mergeGraph = (oldGraph, user, newOverview) => {
         id: user.token,
         label: user.email,
         type: "account",
+        limited: !!accountLimited,
         val: 50
     }
     newGraph.nodes.push(accountNode);
@@ -36,7 +37,7 @@ export const mergeGraph = (oldGraph, user, newOverview) => {
                 label: env.environment.description,
                 type: "environment",
                 val: 50,
-                limited: env.limited
+                limited: !!env.limited || accountNode.limited
             };
             newGraph.nodes.push(envNode);
             newGraph.links.push({
@@ -55,7 +56,7 @@ export const mergeGraph = (oldGraph, user, newOverview) => {
                         envZId: env.environment.zId,
                         label: shrLabel,
                         type: "share",
-                        limited: !!shr.limited,
+                        limited: !!shr.limited || envNode.limited,
                         val: 50
                     };
                     newGraph.nodes.push(shrNode);

From 75376969ca8223208bba6897adbc144e6f4c9a99 Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Tue, 16 May 2023 13:45:43 -0400
Subject: [PATCH 38/49] limit details on explorer nodes (#320)

---
 controller/controller.go                    |   2 +-
 controller/overview.go                      | 134 ++++++++++++++------
 controller/store/environmentLimitJournal.go |  28 ++++
 ui/src/console/Console.js                   |   2 -
 ui/src/console/visualizer/Visualizer.js     |   1 -
 ui/src/console/visualizer/graph.js          |   5 +-
 6 files changed, 130 insertions(+), 42 deletions(-)

diff --git a/controller/controller.go b/controller/controller.go
index 18f189b7..70af7b81 100644
--- a/controller/controller.go
+++ b/controller/controller.go
@@ -55,7 +55,7 @@ func Run(inCfg *config.Config) error {
 	}
 	api.MetadataGetEnvironmentDetailHandler = newEnvironmentDetailHandler()
 	api.MetadataGetShareDetailHandler = newShareDetailHandler()
-	api.MetadataOverviewHandler = metadata.OverviewHandlerFunc(overviewHandler)
+	api.MetadataOverviewHandler = newOverviewHandler()
 	api.MetadataVersionHandler = metadata.VersionHandlerFunc(versionHandler)
 	api.ShareAccessHandler = newAccessHandler()
 	api.ShareShareHandler = newShareHandler()
diff --git a/controller/overview.go b/controller/overview.go
index f557bc0d..2615451c 100644
--- a/controller/overview.go
+++ b/controller/overview.go
@@ -2,54 +2,59 @@ package controller
 
 import (
 	"github.com/go-openapi/runtime/middleware"
+	"github.com/jmoiron/sqlx"
 	"github.com/openziti/zrok/controller/store"
 	"github.com/openziti/zrok/rest_model_zrok"
 	"github.com/openziti/zrok/rest_server_zrok/operations/metadata"
 	"github.com/sirupsen/logrus"
 )
 
-func overviewHandler(_ metadata.OverviewParams, principal *rest_model_zrok.Principal) middleware.Responder {
-	tx, err := str.Begin()
+type overviewHandler struct{}
+
+func newOverviewHandler() *overviewHandler {
+	return &overviewHandler{}
+}
+
+func (h *overviewHandler) Handle(_ metadata.OverviewParams, principal *rest_model_zrok.Principal) middleware.Responder {
+	trx, err := str.Begin()
 	if err != nil {
 		logrus.Errorf("error starting transaction: %v", err)
 		return metadata.NewOverviewInternalServerError()
 	}
-	defer func() { _ = tx.Rollback() }()
-	envs, err := str.FindEnvironmentsForAccount(int(principal.ID), tx)
+	defer func() { _ = trx.Rollback() }()
+	envs, err := str.FindEnvironmentsForAccount(int(principal.ID), trx)
 	if err != nil {
 		logrus.Errorf("error finding environments for '%v': %v", principal.Email, err)
 		return metadata.NewOverviewInternalServerError()
 	}
+	elm, err := newEnvironmentsLimitedMap(envs, trx)
+	if err != nil {
+		logrus.Errorf("error finding limited environments for '%v': %v", principal.Email, err)
+		return metadata.NewOverviewInternalServerError()
+	}
 	var envShrsList rest_model_zrok.EnvironmentSharesList
 	for _, env := range envs {
-		shrs, err := str.FindSharesForEnvironment(env.Id, tx)
+		shrs, err := str.FindSharesForEnvironment(env.Id, trx)
 		if err != nil {
 			logrus.Errorf("error finding shares for environment '%v': %v", env.ZId, err)
 			return metadata.NewOverviewInternalServerError()
 		}
+		slm, err := newSharesLimitedMap(shrs, trx)
+		if err != nil {
+			logrus.Errorf("error finding limited shares for environment '%v': %v", env.ZId, err)
+			return metadata.NewOverviewInternalServerError()
+		}
 		envShrs := &rest_model_zrok.EnvironmentShares{
 			Environment: &rest_model_zrok.Environment{
 				Address:     env.Address,
-				CreatedAt:   env.CreatedAt.UnixMilli(),
 				Description: env.Description,
 				Host:        env.Host,
-				UpdatedAt:   env.UpdatedAt.UnixMilli(),
 				ZID:         env.ZId,
+				Limited:     elm.isLimited(env),
+				CreatedAt:   env.CreatedAt.UnixMilli(),
+				UpdatedAt:   env.UpdatedAt.UnixMilli(),
 			},
 		}
-		var shrIds []int
-		for i := range shrs {
-			shrIds = append(shrIds, shrs[i].Id)
-		}
-		shrsLimited, err := str.FindSelectedLatestShareLimitjournal(shrIds, tx)
-		if err != nil {
-			logrus.Errorf("error finding limited shares for environment '%v': %v", env.ZId, err)
-			return metadata.NewOverviewInternalServerError()
-		}
-		shrsLimitedMap := make(map[int]store.LimitJournalAction)
-		for i := range shrsLimited {
-			shrsLimitedMap[shrsLimited[i].ShareId] = shrsLimited[i].Action
-		}
 		for _, shr := range shrs {
 			feEndpoint := ""
 			if shr.FrontendEndpoint != nil {
@@ -72,32 +77,89 @@ func overviewHandler(_ metadata.OverviewParams, principal *rest_model_zrok.Princ
 				FrontendEndpoint:     feEndpoint,
 				BackendProxyEndpoint: beProxyEndpoint,
 				Reserved:             shr.Reserved,
+				Limited:              slm.isLimited(shr),
 				CreatedAt:            shr.CreatedAt.UnixMilli(),
 				UpdatedAt:            shr.UpdatedAt.UnixMilli(),
 			}
-			if action, found := shrsLimitedMap[shr.Id]; found {
-				if action == store.LimitAction {
-					envShr.Limited = true
-				}
-			}
 			envShrs.Shares = append(envShrs.Shares, envShr)
 		}
 		envShrsList = append(envShrsList, envShrs)
 	}
-	var alj *store.AccountLimitJournal
-	aljEmpty, err := str.IsAccountLimitJournalEmpty(int(principal.ID), tx)
+	accountLimited, err := h.isAccountLimited(principal, trx)
 	if err != nil {
-		logrus.Errorf("error checking account limit journal for '%v': %v", principal.Email, err)
-	}
-	if !aljEmpty {
-		alj, err = str.FindLatestAccountLimitJournal(int(principal.ID), tx)
-		if err != nil {
-			logrus.Errorf("error getting latest account limit journal entry for '%v': %v", principal.Email, err)
-			return metadata.NewOverviewInternalServerError()
-		}
+		logrus.Errorf("error checking account limited for '%v': %v", principal.Email, err)
 	}
 	return metadata.NewOverviewOK().WithPayload(&rest_model_zrok.Overview{
-		AccountLimited: alj != nil && alj.Action == store.LimitAction,
+		AccountLimited: accountLimited,
 		Environments:   envShrsList,
 	})
 }
+
+func (h *overviewHandler) isAccountLimited(principal *rest_model_zrok.Principal, trx *sqlx.Tx) (bool, error) {
+	var alj *store.AccountLimitJournal
+	aljEmpty, err := str.IsAccountLimitJournalEmpty(int(principal.ID), trx)
+	if err != nil {
+		return false, err
+	}
+	if !aljEmpty {
+		alj, err = str.FindLatestAccountLimitJournal(int(principal.ID), trx)
+		if err != nil {
+			return false, err
+		}
+	}
+	return alj != nil && alj.Action == store.LimitAction, nil
+}
+
+type sharesLimitedMap struct {
+	v map[int]struct{}
+}
+
+func newSharesLimitedMap(shrs []*store.Share, trx *sqlx.Tx) (*sharesLimitedMap, error) {
+	var shrIds []int
+	for i := range shrs {
+		shrIds = append(shrIds, shrs[i].Id)
+	}
+	shrsLimited, err := str.FindSelectedLatestShareLimitjournal(shrIds, trx)
+	if err != nil {
+		return nil, err
+	}
+	slm := &sharesLimitedMap{v: make(map[int]struct{})}
+	for i := range shrsLimited {
+		if shrsLimited[i].Action == store.LimitAction {
+			slm.v[shrsLimited[i].ShareId] = struct{}{}
+		}
+	}
+	return slm, nil
+}
+
+func (m *sharesLimitedMap) isLimited(shr *store.Share) bool {
+	_, limited := m.v[shr.Id]
+	return limited
+}
+
+type environmentsLimitedMap struct {
+	v map[int]struct{}
+}
+
+func newEnvironmentsLimitedMap(envs []*store.Environment, trx *sqlx.Tx) (*environmentsLimitedMap, error) {
+	var envIds []int
+	for i := range envs {
+		envIds = append(envIds, envs[i].Id)
+	}
+	envsLimited, err := str.FindSelectedLatestEnvironmentLimitJournal(envIds, trx)
+	if err != nil {
+		return nil, err
+	}
+	elm := &environmentsLimitedMap{v: make(map[int]struct{})}
+	for i := range envsLimited {
+		if envsLimited[i].Action == store.LimitAction {
+			elm.v[envsLimited[i].EnvironmentId] = struct{}{}
+		}
+	}
+	return elm, nil
+}
+
+func (m *environmentsLimitedMap) isLimited(env *store.Environment) bool {
+	_, limited := m.v[env.Id]
+	return limited
+}
diff --git a/controller/store/environmentLimitJournal.go b/controller/store/environmentLimitJournal.go
index 5a7a2963..b3b3d3cc 100644
--- a/controller/store/environmentLimitJournal.go
+++ b/controller/store/environmentLimitJournal.go
@@ -1,6 +1,7 @@
 package store
 
 import (
+	"fmt"
 	"github.com/jmoiron/sqlx"
 	"github.com/pkg/errors"
 )
@@ -41,6 +42,33 @@ func (str *Store) FindLatestEnvironmentLimitJournal(envId int, trx *sqlx.Tx) (*E
 	return j, nil
 }
 
+func (str *Store) FindSelectedLatestEnvironmentLimitJournal(envIds []int, trx *sqlx.Tx) ([]*EnvironmentLimitJournal, error) {
+	if len(envIds) < 1 {
+		return nil, nil
+	}
+	in := "("
+	for i := range envIds {
+		if i > 0 {
+			in += ", "
+		}
+		in += fmt.Sprintf("%d", envIds[i])
+	}
+	in += ")"
+	rows, err := trx.Queryx("select id, environment_id, rx_bytes, tx_bytes, action, created_at, updated_at from environment_limit_journal where id in (select max(id) as id from environment_limit_journal group by environment_id) and environment_id in " + in)
+	if err != nil {
+		return nil, errors.Wrap(err, "error selecting all latest environment_limit_journal")
+	}
+	var eljs []*EnvironmentLimitJournal
+	for rows.Next() {
+		elj := &EnvironmentLimitJournal{}
+		if err := rows.StructScan(elj); err != nil {
+			return nil, errors.Wrap(err, "error scanning environment_limit_journal")
+		}
+		eljs = append(eljs, elj)
+	}
+	return eljs, nil
+}
+
 func (str *Store) FindAllLatestEnvironmentLimitJournal(trx *sqlx.Tx) ([]*EnvironmentLimitJournal, error) {
 	rows, err := trx.Queryx("select id, environment_id, rx_bytes, tx_bytes, action, created_at, updated_at from environment_limit_journal where id in (select max(id) as id from environment_limit_journal group by environment_id)")
 	if err != nil {
diff --git a/ui/src/console/Console.js b/ui/src/console/Console.js
index 12de5ed3..fc36ae1c 100644
--- a/ui/src/console/Console.js
+++ b/ui/src/console/Console.js
@@ -21,7 +21,6 @@ const Console = (props) => {
         let mounted = true;
         metadata.overview().then(resp => {
             if(mounted) {
-                console.log("init overview", resp.data);
                 setOverview(resp.data);
             }
         });
@@ -32,7 +31,6 @@ const Console = (props) => {
         let interval = setInterval(() => {
             metadata.overview().then(resp => {
                 if(mounted) {
-                    console.log("update overview", resp.data);
                     setOverview(resp.data);
                 }
             })
diff --git a/ui/src/console/visualizer/Visualizer.js b/ui/src/console/visualizer/Visualizer.js
index abb15db7..54182ae2 100644
--- a/ui/src/console/visualizer/Visualizer.js
+++ b/ui/src/console/visualizer/Visualizer.js
@@ -10,7 +10,6 @@ const Visualizer = (props) => {
     const [networkGraph, setNetworkGraph] = useState({nodes: [], links: []});
 
     useEffect(() => {
-        console.log("visualizer overview", props.overview);
         setNetworkGraph(mergeGraph(networkGraph, props.user, props.overview.accountLimited, props.overview.environments));
 
         if(isSelectionGone(networkGraph, props.selection)) {
diff --git a/ui/src/console/visualizer/graph.js b/ui/src/console/visualizer/graph.js
index 0ea1b840..92625c89 100644
--- a/ui/src/console/visualizer/graph.js
+++ b/ui/src/console/visualizer/graph.js
@@ -32,12 +32,13 @@ export const mergeGraph = (oldGraph, user, accountLimited, newOverview) => {
 
     if(newOverview) {
         newOverview.forEach(env => {
+            let limited = !!env.limited;
             let envNode = {
                 id: env.environment.zId,
                 label: env.environment.description,
                 type: "environment",
-                val: 50,
-                limited: !!env.limited || accountNode.limited
+                limited: !!env.environment.limited || accountNode.limited,
+                val: 50
             };
             newGraph.nodes.push(envNode);
             newGraph.links.push({

From 8a9e02e46439ca44cac5a69342a0d444792fece2 Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Wed, 17 May 2023 11:23:16 -0400
Subject: [PATCH 39/49] env+shrs -> env+res; making space to return frontends
 (#323)

---
 controller/environmentDetail.go               |   2 +-
 controller/overview.go                        |  21 +-
 .../get_environment_detail_responses.go       |   6 +-
 rest_model_zrok/environment_and_resources.go  | 188 ++++++++++++++++++
 rest_model_zrok/frontend.go                   |  59 ++++++
 rest_model_zrok/frontends.go                  |  73 +++++++
 rest_model_zrok/overview.go                   |  42 ++--
 rest_server_zrok/embedded_spec.go             |  82 ++++++--
 .../get_environment_detail_responses.go       |   6 +-
 specs/zrok.yml                                |  32 ++-
 ui/src/api/metadata.js                        |   2 +-
 ui/src/api/types.js                           |  15 +-
 12 files changed, 467 insertions(+), 61 deletions(-)
 create mode 100644 rest_model_zrok/environment_and_resources.go
 create mode 100644 rest_model_zrok/frontend.go
 create mode 100644 rest_model_zrok/frontends.go

diff --git a/controller/environmentDetail.go b/controller/environmentDetail.go
index 7b31a33d..5f401ad2 100644
--- a/controller/environmentDetail.go
+++ b/controller/environmentDetail.go
@@ -25,7 +25,7 @@ func (h *environmentDetailHandler) Handle(params metadata.GetEnvironmentDetailPa
 		logrus.Errorf("environment '%v' not found for account '%v': %v", params.EnvZID, principal.Email, err)
 		return metadata.NewGetEnvironmentDetailNotFound()
 	}
-	es := &rest_model_zrok.EnvironmentShares{
+	es := &rest_model_zrok.EnvironmentAndResources{
 		Environment: &rest_model_zrok.Environment{
 			Address:     senv.Address,
 			CreatedAt:   senv.CreatedAt.UnixMilli(),
diff --git a/controller/overview.go b/controller/overview.go
index 2615451c..72870826 100644
--- a/controller/overview.go
+++ b/controller/overview.go
@@ -32,7 +32,11 @@ func (h *overviewHandler) Handle(_ metadata.OverviewParams, principal *rest_mode
 		logrus.Errorf("error finding limited environments for '%v': %v", principal.Email, err)
 		return metadata.NewOverviewInternalServerError()
 	}
-	var envShrsList rest_model_zrok.EnvironmentSharesList
+	accountLimited, err := h.isAccountLimited(principal, trx)
+	if err != nil {
+		logrus.Errorf("error checking account limited for '%v': %v", principal.Email, err)
+	}
+	ovr := &rest_model_zrok.Overview{AccountLimited: accountLimited}
 	for _, env := range envs {
 		shrs, err := str.FindSharesForEnvironment(env.Id, trx)
 		if err != nil {
@@ -44,7 +48,7 @@ func (h *overviewHandler) Handle(_ metadata.OverviewParams, principal *rest_mode
 			logrus.Errorf("error finding limited shares for environment '%v': %v", env.ZId, err)
 			return metadata.NewOverviewInternalServerError()
 		}
-		envShrs := &rest_model_zrok.EnvironmentShares{
+		envRes := &rest_model_zrok.EnvironmentAndResources{
 			Environment: &rest_model_zrok.Environment{
 				Address:     env.Address,
 				Description: env.Description,
@@ -81,18 +85,11 @@ func (h *overviewHandler) Handle(_ metadata.OverviewParams, principal *rest_mode
 				CreatedAt:            shr.CreatedAt.UnixMilli(),
 				UpdatedAt:            shr.UpdatedAt.UnixMilli(),
 			}
-			envShrs.Shares = append(envShrs.Shares, envShr)
+			envRes.Shares = append(envRes.Shares, envShr)
 		}
-		envShrsList = append(envShrsList, envShrs)
+		ovr.Environments = append(ovr.Environments, envRes)
 	}
-	accountLimited, err := h.isAccountLimited(principal, trx)
-	if err != nil {
-		logrus.Errorf("error checking account limited for '%v': %v", principal.Email, err)
-	}
-	return metadata.NewOverviewOK().WithPayload(&rest_model_zrok.Overview{
-		AccountLimited: accountLimited,
-		Environments:   envShrsList,
-	})
+	return metadata.NewOverviewOK().WithPayload(ovr)
 }
 
 func (h *overviewHandler) isAccountLimited(principal *rest_model_zrok.Principal, trx *sqlx.Tx) (bool, error) {
diff --git a/rest_client_zrok/metadata/get_environment_detail_responses.go b/rest_client_zrok/metadata/get_environment_detail_responses.go
index df5b305d..35d86c4c 100644
--- a/rest_client_zrok/metadata/get_environment_detail_responses.go
+++ b/rest_client_zrok/metadata/get_environment_detail_responses.go
@@ -63,7 +63,7 @@ GetEnvironmentDetailOK describes a response with status code 200, with default h
 ok
 */
 type GetEnvironmentDetailOK struct {
-	Payload *rest_model_zrok.EnvironmentShares
+	Payload *rest_model_zrok.EnvironmentAndResources
 }
 
 // IsSuccess returns true when this get environment detail o k response has a 2xx status code
@@ -99,13 +99,13 @@ func (o *GetEnvironmentDetailOK) String() string {
 	return fmt.Sprintf("[GET /detail/environment/{envZId}][%d] getEnvironmentDetailOK  %+v", 200, o.Payload)
 }
 
-func (o *GetEnvironmentDetailOK) GetPayload() *rest_model_zrok.EnvironmentShares {
+func (o *GetEnvironmentDetailOK) GetPayload() *rest_model_zrok.EnvironmentAndResources {
 	return o.Payload
 }
 
 func (o *GetEnvironmentDetailOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error {
 
-	o.Payload = new(rest_model_zrok.EnvironmentShares)
+	o.Payload = new(rest_model_zrok.EnvironmentAndResources)
 
 	// response payload
 	if err := consumer.Consume(response.Body(), o.Payload); err != nil && err != io.EOF {
diff --git a/rest_model_zrok/environment_and_resources.go b/rest_model_zrok/environment_and_resources.go
new file mode 100644
index 00000000..493d2818
--- /dev/null
+++ b/rest_model_zrok/environment_and_resources.go
@@ -0,0 +1,188 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package rest_model_zrok
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"context"
+
+	"github.com/go-openapi/errors"
+	"github.com/go-openapi/strfmt"
+	"github.com/go-openapi/swag"
+)
+
+// EnvironmentAndResources environment and resources
+//
+// swagger:model environmentAndResources
+type EnvironmentAndResources struct {
+
+	// environment
+	Environment *Environment `json:"environment,omitempty"`
+
+	// frontends
+	Frontends Frontends `json:"frontends,omitempty"`
+
+	// shares
+	Shares Shares `json:"shares,omitempty"`
+}
+
+// Validate validates this environment and resources
+func (m *EnvironmentAndResources) Validate(formats strfmt.Registry) error {
+	var res []error
+
+	if err := m.validateEnvironment(formats); err != nil {
+		res = append(res, err)
+	}
+
+	if err := m.validateFrontends(formats); err != nil {
+		res = append(res, err)
+	}
+
+	if err := m.validateShares(formats); err != nil {
+		res = append(res, err)
+	}
+
+	if len(res) > 0 {
+		return errors.CompositeValidationError(res...)
+	}
+	return nil
+}
+
+func (m *EnvironmentAndResources) validateEnvironment(formats strfmt.Registry) error {
+	if swag.IsZero(m.Environment) { // not required
+		return nil
+	}
+
+	if m.Environment != nil {
+		if err := m.Environment.Validate(formats); err != nil {
+			if ve, ok := err.(*errors.Validation); ok {
+				return ve.ValidateName("environment")
+			} else if ce, ok := err.(*errors.CompositeError); ok {
+				return ce.ValidateName("environment")
+			}
+			return err
+		}
+	}
+
+	return nil
+}
+
+func (m *EnvironmentAndResources) validateFrontends(formats strfmt.Registry) error {
+	if swag.IsZero(m.Frontends) { // not required
+		return nil
+	}
+
+	if err := m.Frontends.Validate(formats); err != nil {
+		if ve, ok := err.(*errors.Validation); ok {
+			return ve.ValidateName("frontends")
+		} else if ce, ok := err.(*errors.CompositeError); ok {
+			return ce.ValidateName("frontends")
+		}
+		return err
+	}
+
+	return nil
+}
+
+func (m *EnvironmentAndResources) validateShares(formats strfmt.Registry) error {
+	if swag.IsZero(m.Shares) { // not required
+		return nil
+	}
+
+	if err := m.Shares.Validate(formats); err != nil {
+		if ve, ok := err.(*errors.Validation); ok {
+			return ve.ValidateName("shares")
+		} else if ce, ok := err.(*errors.CompositeError); ok {
+			return ce.ValidateName("shares")
+		}
+		return err
+	}
+
+	return nil
+}
+
+// ContextValidate validate this environment and resources based on the context it is used
+func (m *EnvironmentAndResources) ContextValidate(ctx context.Context, formats strfmt.Registry) error {
+	var res []error
+
+	if err := m.contextValidateEnvironment(ctx, formats); err != nil {
+		res = append(res, err)
+	}
+
+	if err := m.contextValidateFrontends(ctx, formats); err != nil {
+		res = append(res, err)
+	}
+
+	if err := m.contextValidateShares(ctx, formats); err != nil {
+		res = append(res, err)
+	}
+
+	if len(res) > 0 {
+		return errors.CompositeValidationError(res...)
+	}
+	return nil
+}
+
+func (m *EnvironmentAndResources) contextValidateEnvironment(ctx context.Context, formats strfmt.Registry) error {
+
+	if m.Environment != nil {
+		if err := m.Environment.ContextValidate(ctx, formats); err != nil {
+			if ve, ok := err.(*errors.Validation); ok {
+				return ve.ValidateName("environment")
+			} else if ce, ok := err.(*errors.CompositeError); ok {
+				return ce.ValidateName("environment")
+			}
+			return err
+		}
+	}
+
+	return nil
+}
+
+func (m *EnvironmentAndResources) contextValidateFrontends(ctx context.Context, formats strfmt.Registry) error {
+
+	if err := m.Frontends.ContextValidate(ctx, formats); err != nil {
+		if ve, ok := err.(*errors.Validation); ok {
+			return ve.ValidateName("frontends")
+		} else if ce, ok := err.(*errors.CompositeError); ok {
+			return ce.ValidateName("frontends")
+		}
+		return err
+	}
+
+	return nil
+}
+
+func (m *EnvironmentAndResources) contextValidateShares(ctx context.Context, formats strfmt.Registry) error {
+
+	if err := m.Shares.ContextValidate(ctx, formats); err != nil {
+		if ve, ok := err.(*errors.Validation); ok {
+			return ve.ValidateName("shares")
+		} else if ce, ok := err.(*errors.CompositeError); ok {
+			return ce.ValidateName("shares")
+		}
+		return err
+	}
+
+	return nil
+}
+
+// MarshalBinary interface implementation
+func (m *EnvironmentAndResources) MarshalBinary() ([]byte, error) {
+	if m == nil {
+		return nil, nil
+	}
+	return swag.WriteJSON(m)
+}
+
+// UnmarshalBinary interface implementation
+func (m *EnvironmentAndResources) UnmarshalBinary(b []byte) error {
+	var res EnvironmentAndResources
+	if err := swag.ReadJSON(b, &res); err != nil {
+		return err
+	}
+	*m = res
+	return nil
+}
diff --git a/rest_model_zrok/frontend.go b/rest_model_zrok/frontend.go
new file mode 100644
index 00000000..65cc237d
--- /dev/null
+++ b/rest_model_zrok/frontend.go
@@ -0,0 +1,59 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package rest_model_zrok
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"context"
+
+	"github.com/go-openapi/strfmt"
+	"github.com/go-openapi/swag"
+)
+
+// Frontend frontend
+//
+// swagger:model frontend
+type Frontend struct {
+
+	// created at
+	CreatedAt int64 `json:"createdAt,omitempty"`
+
+	// shr token
+	ShrToken string `json:"shrToken,omitempty"`
+
+	// updated at
+	UpdatedAt int64 `json:"updatedAt,omitempty"`
+
+	// z Id
+	ZID string `json:"zId,omitempty"`
+}
+
+// Validate validates this frontend
+func (m *Frontend) Validate(formats strfmt.Registry) error {
+	return nil
+}
+
+// ContextValidate validates this frontend based on context it is used
+func (m *Frontend) ContextValidate(ctx context.Context, formats strfmt.Registry) error {
+	return nil
+}
+
+// MarshalBinary interface implementation
+func (m *Frontend) MarshalBinary() ([]byte, error) {
+	if m == nil {
+		return nil, nil
+	}
+	return swag.WriteJSON(m)
+}
+
+// UnmarshalBinary interface implementation
+func (m *Frontend) UnmarshalBinary(b []byte) error {
+	var res Frontend
+	if err := swag.ReadJSON(b, &res); err != nil {
+		return err
+	}
+	*m = res
+	return nil
+}
diff --git a/rest_model_zrok/frontends.go b/rest_model_zrok/frontends.go
new file mode 100644
index 00000000..62f23b2a
--- /dev/null
+++ b/rest_model_zrok/frontends.go
@@ -0,0 +1,73 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package rest_model_zrok
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"context"
+	"strconv"
+
+	"github.com/go-openapi/errors"
+	"github.com/go-openapi/strfmt"
+	"github.com/go-openapi/swag"
+)
+
+// Frontends frontends
+//
+// swagger:model frontends
+type Frontends []*Frontend
+
+// Validate validates this frontends
+func (m Frontends) Validate(formats strfmt.Registry) error {
+	var res []error
+
+	for i := 0; i < len(m); i++ {
+		if swag.IsZero(m[i]) { // not required
+			continue
+		}
+
+		if m[i] != nil {
+			if err := m[i].Validate(formats); err != nil {
+				if ve, ok := err.(*errors.Validation); ok {
+					return ve.ValidateName(strconv.Itoa(i))
+				} else if ce, ok := err.(*errors.CompositeError); ok {
+					return ce.ValidateName(strconv.Itoa(i))
+				}
+				return err
+			}
+		}
+
+	}
+
+	if len(res) > 0 {
+		return errors.CompositeValidationError(res...)
+	}
+	return nil
+}
+
+// ContextValidate validate this frontends based on the context it is used
+func (m Frontends) ContextValidate(ctx context.Context, formats strfmt.Registry) error {
+	var res []error
+
+	for i := 0; i < len(m); i++ {
+
+		if m[i] != nil {
+			if err := m[i].ContextValidate(ctx, formats); err != nil {
+				if ve, ok := err.(*errors.Validation); ok {
+					return ve.ValidateName(strconv.Itoa(i))
+				} else if ce, ok := err.(*errors.CompositeError); ok {
+					return ce.ValidateName(strconv.Itoa(i))
+				}
+				return err
+			}
+		}
+
+	}
+
+	if len(res) > 0 {
+		return errors.CompositeValidationError(res...)
+	}
+	return nil
+}
diff --git a/rest_model_zrok/overview.go b/rest_model_zrok/overview.go
index b4b7453e..fc2dbeb0 100644
--- a/rest_model_zrok/overview.go
+++ b/rest_model_zrok/overview.go
@@ -7,6 +7,7 @@ package rest_model_zrok
 
 import (
 	"context"
+	"strconv"
 
 	"github.com/go-openapi/errors"
 	"github.com/go-openapi/strfmt"
@@ -22,7 +23,7 @@ type Overview struct {
 	AccountLimited bool `json:"accountLimited,omitempty"`
 
 	// environments
-	Environments EnvironmentSharesList `json:"environments,omitempty"`
+	Environments []*EnvironmentAndResources `json:"environments"`
 }
 
 // Validate validates this overview
@@ -44,13 +45,22 @@ func (m *Overview) validateEnvironments(formats strfmt.Registry) error {
 		return nil
 	}
 
-	if err := m.Environments.Validate(formats); err != nil {
-		if ve, ok := err.(*errors.Validation); ok {
-			return ve.ValidateName("environments")
-		} else if ce, ok := err.(*errors.CompositeError); ok {
-			return ce.ValidateName("environments")
+	for i := 0; i < len(m.Environments); i++ {
+		if swag.IsZero(m.Environments[i]) { // not required
+			continue
 		}
-		return err
+
+		if m.Environments[i] != nil {
+			if err := m.Environments[i].Validate(formats); err != nil {
+				if ve, ok := err.(*errors.Validation); ok {
+					return ve.ValidateName("environments" + "." + strconv.Itoa(i))
+				} else if ce, ok := err.(*errors.CompositeError); ok {
+					return ce.ValidateName("environments" + "." + strconv.Itoa(i))
+				}
+				return err
+			}
+		}
+
 	}
 
 	return nil
@@ -72,13 +82,19 @@ func (m *Overview) ContextValidate(ctx context.Context, formats strfmt.Registry)
 
 func (m *Overview) contextValidateEnvironments(ctx context.Context, formats strfmt.Registry) error {
 
-	if err := m.Environments.ContextValidate(ctx, formats); err != nil {
-		if ve, ok := err.(*errors.Validation); ok {
-			return ve.ValidateName("environments")
-		} else if ce, ok := err.(*errors.CompositeError); ok {
-			return ce.ValidateName("environments")
+	for i := 0; i < len(m.Environments); i++ {
+
+		if m.Environments[i] != nil {
+			if err := m.Environments[i].ContextValidate(ctx, formats); err != nil {
+				if ve, ok := err.(*errors.Validation); ok {
+					return ve.ValidateName("environments" + "." + strconv.Itoa(i))
+				} else if ce, ok := err.(*errors.CompositeError); ok {
+					return ce.ValidateName("environments" + "." + strconv.Itoa(i))
+				}
+				return err
+			}
 		}
-		return err
+
 	}
 
 	return nil
diff --git a/rest_server_zrok/embedded_spec.go b/rest_server_zrok/embedded_spec.go
index 7d78648b..0ac1aecc 100644
--- a/rest_server_zrok/embedded_spec.go
+++ b/rest_server_zrok/embedded_spec.go
@@ -137,7 +137,7 @@ func init() {
           "200": {
             "description": "ok",
             "schema": {
-              "$ref": "#/definitions/environmentShares"
+              "$ref": "#/definitions/environmentAndResources"
             }
           },
           "401": {
@@ -1115,23 +1115,20 @@ func init() {
         }
       }
     },
-    "environmentShares": {
+    "environmentAndResources": {
       "type": "object",
       "properties": {
         "environment": {
           "$ref": "#/definitions/environment"
         },
+        "frontends": {
+          "$ref": "#/definitions/frontends"
+        },
         "shares": {
           "$ref": "#/definitions/shares"
         }
       }
     },
-    "environmentSharesList": {
-      "type": "array",
-      "items": {
-        "$ref": "#/definitions/environmentShares"
-      }
-    },
     "environments": {
       "type": "array",
       "items": {
@@ -1141,6 +1138,29 @@ func init() {
     "errorMessage": {
       "type": "string"
     },
+    "frontend": {
+      "type": "object",
+      "properties": {
+        "createdAt": {
+          "type": "integer"
+        },
+        "shrToken": {
+          "type": "string"
+        },
+        "updatedAt": {
+          "type": "integer"
+        },
+        "zId": {
+          "type": "string"
+        }
+      }
+    },
+    "frontends": {
+      "type": "array",
+      "items": {
+        "$ref": "#/definitions/frontend"
+      }
+    },
     "inviteRequest": {
       "type": "object",
       "properties": {
@@ -1218,7 +1238,10 @@ func init() {
           "type": "boolean"
         },
         "environments": {
-          "$ref": "#/definitions/environmentSharesList"
+          "type": "array",
+          "items": {
+            "$ref": "#/definitions/environmentAndResources"
+          }
         }
       }
     },
@@ -1625,7 +1648,7 @@ func init() {
           "200": {
             "description": "ok",
             "schema": {
-              "$ref": "#/definitions/environmentShares"
+              "$ref": "#/definitions/environmentAndResources"
             }
           },
           "401": {
@@ -2603,23 +2626,20 @@ func init() {
         }
       }
     },
-    "environmentShares": {
+    "environmentAndResources": {
       "type": "object",
       "properties": {
         "environment": {
           "$ref": "#/definitions/environment"
         },
+        "frontends": {
+          "$ref": "#/definitions/frontends"
+        },
         "shares": {
           "$ref": "#/definitions/shares"
         }
       }
     },
-    "environmentSharesList": {
-      "type": "array",
-      "items": {
-        "$ref": "#/definitions/environmentShares"
-      }
-    },
     "environments": {
       "type": "array",
       "items": {
@@ -2629,6 +2649,29 @@ func init() {
     "errorMessage": {
       "type": "string"
     },
+    "frontend": {
+      "type": "object",
+      "properties": {
+        "createdAt": {
+          "type": "integer"
+        },
+        "shrToken": {
+          "type": "string"
+        },
+        "updatedAt": {
+          "type": "integer"
+        },
+        "zId": {
+          "type": "string"
+        }
+      }
+    },
+    "frontends": {
+      "type": "array",
+      "items": {
+        "$ref": "#/definitions/frontend"
+      }
+    },
     "inviteRequest": {
       "type": "object",
       "properties": {
@@ -2706,7 +2749,10 @@ func init() {
           "type": "boolean"
         },
         "environments": {
-          "$ref": "#/definitions/environmentSharesList"
+          "type": "array",
+          "items": {
+            "$ref": "#/definitions/environmentAndResources"
+          }
         }
       }
     },
diff --git a/rest_server_zrok/operations/metadata/get_environment_detail_responses.go b/rest_server_zrok/operations/metadata/get_environment_detail_responses.go
index 18ad7c1e..ff2623b3 100644
--- a/rest_server_zrok/operations/metadata/get_environment_detail_responses.go
+++ b/rest_server_zrok/operations/metadata/get_environment_detail_responses.go
@@ -26,7 +26,7 @@ type GetEnvironmentDetailOK struct {
 	/*
 	  In: Body
 	*/
-	Payload *rest_model_zrok.EnvironmentShares `json:"body,omitempty"`
+	Payload *rest_model_zrok.EnvironmentAndResources `json:"body,omitempty"`
 }
 
 // NewGetEnvironmentDetailOK creates GetEnvironmentDetailOK with default headers values
@@ -36,13 +36,13 @@ func NewGetEnvironmentDetailOK() *GetEnvironmentDetailOK {
 }
 
 // WithPayload adds the payload to the get environment detail o k response
-func (o *GetEnvironmentDetailOK) WithPayload(payload *rest_model_zrok.EnvironmentShares) *GetEnvironmentDetailOK {
+func (o *GetEnvironmentDetailOK) WithPayload(payload *rest_model_zrok.EnvironmentAndResources) *GetEnvironmentDetailOK {
 	o.Payload = payload
 	return o
 }
 
 // SetPayload sets the payload to the get environment detail o k response
-func (o *GetEnvironmentDetailOK) SetPayload(payload *rest_model_zrok.EnvironmentShares) {
+func (o *GetEnvironmentDetailOK) SetPayload(payload *rest_model_zrok.EnvironmentAndResources) {
 	o.Payload = payload
 }
 
diff --git a/specs/zrok.yml b/specs/zrok.yml
index 634a01db..c8f4e458 100644
--- a/specs/zrok.yml
+++ b/specs/zrok.yml
@@ -360,7 +360,7 @@ paths:
         200:
           description: ok
           schema:
-            $ref: "#/definitions/environmentShares"
+            $ref: "#/definitions/environmentAndResources"
         401:
           description: unauthorized
         404:
@@ -718,22 +718,36 @@ definitions:
     items:
       $ref: "#/definitions/environment"
 
-  environmentSharesList:
-    type: array
-    items:
-      $ref: "#/definitions/environmentShares"
-
-  environmentShares:
+  environmentAndResources:
     type: object
     properties:
       environment:
         $ref: "#/definitions/environment"
+      frontends:
+        $ref: "#/definitions/frontends"
       shares:
         $ref: "#/definitions/shares"
 
   errorMessage:
     type: string
 
+  frontend:
+    type: object
+    properties:
+      shrToken:
+        type: string
+      zId:
+        type: string
+      createdAt:
+        type: integer
+      updatedAt:
+        type: integer
+
+  frontends:
+    type: array
+    items:
+      $ref: "#/definitions/frontend"
+
   inviteTokenGenerateRequest:
     type: object
     properties:
@@ -791,7 +805,9 @@ definitions:
       accountLimited:
         type: boolean
       environments:
-        $ref: "#/definitions/environmentSharesList"
+        type: array
+        items:
+          $ref: "#/definitions/environmentAndResources"
 
   principal:
     type: object
diff --git a/ui/src/api/metadata.js b/ui/src/api/metadata.js
index 1b50438f..2ff94807 100644
--- a/ui/src/api/metadata.js
+++ b/ui/src/api/metadata.js
@@ -16,7 +16,7 @@ export function getAccountDetail() {
 
 /**
  * @param {string} envZId 
- * @return {Promise<module:types.environmentShares>} ok
+ * @return {Promise<module:types.environmentAndResources>} ok
  */
 export function getEnvironmentDetail(envZId) {
   const parameters = {
diff --git a/ui/src/api/types.js b/ui/src/api/types.js
index 3c80956c..ae81ebab 100644
--- a/ui/src/api/types.js
+++ b/ui/src/api/types.js
@@ -94,13 +94,24 @@
  */
 
 /**
- * @typedef environmentShares
+ * @typedef environmentAndResources
  * @memberof module:types
  * 
  * @property {module:types.environment} environment 
+ * @property {module:types.frontends} frontends 
  * @property {module:types.shares} shares 
  */
 
+/**
+ * @typedef frontend
+ * @memberof module:types
+ * 
+ * @property {string} shrToken 
+ * @property {string} zId 
+ * @property {number} createdAt 
+ * @property {number} updatedAt 
+ */
+
 /**
  * @typedef inviteTokenGenerateRequest
  * @memberof module:types
@@ -148,7 +159,7 @@
  * @memberof module:types
  * 
  * @property {boolean} accountLimited 
- * @property {module:types.environmentSharesList} environments 
+ * @property {module:types.environmentAndResources[]} environments 
  */
 
 /**

From bdf1a32d6a12cc4406bd460612ae1579c6b155d7 Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Wed, 17 May 2023 11:39:05 -0400
Subject: [PATCH 40/49] improved limit indicators (#320)

---
 ui/src/console/visualizer/Network.js | 19 ++++++++++++++-----
 1 file changed, 14 insertions(+), 5 deletions(-)

diff --git a/ui/src/console/visualizer/Network.js b/ui/src/console/visualizer/Network.js
index eeee9af2..e27929e9 100644
--- a/ui/src/console/visualizer/Network.js
+++ b/ui/src/console/visualizer/Network.js
@@ -3,10 +3,11 @@ import {useEffect, useRef} from "react";
 import {ForceGraph2D} from "react-force-graph";
 import * as d3 from "d3-force-3d";
 import {roundRect} from "./draw";
-import {mdiShareVariant, mdiConsoleNetwork, mdiAccountBox} from "@mdi/js";
+import {mdiShareVariant, mdiConsoleNetwork, mdiAccountBox, mdiAlertCircle} from "@mdi/js";
 
 const accountIcon = new Path2D(mdiAccountBox);
 const environmentIcon = new Path2D(mdiConsoleNetwork);
+const limitIcon = new Path2D(mdiAlertCircle);
 const shareIcon = new Path2D(mdiShareVariant);
 
 const Network = (props) => {
@@ -21,7 +22,7 @@ const Network = (props) => {
     }, []);
 
     const paintNode = (node, ctx) => {
-        let nodeColor = node.selected ? "#9BF316" : node.limited ? "#f00": "#04adef";
+        let nodeColor = node.selected ? "#9BF316" : "#04adef";
         let textColor = "black";
 
         ctx.textBaseline = "middle";
@@ -36,9 +37,6 @@ const Network = (props) => {
 
         const nodeIcon = new Path2D();
         let xform = new DOMMatrix();
-        if(node.limited) {
-            ctx.fillStyle = "#f00";
-        }
         xform.translateSelf(node.x - (nodeWidth / 2) - 6, node.y - 13);
         xform.scaleSelf(0.5, 0.5);
         switch(node.type) {
@@ -59,6 +57,17 @@ const Network = (props) => {
         ctx.lineWidth = 0.5;
         ctx.stroke(nodeIcon);
 
+        if(node.limited) {
+            const nodeLimitIcon = new Path2D();
+            let limitXform = new DOMMatrix();
+            limitXform.translateSelf(node.x + (nodeWidth / 2) - 6, node.y - 13);
+            limitXform.scaleSelf(0.5, 0.5);
+            nodeLimitIcon.addPath(limitIcon, limitXform);
+            ctx.fillStyle = "red";
+            ctx.fill(nodeLimitIcon);
+            ctx.stroke(nodeLimitIcon);
+        }
+
         ctx.fillStyle = textColor;
         ctx.fillText(node.label, node.x, node.y);
     }

From 1aaba521c7472e1171185724a2f80b7e28146e17 Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Wed, 17 May 2023 11:45:48 -0400
Subject: [PATCH 41/49] fix for bad network requests before component ready

---
 ui/src/console/detail/share/MetricsTab.js | 48 +++++++++++++----------
 1 file changed, 28 insertions(+), 20 deletions(-)

diff --git a/ui/src/console/detail/share/MetricsTab.js b/ui/src/console/detail/share/MetricsTab.js
index d73bff6f..b5289eaf 100644
--- a/ui/src/console/detail/share/MetricsTab.js
+++ b/ui/src/console/detail/share/MetricsTab.js
@@ -9,28 +9,10 @@ const MetricsTab = (props) => {
     const [metrics1, setMetrics1] = useState(buildMetrics([]));
 
     useEffect(() => {
-        metadata.getShareMetrics(props.share.token)
-            .then(resp => {
-                setMetrics30(buildMetrics(resp.data));
-            });
-        metadata.getShareMetrics(props.share.token, {duration: "168h"})
-            .then(resp => {
-                setMetrics7(buildMetrics(resp.data));
-            });
-        metadata.getShareMetrics(props.share.token, {duration: "24h"})
-            .then(resp => {
-                setMetrics1(buildMetrics(resp.data));
-            });
-    }, [props.share]);
-
-    useEffect(() => {
-        let mounted = true;
-        let interval = setInterval(() => {
+        if(props.share.token) {
             metadata.getShareMetrics(props.share.token)
                 .then(resp => {
-                    if(mounted) {
-                        setMetrics30(buildMetrics(resp.data));
-                    }
+                    setMetrics30(buildMetrics(resp.data));
                 });
             metadata.getShareMetrics(props.share.token, {duration: "168h"})
                 .then(resp => {
@@ -40,6 +22,32 @@ const MetricsTab = (props) => {
                 .then(resp => {
                     setMetrics1(buildMetrics(resp.data));
                 });
+        }
+    }, [props.share]);
+
+    useEffect(() => {
+        let mounted = true;
+        let interval = setInterval(() => {
+            if(props.share.token) {
+                metadata.getShareMetrics(props.share.token)
+                    .then(resp => {
+                        if(mounted) {
+                            setMetrics30(buildMetrics(resp.data));
+                        }
+                    });
+                metadata.getShareMetrics(props.share.token, {duration: "168h"})
+                    .then(resp => {
+                        if(mounted) {
+                            setMetrics7(buildMetrics(resp.data));
+                        }
+                    });
+                metadata.getShareMetrics(props.share.token, {duration: "24h"})
+                    .then(resp => {
+                        if(mounted) {
+                            setMetrics1(buildMetrics(resp.data));
+                        }
+                    });
+            }
         }, 5000);
         return () => {
             mounted = false;

From 9893f3f87453ca4671c960ed82a94e542dde2911 Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Wed, 17 May 2023 11:50:10 -0400
Subject: [PATCH 42/49] more alerty (#320)

---
 ui/src/console/visualizer/Network.js | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/ui/src/console/visualizer/Network.js b/ui/src/console/visualizer/Network.js
index e27929e9..f05e4b79 100644
--- a/ui/src/console/visualizer/Network.js
+++ b/ui/src/console/visualizer/Network.js
@@ -3,11 +3,11 @@ import {useEffect, useRef} from "react";
 import {ForceGraph2D} from "react-force-graph";
 import * as d3 from "d3-force-3d";
 import {roundRect} from "./draw";
-import {mdiShareVariant, mdiConsoleNetwork, mdiAccountBox, mdiAlertCircle} from "@mdi/js";
+import {mdiShareVariant, mdiConsoleNetwork, mdiAccountBox, mdiAlertOctagram} from "@mdi/js";
 
 const accountIcon = new Path2D(mdiAccountBox);
 const environmentIcon = new Path2D(mdiConsoleNetwork);
-const limitIcon = new Path2D(mdiAlertCircle);
+const limitIcon = new Path2D(mdiAlertOctagram);
 const shareIcon = new Path2D(mdiShareVariant);
 
 const Network = (props) => {

From 4119ab4e2bf7f727f7a6c5171c871c723d83815f Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Wed, 17 May 2023 12:46:10 -0400
Subject: [PATCH 43/49] icons

---
 ui/src/console/visualizer/Network.js | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/ui/src/console/visualizer/Network.js b/ui/src/console/visualizer/Network.js
index f05e4b79..86858266 100644
--- a/ui/src/console/visualizer/Network.js
+++ b/ui/src/console/visualizer/Network.js
@@ -3,10 +3,10 @@ import {useEffect, useRef} from "react";
 import {ForceGraph2D} from "react-force-graph";
 import * as d3 from "d3-force-3d";
 import {roundRect} from "./draw";
-import {mdiShareVariant, mdiConsoleNetwork, mdiAccountBox, mdiAlertOctagram} from "@mdi/js";
+import {mdiShareVariant, mdiConsole, mdiAccount, mdiAlertOctagram} from "@mdi/js";
 
-const accountIcon = new Path2D(mdiAccountBox);
-const environmentIcon = new Path2D(mdiConsoleNetwork);
+const accountIcon = new Path2D(mdiAccount);
+const environmentIcon = new Path2D(mdiConsole);
 const limitIcon = new Path2D(mdiAlertOctagram);
 const shareIcon = new Path2D(mdiShareVariant);
 

From 05b53df6ba3ed4ec640582ff5b8b95c9964af3b0 Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Wed, 17 May 2023 13:21:01 -0400
Subject: [PATCH 44/49] frontends and data plane connections to owned shares
 (#323)

---
 controller/overview.go               | 42 +++++++++++++++++++++-------
 rest_model_zrok/frontend.go          |  3 ++
 rest_server_zrok/embedded_spec.go    |  6 ++++
 specs/zrok.yml                       |  2 ++
 ui/src/api/types.js                  |  1 +
 ui/src/console/visualizer/Network.js |  9 ++++--
 ui/src/console/visualizer/graph.js   | 32 +++++++++++++++++++++
 7 files changed, 83 insertions(+), 12 deletions(-)

diff --git a/controller/overview.go b/controller/overview.go
index 72870826..5c3bb6f8 100644
--- a/controller/overview.go
+++ b/controller/overview.go
@@ -38,16 +38,6 @@ func (h *overviewHandler) Handle(_ metadata.OverviewParams, principal *rest_mode
 	}
 	ovr := &rest_model_zrok.Overview{AccountLimited: accountLimited}
 	for _, env := range envs {
-		shrs, err := str.FindSharesForEnvironment(env.Id, trx)
-		if err != nil {
-			logrus.Errorf("error finding shares for environment '%v': %v", env.ZId, err)
-			return metadata.NewOverviewInternalServerError()
-		}
-		slm, err := newSharesLimitedMap(shrs, trx)
-		if err != nil {
-			logrus.Errorf("error finding limited shares for environment '%v': %v", env.ZId, err)
-			return metadata.NewOverviewInternalServerError()
-		}
 		envRes := &rest_model_zrok.EnvironmentAndResources{
 			Environment: &rest_model_zrok.Environment{
 				Address:     env.Address,
@@ -59,6 +49,16 @@ func (h *overviewHandler) Handle(_ metadata.OverviewParams, principal *rest_mode
 				UpdatedAt:   env.UpdatedAt.UnixMilli(),
 			},
 		}
+		shrs, err := str.FindSharesForEnvironment(env.Id, trx)
+		if err != nil {
+			logrus.Errorf("error finding shares for environment '%v': %v", env.ZId, err)
+			return metadata.NewOverviewInternalServerError()
+		}
+		slm, err := newSharesLimitedMap(shrs, trx)
+		if err != nil {
+			logrus.Errorf("error finding limited shares for environment '%v': %v", env.ZId, err)
+			return metadata.NewOverviewInternalServerError()
+		}
 		for _, shr := range shrs {
 			feEndpoint := ""
 			if shr.FrontendEndpoint != nil {
@@ -87,6 +87,28 @@ func (h *overviewHandler) Handle(_ metadata.OverviewParams, principal *rest_mode
 			}
 			envRes.Shares = append(envRes.Shares, envShr)
 		}
+		fes, err := str.FindFrontendsForEnvironment(env.Id, trx)
+		if err != nil {
+			logrus.Errorf("error finding frontends for environment '%v': %v", env.ZId, err)
+			return metadata.NewOverviewInternalServerError()
+		}
+		for _, fe := range fes {
+			envFe := &rest_model_zrok.Frontend{
+				ID:        int64(fe.Id),
+				ZID:       fe.ZId,
+				CreatedAt: fe.CreatedAt.UnixMilli(),
+				UpdatedAt: fe.UpdatedAt.UnixMilli(),
+			}
+			if fe.PrivateShareId != nil {
+				feShr, err := str.GetShare(*fe.PrivateShareId, trx)
+				if err != nil {
+					logrus.Errorf("error getting share for frontend '%v': %v", fe.ZId, err)
+					return metadata.NewOverviewInternalServerError()
+				}
+				envFe.ShrToken = feShr.Token
+			}
+			envRes.Frontends = append(envRes.Frontends, envFe)
+		}
 		ovr.Environments = append(ovr.Environments, envRes)
 	}
 	return metadata.NewOverviewOK().WithPayload(ovr)
diff --git a/rest_model_zrok/frontend.go b/rest_model_zrok/frontend.go
index 65cc237d..3185dcaf 100644
--- a/rest_model_zrok/frontend.go
+++ b/rest_model_zrok/frontend.go
@@ -20,6 +20,9 @@ type Frontend struct {
 	// created at
 	CreatedAt int64 `json:"createdAt,omitempty"`
 
+	// id
+	ID int64 `json:"id,omitempty"`
+
 	// shr token
 	ShrToken string `json:"shrToken,omitempty"`
 
diff --git a/rest_server_zrok/embedded_spec.go b/rest_server_zrok/embedded_spec.go
index 0ac1aecc..40dd52ea 100644
--- a/rest_server_zrok/embedded_spec.go
+++ b/rest_server_zrok/embedded_spec.go
@@ -1144,6 +1144,9 @@ func init() {
         "createdAt": {
           "type": "integer"
         },
+        "id": {
+          "type": "integer"
+        },
         "shrToken": {
           "type": "string"
         },
@@ -2655,6 +2658,9 @@ func init() {
         "createdAt": {
           "type": "integer"
         },
+        "id": {
+          "type": "integer"
+        },
         "shrToken": {
           "type": "string"
         },
diff --git a/specs/zrok.yml b/specs/zrok.yml
index c8f4e458..bf0ce90f 100644
--- a/specs/zrok.yml
+++ b/specs/zrok.yml
@@ -734,6 +734,8 @@ definitions:
   frontend:
     type: object
     properties:
+      id:
+        type: integer
       shrToken:
         type: string
       zId:
diff --git a/ui/src/api/types.js b/ui/src/api/types.js
index ae81ebab..b55e3d00 100644
--- a/ui/src/api/types.js
+++ b/ui/src/api/types.js
@@ -106,6 +106,7 @@
  * @typedef frontend
  * @memberof module:types
  * 
+ * @property {number} id 
  * @property {string} shrToken 
  * @property {string} zId 
  * @property {number} createdAt 
diff --git a/ui/src/console/visualizer/Network.js b/ui/src/console/visualizer/Network.js
index 86858266..2206a185 100644
--- a/ui/src/console/visualizer/Network.js
+++ b/ui/src/console/visualizer/Network.js
@@ -3,10 +3,11 @@ import {useEffect, useRef} from "react";
 import {ForceGraph2D} from "react-force-graph";
 import * as d3 from "d3-force-3d";
 import {roundRect} from "./draw";
-import {mdiShareVariant, mdiConsole, mdiAccount, mdiAlertOctagram} from "@mdi/js";
+import {mdiShareVariant, mdiConsole, mdiAccount, mdiAlertOctagram, mdiAccessPointNetwork} from "@mdi/js";
 
 const accountIcon = new Path2D(mdiAccount);
 const environmentIcon = new Path2D(mdiConsole);
+const frontendIcon = new Path2D(mdiAccessPointNetwork);
 const limitIcon = new Path2D(mdiAlertOctagram);
 const shareIcon = new Path2D(mdiShareVariant);
 
@@ -46,6 +47,9 @@ const Network = (props) => {
             case "environment":
                 nodeIcon.addPath(environmentIcon, xform);
                 break;
+            case "frontend":
+                nodeIcon.addPath(frontendIcon, xform);
+                break;
             case "account":
                 nodeIcon.addPath(accountIcon, xform);
                 break;
@@ -84,7 +88,8 @@ const Network = (props) => {
             height={800}
             onNodeClick={nodeClicked}
             linkOpacity={.75}
-            linkWidth={1.5}
+            linkWidth={(l) => l.type === "data" ? 3.0 : 1.5 }
+            linkLineDash={(l) => l.type === "data" ? [3, 3] : [] }
             nodeCanvasObject={paintNode}
             backgroundColor={"linear-gradient(180deg, #0E0238 0%, #231069 100%);"}
             cooldownTicks={300}
diff --git a/ui/src/console/visualizer/graph.js b/ui/src/console/visualizer/graph.js
index 92625c89..afbd1e82 100644
--- a/ui/src/console/visualizer/graph.js
+++ b/ui/src/console/visualizer/graph.js
@@ -31,6 +31,8 @@ export const mergeGraph = (oldGraph, user, accountLimited, newOverview) => {
     newGraph.nodes.push(accountNode);
 
     if(newOverview) {
+        let allShares = {};
+        let allFrontends = [];
         newOverview.forEach(env => {
             let limited = !!env.limited;
             let envNode = {
@@ -60,6 +62,7 @@ export const mergeGraph = (oldGraph, user, accountLimited, newOverview) => {
                         limited: !!shr.limited || envNode.limited,
                         val: 50
                     };
+                    allShares[shr.token] = shrNode;
                     newGraph.nodes.push(shrNode);
                     newGraph.links.push({
                         target: envNode.id,
@@ -68,6 +71,35 @@ export const mergeGraph = (oldGraph, user, accountLimited, newOverview) => {
                     });
                 });
             }
+            if(env.frontends) {
+                env.frontends.forEach(fe => {
+                   let feNode = {
+                       id: fe.id,
+                       target: fe.shrToken,
+                       label: fe.shrToken,
+                       type: "frontend",
+                       val: 50
+                   }
+                   allFrontends.push(feNode);
+                   newGraph.nodes.push(feNode);
+                   newGraph.links.push({
+                       target: envNode.id,
+                       source: feNode.id,
+                       color: "#04adef"
+                   });
+                });
+            }
+        });
+        allFrontends.forEach(fe => {
+            let target = allShares[fe.target];
+            if(target) {
+                newGraph.links.push({
+                    target: target.id,
+                    source: fe.id,
+                    color: "#9BF316",
+                    type: "data",
+                });
+            }
         });
     }
     newGraph.nodes = sortNodes(newGraph.nodes);

From 7963ba83b0e1ba341fea00759fffb8b0a2aa5419 Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Wed, 17 May 2023 13:53:18 -0400
Subject: [PATCH 45/49] frontend details (#323)

---
 controller/controller.go                      |   1 +
 controller/frontendDetail.go                  |  60 ++++
 .../get_frontend_detail_parameters.go         | 149 ++++++++++
 .../metadata/get_frontend_detail_responses.go | 269 ++++++++++++++++++
 rest_client_zrok/metadata/metadata_client.go  |  41 +++
 rest_server_zrok/embedded_spec.go             |  76 +++++
 .../metadata/get_frontend_detail.go           |  71 +++++
 .../get_frontend_detail_parameters.go         |  77 +++++
 .../metadata/get_frontend_detail_responses.go | 134 +++++++++
 .../get_frontend_detail_urlbuilder.go         | 101 +++++++
 rest_server_zrok/operations/zrok_api.go       |  12 +
 specs/zrok.yml                                |  24 ++
 ui/src/api/metadata.js                        |  23 ++
 ui/src/console/detail/Detail.js               |   6 +
 ui/src/console/detail/access/AccessDetail.js  |  30 ++
 ui/src/console/detail/access/DetailTab.js     |  14 +
 16 files changed, 1088 insertions(+)
 create mode 100644 controller/frontendDetail.go
 create mode 100644 rest_client_zrok/metadata/get_frontend_detail_parameters.go
 create mode 100644 rest_client_zrok/metadata/get_frontend_detail_responses.go
 create mode 100644 rest_server_zrok/operations/metadata/get_frontend_detail.go
 create mode 100644 rest_server_zrok/operations/metadata/get_frontend_detail_parameters.go
 create mode 100644 rest_server_zrok/operations/metadata/get_frontend_detail_responses.go
 create mode 100644 rest_server_zrok/operations/metadata/get_frontend_detail_urlbuilder.go
 create mode 100644 ui/src/console/detail/access/AccessDetail.js
 create mode 100644 ui/src/console/detail/access/DetailTab.js

diff --git a/controller/controller.go b/controller/controller.go
index 70af7b81..15288e3b 100644
--- a/controller/controller.go
+++ b/controller/controller.go
@@ -54,6 +54,7 @@ func Run(inCfg *config.Config) error {
 		api.MetadataGetShareMetricsHandler = newGetShareMetricsHandler(cfg.Metrics.Influx)
 	}
 	api.MetadataGetEnvironmentDetailHandler = newEnvironmentDetailHandler()
+	api.MetadataGetFrontendDetailHandler = newGetFrontendDetailHandler()
 	api.MetadataGetShareDetailHandler = newShareDetailHandler()
 	api.MetadataOverviewHandler = newOverviewHandler()
 	api.MetadataVersionHandler = metadata.VersionHandlerFunc(versionHandler)
diff --git a/controller/frontendDetail.go b/controller/frontendDetail.go
new file mode 100644
index 00000000..aca79322
--- /dev/null
+++ b/controller/frontendDetail.go
@@ -0,0 +1,60 @@
+package controller
+
+import (
+	"github.com/go-openapi/runtime/middleware"
+	"github.com/openziti/zrok/rest_model_zrok"
+	"github.com/openziti/zrok/rest_server_zrok/operations/metadata"
+	"github.com/sirupsen/logrus"
+)
+
+type getFrontendDetailHandler struct{}
+
+func newGetFrontendDetailHandler() *getFrontendDetailHandler {
+	return &getFrontendDetailHandler{}
+}
+
+func (h *getFrontendDetailHandler) Handle(params metadata.GetFrontendDetailParams, principal *rest_model_zrok.Principal) middleware.Responder {
+	trx, err := str.Begin()
+	if err != nil {
+		logrus.Errorf("error starting transaction: %v", err)
+		return metadata.NewGetFrontendDetailInternalServerError()
+	}
+	defer func() { _ = trx.Rollback() }()
+	fe, err := str.GetFrontend(int(params.FeID), trx)
+	if err != nil {
+		logrus.Errorf("error finding share '%d': %v", params.FeID, err)
+		return metadata.NewGetFrontendDetailNotFound()
+	}
+	envs, err := str.FindEnvironmentsForAccount(int(principal.ID), trx)
+	if err != nil {
+		logrus.Errorf("error finding environments for account '%v': %v", principal.Email, err)
+		return metadata.NewGetFrontendDetailInternalServerError()
+	}
+	found := false
+	if fe.EnvironmentId == nil {
+		logrus.Errorf("non owned environment '%d' for '%v'", fe.Id, principal.Email)
+		return metadata.NewGetFrontendDetailNotFound()
+	}
+	for _, env := range envs {
+		if *fe.EnvironmentId == env.Id {
+			found = true
+			break
+		}
+	}
+	if !found {
+		logrus.Errorf("environment not matched for frontend '%d' for account '%v'", fe.Id, principal.Email)
+		return metadata.NewGetFrontendDetailNotFound()
+	}
+	shr, err := str.GetShare(fe.Id, trx)
+	if err != nil {
+		logrus.Errorf("error getting share for frontend '%d': %v", fe.Id, err)
+		return metadata.NewGetFrontendDetailInternalServerError()
+	}
+	return metadata.NewGetFrontendDetailOK().WithPayload(&rest_model_zrok.Frontend{
+		ID:        int64(fe.Id),
+		ShrToken:  shr.Token,
+		ZID:       fe.ZId,
+		CreatedAt: fe.CreatedAt.UnixMilli(),
+		UpdatedAt: fe.UpdatedAt.UnixMilli(),
+	})
+}
diff --git a/rest_client_zrok/metadata/get_frontend_detail_parameters.go b/rest_client_zrok/metadata/get_frontend_detail_parameters.go
new file mode 100644
index 00000000..4ce868af
--- /dev/null
+++ b/rest_client_zrok/metadata/get_frontend_detail_parameters.go
@@ -0,0 +1,149 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metadata
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"context"
+	"net/http"
+	"time"
+
+	"github.com/go-openapi/errors"
+	"github.com/go-openapi/runtime"
+	cr "github.com/go-openapi/runtime/client"
+	"github.com/go-openapi/strfmt"
+	"github.com/go-openapi/swag"
+)
+
+// NewGetFrontendDetailParams creates a new GetFrontendDetailParams object,
+// with the default timeout for this client.
+//
+// Default values are not hydrated, since defaults are normally applied by the API server side.
+//
+// To enforce default values in parameter, use SetDefaults or WithDefaults.
+func NewGetFrontendDetailParams() *GetFrontendDetailParams {
+	return &GetFrontendDetailParams{
+		timeout: cr.DefaultTimeout,
+	}
+}
+
+// NewGetFrontendDetailParamsWithTimeout creates a new GetFrontendDetailParams object
+// with the ability to set a timeout on a request.
+func NewGetFrontendDetailParamsWithTimeout(timeout time.Duration) *GetFrontendDetailParams {
+	return &GetFrontendDetailParams{
+		timeout: timeout,
+	}
+}
+
+// NewGetFrontendDetailParamsWithContext creates a new GetFrontendDetailParams object
+// with the ability to set a context for a request.
+func NewGetFrontendDetailParamsWithContext(ctx context.Context) *GetFrontendDetailParams {
+	return &GetFrontendDetailParams{
+		Context: ctx,
+	}
+}
+
+// NewGetFrontendDetailParamsWithHTTPClient creates a new GetFrontendDetailParams object
+// with the ability to set a custom HTTPClient for a request.
+func NewGetFrontendDetailParamsWithHTTPClient(client *http.Client) *GetFrontendDetailParams {
+	return &GetFrontendDetailParams{
+		HTTPClient: client,
+	}
+}
+
+/*
+GetFrontendDetailParams contains all the parameters to send to the API endpoint
+
+	for the get frontend detail operation.
+
+	Typically these are written to a http.Request.
+*/
+type GetFrontendDetailParams struct {
+
+	// FeID.
+	FeID int64
+
+	timeout    time.Duration
+	Context    context.Context
+	HTTPClient *http.Client
+}
+
+// WithDefaults hydrates default values in the get frontend detail params (not the query body).
+//
+// All values with no default are reset to their zero value.
+func (o *GetFrontendDetailParams) WithDefaults() *GetFrontendDetailParams {
+	o.SetDefaults()
+	return o
+}
+
+// SetDefaults hydrates default values in the get frontend detail params (not the query body).
+//
+// All values with no default are reset to their zero value.
+func (o *GetFrontendDetailParams) SetDefaults() {
+	// no default values defined for this parameter
+}
+
+// WithTimeout adds the timeout to the get frontend detail params
+func (o *GetFrontendDetailParams) WithTimeout(timeout time.Duration) *GetFrontendDetailParams {
+	o.SetTimeout(timeout)
+	return o
+}
+
+// SetTimeout adds the timeout to the get frontend detail params
+func (o *GetFrontendDetailParams) SetTimeout(timeout time.Duration) {
+	o.timeout = timeout
+}
+
+// WithContext adds the context to the get frontend detail params
+func (o *GetFrontendDetailParams) WithContext(ctx context.Context) *GetFrontendDetailParams {
+	o.SetContext(ctx)
+	return o
+}
+
+// SetContext adds the context to the get frontend detail params
+func (o *GetFrontendDetailParams) SetContext(ctx context.Context) {
+	o.Context = ctx
+}
+
+// WithHTTPClient adds the HTTPClient to the get frontend detail params
+func (o *GetFrontendDetailParams) WithHTTPClient(client *http.Client) *GetFrontendDetailParams {
+	o.SetHTTPClient(client)
+	return o
+}
+
+// SetHTTPClient adds the HTTPClient to the get frontend detail params
+func (o *GetFrontendDetailParams) SetHTTPClient(client *http.Client) {
+	o.HTTPClient = client
+}
+
+// WithFeID adds the feID to the get frontend detail params
+func (o *GetFrontendDetailParams) WithFeID(feID int64) *GetFrontendDetailParams {
+	o.SetFeID(feID)
+	return o
+}
+
+// SetFeID adds the feId to the get frontend detail params
+func (o *GetFrontendDetailParams) SetFeID(feID int64) {
+	o.FeID = feID
+}
+
+// WriteToRequest writes these params to a swagger request
+func (o *GetFrontendDetailParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error {
+
+	if err := r.SetTimeout(o.timeout); err != nil {
+		return err
+	}
+	var res []error
+
+	// path param feId
+	if err := r.SetPathParam("feId", swag.FormatInt64(o.FeID)); err != nil {
+		return err
+	}
+
+	if len(res) > 0 {
+		return errors.CompositeValidationError(res...)
+	}
+	return nil
+}
diff --git a/rest_client_zrok/metadata/get_frontend_detail_responses.go b/rest_client_zrok/metadata/get_frontend_detail_responses.go
new file mode 100644
index 00000000..90058d0b
--- /dev/null
+++ b/rest_client_zrok/metadata/get_frontend_detail_responses.go
@@ -0,0 +1,269 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metadata
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"fmt"
+	"io"
+
+	"github.com/go-openapi/runtime"
+	"github.com/go-openapi/strfmt"
+
+	"github.com/openziti/zrok/rest_model_zrok"
+)
+
+// GetFrontendDetailReader is a Reader for the GetFrontendDetail structure.
+type GetFrontendDetailReader struct {
+	formats strfmt.Registry
+}
+
+// ReadResponse reads a server response into the received o.
+func (o *GetFrontendDetailReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (interface{}, error) {
+	switch response.Code() {
+	case 200:
+		result := NewGetFrontendDetailOK()
+		if err := result.readResponse(response, consumer, o.formats); err != nil {
+			return nil, err
+		}
+		return result, nil
+	case 401:
+		result := NewGetFrontendDetailUnauthorized()
+		if err := result.readResponse(response, consumer, o.formats); err != nil {
+			return nil, err
+		}
+		return nil, result
+	case 404:
+		result := NewGetFrontendDetailNotFound()
+		if err := result.readResponse(response, consumer, o.formats); err != nil {
+			return nil, err
+		}
+		return nil, result
+	case 500:
+		result := NewGetFrontendDetailInternalServerError()
+		if err := result.readResponse(response, consumer, o.formats); err != nil {
+			return nil, err
+		}
+		return nil, result
+	default:
+		return nil, runtime.NewAPIError("response status code does not match any response statuses defined for this endpoint in the swagger spec", response, response.Code())
+	}
+}
+
+// NewGetFrontendDetailOK creates a GetFrontendDetailOK with default headers values
+func NewGetFrontendDetailOK() *GetFrontendDetailOK {
+	return &GetFrontendDetailOK{}
+}
+
+/*
+GetFrontendDetailOK describes a response with status code 200, with default header values.
+
+ok
+*/
+type GetFrontendDetailOK struct {
+	Payload *rest_model_zrok.Frontend
+}
+
+// IsSuccess returns true when this get frontend detail o k response has a 2xx status code
+func (o *GetFrontendDetailOK) IsSuccess() bool {
+	return true
+}
+
+// IsRedirect returns true when this get frontend detail o k response has a 3xx status code
+func (o *GetFrontendDetailOK) IsRedirect() bool {
+	return false
+}
+
+// IsClientError returns true when this get frontend detail o k response has a 4xx status code
+func (o *GetFrontendDetailOK) IsClientError() bool {
+	return false
+}
+
+// IsServerError returns true when this get frontend detail o k response has a 5xx status code
+func (o *GetFrontendDetailOK) IsServerError() bool {
+	return false
+}
+
+// IsCode returns true when this get frontend detail o k response a status code equal to that given
+func (o *GetFrontendDetailOK) IsCode(code int) bool {
+	return code == 200
+}
+
+func (o *GetFrontendDetailOK) Error() string {
+	return fmt.Sprintf("[GET /detail/frontend/{feId}][%d] getFrontendDetailOK  %+v", 200, o.Payload)
+}
+
+func (o *GetFrontendDetailOK) String() string {
+	return fmt.Sprintf("[GET /detail/frontend/{feId}][%d] getFrontendDetailOK  %+v", 200, o.Payload)
+}
+
+func (o *GetFrontendDetailOK) GetPayload() *rest_model_zrok.Frontend {
+	return o.Payload
+}
+
+func (o *GetFrontendDetailOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error {
+
+	o.Payload = new(rest_model_zrok.Frontend)
+
+	// response payload
+	if err := consumer.Consume(response.Body(), o.Payload); err != nil && err != io.EOF {
+		return err
+	}
+
+	return nil
+}
+
+// NewGetFrontendDetailUnauthorized creates a GetFrontendDetailUnauthorized with default headers values
+func NewGetFrontendDetailUnauthorized() *GetFrontendDetailUnauthorized {
+	return &GetFrontendDetailUnauthorized{}
+}
+
+/*
+GetFrontendDetailUnauthorized describes a response with status code 401, with default header values.
+
+unauthorized
+*/
+type GetFrontendDetailUnauthorized struct {
+}
+
+// IsSuccess returns true when this get frontend detail unauthorized response has a 2xx status code
+func (o *GetFrontendDetailUnauthorized) IsSuccess() bool {
+	return false
+}
+
+// IsRedirect returns true when this get frontend detail unauthorized response has a 3xx status code
+func (o *GetFrontendDetailUnauthorized) IsRedirect() bool {
+	return false
+}
+
+// IsClientError returns true when this get frontend detail unauthorized response has a 4xx status code
+func (o *GetFrontendDetailUnauthorized) IsClientError() bool {
+	return true
+}
+
+// IsServerError returns true when this get frontend detail unauthorized response has a 5xx status code
+func (o *GetFrontendDetailUnauthorized) IsServerError() bool {
+	return false
+}
+
+// IsCode returns true when this get frontend detail unauthorized response a status code equal to that given
+func (o *GetFrontendDetailUnauthorized) IsCode(code int) bool {
+	return code == 401
+}
+
+func (o *GetFrontendDetailUnauthorized) Error() string {
+	return fmt.Sprintf("[GET /detail/frontend/{feId}][%d] getFrontendDetailUnauthorized ", 401)
+}
+
+func (o *GetFrontendDetailUnauthorized) String() string {
+	return fmt.Sprintf("[GET /detail/frontend/{feId}][%d] getFrontendDetailUnauthorized ", 401)
+}
+
+func (o *GetFrontendDetailUnauthorized) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error {
+
+	return nil
+}
+
+// NewGetFrontendDetailNotFound creates a GetFrontendDetailNotFound with default headers values
+func NewGetFrontendDetailNotFound() *GetFrontendDetailNotFound {
+	return &GetFrontendDetailNotFound{}
+}
+
+/*
+GetFrontendDetailNotFound describes a response with status code 404, with default header values.
+
+not found
+*/
+type GetFrontendDetailNotFound struct {
+}
+
+// IsSuccess returns true when this get frontend detail not found response has a 2xx status code
+func (o *GetFrontendDetailNotFound) IsSuccess() bool {
+	return false
+}
+
+// IsRedirect returns true when this get frontend detail not found response has a 3xx status code
+func (o *GetFrontendDetailNotFound) IsRedirect() bool {
+	return false
+}
+
+// IsClientError returns true when this get frontend detail not found response has a 4xx status code
+func (o *GetFrontendDetailNotFound) IsClientError() bool {
+	return true
+}
+
+// IsServerError returns true when this get frontend detail not found response has a 5xx status code
+func (o *GetFrontendDetailNotFound) IsServerError() bool {
+	return false
+}
+
+// IsCode returns true when this get frontend detail not found response a status code equal to that given
+func (o *GetFrontendDetailNotFound) IsCode(code int) bool {
+	return code == 404
+}
+
+func (o *GetFrontendDetailNotFound) Error() string {
+	return fmt.Sprintf("[GET /detail/frontend/{feId}][%d] getFrontendDetailNotFound ", 404)
+}
+
+func (o *GetFrontendDetailNotFound) String() string {
+	return fmt.Sprintf("[GET /detail/frontend/{feId}][%d] getFrontendDetailNotFound ", 404)
+}
+
+func (o *GetFrontendDetailNotFound) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error {
+
+	return nil
+}
+
+// NewGetFrontendDetailInternalServerError creates a GetFrontendDetailInternalServerError with default headers values
+func NewGetFrontendDetailInternalServerError() *GetFrontendDetailInternalServerError {
+	return &GetFrontendDetailInternalServerError{}
+}
+
+/*
+GetFrontendDetailInternalServerError describes a response with status code 500, with default header values.
+
+internal server error
+*/
+type GetFrontendDetailInternalServerError struct {
+}
+
+// IsSuccess returns true when this get frontend detail internal server error response has a 2xx status code
+func (o *GetFrontendDetailInternalServerError) IsSuccess() bool {
+	return false
+}
+
+// IsRedirect returns true when this get frontend detail internal server error response has a 3xx status code
+func (o *GetFrontendDetailInternalServerError) IsRedirect() bool {
+	return false
+}
+
+// IsClientError returns true when this get frontend detail internal server error response has a 4xx status code
+func (o *GetFrontendDetailInternalServerError) IsClientError() bool {
+	return false
+}
+
+// IsServerError returns true when this get frontend detail internal server error response has a 5xx status code
+func (o *GetFrontendDetailInternalServerError) IsServerError() bool {
+	return true
+}
+
+// IsCode returns true when this get frontend detail internal server error response a status code equal to that given
+func (o *GetFrontendDetailInternalServerError) IsCode(code int) bool {
+	return code == 500
+}
+
+func (o *GetFrontendDetailInternalServerError) Error() string {
+	return fmt.Sprintf("[GET /detail/frontend/{feId}][%d] getFrontendDetailInternalServerError ", 500)
+}
+
+func (o *GetFrontendDetailInternalServerError) String() string {
+	return fmt.Sprintf("[GET /detail/frontend/{feId}][%d] getFrontendDetailInternalServerError ", 500)
+}
+
+func (o *GetFrontendDetailInternalServerError) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error {
+
+	return nil
+}
diff --git a/rest_client_zrok/metadata/metadata_client.go b/rest_client_zrok/metadata/metadata_client.go
index 764216b3..194736ee 100644
--- a/rest_client_zrok/metadata/metadata_client.go
+++ b/rest_client_zrok/metadata/metadata_client.go
@@ -40,6 +40,8 @@ type ClientService interface {
 
 	GetEnvironmentMetrics(params *GetEnvironmentMetricsParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*GetEnvironmentMetricsOK, error)
 
+	GetFrontendDetail(params *GetFrontendDetailParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*GetFrontendDetailOK, error)
+
 	GetShareDetail(params *GetShareDetailParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*GetShareDetailOK, error)
 
 	GetShareMetrics(params *GetShareMetricsParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*GetShareMetricsOK, error)
@@ -245,6 +247,45 @@ func (a *Client) GetEnvironmentMetrics(params *GetEnvironmentMetricsParams, auth
 	panic(msg)
 }
 
+/*
+GetFrontendDetail get frontend detail API
+*/
+func (a *Client) GetFrontendDetail(params *GetFrontendDetailParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*GetFrontendDetailOK, error) {
+	// TODO: Validate the params before sending
+	if params == nil {
+		params = NewGetFrontendDetailParams()
+	}
+	op := &runtime.ClientOperation{
+		ID:                 "getFrontendDetail",
+		Method:             "GET",
+		PathPattern:        "/detail/frontend/{feId}",
+		ProducesMediaTypes: []string{"application/zrok.v1+json"},
+		ConsumesMediaTypes: []string{"application/zrok.v1+json"},
+		Schemes:            []string{"http"},
+		Params:             params,
+		Reader:             &GetFrontendDetailReader{formats: a.formats},
+		AuthInfo:           authInfo,
+		Context:            params.Context,
+		Client:             params.HTTPClient,
+	}
+	for _, opt := range opts {
+		opt(op)
+	}
+
+	result, err := a.transport.Submit(op)
+	if err != nil {
+		return nil, err
+	}
+	success, ok := result.(*GetFrontendDetailOK)
+	if ok {
+		return success, nil
+	}
+	// unexpected success response
+	// safeguard: normally, absent a default response, unknown success responses return an error above: so this is a codegen issue
+	msg := fmt.Sprintf("unexpected success response for getFrontendDetail: API contract not enforced by server. Client expected to get an error, but got: %T", result)
+	panic(msg)
+}
+
 /*
 GetShareDetail get share detail API
 */
diff --git a/rest_server_zrok/embedded_spec.go b/rest_server_zrok/embedded_spec.go
index 40dd52ea..bb04abd4 100644
--- a/rest_server_zrok/embedded_spec.go
+++ b/rest_server_zrok/embedded_spec.go
@@ -152,6 +152,44 @@ func init() {
         }
       }
     },
+    "/detail/frontend/{feId}": {
+      "get": {
+        "security": [
+          {
+            "key": []
+          }
+        ],
+        "tags": [
+          "metadata"
+        ],
+        "operationId": "getFrontendDetail",
+        "parameters": [
+          {
+            "type": "integer",
+            "name": "feId",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "ok",
+            "schema": {
+              "$ref": "#/definitions/frontend"
+            }
+          },
+          "401": {
+            "description": "unauthorized"
+          },
+          "404": {
+            "description": "not found"
+          },
+          "500": {
+            "description": "internal server error"
+          }
+        }
+      }
+    },
     "/detail/share/{shrToken}": {
       "get": {
         "security": [
@@ -1666,6 +1704,44 @@ func init() {
         }
       }
     },
+    "/detail/frontend/{feId}": {
+      "get": {
+        "security": [
+          {
+            "key": []
+          }
+        ],
+        "tags": [
+          "metadata"
+        ],
+        "operationId": "getFrontendDetail",
+        "parameters": [
+          {
+            "type": "integer",
+            "name": "feId",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "ok",
+            "schema": {
+              "$ref": "#/definitions/frontend"
+            }
+          },
+          "401": {
+            "description": "unauthorized"
+          },
+          "404": {
+            "description": "not found"
+          },
+          "500": {
+            "description": "internal server error"
+          }
+        }
+      }
+    },
     "/detail/share/{shrToken}": {
       "get": {
         "security": [
diff --git a/rest_server_zrok/operations/metadata/get_frontend_detail.go b/rest_server_zrok/operations/metadata/get_frontend_detail.go
new file mode 100644
index 00000000..c532c8f1
--- /dev/null
+++ b/rest_server_zrok/operations/metadata/get_frontend_detail.go
@@ -0,0 +1,71 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metadata
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the generate command
+
+import (
+	"net/http"
+
+	"github.com/go-openapi/runtime/middleware"
+
+	"github.com/openziti/zrok/rest_model_zrok"
+)
+
+// GetFrontendDetailHandlerFunc turns a function with the right signature into a get frontend detail handler
+type GetFrontendDetailHandlerFunc func(GetFrontendDetailParams, *rest_model_zrok.Principal) middleware.Responder
+
+// Handle executing the request and returning a response
+func (fn GetFrontendDetailHandlerFunc) Handle(params GetFrontendDetailParams, principal *rest_model_zrok.Principal) middleware.Responder {
+	return fn(params, principal)
+}
+
+// GetFrontendDetailHandler interface for that can handle valid get frontend detail params
+type GetFrontendDetailHandler interface {
+	Handle(GetFrontendDetailParams, *rest_model_zrok.Principal) middleware.Responder
+}
+
+// NewGetFrontendDetail creates a new http.Handler for the get frontend detail operation
+func NewGetFrontendDetail(ctx *middleware.Context, handler GetFrontendDetailHandler) *GetFrontendDetail {
+	return &GetFrontendDetail{Context: ctx, Handler: handler}
+}
+
+/*
+	GetFrontendDetail swagger:route GET /detail/frontend/{feId} metadata getFrontendDetail
+
+GetFrontendDetail get frontend detail API
+*/
+type GetFrontendDetail struct {
+	Context *middleware.Context
+	Handler GetFrontendDetailHandler
+}
+
+func (o *GetFrontendDetail) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
+	route, rCtx, _ := o.Context.RouteInfo(r)
+	if rCtx != nil {
+		*r = *rCtx
+	}
+	var Params = NewGetFrontendDetailParams()
+	uprinc, aCtx, err := o.Context.Authorize(r, route)
+	if err != nil {
+		o.Context.Respond(rw, r, route.Produces, route, err)
+		return
+	}
+	if aCtx != nil {
+		*r = *aCtx
+	}
+	var principal *rest_model_zrok.Principal
+	if uprinc != nil {
+		principal = uprinc.(*rest_model_zrok.Principal) // this is really a rest_model_zrok.Principal, I promise
+	}
+
+	if err := o.Context.BindValidRequest(r, route, &Params); err != nil { // bind params
+		o.Context.Respond(rw, r, route.Produces, route, err)
+		return
+	}
+
+	res := o.Handler.Handle(Params, principal) // actually handle the request
+	o.Context.Respond(rw, r, route.Produces, route, res)
+
+}
diff --git a/rest_server_zrok/operations/metadata/get_frontend_detail_parameters.go b/rest_server_zrok/operations/metadata/get_frontend_detail_parameters.go
new file mode 100644
index 00000000..14263c9c
--- /dev/null
+++ b/rest_server_zrok/operations/metadata/get_frontend_detail_parameters.go
@@ -0,0 +1,77 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metadata
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"net/http"
+
+	"github.com/go-openapi/errors"
+	"github.com/go-openapi/runtime/middleware"
+	"github.com/go-openapi/strfmt"
+	"github.com/go-openapi/swag"
+)
+
+// NewGetFrontendDetailParams creates a new GetFrontendDetailParams object
+//
+// There are no default values defined in the spec.
+func NewGetFrontendDetailParams() GetFrontendDetailParams {
+
+	return GetFrontendDetailParams{}
+}
+
+// GetFrontendDetailParams contains all the bound params for the get frontend detail operation
+// typically these are obtained from a http.Request
+//
+// swagger:parameters getFrontendDetail
+type GetFrontendDetailParams struct {
+
+	// HTTP Request Object
+	HTTPRequest *http.Request `json:"-"`
+
+	/*
+	  Required: true
+	  In: path
+	*/
+	FeID int64
+}
+
+// BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface
+// for simple values it will use straight method calls.
+//
+// To ensure default values, the struct must have been initialized with NewGetFrontendDetailParams() beforehand.
+func (o *GetFrontendDetailParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error {
+	var res []error
+
+	o.HTTPRequest = r
+
+	rFeID, rhkFeID, _ := route.Params.GetOK("feId")
+	if err := o.bindFeID(rFeID, rhkFeID, route.Formats); err != nil {
+		res = append(res, err)
+	}
+	if len(res) > 0 {
+		return errors.CompositeValidationError(res...)
+	}
+	return nil
+}
+
+// bindFeID binds and validates parameter FeID from path.
+func (o *GetFrontendDetailParams) bindFeID(rawData []string, hasKey bool, formats strfmt.Registry) error {
+	var raw string
+	if len(rawData) > 0 {
+		raw = rawData[len(rawData)-1]
+	}
+
+	// Required: true
+	// Parameter is provided by construction from the route
+
+	value, err := swag.ConvertInt64(raw)
+	if err != nil {
+		return errors.InvalidType("feId", "path", "int64", raw)
+	}
+	o.FeID = value
+
+	return nil
+}
diff --git a/rest_server_zrok/operations/metadata/get_frontend_detail_responses.go b/rest_server_zrok/operations/metadata/get_frontend_detail_responses.go
new file mode 100644
index 00000000..538cc9bf
--- /dev/null
+++ b/rest_server_zrok/operations/metadata/get_frontend_detail_responses.go
@@ -0,0 +1,134 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metadata
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"net/http"
+
+	"github.com/go-openapi/runtime"
+
+	"github.com/openziti/zrok/rest_model_zrok"
+)
+
+// GetFrontendDetailOKCode is the HTTP code returned for type GetFrontendDetailOK
+const GetFrontendDetailOKCode int = 200
+
+/*
+GetFrontendDetailOK ok
+
+swagger:response getFrontendDetailOK
+*/
+type GetFrontendDetailOK struct {
+
+	/*
+	  In: Body
+	*/
+	Payload *rest_model_zrok.Frontend `json:"body,omitempty"`
+}
+
+// NewGetFrontendDetailOK creates GetFrontendDetailOK with default headers values
+func NewGetFrontendDetailOK() *GetFrontendDetailOK {
+
+	return &GetFrontendDetailOK{}
+}
+
+// WithPayload adds the payload to the get frontend detail o k response
+func (o *GetFrontendDetailOK) WithPayload(payload *rest_model_zrok.Frontend) *GetFrontendDetailOK {
+	o.Payload = payload
+	return o
+}
+
+// SetPayload sets the payload to the get frontend detail o k response
+func (o *GetFrontendDetailOK) SetPayload(payload *rest_model_zrok.Frontend) {
+	o.Payload = payload
+}
+
+// WriteResponse to the client
+func (o *GetFrontendDetailOK) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) {
+
+	rw.WriteHeader(200)
+	if o.Payload != nil {
+		payload := o.Payload
+		if err := producer.Produce(rw, payload); err != nil {
+			panic(err) // let the recovery middleware deal with this
+		}
+	}
+}
+
+// GetFrontendDetailUnauthorizedCode is the HTTP code returned for type GetFrontendDetailUnauthorized
+const GetFrontendDetailUnauthorizedCode int = 401
+
+/*
+GetFrontendDetailUnauthorized unauthorized
+
+swagger:response getFrontendDetailUnauthorized
+*/
+type GetFrontendDetailUnauthorized struct {
+}
+
+// NewGetFrontendDetailUnauthorized creates GetFrontendDetailUnauthorized with default headers values
+func NewGetFrontendDetailUnauthorized() *GetFrontendDetailUnauthorized {
+
+	return &GetFrontendDetailUnauthorized{}
+}
+
+// WriteResponse to the client
+func (o *GetFrontendDetailUnauthorized) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) {
+
+	rw.Header().Del(runtime.HeaderContentType) //Remove Content-Type on empty responses
+
+	rw.WriteHeader(401)
+}
+
+// GetFrontendDetailNotFoundCode is the HTTP code returned for type GetFrontendDetailNotFound
+const GetFrontendDetailNotFoundCode int = 404
+
+/*
+GetFrontendDetailNotFound not found
+
+swagger:response getFrontendDetailNotFound
+*/
+type GetFrontendDetailNotFound struct {
+}
+
+// NewGetFrontendDetailNotFound creates GetFrontendDetailNotFound with default headers values
+func NewGetFrontendDetailNotFound() *GetFrontendDetailNotFound {
+
+	return &GetFrontendDetailNotFound{}
+}
+
+// WriteResponse to the client
+func (o *GetFrontendDetailNotFound) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) {
+
+	rw.Header().Del(runtime.HeaderContentType) //Remove Content-Type on empty responses
+
+	rw.WriteHeader(404)
+}
+
+// GetFrontendDetailInternalServerErrorCode is the HTTP code returned for type GetFrontendDetailInternalServerError
+const GetFrontendDetailInternalServerErrorCode int = 500
+
+/*
+GetFrontendDetailInternalServerError internal server error
+
+swagger:response getFrontendDetailInternalServerError
+*/
+type GetFrontendDetailInternalServerError struct {
+}
+
+// NewGetFrontendDetailInternalServerError creates GetFrontendDetailInternalServerError with default headers values
+func NewGetFrontendDetailInternalServerError() *GetFrontendDetailInternalServerError {
+
+	return &GetFrontendDetailInternalServerError{}
+}
+
+// WriteResponse to the client
+func (o *GetFrontendDetailInternalServerError) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) {
+
+	rw.Header().Del(runtime.HeaderContentType) //Remove Content-Type on empty responses
+
+	rw.WriteHeader(500)
+}
diff --git a/rest_server_zrok/operations/metadata/get_frontend_detail_urlbuilder.go b/rest_server_zrok/operations/metadata/get_frontend_detail_urlbuilder.go
new file mode 100644
index 00000000..ec2a9784
--- /dev/null
+++ b/rest_server_zrok/operations/metadata/get_frontend_detail_urlbuilder.go
@@ -0,0 +1,101 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package metadata
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the generate command
+
+import (
+	"errors"
+	"net/url"
+	golangswaggerpaths "path"
+	"strings"
+
+	"github.com/go-openapi/swag"
+)
+
+// GetFrontendDetailURL generates an URL for the get frontend detail operation
+type GetFrontendDetailURL struct {
+	FeID int64
+
+	_basePath string
+	// avoid unkeyed usage
+	_ struct{}
+}
+
+// WithBasePath sets the base path for this url builder, only required when it's different from the
+// base path specified in the swagger spec.
+// When the value of the base path is an empty string
+func (o *GetFrontendDetailURL) WithBasePath(bp string) *GetFrontendDetailURL {
+	o.SetBasePath(bp)
+	return o
+}
+
+// SetBasePath sets the base path for this url builder, only required when it's different from the
+// base path specified in the swagger spec.
+// When the value of the base path is an empty string
+func (o *GetFrontendDetailURL) SetBasePath(bp string) {
+	o._basePath = bp
+}
+
+// Build a url path and query string
+func (o *GetFrontendDetailURL) Build() (*url.URL, error) {
+	var _result url.URL
+
+	var _path = "/detail/frontend/{feId}"
+
+	feID := swag.FormatInt64(o.FeID)
+	if feID != "" {
+		_path = strings.Replace(_path, "{feId}", feID, -1)
+	} else {
+		return nil, errors.New("feId is required on GetFrontendDetailURL")
+	}
+
+	_basePath := o._basePath
+	if _basePath == "" {
+		_basePath = "/api/v1"
+	}
+	_result.Path = golangswaggerpaths.Join(_basePath, _path)
+
+	return &_result, nil
+}
+
+// Must is a helper function to panic when the url builder returns an error
+func (o *GetFrontendDetailURL) Must(u *url.URL, err error) *url.URL {
+	if err != nil {
+		panic(err)
+	}
+	if u == nil {
+		panic("url can't be nil")
+	}
+	return u
+}
+
+// String returns the string representation of the path with query string
+func (o *GetFrontendDetailURL) String() string {
+	return o.Must(o.Build()).String()
+}
+
+// BuildFull builds a full url with scheme, host, path and query string
+func (o *GetFrontendDetailURL) BuildFull(scheme, host string) (*url.URL, error) {
+	if scheme == "" {
+		return nil, errors.New("scheme is required for a full url on GetFrontendDetailURL")
+	}
+	if host == "" {
+		return nil, errors.New("host is required for a full url on GetFrontendDetailURL")
+	}
+
+	base, err := o.Build()
+	if err != nil {
+		return nil, err
+	}
+
+	base.Scheme = scheme
+	base.Host = host
+	return base, nil
+}
+
+// StringFull returns the string representation of a complete url
+func (o *GetFrontendDetailURL) StringFull(scheme, host string) string {
+	return o.Must(o.BuildFull(scheme, host)).String()
+}
diff --git a/rest_server_zrok/operations/zrok_api.go b/rest_server_zrok/operations/zrok_api.go
index ed5c7721..158e03c3 100644
--- a/rest_server_zrok/operations/zrok_api.go
+++ b/rest_server_zrok/operations/zrok_api.go
@@ -82,6 +82,9 @@ func NewZrokAPI(spec *loads.Document) *ZrokAPI {
 		MetadataGetEnvironmentMetricsHandler: metadata.GetEnvironmentMetricsHandlerFunc(func(params metadata.GetEnvironmentMetricsParams, principal *rest_model_zrok.Principal) middleware.Responder {
 			return middleware.NotImplemented("operation metadata.GetEnvironmentMetrics has not yet been implemented")
 		}),
+		MetadataGetFrontendDetailHandler: metadata.GetFrontendDetailHandlerFunc(func(params metadata.GetFrontendDetailParams, principal *rest_model_zrok.Principal) middleware.Responder {
+			return middleware.NotImplemented("operation metadata.GetFrontendDetail has not yet been implemented")
+		}),
 		MetadataGetShareDetailHandler: metadata.GetShareDetailHandlerFunc(func(params metadata.GetShareDetailParams, principal *rest_model_zrok.Principal) middleware.Responder {
 			return middleware.NotImplemented("operation metadata.GetShareDetail has not yet been implemented")
 		}),
@@ -205,6 +208,8 @@ type ZrokAPI struct {
 	MetadataGetEnvironmentDetailHandler metadata.GetEnvironmentDetailHandler
 	// MetadataGetEnvironmentMetricsHandler sets the operation handler for the get environment metrics operation
 	MetadataGetEnvironmentMetricsHandler metadata.GetEnvironmentMetricsHandler
+	// MetadataGetFrontendDetailHandler sets the operation handler for the get frontend detail operation
+	MetadataGetFrontendDetailHandler metadata.GetFrontendDetailHandler
 	// MetadataGetShareDetailHandler sets the operation handler for the get share detail operation
 	MetadataGetShareDetailHandler metadata.GetShareDetailHandler
 	// MetadataGetShareMetricsHandler sets the operation handler for the get share metrics operation
@@ -353,6 +358,9 @@ func (o *ZrokAPI) Validate() error {
 	if o.MetadataGetEnvironmentMetricsHandler == nil {
 		unregistered = append(unregistered, "metadata.GetEnvironmentMetricsHandler")
 	}
+	if o.MetadataGetFrontendDetailHandler == nil {
+		unregistered = append(unregistered, "metadata.GetFrontendDetailHandler")
+	}
 	if o.MetadataGetShareDetailHandler == nil {
 		unregistered = append(unregistered, "metadata.GetShareDetailHandler")
 	}
@@ -550,6 +558,10 @@ func (o *ZrokAPI) initHandlerCache() {
 	if o.handlers["GET"] == nil {
 		o.handlers["GET"] = make(map[string]http.Handler)
 	}
+	o.handlers["GET"]["/detail/frontend/{feId}"] = metadata.NewGetFrontendDetail(o.context, o.MetadataGetFrontendDetailHandler)
+	if o.handlers["GET"] == nil {
+		o.handlers["GET"] = make(map[string]http.Handler)
+	}
 	o.handlers["GET"]["/detail/share/{shrToken}"] = metadata.NewGetShareDetail(o.context, o.MetadataGetShareDetailHandler)
 	if o.handlers["GET"] == nil {
 		o.handlers["GET"] = make(map[string]http.Handler)
diff --git a/specs/zrok.yml b/specs/zrok.yml
index bf0ce90f..78aeb8d4 100644
--- a/specs/zrok.yml
+++ b/specs/zrok.yml
@@ -368,6 +368,30 @@ paths:
         500:
           description: internal server error
 
+  /detail/frontend/{feId}:
+    get:
+      tags:
+        - metadata
+      security:
+        - key: []
+      operationId: getFrontendDetail
+      parameters:
+        - name: feId
+          in: path
+          type: integer
+          required: true
+      responses:
+        200:
+          description: ok
+          schema:
+            $ref: "#/definitions/frontend"
+        401:
+          description: unauthorized
+        404:
+          description: not found
+        500:
+          description: internal server error
+
   /detail/share/{shrToken}:
     get:
       tags:
diff --git a/ui/src/api/metadata.js b/ui/src/api/metadata.js
index 2ff94807..463128c3 100644
--- a/ui/src/api/metadata.js
+++ b/ui/src/api/metadata.js
@@ -27,6 +27,19 @@ export function getEnvironmentDetail(envZId) {
   return gateway.request(getEnvironmentDetailOperation, parameters)
 }
 
+/**
+ * @param {number} feId 
+ * @return {Promise<module:types.frontend>} ok
+ */
+export function getFrontendDetail(feId) {
+  const parameters = {
+    path: {
+      feId
+    }
+  }
+  return gateway.request(getFrontendDetailOperation, parameters)
+}
+
 /**
  * @param {string} shrToken 
  * @return {Promise<module:types.share>} ok
@@ -130,6 +143,16 @@ const getEnvironmentDetailOperation = {
   ]
 }
 
+const getFrontendDetailOperation = {
+  path: '/detail/frontend/{feId}',
+  method: 'get',
+  security: [
+    {
+      id: 'key'
+    }
+  ]
+}
+
 const getShareDetailOperation = {
   path: '/detail/share/{shrToken}',
   method: 'get',
diff --git a/ui/src/console/detail/Detail.js b/ui/src/console/detail/Detail.js
index 3c5b1aea..6a92761a 100644
--- a/ui/src/console/detail/Detail.js
+++ b/ui/src/console/detail/Detail.js
@@ -1,11 +1,17 @@
 import AccountDetail from "./account/AccountDetail";
 import ShareDetail from "./share/ShareDetail";
 import EnvironmentDetail from "./environment/EnvironmentDetail";
+import AccessDetail from "./access/AccessDetail";
 
 const Detail = (props) => {
     let detailComponent = <h1>{props.selection.id} ({props.selection.type})</h1>;
+    console.log("selection type", props.selection.type);
 
     switch(props.selection.type) {
+        case "frontend":
+            detailComponent = <AccessDetail selection={props.selection} />;
+            break;
+
         case "environment":
             detailComponent = <EnvironmentDetail selection={props.selection} />;
             break;
diff --git a/ui/src/console/detail/access/AccessDetail.js b/ui/src/console/detail/access/AccessDetail.js
new file mode 100644
index 00000000..07299597
--- /dev/null
+++ b/ui/src/console/detail/access/AccessDetail.js
@@ -0,0 +1,30 @@
+import {mdiAccessPointNetwork} from "@mdi/js";
+import Icon from "@mdi/react";
+import {useEffect, useState} from "react";
+import {getFrontendDetail} from "../../../api/metadata";
+import {Tab, Tabs} from "react-bootstrap";
+import DetailTab from "./DetailTab";
+
+const AccessDetail = (props) => {
+    const [detail, setDetail] = useState({});
+
+    useEffect(() => {
+        getFrontendDetail(props.selection.id)
+            .then(resp => {
+                setDetail(resp.data);
+            });
+    }, [props.selection]);
+
+    return (
+        <div>
+            <h2><Icon path={mdiAccessPointNetwork} size={2} />{" "}{detail.shrToken} ({detail.id})</h2>
+            <Tabs defaultActiveKey={"detail"} className={"mb-3"}>
+                <Tab eventKey={"detail"} title={"Detail"}>
+                    <DetailTab frontend={detail} />
+                </Tab>
+            </Tabs>
+        </div>
+    );
+}
+
+export default AccessDetail;
\ No newline at end of file
diff --git a/ui/src/console/detail/access/DetailTab.js b/ui/src/console/detail/access/DetailTab.js
new file mode 100644
index 00000000..65362c99
--- /dev/null
+++ b/ui/src/console/detail/access/DetailTab.js
@@ -0,0 +1,14 @@
+import SecretToggle from "../../SecretToggle";
+import PropertyTable from "../../PropertyTable";
+
+const DetailTab = (props) => {
+    const customProperties = {
+        zId: row => <SecretToggle secret={row.value} />
+    }
+
+    return (
+        <PropertyTable object={props.frontend} custom={customProperties} />
+    );
+};
+
+export default DetailTab;
\ No newline at end of file

From d205405aa089e65227140bbed7effd083da3f945 Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Wed, 17 May 2023 20:58:07 -0400
Subject: [PATCH 46/49] use a synthetic 'node.id' for frontend nodes to fix
 'undulating visualizer' issue; change detection was breaking (#323)

---
 ui/src/console/detail/access/AccessDetail.js | 2 +-
 ui/src/console/visualizer/graph.js           | 3 ++-
 2 files changed, 3 insertions(+), 2 deletions(-)

diff --git a/ui/src/console/detail/access/AccessDetail.js b/ui/src/console/detail/access/AccessDetail.js
index 07299597..455fa016 100644
--- a/ui/src/console/detail/access/AccessDetail.js
+++ b/ui/src/console/detail/access/AccessDetail.js
@@ -9,7 +9,7 @@ const AccessDetail = (props) => {
     const [detail, setDetail] = useState({});
 
     useEffect(() => {
-        getFrontendDetail(props.selection.id)
+        getFrontendDetail(props.selection.feId)
             .then(resp => {
                 setDetail(resp.data);
             });
diff --git a/ui/src/console/visualizer/graph.js b/ui/src/console/visualizer/graph.js
index afbd1e82..cb2efb2a 100644
--- a/ui/src/console/visualizer/graph.js
+++ b/ui/src/console/visualizer/graph.js
@@ -74,7 +74,8 @@ export const mergeGraph = (oldGraph, user, accountLimited, newOverview) => {
             if(env.frontends) {
                 env.frontends.forEach(fe => {
                    let feNode = {
-                       id: fe.id,
+                       id: "fe:" + fe.id,
+                       feId: fe.id,
                        target: fe.shrToken,
                        label: fe.shrToken,
                        type: "frontend",

From 1b70c6e013bed5c1636350b952931b6b6173126a Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Thu, 18 May 2023 13:10:12 -0400
Subject: [PATCH 47/49] synthetic 'selection.id', real identifiers on bespoke
 properties (#323)

---
 ui/src/console/detail/Detail.js                      |  1 -
 .../console/detail/environment/EnvironmentDetail.js  |  2 +-
 ui/src/console/detail/environment/MetricsTab.js      | 12 ++++++------
 ui/src/console/detail/environment/SharesTab.js       |  4 ++--
 ui/src/console/detail/share/ShareDetail.js           |  4 ++--
 ui/src/console/visualizer/graph.js                   |  8 +++++---
 6 files changed, 16 insertions(+), 15 deletions(-)

diff --git a/ui/src/console/detail/Detail.js b/ui/src/console/detail/Detail.js
index 6a92761a..582d0503 100644
--- a/ui/src/console/detail/Detail.js
+++ b/ui/src/console/detail/Detail.js
@@ -5,7 +5,6 @@ import AccessDetail from "./access/AccessDetail";
 
 const Detail = (props) => {
     let detailComponent = <h1>{props.selection.id} ({props.selection.type})</h1>;
-    console.log("selection type", props.selection.type);
 
     switch(props.selection.type) {
         case "frontend":
diff --git a/ui/src/console/detail/environment/EnvironmentDetail.js b/ui/src/console/detail/environment/EnvironmentDetail.js
index 25526520..818a4d97 100644
--- a/ui/src/console/detail/environment/EnvironmentDetail.js
+++ b/ui/src/console/detail/environment/EnvironmentDetail.js
@@ -12,7 +12,7 @@ const EnvironmentDetail = (props) => {
     const [detail, setDetail] = useState({});
 
     useEffect(() => {
-        getEnvironmentDetail(props.selection.id)
+        getEnvironmentDetail(props.selection.envZId)
             .then(resp => {
                 setDetail(resp.data);
             });
diff --git a/ui/src/console/detail/environment/MetricsTab.js b/ui/src/console/detail/environment/MetricsTab.js
index 37e447a0..bf234580 100644
--- a/ui/src/console/detail/environment/MetricsTab.js
+++ b/ui/src/console/detail/environment/MetricsTab.js
@@ -9,15 +9,15 @@ const MetricsTab = (props) => {
 	const [metrics1, setMetrics1] = useState(buildMetrics([]));
 
 	useEffect(() => {
-		metadata.getEnvironmentMetrics(props.selection.id)
+		metadata.getEnvironmentMetrics(props.selection.envZId)
 			.then(resp => {
 				setMetrics30(buildMetrics(resp.data));
 			});
-		metadata.getEnvironmentMetrics(props.selection.id, {duration: "168h"})
+		metadata.getEnvironmentMetrics(props.selection.envZId, {duration: "168h"})
 			.then(resp => {
 				setMetrics7(buildMetrics(resp.data));
 			});
-		metadata.getEnvironmentMetrics(props.selection.id, {duration: "24h"})
+		metadata.getEnvironmentMetrics(props.selection.envZId, {duration: "24h"})
 			.then(resp => {
 				setMetrics1(buildMetrics(resp.data));
 			});
@@ -26,17 +26,17 @@ const MetricsTab = (props) => {
 	useEffect(() => {
 		let mounted = true;
 		let interval = setInterval(() => {
-			metadata.getEnvironmentMetrics(props.selection.id)
+			metadata.getEnvironmentMetrics(props.selection.envZId)
 				.then(resp => {
 					if(mounted) {
 						setMetrics30(buildMetrics(resp.data));
 					}
 				});
-			metadata.getEnvironmentMetrics(props.selection.id, {duration: "168h"})
+			metadata.getEnvironmentMetrics(props.selection.envZId, {duration: "168h"})
 				.then(resp => {
 					setMetrics7(buildMetrics(resp.data));
 				});
-			metadata.getEnvironmentMetrics(props.selection.id, {duration: "24h"})
+			metadata.getEnvironmentMetrics(props.selection.envZId, {duration: "24h"})
 				.then(resp => {
 					setMetrics1(buildMetrics(resp.data));
 				});
diff --git a/ui/src/console/detail/environment/SharesTab.js b/ui/src/console/detail/environment/SharesTab.js
index 7a297221..d47c026b 100644
--- a/ui/src/console/detail/environment/SharesTab.js
+++ b/ui/src/console/detail/environment/SharesTab.js
@@ -7,7 +7,7 @@ const SharesTab = (props) => {
     const [detail, setDetail] = useState({});
 
     useEffect(() => {
-        metadata.getEnvironmentDetail(props.selection.id)
+        metadata.getEnvironmentDetail(props.selection.envZId)
             .then(resp => {
                 setDetail(resp.data);
             });
@@ -16,7 +16,7 @@ const SharesTab = (props) => {
     useEffect(() => {
         let mounted = true;
         let interval = setInterval(() => {
-            metadata.getEnvironmentDetail(props.selection.id)
+            metadata.getEnvironmentDetail(props.selection.envZId)
                 .then(resp => {
                     if(mounted) {
                         setDetail(resp.data);
diff --git a/ui/src/console/detail/share/ShareDetail.js b/ui/src/console/detail/share/ShareDetail.js
index b942cd3e..b440db9b 100644
--- a/ui/src/console/detail/share/ShareDetail.js
+++ b/ui/src/console/detail/share/ShareDetail.js
@@ -13,7 +13,7 @@ const ShareDetail = (props) => {
     const [detail, setDetail] = useState({});
 
     useEffect(() => {
-        metadata.getShareDetail(props.selection.id)
+        metadata.getShareDetail(props.selection.shrToken)
             .then(resp => {
                 let detail = resp.data;
                 detail.envZId = props.selection.envZId;
@@ -24,7 +24,7 @@ const ShareDetail = (props) => {
     useEffect(() => {
         let mounted = true;
         let interval = setInterval(() => {
-            metadata.getShareDetail(props.selection.id)
+            metadata.getShareDetail(props.selection.shrToken)
                 .then(resp => {
                     if(mounted) {
                         let detail = resp.data;
diff --git a/ui/src/console/visualizer/graph.js b/ui/src/console/visualizer/graph.js
index cb2efb2a..3d35d7d4 100644
--- a/ui/src/console/visualizer/graph.js
+++ b/ui/src/console/visualizer/graph.js
@@ -36,7 +36,8 @@ export const mergeGraph = (oldGraph, user, accountLimited, newOverview) => {
         newOverview.forEach(env => {
             let limited = !!env.limited;
             let envNode = {
-                id: env.environment.zId,
+                id: 'env:' + env.environment.zId,
+                envZId: env.environment.zId,
                 label: env.environment.description,
                 type: "environment",
                 limited: !!env.environment.limited || accountNode.limited,
@@ -55,7 +56,8 @@ export const mergeGraph = (oldGraph, user, accountLimited, newOverview) => {
                         shrLabel = shr.backendProxyEndpoint;
                     }
                     let shrNode = {
-                        id: shr.token,
+                        id: 'shr:' + shr.token,
+                        shrToken: shr.token,
                         envZId: env.environment.zId,
                         label: shrLabel,
                         type: "share",
@@ -74,7 +76,7 @@ export const mergeGraph = (oldGraph, user, accountLimited, newOverview) => {
             if(env.frontends) {
                 env.frontends.forEach(fe => {
                    let feNode = {
-                       id: "fe:" + fe.id,
+                       id: 'ac:' + fe.id,
                        feId: fe.id,
                        target: fe.shrToken,
                        label: fe.shrToken,

From 871bf2d52874bace74cb052fd075ed7f4431355a Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Thu, 18 May 2023 13:19:16 -0400
Subject: [PATCH 48/49] make frontend dial policies for private access names
 more unique (include frontend token) (#329)

---
 controller/access.go                    | 2 +-
 controller/limits/accountRelaxAction.go | 4 ++--
 controller/limits/shareRelaxAction.go   | 2 +-
 3 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/controller/access.go b/controller/access.go
index f55ea5e2..b989988e 100644
--- a/controller/access.go
+++ b/controller/access.go
@@ -76,7 +76,7 @@ func (h *accessHandler) Handle(params share.AccessParams, principal *rest_model_
 		"zrokFrontendToken":  feToken,
 		"zrokShareToken":     shrToken,
 	}
-	if err := zrokEdgeSdk.CreateServicePolicyDial(envZId+"-"+shr.ZId+"-dial", shr.ZId, []string{envZId}, addlTags, edge); err != nil {
+	if err := zrokEdgeSdk.CreateServicePolicyDial(feToken+"-"+envZId+"-"+shr.ZId+"-dial", shr.ZId, []string{envZId}, addlTags, edge); err != nil {
 		logrus.Errorf("unable to create dial policy for user '%v': %v", principal.Email, err)
 		return share.NewAccessInternalServerError()
 	}
diff --git a/controller/limits/accountRelaxAction.go b/controller/limits/accountRelaxAction.go
index e73829dd..5e46c35f 100644
--- a/controller/limits/accountRelaxAction.go
+++ b/controller/limits/accountRelaxAction.go
@@ -35,11 +35,11 @@ func (a *accountRelaxAction) HandleAccount(acct *store.Account, _, _ int64, _ *B
 			switch shr.ShareMode {
 			case "public":
 				if err := relaxPublicShare(a.str, a.edge, shr, trx); err != nil {
-					return err
+					return errors.Wrap(err, "error relaxing public share")
 				}
 			case "private":
 				if err := relaxPrivateShare(a.str, a.edge, shr, trx); err != nil {
-					return err
+					return errors.Wrap(err, "error relaxing private share")
 				}
 			}
 		}
diff --git a/controller/limits/shareRelaxAction.go b/controller/limits/shareRelaxAction.go
index 511ec49b..2a5912c6 100644
--- a/controller/limits/shareRelaxAction.go
+++ b/controller/limits/shareRelaxAction.go
@@ -72,7 +72,7 @@ func relaxPrivateShare(str *store.Store, edge *rest_management_api_client.ZitiEd
 				"zrokFrontendToken":  fe.Token,
 				"zrokShareToken":     shr.Token,
 			}
-			if err := zrokEdgeSdk.CreateServicePolicyDial(env.ZId+"-"+shr.ZId+"-dial", shr.ZId, []string{env.ZId}, addlTags, edge); err != nil {
+			if err := zrokEdgeSdk.CreateServicePolicyDial(fe.Token+"-"+env.ZId+"-"+shr.ZId+"-dial", shr.ZId, []string{env.ZId}, addlTags, edge); err != nil {
 				return errors.Wrapf(err, "unable to create dial policy for frontend '%v'", fe.Token)
 			}
 

From 78ea98626dab21cdacbbbdd8a13238c7199240a4 Mon Sep 17 00:00:00 2001
From: Michael Quigley <michael@quigley.com>
Date: Thu, 18 May 2023 14:25:53 -0400
Subject: [PATCH 49/49] support deleting multiple service policies in one shot;
 bug in limits (#329)

---
 controller/disable.go                       |  6 +++---
 controller/gc.go                            |  6 +++---
 controller/limits/accountLimitAction.go     |  2 +-
 controller/limits/environmentLimitAction.go |  2 +-
 controller/limits/shareLimitAction.go       |  2 +-
 controller/unaccess.go                      |  2 +-
 controller/unshare.go                       |  4 ++--
 controller/zrokEdgeSdk/sp.go                | 22 +++++++++++----------
 8 files changed, 24 insertions(+), 22 deletions(-)

diff --git a/controller/disable.go b/controller/disable.go
index 672c7f33..af45228e 100644
--- a/controller/disable.go
+++ b/controller/disable.go
@@ -100,10 +100,10 @@ func (h *disableHandler) removeSharesForEnvironment(envId int, tx *sqlx.Tx, edge
 			if err := zrokEdgeSdk.DeleteServiceEdgeRouterPolicy(env.ZId, shrToken, edge); err != nil {
 				logrus.Error(err)
 			}
-			if err := zrokEdgeSdk.DeleteServicePolicyDial(env.ZId, shrToken, edge); err != nil {
+			if err := zrokEdgeSdk.DeleteServicePoliciesDial(env.ZId, shrToken, edge); err != nil {
 				logrus.Error(err)
 			}
-			if err := zrokEdgeSdk.DeleteServicePolicyBind(env.ZId, shrToken, edge); err != nil {
+			if err := zrokEdgeSdk.DeleteServicePoliciesBind(env.ZId, shrToken, edge); err != nil {
 				logrus.Error(err)
 			}
 			if err := zrokEdgeSdk.DeleteConfig(env.ZId, shrToken, edge); err != nil {
@@ -129,7 +129,7 @@ func (h *disableHandler) removeFrontendsForEnvironment(envId int, tx *sqlx.Tx, e
 			return err
 		}
 		for _, fe := range fes {
-			if err := zrokEdgeSdk.DeleteServicePolicy(env.ZId, fmt.Sprintf("tags.zrokFrontendToken=\"%v\" and type=1", fe.Token), edge); err != nil {
+			if err := zrokEdgeSdk.DeleteServicePolicies(env.ZId, fmt.Sprintf("tags.zrokFrontendToken=\"%v\" and type=1", fe.Token), edge); err != nil {
 				logrus.Errorf("error removing frontend access for '%v': %v", fe.Token, err)
 			}
 		}
diff --git a/controller/gc.go b/controller/gc.go
index e0df53b9..ebeb503f 100644
--- a/controller/gc.go
+++ b/controller/gc.go
@@ -76,10 +76,10 @@ func gcServices(edge *rest_management_api_client.ZitiEdgeManagement, liveMap map
 				if err := zrokEdgeSdk.DeleteServiceEdgeRouterPolicy("gc", *svc.Name, edge); err != nil {
 					logrus.Errorf("error garbage collecting service edge router policy: %v", err)
 				}
-				if err := zrokEdgeSdk.DeleteServicePolicyDial("gc", *svc.Name, edge); err != nil {
+				if err := zrokEdgeSdk.DeleteServicePoliciesDial("gc", *svc.Name, edge); err != nil {
 					logrus.Errorf("error garbage collecting service dial policy: %v", err)
 				}
-				if err := zrokEdgeSdk.DeleteServicePolicyBind("gc", *svc.Name, edge); err != nil {
+				if err := zrokEdgeSdk.DeleteServicePoliciesBind("gc", *svc.Name, edge); err != nil {
 					logrus.Errorf("error garbage collecting service bind policy: %v", err)
 				}
 				if err := zrokEdgeSdk.DeleteConfig("gc", *svc.Name, edge); err != nil {
@@ -137,7 +137,7 @@ func gcServicePolicies(edge *rest_management_api_client.ZitiEdgeManagement, live
 			if _, found := liveMap[spName]; !found {
 				logrus.Infof("garbage collecting, svcId='%v'", spName)
 				deleteFilter := fmt.Sprintf("id=\"%v\"", *sp.ID)
-				if err := zrokEdgeSdk.DeleteServicePolicy("gc", deleteFilter, edge); err != nil {
+				if err := zrokEdgeSdk.DeleteServicePolicies("gc", deleteFilter, edge); err != nil {
 					logrus.Errorf("error garbage collecting service policy: %v", err)
 				}
 			} else {
diff --git a/controller/limits/accountLimitAction.go b/controller/limits/accountLimitAction.go
index 919166bc..02857f5c 100644
--- a/controller/limits/accountLimitAction.go
+++ b/controller/limits/accountLimitAction.go
@@ -33,7 +33,7 @@ func (a *accountLimitAction) HandleAccount(acct *store.Account, rxBytes, txBytes
 		}
 
 		for _, shr := range shrs {
-			if err := zrokEdgeSdk.DeleteServicePolicyDial(env.ZId, shr.Token, a.edge); err != nil {
+			if err := zrokEdgeSdk.DeleteServicePoliciesDial(env.ZId, shr.Token, a.edge); err != nil {
 				return errors.Wrapf(err, "error deleting dial service policy for '%v'", shr.Token)
 			}
 			logrus.Infof("removed dial service policy for share '%v' of environment '%v'", shr.Token, env.ZId)
diff --git a/controller/limits/environmentLimitAction.go b/controller/limits/environmentLimitAction.go
index ce26cafc..58e277dc 100644
--- a/controller/limits/environmentLimitAction.go
+++ b/controller/limits/environmentLimitAction.go
@@ -27,7 +27,7 @@ func (a *environmentLimitAction) HandleEnvironment(env *store.Environment, _, _
 	}
 
 	for _, shr := range shrs {
-		if err := zrokEdgeSdk.DeleteServicePolicyDial(env.ZId, shr.Token, a.edge); err != nil {
+		if err := zrokEdgeSdk.DeleteServicePoliciesDial(env.ZId, shr.Token, a.edge); err != nil {
 			return errors.Wrapf(err, "error deleting dial service policy for '%v'", shr.Token)
 		}
 		logrus.Infof("removed dial service policy for share '%v' of environment '%v'", shr.Token, env.ZId)
diff --git a/controller/limits/shareLimitAction.go b/controller/limits/shareLimitAction.go
index 7eefffb2..0b0e9fd7 100644
--- a/controller/limits/shareLimitAction.go
+++ b/controller/limits/shareLimitAction.go
@@ -25,7 +25,7 @@ func (a *shareLimitAction) HandleShare(shr *store.Share, _, _ int64, _ *Bandwidt
 		return err
 	}
 
-	if err := zrokEdgeSdk.DeleteServicePolicyDial(env.ZId, shr.Token, a.edge); err != nil {
+	if err := zrokEdgeSdk.DeleteServicePoliciesDial(env.ZId, shr.Token, a.edge); err != nil {
 		return err
 	}
 	logrus.Infof("removed dial service policy for '%v'", shr.Token)
diff --git a/controller/unaccess.go b/controller/unaccess.go
index a8bcb21b..197c1b7e 100644
--- a/controller/unaccess.go
+++ b/controller/unaccess.go
@@ -68,7 +68,7 @@ func (h *unaccessHandler) Handle(params share.UnaccessParams, principal *rest_mo
 		return share.NewUnaccessNotFound()
 	}
 
-	if err := zrokEdgeSdk.DeleteServicePolicy(envZId, fmt.Sprintf("tags.zrokShareToken=\"%v\" and tags.zrokFrontendToken=\"%v\" and type=1", shrToken, feToken), edge); err != nil {
+	if err := zrokEdgeSdk.DeleteServicePolicies(envZId, fmt.Sprintf("tags.zrokShareToken=\"%v\" and tags.zrokFrontendToken=\"%v\" and type=1", shrToken, feToken), edge); err != nil {
 		logrus.Errorf("error removing access to '%v' for '%v': %v", shrToken, envZId, err)
 		return share.NewUnaccessInternalServerError()
 	}
diff --git a/controller/unshare.go b/controller/unshare.go
index baf2052b..4b02021c 100644
--- a/controller/unshare.go
+++ b/controller/unshare.go
@@ -124,10 +124,10 @@ func (h *unshareHandler) deallocateResources(senv *store.Environment, shrToken,
 	if err := zrokEdgeSdk.DeleteServiceEdgeRouterPolicy(senv.ZId, shrToken, edge); err != nil {
 		return err
 	}
-	if err := zrokEdgeSdk.DeleteServicePolicyDial(senv.ZId, shrToken, edge); err != nil {
+	if err := zrokEdgeSdk.DeleteServicePoliciesDial(senv.ZId, shrToken, edge); err != nil {
 		return err
 	}
-	if err := zrokEdgeSdk.DeleteServicePolicyBind(senv.ZId, shrToken, edge); err != nil {
+	if err := zrokEdgeSdk.DeleteServicePoliciesBind(senv.ZId, shrToken, edge); err != nil {
 		return err
 	}
 	if err := zrokEdgeSdk.DeleteConfig(senv.ZId, shrToken, edge); err != nil {
diff --git a/controller/zrokEdgeSdk/sp.go b/controller/zrokEdgeSdk/sp.go
index cd790a42..89a763a7 100644
--- a/controller/zrokEdgeSdk/sp.go
+++ b/controller/zrokEdgeSdk/sp.go
@@ -78,16 +78,16 @@ func createServicePolicy(name string, semantic rest_model.Semantic, identityRole
 	return resp.Payload.Data.ID, nil
 }
 
-func DeleteServicePolicyBind(envZId, shrToken string, edge *rest_management_api_client.ZitiEdgeManagement) error {
-	return DeleteServicePolicy(envZId, fmt.Sprintf("tags.zrokShareToken=\"%v\" and type=%d", shrToken, servicePolicyBind), edge)
+func DeleteServicePoliciesBind(envZId, shrToken string, edge *rest_management_api_client.ZitiEdgeManagement) error {
+	return DeleteServicePolicies(envZId, fmt.Sprintf("tags.zrokShareToken=\"%v\" and type=%d", shrToken, servicePolicyBind), edge)
 }
 
-func DeleteServicePolicyDial(envZId, shrToken string, edge *rest_management_api_client.ZitiEdgeManagement) error {
-	return DeleteServicePolicy(envZId, fmt.Sprintf("tags.zrokShareToken=\"%v\" and type=%d", shrToken, servicePolicyDial), edge)
+func DeleteServicePoliciesDial(envZId, shrToken string, edge *rest_management_api_client.ZitiEdgeManagement) error {
+	return DeleteServicePolicies(envZId, fmt.Sprintf("tags.zrokShareToken=\"%v\" and type=%d", shrToken, servicePolicyDial), edge)
 }
 
-func DeleteServicePolicy(envZId, filter string, edge *rest_management_api_client.ZitiEdgeManagement) error {
-	limit := int64(1)
+func DeleteServicePolicies(envZId, filter string, edge *rest_management_api_client.ZitiEdgeManagement) error {
+	limit := int64(0)
 	offset := int64(0)
 	listReq := &service_policy.ListServicePoliciesParams{
 		Filter:  &filter,
@@ -100,8 +100,9 @@ func DeleteServicePolicy(envZId, filter string, edge *rest_management_api_client
 	if err != nil {
 		return err
 	}
-	if len(listResp.Payload.Data) == 1 {
-		spId := *(listResp.Payload.Data[0].ID)
+	logrus.Infof("found %d service policies to delete for '%v'", len(listResp.Payload.Data), filter)
+	for i := range listResp.Payload.Data {
+		spId := *(listResp.Payload.Data[i].ID)
 		req := &service_policy.DeleteServicePolicyParams{
 			ID:      spId,
 			Context: context.Background(),
@@ -112,8 +113,9 @@ func DeleteServicePolicy(envZId, filter string, edge *rest_management_api_client
 			return err
 		}
 		logrus.Infof("deleted service policy '%v' for environment '%v'", spId, envZId)
-	} else {
-		logrus.Infof("did not find a service policy")
+	}
+	if len(listResp.Payload.Data) < 1 {
+		logrus.Warnf("did not find any service policies to delete for '%v'", filter)
 	}
 	return nil
 }