').append(c,d,e),g=a('
').addClass(this.$element.prop("checked")?this._onstyle:this._offstyle+" off").addClass(b).addClass(this.options.style);this.$element.wrap(g),a.extend(this,{$toggle:this.$element.parent(),$toggleOn:c,$toggleOff:d,$toggleGroup:f}),this.$toggle.append(f);var h=this.options.width||Math.max(c.outerWidth(),d.outerWidth())+e.outerWidth()/2,i=this.options.height||Math.max(c.outerHeight(),d.outerHeight());c.addClass("toggle-on"),d.addClass("toggle-off"),this.$toggle.css({width:h,height:i}),this.options.height&&(c.css("line-height",c.height()+"px"),d.css("line-height",d.height()+"px")),this.update(!0),this.trigger(!0)},c.prototype.toggle=function(){this.$element.prop("checked")?this.off():this.on()},c.prototype.on=function(a){return this.$element.prop("disabled")?!1:(this.$toggle.removeClass(this._offstyle+" off").addClass(this._onstyle),this.$element.prop("checked",!0),void(a||this.trigger()))},c.prototype.off=function(a){return this.$element.prop("disabled")?!1:(this.$toggle.removeClass(this._onstyle).addClass(this._offstyle+" off"),this.$element.prop("checked",!1),void(a||this.trigger()))},c.prototype.enable=function(){this.$toggle.removeAttr("disabled"),this.$element.prop("disabled",!1)},c.prototype.disable=function(){this.$toggle.attr("disabled","disabled"),this.$element.prop("disabled",!0)},c.prototype.update=function(a){this.$element.prop("disabled")?this.disable():this.enable(),this.$element.prop("checked")?this.on(a):this.off(a)},c.prototype.trigger=function(b){this.$element.off("change.bs.toggle"),b||this.$element.change(),this.$element.on("change.bs.toggle",a.proxy(function(){this.update()},this))},c.prototype.destroy=function(){this.$element.off("change.bs.toggle"),this.$toggleGroup.remove(),this.$element.removeData("bs.toggle"),this.$element.unwrap()};var d=a.fn.bootstrapToggle;a.fn.bootstrapToggle=b,a.fn.bootstrapToggle.Constructor=c,a.fn.toggle.noConflict=function(){return a.fn.bootstrapToggle=d,this},a(function(){a("input[type=checkbox][data-toggle^=toggle]").bootstrapToggle()}),a(document).on("click.bs.toggle","div[data-toggle^=toggle]",function(b){var c=a(this).find("input[type=checkbox]");c.bootstrapToggle("toggle"),b.preventDefault()})}(jQuery);
9 |
--------------------------------------------------------------------------------
/internal/static/libs/js/popperjs-core2:
--------------------------------------------------------------------------------
1 | /**
2 | * @popperjs/core v2.11.5 - MIT License
3 | */
4 |
5 | !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).Popper={})}(this,(function(e){"use strict";function t(e){if(null==e)return window;if("[object Window]"!==e.toString()){var t=e.ownerDocument;return t&&t.defaultView||window}return e}function n(e){return e instanceof t(e).Element||e instanceof Element}function r(e){return e instanceof t(e).HTMLElement||e instanceof HTMLElement}function o(e){return"undefined"!=typeof ShadowRoot&&(e instanceof t(e).ShadowRoot||e instanceof ShadowRoot)}var i=Math.max,a=Math.min,s=Math.round;function f(e,t){void 0===t&&(t=!1);var n=e.getBoundingClientRect(),o=1,i=1;if(r(e)&&t){var a=e.offsetHeight,f=e.offsetWidth;f>0&&(o=s(n.width)/f||1),a>0&&(i=s(n.height)/a||1)}return{width:n.width/o,height:n.height/i,top:n.top/i,right:n.right/o,bottom:n.bottom/i,left:n.left/o,x:n.left/o,y:n.top/i}}function c(e){var n=t(e);return{scrollLeft:n.pageXOffset,scrollTop:n.pageYOffset}}function p(e){return e?(e.nodeName||"").toLowerCase():null}function u(e){return((n(e)?e.ownerDocument:e.document)||window.document).documentElement}function l(e){return f(u(e)).left+c(e).scrollLeft}function d(e){return t(e).getComputedStyle(e)}function h(e){var t=d(e),n=t.overflow,r=t.overflowX,o=t.overflowY;return/auto|scroll|overlay|hidden/.test(n+o+r)}function m(e,n,o){void 0===o&&(o=!1);var i,a,d=r(n),m=r(n)&&function(e){var t=e.getBoundingClientRect(),n=s(t.width)/e.offsetWidth||1,r=s(t.height)/e.offsetHeight||1;return 1!==n||1!==r}(n),v=u(n),g=f(e,m),y={scrollLeft:0,scrollTop:0},b={x:0,y:0};return(d||!d&&!o)&&(("body"!==p(n)||h(v))&&(y=(i=n)!==t(i)&&r(i)?{scrollLeft:(a=i).scrollLeft,scrollTop:a.scrollTop}:c(i)),r(n)?((b=f(n,!0)).x+=n.clientLeft,b.y+=n.clientTop):v&&(b.x=l(v))),{x:g.left+y.scrollLeft-b.x,y:g.top+y.scrollTop-b.y,width:g.width,height:g.height}}function v(e){var t=f(e),n=e.offsetWidth,r=e.offsetHeight;return Math.abs(t.width-n)<=1&&(n=t.width),Math.abs(t.height-r)<=1&&(r=t.height),{x:e.offsetLeft,y:e.offsetTop,width:n,height:r}}function g(e){return"html"===p(e)?e:e.assignedSlot||e.parentNode||(o(e)?e.host:null)||u(e)}function y(e){return["html","body","#document"].indexOf(p(e))>=0?e.ownerDocument.body:r(e)&&h(e)?e:y(g(e))}function b(e,n){var r;void 0===n&&(n=[]);var o=y(e),i=o===(null==(r=e.ownerDocument)?void 0:r.body),a=t(o),s=i?[a].concat(a.visualViewport||[],h(o)?o:[]):o,f=n.concat(s);return i?f:f.concat(b(g(s)))}function x(e){return["table","td","th"].indexOf(p(e))>=0}function w(e){return r(e)&&"fixed"!==d(e).position?e.offsetParent:null}function O(e){for(var n=t(e),i=w(e);i&&x(i)&&"static"===d(i).position;)i=w(i);return i&&("html"===p(i)||"body"===p(i)&&"static"===d(i).position)?n:i||function(e){var t=-1!==navigator.userAgent.toLowerCase().indexOf("firefox");if(-1!==navigator.userAgent.indexOf("Trident")&&r(e)&&"fixed"===d(e).position)return null;var n=g(e);for(o(n)&&(n=n.host);r(n)&&["html","body"].indexOf(p(n))<0;){var i=d(n);if("none"!==i.transform||"none"!==i.perspective||"paint"===i.contain||-1!==["transform","perspective"].indexOf(i.willChange)||t&&"filter"===i.willChange||t&&i.filter&&"none"!==i.filter)return n;n=n.parentNode}return null}(e)||n}var j="top",E="bottom",D="right",A="left",L="auto",P=[j,E,D,A],M="start",k="end",W="viewport",B="popper",H=P.reduce((function(e,t){return e.concat([t+"-"+M,t+"-"+k])}),[]),T=[].concat(P,[L]).reduce((function(e,t){return e.concat([t,t+"-"+M,t+"-"+k])}),[]),R=["beforeRead","read","afterRead","beforeMain","main","afterMain","beforeWrite","write","afterWrite"];function S(e){var t=new Map,n=new Set,r=[];function o(e){n.add(e.name),[].concat(e.requires||[],e.requiresIfExists||[]).forEach((function(e){if(!n.has(e)){var r=t.get(e);r&&o(r)}})),r.push(e)}return e.forEach((function(e){t.set(e.name,e)})),e.forEach((function(e){n.has(e.name)||o(e)})),r}function C(e){return e.split("-")[0]}function q(e,t){var n=t.getRootNode&&t.getRootNode();if(e.contains(t))return!0;if(n&&o(n)){var r=t;do{if(r&&e.isSameNode(r))return!0;r=r.parentNode||r.host}while(r)}return!1}function V(e){return Object.assign({},e,{left:e.x,top:e.y,right:e.x+e.width,bottom:e.y+e.height})}function N(e,r){return r===W?V(function(e){var n=t(e),r=u(e),o=n.visualViewport,i=r.clientWidth,a=r.clientHeight,s=0,f=0;return o&&(i=o.width,a=o.height,/^((?!chrome|android).)*safari/i.test(navigator.userAgent)||(s=o.offsetLeft,f=o.offsetTop)),{width:i,height:a,x:s+l(e),y:f}}(e)):n(r)?function(e){var t=f(e);return t.top=t.top+e.clientTop,t.left=t.left+e.clientLeft,t.bottom=t.top+e.clientHeight,t.right=t.left+e.clientWidth,t.width=e.clientWidth,t.height=e.clientHeight,t.x=t.left,t.y=t.top,t}(r):V(function(e){var t,n=u(e),r=c(e),o=null==(t=e.ownerDocument)?void 0:t.body,a=i(n.scrollWidth,n.clientWidth,o?o.scrollWidth:0,o?o.clientWidth:0),s=i(n.scrollHeight,n.clientHeight,o?o.scrollHeight:0,o?o.clientHeight:0),f=-r.scrollLeft+l(e),p=-r.scrollTop;return"rtl"===d(o||n).direction&&(f+=i(n.clientWidth,o?o.clientWidth:0)-a),{width:a,height:s,x:f,y:p}}(u(e)))}function I(e,t,o){var s="clippingParents"===t?function(e){var t=b(g(e)),o=["absolute","fixed"].indexOf(d(e).position)>=0&&r(e)?O(e):e;return n(o)?t.filter((function(e){return n(e)&&q(e,o)&&"body"!==p(e)})):[]}(e):[].concat(t),f=[].concat(s,[o]),c=f[0],u=f.reduce((function(t,n){var r=N(e,n);return t.top=i(r.top,t.top),t.right=a(r.right,t.right),t.bottom=a(r.bottom,t.bottom),t.left=i(r.left,t.left),t}),N(e,c));return u.width=u.right-u.left,u.height=u.bottom-u.top,u.x=u.left,u.y=u.top,u}function _(e){return e.split("-")[1]}function F(e){return["top","bottom"].indexOf(e)>=0?"x":"y"}function U(e){var t,n=e.reference,r=e.element,o=e.placement,i=o?C(o):null,a=o?_(o):null,s=n.x+n.width/2-r.width/2,f=n.y+n.height/2-r.height/2;switch(i){case j:t={x:s,y:n.y-r.height};break;case E:t={x:s,y:n.y+n.height};break;case D:t={x:n.x+n.width,y:f};break;case A:t={x:n.x-r.width,y:f};break;default:t={x:n.x,y:n.y}}var c=i?F(i):null;if(null!=c){var p="y"===c?"height":"width";switch(a){case M:t[c]=t[c]-(n[p]/2-r[p]/2);break;case k:t[c]=t[c]+(n[p]/2-r[p]/2)}}return t}function z(e){return Object.assign({},{top:0,right:0,bottom:0,left:0},e)}function X(e,t){return t.reduce((function(t,n){return t[n]=e,t}),{})}function Y(e,t){void 0===t&&(t={});var r=t,o=r.placement,i=void 0===o?e.placement:o,a=r.boundary,s=void 0===a?"clippingParents":a,c=r.rootBoundary,p=void 0===c?W:c,l=r.elementContext,d=void 0===l?B:l,h=r.altBoundary,m=void 0!==h&&h,v=r.padding,g=void 0===v?0:v,y=z("number"!=typeof g?g:X(g,P)),b=d===B?"reference":B,x=e.rects.popper,w=e.elements[m?b:d],O=I(n(w)?w:w.contextElement||u(e.elements.popper),s,p),A=f(e.elements.reference),L=U({reference:A,element:x,strategy:"absolute",placement:i}),M=V(Object.assign({},x,L)),k=d===B?M:A,H={top:O.top-k.top+y.top,bottom:k.bottom-O.bottom+y.bottom,left:O.left-k.left+y.left,right:k.right-O.right+y.right},T=e.modifiersData.offset;if(d===B&&T){var R=T[i];Object.keys(H).forEach((function(e){var t=[D,E].indexOf(e)>=0?1:-1,n=[j,E].indexOf(e)>=0?"y":"x";H[e]+=R[n]*t}))}return H}var G={placement:"bottom",modifiers:[],strategy:"absolute"};function J(){for(var e=arguments.length,t=new Array(e),n=0;n=0?-1:1,i="function"==typeof n?n(Object.assign({},t,{placement:e})):n,a=i[0],s=i[1];return a=a||0,s=(s||0)*o,[A,D].indexOf(r)>=0?{x:s,y:a}:{x:a,y:s}}(n,t.rects,i),e}),{}),s=a[t.placement],f=s.x,c=s.y;null!=t.modifiersData.popperOffsets&&(t.modifiersData.popperOffsets.x+=f,t.modifiersData.popperOffsets.y+=c),t.modifiersData[r]=a}},ie={left:"right",right:"left",bottom:"top",top:"bottom"};function ae(e){return e.replace(/left|right|bottom|top/g,(function(e){return ie[e]}))}var se={start:"end",end:"start"};function fe(e){return e.replace(/start|end/g,(function(e){return se[e]}))}function ce(e,t){void 0===t&&(t={});var n=t,r=n.placement,o=n.boundary,i=n.rootBoundary,a=n.padding,s=n.flipVariations,f=n.allowedAutoPlacements,c=void 0===f?T:f,p=_(r),u=p?s?H:H.filter((function(e){return _(e)===p})):P,l=u.filter((function(e){return c.indexOf(e)>=0}));0===l.length&&(l=u);var d=l.reduce((function(t,n){return t[n]=Y(e,{placement:n,boundary:o,rootBoundary:i,padding:a})[C(n)],t}),{});return Object.keys(d).sort((function(e,t){return d[e]-d[t]}))}var pe={name:"flip",enabled:!0,phase:"main",fn:function(e){var t=e.state,n=e.options,r=e.name;if(!t.modifiersData[r]._skip){for(var o=n.mainAxis,i=void 0===o||o,a=n.altAxis,s=void 0===a||a,f=n.fallbackPlacements,c=n.padding,p=n.boundary,u=n.rootBoundary,l=n.altBoundary,d=n.flipVariations,h=void 0===d||d,m=n.allowedAutoPlacements,v=t.options.placement,g=C(v),y=f||(g===v||!h?[ae(v)]:function(e){if(C(e)===L)return[];var t=ae(e);return[fe(e),t,fe(t)]}(v)),b=[v].concat(y).reduce((function(e,n){return e.concat(C(n)===L?ce(t,{placement:n,boundary:p,rootBoundary:u,padding:c,flipVariations:h,allowedAutoPlacements:m}):n)}),[]),x=t.rects.reference,w=t.rects.popper,O=new Map,P=!0,k=b[0],W=0;W=0,S=R?"width":"height",q=Y(t,{placement:B,boundary:p,rootBoundary:u,altBoundary:l,padding:c}),V=R?T?D:A:T?E:j;x[S]>w[S]&&(V=ae(V));var N=ae(V),I=[];if(i&&I.push(q[H]<=0),s&&I.push(q[V]<=0,q[N]<=0),I.every((function(e){return e}))){k=B,P=!1;break}O.set(B,I)}if(P)for(var F=function(e){var t=b.find((function(t){var n=O.get(t);if(n)return n.slice(0,e).every((function(e){return e}))}));if(t)return k=t,"break"},U=h?3:1;U>0;U--){if("break"===F(U))break}t.placement!==k&&(t.modifiersData[r]._skip=!0,t.placement=k,t.reset=!0)}},requiresIfExists:["offset"],data:{_skip:!1}};function ue(e,t,n){return i(e,a(t,n))}var le={name:"preventOverflow",enabled:!0,phase:"main",fn:function(e){var t=e.state,n=e.options,r=e.name,o=n.mainAxis,s=void 0===o||o,f=n.altAxis,c=void 0!==f&&f,p=n.boundary,u=n.rootBoundary,l=n.altBoundary,d=n.padding,h=n.tether,m=void 0===h||h,g=n.tetherOffset,y=void 0===g?0:g,b=Y(t,{boundary:p,rootBoundary:u,padding:d,altBoundary:l}),x=C(t.placement),w=_(t.placement),L=!w,P=F(x),k="x"===P?"y":"x",W=t.modifiersData.popperOffsets,B=t.rects.reference,H=t.rects.popper,T="function"==typeof y?y(Object.assign({},t.rects,{placement:t.placement})):y,R="number"==typeof T?{mainAxis:T,altAxis:T}:Object.assign({mainAxis:0,altAxis:0},T),S=t.modifiersData.offset?t.modifiersData.offset[t.placement]:null,q={x:0,y:0};if(W){if(s){var V,N="y"===P?j:A,I="y"===P?E:D,U="y"===P?"height":"width",z=W[P],X=z+b[N],G=z-b[I],J=m?-H[U]/2:0,K=w===M?B[U]:H[U],Q=w===M?-H[U]:-B[U],Z=t.elements.arrow,$=m&&Z?v(Z):{width:0,height:0},ee=t.modifiersData["arrow#persistent"]?t.modifiersData["arrow#persistent"].padding:{top:0,right:0,bottom:0,left:0},te=ee[N],ne=ee[I],re=ue(0,B[U],$[U]),oe=L?B[U]/2-J-re-te-R.mainAxis:K-re-te-R.mainAxis,ie=L?-B[U]/2+J+re+ne+R.mainAxis:Q+re+ne+R.mainAxis,ae=t.elements.arrow&&O(t.elements.arrow),se=ae?"y"===P?ae.clientTop||0:ae.clientLeft||0:0,fe=null!=(V=null==S?void 0:S[P])?V:0,ce=z+ie-fe,pe=ue(m?a(X,z+oe-fe-se):X,z,m?i(G,ce):G);W[P]=pe,q[P]=pe-z}if(c){var le,de="x"===P?j:A,he="x"===P?E:D,me=W[k],ve="y"===k?"height":"width",ge=me+b[de],ye=me-b[he],be=-1!==[j,A].indexOf(x),xe=null!=(le=null==S?void 0:S[k])?le:0,we=be?ge:me-B[ve]-H[ve]-xe+R.altAxis,Oe=be?me+B[ve]+H[ve]-xe-R.altAxis:ye,je=m&&be?function(e,t,n){var r=ue(e,t,n);return r>n?n:r}(we,me,Oe):ue(m?we:ge,me,m?Oe:ye);W[k]=je,q[k]=je-me}t.modifiersData[r]=q}},requiresIfExists:["offset"]};var de={name:"arrow",enabled:!0,phase:"main",fn:function(e){var t,n=e.state,r=e.name,o=e.options,i=n.elements.arrow,a=n.modifiersData.popperOffsets,s=C(n.placement),f=F(s),c=[A,D].indexOf(s)>=0?"height":"width";if(i&&a){var p=function(e,t){return z("number"!=typeof(e="function"==typeof e?e(Object.assign({},t.rects,{placement:t.placement})):e)?e:X(e,P))}(o.padding,n),u=v(i),l="y"===f?j:A,d="y"===f?E:D,h=n.rects.reference[c]+n.rects.reference[f]-a[f]-n.rects.popper[c],m=a[f]-n.rects.reference[f],g=O(i),y=g?"y"===f?g.clientHeight||0:g.clientWidth||0:0,b=h/2-m/2,x=p[l],w=y-u[c]-p[d],L=y/2-u[c]/2+b,M=ue(x,L,w),k=f;n.modifiersData[r]=((t={})[k]=M,t.centerOffset=M-L,t)}},effect:function(e){var t=e.state,n=e.options.element,r=void 0===n?"[data-popper-arrow]":n;null!=r&&("string"!=typeof r||(r=t.elements.popper.querySelector(r)))&&q(t.elements.popper,r)&&(t.elements.arrow=r)},requires:["popperOffsets"],requiresIfExists:["preventOverflow"]};function he(e,t,n){return void 0===n&&(n={x:0,y:0}),{top:e.top-t.height-n.y,right:e.right-t.width+n.x,bottom:e.bottom-t.height+n.y,left:e.left-t.width-n.x}}function me(e){return[j,D,E,A].some((function(t){return e[t]>=0}))}var ve={name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:function(e){var t=e.state,n=e.name,r=t.rects.reference,o=t.rects.popper,i=t.modifiersData.preventOverflow,a=Y(t,{elementContext:"reference"}),s=Y(t,{altBoundary:!0}),f=he(a,r),c=he(s,o,i),p=me(f),u=me(c);t.modifiersData[n]={referenceClippingOffsets:f,popperEscapeOffsets:c,isReferenceHidden:p,hasPopperEscaped:u},t.attributes.popper=Object.assign({},t.attributes.popper,{"data-popper-reference-hidden":p,"data-popper-escaped":u})}},ge=K({defaultModifiers:[Z,$,ne,re]}),ye=[Z,$,ne,re,oe,pe,le,de,ve],be=K({defaultModifiers:ye});e.applyStyles=re,e.arrow=de,e.computeStyles=ne,e.createPopper=be,e.createPopperLite=ge,e.defaultModifiers=ye,e.detectOverflow=Y,e.eventListeners=Z,e.flip=pe,e.hide=ve,e.offset=oe,e.popperGenerator=K,e.popperOffsets=$,e.preventOverflow=le,Object.defineProperty(e,"__esModule",{value:!0})}));
6 |
--------------------------------------------------------------------------------
/internal/static/libs/webfonts/fa-solid-900.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arl/statsviz/014125456991f246926eb6c7fbfa9ffd1a161edf/internal/static/libs/webfonts/fa-solid-900.woff2
--------------------------------------------------------------------------------
/statsviz.go:
--------------------------------------------------------------------------------
1 | // Package statsviz allows visualizing Go runtime metrics data in real time in
2 | // your browser.
3 | //
4 | // Register a Statsviz HTTP handlers with your server's [http.ServeMux]
5 | // (preferred method):
6 | //
7 | // mux := http.NewServeMux()
8 | // statsviz.Register(mux)
9 | //
10 | // Alternatively, you can register with [http.DefaultServeMux]:
11 | //
12 | // ss := statsviz.Server{}
13 | // s.Register(http.DefaultServeMux)
14 | //
15 | // By default, Statsviz is served at http://host:port/debug/statsviz/. This, and
16 | // other settings, can be changed by passing some [Option] to [NewServer].
17 | //
18 | // If your application is not already running an HTTP server, you need to start
19 | // one. Add "net/http" and "log" to your imports, and use the following code in
20 | // your main function:
21 | //
22 | // go func() {
23 | // log.Println(http.ListenAndServe("localhost:8080", nil))
24 | // }()
25 | //
26 | // Then open your browser and visit http://localhost:8080/debug/statsviz/.
27 | //
28 | // # Advanced usage:
29 | //
30 | // If you want more control over Statsviz HTTP handlers, examples are:
31 | // - you're using some HTTP framework
32 | // - you want to place Statsviz handler behind some middleware
33 | //
34 | // then use [NewServer] to obtain a [Server] instance. Both the [Server.Index] and
35 | // [Server.Ws]() methods return [http.HandlerFunc].
36 | //
37 | // srv, err := statsviz.NewServer(); // Create server or handle error
38 | // srv.Index() // UI (dashboard) http.HandlerFunc
39 | // srv.Ws() // Websocket http.HandlerFunc
40 | package statsviz
41 |
42 | import (
43 | "bytes"
44 | "encoding/json"
45 | "fmt"
46 | "net/http"
47 | "os"
48 | "path/filepath"
49 | "strconv"
50 | "strings"
51 | "time"
52 |
53 | "github.com/gorilla/websocket"
54 |
55 | "github.com/arl/statsviz/internal/plot"
56 | "github.com/arl/statsviz/internal/static"
57 | )
58 |
59 | const (
60 | defaultRoot = "/debug/statsviz"
61 | defaultSendInterval = time.Second
62 | )
63 |
64 | // RegisterDefault registers the Statsviz HTTP handlers on [http.DefaultServeMux].
65 | //
66 | // RegisterDefault should not be used in production.
67 | func RegisterDefault(opts ...Option) error {
68 | return Register(http.DefaultServeMux, opts...)
69 | }
70 |
71 | // Register registers the Statsviz HTTP handlers on the provided mux.
72 | func Register(mux *http.ServeMux, opts ...Option) error {
73 | srv, err := NewServer(opts...)
74 | if err != nil {
75 | return err
76 | }
77 | srv.Register(mux)
78 | return nil
79 | }
80 |
81 | // Server is the core component of Statsviz. It collects and periodically
82 | // updates metrics data and provides two essential HTTP handlers:
83 | // - the Index handler serves Statsviz user interface, allowing you to
84 | // visualize runtime metrics on your browser.
85 | // - The Ws handler establishes a WebSocket connection allowing the connected
86 | // browser to receive metrics updates from the server.
87 | //
88 | // The zero value is not a valid Server, use NewServer to create a valid one.
89 | type Server struct {
90 | intv time.Duration // interval between consecutive metrics emission
91 | root string // HTTP path root
92 | plots *plot.List // plots shown on the user interface
93 | userPlots []plot.UserPlot
94 | }
95 |
96 | // NewServer constructs a new Statsviz Server with the provided options, or the
97 | // default settings.
98 | //
99 | // Note that once the server is created, its HTTP handlers needs to be registered
100 | // with some HTTP server. You can either use the Register method or register yourself
101 | // the Index and Ws handlers.
102 | func NewServer(opts ...Option) (*Server, error) {
103 | s := &Server{
104 | intv: defaultSendInterval,
105 | root: defaultRoot,
106 | }
107 |
108 | for _, opt := range opts {
109 | if err := opt(s); err != nil {
110 | return nil, err
111 | }
112 | }
113 |
114 | pl, err := plot.NewList(s.userPlots)
115 | if err != nil {
116 | return nil, err
117 | }
118 | s.plots = pl
119 | return s, nil
120 | }
121 |
122 | // Option is a configuration option for the Server.
123 | type Option func(*Server) error
124 |
125 | // SendFrequency changes the interval between successive acquisitions of metrics
126 | // and their sending to the user interface. The default interval is one second.
127 | func SendFrequency(intv time.Duration) Option {
128 | return func(s *Server) error {
129 | if intv <= 0 {
130 | return fmt.Errorf("frequency must be a positive integer")
131 | }
132 | s.intv = intv
133 | return nil
134 | }
135 | }
136 |
137 | // Root changes the root path of the Statsviz user interface.
138 | // The default is "/debug/statsviz".
139 | func Root(path string) Option {
140 | return func(s *Server) error {
141 | s.root = path
142 | return nil
143 | }
144 | }
145 |
146 | // TimeseriesPlot adds a new time series plot to Statsviz. This options can
147 | // be added multiple times.
148 | func TimeseriesPlot(tsp TimeSeriesPlot) Option {
149 | return func(s *Server) error {
150 | s.userPlots = append(s.userPlots, plot.UserPlot{Scatter: tsp.timeseries})
151 | return nil
152 | }
153 | }
154 |
155 | // Register registers the Statsviz HTTP handlers on the provided mux.
156 | func (s *Server) Register(mux *http.ServeMux) {
157 | mux.Handle(s.root+"/", s.Index())
158 | mux.HandleFunc(s.root+"/ws", s.Ws())
159 | }
160 |
161 | // intercept is a middleware that intercepts requests for plotsdef.js, which is
162 | // generated dynamically based on the plots configuration. Other requests are
163 | // forwarded as-is.
164 | func intercept(h http.Handler, cfg *plot.Config) http.HandlerFunc {
165 | buf := bytes.Buffer{}
166 | buf.WriteString("export default ")
167 | enc := json.NewEncoder(&buf)
168 | enc.SetIndent("", " ")
169 | if err := enc.Encode(cfg); err != nil {
170 | panic("unexpected failure to encode plot definitions: " + err.Error())
171 | }
172 | buf.WriteString(";")
173 | plotsdefjs := buf.Bytes()
174 |
175 | return func(w http.ResponseWriter, r *http.Request) {
176 | if r.URL.Path == "js/plotsdef.js" {
177 | w.Header().Add("Content-Length", strconv.Itoa(buf.Len()))
178 | w.Header().Add("Content-Type", "text/javascript; charset=utf-8")
179 | w.Write(plotsdefjs)
180 | return
181 | }
182 |
183 | // Force Content-Type if needed.
184 | if ct, ok := contentTypes[r.URL.Path]; ok {
185 | w.Header().Add("Content-Type", ct)
186 | }
187 |
188 | h.ServeHTTP(w, r)
189 | }
190 | }
191 |
192 | // contentTypes forces the Content-Type HTTP header for certain files of some
193 | // JavaScript libraries that have no extensions. Otherwise, the HTTP file server
194 | // would serve them with "Content-Type: text/plain".
195 | var contentTypes = map[string]string{
196 | "libs/js/popperjs-core2": "text/javascript",
197 | "libs/js/tippy.js@6": "text/javascript",
198 | }
199 |
200 | // Returns an FS serving the embedded assets, or the assets directory if
201 | // STATSVIZ_DEBUG contains the 'asssets' key.
202 | func assetsFS() http.FileSystem {
203 | assets := http.FS(static.Assets)
204 |
205 | vdbg := os.Getenv("STATSVIZ_DEBUG")
206 | if vdbg == "" {
207 | return assets
208 | }
209 |
210 | kvs := strings.Split(vdbg, ";")
211 | for _, kv := range kvs {
212 | k, v, found := strings.Cut(strings.TrimSpace(kv), "=")
213 | if !found {
214 | panic("invalid STATSVIZ_DEBUG value: " + kv)
215 | }
216 | if k == "assets" {
217 | dir := filepath.Join(v)
218 | return http.Dir(dir)
219 | }
220 | }
221 |
222 | return assets
223 | }
224 |
225 | // Index returns the index handler, which responds with the Statsviz user
226 | // interface HTML page. By default, the handler is served at the path specified
227 | // by the root. Use [WithRoot] to change the path.
228 | func (s *Server) Index() http.HandlerFunc {
229 | prefix := strings.TrimSuffix(s.root, "/") + "/"
230 | assets := http.FileServer(assetsFS())
231 | handler := intercept(assets, s.plots.Config())
232 |
233 | return http.StripPrefix(prefix, handler).ServeHTTP
234 | }
235 |
236 | // Ws returns the WebSocket handler used by Statsviz to send application
237 | // metrics. The underlying net.Conn is used to upgrade the HTTP server
238 | // connection to the WebSocket protocol.
239 | func (s *Server) Ws() http.HandlerFunc {
240 | return func(w http.ResponseWriter, r *http.Request) {
241 | var upgrader = websocket.Upgrader{
242 | ReadBufferSize: 1024,
243 | WriteBufferSize: 1024,
244 | }
245 |
246 | ws, err := upgrader.Upgrade(w, r, nil)
247 | if err != nil {
248 | return
249 | }
250 | defer ws.Close()
251 |
252 | // Ignore this error. This happens when the other end connection closes,
253 | // for example. We can't handle it in any meaningful way anyways.
254 | _ = s.sendStats(ws, s.intv)
255 | }
256 | }
257 |
258 | // sendStats sends runtime statistics over the WebSocket connection.
259 | func (s *Server) sendStats(conn *websocket.Conn, frequency time.Duration) error {
260 | tick := time.NewTicker(frequency)
261 | defer tick.Stop()
262 |
263 | // If the WebSocket connection is initiated by an already open web UI
264 | // (started by a previous process, for example), then plotsdef.js won't be
265 | // requested. Call plots.Config() manually to ensure that s.plots internals
266 | // are correctly initialized.
267 | s.plots.Config()
268 |
269 | for range tick.C {
270 | w, err := conn.NextWriter(websocket.TextMessage)
271 | if err != nil {
272 | return err
273 | }
274 | if err := s.plots.WriteValues(w); err != nil {
275 | return err
276 | }
277 | if err := w.Close(); err != nil {
278 | return err
279 | }
280 | }
281 |
282 | panic("unreachable")
283 | }
284 |
--------------------------------------------------------------------------------
/statsviz_test.go:
--------------------------------------------------------------------------------
1 | package statsviz
2 |
3 | import (
4 | "bytes"
5 | "io"
6 | "io/fs"
7 | "net/http"
8 | "net/http/httptest"
9 | "net/url"
10 | "strings"
11 | "testing"
12 | "time"
13 |
14 | "github.com/gorilla/websocket"
15 |
16 | "github.com/arl/statsviz/internal/static"
17 | )
18 |
19 | func testIndex(t *testing.T, f http.Handler, url string) {
20 | t.Helper()
21 |
22 | req := httptest.NewRequest("GET", url, nil)
23 | w := httptest.NewRecorder()
24 | f.ServeHTTP(w, req)
25 |
26 | resp := w.Result()
27 | body, _ := io.ReadAll(resp.Body)
28 |
29 | if resp.StatusCode != http.StatusOK {
30 | t.Errorf("http status %v, want %v", resp.StatusCode, http.StatusOK)
31 | }
32 |
33 | if resp.Header.Get("Content-Type") != "text/html; charset=utf-8" {
34 | t.Errorf("header[Content-Type] %s, want %s", resp.Header.Get("Content-Type"), "text/html; charset=utf-8")
35 | }
36 |
37 | html, err := static.Assets.ReadFile("index.html")
38 | if err != nil {
39 | t.Fatalf("couldn't read index.html from assets Fs: %v", err)
40 | }
41 |
42 | if !bytes.Equal(html, body) {
43 | t.Errorf("read body is not that of index.html from assets")
44 | }
45 | }
46 |
47 | func newServer(tb testing.TB, opts ...Option) *Server {
48 | tb.Helper()
49 |
50 | srv, err := NewServer(opts...)
51 | if err != nil {
52 | tb.Fatal(err)
53 | }
54 | return srv
55 | }
56 |
57 | func TestIndex(t *testing.T) {
58 | t.Parallel()
59 |
60 | srv := newServer(t)
61 | testIndex(t, srv.Index(), "http://example.com/debug/statsviz/")
62 | }
63 |
64 | func TestRoot(t *testing.T) {
65 | t.Parallel()
66 |
67 | testIndex(t, newServer(t, Root("/debug/")).Index(), "http://example.com/debug/")
68 | testIndex(t, newServer(t, Root("/debug")).Index(), "http://example.com/debug/")
69 | testIndex(t, newServer(t, Root("/")).Index(), "http://example.com/")
70 | testIndex(t, newServer(t, Root("/test/")).Index(), "http://example.com/test/")
71 | }
72 |
73 | func testWs(t *testing.T, f http.Handler, URL string) {
74 | t.Helper()
75 |
76 | s := httptest.NewServer(f)
77 | defer s.Close()
78 |
79 | // Build a "ws://" url using the httptest server URL and the URL argument.
80 | u1, err := url.Parse(s.URL)
81 | if err != nil {
82 | t.Fatal(err)
83 | }
84 | u2, err := url.Parse(URL)
85 | if err != nil {
86 | t.Fatal(err)
87 | }
88 |
89 | u1.Scheme = "ws"
90 | u1.Path = u2.Path
91 |
92 | // Connect to the server
93 | ws, _, err := websocket.DefaultDialer.Dial(u1.String(), nil)
94 | if err != nil {
95 | t.Fatalf("%v", err)
96 | }
97 | defer ws.Close()
98 |
99 | // Check the content of 2 consecutive payloads.
100 | for i := 0; i < 2; i++ {
101 |
102 | // Verifies that we've received 1 time series (goroutines) and one
103 | // heatmap (sizeClasses).
104 | var data struct {
105 | Goroutines []uint64 `json:"goroutines"`
106 | SizeClasses []uint64 `json:"size-classes"`
107 | }
108 | if err := ws.ReadJSON(&data); err != nil {
109 | t.Fatalf("failed reading json from websocket: %v", err)
110 | }
111 |
112 | // The time series must have one and only one element
113 | if len(data.Goroutines) != 1 {
114 | t.Errorf("len(goroutines) = %d, want 1", len(data.Goroutines))
115 | }
116 | // Heatmaps should have many elements, check that there's more than one.
117 | if len(data.SizeClasses) <= 1 {
118 | t.Errorf("len(sizeClasses) = %d, want > 1", len(data.SizeClasses))
119 | }
120 | }
121 | }
122 |
123 | func TestWs(t *testing.T) {
124 | t.Parallel()
125 |
126 | testWs(t, newServer(t).Ws(), "http://example.com/debug/statsviz/ws")
127 | }
128 |
129 | func TestWsCantUpgrade(t *testing.T) {
130 | url := "http://example.com/debug/statsviz/ws"
131 |
132 | req := httptest.NewRequest("GET", url, nil)
133 | w := httptest.NewRecorder()
134 | newServer(t).Ws()(w, req)
135 |
136 | if w.Result().StatusCode != http.StatusBadRequest {
137 | t.Errorf("responded %v to %q with non-websocket-upgradable conn, want %v", w.Result().StatusCode, url, http.StatusBadRequest)
138 | }
139 | }
140 |
141 | func testRegister(t *testing.T, f http.Handler, baseURL string) {
142 | testIndex(t, f, baseURL)
143 | ws := strings.TrimRight(baseURL, "/") + "/ws"
144 | testWs(t, f, ws)
145 | }
146 |
147 | func TestRegister(t *testing.T) {
148 | t.Run("default", func(t *testing.T) {
149 | t.Parallel()
150 |
151 | mux := http.NewServeMux()
152 | newServer(t).Register(mux)
153 | testRegister(t, mux, "http://example.com/debug/statsviz/")
154 | })
155 |
156 | t.Run("root", func(t *testing.T) {
157 | t.Parallel()
158 |
159 | mux := http.NewServeMux()
160 | newServer(t,
161 | Root(""),
162 | ).Register(mux)
163 |
164 | testRegister(t, mux, "http://example.com/")
165 | })
166 |
167 | t.Run("root2", func(t *testing.T) {
168 | t.Parallel()
169 |
170 | mux := http.NewServeMux()
171 | newServer(t,
172 | Root("/path/to/statsviz"),
173 | ).Register(mux)
174 |
175 | testRegister(t, mux, "http://example.com/path/to/statsviz/")
176 | })
177 |
178 | t.Run("root+frequency", func(t *testing.T) {
179 | t.Parallel()
180 |
181 | mux := http.NewServeMux()
182 | newServer(t,
183 | Root("/path/to/statsviz"),
184 | SendFrequency(100*time.Millisecond),
185 | ).Register(mux)
186 |
187 | testRegister(t, mux, "http://example.com/path/to/statsviz/")
188 | })
189 |
190 | t.Run("non-positive frequency", func(t *testing.T) {
191 | t.Parallel()
192 |
193 | if _, err := NewServer(
194 | Root("/path/to/statsviz"),
195 | SendFrequency(-1),
196 | ); err == nil {
197 | t.Errorf("NewServer() should have errored")
198 | }
199 | })
200 | }
201 |
202 | func TestRegisterDefault(t *testing.T) {
203 | mux := http.DefaultServeMux
204 | Register(mux)
205 | testRegister(t, mux, "http://example.com/debug/statsviz/")
206 | }
207 |
208 | func Test_intercept(t *testing.T) {
209 | // Check that the file server has been 'hijacked'.
210 | // 'plotsdef.js' is generated at runtime, it doesn't actually exist, it is generated on the fly.
211 | w := httptest.NewRecorder()
212 | req := httptest.NewRequest(http.MethodGet, "http://example.com/debug/statsviz/js/plotsdef.js", nil)
213 |
214 | srv := newServer(t)
215 | intercept(srv.Index(), srv.plots.Config())(w, req)
216 |
217 | resp := w.Result()
218 | if resp.StatusCode != http.StatusOK {
219 | t.Errorf("http status %v, want %v", resp.StatusCode, http.StatusOK)
220 | }
221 |
222 | contentType := "text/javascript; charset=utf-8"
223 | if resp.Header.Get("Content-Type") != contentType {
224 | t.Errorf("header[Content-Type] %s, want %s", resp.Header.Get("Content-Type"), contentType)
225 | }
226 | }
227 |
228 | func TestContentTypeIsSet(t *testing.T) {
229 | // Check that "Content-Type" headers on the assets we serve are all set to
230 | // something more specific than "text/plain" because that'd make the page be
231 | // rejected in certain 'strict' environments.
232 | const root = "/some/root/path"
233 | srv := newServer(t, Root(root))
234 | httpfs := srv.Index()
235 |
236 | requested := []string{}
237 |
238 | // While we walk the embedded assets filesystem, control the header on the
239 | // http filesystem server.
240 | _ = fs.WalkDir(static.Assets, ".", func(path string, d fs.DirEntry, err error) error {
241 | if d.IsDir() || path == "fs.go" || path == "index.html" {
242 | return nil
243 | }
244 |
245 | w := httptest.NewRecorder()
246 | r := httptest.NewRequest(http.MethodGet, root+"/"+path, nil)
247 |
248 | httpfs(w, r)
249 | res := w.Result()
250 | if res.StatusCode != 200 && path != "index.html" {
251 | t.Errorf("GET %q returned HTTP %d, want 200", path, res.StatusCode)
252 | return nil
253 | }
254 |
255 | ct := res.Header.Get("Content-Type")
256 | if ct == "" || strings.Contains(ct, "text/plain") {
257 | t.Errorf(`GET %q has incorrect header "Content-Type = %s"`, path, ct)
258 | return nil
259 | }
260 |
261 | if testing.Verbose() {
262 | t.Logf("%q Content-Type %q", path, ct)
263 | }
264 | requested = append(requested, path)
265 | return nil
266 | })
267 |
268 | // Verify that all files in contentTypes map have been requested. This is to
269 | // keep the map aligned with the actual content of the static/ dir.
270 | for path := range contentTypes {
271 | found := false
272 | for i := range requested {
273 | if requested[i] == path {
274 | found = true
275 | break
276 | }
277 | }
278 | if !found {
279 | t.Fatalf("contentTypes[%v] matches no files in the static/ dir", path)
280 | }
281 | }
282 | }
283 |
--------------------------------------------------------------------------------
/testdata/chi.txt:
--------------------------------------------------------------------------------
1 | cp $STATSVIZ_ROOT/_example/chi/main.go .
2 | cp $STATSVIZ_ROOT/_example/chi/go.mod .
3 |
4 | go mod edit -replace=github.com/arl/statsviz=$STATSVIZ_ROOT
5 | go mod edit -replace=github.com/arl/statsviz/_example=$STATSVIZ_ROOT/_example
6 | go mod tidy
7 |
8 | go build main.go
9 | ! exec ./main &
10 | checkui http://localhost:8081/debug/statsviz/
--------------------------------------------------------------------------------
/testdata/default.txt:
--------------------------------------------------------------------------------
1 | cp $STATSVIZ_ROOT/_example/default/main.go .
2 | cp $STATSVIZ_ROOT/_example/default/go.mod .
3 |
4 | go mod edit -replace=github.com/arl/statsviz=$STATSVIZ_ROOT
5 | go mod edit -replace=github.com/arl/statsviz/_example=$STATSVIZ_ROOT/_example
6 | go mod tidy
7 |
8 | go build main.go
9 | ! exec ./main &
10 | checkui http://localhost:8080/debug/statsviz/
--------------------------------------------------------------------------------
/testdata/echo.txt:
--------------------------------------------------------------------------------
1 | cp $STATSVIZ_ROOT/_example/echo/main.go .
2 | cp $STATSVIZ_ROOT/_example/echo/go.mod .
3 |
4 | go mod edit -replace=github.com/arl/statsviz=$STATSVIZ_ROOT
5 | go mod edit -replace=github.com/arl/statsviz/_example=$STATSVIZ_ROOT/_example
6 | go mod tidy
7 |
8 | go build main.go
9 | ! exec ./main &
10 | checkui http://localhost:8082/debug/statsviz/
--------------------------------------------------------------------------------
/testdata/fasthttp.txt:
--------------------------------------------------------------------------------
1 | cp $STATSVIZ_ROOT/_example/fasthttp/main.go .
2 | cp $STATSVIZ_ROOT/_example/fasthttp/go.mod .
3 |
4 | go mod edit -replace=github.com/arl/statsviz=$STATSVIZ_ROOT
5 | go mod edit -replace=github.com/arl/statsviz/_example=$STATSVIZ_ROOT/_example
6 | go mod tidy
7 |
8 | go build main.go
9 | ! exec ./main &
10 | checkui http://localhost:8083/debug/statsviz/
--------------------------------------------------------------------------------
/testdata/fiber.txt:
--------------------------------------------------------------------------------
1 | cp $STATSVIZ_ROOT/_example/fiber/main.go .
2 | cp $STATSVIZ_ROOT/_example/fiber/go.mod .
3 |
4 | go mod edit -replace=github.com/arl/statsviz=$STATSVIZ_ROOT
5 | go mod edit -replace=github.com/arl/statsviz/_example=$STATSVIZ_ROOT/_example
6 | go mod tidy
7 |
8 | go build main.go
9 | ! exec ./main &
10 | checkui http://localhost:8093/debug/statsviz/
--------------------------------------------------------------------------------
/testdata/gin.txt:
--------------------------------------------------------------------------------
1 | cp $STATSVIZ_ROOT/_example/gin/main.go .
2 | cp $STATSVIZ_ROOT/_example/gin/go.mod .
3 |
4 | go mod edit -replace=github.com/arl/statsviz=$STATSVIZ_ROOT
5 | go mod edit -replace=github.com/arl/statsviz/_example=$STATSVIZ_ROOT/_example
6 | go mod tidy
7 |
8 | go build main.go
9 | ! exec ./main &
10 | checkui http://localhost:8085/debug/statsviz/
--------------------------------------------------------------------------------
/testdata/gorilla.txt:
--------------------------------------------------------------------------------
1 | cp $STATSVIZ_ROOT/_example/gorilla/main.go .
2 | cp $STATSVIZ_ROOT/_example/gorilla/go.mod .
3 |
4 | go mod edit -replace=github.com/arl/statsviz=$STATSVIZ_ROOT
5 | go mod edit -replace=github.com/arl/statsviz/_example=$STATSVIZ_ROOT/_example
6 | go mod tidy
7 |
8 | go build main.go
9 | ! exec ./main &
10 | checkui http://localhost:8086/debug/statsviz/
--------------------------------------------------------------------------------
/testdata/https.txt:
--------------------------------------------------------------------------------
1 | cp $STATSVIZ_ROOT/_example/https/main.go .
2 | cp $STATSVIZ_ROOT/_example/https/cert.pem .
3 | cp $STATSVIZ_ROOT/_example/https/key.pem .
4 | cp $STATSVIZ_ROOT/_example/chi/go.mod .
5 |
6 | go mod edit -replace=github.com/arl/statsviz=$STATSVIZ_ROOT
7 | go mod edit -replace=github.com/arl/statsviz/_example=$STATSVIZ_ROOT/_example
8 | go mod tidy
9 |
10 | go build main.go
11 | ! exec ./main &
12 | checkui https://localhost:8087/debug/statsviz/
--------------------------------------------------------------------------------
/testdata/iris.txt:
--------------------------------------------------------------------------------
1 | cp $STATSVIZ_ROOT/_example/iris/main.go .
2 | cp $STATSVIZ_ROOT/_example/iris/go.mod .
3 |
4 | go mod edit -replace=github.com/arl/statsviz=$STATSVIZ_ROOT
5 | go mod edit -replace=github.com/arl/statsviz/_example=$STATSVIZ_ROOT/_example
6 | go mod tidy
7 |
8 | go build main.go
9 | exec ./main &
10 | checkui http://localhost:8088/debug/statsviz/
--------------------------------------------------------------------------------
/testdata/middleware.txt:
--------------------------------------------------------------------------------
1 | cp $STATSVIZ_ROOT/_example/middleware/main.go .
2 | cp $STATSVIZ_ROOT/_example/middleware/go.mod .
3 |
4 | go mod edit -replace=github.com/arl/statsviz=$STATSVIZ_ROOT
5 | go mod edit -replace=github.com/arl/statsviz/_example=$STATSVIZ_ROOT/_example
6 | go mod tidy
7 |
8 | go build main.go
9 | ! exec ./main &
10 | checkui http://localhost:8090/debug/statsviz/ statsviz rocks
--------------------------------------------------------------------------------
/testdata/mux.txt:
--------------------------------------------------------------------------------
1 | cp $STATSVIZ_ROOT/_example/mux/main.go .
2 | cp $STATSVIZ_ROOT/_example/mux/go.mod .
3 |
4 | go mod edit -replace=github.com/arl/statsviz=$STATSVIZ_ROOT
5 | go mod edit -replace=github.com/arl/statsviz/_example=$STATSVIZ_ROOT/_example
6 | go mod tidy
7 |
8 | go build main.go
9 | ! exec ./main &
10 | checkui http://localhost:8091/debug/statsviz/
--------------------------------------------------------------------------------
/testdata/options.txt:
--------------------------------------------------------------------------------
1 | cp $STATSVIZ_ROOT/_example/options/main.go .
2 | cp $STATSVIZ_ROOT/_example/options/go.mod .
3 |
4 | go mod edit -replace=github.com/arl/statsviz=$STATSVIZ_ROOT
5 | go mod edit -replace=github.com/arl/statsviz/_example=$STATSVIZ_ROOT/_example
6 | go mod tidy
7 |
8 | go build main.go
9 | ! exec ./main &
10 | checkui http://localhost:8092/foo/bar/
--------------------------------------------------------------------------------
/testdata/userplots.txt:
--------------------------------------------------------------------------------
1 | cp $STATSVIZ_ROOT/_example/userplots/main.go .
2 | cp $STATSVIZ_ROOT/_example/userplots/go.mod .
3 |
4 | go mod edit -replace=github.com/arl/statsviz=$STATSVIZ_ROOT
5 | go mod edit -replace=github.com/arl/statsviz/_example=$STATSVIZ_ROOT/_example
6 | go mod tidy
7 |
8 | go build main.go
9 | ! exec ./main &
10 | checkui http://localhost:8093/debug/statsviz/
--------------------------------------------------------------------------------
/userplot.go:
--------------------------------------------------------------------------------
1 | package statsviz
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 |
7 | "github.com/arl/statsviz/internal/plot"
8 | )
9 |
10 | // TimeSeriesType describes the type of a time series plot.
11 | type TimeSeriesType string
12 |
13 | const (
14 | // Scatter is a time series plot made of lines.
15 | Scatter TimeSeriesType = "scatter"
16 |
17 | // Bar is a time series plot made of bars.
18 | Bar TimeSeriesType = "bar"
19 | )
20 |
21 | // BarMode determines how bars at the same location are displayed on a bar plot.
22 | type BarMode string
23 |
24 | const (
25 | // Stack indicates that bars are stacked on top of one another.
26 | Stack BarMode = "stack"
27 |
28 | // Ggroup indicates that bars are plotted next to one another, centered
29 | // around the shared location.
30 | Group BarMode = "group"
31 |
32 | // Relative indicates that bars are stacked on top of one another, with
33 | // negative values below the axis and positive values above.
34 | Relative BarMode = "relative"
35 |
36 | // Overlay indicates that bars are plotted over one another.
37 | Overlay BarMode = "overlay"
38 | )
39 |
40 | var (
41 | // ErrNoTimeSeries is returned when a user plot has no time series.
42 | ErrNoTimeSeries = errors.New("user plot must have at least one time series")
43 |
44 | // ErrEmptyPlotName is returned when a user plot has an empty name.
45 | ErrEmptyPlotName = errors.New("user plot name can't be empty")
46 | )
47 |
48 | // ErrReservedPlotName is returned when a reserved plot name is used for a user plot.
49 | type ErrReservedPlotName string
50 |
51 | func (e ErrReservedPlotName) Error() string {
52 | return fmt.Sprintf("%q is a reserved plot name", string(e))
53 | }
54 |
55 | // HoverOnType describes the type of hover effect on a time series plot.
56 | type HoverOnType string
57 |
58 | const (
59 | // HoverOnPoints specifies that the hover effects highlights individual
60 | // points.
61 | HoverOnPoints HoverOnType = "points"
62 |
63 | // HoverOnPoints specifies that the hover effects highlights filled regions.
64 | HoverOnFills HoverOnType = "fills"
65 |
66 | // HoverOnPointsAndFills specifies that the hover effects highlights both
67 | // points and filled regions.
68 | HoverOnPointsAndFills HoverOnType = "points+fills"
69 | )
70 |
71 | // A TimeSeries describes a single time series of a plot.
72 | type TimeSeries struct {
73 | // Name is the name identifying this time series in the user interface.
74 | Name string
75 |
76 | // UnitFmt is the d3-format string used to format the numbers of this time
77 | // series in the user interface. See https://github.com/d3/d3-format.
78 | Unitfmt string
79 |
80 | // HoverOn configures whether the hover effect highlights individual points
81 | // or do they highlight filled regions, or both. Defaults to HoverOnFills.
82 | HoverOn HoverOnType
83 |
84 | // Type is the time series type, either [Scatter] or [Bar]. default: [Scatter].
85 | Type TimeSeriesType
86 |
87 | // GetValue specifies the function called to get the value of this time
88 | // series.
89 | GetValue func() float64
90 | }
91 |
92 | // TimeSeriesPlotConfig describes the configuration of a time series plot.
93 | type TimeSeriesPlotConfig struct {
94 | // Name is the plot name, it must be unique.
95 | Name string
96 |
97 | // Title is the plot title, shown above the plot.
98 | Title string
99 |
100 | // Type is either [Scatter] or [Bar]. default: [Scatter].
101 | Type TimeSeriesType
102 |
103 | // BarMode is either [Stack], [Group], [Relative] or [Overlay].
104 | // default: [Group].
105 | BarMode BarMode
106 |
107 | // Tooltip is the html-aware text shown when the user clicks on the plot
108 | // Info icon.
109 | InfoText string
110 |
111 | // YAxisTitle is the title of Y axis.
112 | YAxisTitle string
113 |
114 | // YAxisTickSuffix is the suffix added to tick values.
115 | YAxisTickSuffix string
116 |
117 | // Series contains the time series shown on this plot, there must be at
118 | // least one.
119 | Series []TimeSeries
120 | }
121 |
122 | // Build validates the configuration and builds a time series plot for it
123 | func (p TimeSeriesPlotConfig) Build() (TimeSeriesPlot, error) {
124 | var zero TimeSeriesPlot
125 | if p.Name == "" {
126 | return zero, ErrEmptyPlotName
127 | }
128 | if plot.IsReservedPlotName(p.Name) {
129 | return zero, ErrReservedPlotName(p.Name)
130 | }
131 | if len(p.Series) == 0 {
132 | return zero, ErrNoTimeSeries
133 | }
134 |
135 | var (
136 | subplots []plot.Subplot
137 | funcs []func() float64
138 | )
139 | for _, ts := range p.Series {
140 | switch ts.HoverOn {
141 | case "":
142 | ts.HoverOn = HoverOnFills
143 | case HoverOnPoints, HoverOnFills, HoverOnPointsAndFills:
144 | // ok
145 | default:
146 | return zero, fmt.Errorf("invalid HoverOn value %s", ts.HoverOn)
147 | }
148 |
149 | subplots = append(subplots, plot.Subplot{
150 | Name: ts.Name,
151 | Unitfmt: ts.Unitfmt,
152 | HoverOn: string(ts.HoverOn),
153 | Type: string(ts.Type),
154 | })
155 | funcs = append(funcs, ts.GetValue)
156 | }
157 |
158 | return TimeSeriesPlot{
159 | timeseries: &plot.ScatterUserPlot{
160 | Plot: plot.Scatter{
161 | Name: p.Name,
162 | Title: p.Title,
163 | Type: string(p.Type),
164 | InfoText: p.InfoText,
165 | Layout: plot.ScatterLayout{
166 | BarMode: string(p.BarMode),
167 | Yaxis: plot.ScatterYAxis{
168 | Title: p.YAxisTitle,
169 | TickSuffix: p.YAxisTickSuffix,
170 | },
171 | },
172 | Subplots: subplots,
173 | },
174 | Funcs: funcs,
175 | },
176 | }, nil
177 | }
178 |
179 | // TimeSeriesPlot is an opaque type representing a timeseries plot.
180 | // A plot can be created with [TimeSeriesPlotConfig.Build].
181 | type TimeSeriesPlot struct {
182 | timeseries *plot.ScatterUserPlot
183 | }
184 |
--------------------------------------------------------------------------------
/userplot_test.go:
--------------------------------------------------------------------------------
1 | package statsviz
2 |
3 | import (
4 | "errors"
5 | "testing"
6 | )
7 |
8 | func TestTimeSeriesPlotConfigErrors(t *testing.T) {
9 | t.Run("empty name", func(t *testing.T) {
10 | tsb := TimeSeriesPlotConfig{}
11 | if _, err := tsb.Build(); !errors.Is(err, ErrEmptyPlotName) {
12 | t.Errorf("Build() returned err = %v, want %v", err, ErrEmptyPlotName)
13 | }
14 | })
15 | t.Run("reserved name", func(t *testing.T) {
16 | tsb := TimeSeriesPlotConfig{Name: "timestamp"}
17 | var target ErrReservedPlotName
18 | if _, err := tsb.Build(); !errors.As(err, &target) {
19 | t.Errorf("Build() returned err = %v, want %v", err, target)
20 | }
21 | })
22 | t.Run("no time series", func(t *testing.T) {
23 | tsb := TimeSeriesPlotConfig{Name: "some name"}
24 | if _, err := tsb.Build(); !errors.Is(err, ErrNoTimeSeries) {
25 | t.Errorf("Build() returned err = %v, want %v", err, ErrEmptyPlotName)
26 | }
27 | })
28 | }
29 |
--------------------------------------------------------------------------------